从设计者的角度理解源码–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(终极手段) |
三条铁律,从最重要的开始:
- 缓存优先:能保缓存就保缓存 → 优先软清除
- 语义保底:即使缓存没了,也要留思维线索 → 占位符 ≠ 空字符串
- 可逆兜底:能指回原始数据就给恢复路径 → 路径引用或折叠指向
可迁移的设计原则
七把刀 + 三层软清除意图,收拢成一句话:
永远从最便宜的那档开始,够用即止。
不是"选一种最好的压缩算法",是排一条代价递增的队列,逐级触发——每上一级都是一次"要不要动用到更贵的手段"的判断。
这条思路不只适用于 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 优先级 / 熔断器)。