3.6 前缀缓存策略
前面提到,上下文工程是"带着镣铐跳舞"——镣铐就是前缀缓存。这条镣铐是什么形状,Claude Code 又如何在它的约束下把舞跳好,下面细看。
为什么需要前缀缓存?
每次 API 请求,服务端都需要对输入做一遍 KV Cache 计算(transformer 注意力机制的底层操作)。一次请求的完整输入可能有 100K-200K token——如果每次都从头算,延迟和成本都不可接受。
前缀缓存的原理:服务端记住上一次请求的 KV Cache 结果,下一次请求时,如果前缀完全一致,就直接复用之前的计算结果,只需要处理新增的部分。但这里有一个 transformer 架构层面的硬约束:前缀必须字节级完全一致才能复用 KV Cache。不是"差不多就行",而是任何一个字节的变化都会导致该位置之后的所有 KV Cache 失效。
缓存链:从系统提示词到对话内容
Claude Code 并不只是缓存系统提示词——它在 system 和 messages 两个层次显式设置缓存断点,串成一条完整的缓存链;工具数组因为渲染在 system 之前,被 system 的断点一并覆盖:
┌──────────────────────────────────────────────────────────────────────┐
│ 请求 N: │
│ │
│ [system 块 ← cache_control] [tools(随 system 缓存)] [历史消息... │
│ ~~~~~~缓存命中~~~~~~ ~~~~~~一并命中~~~~~~ msg1 msg2 msg3│
│ ← cache_control│
│ │
│ 请求 N+1: │
│ │
│ [system 块 ← cache_control] [tools(随 system 缓存)] [历史消息... │
│ ~~~~~~缓存命中~~~~~~ ~~~~~~一并命中~~~~~~ msg1 msg2 msg3│
│ ~~~~~~缓存命中~~~~~~│
│ msg4 msg5 │
│ ← cache_control│
│ ↑ 只有这部分 │
│ 需要计算 │
└──────────────────────────────────────────────────────────────────────┘第一个断点:系统提示词。splitSysPromptPrefix() 在系统提示词的静态/动态边界处标记 cache_control,使得核心指令部分可以跨用户共享缓存(详见下文的"分割策略")。API 的渲染顺序是 tools → system → messages,标在 system 块上的断点会把它前面的整个工具数组一并纳入缓存前缀——所以工具定义不必单独打断点也能被缓存。
第二个断点:消息数组。addCacheBreakpoints() 在最后一条消息上标记 cache_control。这意味着所有历史消息(上一轮及之前的)都在缓存前缀内,每轮只需处理新增的消息。
关于工具数组的断点:
toolToAPISchema保留了给工具 schema 打cache_control的能力(options.cacheControl),但在我们分析的这份快照里,主查询路径没有任何调用传入它(claude.ts那里只传deferLoading),源码里"工具 schema 携带缓存标记"那句注释描述的是意图、并未落地。所以实际生效的是 system 与 messages 两个断点,工具由前者覆盖。可选的服务端工具(如 advisor)排在工具数组末尾——按设计意图,工具断点应打在它们之前、让开关不破坏缓存;工具断点既然没接线,这层保护在快照里也就没有生效。
两个断点串联起来,实现了全链路缓存:一个持续 20 轮的对话,第 21 轮只需要处理最新的用户消息和工具结果,前面积累的 system + tools + 20 轮历史消息全部命中缓存。这是 Claude Code 能保持低延迟响应的核心原因之一。
细节:fire-and-forget 请求的缓存保护。某些次要请求(如后台的辅助查询)会把
cache_control标记在倒数第二条消息上,而不是最后一条。这样临时请求不会把自己的内容写入主缓存链,避免污染后续正常对话的缓存前缀。
细节:Assistant 消息的特殊处理。
cache_control只标记在消息的最后一个内容块上,但会跳过thinking和redacted_thinking块——这些块的内容不稳定,标记在它们上面会降低缓存命中率。
缓存稳定性:四层防御
全链路缓存的收益巨大,但也意味着缓存失效的代价极高——一次意外断裂,就可能让 100K+ token 的前缀全部重新计算。Claude Code 建立了四层防御来维护缓存稳定性。
第一层:系统提示词分割——最大化缓存共享
核心问题:系统提示词既包含全用户通用的内容(核心指令、安全规则、工具描述),也包含因用户而异的内容(CLAUDE.md 引用、MCP 工具指令、输出风格偏好)。如果整个系统提示词只能作为一个整体缓存,那么每个用户的缓存都不同——数百万用户就需要数百万份缓存,其中大部分内容是完全重复的。
解决方案:splitSysPromptPrefix()(src/utils/api.ts)通过 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记,将系统提示词在通用/专属的边界处切开,对两部分使用不同的缓存策略。
但这里有一个决策树,因为不是所有场景都能使用最优策略:
- 用户安装了 MCP 工具吗? 如果是,MCP 工具的 schema 因用户而异(不同用户安装了不同的 MCP server)。即使系统提示词文本相同,工具数组也不同——全局缓存无法生效。此时所有块降级为
scope: 'org'(组织内共享)。 - 全局缓存功能可用且边界标记存在? 如果是,这是最优路径:边界之前的静态内容使用
scope: 'global'(全球所有用户共享同一份缓存),边界之后的动态内容不缓存。 - 以上都不满足? 回退为所有内容使用
scope: 'org'。
| 场景 | 静态内容缓存 | 动态内容缓存 | 缓存共享范围 |
|---|---|---|---|
| 有 MCP 工具 | org | org | 组织内 |
| 无 MCP + 支持全局缓存 | global | 不缓存 | 全球 |
| 回退 | org | org | 组织内 |
第二层:会话级锁存——防止中途翻转
即使分割做对了,缓存仍然可能因为元数据的中途变化而失效。有两类特别隐蔽的风险:
风险 1:缓存过期时间变化
服务端缓存有过期时间(TTL):普通用户是 5 分钟,内部用户和付费订阅用户可以享受 1 小时。但 TTL 本身也是缓存键的一部分——如果一个用户在对话到第 10 轮时用量超标,系统将其从"1 小时"降级为"5 分钟",缓存键就变了,之前积累的缓存全部作废。
Claude Code 的解法:在会话开始时锁定缓存资格,中途不变。setPromptCache1hEligible() 在首次 API 调用时评估用户是否有资格使用 1 小时 TTL,结果缓存到会话结束。即使用户中途超标,TTL 也不会降级。
风险 2:API 请求头变化
Claude API 支持一些 beta 功能(如 fast-mode、cache-editing、thinking-clear 等),通过 HTTP 请求头启用。这些请求头也会影响服务端的缓存键——如果第 3 轮请求带了 fast-mode header,第 4 轮没带,缓存键就不同了。
而这些功能可能受 feature flag 控制,flag 随时可能在服务端切换。想象一下:用户正在编码,feature flag 突然关闭了 fast-mode,下一次请求就少了一个 header,50-70K token 的缓存立刻作废。
Claude Code 的解法:一旦某个 beta header 被首次发送,它就会在该会话的所有后续请求中持续发送——即使触发它的 feature flag 已经关闭。源码中管这叫"粘性锁存"(sticky-on latch),涉及四个 header:AFK 模式、快速模式、缓存编辑、思考清理。
两种锁存共享一个重置点:/clear 和 /compact 命令会同时重置所有锁存状态。这是合理的——这两个命令本身就会重建对话上下文,缓存必然要重新构建,锁存也就没有保护的必要了。
设计权衡:锁存牺牲了灵活性(无法中途关闭某个 beta 功能、无法中途降级 TTL),换来了缓存稳定性。在 100K+ token 缓存失效的高成本面前,这是值得的取舍。
第三层:工具数组与消息数组的排列策略
工具排列:设计意图是把可选的服务端工具(如 advisor)排在工具断点之后,让 /advisor 的开关只改变断点之后的一小段内容;不过如 3.6 所述,快照中工具断点并未接线,这层保护没有实际生效。MCP 工具通过延迟加载(defer_loading)机制,在 Tool Search 被调用之前不出现在工具数组中——与第一层的分割策略配合,减少因用户特有工具导致的缓存差异。
Cached Microcompact 的缓存感知:前面 3.4 提到 Microcompact 有两条路径。当缓存是"热的"(未过期)时,它不直接修改消息内容(那会破坏整个消息前缀的缓存),而是通过 cache_edits 指令告诉服务端"在缓存中把某些 tool_result 块删掉"。这样既清理了旧内容释放了空间,又不需要客户端重新上传和服务端重新处理整个前缀——这是消息层缓存和压缩机制之间的精妙配合。
第四层:缓存断裂检测——诊断安全网
有了前三层防御,缓存应该是稳定的。但"应该"和"实际"之间总有差距——Claude Code 建立了一个诊断系统来验证这些防御是否真正生效。
promptCacheBreakDetection.ts 实现了两阶段快照对比:
- API 调用前:记录当前所有影响缓存键的状态——系统提示词 hash、工具 schema hash(精确到每个工具)、beta header 列表、TTL 设置等
- API 调用后:检查响应中的
cache_read_input_tokens。如果比上次下降超过 5% 且 2000 token,判定为缓存断裂
检测到断裂后,系统自动归因到三种原因之一:
- TTL 过期:距上次请求超过了缓存时间窗口(用户离开太久)
- 客户端变更:通过对比前后的 hash diff,精确定位是哪个字段变了(甚至能定位到具体是哪个工具的 schema 变了)
- 服务端驱逐:客户端一切不变,但缓存仍然失效——说明是服务端主动驱逐了缓存
这个检测系统形成了一个改进闭环:源码注释里提到,第二层的锁存机制正是在检测系统发现特定 header 翻转打断缓存后才补上的。先有检测,发现问题,再建防御——这是工程上的正循环。
小结:Claude Code 的前缀缓存是一个全链路方案——system 与 messages 两层显式打了缓存断点,工具数组随 system 断点一并缓存,串联成一条完整的缓存链。四层防御机制(分割、锁存、排列策略、断裂检测)共同确保这条缓存链的稳定性。最终效果:无论对话进行到第几轮,每次请求只需处理最新的增量内容,前面积累的所有上下文都由 KV Cache 免费提供。