吃透 AI Agent 开发 · 第 17 篇 · 第四章 · Context Engineering
对话、工具输出和子任务结果都会增长。压缩保留语义,预算控制注入,Offload 保存完整正文,三者不能混为一谈。
你和 Agent 讨论了半小时,明确说过“不要改数据库结构”。它前面一直遵守,后面却突然提出新增字段。看上去像模型忘了,其实更可能是旧消息已经装不进这次请求,或者压缩摘要漏掉了约束。
上下文窗口再大也不是无限的。更大的窗口只是把问题推迟,不能替代治理。
工作台、便签和档案柜
可以把上下文想成一张工作台。
- 正在处理的文件摊在桌面上。
- 已经讨论过的结论写成便签。
- 十万行日志放进档案柜,只在桌面留编号和摘要。
- 不再相关的草稿从桌上拿走。
压缩负责把一摞历史整理成便签;预算负责限制每类材料占多少空间;Offload 负责把大文件移到档案柜。三者解决的问题不同。
先知道钱花在哪里
不要只看一个“上下文用了 80%”。至少要按来源统计:
interface ContextUsage {
system: number
history: number
toolResults: number
projectFiles: number
memory: number
reservedForOutput: number
}
如果大头是工具结果,压缩聊天历史帮助有限;如果稳定规则本身占了一半,频繁总结也治标不治本。预算报告让优化从猜测变成定位。
还要为模型输出预留空间。把窗口全部塞满输入,模型可能没有足够 token 完成回答或生成工具参数。
什么时候压缩
常见触发点有三个:
- 请求前检查。 发现下一次请求会超预算,先压缩再调用模型。
- 一轮结束后整理。 当前任务阶段刚完成,适合生成边界清楚的摘要。
- 用户主动触发。 长会话进入新阶段,用户明确要求整理上下文。
请求前压缩最必要,却也是体验最敏感的时刻。应该保留取消信号和进度事件,避免界面看起来突然卡住。
一份合格摘要要保留什么
摘要不是“把聊天变短”,而是生成后续执行所需的状态。至少包括:
- 当前目标和完成标准。
- 用户明确约束与禁止事项。
- 已确认的事实和关键决定。
- 已修改的文件或外部状态。
- 未完成事项和下一步。
- 尝试过但失败的方法,以及失败原因。
最后一项很重要。只记录成功结论,Agent 可能在压缩后重新走一遍已经证明不通的路。
可以让摘要使用固定结构,而不是一段自由散文:
目标:
约束:
已完成:
关键决定:
失败尝试:
待办:
结构稳定也方便写自动检查,例如摘要中必须出现所有未完成任务 ID。
Offload:完整内容不必进模型
Shell 输出十万行时,最差的做法是先全部塞进消息,再指望下一轮压缩。更好的协议是工具执行完成后就判断体积:
interface LargeResultHandle {
preview: string
artifactFile: string
originalChars: number
truncated: true
sha256: string
}
模型先看到错误附近的片段、总字符数和文件句柄。需要更多信息时,再调用读取工具按行恢复。完整数据仍然存在,只是不常驻上下文。
这个模式同样适用于子 Agent 报告、网页正文、数据库导出和评测结果。
记忆也要有预算和新鲜度
长期记忆不是免费外挂。一次注入二十份“可能相关”的旧笔记,会把当前问题淹没。
实用流程是:先只读取标题、描述、类型和更新时间;根据当前问题选择少量候选;最后在单文件、单轮和单会话预算内加载正文。注入时带上来源和年龄,提醒模型旧信息需要验证。
“用户两年前偏好 npm”不能自动压过当前仓库里的 pnpm-lock.yaml。长期记忆是线索,不是高于现场证据的事实。
四种压缩失败
约束被总结成背景
“用户提到不要改数据库”听起来像历史描述,不像仍然有效的约束。摘要应写成“禁止修改数据库结构”。
状态和正文混在一起
把完整代码粘进摘要,会迅速让摘要再次膨胀。状态保留路径、符号名和关键 diff,正文留在文件中。
压缩结果覆盖原始记录
摘要用于后续上下文,不应删除原始会话。原始记录是审计和重新压缩的依据。
每轮都压缩
压缩本身需要模型调用,也会逐步损失细节。没有达到阈值时反复总结,只会增加成本和信息损耗。
用 On-demand Restore 做个小练习
准备一段包含目标、两个约束、一次失败尝试和三个待办的对话。写一个 validateSummary:
function validateSummary(summary: string, taskIds: string[]): string[] {
return taskIds.filter((id) => !summary.includes(id))
}
让摘要漏掉一个任务 ID,确认验证能发现。然后再思考:自然语言约束应该怎样结构化,才能做类似检查?
Long History 到 On-demand Restore 的小结
上下文治理不是定期“删聊天”。先用预算看清来源,再用摘要保存执行状态,用 Offload 保存大体积原文,用带时间和来源的选择机制注入记忆。这样窗口变小了,任务连续性反而更强。
下一篇轮到 Agent 的手脚:工具。我们会看到,真正重要的不是多写几个函数,而是让所有副作用经过同一个可控制、可观察的执行入口。
用预算账本决定压缩顺序
上下文接近上限时,先删什么不能靠感觉。稳定规则、当前任务、最近工具结果和历史闲聊的恢复价值不同。给每类内容记录字符数、token 估算、是否可恢复和过期时间,才能把“压缩”从一次字符串处理变成预算决策。
稳定规则 8 KB 必须保留 不可从会话恢复
当前任务 2 KB 必须保留 来自任务状态
旧对话 40 KB 可摘要 原文在 transcript
工具长输出 90 KB 应 offload 原文在 artifact
项目记忆 12 KB 按相关性选择 原文在 memory 文件
Offload 与摘要解决的问题不同。Offload 保存完整原件并返回句柄,摘要只为当前继续执行提供紧凑状态;拿摘要替代原件,会让后续验证失去证据。
四处实现对应四种责任
| 读取编号 | 源码路径 | 需要确认 |
|---|---|---|
| 1. 压缩器 | src/context/compressor.ts |
摘要输入和输出保留哪些任务状态 |
| 2. Offload | src/context/offload.ts |
大内容存放在哪里,句柄如何恢复 |
| 3. 记忆选择 | src/context/memory/selection.ts |
headers、正文预算和年龄怎样结合 |
| 4. 子 Agent 产物 | src/agents/final-output-artifact.ts |
长结果如何只回传 preview |
失败反例:摘要覆盖原始记录
摘要覆盖原始记录,或把压缩结果当成绝对事实,导致约束和未完成事项丢失。最危险的是摘要仍然语句通顺,团队很难察觉它漏掉了一个否定条件或任务 id。
可靠做法是让摘要引用原始范围,并经过可验证字段检查。任务 id、待办状态、用户明确约束可以结构化断言;自然语言细节无法完全验证时,至少保留原文 artifact 和“摘要可能不完整”的状态。
练习:故意压坏一次摘要
准备包含三个任务、两个失败尝试和一条禁止条件的对话,生成摘要后故意删掉其中一个任务。验证器应指出缺失 id,并阻止把摘要当成唯一恢复来源。随后把一段 50KB 工具输出改为 artifact,确认模型仍能按句柄恢复需要的片段。
压缩对象应该是一段已封口的历史
模型还在流式输出、工具仍在运行时就压缩,会拿到半条 assistant 消息或没有结果的 tool call。压缩边界最好落在完整用户轮次之后:本轮所有前台工具已进入终态,消息账本协议完整,任务状态已经 checkpoint。
sequenceDiagram
participant L as Loop
participant T as Tool
participant C as Compressor
participant S as Session Store
L->>T: tool call c9
T-->>L: tool result c9
L->>S: append complete turn
L->>C: compact sealed range 1..18
C-->>L: summary + source range + validation
L->>S: append compression record
后台任务不必阻塞整段压缩,但要以 taskId、状态和 artifact 句柄进入摘要。任务完成后再通过结构化通知补充,不能把 pending 写成 completed。
摘要最好分“事实”和“待验证线索”
压缩模型会把语气变得更肯定。例如原对话是“可能是缓存问题,还没验证”,摘要却写成“根因是缓存”。下一轮就会围绕错误结论继续。
摘要结构可以把事实分层:用户明确约束、工具验证事实、团队决策、模型假设、待验证项。只有带工具证据或用户确认的内容进入 facts;推测留在 hypotheses,并附验证动作。
facts:
- value: "tests/unit/cache.test.ts 当前失败"
evidence: "tool:c14 exit=1"
constraints:
- "不得修改生产数据库结构"
hypotheses:
- value: "失败可能与旧缓存有关"
confidence: low
verify: "清理测试 fixture 后重跑 c14"
failed_attempts:
- "仅提高 timeout 无效,trace:c11"
这会多占一点摘要长度,却显著降低“推测升级成事实”的风险。
增量压缩要避免摘要的摘要无限失真
会话很长时,系统可能把旧摘要和新十轮再次总结。每压一次,都可能丢一些否定、数字和来源。若永远只总结上一次摘要,误差会积累。
可以保留原始 transcript 与压缩记录的 source range。新摘要输入由“上一份结构化状态 + 最近原始轮次 + 仍有效的关键原文引用”组成;定期或发现冲突时,允许从原始范围重新压缩。原文不常驻模型,但仍可恢复。
摘要记录带版本和 hash。恢复时发现摘要引用的原始范围不完整、任务 ID 缺失或 schema 旧,宁可回退到较早 checkpoint 并重新生成,也不要静默使用损坏状态。
Offload 的句柄需要完整性检查
artifactFile 指向的文件可能被清理、移动或外部修改。句柄带字符数、hash、创建时间和内容类型,读取时重新验证;不一致就告诉模型“原始证据已变化”,而不是继续引用旧摘要。
对日志类 artifact,按行范围读取比一次全取更实用。预览可以包含错误上下各 20 行、总行数和查询建议;模型需要某个测试名时用 grep,再只读相关片段。句柄化不是把大内容藏起来,而是提供更精确的访问路径。
清理策略也应考虑会话状态。仍被未完成任务或摘要引用的 artifact 不能按普通临时文件删除;会话归档后可以按保留期清理,manifest 留下“原文已过期”的明确记录。
压缩前先做便宜的确定性整理
不是所有缩减都要调用模型。重复的工具进度事件、已经被最终结果覆盖的临时 UI 状态、完整 Shell 输出的内联副本、相同文件的旧读取版本,可以按确定规则去重或 offload。
之后再让模型总结自然语言历史,输入更干净,成本更低。确定性整理还不会把“禁止”总结成“提到过”,适合先保护协议结构和显式状态。
顺序可以是:验证消息账本完整,移除不进历史的 transient 状态,长结果转句柄,按 taskId 汇总进度,最后压缩剩余对话。每一步都记录节省量,团队才知道真正的大头在哪里。
压缩也可能失败,主任务不能因此消失
摘要请求超时或模型返回空内容时,系统仍有原始会话。若下一次模型请求尚能在窗口内运行,可以继续并提示压缩未完成;若已经超预算,则阻止新模型调用,提供重试、导出或新会话交接选项。
不能在压缩开始前就删除旧 messages,也不能把无效摘要写成成功 checkpoint。压缩结果先在临时结构通过 schema 和关键字段验证,再原子追加记录。
用户取消压缩时也要回到稳定状态。界面可以显示“未压缩,原会话保持不变”,而不是留在永久 compacting。
设计一次恢复演练
准备一段 40 轮会话,其中有禁止条件、两次失败实验、三个未完成任务、一个 100KB 日志和一个仍在后台运行的 task。压缩后关闭进程,再从 session 与 artifact 恢复。
验收不是摘要读起来流畅,而是恢复后:禁止条件仍是命令式约束;失败方法不会被再次建议;三个 taskId 状态正确;日志可按句柄读取;后台任务没有被误报完成;当前模型仍由新进程配置决定。一次这样的演练,能发现比“摘要字数降低了多少”更关键的问题。