AI Agent 上下文压缩(二):旧工具结果自动淡化,Microcompact 机制详解

从 Claude Code 源码看如何在不丢信息的前提下,让过期工具结果退场

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

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_idtype 都保留,保证消息结构不被破坏。模型仍然能看到"这里曾经有个工具结果",只是内容没了。

为什么选在"用户回来"时触发?

这不是随便选的,背后的推理链条:

用户离开 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 调用前:

  1. 扫描消息,把新的 compactable tool_result 注册进 state
  2. getToolResultsToDelete() 根据 trigger 阈值和 keep 数量,决定要删哪些
  3. 生成 cache_edits 块,挂到 pendingCacheEdits
  4. API 层发送请求时附上 cache_edits
  5. 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 的"压缩视图"。这样做的好处:

  1. 如果后续需要全量 compact (调 API 生成摘要),能用完整历史生成更准确的摘要
  2. 用户查看历史记录时能看到完整内容
  3. 每轮的清理决策是独立的,不会"连锁淡化"

与 Claude Code 的差异

特性Claude Codetiny-claw
时间驱动触发❌ (简化为计数驱动)
Cached MC✅ (Anthropic 专属)❌ (不依赖特定 API)
工具白名单✅ (8 种工具)❌ (所有 Tool 角色都处理)
保留数量配置化 (GrowthBook)常量 KEEP_RECENT = 6
修改目标messages / cache_editsmessages_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 块。这是因为:

  1. 消息结构一致性: tool_usetool_result 必须配对,删掉 tool_result 会导致 API 报错
  2. 模型认知: 模型看到 [Old tool result content cleared] 能理解"这里有过结果但被清理了",不会困惑于为什么有 tool_use 但没结果
  3. prompt cache: 保留块结构意味着消息数组的"骨架"不变,更有利于 cache 命中

📝 总结

Microcompact 是上下文压缩管线里"投入最小、收益最稳"的一环:

  • 不需要调 API (不像 auto compact 需要额外一次 LLM 调用)
  • 不丢信息 (模型知道结果被清理了,需要时可以重新调工具获取)
  • 效果立竿见影 (每个旧工具结果少则几百、多则几千 token)

它的哲学是:工具结果是有保质期的。 读文件的结果在文件被修改后就过期了,grep 的结果在做完决策后就过期了。与其让过期信息占着上下文窗口影响模型注意力,不如主动清理,留给更重要的当前上下文。

下一篇会讲最重的压缩机制 —— Auto Compact: 当 token 逼近上下文窗口上限时,调 LLM 生成一份结构化摘要,替换整段对话历史。那是"终极手段",但也是最贵的 —— 毕竟要多花一次 API 调用。