Claude Code 源码学习 – Prompt Cache
代码部分的梳理是通过 Claude Code + GLM-5.1 完成的,相关代码自己仔细读过若干遍进行理解。
下文中的 Prompt Cache 和 前缀缓存,可以认为是在说同一件事。
输入tokens → [Prefill:计算KV矩阵] → [Decode:逐token生成输出]↑这一步非常昂贵(O(n²)复杂度)这就是Prompt Cache缓存的东西
输入 tokens: [x1, x2, x3, x4] (提示词,如 "今天天气")│┌────▼─────┐│ Token ││ Embedding│└────┬─────┘│┌────▼─────────────────────────────────────┐│ Transformer Block (×N) ││ ┌────────────────────────────────────┐ ││ │ Multi-Head Self-Attention │ ││ │ (带因果掩码 + KV 缓存机制) │ ││ └────────────────┬───────────────────┘ ││ Add & Norm ││ ┌────────────────────────────────────┐ ││ │ Feed-Forward Network (FFN) │ ││ └────────────────┬───────────────────┘ ││ Add & Norm │└────────────────┬────────────────────────┘│┌────▼────┐│ LM ││ Head │└────┬────┘│next token logits → 采样得到 x5
Prefill 阶段 = 处理整个输入提示词的一次完整前向传播,它遍历所有的 Transformer Block,在每个 Block 内部完成以下操作:
1、计算每个 token 位置的 Query、Key、Value(通过线性层);
2、对每个 token 计算带因果掩码的自注意力(即每个 token 只能看到它自己以及之前的 token,不能看到未来 token);
3、得到每个 token 的新表示,并存入 KV 缓存(KV Cache),这个缓存就是前缀缓存的核心;
在架构图中,预填充阶段横跨所有 Block,但最关键的“缓存生成”发生在每个 Block 的 Multi-Head Self-Attention 内部。具体来说:
-
当计算完第
i个 token 的注意力时,模型会把该 token 的 Key 向量和 Value 向量保存在该 Block 的 KV 缓存中。 -
在预填充阶段结束时,所有输入 token 的 KV 都已经存好。
作为对比,我们再看看解码阶段,即生成后续所有 token 的阶段:
-
每次只输入1 个新 token(上一轮生成的 token)。
-
对于这个新 token,也要计算它的 Q、K、V,但它的 K、V 也会被添加到 KV 缓存中(和预填充阶段的 KV 矩阵是两个独立的矩阵)。
-
在计算自注意力时,它可以直接使用缓存中之前所有 token 的 K、V,不需要重新计算之前 token 的注意力。
前缀缓存,缓存了预填充阶段计算出来的、属于固定提示词部分的 KV 值。当新的请求与之前某个请求的提示词前缀完全一致时(比如系统指令、角色设定等),模型可以直接复用这些 KV,完全跳过预填充阶段,直接从解码阶段开始生成。
在架构图上,前缀缓存相当于直接取用之前保存在各 Block 中的 Key 和 Value 矩阵(针对前缀部分),而不必重新走一遍所有 Block 的自注意力计算,从而节省了大量的计算资源,也提升了模型的首 token 响应时间。
"usage": {"input_tokens": 100, // 未命中的token数(全价)"cache_creation_input_tokens": 8000, // 新建缓存的token数(全价,但只付一次,下次复用)"cache_read_input_tokens": 50000 // 命中缓存的token数(便宜,具体看折扣)}
多厂商 Prompt Cache 对比
我们对比一下 OpenAI、火山方舟,还有 Anthropic 自己的 OpenAPI,看看他们的前缀缓存能力是以什么样的方式体现的。
OpenAI
OpenAI 采用了全自动、启发式的前缀缓存能力。
全自动,指的是即使用户什么都不做,只要 prompt 长度大于 1024 token,OpenAI 将会开启前缀缓存能力,如果成功匹配到缓存的前缀,那么在 API 返回中就会有如下的表现:
"usage": {"prompt_tokens": 2006,"prompt_tokens_details": {"cached_tokens": 1920 // 关键字段,显示命中的token数},"completion_tokens": 300,"total_tokens": 2306}
可见,cached_tokens 字段就是本次请求命中前缀缓存带来的收益(缓存命中以 128 token 为增量)。
启发式,是指这个自动的缓存的命中率并不保证它一定很高,OpenAI 通常会使用前 256 tokens 做哈希,将用户请求路由到多台机器上(不共享缓存),因此没办法保证命中率。
为了解决此问题,OpenAI 在输入的 API 中提供了一个顶层参数:prompt_cache_key,用户可以根据自己的需要给该字段传值(字符串),OpenAI 将使用 prompt_cache_key 作为缓存键,结合前缀哈希优化路由,提升缓存命中率。
火山方舟
火山方舟采取了和 OpenAI 截然不同的思路,它显式的将前缀缓存封装为一个“资源”。
用户需要首先调用“创建上下文缓存 API(POST /api/v3/context/create)”,将希望被缓存的 messages 发送给火山,用户会得到一个 context_id,它关联到了这个新创建的缓存。
在进行后续的对话时,调用“上下文缓存对话 API(POST /api/v3/context/chat/completions)”,跟随增量 messages 显式的传入这个 context_id,火山的服务端看到的 messages 等于前次缓存的 messages + 本次传递的新增 messages,至少可以匹配到缓存部分的输入。
它的 API 中返回的 usage 字段如下:
"usage": {"prompt_tokens": 28,"completion_tokens": 4,"total_tokens": 32,"prompt_tokens_details": {"cached_tokens": 18 // 关键字段,显示命中的token数},"completion_tokens_details": {"reasoning_tokens": 0}}
火山方舟的实现方式相比于 OpenAI 有一个非常好的点,就是它明确把缓存和 messages 绑定到了一起(OpenAI 可能会存在 prompt_cache_key 和 messages 之间完全不匹配的情况),避免了误用导致的命中率降低。
Anthropic
Anthropic 官网上关于 Prompt Cache 的说明性文档:https://platform.claude.com/docs/en/build-with-claude/prompt-caching
Anthropic 所有的模型都支持前缀缓存能力,他们的 OpenAPI 提供了两种方式使用该能力:
1、Automatic caching,即自动模式,在请求中添加一个单独的 cache_control 顶层字段,系统将自动的在最后一个可缓存 block 上添加缓存断点(cache breakpoint),随着会话的增长将其向前移动。这种场景最适合多轮对话,自动缓存不断增长的会话消息。
{"model": "claude-opus-4-6","max_tokens": 1024,"cache_control": {"type": "ephemeral"},"system": "You are a helpful assistant that remembers our conversation.","messages": [{"role": "user", "content": "My name is Alex. I work on machine learning."},{"role": "assistant", "content": "Nice to meet you, Alex! How can I help with your ML work today?"},{"role": "user", "content": "What did I say I work on?"}]}
2、Explicit cache breakpoint,即手动模式,在相应的 content blocks 上放置 cache_control 标记(一次请求中最多允许设置4个 cache_control 标记),以便对缓存做更细粒度的控制。
{"model": "claude-opus-4-6","max_tokens": 1024,"cache_control": { "type": "ephemeral" },"system": [{"type": "text","text": "You are a helpful assistant.","cache_control": { "type": "ephemeral" }}],"messages": [{ "role": "user", "content": "What are the key terms?" }]}
Explicit cache breakpoint 模型下 cache_control 标识可以在 system、tools、messages 等部分使用,如下:
{"model": "claude-opus-4-6","system": [...], // 可以在这里设置缓存断点"tools": [...], // 可以在这里设置缓存断点"messages": [ // 可以在这里设置缓存断点{ "role": "user", "content": "..." },{ "role": "assistant", "content": "..." }]}
在 Claude Code 中使用的便是 Explicit cache breakpoint 模式,我们将在后面结合源码来看具体是如何使用这种机制的。
系统默认的缓存 ttl 是5分钟,如果用户希望更长的 ttl,Anthropic 支持将其设置为1小时,缓存的费用也会有相应的调整(更贵一些,但缓存重建次数少,命中率也高,总体而言可能更便宜)。
Anthropic 前缀缓存详解
Anthropic 采用了 cache_control 的方式显式的控制缓存的方式,每个 cache_control 标记都会在服务端创建一个独立的缓存条目。两个标记就是两个条目:
token 位置: 0 ──────── 15K ──────── 80K│ │ ││ 标记 A (global) 标记 B (org)│ │ ││ 缓存条目 A 缓存条目 B│ scope = global scope = org│ KV[0..15K] KV[0..80K]
其中,scope = global 的缓存是可以单独存一份的,它对所有人可用,而 scope = org 的缓存只在当前用户下可用。
缓存条目 A 的 scope 是 global,存储的是 KV[0..15K](system 静态部分)。用户 B 的请求只要前 15K 字节和用户 A 完全一致,就命中条目 A。
用户 A 的请求:system(静态15K) → 创建缓存条目 A,scope=global
用户 B 的请求:system(静态15K) → 命中缓存条目 A,不需要重新计算前 15K
缓存条目 B 是 KV[0..80K],它依赖于前面的所有内容。要命中条目 B,必须前 80K 字节全部匹配。因为 15K 之后的内容每个用户不同(不同的 CWD、CLAUDE.md、MCP 配置),所以条目 B 只有同一用户的下一轮请求才能命中。
用户 B 第一次发请求,完整的 token 流:
位置 0 ─── 15K ─── 30K ─── 80K│ │ │ ││ 标记A 标记B ││ │ │ ││ KV[0..15K] KV[0..80K]服务端计算过程:位置 0..15K: 命中全局缓存条目 A → 跳过计算,直接加载 KV 矩阵 ✓位置 15K..80K: 从缓存 A 的 KV 矩阵继续往后计算 → 得到 KV[0..80K]→ 存为缓存条目 B(scope=org)
用户 B 的下一轮请求:
位置 0 ─── 15K ─── 30K ─── 80K ─── 85K│ │ │ │ ││ 标记A 标记B' ││ │ │ ││ KV[0..15K] KV[0..85K]服务端计算过程:位置 0..15K: 命中全局缓存条目 A → 跳过 ✓位置 15K..80K: 命中 org 缓存条目 B → 跳过 ✓(新增对话前缀匹配)位置 80K..85K: 全新计算(新增的 5K tokens)→ 更新缓存条目 B' 为 KV[0..85K]
这个过程非常清晰的阐述了”前缀缓存”这个名字的由来,每个缓存条目都是从位置 0 开始的前缀,长的条目可以复用短的条目作为起点,避免从头计算。
Claude Code 的 prompt 结构
Claude Code 中整个 prompt 的组成结构如下:
POST /v1/messages 请求体 (JSON body)││ 服务端缓存键计算方向 ──────────────────────────────►│├─ model: "claude-opus-4-6"│├─ system[] ──────────────────────────────────── cache_control│ ├─ [0] "x-anthropic-billing-header: ..." → 无(含设备指纹,每用户不同)│ ├─ [1] "You are Claude Code, ..." → 无│ ├─ [2] 静态规则(doing tasks / tools / ...) → {type:'ephemeral', scope:'global'}│ └─ [3] 动态上下文(memory / MCP / env / ...) → 无│├─ tools[] ───────────────────────────────────── 服务端按配置自动设断点│ ├─ BashTool { name, description, input_schema }│ ├─ FileReadTool { ... }│ ├─ FileEditTool { ... }│ ├─ ...(按 name 字典序排列)│ └─ mcp__xxx__yyy { ... }(MCP 工具排在内置工具之后)│├─ messages[] ────────────────────────────────── cache_control│ ├─ [0] user: "帮我看看这个 bug"│ ├─ [1] assistant: [text, tool_use, ...]│ ├─ [2] user: [tool_result, ...]│ ├─ ...│ └─ [N] 最后一条消息(user 或 assistant) → {type:'ephemeral', ttl:'1h'}│ 唯一的消息级标记│├─ betas: [ ──────────────────────────────────── 隐式参与缓存键│ "claude-code-20250219",│ "interleaved-thinking-2025-05-14",│ "prompt-caching-scope-2026-01-05",│ "redact-thinking-2026-02-12",│ ...(latch 机制保证会话内不变)│ ]│├─ tool_choice: { type: "auto" }├─ metadata: { user_id: "..." }├─ max_tokens: 16384├─ thinking: { type: "enabled", budget_tokens: ... }├─ context_management: { ... } ← 仅 betas 包含对应 header 时才发├─ output_config: { ... }└─ speed: "fast" ← 仅 fast mode 实际生效时才发
以上只是 JSON Body 中的顺序,它不代表 Anthropic 服务端真正的处理逻辑,而且 JSON 规范里对象是无序的。
Claude Code 在 toolSchemaCache.ts:4-5 中明确说明了在服务端计算时 tools 的位置,是位于 system 之前的:
Tool schemas render at server position 2 (before system prompt),so any byte-level change busts the entire ~11K-token tool blockAND everything downstream.
因此服务端最终计算前缀时的顺序如下:
┌─────────────────────┐│ tools[](内置工具) │ ← 服务端在最后一个内置工具后设 global 缓存断点├─────────────────────┤│ tools[](MCP 工具) │ ← 用户特有,不参与 global 缓存├─────────────────────┤│ system[0] billing │ ← 服务端识别并跳过│ system[1] CLI prefix │ ← 已知锚点│ system[2] 静态规则 │ ← cache_control: scope:'global'│ system[3] 动态上下文 │ ← cache_control: scope:'org' 或无├─────────────────────┤│ betas[] │ ← 隐式参与缓存键├─────────────────────┤│ messages[0] ││ messages[1] ││ ... ││ messages[N] │ ← cache_control: ephemeral└─────────────────────┘
为什么是这个排列顺序呢?为了最大限度地利用缓存,必须把所有可能在多轮对话中保持不变的静态内容(如system 和 tools)放在最前面,把动态变化的部分(如 messages)放在最后,这是 Claude Code 实现超过 95% 缓存命中率的核心设计。
queryModel() ← API 请求入口├─ getSystemPrompt() ← 组装系统提示词数组(静态 + 动态边界)│ └─ resolveSystemPromptSections() ← 区分可缓存/不可缓存 Section├─ toolToAPISchema() ← 渲染工具 schema(会话级缓存)├─ buildSystemPromptBlocks() ← 将系统提示词拆分为带 cache_scope 的块│ └─ splitSysPromptPrefix() ← 核心拆分逻辑:静态块→global,动态块→null├─ addCacheBreakpoints() ← 在消息层添加唯一的 cache_control 标记│ ├─ userMessageToMessageParam()│ └─ assistantMessageToMessageParam()└─ recordPromptState() ← 缓存断裂检测(前置快照)└─ checkResponseForCacheBreak() ← 缓存断裂检测(后置对比)
下面细说一下每个部分的内容。
一、System Prompt 分层缓存策略
静态/动态边界标记(prompts.ts:114-115)
有这么一段代码:
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY ='__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
这是一个哨兵字段,插入在系统提示词数组的静态部分和动态部分之间的(在 system[2] 和 system[3] 之间),它的作用是告诉下游的 splitSysPromptPrefix() 方法,边界之前的内容对所有用户都一样,可以用 scope: global 缓存策略,边界之后的内容是会话特异的,不能全局缓存。
这里有一个细节,system[0] 这部分叫做 billing header,包含设备指纹信息,每个设备都是不同的,那么它位于 scope: global 之前,岂不是全局缓存会把它也包括进去?
服务端有一个缓存策略配置 (claude_code_system_cache_policy),它知道 Claude Code 的请求结构。当遇到 scope: global 的 block 时,服务端并不是简单的以“第一个字节到当前 block”这一段直接做前缀哈希,而是:
1、识别并跳过“x-anthropic-billing-header:”行,服务端能够识别并剥离这个已知的、格式固定的元数据标记;
2、匹配已知锚点:”You are Claude Code, Anthropic’s official CLI for Claude.” ,这个字符串是 system.ys 中定义的 CLI_SYSPROMPT_PREFIXES 中的一个元素,是所有 Claude Code 用户共享的固定前缀之一,服务段忽略掉前面会变化的部分,从锚点开始计算真正的缓存键。
这就是为什么客户端代码要严格把 attribution header 拆成独立 block、不让它混入静态内容里的原因。
系统提示词组装(prompts.ts:444-577)
getSystemPrompt()return [// --- Static content (cacheable) ---getSimpleIntroSection(outputStyleConfig),getSimpleSystemSection(),getSimpleDoingTasksSection(), // 如果 outputStyleConfig 允许getActionsSection(),getUsingYourToolsSection(enabledTools),getSimpleToneAndStyleSection(),getOutputEfficiencySection(),// === BOUNDARY MARKER - DO NOT MOVE OR REMOVE ===...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),// --- Dynamic content (registry-managed) ---...resolvedDynamicSections,].filter(s => s !== null)
系统提示词为什么要这样排列呢?
1、前面的7个函数是静态函数,它们不依赖会话的状态,对所有用户输出同样的字符串。
2、SYSTEM_PROMPT_DYNAMIC_BOUNDARY 是上面提到的哨兵字段,对系统提示词做了分隔。
3、resolvedDynamicSections 是动态的内容,包含了 memory、MCP 指令、语言偏好等每个会话、每个用户不同的内容。
符合前面说的,静态内容在前动态内容在后的原则,利于使用前缀缓存。
动态 Section 的缓存注册机制(systemPromptSections.ts)
// 可缓存的 section——计算一次后在整个会话中复用export function systemPromptSection(name: string,compute: ComputeFn,): SystemPromptSection {return { name, compute, cacheBreak: false }}// 不可缓存的 section——每轮都重新计算,会破坏前缀缓存export function DANGEROUS_uncachedSystemPromptSection(name: string,compute: ComputeFn,_reason: string, // 必须解释为什么需要破坏缓存): SystemPromptSection {return { name, compute, cacheBreak: true }}
可以看到 DANGEROUS_uncachedSystemPromptSection 标记为 cacheBreak: true,每一轮都必须重新计算,如果它的内容发生了变化就会破坏前缀缓存,而静态的内容 cacheBreak 标记为 false,调用 resolvedDynamicSections 方法时会走缓存(只计算一次)。
核心拆分 splitSysPromptPrefix(api.ts:321-435)
这是缓存策略的核心策略,它会根据三种情况返回不同结构的 SystemPromptBlock[],直接决定了 Claude Code 向大模型发起什么样的请求。
情况1,有 MCP 工具(skipGlobalCacheForSystemPrompt=true)
返回最多3个块,全部用 scope: org:
[{ text: attributionHeader, cacheScope: null },{ text: systemPromptPrefix, cacheScope: 'org' },{ text: restJoined, cacheScope: 'org' },]
这么做的原因是 MCP 工具是用户特有的,不同用户的工具集不同,如果强行用 scope: global,服务端会因为前缀不一致而 cache miss。
情况2,Global cache 模式 + 边界标记存在(仅限于直连 Anthropic 模型)
这里判断是否直连 Anthropic 模型的标准:
export function shouldUseGlobalCacheScope(): boolean {return (getAPIProvider() === 'firstParty' &&!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS))}
这种情况下返回最多4个块:
[{ text: attributionHeader, cacheScope: null }, // 计费头{ text: systemPromptPrefix, cacheScope: null }, // CLI 前缀{ text: staticJoined, cacheScope: 'global' }, // 静态内容(跨用户共享!){ text: dynamicJoined, cacheScope: null }, // 动态内容]
这是最优的情况,静态内容用 scope: global,意味着所有的 Claude Code 用户共享同一个缓存条目,只要静态提示词的字节不变服务端就可以复用 KV 缓存,节省大量计算和延迟。
情况3,默认模式(采用三方模型供应商或者边界缺失)
这是兜底的逻辑分支,返回3个块:
[{ text: attributionHeader, cacheScope: null },{ text: systemPromptPrefix, cacheScope: 'org' },{ text: restJoined, cacheScope: 'org' },]
第三方模型不一定支持 cache = global 的策略,因此统一用 org 级别。
查了一下,目前大部分供应商支持不了 Anthropic 这样的完整策略,有些供应商甚至连 cache_control 标记都不支持,这种情况下如果供应商支持前缀缓存那么就走用户级缓存(等同于 scope = org),具体的效果就不能做保证。
从 SystemPromptBlock 到 API TextBlockParam(claude.ts:3213-3237)
export function buildSystemPromptBlocks(systemPrompt: SystemPrompt,enablePromptCaching: boolean,options?,): TextBlockParam[] {return splitSysPromptPrefix(systemPrompt, {skipGlobalCacheForSystemPrompt: options?.skipGlobalCacheForSystemPrompt,}).map(block => ({type: 'text' as const,text: block.text,...(enablePromptCaching &&block.cacheScope !== null && {cache_control: getCacheControl({scope: block.cacheScope,querySource: options?.querySource,}),}),}))}
这里其实就是将 SystemPromptBlock 转换为向 API 发送的结构,只有当 block.cacheScope != null 并且开启了前缀缓存时这个 block 才会打上 cache_control 标记。
这个方法有个参数 enablePromptCaching 也很重要,直接决定是否开启前缀缓存,它的逻辑如下:
export function getPromptCachingEnabled(model: string): boolean {// Global disable takes precedenceif (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING)) return false// Check if we should disable for small/fast modelif (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_HAIKU)) {const smallFastModel = getSmallFastModel()if (model === smallFastModel) return false}// Check if we should disable for default Sonnetif (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_SONNET)) {const defaultSonnet = getDefaultSonnetModel()if (model === defaultSonnet) return false}// Check if we should disable for default Opusif (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_OPUS)) {const defaultOpus = getDefaultOpusModel()if (model === defaultOpus) return false}return true}
可见,如果全局关闭了前缀缓存,那么会返回 false,否则需要针对每一种模型单独去关闭,如果没有显式的关闭 Claude Code 还是会选择开启前缀缓存的。
二、消息层的缓存断点
唯一标记策略(claude.ts:3063-3211)
这个逻辑在 addCacheBreakpoints 方法中,核心逻辑如下:
// 每个请求恰好一个消息级 cache_control 标记const markerIndex = skipCacheWrite? messages.length - 2 // fork:倒数第二条(共享前缀点): messages.length - 1 // 主线程:最后一条const result = messages.map((msg, index) => {const addCache = index === markerIndex// 只有 markerIndex 处的消息会加上 cache_control...})
Anthropic 的 KV cache 系统使用“最后一处 cache_control 标记”来确定缓存写入的深度。代码注释引用了服务端源码(page_manager/index.rs):
“Mycro 的 turn-to-turn 淘汰机制会释放所有不在
cache_store_int_token_boundaries中的缓存前缀位置的 local-attention KV 页。如果有两个标记,倒数第二个位置会被保护下来并多存活一轮——但没有任何东西会从那个位置恢复。”
精确使用一个标记是最优策略——既确保缓存命中最大化,又避免了无谓的 KV 页占用。
skipCacheWrite 的 fork 优化
Fork 是 Claude Code 中处理复杂任务的一种子 Agent(但不是典型的子 Agent,无 subagent_type),它用的不是自己的专用 system prompt,而是和主对话用了共同的上下文,只是跑在后台而已。
对于 fire-and-forget 的 fork 子代理,skipCacheWrite=true:
const markerIndex = skipCacheWrite ? messages.length - 2 : messages.length - 1
getCacheControl() 缓存控制对象生成(claude.ts:358-374)
export function getCacheControl({scope,querySource,}: {scope?: CacheScopequerySource?: QuerySource} = {}): { type: 'ephemeral'; ttl?: '1h'; scope?: CacheScope } {return {type: 'ephemeral',...(should1hCacheTTL(querySource) && { ttl: '1h' }),...(scope === 'global' && { scope }),}}
默认 TTL 是5分钟(没有 ttl 字段,服务端默认5分钟),符合条件时升级到1小时,而且仅在直连 Anthropic 模型且无 MCP 工具时使用 scope: global。
1小时 TTL 的决策逻辑 should1hCacheTTL(claude.ts:393-434)
function should1hCacheTTL(querySource?: QuerySource): boolean {// Bedrock 3P 用户通过环境变量 opt-inif (getAPIProvider() === 'bedrock' &&isEnvTruthy(process.env.ENABLE_PROMPT_CACHING_1H_BEDROCK)) {return true}// 资格锁存——防止会话中途 overage 状态翻转导致 TTL 变化// TTL 变化会破坏服务端提示缓存(~20K tokens 每次翻转)let userEligible = getPromptCache1hEligible()if (userEligible === null) {userEligible =process.env.USER_TYPE === 'ant' ||(isClaudeAISubscriber() && !currentLimits.isUsingOverage)setPromptCache1hEligible(userEligible)}if (!userEligible) return false// 查询源必须匹配 GrowthBook 白名单let allowlist = getPromptCache1hAllowlist()...return querySource !== undefined &&allowlist.some(pattern => pattern.endsWith('*')? querySource.startsWith(pattern.slice(0, -1)): querySource === pattern)}
里面的核心逻辑是对 userEligible 的处理,它被锁定在会话状态中。原因是:如果会话中途用户的状态从”有资格”变为”超量使用”,TTL 从 1h 翻转到 5m,会直接破坏服务端缓存(因为 cache_control 对象变了,导致前缀哈希不匹配),浪费约 20K tokens 的缓存条目。
三、Tool Schemas 的稳定性缓存
会话级 Tool Schema 缓存(toolSchemaCache.ts)
// 会话范围的渲染工具 schema 缓存// 工具 schema 在服务端位置 2(系统提示词之前),// 所以任何字节变化都会破坏整个 ~11K token 的工具块及下游所有内容const TOOL_SCHEMA_CACHE = new Map<string, CachedSchema>()
API 请求中 Tool Schema 位于系统提示词之前。如果工具描述每轮都变化(比如 GrowthBook 特性门控翻转——是个远程控制的A/B测试平台、MCP 重连导致描述变化等),会导致约 11K tokens 的缓存失效。通过在会话级别缓存渲染后的 schema,锁定首次渲染的字节——会话中途的 GrowthBook 刷新不再破坏缓存。
工具排序稳定性(tools.ts:345-367)
在 assembleToolPool() 方法中:
// 按 name 字典序排序每个分区,保持内置工具作为连续前缀// 服务端的 claude_code_system_cache_policy 在最后一个前缀匹配的内置工具后// 放置全局缓存断点;扁平排序会把 MCP 工具交错到内置工具之间,// 每当 MCP 工具排在现有内置工具之间时就会使所有下游缓存键失效const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)return uniqBy([...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),'name',)
工具的排列顺序会直接影响 API 请求体的字节序列。如果排序不稳定(比如 MCP 工具随机插入内置工具之间),每次 MCP 的变化都会导致整个工具前缀变化。先排序内置工具,再追加 MCP 工具,确保内置工具部分的字节对所有用户一致。
四、Cache Scope 的条件判断链
这部分代码在 betas.ts:227-232,如下:
// Global-scope 提示缓存仅限 firstParty。Foundry 被排除,因为 GrowthBook// 从未将 Foundry 用户纳入推出实验——实验数据是 firstParty-only 的。export function shouldUseGlobalCacheScope(): boolean {return (getAPIProvider() === 'firstParty' &&!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS))}
对应的 beta header:
// 总是发送 beta header 以便 1P 使用。没有 scope 字段时 header 是空操作if (includeFirstPartyOnlyBetas) {betaHeaders.push(PROMPT_CACHING_SCOPE_BETA_HEADER)}
如果使用了其他的供应商,就不会,也没必要包含 beta header。
五、缓存断裂检测
这部分代码位于 promptCacheBreakDetection.ts 中,它是一个两阶段的检测系统,用于诊断“为什么缓存突然断了”。
阶段1,请求前快照(recordPromptState)
每次调用 API 之前记录当前状态:
recordPromptState({system, // 系统提示词块toolSchemas, // 工具 schemaquerySource, // 查询来源model, // 模型 IDfastMode, // fast mode header(锁存值)globalCacheStrategy, // global 缓存策略betas, // beta headersautoModeActive, // AFK 模式 header(锁存值)isUsingOverage, // 超量使用状态cachedMCEnabled, // 缓存编辑 header(锁存值)effortValue, // effort 值extraBodyParams, // 额外请求体参数})
与上一次的快照对比,检测哪些维度发生了变化。
阶段2,响应后对比(checkResponseForCacheBreak)
API 返回后,检查 cache_read_tokens 是否大幅下降,标准是下降比例大于5%而且绝对值大于2000 tokens。
const tokenDrop = prevCacheRead - cacheReadTokensif (cacheReadTokens >= prevCacheRead * 0.95 || tokenDrop < MIN_CACHE_MISS_TOKENS) {// 没有断裂return}// 断裂了!用 pending changes 解释原因
如果断裂了,会阶段阶段1的记录的变更信息生成解释,如“system prompt changed (+42 chars)”、“tools changed (+1/-0 tools)”、“possible 5min TTL expiry (prompt unchanged)”等。
六、Header 锁存机制
这部分代码位于 claude.ts:1405-1444 中。
某些 beta headers 一旦首次发送,就在整个会话中持续发送(即使原始条件不再满足),避免中途翻转破坏缓存:
// 一旦开启就锁存,会话中途切换不会改变服务端缓存键// 锁存在 /clear 和 /compact 时通过 clearBetaHeaderLatches() 清除let afkHeaderLatched = getAfkModeHeaderLatched() === trueif (!afkHeaderLatched && isAgenticQuery && shouldIncludeFirstPartyOnlyBetas() &&(autoModeStateModule?.isAutoModeActive() ?? false)) {afkHeaderLatched = truesetAfkModeHeaderLatched(true)}
就像前文说的,beta headers 也是缓存的一部分,它的变化会影响服务端的前缀哈希。如果中途某个 header 持续做翻转,每次翻转都会使约 50-70K tokens 的缓存条目失效。锁存机制就是为了确保一旦 beta headers 发送就保持不变,尽可能命中缓存。
一点启发
我们都知道 Anthropic 不但是家努力提升大模型能力的公司,他们在挖掘模型潜力的路上也一直走在前面,从2024年的 MCP,再到2025年的 Skills,都体现出他们在这条路上的不断探索。
Claude Code 中的 Prompt Cache 机制,说实话相比于它出众的编码能力,其实不算一个多么亮眼的功能,但他们依旧在这个“细分领域”做了深入的挖掘,达成了超过 95% 的缓存命中率,节省了大量资源。
对于业界其他公司来说,最大的启发我觉得是两点:
1、做产品需要比别人多想一步、先走一步,当其他人还投入在“做出了某一产品功能”时,你可能已经在深度优化它的路上。
2、在产品中,产品能力是第一生产力,技术的顺位要靠后一些,但是产品规模做起来之后一定要从技术视角多思考,可能一个细节上的微妙设计就能给用户和自己带来巨大的收益。
附一个2006年关于 Window XP 系统的新闻(有删改):
微软此前曾表示,部署新一代操作系统 Windows Vista,每台PC每年可节省50-70美元的能源消耗。而据 Foreign Policy 刊登的一篇文章显示,Vista 的节能功能每年可以轻松地为全球节省50亿美元的费用,并少排放4500万吨的二氧化碳。
据悉,微软主要是通过解决 Windows XP 中的三个问题来实现 Vista 的该节能功能的。首先,XP 的节能选项并不是默认的;其次,睡眠模式存在缺陷,即任何程序可以阻止系统进入睡眠模式;第三,就是系统管理员无法或很难通过网络为其他用户设置节能功能。
也就是说,因为微软的一点低级错误,Windows XP 这五年给全球带来了250亿美元的经济损失,以及2.25亿吨的二氧化碳排放量。
夜雨聆风