AI Agent 上下文压缩(二):旧工具结果自动淡化,Microcompact 机制详解
从 Claude Code 源码看如何在不丢信息的前提下,让过期工具结果退场
Tool Result Budget 解决了"单次结果太大"的问题,但即使每个结果都控制在合理大小,几十轮下来,一堆"不再被关心的旧结果"仍然塞满上下文。Microcompact 做的事很简单:把旧的清掉,只留最近用到的。
🎯 问题:旧工具结果的堆积
上一篇聊了 Tool Result Budget —— 把超大工具结果写磁盘,消息里只留预览。但那解决的是单次结果太大的问题。
还有一个更隐蔽的浪费:随着对话进行,早期工具结果占着 token 但已经没人看了。
时间轴 →
轮次 1: read_file(config.toml) → 2000 tokens ← 早就不关心了
轮次 3: grep("TODO", src/) → 3000 tokens ← 早就不关心了
轮次 5: bash(cargo build) → 1500 tokens ← 早就不关心了
轮次 8: read_file(lib.rs) → 2500 tokens ← 早就不关心了
轮次 12: bash(cargo test) → 1800 tokens ← 可能还有用
轮次 15: read_file(agent.rs) → 3000 tokens ← 正在用
轮次 16: grep("compact", src/) → 1200 tokens ← 正在用
────────────
~15000 tokens 的工具结果
其中一半以上已经没用了,但仍然占着上下文窗口
这些旧结果的特点:
| 特征 | 说明 |
|---|---|
| 内容已过时 | 第 1 轮读的文件可能早就被修改了 |
| 决策已完成 | 模型看到 grep 结果后已经做了判断,原始结果不再需要 |
| 占比可观 | 工具结果往往是消息中最大的内容块 |
| 不影响推理 | 模型当前的推理只依赖最近几轮的结果 |
Tool Result Budget 管的是"入口水龙头",控制每个结果的最大体积。但水龙头拧小了,水池(上下文)里的旧水还是会越积越多。
Microcompact 就是排水口 —— 定期把旧水放掉。
🧠 Claude Code 的设计:两种 Microcompact 模式
翻了 Claude Code 的 src/services/compact/microCompact.ts,它实现了两种 microcompact 路径,根据场景自动选择。
整体决策流程
microcompactMessages(messages, toolUseContext, querySource)
│
├── [1] 时间驱动 microcompact
│ 检查最后一条 assistant 消息距今多久
│ ├── 超过阈值 (例如 5 分钟) → 清理旧工具结果,直接改消息内容
│ └── 未超过 → 继续
│
├── [2] Cached microcompact (CACHED_MICROCOMPACT feature gate)
│ 不改本地消息,生成 cache_edits 指令
│ ├── 可用 → 服务端删除旧工具结果
│ └── 不可用 → 跳过
│
└── [3] 都不触发 → 返回原消息,不做任何压缩
两条路径互斥:时间驱动触发了就不走 cached 路径。这是有原因的 —— 后面会解释。
🔍 模式一:时间驱动 Microcompact
这是最直观的模式:用户离开一段时间回来,服务端 prompt cache 已经过期了,索性趁机清理旧工具结果。
触发条件
evaluateTimeBasedTrigger(messages, querySource)
│
├── 找到最后一条 assistant 消息
├── 计算 gapMinutes = (当前时间 - 该消息时间戳) / 60000
└── gapMinutes >= 配置的阈值? → 触发
注意这里有个巧妙的设计:只在主线程 (repl_main_thread) 触发,不在子 agent 里触发。 因为子 agent (如 session_memory、prompt_suggestion) 有自己独立的上下文,如果它们也跑 microcompact,会把主线程的工具状态搞乱。
哪些工具的结果可以被清理?
不是所有工具结果都适合清理。Claude Code 定义了一个白名单:
const COMPACTABLE_TOOLS = new Set([
'Read', // 文件读取
'Bash', // shell 命令
'Grep', // 搜索
'Glob', // 文件查找
'WebSearch', // 网页搜索
'WebFetch', // 网页抓取
'Edit', // 文件编辑
'Write', // 文件写入
])
这些工具的共同特点:结果是"查询型"的,过期后价值递减。 相比之下,像 AgentTool(子 agent 调用)的结果可能包含关键决策,不适合盲目清理。
清理逻辑:保留最近 N 个
收集所有 compactable 工具的 tool_use_id (按出现顺序)
│
├── 保留最后 N 个 (keepRecent,至少 1 个)
│ keepSet = compactableIds.slice(-keepRecent)
│
└── 其余的全部清理
clearSet = compactableIds.filter(id => !keepSet.has(id))
清理方式很简单粗暴 —— 把 tool_result 的 content 替换成一行占位符:
[Old tool result content cleared]
源码中的实际替换逻辑:
const newContent = message.message.content.map(block => {
if (
block.type === 'tool_result' &&
clearSet.has(block.tool_use_id) &&
block.content !== TIME_BASED_MC_CLEARED_MESSAGE // 已清理的不重复处理
) {
tokensSaved += calculateToolResultTokens(block)
return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE }
}
return block
})
注意:是替换 content,不是删除整个 tool_result 块。 tool_use_id 和 type 都保留,保证消息结构不被破坏。模型仍然能看到"这里曾经有个工具结果",只是内容没了。
为什么选在"用户回来"时触发?
这不是随便选的,背后的推理链条:
用户离开 5 分钟
→ 服务端 prompt cache 过期 (Anthropic cache TTL 约 5 分钟)
→ 下次请求不管怎样都要重新写入 cache
→ 改消息内容不会造成额外的 cache 失效
→ 正好是清理旧结果的最佳时机
如果在 cache 还热的时候改消息,会破坏 prompt cache 前缀,导致整个 cache 需要重建 —— 反而更贵。时间驱动模式等 cache 自然过期,修改消息的"副作用"为零。
🔍 模式二:Cached Microcompact (缓存编辑)
这是更高级的模式,设计目标是:在 prompt cache 仍然有效的情况下,也能安全地删除旧工具结果。
核心思路
不改本地消息内容,而是生成一组 cache_edits 指令附在 API 请求上,告诉服务端:"请在你缓存的副本里删掉这些 tool_result 的内容。"
本地消息 (不变)
├── 轮次 1: read_file 结果 = "完整内容..."
├── 轮次 5: grep 结果 = "完整内容..."
└── 轮次 10: bash 结果 = "完整内容..."
API 请求
├── messages: [不变的消息]
└── cache_edits: [
{ delete: tool_result_id_1 },
{ delete: tool_result_id_5 }
]
服务端处理
├── 从缓存中删除指定的工具结果内容
├── 返回 cache_deleted_input_tokens = 实际节省的 token 数
└── 缓存前缀保持有效 (关键!)
为什么需要这种模式?
| 场景 | 时间驱动 | Cached MC |
|---|---|---|
| 用户离开 5+ 分钟后回来 | ✅ 正好,cache 已过期 | ❌ 没必要 |
| 连续对话,中间不停顿 | ❌ 不会触发 | ✅ 唯一选择 |
| cache 命中率 | 改了消息,cache 失效 | 不改消息,cache 保持 |
| 实现复杂度 | 低 | 高(需要 API 支持) |
连续长对话是最常见的场景 —— 用户一直在和 agent 交互,从不离开。这时时间驱动永远不会触发,但工具结果在持续堆积。Cached MC 就是为这个场景设计的。
状态管理
Cached MC 维护了一个模块级别的状态:
CachedMCState
├── registeredTools: Set<string> ← 已注册的 tool_use_id
├── toolOrder: string[] ← 注册顺序 (用于选择要删哪些)
├── deletedRefs: Set<string> ← 已经通过 cache_edits 删除的
└── pinnedEdits: PinnedCacheEdits[] ← 已经发送过的 cache_edits (下次要重发)
每轮 API 调用前:
- 扫描消息,把新的 compactable tool_result 注册进
state getToolResultsToDelete()根据 trigger 阈值和 keep 数量,决定要删哪些- 生成
cache_edits块,挂到pendingCacheEdits - API 层发送请求时附上
cache_edits - API 响应后,用
cache_deleted_input_tokens计算实际省了多少 token
注意 pinnedEdits 的设计: 已经发送过的 cache_edits 在后续请求中必须重发 —— 否则服务端会在下次缓存写入时恢复被删除的内容。这类似于"声明式删除",每次都要声明"我要删掉这些"。
与时间驱动的互斥
当时间驱动触发时,会调用 resetMicrocompactState() 清空 cached MC 的状态。原因:
时间驱动触发
→ 直接改了消息内容 (把旧工具结果替换为占位符)
→ prompt cache 失效 (内容变了,前缀不匹配了)
→ cached MC 状态中记录的 tool_use_id 在服务端缓存里已经不存在
→ 如果不重置,cached MC 下次会尝试 cache_edit 不存在的 ID → 出错
🏛️ 两种模式的完整对比
| 维度 | 时间驱动 | Cached MC |
|---|---|---|
| 触发条件 | 距上次 assistant 消息超过 N 分钟 | 工具数量超过阈值 |
| 清理方式 | 替换消息内容为占位符 | 生成 cache_edits 指令 |
| 本地消息是否修改 | ✅ 修改 | ❌ 不修改 |
| prompt cache 影响 | 失效 (但 cache 本就已过期) | 保持 (这是它存在的意义) |
| 节省量计算 | 客户端估算 (roughTokenCountEstimation) | 服务端精确返回 |
| 适用场景 | 用户离开后回来 | 连续长对话 |
| 外部可用 | ✅ 通用 | ❌ 仅 Anthropic API |
⚙️ 在压缩管线中的位置
回顾 Claude Code 的 5 级压缩管线,microcompact 在 L3:
每轮 API 调用前:
messagesForQuery = getMessagesAfterCompactBoundary(messages)
│
├── [L1] Tool Result Budget ← 大结果写磁盘 (上一篇)
│
├── [L2] Snip ← 裁剪早期历史 (内部功能)
│
├── [L3] Microcompact ← 本篇:旧工具结果淡化
│
├── [L4] Context Collapse ← 折叠过时上下文段 (内部功能)
│
└── [L5] Auto Compact ← 调 API 摘要 (下一篇)
│
压缩后 messagesForQuery → 调 API
顺序有讲究: L1 在 L3 之前,因为 Tool Result Budget 的 persist 操作改的是消息内容,而 cached MC 只看 tool_use_id 不看内容,两者可以干净地组合。如果反过来,cached MC 先删了某个 tool_result,L1 再尝试 persist 同一个结果就会出问题。
⚙️ tiny-claw 的简化实现
在 tiny-claw (Rust) 里,我们不需要实现 cached MC (那依赖 Anthropic 的 cache_edits API),只需要实现时间无关的计数驱动版本 —— 消息足够多时,清理旧工具结果。
核心逻辑
const KEEP_RECENT_TOOL_RESULTS: usize = 6;
const CLEARED_MESSAGE: &str = "[Previous tool result cleared to save context]";
pub fn microcompact_messages(messages: &mut Vec<ChatMessage>, keep_recent: usize) -> usize {
// 1. 收集所有 Role::Tool 消息的索引
let tool_indices: Vec<usize> = messages
.iter()
.enumerate()
.filter(|(_, m)| m.role == Role::Tool)
.map(|(i, _)| i)
.collect();
// 2. 保留最近 keep_recent 条,其余替换
if tool_indices.len() <= keep_recent {
return 0;
}
let to_clear = tool_indices.len() - keep_recent;
let mut cleared = 0;
for &idx in &tool_indices[..to_clear] {
if messages[idx].content != CLEARED_MESSAGE {
messages[idx].content = CLEARED_MESSAGE.to_string();
cleared += 1;
}
}
cleared
}
调用时机
在 AgentLoop::chat() 循环顶部,构造 messages_for_query 之后、发送 API 之前:
let mut messages_for_query = self.messages.clone();
if messages_for_query.len() > 20 {
let cleared = microcompact_messages(&mut messages_for_query, KEEP_RECENT_TOOL_RESULTS);
if cleared > 0 {
log::info!("[Compact] microcompact cleared {cleared} old tool results");
}
}
// messages_for_query 发给 API
// self.messages 保持不变 (完整记录)
关键设计:修改的是副本,不是原始记录。 self.messages 保留完整历史,messages_for_query 是发给 API 的"压缩视图"。这样做的好处:
- 如果后续需要全量 compact (调 API 生成摘要),能用完整历史生成更准确的摘要
- 用户查看历史记录时能看到完整内容
- 每轮的清理决策是独立的,不会"连锁淡化"
与 Claude Code 的差异
| 特性 | Claude Code | tiny-claw |
|---|---|---|
| 时间驱动触发 | ✅ | ❌ (简化为计数驱动) |
| Cached MC | ✅ (Anthropic 专属) | ❌ (不依赖特定 API) |
| 工具白名单 | ✅ (8 种工具) | ❌ (所有 Tool 角色都处理) |
| 保留数量 | 配置化 (GrowthBook) | 常量 KEEP_RECENT = 6 |
| 修改目标 | messages / cache_edits | messages_for_query 副本 |
| token 节省统计 | 精确 (服务端 / 粗估) | 不统计 (日志记录清除数量) |
tiny-claw 的实现刻意简化,后续可以根据需要逐步加入工具白名单和 token 估算。
⚠️ 设计取舍与注意事项
为什么不用 token 数做阈值?
Claude Code 的时间驱动用的是"距上次 assistant 消息的时间差",计数驱动用的是"工具结果数量"。为什么不用"工具结果总 token 数"?
因为粗估不靠谱,按个数更稳定。 一个 grep 结果可能 100 token 也可能 5000 token,但"6 个结果"是确定的。用个数做阈值,行为可预测;用 token 估算,可能因为估算误差导致忽紧忽松。
保留多少个才合理?
Claude Code 的 keepRecent 是通过 GrowthBook (远程配置平台) 动态调整的,生产环境可以 A/B 测试。tiny-claw 暂定 6 个,理由:
- Agent 通常在最近 2-3 轮做关键决策,每轮可能调 2-3 次工具
- 6 个覆盖了最近 2-3 轮的工具结果
- 太少 (如 2-3 个) 可能导致模型丢失正在使用的上下文
- 太多 (如 20 个) 压缩效果不明显
替换 vs 删除?
Claude Code 选择替换为占位符而非删除整个 tool_result 块。这是因为:
- 消息结构一致性:
tool_use→tool_result必须配对,删掉tool_result会导致 API 报错 - 模型认知: 模型看到
[Old tool result content cleared]能理解"这里有过结果但被清理了",不会困惑于为什么有tool_use但没结果 - prompt cache: 保留块结构意味着消息数组的"骨架"不变,更有利于 cache 命中
📝 总结
Microcompact 是上下文压缩管线里"投入最小、收益最稳"的一环:
- 不需要调 API (不像 auto compact 需要额外一次 LLM 调用)
- 不丢信息 (模型知道结果被清理了,需要时可以重新调工具获取)
- 效果立竿见影 (每个旧工具结果少则几百、多则几千 token)
它的哲学是:工具结果是有保质期的。 读文件的结果在文件被修改后就过期了,grep 的结果在做完决策后就过期了。与其让过期信息占着上下文窗口影响模型注意力,不如主动清理,留给更重要的当前上下文。
下一篇会讲最重的压缩机制 —— Auto Compact: 当 token 逼近上下文窗口上限时,调 LLM 生成一份结构化摘要,替换整段对话历史。那是"终极手段",但也是最贵的 —— 毕竟要多花一次 API 调用。