Skip to content

Context Budget

本课交付结果

你将交付 packContext(snippets, maxChars):按分数稳定排序,在字符预算内选择完整片段,返回已用字符数和是否截断,不修改调用方输入。

Week 2 到此形成“发现—读取—筛选”链路。Agent 不再把搜索命中的全部文件无界塞进模型请求。

岗位问题

上下文窗口很大也不是无限资源。未经筛选的搜索结果会挤掉任务约束、关键文件和工具观察;简单从尾部截字符串还可能切断代码语法,让模型看到不可解释的半段证据。

岗位实现需要明确预算单位、相关性顺序、同分稳定性与装箱策略,并把“哪些证据被舍弃”暴露为可观测状态。

前置检查

前置知识快照

先验证安全读取与结构化搜索:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 08
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 09

本课用字符数建立可重复教学模型;生产系统应根据目标模型 tokenizer 计算 token,并为系统提示、历史、工具结果和输出分别预留预算。

先区分模型窗口与可用上下文预算。窗口是输入和输出的总容量,系统提示、对话历史、工具目录、当前证据和预期输出共同竞争空间。不能把模型宣称的窗口全部交给代码片段;如果没有给回复和工具循环预留余量,请求即使勉强成功,也可能让模型无法输出完整补丁。

上一课搜索返回的是候选,不是都应进入提示的事实。候选通常有相关性分数、来源与文本,预算器只负责确定性选择。检索负责“可能有用”,装箱负责“本轮带什么”,模型负责“如何推理”。把三者分开,才能分别测召回、选择和答案质量。

原理拆解

装箱算法按以下约束工作:

  1. maxChars 必须是非负整数。
  2. 片段按 score 从高到低排序,同分保持输入顺序。
  3. 只放入完整片段,不在任意字符处截断代码。
  4. 当前大片段放不下时,可继续尝试后续较小片段,提高预算利用率。
  5. 返回 usedCharstruncated,供 Trace 和策略层判断证据损失。

这是一个可解释的贪心基线,不宣称求得全局最优。未来可以加入文件多样性、依赖关系与 query-aware reranking。

mermaid
flowchart LR
  A[搜索与读取候选] --> B[校验预算]
  B --> C[按分数稳定排序]
  C --> D{当前完整片段装得下?}
  D -- 是 --> E[复制并加入]
  D -- 否 --> F[记录省略并继续]
  E --> G[累计 usedChars]
  F --> H{还有候选?}
  G --> H
  H --> I[selected + truncated]

稳定性是可评测系统的基础。相同候选和预算应生成相同上下文,否则模型结果变化时无法区分是模型随机性还是装箱抖动。Lab 同分时保留原始输入顺序,所以排序前携带索引;总项目则按稳定 id 破同分,适合来自多个异步检索器、输入顺序本就不稳定的生产候选。两者服务于不同输入契约。

“完整片段”不是完整文件,而是检索器已经切出的语义单元,例如函数、测试用例或有限行窗口。预算器不在任意字符处切割,因为半个函数缺少声明、闭括号或断言,会让模型得到误导证据。若单个片段总是太大,应回到切片阶段按语法或行边界缩小,而不是在装箱层悄悄砍断。

遇到高分大片段放不下时为什么继续?假设预算还剩两百字符,第一候选三百字符,第二候选一百字符。break 会浪费全部余量,continue 能纳入第二条证据。这个贪心策略不保证组合总价值最高,但行为直观、线性扫描、容易解释,适合建立可回归的基线。

truncated 表示至少一个候选因预算未被选择,不表示某段文本被截断。命名虽然相同,语义必须在合同中写清。上层看到它可以决定缩小任务、增加预算、分轮读取或在答案中声明证据不完整。没有这个信号,模型与用户都会误以为候选已经全部覆盖。

不可变性保护跨层复用。搜索结果可能同时进入 Trace、评测和另一个策略;若预算器直接对输入 sort(),其他消费者看到的顺序随调用发生变化。返回时复制选中对象,进一步避免上层为渲染添加字段时污染原始候选。长运行 Agent 中,共享可变数据是最难复现的问题之一。

代码实验

失败实现

最常见错误是原地排序,遇到第一个过大候选就停止:

ts
function unsafePack(snippets: Snippet[], maxChars: number) {
  snippets.sort((a, b) => b.score - a.score);
  const selected = [];
  let used = 0;
  for (const snippet of snippets) {
    if (used + snippet.content.length > maxChars) break;
    selected.push(snippet);
    used += snippet.content.length;
  }
  return selected;
}

正确基线复制、稳定排序并跳过装不下的候选:

ts
export function packContext(snippets: readonly Snippet[], maxChars: number): PackResult {
  if (!Number.isInteger(maxChars) || maxChars < 0) throw new Error("maxChars 必须是非负整数");
  const ranked = snippets.map((snippet, index) => ({ snippet, index }))
    .sort((a, b) => b.snippet.score - a.snippet.score || a.index - b.index);
  const selected: Snippet[] = [];
  let usedChars = 0;
  for (const { snippet } of ranked) {
    if (usedChars + snippet.content.length > maxChars) continue;
    selected.push(structuredClone(snippet));
    usedChars += snippet.content.length;
  }
  return { selected, usedChars, truncated: selected.length !== snippets.length };
}

注意 Lab 字段名是 content,总项目使用 text;Lab 同分按原始索引,总项目同分按 id。不要在文章示例与生产路径间偷换语义,迁移时应通过适配器显式转换。

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/10-context-budget/starter test
pnpm --dir bootcamps/coding-agent/labs/10-context-budget/solution test

Starter 会以 EXERCISE_NOT_IMPLEMENTED:实现上下文排序与装箱 失败。Solution 应通过 5 项测试,覆盖稳定排序、跳过过大片段、零预算、非法预算和输入不可变。

下面测试同时证明“跳过而非终止”和完整装箱:

ts
it("高分大片段放不下时继续尝试小片段", () => {
  const snippets = [
    { id: "large", content: "过大的完整片段", score: 10 },
    { id: "small", content: "可用", score: 5 },
  ] as const;
  const result = packContext(snippets, 2);
  expect(result.selected.map((item) => item.id)).toEqual(["small"]);
  expect(result).toMatchObject({ usedChars: 2, truncated: true });
});

关键实现讲解

先携带原始索引排序,避免依赖运行时排序实现细节:

ts
const ranked = snippets
  .map((snippet, index) => ({ snippet, index }))
  .sort((a, b) => b.snippet.score - a.snippet.score || a.index - b.index);

循环中只有 usedChars + text.length <= maxChars 才加入完整片段。返回新对象和新数组,不要对输入 sort(),否则同一批候选在重试、Trace 和 Eval 中会出现难以解释的顺序变化。

预算校验必须在任何排序与复制前完成。负数、小数、无穷和非数字值都不具备清晰语义,应立即失败;零是合法预算,返回空选择,并在有候选时标记截断。把零当默认值或异常,会让调用方无法显式要求“本轮不带仓库证据”。

字符计数使用 JavaScript 字符串长度只是教学近似,某些 Unicode 字符占两个 UTF-16 code unit,模型 token 又是另一种切分。重要的是计数器可注入、选择算法不依赖具体单位。生产实现先给每段缓存 token 成本,再用同一装箱逻辑处理,避免在循环中重复调用昂贵 tokenizer。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 10
pnpm --filter @coding-agent/repo-context test

单课门禁应报告 observedTests: 5。构造一个高分超大片段和一个低分小片段,确认算法跳过前者后仍装入后者;再冻结输入数组,证明实现没有原地排序。

实际执行 Starter 五项失败,Solution 五项通过:

text
starter: 5 failed, 0 passed
solution: 5 passed, 0 failed
覆盖: 稳定排序 / 跳过过大候选 / 零预算 / 非法预算 / 输入不可变

验收时冻结数组与嵌套对象,然后连续调用两次并深比较结果。再调换同分候选,确认 Lab 按新的输入顺序稳定变化。最后用预算恰好等于文本长度的样本证明边界使用小于等于,而不是错误地浪费最后一个字符。

常见失败与排查

故障案例 1

装箱后原始搜索结果顺序改变。

症状:Trace 中候选顺序与检索阶段不同,第二次策略调用得到另一套结果。

根因:直接对调用方数组执行原地 sort(),共享数据被预算器改写。

定位:调用前冻结数组或保存深拷贝,调用后比较;若抛出只读错误或顺序变化,就是可变性泄漏。

修复:先映射到带索引的新数组,返回选中对象副本;函数签名接收 readonly 强化意图。

故障案例 2

高分巨型文件吞掉全部机会。

症状:预算仍有空间,结果却为空,后续多个小测试片段都未被选择。

根因:第一个候选放不下时使用 break,把“当前不合适”误当成“后面都不合适”。

定位:构造一大一小两个候选,大的高分且超限,小的可容纳;若小的未出现,循环控制错误。

修复:过大时记录省略原因并 continue;片段级截断交给上游语义切片器。

故障案例 3

评测结果在相同输入上抖动。

症状:候选分数相同且预算只能装部分时,多次运行选择不同文件。

根因:没有明确同分规则,或输入来自异步集合而顺序本身不稳定。

定位:固定候选内容与分数重复运行并比较选中 id;再检查排序比较器是否在同分返回确定次序。

修复:Lab 携带原始索引,生产候选使用稳定 id;把排序规则与序列化结果纳入快照测试。

课后作业

把字符预算替换为可注入的 token 计数器,并为每个文件增加最多两个片段的多样性约束。写测试证明预留输出 token 后不会超总窗口,同时 Trace 能记录被丢弃片段的 id 与原因。

进阶要求是设计两阶段预算:先为任务约束、错误日志、实现和测试保留各自最低配额,再在剩余空间按分数竞争。用固定夹具比较纯贪心与多样性策略,记录最终补丁通过率,而不是只比较预算利用率。

验收 Rubric

维度通过标准常见扣分
排序高分优先且同分稳定相同输入产生不同顺序
装箱只加入完整且可容纳片段任意截断或过大即停止
不可变性不修改输入数组和对象原地排序污染调用方状态
可观测性报告已用预算与截断状态无法解释证据为何缺失

总项目增量

总项目包:@coding-agent/repo-context

总项目路径:packages/repo-context/src/budget.ts

总项目验证命令:pnpm --filter @coding-agent/repo-context test

Week 2 检查点至此完成:工具有合同和注册表,文件访问有真实路径边界,搜索无 shell 且结构化,上下文有稳定预算。Week 3 将进入补丁生成、校验和应用。

延伸阅读

方案对比与工程取舍

字符预算实现简单、可重复、无需知道模型,适合教学和快速保护;token 预算更贴近真实计费与窗口,却绑定 tokenizer 版本并增加计算;直接依赖 Provider 的预估最省本地代码,但难以离线测试。生产系统通常注入模型特定计数器,并保留字符或字节硬上限作为故障保护。

纯分数贪心可解释且快速,却可能连续选择同一文件的重复片段。零一背包能在离散成本与价值下求更优组合,但相关性分数不一定可加,计算也随候选和预算增长。MMR 一类方法会惩罚相似度,提升多样性,却引入额外参数。正确选择应通过任务成功率评测,而不是只看装满比例。

“装得最满”不等于“最有用”。一堆短小低价值片段可能占满预算,却挤掉稍大但决定性的接口定义。分数需要反映任务相关性、符号完整匹配、文件角色和新颖性;预算器只执行给定价值。如果评分失真,应修检索与重排,而不是让装箱器猜业务。

上下文还存在位置效应。重要约束放在开头,关键证据放在显著位置,可能比简单分数降序更好;模型对中间内容的利用常弱于两端。可以把任务与验收固定在首尾,代码证据按组排列,并在每段前标明来源。布局属于提示组装层,不应改变预算选择的事实记录。

预算必须分区。总窗口先减去系统提示、工具定义、对话历史和预期输出,再把剩余给仓库上下文;工具调用轮次还要预留结果增长。若历史无限累积,再聪明的代码装箱也会被挤到零。会话层需要摘要、淘汰和检查点,与本课片段预算共同工作。

输出预留尤其关键。修复任务可能需要生成长补丁与解释,如果输入占满窗口,模型会截断输出或无法完成。可以按任务类型设最低输出额度,并在实际响应接近上限时让 Agent 分步修改。预算是一份资源计划,不只是输入剪刀。

片段去重应在装箱前完成。搜索同一符号可能返回定义、导出和重复生成文件,重叠行窗口也会重复文本。按文件与行范围合并重叠片段,或按规范化内容哈希去重,能把空间留给新证据。去重过程同样要稳定,并记录哪些候选合并到了哪个片段。

文件多样性可以设软约束:每个文件先选最高分片段,再进行第二轮;也可以对同文件后续候选衰减分数。硬上限容易错过同一核心文件中相距很远的实现与测试,软衰减更灵活但难解释。课程作业用最多两个片段建立直观基线,再让评测决定是否调整。

相关依赖有时必须成组出现。例如接口与实现、函数与测试、错误堆栈与对应代码,单独一段价值很低。可在上游生成带 group id 的复合候选,装箱按整组成本决定;若组太大,回到切片器缩小。不要让预算器随机选到一半关系后假装证据完整。

可观测结果建议包含每个候选的 id、原始分数、成本、最终排名、选择状态和省略原因。长期 Trace 不必保存全文,可以保存哈希、相对路径与行范围。线上任务失败时,团队能判断是没召回、评分太低、预算不足还是位置布局问题,而不是笼统归因于模型。

缓存能降低重复 token 计数与提示成本。片段内容哈希对应 token 成本,模型与 tokenizer 版本加入缓存键;候选集合与预算产生确定选择哈希。内容或模型变化时自然失效。不要只按文件路径缓存,文件更新后会使用过期成本和证据。

处理动态工具结果时,应给单次观察设置独立上限。错误日志、测试输出和搜索命中可能在循环中增长;先由工具做结构化截断,再由上下文层竞争预算。若把原始无限输出交给预算器,它在计算成本之前就可能耗尽内存。

零预算是很有用的边界测试,也代表合法策略:只依据用户提供内容回答,或在审批前不发送仓库代码。返回空数组、零使用量,并在存在候选时标记截断,语义完整。非法预算则应同步失败,避免默默修正掩盖上层计算错误。

验收不应停在单元测试。选取若干真实修复任务,记录候选全集、预算选择、模型答案和测试结果;逐步改变预算,观察成功率、成本和延迟。若预算翻倍却成功率不升,瓶颈可能是召回或噪声;若小幅增加就显著改善,应调整默认分区。

用一个数字例子检查算法。预算为十个字符,候选甲成本十二、分数九,候选乙成本六、分数八,候选丙成本四、分数七。排序后甲首先被跳过,乙与丙依次装入,使用量恰好为十,截断为真。若错误地遇甲就停止,使用量为零;若任意截断甲,模型只得到不完整高分证据;若按最短优先,虽然也装满,却忽略相关性顺序。这个例子把循环控制、完整性和价值优先同时固定下来。

再看同分稳定性。甲乙分数都为八,Lab 接收顺序是甲后乙,预算只能放一个,就选择甲;调用方反转输入后应选择乙,因为原始顺序就是 Lab 的次级规则。总项目候选可能来自并发搜索,输入到达顺序不可靠,所以改用 id。稳定不代表永远按一种字段,而是对输入契约给出明确且可重复的破局规则。

评分值本身要校验。非数字、无穷或缺失分数会让比较器返回异常结果,不同运行时可能排序不同。候选生成阶段应将分数归一到有限范围,预算器可拒绝非法候选或采用明确最低分。静默把 NaN 当零会掩盖重排器故障,导致关键证据无故落后。

文本成本也不只是正文长度。真正发送时通常包含路径标题、行号、代码围栏与分隔符;若预算器只计算正文,组装后仍可能超限。生产候选应预先计算完整渲染成本,或让渲染器与计数器使用同一模板。预算计算和实际序列化必须来自同一事实来源。

片段一旦选择,顺序可以保持排名,也可以按文件与行号重组。排名顺序突出相关性,文件顺序更容易理解代码上下文。无论怎么布局,都不要改变“选择了谁”的记录,且重新组装后再次核对总成本。选择与呈现是两个阶段,混在一个循环里会让测试难以解释。

被省略原因至少分为超预算、重复、文件配额、非法内容和策略拒绝。单一 truncated 供快速控制流,详细原因供 Trace 与评测。若大量高分候选因单段过大被拒绝,应改进切片;若因重复被拒绝说明去重有效;不同原因对应完全不同的优化动作。

摘要是另一种压缩手段,但不能和原文等价。模型生成摘要可能遗漏精确类型、错误消息和边界条件,适合对话历史或背景说明,不适合替代即将修改的关键代码。可以把低优先级文件先摘要,把高风险实现与测试保留原文,并在证据元数据中标注来源是原文还是派生摘要。

隐私也能通过预算层加强。候选进入模型前检查敏感路径、密钥模式和项目策略;被拒绝片段不参与装箱。不要为了提高预算利用率把被安全策略排除的内容重新补回。相关性、资源和权限是三条独立约束,只有同时满足的候选才有资格竞争。

多模型系统要按实际目标模型计数。规划模型、代码模型和总结模型的 tokenizer 与窗口可能不同,同一选择不能盲目复用。候选与分数可以共享,成本和最终装箱应按目标重新计算;缓存键包含模型标识。这样路由变化不会造成偶发超窗。

当窗口不足时,系统应优先降级而不是随机删内容:先减少低分候选,再压缩历史,再缩短工具目录,始终保留用户任务、安全约束和当前失败输出。优先级是一项产品策略,应写成可测试配置。把所有消息统一从尾部截断,最可能删掉恰好解释失败的部分。

Week 2 检查点可以做端到端演练:注册搜索和读取工具,在临时仓库查找符号,安全读取命中片段,给每段打分,使用小预算装箱,断言最终上下文没有越界路径、没有超过预算、顺序确定且明确报告省略。单课测试保护局部,演练证明组合后的能力仍符合合同。

下一课衔接

Week 2 已把模型意图接到受控工具:合同描述能力,Registry 唯一分发,文件工具限制真实路径,搜索提供有界候选,预算器选择完整证据。Week 3 将使用这些证据生成统一 diff,先解析和校验补丁,再安全应用与回滚。上下文是否准确,将直接决定补丁质量。

  • 0/1 Knapsack 与贪心基线的取舍。
  • Retrieval reranking、MMR 与上下文多样性。
  • Token budget、prompt caching 与 lost-in-the-middle 现象。

预算算法只能优化已检索到的候选;如果搜索召回错误,再精细的装箱也无法恢复缺失证据。

从零实现 Mini Code Agent Runtime