07-Prompt-Cache
第 7 篇:Prompt Cache — 跨模块的缓存策略如何降低 API 成本
本篇是《深入 Claude Code 源码》系列的第 7 篇。我们将从一个横切视角,解析 Prompt Cache 机制如何贯穿 System Prompt、对话循环、上下文管理三大模块,以及 Fork Agent 如何通过精密的参数对齐实现跨进程缓存共享。
为什么需要理解 Prompt Cache?
调用大模型 API 最大的成本来自 input tokens。在一个典型的 Claude Code 交互式会话中,每次用户提问都会把完整的 System Prompt(约 2 万 token)、工具定义(几千 token)、以及之前所有的对话历史一起发送给 API。如果对话进行了 50 轮,每轮平均 5 万 input tokens,那一个会话就消耗 250 万 input tokens。
Anthropic 的 Prompt Caching 机制可以将 重复前缀 的成本降低 90%。服务端会缓存请求前缀(System Prompt + 工具定义 + 消息历史前缀),只要下次请求的前缀字节完全一致,就可以从缓存中读取,而非重新处理。
但”字节完全一致”这个要求极其严苛 —— 哪怕多一个空格、换一个 JSON 字段顺序、改一个 cache_control 的 scope,整个缓存就失效了。 Claude Code 的源码中充斥着对这个约束的防御性设计。本篇将揭示这些设计的全貌。
本篇将回答三个核心问题:
- 缓存是怎么标记的? —
cache_control标记的放置策略与作用域选择 - 缓存是怎么保护的? — 从 System Prompt 分段到 Cached Microcompact,如何避免缓存失效
- 缓存是怎么共享的? — Fork Agent 如何通过
CacheSafeParams实现跨线程缓存命中
一、Prompt Cache 的基本原理与标记策略
1.1 cache_control 标记:告诉 API “请缓存到这里”
Anthropic 的 Prompt Caching 通过在请求中的特定位置放置 cache_control: { type: 'ephemeral' } 标记来工作。API 服务端会缓存从请求开头到最后一个 cache_control 标记之间的所有内容。
Claude Code 在两个层级放置缓存标记:
1 | graph TD |
注意:
toolToAPISchema()的接口支持cacheControl选项(utils/api.ts:129),但当前主查询路径调用它时并未传入cacheControl(services/api/claude.ts:1235-1245)。源码中有一条遗留注释 “toolSchemas (which carries the cache_control marker)”(claude.ts:1388),但这与当前代码行为不一致 —— 工具数组上目前没有cache_control标记。尽管如此,工具定义的字节稳定性仍然至关重要:它们是 API 请求前缀的一部分,任何变化都会导致缓存失效,这也是toolSchemaCache存在的原因(详见第五节)。
第一层:System Prompt 块上的标记
1 | // services/api/claude.ts:3213-3237 |
每个 System Prompt 块可以独立设置 cacheScope('global'、'org' 或 null),不同 scope 有不同的缓存共享范围。
第二层:消息历史上的标记
1 | // services/api/claude.ts:3063-3106 |
关键设计:每个请求只放一个消息级 cache_control 标记。注释中解释了原因 —— API 服务端的 KV 缓存页管理器(page_manager/index.rs)只会保留最后一个 cache_control 位置的本地注意力 KV 页。如果放两个标记,倒数第二个位置的页面会被保护一个额外的 turn 却永远不会被恢复,浪费缓存空间。
1.2 缓存作用域:Global vs Org vs 无标记
getCacheControl() 函数根据条件返回不同的缓存控制参数:
1 | // services/api/claude.ts:358-374 |
三种作用域的含义:
| 作用域 | 含义 | wire 上的表现 | 适用内容 |
|---|---|---|---|
global |
所有用户共享缓存 | { type: 'ephemeral', scope: 'global' } |
System Prompt 中不变的静态部分 |
org |
同一组织内用户共享 | { type: 'ephemeral' }(省略 scope 字段) |
System Prompt 中组织级别相同的部分 |
null(无标记) |
不设置缓存 | 不添加 cache_control |
每次请求都不同的动态内容 |
注意 getCacheControl() 只在 scope === 'global' 时才在 wire 上显式发送 scope 字段(claude.ts:372)。org 是 Claude Code 内部的抽象概念(cacheScope: 'org'),在实际 API 请求中表现为省略 scope —— 这是 Anthropic API 的默认缓存行为(组织级隔离)。
1.3 缓存 TTL:5 分钟 vs 1 小时
默认的 ephemeral 缓存 TTL 是 5 分钟。但对于符合条件的用户(Anthropic 内部员工或付费订阅用户且未超限),可以获得 1 小时的 TTL:
1 | // services/api/claude.ts:393-434 |
注意 latch(锁存)模式:TTL 相关的判断结果在 session 级别被锁定,即使中途用户超出配额或 GrowthBook 配置更新,都不会改变已经生效的 TTL。这防止了 mid-session 的 TTL 翻转(从 1h 变成 5m 或反过来),因为 TTL 翻转会改变 cache_control 的序列化字节,导致缓存失效。
二、System Prompt 的缓存分块策略
2.1 SYSTEM_PROMPT_DYNAMIC_BOUNDARY:静态与动态的分界线
System Prompt 由多个 section 组成(详见第 4 篇),其中一些是所有用户共享的(如核心指令、工具使用规范),另一些是会话特定的(如 Git 状态、CLAUDE.md 内容、语言设置)。如果把它们合并成一个块做缓存标记,任何动态部分的变化都会导致整个 System Prompt 的缓存失效。
Claude Code 的解决方案是插入一个 边界标记:
1 | // constants/prompts.ts:114-115 |
这个标记被插入在 System Prompt 的静态 section 和动态 section 之间:
1 | // constants/prompts.ts:568-577 |
2.2 splitSysPromptPrefix():按边界拆分为缓存块
splitSysPromptPrefix() 根据边界标记将 System Prompt 拆分为最多 4 个块,并为每个块分配不同的 cacheScope:
1 | // utils/api.ts:321-435 |
根据不同条件,有两种主要的拆分策略(以及一个降级的 MCP 分支):
| 模式 | 条件 | 块划分 |
|---|---|---|
| Global cache 模式 | 1P 用户 + 有边界标记 + 无非 defer 的 MCP 工具 | Attribution(null) → Prefix(null) → 静态内容(global) → 动态内容(null) |
| 有 MCP 工具时 | 1P + 有非 defer 的 MCP 工具 | skipGlobalCacheForSystemPrompt=true,降级为 Attribution(null) → Prefix(org) → 其余内容(org) |
| 默认模式 | 3P 或无边界 | Attribution(null) → Prefix(org) → 其余内容(org) |
Global cache 模式的核心思想是:静态部分用 global scope 标记,可以跨所有用户共享缓存。当 10 万用户发送相同的静态 System Prompt 前缀时,只需要缓存一份。动态部分不设缓存标记(null),因为它每个用户都不同。
当有非 defer 的 MCP 工具时,主查询路径会将 globalCacheStrategy 设为 'none'(claude.ts:1225-1229),并通过 skipGlobalCacheForSystemPrompt 使 System Prompt 降级到 org scope。这是因为 MCP 工具定义是用户级别的(不同用户配置的 MCP 服务器不同),使用 global scope 缓存 System Prompt 但工具定义不同,前缀无法整体匹配。
源码考古:
GlobalCacheStrategy类型定义中还保留了'tool_based'值(services/api/logging.ts:46),但当前主查询路径只会产生'system_prompt'或'none'。'tool_based'可能是早期设计的遗留,或为未来的工具级缓存标记预留。
2.3 systemPromptSection() vs DANGEROUS_uncachedSystemPromptSection()
System Prompt 的各个 section 通过注册机制管理缓存行为:
1 | // constants/systemPromptSections.ts:20-38 |
systemPromptSection() 创建的 section 只在第一次被求值,之后缓存在 bootstrap state 中复用。而 DANGEROUS_uncachedSystemPromptSection() 每次都重新计算 —— 函数名中的 DANGEROUS_ 前缀是刻意为之的:如果计算结果发生变化,会破坏 Prompt Cache。它要求调用者必须传入一个 _reason 参数解释为什么必须这样做。
1 | // constants/systemPromptSections.ts:43-58 |
三、Cached Microcompact:在保护缓存的同时清理上下文
3.1 问题:清理旧的工具结果 vs 保护缓存前缀
在长对话中,早期的工具调用结果(如 FileRead 读取的文件内容、Bash 的命令输出)会占用大量 token,但随着对话推进,这些结果已经不再有用。第 6 篇介绍了 Microcompact 机制来清理它们。
但传统的 Microcompact 有一个问题:修改消息内容会改变请求前缀的字节,导致缓存失效。假设服务端缓存了 [sysprompt, msg1, msg2(tool_result="file content..."), msg3] 这个前缀,如果我们把 msg2 的 tool_result 替换为 "[Old tool result content cleared]",前缀就不匹配了。
3.2 两条路径:Time-based vs Cached Microcompact
microcompactMessages() 函数实现了两条互斥的路径:
1 | // services/compact/microCompact.ts:253-293 |
Time-based 路径(缓存冷态):
当距离上一次 API 响应超过配置的时间阈值(默认 5 分钟,即缓存已过期)时,直接修改消息内容:
1 | // services/compact/microCompact.ts:446-529 |
Cached MC 路径(缓存热态):
当缓存仍然有效时,不修改消息内容,而是通过 Anthropic 的 cache_edits API 告诉服务端 “删除缓存中某些 tool_result 的内容”:
1 | // services/compact/microCompact.ts:305-399 |
3.3 cache_edits 和 cache_reference 的注入
Cached MC 的核心是两个 API 扩展字段:
cache_reference:标记在tool_result块上,告诉 API “这个结果可以被引用删除”cache_edits:放在 user message 中,指令 API “删除这些 cache_reference 对应的内容”
这些字段在 addCacheBreakpoints() 函数中注入:
1 | // services/api/claude.ts:3141-3208 |
Pinned edits 机制:一旦某个 cache_edits block 在某个位置被发送,后续所有请求都必须在相同的 message index 重新发送它。这是 API 的要求 —— cache_edits 本身也是请求前缀的一部分,如果只在一次请求中出现,前缀就不一致了。
1 | // services/compact/microCompact.ts:100-118 |
四、Fork Agent 的缓存共享:CacheSafeParams
4.1 为什么 Fork Agent 需要共享缓存?
Claude Code 会在后台 fork 出子 Agent 执行各种任务:session memory 提取、prompt suggestion 生成、post-turn summary 等。每个 fork 都会发起独立的 API 请求。如果每个 fork 的请求前缀都不同,就无法利用主线程已经创建的缓存 —— 意味着每个 fork 都需要重新处理几万 token 的前缀,这是巨大的浪费。
4.2 CacheSafeParams:必须与父线程一致的参数
CacheSafeParams 类型精确定义了哪些参数必须与父线程保持一致才能共享缓存:
1 | // utils/forkedAgent.ts:46-68 |
API 的缓存键由 5 个要素组成:system prompt + tools + model + messages prefix + thinking config。CacheSafeParams 把前 4 个显式携带(thinking config 从 toolUseContext.options.thinkingConfig 继承)。
4.3 saveCacheSafeParams() / getLastCacheSafeParams():跨 turn 参数复用
在主线程每个 turn 结束后,handleStopHooks 会保存当前的 CacheSafeParams:
1 | // utils/forkedAgent.ts:70-81 |
后续的 post-turn fork(如 prompt suggestion、session memory)直接读取这个 slot,无需每个调用方手动线程化参数。
4.4 runForkedAgent():使用缓存安全参数发起请求
1 | // utils/forkedAgent.ts:489-556 |
4.5 Fork Subagent 的 byte-exact prompt threading
Fork 子 Agent 有一个更激进的缓存优化 —— 直接复用父线程已渲染的 System Prompt 字节,而非重新调用 getSystemPrompt() 重新生成:
1 | // tools/AgentTool/forkSubagent.ts:44-71 |
为什么不能重新生成 System Prompt?因为 GrowthBook(A/B 测试框架)的状态可能在 fork 时已经从 “cold”(未初始化)变成了 “warm”(已加载),导致生成的 System Prompt 包含不同的 Feature Flag 分支 —— 即使语义完全一致,字节上的差异也会导致缓存失效。
4.6 contentReplacementState 的克隆:保证工具结果替换决策一致
forkedAgent.ts 的 createSubagentContext() 中有一个精妙的细节 —— 工具结果的替换状态(contentReplacementState)默认是克隆而非重新创建:
1 | // utils/forkedAgent.ts:389-403 |
背景:Claude Code 有一个工具结果预算管理机制,会把超大的工具结果(如读取了一个 1 万行的文件)替换为磁盘上的摘要版本。这个替换决策记录在 contentReplacementState 中。如果 fork 子进程用一个全新的 state,它在处理父线程的消息前缀时会做出不同的替换决策 —— 某些结果在父线程被替换了,在 fork 中却保留了原文 —— 导致序列化的消息字节不一致,缓存失效。
1 | // utils/toolResultStorage.ts:399-412 |
4.7 Fork 消息的 byte-identical 构造
Fork 子进程的消息前缀也需要精心构造以最大化缓存共享:
1 | // tools/AgentTool/forkSubagent.ts:95-105 |
所有 fork 子进程共享相同的 tool_result 占位符文本('Fork started — processing in background'),只有最后的 directive 文本块不同。由于 cache_control 标记放在最后一条消息的最后一个块上,而 directive 正好是最后一个块 —— 前面所有相同的部分都可以被缓存命中。
1 | // tools/AgentTool/forkSubagent.ts:91-93 |
4.8 skipCacheWrite:避免 fork 污染缓存
Fork 子进程通常是 fire-and-forget(执行完就丢弃),它的末尾消息不需要被缓存:
1 | // services/api/claude.ts:3084-3089 |
skipCacheWrite 模式将 cache_control 标记移到倒数第二条消息 —— 这恰好是父线程和 fork 共享的最后一个前缀点。在这个点写入缓存是一个 no-op(因为父线程已经缓存过了),而 fork 自己的末尾消息不会被写入 KVCC(Key-Value Cache Collection),避免了缓存污染。
五、Prompt Cache Break Detection:当缓存失效时
5.1 两阶段检测机制
即使有了上述所有保护措施,缓存仍然可能意外失效。Claude Code 实现了一个完整的缓存失效检测系统,帮助团队追踪和诊断缓存问题:
1 | // services/api/promptCacheBreakDetection.ts:247-430 |
检测器跟踪 12 个可能导致缓存失效的维度:
| 维度 | 说明 |
|---|---|
systemPromptChanged |
System Prompt 文本变化(去除 cache_control 后的 hash) |
toolSchemasChanged |
工具定义变化(逐工具 hash 对比) |
modelChanged |
模型切换 |
fastModeChanged |
Fast mode 开关 |
cacheControlChanged |
cache_control 的 scope 或 TTL 变化 |
globalCacheStrategyChanged |
Global cache 策略变化(当前仅使用 system_prompt / none) |
betasChanged |
Beta header 列表变化 |
autoModeChanged |
Auto mode 开关 |
overageChanged |
用量超限状态变化 |
cachedMCChanged |
Cached microcompact 开关 |
effortChanged |
Effort 级别变化 |
extraBodyChanged |
额外 API body 参数变化 |
5.2 Latch 模式:防止状态翻转导致缓存失效
源码中多处使用 latch(锁存) 模式来防止 session 中途的状态翻转破坏缓存:
1 | // services/api/claude.ts:117-132(bootstrap/state.ts 中的 latch 状态) |
以 AFK mode 为例:一旦 auto mode 在 session 中首次激活,AFK beta header 就被锁存为 true,即使后续 auto mode 被关闭,header 也不会被移除。这确保了 beta header 列表在整个 session 中保持稳定,不会因为 header 列表的变化导致缓存失效。
同样的模式应用于 cache editing header、fast mode header 等 —— 一旦开启就不关闭,直到 session 结束、/clear 或 /compact。 源码中 clearSystemPromptSections() 明确在 /clear 和 /compact 时同时清除 section cache 和 beta header latches(systemPromptSections.ts:60-68),因为 compact 后对话已被重新摘要,相当于开始一段新的缓存前缀。
5.3 Tool Schema Cache:防止工具定义抖动
工具定义是缓存键的重要组成部分。如果某个工具的 description(来自 tool.prompt())每次请求都微妙不同,缓存就会不断失效。
1 | // utils/api.ts:139-209 |
工具的 name、description、input_schema 在 session 内只计算一次并缓存。即使 GrowthBook 的 feature flag 在 session 中途翻转(比如 tengu_tool_pear 从 false 变成 true),也不会改变已经缓存的工具 schema。
六、缓存策略全景图
1 | graph TD |
七、可迁移的设计模式
模式 1:分层缓存标记 + 静态/动态分界
将请求内容分为”所有用户共享的静态部分”和”用户特定的动态部分”,用不同的缓存作用域标记。静态部分用最广的 scope(全局或组织级),动态部分不标记或用窄 scope。这种模式适用于任何有 prompt caching 的 LLM API。
适用场景:调用任何支持 prompt caching 的 LLM API 的应用,尤其是多用户共享基础 prompt 的 SaaS 产品。
模式 2:Latch(锁存)防止 mid-session 翻转
对于影响缓存键的配置(TTL、beta headers、feature flags),在 session 开始时评估一次并锁存,整个 session 期间不再重新评估。这避免了配置热更新或用户状态变化导致的缓存失效。
适用场景:任何使用 feature flags / A/B testing 且关心缓存一致性的系统。
模式 3:Fork 子进程的 byte-exact 参数传递
在 fork 子进程中,不要重新计算/渲染父线程已有的数据(如 System Prompt),而是直接传递父线程已经序列化好的字节。重新计算可能因为环境状态的微小差异产生不同的结果,破坏缓存前缀的一致性。
适用场景:任何需要并行执行多个 API 请求且希望共享缓存前缀的场景(如 agent swarm、并发搜索)。
下一篇预告
第 8 篇:Thinking 与推理控制 — 让模型”想”多少
我们将探索 Claude Code 如何通过 ThinkingConfig、adaptive / enabled / disabled 三种模式、以及 ultrathink 关键词来精细控制模型的推理深度。你会看到 thinking budget 与 output token 限制之间微妙的交互关系。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)