09. Context Engineering 与压缩
上下文窗口再大也会被 Agent 用完。Coding Agent 会读文件、跑命令、输出 diff、遇到错误、接收用户插话。Context Engineering 要回答的不是“如何塞更多 token”,而是“如何保留继续工作的必要状态”。压缩是把旧上下文投影成可继续执行的摘要,而不是把聊天记录变短。
什么时候压缩
压缩触发应该基于模型窗口和预留预算:
if context_tokens > context_window - reserve_tokens:
compact()
reserve_tokens 要给 system prompt 和下一次响应留足空间。一个真实系统的默认值可以参考:预留 16k token,压缩后保留最近约 20k token 的消息。窗口 200k 的模型,意味着上下文用到 184k 左右就触发压缩。预留太小,压缩还没来得及做,下一次请求就被 provider 拒绝了。
完整的触发路径其实有三条,都要处理:
- 阈值触发:每次模型响应后检查 token 数,超了就压缩。这是主路径。
- 手动触发:用户显式请求压缩(比如一条
/compact命令)。用户有时比阈值更早知道“接下来要干大事”。 - 溢出恢复:请求已经被 provider 以“上下文超长”拒绝。此时要压缩后自动重试刚才的请求,让任务无感继续,而不是把错误直接抛给用户。
token 计数本身也有讲究。历史消息未必都有精确 usage(用户消息、工具结果没有),需要估算。“字符数除以 4”是常用的保守启发式——它倾向高估,宁可早压缩,也不要撞墙。每次 assistant 响应带回的真实 usage 则用来校准当前总量。
压缩切点
不要在任意消息中间切。安全切点通常是一个完整 turn 之后,也就是 assistant 消息和它请求的所有 tool result 都已经落日志。切断半个 tool batch 会让模型看到“我调用了工具,但结果消失了”,恢复后很容易重复执行或误判。实现上可以从日志末尾往前走,累积估算 token 数,直到达到“保留最近 N token”的预算,再把切点对齐到最近的安全边界——tool result 永远不能作为切点,它必须跟着自己的 tool call 走。
压缩 entry 应记录:
- summary 文本。
- 压缩前 token 数。
- 第一条仍完整保留的 entry id。
- 读过的关键文件列表。
- 改过的文件列表。
文件列表值得单独强调:它们可以在压缩时从被摘要的消息里机械提取(每个 read、edit、write 的路径都在 tool call 参数里),不依赖摘要模型的发挥。这些 details 不一定都进模型,但应该进日志,方便 UI 和扩展使用。
好摘要的结构
面向 Agent 的摘要不是会议纪要。它应该帮助模型继续工作。摘要本身由一次模型调用生成,用专门的提示词要求固定结构:
Goal:
- User wants ...
Current status:
- Done ...
- Still failing ...
Important constraints:
- Do not edit tests.
- Keep public API unchanged.
Files observed:
- src/parser.ts: contains ...
Files modified:
- src/parser.ts: changed ...
Open tool results:
- Last test run failed with ...
Next step:
- Inspect ...
摘要里最重要的是约束、文件事实和下一步。不要让模型在压缩后重新发现所有东西。
投影时,摘要要带明确的包装语,比如“更早的对话历史已被压缩为以下摘要”,并用清晰的定界符标出摘要范围。没有这层说明,模型可能把摘要当成用户刚说的话,或者反过来,对着摘要里的文件描述幻觉出不存在的细节。
压缩后的失忆测试
每个压缩实现都应该做一个失忆测试:构造长会话,让 Agent 读一个文件、发现约束、修改另一个文件,然后触发压缩。压缩后问模型“下一步是什么”。如果模型忘记用户约束或刚刚修改的文件,说明摘要不可用。
这个测试不需要真实模型。你可以让 faux provider 检查压缩后 context 是否包含这些关键词:目标、约束、已修改文件、最近失败、下一步。真实模型 smoke test 只用来检查摘要是否自然可读。
上下文不是越多越好
很多新手会倾向于保留尽可能多的旧消息。这样看似安全,实际上会让模型注意力被历史噪声稀释。尤其是工具输出:一次失败测试可能有几千行日志,其中真正有用的是失败名称、错误行、堆栈尾部和命令退出码。
Context Engineering 的目标是高信噪比。对 Agent 来说,好的压缩不是“无损”,而是“保留继续完成任务所需的状态,并明确哪些信息被丢弃”。当摘要无法覆盖某个旧事实时,它应该告诉模型重新读取文件,而不是假装记得。
生产化取舍
压缩本身也会调用模型,因此它会失败、花钱、被限流。运行时要决定压缩失败时怎么办。常见策略是:
- 如果还有空间,继续一轮并稍后再压缩。
- 如果已经接近上限,暂停任务并要求用户确认。
- 如果压缩模型失败,降级到较短的本地摘要模板(目标加文件列表),但标记质量较低。
压缩期间的用户输入也要处理:正在压缩时用户又发了消息,应该排队等压缩完成,而不是插进一半的上下文里。UI 要显示“正在压缩”状态,机器可读模式要有对应事件。
压缩摘要应该写入日志。不要只存在内存里。恢复会话时,context builder 必须能看到压缩 entry,并据此跳过更早消息。最后,把压缩入口做成可替换的钩子是值得的:某些场景(比如维护自己知识库的扩展)需要自定义摘要策略,钩子生成的压缩 entry 与内置压缩走同一条投影路径。
练习
给 session log 增加 compaction entry 和 context builder。
验收标准:
- 压缩只发生在完整 turn 边界,tool result 永远不与它的 tool call 分离。
- 压缩 entry 包含 summary、tokensBefore、firstKeptEntryId 和机械提取的文件列表。
- 构建上下文时,旧消息被带定界符的 summary 替代,不从日志删除。
- 压缩后模型仍能看到目标、约束、已读文件、已改文件和下一步(失忆测试通过)。
- 当 provider 报上下文超长时,Agent 压缩后自动重试原请求,而不是直接退出。
- 手动触发压缩的入口可用。