如何用「四级压缩法」干掉 Agent 上下文膨胀?
🎯 学习目标
- 四层压缩分别是什么?为什么按成本从低到高执行?
- canonical history / request history / transcript / artifact 四者区别?
- 什么是消息原子组?为什么不能只看相邻两条消息?
- 大工具结果落盘的阈值是多少?为什么按 UTF-8 bytes 不按字符串 length?
- 50,000 bytes 摘要阈值是什么?不是什么?
- finish_reason==="length" 等于 prompt-too-long 吗?P08 Loop 自动恢复了吗?
🧠 核心概念
1 · 核心原则:先便宜,后昂贵 ▸
- 大工具结果落盘:只把路径+有界预览留在历史。
- snip:按完整消息组裁掉中段。
- micro:较早工具组结果替换为占位符。
- 模型摘要:前三层后仍超阈值才调用模型。
前三层不增加模型请求,第四层才发起一次不带工具的摘要请求。压缩在两次模型调用之间预处理 request history,不改 System Prompt 静态前缀。这个时机是刻意的:未压缩部分与上一轮请求共享前缀,能命中供应商 KV Cache;中段一旦被裁,后缀缓存全部失效,所以能用便宜层解决就不动结构。
2 · 三种历史 + 一种产物 ▸
| 对象 | 含义 | 被请求压缩改写? |
|---|---|---|
| canonical history | AgentRunner.#history 会话事实;RunResult.history 返回不可变快照 | 否 |
| request history | 每次模型调用前从 canonical 复制出的请求快照 | 是(只影响本次及后续可复用快照) |
| transcript | 进入模型摘要前保存的 canonical history JSONL 快照 | 否;文件独占发布 |
| tool-result artifact | 大工具结果的完整 UTF-8 内容 | 否;canonical 中只存路径和预览 |
<persisted-tool-result> 引用而非大正文。snip/micro/摘要都发生在请求快照上,不删除 canonical 中已存在消息。3 · 消息原子组:不能只看相邻两条 ▸
一条 assistant 消息可含多个 tool_calls,合法历史不是「一条调用配一条结果」,而是不可拆分原子组:
assistant(tool_calls=[call_a, call_b])
tool(tool_call_id=call_a)
tool(tool_call_id=call_b)
validatedGroups() 先 validateToolPairing() 再划成两类组:普通消息单独一组;带工具调用的 assistant + 全部 tool results 共同构成工具交换组。snip 的保留边界、micro 的替换单位都用组,不会留孤儿 tool 或缺结果的 assistant。
4 · 第一层:大结果按 UTF-8 bytes 落盘 ▸
阈值用真实 UTF-8 字节数 Buffer.from(content,"utf8").byteLength:
- 单项结果 > 30,000 bytes 直接落盘(等于阈值仍保留);
- 未落盘合计仍 > 200,000 bytes,按字节数从大到小继续落盘;
- 完整内容写
.agent_tutorial/artifacts/tool-result-<id>.txt; - 消息保留相对路径、original_bytes、头部最多 2,000 bytes + 尾部最多 2,000 bytes 预览。
5 · 第二层 snip + 第三层 micro ▸
snip:超过 50 个消息组触发,保留前 3 组 + 一条 [Compacted: N message groups omitted] 标记 + 后 46 组。统计的是消息组不是 ChatMessage 条数。
micro:完整保留最近 3 个工具交换组,更早工具组所有结果替换为 [Earlier tool result compacted. Re-run if needed.]。不删除 assistant 调用,不改 tool_call_id,整个原子组合法。snip 先执行,micro 后执行,都基于不可变数组快照生成 request history,不原地改 canonical。
6 · 第四层:50,000 bytes 才摘要 ▸
前三层后 historyUtf8Bytes() 把请求快照转规范 OpenAI JSONL 算 UTF-8 bytes,只有 > 50,000 才进模型摘要。顺序:先存未压缩 canonical 快照为 transcript → summarizer 读已过便宜层的 request history → 摘要请求不携带工具 → 只接受 finishReason==="stop"、无 toolCalls、非空且字段精确的 JSON object → 成功后请求历史变成一条 system 消息(含 transcript 路径 + 五字段)。
五字段:current_goal、key_findings、files_read_or_changed、remaining_work、user_constraints。
7 · prompt-too-long:原语有,Loop 信号没有 ▸
| 信号 | 含义 | P08 行为 |
|---|---|---|
| finishReason==="length" | 请求已接受但输出达上限,响应不完整 | 抛 IncompleteModelReplyError,不做响应式压缩 |
| 输入 prompt-too-long | 输入上下文被供应商拒绝 | 尚无统一 typed signal,无自动恢复接线 |
| compactOnPromptTooLong(retryCount:0) | 调用者完成错误分类后可用的恢复原语 | 单独可测;只允许一次尝试 |
▶️ 交互演示:四级压缩管线
🔑 一句话总结
按需加载负责少拿(第7章),Context Compact 负责及时放下(本章)。两者都只影响 request history,canonical history 始终是会话事实。