从设计者的角度理解源码–CludeCode的记忆压缩策略

王大爷 2026年09月07日 14次浏览

从设计者的角度理解源码–Clude Code的记忆压缩策略

引言

本篇是《从设计者的角度理解源码》系列里聚焦上下文与记忆的一篇。

聊一个每个做 LLM 应用的人都会撞上的问题:对话跑长了,token 不够用了怎么办?

大部分项目的答案很简单——快爆了就让模型自己总结一下,用摘要替换历史。这当然能用,但总觉得糙。Claude Code 不一样,它在"省 token"这件事上堆了整整七条刀法,从最轻到最重排成一条管道,LLM 摘要反而是最后万不得已才掏的那一把。

哎,不就是省几个 token 吗,至于搞七种?用最猛的一把 LLM 摘要不就全解决了?

等你顺着设计者的思路走完这条管道就知道:每多一种刀法,就在"保缓存 / 保语义 / 保可逆 / 保延迟"这四件事上多保住一样。七种不是冗余,是七层防御、逐级兜底。

这篇文章,我顺着这条管道,一把一把掏出来看。


总览:一条成本递增的压缩管道

先看全景。Claude Code 的压缩策略全集中在 query 循环的阶段 1(API 调用之前),按 1a → 1e 的顺序依次执行:

// src/query.ts  阶段 1a~1e:上下文压缩管道
//
//  1a  ToolResultBudget   超大输出存盘 + 预览替换
//  1b  SnipCompact        模型主动标记的消息物理删除
//  1c  MicroCompact       过期工具结果清理(时基 + cache_edits 双路径)
//  1d  ContextCollapse    读时投影,折叠旧版本文件内容
//  1e  AutoCompact        LLM 摘要(终极手段,先试 SessionMemory 零 API)
//
//  排序三原则:
//    成本递增   → 无 LLM 调用 → 零成本摘要 → LLM 摘要 → 报错
//    可逆性递减 → 可逆投影 → 部分丢弃 → 全局摘要
//    粒度从细到粗 → 单条结果 → 单条消息 → 同类投影 → 全量历史

七刀排成一队,挨个上阵。前面的能搞定,后面的就不触发。

哎,为什么要排这么长一条队?直接上最后一把 LLM 摘要不行吗?

有了:每一把刀的"代价"是不一样的——有的零成本、可逆、不破坏缓存;有的要调 LLM、不可逆、缓存全废。管道的设计哲学就一句话:能用便宜的解决,就绝不用贵的;能用可逆的,就绝不用不可逆的;能保缓存的,就绝不破坏缓存。

越往后的刀,越锋利,也越伤元气。不到万不得已,不掏出来。

这一篇,我们从最前面那把最轻的开始,一把一把看。


第一刀:超大输出先存盘(ToolResultBudget)

场景:一条 Bash 命令吐出了 1MB 的构建日志,或者一个 grep 扫出了三万行匹配。一条工具结果占掉半个上下文窗口。

哎,这么大的输出,直接删了可惜、留着占地方,怎么办?

有了:存盘 + 留预览。完整内容写到磁盘文件里,对话里用一个 <persisted-output> 占位符代替,带文件路径 + 前几行预览。要用的时候,模型随时可以 Read 回来。

这就是 1a 的 ToolResultBudget,整条管道里最轻的一刀。

它背后有一条设计铁律,叫"冻结",值得单独说:

// src/utils/toolResultStorage.ts
//
//   "冻结"的意义:如果某次发送了完整内容,后续就不能再替换(会破坏 prompt cache)。
//   如果某次替换了,后续必须原样替换(同样为了 prompt cache 稳定)。
//
//   【为什么 prompt cache 对前缀一致性要求这么高?】
//   Claude API 的 prompt cache 缓存的是 Prefill 阶段产生的 KV Cache
//   (Encoder 产物),命中条件是请求前缀字节完全一致。查询循环每轮都重发全
//   部历史消息,前缀中任何一处内容变化都会导致从断裂点开始的全部 Prefill
//   重新计算,既浪费成本也增加延迟。
//
//   因此本体系的唯一铁律:一旦某个 tool_result 的"面目"(完整内容 vs 预览)
//   在第一轮决定了,后续所有轮次必须保持一致。

看懂了吗?一个 tool_result 长什么样,第一轮定了就终身不能改。 第一轮发了完整内容,后面永远得发完整内容;第一轮替换成了占位符,后面永远得是一模一样的占位符。

为什么?因为前缀字节一变,KV Cache 从断裂点开始全部重算——你为了省几 KB 的内容,赔掉了后面几万 token 的缓存命中。这笔账怎么算都亏。

而且还有个巧妙的设计:模型想用完整内容怎么办?Read 一下磁盘文件就行。Read 是追加新消息到末尾,不改已有前缀 → 不破坏缓存。

// src/utils/toolResultStorage.ts
//
// 【持久化结果与模型 Read 的协作】
//   被 <persisted-output> 替换后,模型从未见过完整内容——完整版在磁盘文件里。
//   但模型可以调用 Read 工具主动读取。Read 是追加新消息到末尾,不修改
//   已有前缀 → 不影响 prompt cache。
  • 成本:零 LLM 调用,一次磁盘 I/O
  • 可逆性:完全可逆(Read 一下就回来)
  • 缓存影响:只影响该条结果自己之后的前缀(冻结后就稳定了)
  • 粒度:单条工具结果

最轻的一刀,先把最胖的那几条砍了。


第二刀:模型自己删什么(SnipCompact)

大的存盘了,接下来是"哪些消息完全没用,可以直接物理删掉"。

哎,系统怎么知道哪条消息没用?删错了把关键信息删了怎么办?

有了:让模型自己决定删什么。不是系统硬删,是给模型一个 SnipTool 工具,模型主动说"删 ID 为 abc123 的那条",系统才删。

// src/query.ts  阶段 1b:Snip - 历史消息裁剪
//
//  Snip 是一个模型辅助裁剪系统,由三部分协作完成:
//
//  1. 标记注入:给每条 user 消息末尾追加 [id:abc123] 短 ID
//     让模型可以精确引用"删除 ID 为 abc123 的那条消息"
//
//  2. SnipTool:模型可以调用的工具,参数为要删除的消息 ID 数组
//     上下文效率低时自动提示模型使用此工具(SNIP_NUDGE_TEXT)
//
//  3. snipCompactIfNeeded:扫描消息列表,检测被标记的消息并物理移除
//
//  与 autoCompact 的区别:
//    snip: 模型主动选择要删什么(显式指定 ID),规则驱动,0 API 调用
//    autoCompact: LLM 总结旧消息为摘要,API 调用,代价高但语义保留更好

这一刀的妙处在于:把"删什么"的判断权交还给最懂语义的模型,但执行权留在系统手里。 模型说删哪条就删哪条,零额外 LLM 调用;删错了也是模型自己的决定,不会出现"系统硬删把关键信息删了"的事故。

  • 成本:零 LLM 调用(模型用工具决定,本身就在推理过程中)
  • 可逆性:不可逆(物理删除)
  • 缓存影响:比较大——删消息等于后面所有消息位移,前缀断裂
  • 粒度:单条消息

注意,这是管道里第一把"硬清除"——真删。为什么敢放这么靠前?因为删不删、删哪条,是模型自己选的,语义安全性由模型自己保证。


第三刀:过期工具结果的两种清法(MicroCompact)

Snip 是模型主动管的。但很多工具结果(比如半小时前的 grep、两小时前的 Read)模型自己都忘了,不会主动删。

这就得靠 MicroCompact 自动清理——而且它有两套刀法,看缓存热不热来选。

刀法 A:时基清理(缓存冷了再动手)

// src/services/compact/timeBasedMCConfig.ts
//
// 触发条件:距离上次主线程 assistant 消息的间隔 > gapThresholdMinutes
// 默认值:
//   - gapThresholdMinutes: 60
//     (服务器 1h cache TTL 保证过期,不会因误触发而 cache miss)
//   - keepRecent: 5
//     (保留最近 5 个工具结果,其余清除)
//
// 原理:保留最近 N 个 tool_result,清除更旧的。
// 清除方式:将 content 替换为 "[Old tool result content cleared]"。

哎,为什么非得等 60 分钟?一过期就清不行吗?

有了:得等缓存先冷。服务器端 prompt cache 有 TTL(约 5 分钟默认 / 1 小时长缓存),闲置超过阈值缓存必然过期。既然 cache 已经冷了,下次请求本来就要全部重新 Prefill,不如顺手把旧工具结果清掉,减少重算的数据量。

时机掐得很准——在缓存"反正要失效"的那个点上动手,清除行为不造成额外损失。

// src/services/compact/microCompact.ts
//
// 原理:服务器端 prompt cache 有 TTL(约5分钟),闲置超过阈值后缓存必然过期。
// 既然 cache 已经冷了(下次请求必然全部重新 Prefill),不如把旧 tool_result
// 内容清除掉,减少需要重新 Prefill 的数据量。
//
// 副作用:修改了消息内容 → 必然 cache miss(但 cache 本该已经冷了)

刀法 B:cache_edits(缓存热的时候,怎么删都不破坏前缀)

时基清理只在缓存冷了才动手。那缓存还热的时候呢?想清理旧工具结果又不敢动消息体——一动前缀就断,KV Cache 全废。

哎,想删又不能改消息体,这不是死局吗?

有了:Anthropic 提供了 cache_edits API。消息体一个字节都不改,原封不动发出去保前缀命中;删除指令作为一个独立块附在请求末尾,告诉服务器:"解码的时候,跳过 cache_reference 指向的那几块。"

// src/services/compact/microCompact.ts
//
// 缓存编辑微压缩路径——使用 cache_edits API 不破坏前缀。
//
// - 不修改 messages(直接返回原数组),替换信息通过 cache_edits 在 API 层生效
// - registerToolResult → 将新 tool_result 注册到全局状态
// - 计数触发:每轮检查是否超过阈值,超了生成删除块
// - createCacheEditsBlock → 生成 API 格式的 cache_edits 块
// - 此能力仅 Anthropic API 支持,DeepSeek/Kimi 等国内模型不支持 cache_edits

不仅不改消息体,还得保证 cache_edits 块本身的位置也稳定——不然块的位置变了,前缀还是会断。于是有了 pin 机制:

pinCacheEdits:把 cache_edits 块固定到某条 user 消息的某个位置,后续每轮 API 请求都在同样的索引位置重新插回去。位置稳了,前缀就稳了。

这是全管道里最"软"的一刀——连消息结构都不动,纯 API 层魔法。代价是只支持 Anthropic,别的模型只能回退到时基清理。

同一件事(清理旧工具结果),根据缓存温度用两种完全不同的刀法——冷了就硬替换(反正要重算),热了就用 API 层软删(保前缀命中)。设计者对"缓存温度"的敏感,可见一斑。


第四刀:折叠旧版本的文件内容(ContextCollapse)

工具结果清了,但还有一类冗余:同一个文件 Read 了五遍,五份结果都留着,大部分内容是重复的。

哎,同一个文件读了五遍,五份结果都留着太浪费;但哪份是最新的、哪几份可以遮起来?

有了:投影折叠。REPL 原文一丝不动,另建一份 collapse store 存"替换规则",每次发 API 前由 projectView() 拿着规则实时投影出视图——旧版本的 tool_use/tool_result 对,替换成一句 [文件已修改,当前内容见最新 Read]

// src/query.ts  阶段 1d:Context Collapse
//
// 【三层数据模型】
//   REPL 数组(完整历史,只增不减,永不修改)
//     ↓
//   collapse store(折叠规则持久化日志,Map<UUID, 替换文本>)
//     ↓
//   projectView()(每次发 API 前即时投影 → messagesForQuery)
//
// 【与 autocompact 的区别】
//   - autocompact:生成 LLM 摘要,全局替换历史(不可逆)
//   - contextCollapse:保留历史结构,用替换文本遮蔽过时内容(可逆)

每条折叠规则带个 fingerprint(文件路径:sha256=xxx),用来检测折叠有没有过期——用户要是在两轮之间用 vim 改了文件,hash 对不上,规则自动作废,原样展示。

顺带一说:还有个 ABA 取舍——文件改了一顿又改回原样,hash 重新匹配,折叠"错误地"恢复。但概率极低,后果也不过是模型多读一次文件,不值得引入更复杂的设计。好的设计不是没漏洞,是算清楚每个漏洞的代价。

这把刀排在 1d,正好卡在 AutoCompact(1e,不可逆摘要)的正前方。注释说得直白:

在 autocompact 之前运行,这样如果 collapse 让上下文低于阈值,autocompact 就是空操作——保留细粒度上下文,而不是单个摘要。

可逆的投影折叠,永远优先于不可逆的 LLM 摘要。


第五刀:后台预先写好的摘要(SessionMemoryCompact)

前面四刀都用完了,token 还超,就得动真格的——摘要。

哎,真要让 LLM 做摘要了。可等爆了再摘,用户等着不说,还可能失败——有没有更稳的办法?

有了:平时就写好。别等压的时候才临时摘,后台子代理每 5-8 轮增量更新一份 session-memory.md,压的时候直接读文件,零 API 调用、约 50ms。

这份摘要不是自由写的,它有一个固定的 9 分区模板

# Session Title          ← 对话标题
# Current State           ← 当前在做什么(压缩后最重要的一节)
# Task specification      ← 用户要建什么,设计决策
# Files and Functions     ← 重要文件及其作用
# Workflow                ← 常用命令和执行顺序
# Errors & Corrections    ← 犯过的错误和修复方式
# Codebase and System      ← 系统组件和架构
# Learnings               ← 哪些有效,哪些无效
# Key results             ← 用户要求的精确输出
# Worklog                 ← 逐步操作记录
// src/services/SessionMemory/prompts.ts
export const DEFAULT_SESSION_MEMORY_TEMPLATE = `
# Session Title
_A short and distinctive 5-10 word descriptive title..._

# Current State
_What is actively being worked on right now?..._

...(共 10 个分区)
`

哎,模板结构为什么是固定的?让模型自由发挥不好吗?

有了:结构刚性换推理稳定性。每次压缩后 LLM 看到的认知框架是一样的——它知道 Current State 在第二个标题下面、Errors 在第六个,找信息的路径是可预测的。模板标题换来换去,LLM 每次压缩后都要重新"摸地图",推理路径就飘了。

这是 AutoCompact 的首选路径——成本几乎为零,效果也还过得去。只有 session-memory 不可用的时候,才回退到真正调 LLM 的 compactConversation。


第六刀:LLM 摘要(AutoCompact)

终于到了大家最熟悉的这把——让 LLM 把历史总结成一段摘要,替换掉旧消息。

在 Claude Code 的管道里,它是 1e,最后一道预防式防线。前面五刀都没顶住,才轮到它。

// src/services/compact/autoCompact.ts
//
// 优先级:
//   1. trySessionMemoryCompaction(首选——读已写好的 session-memory,零 API)
//   2. compactConversation(回退——真正调 LLM 做摘要)
//
// 熔断器:连续失败 3 次停止尝试
//   MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3

而且它还有个熔断器——连续失败 3 次就再也不试了。

哎,失败几次很正常吧,为什么要熔断?

有了:压缩这件事本身就是"自救机制",如果自救还老失败,说明当前状态有问题,再重试只会让情况更糟——每试一次又产生一次新的 API 调用、又多几条消息、上下文又胖一圈。自救机制不能变成故障源,该收手时就收手。

到这把刀,就是最贵、最不可逆、最粗粒度的了:

  • 成本:一次完整 LLM 调用
  • 可逆性:完全不可逆(摘要替换掉了原始历史)
  • 缓存影响:全局断裂,几乎全废
  • 粒度:整段历史 → 一段摘要

能不掏就不掏。


第七刀:API 真爆了的紧急压缩(ReactiveCompact)

预防管道全上过了,API 还是返回 413(prompt too long)?那就是最后一道反应式防线了。

413 恢复链分三层,代价依次加重:

第 1 次 413
  → collapse_drain_retry(最轻):清空折叠规则,投影恢复完整 REPL
     (为什么先展开?因为不可逆压缩必须作用在完整状态上——
       被折叠遮住的内容如果进了摘要,就永久丢失了)
  → 大概率还是 413,继续

第 2 次 413
  → reactive_compact_retry(中等):基于 session memory 快速压缩
     (此刻输入已展开为完整消息,摘要基于完整真相)
  → 成功就过,失败继续

都不行
  → surface error(最重):浮出错误给用户,请手动 /compact
// src/query.ts  阶段 4:错误恢复链
//
// 【三层恢复策略,代价递增】
//   1. collapse_drain_retry(最轻):撤销 context-collapse 的投影,
//      用原始 REPL 内容重试。零 API 成本,但可能展开后仍 413。
//   2. reactive_compact_retry(中等):基于 session memory 快速压缩。
//   3. surface error(最重):恢复耗尽,不再尝试。
//
// 【防止死循环】每种恢复只尝试一次,
//   通过 transition.reason 和 hasAttemptedReactiveCompact 守卫。

注意第一层的 drain——就是上一篇我们聊了半天的"清空折叠反而让上下文变大"。放在整条管道里看就不奇怪了:它不是来"变小"的,是来为后面那把不可逆的刀,准备一份完整的输入

而且每一层都有守卫,保证只试一次——恢复链也不能死循环。


软清除的三层意图

七把刀看完了,你可能注意到一件事:大部分刀都是"软清除"——不是物理删掉,而是替换成某种占位符。

哎,占位符直接用空字符串不行吗?空的最省 token 啊。

有了:占位符不是为了"占个坑"而已,它承载了三层设计意图,一层比一层深:

第一层:结构保序 → 缓存稳定

这是最重要的一层。长对话每轮重发全部历史消息,物理删一条消息 → 后面所有消息的索引全部偏移 → 前缀断裂 → 几万 token 的 KV Cache 重算。

软清除用占位符替换内容,消息条数和位置不变 → 前缀稳定 → 缓存命中。

冻结铁律、cache_edits、pin 机制,全都是为了这同一个目标——保前缀,保缓存

第二层:语义连续 → 思维链不中断

占位符不只是占坑,还告诉 LLM 发生过什么:

[没有占位符——物理删除]
用户: 帮我重构 utils.ts
助手: 好的,我看到了代码,发现...
       ↑ LLM 困惑:我什么时候读的文件?读到了什么?

[有占位符——软清除]
用户: 帮我重构 utils.ts
助手: 调用 Read("utils.ts")
助手: [文件已修改,当前内容见最新 Read]  ← 占位符
助手: 好的,我看到了代码,发现...
       ↑ LLM 知道:我读过这个文件,虽然旧内容被折叠了

LLM 知道"某件事发生过",思维链是连续的。空字符串做不到这一点。

第三层:可恢复 → 给一个行动路径

最强的占位符不仅标记,还指向真实数据

占位符指向什么怎么恢复
<persisted-output> + 路径磁盘文件Read 工具取回完整内容
[文件已修改,当前内容见最新 Read]对话中最新的 Read 结果顺着最新 Read 找
[Old tool result content cleared]纯标记,无恢复路径

能恢复的,就给一条回去的路。

何时硬、何时软? 看缓存温度:

条件策略
缓存热(可能命中)→ 必须软cache_edits / ContextCollapse / ToolResultBudget
缓存冷(TTL 过期)→ 可以硬MicroCompact 时基(反正要重算)
模型主动选择删除 → 可以硬SnipCompact(模型自己决定)
所有廉价手段耗尽 → 必须硬AutoCompact(终极手段)

三条铁律,从最重要的开始:

  1. 缓存优先:能保缓存就保缓存 → 优先软清除
  2. 语义保底:即使缓存没了,也要留思维线索 → 占位符 ≠ 空字符串
  3. 可逆兜底:能指回原始数据就给恢复路径 → 路径引用或折叠指向

可迁移的设计原则

七把刀 + 三层软清除意图,收拢成一句话:

永远从最便宜的那档开始,够用即止。

不是"选一种最好的压缩算法",是排一条代价递增的队列,逐级触发——每上一级都是一次"要不要动用到更贵的手段"的判断。

这条思路不只适用于 LLM 上下文压缩。放到任何系统里都成立:

要清理资源 / 省空间 / 降延迟时,先问自己:
  1. 能不能只做"逻辑删除",不动真实数据?(软清除 / 投影)
  2. 能不能平时就预计算好,压的时候直接用?(SessionMemory 后台预写)
  3. 能不能在"反正要失效"的时机动手,不造成额外损失?(时基清理等缓存 TTL)
  4. 实在要删,能不能让最懂语义的那一方决定删什么?(Snip 模型自主)
  5. 最后那把最贵的刀,永远放在最后,而且要有熔断。

知识补习:

  • KV Cache / Prompt Cache:LLM Prefill 阶段计算的 K,V 矩阵可以复用,前提是请求前缀字节完全一致。这就是为什么"前缀稳定"比"内容少一点"重要得多——少一点内容省的 token,远比不上缓存断裂重算几万 token 的代价。
  • 逻辑删除 vs 物理删除:cache_edits 是经典的"逻辑删除不碰数据本身,用附加指令标记跳过",和数据库里 tombstone、软删除是一个路数——能用逻辑就别碰物理,副作用小得多。
  • Event Sourcing / 读时投影:ContextCollapse 的 collapse store 不存"当前状态",存"发生过哪些折叠决策";需要视图时重放规则。这就是 event sourcing 的微缩版——存事件,不存状态,状态是事件的折叠结果。
  • 熔断器(Circuit Breaker):AutoCompact 连续失败 3 次就停。熔断器的核心思想是——当一个操作反复失败时,继续重试只会让情况更糟,不如先歇一会儿。

本文的机制分析基于 Claude Code 源码。关键代码位于 src/query.ts(阶段 1a~1e 压缩管道、阶段 4 恢复链)、src/utils/toolResultStorage.ts(ToolResultBudget / 冻结铁律)、src/services/compact/microCompact.ts(MicroCompact 双路径)、src/services/api/claude.ts(cache_edits / KV Cache)、src/services/SessionMemory/prompts.ts(9 分区模板)、src/services/compact/autoCompact.ts(AutoCompact 优先级 / 熔断器)。