上一篇讲 resume 时,我们一直在区分 UI、transcript、 runtime view 和 API view。到了 prompt cache,这个区分更重要。缓存命中的对象不是“聊天窗口”, 也不是“磁盘里的 transcript”,而是 provider 看到的一段请求前缀。 只要这段前缀的字节形状、工具 schema、系统块、cache marker、beta headers 或模型参数变了, 客户端本地再怎么觉得“还是同一段会话”,服务端也可能没法复用上一轮的处理结果。

这篇的结论先说清:Claude Code 的性能设计,是一套请求形状纪律。 cache_control 只是 marker;真正让 marker 有用的,是把动态内容挪到尾部, 把会话级开关 latch 住,把 fork 子任务做成共享前缀加短尾巴,把 compact 和工具结果替换做成不会让旧前缀漂移的投影。

阅读契约:本篇只追客户端源码可见的 cache discipline: getCacheControl() 怎样生成 marker,addCacheBreakpoints() 怎样放置请求级 cache marker,动态工具和 beta header 怎样避免打穿前缀, fork path 怎样复用父级 cache-safe params,cached microcompact 和 content replacement 怎样缩短上下文但保持旧前缀稳定,最后再看 cache break detection 用响应计数验证是否真的断了。

证据边界同前几篇一样:产品和 API 契约参考 Anthropic prompt caching 文档 与 Claude Code 官方概览; 源码层来自 Rememorio/claude-code 公开镜像。 本文只讲客户端源码能观察到的请求形状、缓存标记、计数和恢复边界,不推断 provider 内部如何实现 KV 或缓存替换。

一、先把 prompt cache 看成“前缀契约”

如果把 prompt cache 想成一个布尔开关,很快会误读源码。Claude Code 的 API 请求不是一段单纯的 text prompt; 它至少由 system blocks、message list、tool schemas、model、thinking、extra body、beta headers 和 cache marker 共同构成。缓存复用依赖的是这些东西在可缓存前缀内足够稳定,而不是某个配置值孤立地为真。

源码里最直接的 marker 生成在 getCacheControl()。 它返回 { type: "ephemeral" },并在符合条件时加上 ttl: "1h", 在全局缓存策略允许时加上 scope: "global"。这说明 marker 本身并不神秘: 它只是告诉 API 哪个断点值得缓存。真正难的是:下次请求来到同一个断点时,前面的结构还像不像上次。

1.1 TTL 也会影响请求形状

一个容易忽略的细节是 TTL。should1hCacheTTL() 不只是查一次 feature flag。源码注释明确说,用户资格和 allowlist 会被 latch 到 bootstrap state, 目的是避免会话中途 GrowthBook 缓存或 overage 状态变化,导致 cache_control 从 5 分钟形状变成 1 小时形状,或者反过来。对 provider 来说,这不是“同一段文本只是缓存时长不同”, 而是请求里的 cache marker 变了。

Prompt cache API view 图,展示 UI transcript、runtime view、API view 和 provider 看到的 stable prefix
Prompt cache 命中的是 API view 里的稳定前缀;transcript 和 UI 只是它的上游材料。

1.2 system 和 message 各有自己的 marker 位置

system prompt 的 marker 在 buildSystemPromptBlocks() 里加。源码还留下一个很硬的提醒:不要再为了缓存多加 block,否则可能被 API 拒绝。 message 侧则由 addCacheBreakpoints() 控制。它每个请求只放一个 message-level cache_control marker; 普通请求放在最后一条 message,skipCacheWrite 场景则把 marker 放到倒数第二条, 让共享前缀可以被读到,但 fork-only 尾巴不留下新的缓存尾部。

shape-level example

main turn:
  S = system + tools + stable history
  U = current user turn
  cache marker sits after S + U

fire-and-forget fork:
  S = shared parent prefix
  F = fork-only task suffix
  cache marker sits after S, not after S + F
这只是形状示例,不表示 provider 内部索引。源码事实是:skipCacheWrite 会改变 marker index,让短生命周期 fork 读共享前缀而不写入自己的尾巴。

二、动态内容必须躲开稳定前缀

一个 coding agent 的请求很容易变:工具池会变,MCP server 会重连,Chrome 工具可能晚到, slash command 和 skill 会让可用能力变化,advisor 可能临时打开。简单做法是每次把这些动态材料都塞进系统提示词前面。 但这样每多一个工具、少一个工具,稳定前缀就被改写了。

Claude Code 的源码里有很多“为了不动前缀”的小设计。 在 API 请求装配中,deferred tools 原本会被 prepend 成一段 meta user message;注释说如果启用 delta attachment,就改走持久化的 deferred_tools_delta,因为每次工具池变化都 prepend 会 bust cache。 Chrome tool-search instructions 也是同样逻辑:Chrome 晚连接时,如果把说明 append 到 system prompt,会打穿 prompt cache, 所以启用 delta 后挪到 mcp_instructions_delta

MCP 和 global cache 的关系也在请求装配处显式处理。 源码判断 全局缓存可用时,如果将要渲染的是用户级、动态的 MCP tool,就不把 marker 放在 system prompt 的全局缓存路径上。 这不是性能保守,而是边界正确:一个用户的动态工具段不应该被当成全局稳定材料。

第二篇 Memory 里提到的 CLAUDE.md、auto memory 和 agent memory 都在提醒我们:长期材料要先看 prompt 入口,再谈 cache。CLAUDE.md / rules 不是 system prompt 本体, 而是经 prependUserContext() 变成 meta user message,进入 API-bound messages 的前部; auto memory 的内容 entrypoint 可被注入 user context 或 relevant-memory attachments,写入/检索规则则来自 memory prompt section。 于是判断标准就很简单:投影文本稳定时,它可以和其他前缀材料一起被复用; memory 文件、auto-memory index 或其他 user-context 文本在重载后不同,provider 看到的前缀也可能变化。 所以“memory 是动态的”不是 cache 失效的充分条件,被投影到 API view 的那段文本变了才是关键。

动态来源 源码处理 保护的东西
memory / user context 通过 prependUserContext() 进入 messages 前部。 长期规则稳定时可随前缀复用;重载或写入后可能改写前缀。
deferred tools 优先走 delta attachment,避免每次 prepend 新 meta message。 messages 前缀不因工具池发现顺序改变。
Chrome tool instructions 连接晚到时移出 system prompt append 路径。 system prompt 不被临时说明打穿。
MCP tools 动态 MCP tool 会改变 global cache strategy。 全局缓存只覆盖真正稳定的 system prompt。
advisor tool 追加在已有 tool schemas 后面。 开关 advisor 只扰动尾部,而不是整段工具前缀。
beta headers 会话级 sticky-on latch。 中途开关不改变 server-side cache key。

2.1 headers 也要稳定

很多 cache miss 不来自正文,而来自请求配置。Claude Code 在 API 装配中 latch 动态 beta headers: AFK、fast mode、cache editing、thinking clear 都有各自的 sticky 逻辑。 注释写得很清楚:一旦某个 header 首次发送,就尽量在本会话余下请求中继续发送, 以免中途 toggle 改变 server-side cache key。

这里的微妙之处是“header 稳定”和“行为仍可动态”并不冲突。 fast mode 的 header 会 latch,speed="fast" 仍然会按 cooldown 动态决定; cache editing 的 beta header 会 latch,但是否真的发送 cache_edits 仍由当前 feature 和 provider 条件决定。 也就是说,源码把“会影响 cache key 的外形”和“本轮实际行为”拆开了。

三、Fork path:为了 cache,故意不像普通 subagent

第七篇讲 subagent 时,我们已经看过普通子代理会有自己的 system prompt、工具上下文、权限模式和消息链。 但 fork path 不是普通子代理。它的目标不是“换一个人格去做任务”,而是从父会话分出一条短尾巴, 让子任务尽量复用父查询已经写好的缓存前缀。

这个设计在 AgentTool.tsx 里写得很直白:fork child 继承父级 system prompt,而不是使用 FORK_AGENT 自己的 system prompt; buildForkedMessages() 会复制父 assistant message 和占位 tool results,再追加每个 child 自己的 directive。 到 组装 runAgent 参数 时,fork path 还会传父级工具数组、父级消息和 useExactTools: true

runAgent() 继续沿着这个选择走:useExactTools 的 fork child 继承父级 isNonInteractiveSessionthinkingConfig。普通 subagent 则会关闭 thinking 来控输出成本。 如果把 fork child 当普通 subagent 处理,system、tools、thinking config 任意一个不一致,缓存前缀都可能从第一次不同处断掉。

Fork path 复制父级前缀图,展示子查询共享 system prompt、tools、thinking config 和 messages prefix,尾部追加 placeholder tool_results、fork directive 和 child task
Fork path 的关键不是“又启动一个 agent”,而是“共享父级稳定前缀,只替换任务尾巴”。

四、Compact:缩短上下文时,也不能随手改旧前缀

上下文压缩看起来和 prompt cache 天然冲突:既然 compact 会改 messages,它不是必然让缓存失效吗? Claude Code 的答案分两层。第一层是 full compact:它把旧会话总结成新的 compact boundary, 之后的请求自然进入一条新前缀。第二层是 microcompact / content replacement: 它们想缩短模型可见的工具结果,但尽量不让已经缓存过的旧前缀字节突然漂移。

full compact 也有 cache sharing。compact 主流程 读取 tengu_compact_cache_prefix,默认让 compact summary 的 fork 路径复用主会话 prompt cache。 这和前面的 fork path 是同一套思想:summary request 是短生命周期 side path, 它应该读取主会话稳定前缀,而不是把自己当成完全陌生的请求从头开始。

cached microcompact 更直接。microcompactMessages() 先处理时间触发:如果离上一条 assistant message 太久,源码认为服务端缓存可能已经过期, 这时直接走 content-clear 老工具结果,因为完整前缀反正要重写。只有在 main thread、feature 打开、模型支持、 缓存仍有意义时,才进入 cached microcompact。

cached path 的关键注释在 cachedMicrocompactPath(): 它不修改本地 message content;cache_referencecache_edits 在 API 层添加;它追踪工具结果并排队 cache edits。 真正有删除发生时, 源码会 queue cache_edits, 通知 cache break detection 这是预期下降,然后返回未改动的 messages。

Prompt cache compact 图,展示 full compact 建新边界和 cached microcompact 通过 cache_reference、cache_edits 编辑缓存
Compact 会重写运行时边界;cached microcompact 则尽量把删除动作推到 API 层,避免本地历史直接漂移。

4.1 cache_edits 不是“本地删消息”

addCacheBreakpoints() 里可以看到 cache editing 的真正入口。 它先把正常 message 转成 API param,再在启用 cached microcompact 时 去重并插入 cache_edits block。 后面还会给处于缓存前缀内的 tool_result 加 cache_reference。 所以它保护的是 provider-side cached prefix 的可编辑路径,而不是把本地 transcript 改得更短。

这也是为什么它要把 pending edits 在 定义 paramsFromContext 之前消费一次paramsFromContext 可能被 logging、retry、fallback 多次调用; 如果每次调用都重新消费 pending edits,同一轮重试里的请求形状就会自己不一致。

五、Tool result replacement:变小,也要字节一致

工具输出太大时,Claude Code 会把大结果持久化到本地,只把 preview 放进模型视野。 但这里也不能“看心情替换”。一个旧 tool_result 第一轮没被替换,第二轮突然替换; 或者第一轮替换成某段 preview,第三轮重新从磁盘读出一段略有差异的 preview, 都会改变已经缓存过的前缀。

所以 toolResultStorage.ts 明确维护 ContentReplacementStateseenIds 记录哪些 tool result 已经过预算判断; replacements 记录已经持久化并替换的 preview 字符串。 注释强调:被替换过的结果以后通过 Map lookup 重新应用,不做文件 I/O,保证 byte-identical。 fork 这类共享缓存前缀的路径还会 clone replacement state,让子任务和父任务做出同样选择。

预算执行时,候选结果先按历史决策分区: 以前替换过的必须复用同一段 preview;以前见过但没替换的冻结,不允许后面再替换; 只有从未见过的 fresh 结果才参与新一轮预算选择。 enforceToolResultBudget() 的注释把这个约束说得更直接:以前处理过的消息每轮只重新应用缓存 replacement, 新消息才做预算判断。

Prompt cache tool result replacement 图,展示 transcript、ContentReplacementState、runtime projection、API view、resume 和 fork
工具结果替换影响的是 runtime projection 和 API view;transcript 还要能支撑 resume 重建同一套替换状态。

5.1 resume 和 fork 都要带上替换历史

这和上一篇 resume 连在一起。替换历史如果恢复错了,旧 transcript 能读回来, 但下一轮 API view 会变成另一条前缀。源码在 ContentReplacementState 的生命周期注释里把 main thread、subagent、resumeAgentBackground 和 cache-sharing fork 都列出来; 目的不是为了本地文件管理好看,而是让“哪些 tool result 应该还是 preview”这件事跨 turn、resume、fork 都稳定。

还有一个更小的并发边界: 源码在 await 前后维护 seenIdsreplacements 的原子性。 如果并发读者看到某个 id 已经 seen,但 replacement 还没写入,它可能把这个结果误判成 frozen, 发送完整内容;而主线程发送的是 preview。两条路径对同一个历史前缀给出不同 wire content,结果就是 cache miss。

六、Cache break detection:别靠感觉判断命中

前面讲的都是“怎样避免 break”。但真正上线后,客户端还要回答另一个问题: 如果 cache read tokens 突然掉了,到底是 system prompt 改了、tools 变了、TTL 到了、headers 变了, 还是 provider 侧路由或 eviction?Claude Code 为此有一套 cache break detection。

PreviousState 里保存 system hash、tools hash、cache_control hash、tool names、model、fast mode、global cache strategy、 beta headers、overage、cached microcompact、effort、extra body、上一轮 cache read tokens 和 pending changes。 getTrackingKey() 又把 main thread、SDK、tracked agents 和 compact 分开;compact 会共享 repl_main_thread 的 tracking key,因为它共享同一套 cache-safe params。

API 请求前,query 装配调用 recordPromptState()。 注意它传的是已经 latch 后的 header 状态,并且排除了 defer_loading tools, 因为这些工具不会进入实际 prompt。也就是说 detection 不是拿“用户开了什么开关”做猜测, 而是记录“本次真的会影响 server-side cache key 的客户端可见形状”。

API 响应后,源码用 response usage 调 checkResponseForCacheBreak()。 这个函数先比较上一轮和本轮的 cache_read_input_tokens, 如果下降不明显就直接清空 pending changes;如果 cached microcompact 刚做过 cache deletion, 它把下降视为预期现象。 真正疑似 break 时,才把 model、system、tools、cache_control、betas、effort、extra body 等变化整理成原因。

Prompt cache break detection 图,展示 PreviousState、cache_read tokens、expected deletion、suspected break 和原因清单
Cache break detection 的作用,是把“前缀为什么没活下来”从体感问题变成可归因的请求形状问题。

七、把十篇串起来:最后看的是同一条 runtime 账本

到这里,Claude Code 系列的第一轮路线就闭合了。第一篇建立运行主线; 第二篇讲 Memory 怎样投影进 prompt 入口;第三篇讲上下文怎样进入 model-visible view; 第四、五篇讲工具和权限怎样把副作用收口;第六篇讲命令、skills、MCP 怎样把能力引入本轮; 第七篇讲 subagent 和 fork;第八篇讲 hooks 的生命周期插槽;第九篇讲 transcript 和 resume; 第十篇把这些都收回到性能层。

机制 如果只看功能 从 prompt cache 看
memory layer 保存长期规则。 不是 transcript;它投影进 user context 后会成为前缀纪律的一部分。
context management 把长会话变短。 决定哪些内容留在稳定前缀,哪些变成尾部或摘要。
tools / MCP 给模型更多能力。 工具 schema 和动态说明会改变 cache key,必须控制注入位置。
permissions / hooks 拦截副作用。 hooks 结果和运行时状态不能无意污染缓存前缀。
subagent / fork 把任务分出去。 普通 subagent 隔离上下文;fork child 共享父级稳定前缀。
resume / replacement 继续旧会话。 恢复的不只是 messages,还要恢复会影响 wire prefix 的替换决策。
compact / microcompact 压缩上下文。 full compact 建新前缀;cached microcompact 尽量编辑旧缓存而不改本地历史。

所以,Claude Code 的 prompt cache 不是最后加上的性能小技巧,而是倒逼很多 runtime 设计变得精细的约束。 请求形状越稳定,长会话、fork、compact、resume 和工具输出替换才越像一套系统; 请求形状一漂,前面再多工程都可能在下一次 API 调用时重新付账。

参考源码与文档