AI Agent 上下文压缩(一):工具结果写磁盘,token 直降 90%

从 Claude Code 源码到 tiny-claw 落地 —— Phase 2: Tool Result Budget 实现全记录

April 7, 2026·5 min read·Yimin
#LLM#Claude Code#上下文压缩#AI Agent#Rust#tiny-claw

一个 grep -r 的输出能吃掉几万 token,但模型真正需要的可能只是前 500 个字符的预览。与其把所有内容塞进上下文,不如写到磁盘,让模型按需读取。

🎯 问题:工具结果撑爆上下文

AI Agent 的核心循环是 思考 → 调工具 → 拿结果 → 继续思考。每一轮工具执行的结果都会追加到 messages 数组里,作为下一次 API 调用的输入。

问题是:工具结果可能非常大。

场景典型大小
bash: find . -name "*.rs"50K-100K 字符
bash: cargo build 2>&130K-200K 字符
web_fetch 抓取整个页面50K+ 字符
grep 匹配几百个结果20K-80K 字符

以 Claude 200K 上下文窗口为例,auto compact 阈值约 167K token (83.5%)。如果一轮工具调用就吃掉 3 万 token,几轮下来上下文就满了。

更浪费的是:这些大结果往往只在当轮有用。 第 3 轮的 bash 输出到第 10 轮时基本没人关心,但它仍然占着 token。


🔍 Claude Code 的解法:Tool Result Budget

读了 Claude Code 的源码 (src/utils/toolResultStorage.ts),它用了一个很优雅的方案: 大结果写磁盘,消息中只留预览。

核心流程

工具返回 result
  │
  ├── result.length > tool.maxResultSizeChars?
  │     ├── 是 → 完整内容写入磁盘文件
  │     │         消息中替换为 <persisted-output> 标签
  │     │         (含文件路径 + 前 2KB 预览)
  │     └── 否 → 保持原样
  │
  └── 同一 user 消息内所有 tool_result 总字符 > 200K?
        ├── 是 → 对组内最大的新结果也做 persist
        └── 否 → 保持原样

替换后的消息长这样

<persisted-output>
Output too large (50KB). Full output saved to: /path/to/tool-results/call_abc123.txt

Preview (first 2KB):
[前 2000 字符的预览,尽量切在换行处]
...
</persisted-output>

模型看到 2KB 预览,能判断这个结果是否有用。如果需要完整内容,再用 read_file 工具读回磁盘文件。

这就是关键设计:预览而非删除。 模型不丢失信息,只是多了一步"按需读取"。

为什么不在框架层自动展开?

看到 <persisted-output> 时,框架直接帮模型调一次 read_file、把完整内容注入回消息,不就省了一次 API 来回吗?

不行,这破坏了整个设计目的。 自动展开等于每次都塞进全量内容,节省 token 为零。关键在于:模型看 2KB 预览就能做出判断——"够了,不需要完整内容"。这是懒加载:只有模型主动要求时才按需拉取全文。如果框架强制展开,就是退化成了不压缩。

每个工具有独立阈值

Claude Code 的每个工具定义了自己的最大结果字符数:

工具阈值原因
bash / powershell30Kshell 输出经常很大
grep20K搜索结果是最常见的爆上下文来源
read_fileInfinity它自己已有限制,persist 会造成死循环
其他工具50K (全局上限)默认兜底

还有一个 全局上限 50K —— 即使工具声明了 100K,实际也被 clamp 到 50K。

触发时机:两个独立阶段

Tool Result Budget 不是一个机制,而是 两个阶段分别执行:

阶段 A:工具刚执行完(实时)
  processToolResultBlock()
  → 单条结果 > maxResultSizeChars → 立刻写磁盘、替换内容
  → 与"上下文用了多少"无关,只管这一条

阶段 B:下一次 API 请求发出前
  applyToolResultBudget()   → 同一轮并行工具总量 > 200K 再追加落盘
  microcompactMessages()    → 清掉旧 tool_result(保留最近 N 条)
  autoCompactIfNeeded()     → token 超 83.5% → LLM 生成摘要替换全部历史

L1(阶段 A)和 L2L4(阶段 B)的关注点完全不同:L1 只防单条爆炸,与总量无关;L2L4 处理累积问题。tiny-claw 当前实现的是 L1,Phase 3/4 对应后面几层。

read_file 的完整生命周期

read_filemaxResultSizeChars: Infinity,不会被 L1 截断,但它自己有独立的 25,000 token 上限(FileReadTool/limits.ts)。超限时报错并提示用 offset/limit 分段读。

bash 输出 34KB → 超过 30K → 写磁盘,消息变 <persisted-output>
       │
       ▼
  模型按需调 read_file 读那个 .txt
       │
       ├── 文件 < 25K tokens → 读成功,full content 进 messages
       │                              │
       │                              └── 几轮后被 microcompact 清掉
       │                                  → "[Old tool result content cleared]"
       │
       └── 文件 > 25K tokens → 报错,强迫模型用 offset/limit 分段读

read_file 的结果同样会被 microcompact 清掉——它在 COMPACTABLE_TOOLS 列表里。

ContentReplacementState:保证 Prompt Cache 稳定

Claude Code 用一个状态结构来冻结替换决策:记录所有处理过的 tool_use_id 和它们的替换结果。

规则:见过的 tool_use_id,内容命运就此冻结,永不翻转。

Prompt Cache 是怎么工作的

Anthropic 的 Prompt Cache 是线性前缀缓存。服务端以 messages 数组的顺序哈希前缀:

第 1 轮: [sys, user₁, asst₁]          → 缓存 prefix-1
第 2 轮: [sys, user₁, asst₁, user₂, asst₂]  → prefix-1 命中缓存,只计算新增部分
第 3 轮: [sys, user₁, asst₁, user₂, asst₂, user₃, asst₃]  → prefix-2 命中

关键:只要前缀字节完全一致就能命中。一旦中间某条消息改变,从那条开始之后的所有内容全部缓存失效。

为什么要冻结替换决策

假设第 2 轮某个工具结果没超阈值,发给 API 的是原始内容。第 3 轮时恰好并行工具总量超了 200K,系统想"回头补一刀"把第 2 轮那条也替换掉:

第 2 轮消息(已缓存): "stdout: ...全量内容..."
第 3 轮重建后变成:    "<persisted-output>..."   ← 前缀变了!

缓存全部失效,原本省钱的操作反而更贵。所以 ContentReplacementState 的核心逻辑是:

  • mustReapply:曾经被替换过的 → 再次发请求时重放完全一致的替换字符串(不做任何 I/O,Map 查表直接取)
  • frozen:曾经发过原始内容的 → 永远不再替换
  • fresh:首次出现的 → 本轮决定,决定后冻结

microcompact 不就破坏了这个承诺吗?

这是一个好问题。答案是:两个机制管的是不同的"层"

ContentReplacementState 管的是"<persisted-output> 替换是否稳定"。microcompact 是另一个覆盖层——把旧内容改成 [Old tool result content cleared]。这确实改变了消息前缀。

Claude Code 的解法是Cached Microcompact:它不直接修改本地 messages,而是通过 cache_edits API 在服务端删除缓存中的旧内容。对服务端而言,删除前是一个缓存版本,删除后是另一个;下次请求发 cache_reference + cache_edits,服务端直接"剪枝"对应片段。本地 messages 内容没变,cache 命中率照旧,只是被剪掉的那部分不再占服务端缓存空间。

本地 messages:  [A][B][C][D]   (内容不变)
服务端缓存:      [A][B][C]      (D 被 cache_edits 剪掉)
下次请求:        [A][B][C][D][E]
                 └─── A-B-C 命中缓存,只计费 D-E ─────┘

两者职责分离,互不干扰。


🔧 tiny-claw 实现:Phase 2 完整记录

tiny-claw 是我用 Rust 写的 AI Agent 框架。Phase 1 (Token 计数) 已经完成,Phase 2 (Tool Result Budget) 是第一个真正"压缩"上下文的机制。

Step 1: Tool trait 增加 max_result_size()

每个工具需要声明自己的结果大小上限。在 trait 中加一个带默认值的方法,默认返回 None 表示不限制。需要截断的工具显式覆盖为具体值:

  • BashExecTool → 30K
  • WebFetchTool → 30K
  • WebSearchTool → 20K
  • ReadFileTool → None (它自己有截断,再 persist 会造成死循环)
  • ListDirTool → None (输出通常不大)

设计决策: None 表示"不限制",而不是"用全局默认值"。需要截断的工具显式声明。这样更灵活,后续加 aggregate budget 时再加全局 cap。

Step 2: 核心逻辑 —— 截断 + 磁盘持久化

新建了 tool_result_budget.rs 模块,核心两个函数:

persist_tool_result: 把完整内容写入磁盘文件,返回 <persisted-output> 格式的替换文本。预览取前 500 字符,尽量切在换行处(从 50% 位置往后找最后一个换行)。

maybe_truncate_tool_result: 先检查内容是否超阈值,没超就原样返回,超了才调用 persist_tool_result

Step 3: 集成到 Agent Loop

在工具执行结果写入 messages 之前插入截断逻辑:

  • 从 registry 查工具的 max_result_size()
  • 如果有值且有 output_dir,调用 maybe_truncate_tool_result
  • 截断成功 → 用截断版本发给 API
  • 截断失败 → 降级为原始内容,不中断对话
  • 关键点: DB 的 persist_message 仍然存储原始内容,只有发给 API 的 messages 用截断版本

目录结构

持久化路径按 conversation 隔离:

~/.tiny-claw/
  ├── tiny-claw.db
  ├── logs/
  └── tool-results/
      └── {conversation-uuid}/
          ├── call_abc123.txt    ← 完整 bash 输出
          ├── call_def456.txt    ← 完整 grep 输出
          └── ...

测试

写了 7 个单元测试,覆盖:内容未超阈值(返回 None)、超阈值(返回 persisted-output)、自动创建子目录、预览生成边界情况等。


📊 效果:token 能省多少?

以一个典型场景估算:

bash 输出 50K 字符 ≈ 12,500 token
截断后 <persisted-output> ≈ 800 token (500 字符预览 + 标签)
节省 ≈ 11,700 token (93.6%)

如果一轮并行执行 3 个工具,每个 30K+ 输出,一轮就能省 3 万+ token。


🔜 后续:Phase 3 和 Phase 4

Tool Result Budget 解决了"单条结果太大"的问题,但长对话还有另一个问题:旧工具结果虽然不大,但累积起来也很可观。

Phase 3: Microcompact —— 保留最近 6 条工具结果,更早的替换为 "[Previous tool result cleared to save context]"。这是时间驱动的轻量压缩。

Phase 4: Auto Compact —— 当 token 接近阈值时,调 LLM 生成 9 章节结构化摘要,替换全部历史。这是最重但最有效的手段。

三级机制叠加: L1 截断大结果 → L3 淡化旧结果 → L5 整体摘要,逐级递进,保证上下文始终在预算内。