乐于分享
好东西不私藏

Claude Code 源码学习 – Prompt Cache

Claude Code 源码学习 – Prompt Cache

代码部分的梳理是通过 Claude Code + GLM-5.1 完成的,相关代码自己仔细读过若干遍进行理解。

什么是 Prompt Cache?
Prompt Cache 本质上其实就是大模型的前缀缓存,它缓存的不是模型对输入回答的结果,而是对话输入部分的 KV 矩阵,它是为了节省计算资源、加快模型请求首响而生。

下文中的 Prompt Cache 和 前缀缓存,可以认为是在说同一件事。

大模型处理请求时计算分为两步:Prefill(预填充) 和 Decode(解码)。Prefill 笼统的来说是对问题的理解,要根据输入问题计算 KV 矩阵,Decode 根据 Prefill 阶段的 KV 矩阵逐个 token 的生成输出(自回归)。
整个过程如下所示:
输入tokens → [Prefill:计算KV矩阵] → [Decode:逐token生成输出]                  ↑              这一步非常昂贵(O(n²)复杂度)              这就是Prompt Cache缓存的东西
当下一轮对话时,如果输入的前缀和之前的一样,服务器直接复用之前算好的 KV 矩阵,跳过重复计算。
我们可以稍微再细节一些,从 Transformer 架构的角度来看。
当前主流文本生成模型(如 GPT、Claude、豆包等)都是 Decoder-only 的 Transformer 架构,我们把这个架构画出来,来看看 Prefill 对应哪个环节。
输入 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 响应时间。

回到具体的使用上来说,Prompt Cache 对用户最大的意义就是节约了成本,以 Anthropic 为例,在 OpenAPI 的返回上有非常明确的体现,其中的 usage 字段有如下表现:
"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% 缓存命中率的核心设计。

Prompt Cache 处理主流程
我们接下来看看 Claude Code 是如何精心构造 prompt 的结构以实现最大的缓存利用率。
整体调用链在 src/services/api/claude.ts 中,如下:
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(  namestring,  computeComputeFn,): SystemPromptSection {  return { name, compute, cacheBreakfalse }}// 不可缓存的 section——每轮都重新计算,会破坏前缀缓存export function DANGEROUS_uncachedSystemPromptSection(  namestring,  computeComputeFn,  _reasonstring,   // 必须解释为什么需要破坏缓存): SystemPromptSection {  return { name, compute, cacheBreaktrue }}

可以看到 DANGEROUS_uncachedSystemPromptSection 标记为 cacheBreak: true,每一轮都必须重新计算,如果它的内容发生了变化就会破坏前缀缓存,而静态的内容 cacheBreak 标记为 false,调用 resolvedDynamicSections 方法时会走缓存(只计算一次)。

核心拆分 splitSysPromptPrefix(api.ts:321-435

这是缓存策略的核心策略,它会根据三种情况返回不同结构的 SystemPromptBlock[],直接决定了 Claude Code 向大模型发起什么样的请求。

情况1,有 MCP 工具(skipGlobalCacheForSystemPrompt=true)

返回最多3个块,全部用 scope: org:

[  { text: attributionHeader, cacheScopenull },  { 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, cacheScopenull },      // 计费头  { text: systemPromptPrefix, cacheScopenull },     // CLI 前缀  { text: staticJoined,       cacheScope'global' }, // 静态内容(跨用户共享!)  { text: dynamicJoined,      cacheScopenull },     // 动态内容]

这是最优的情况,静态内容用 scope: global,意味着所有的 Claude Code 用户共享同一个缓存条目,只要静态提示词的字节不变服务端就可以复用 KV 缓存,节省大量计算和延迟。

情况3,默认模式(采用三方模型供应商或者边界缺失)

这是兜底的逻辑分支,返回3个块:

[  { text: attributionHeader, cacheScopenull },  { 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,  enablePromptCachingboolean,  options?,): TextBlockParam[] {  return splitSysPromptPrefix(systemPrompt, {    skipGlobalCacheForSystemPrompt: options?.skipGlobalCacheForSystemPrompt,  }).map(block => ({    type'text' as const,    text: block.text,    ...(enablePromptCaching &&      block.cacheScope !== null && {        cache_controlgetCacheControl({          scope: block.cacheScope,          querySource: options?.querySource,        }),      }),  }))}

这里其实就是将 SystemPromptBlock 转换为向 API 发送的结构,只有当 block.cacheScope != null 并且开启了前缀缓存时这个 block 才会打上 cache_control 标记。

这个方法有个参数 enablePromptCaching 也很重要,直接决定是否开启前缀缓存,它的逻辑如下:

export function getPromptCachingEnabled(model: string): boolean {  // Global disable takes precedence  if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING)) return false  // Check if we should disable for small/fast model  if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_HAIKU)) {    const smallFastModel = getSmallFastModel()    if (model === smallFastModel) return false  }  // Check if we should disable for default Sonnet  if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_SONNET)) {    const defaultSonnet = getDefaultSonnetModel()    if (model === defaultSonnet) return false  }  // Check if we should disable for default Opus  if (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
fork 不需要写入自己的缓存条目(它执行完任务之后生命周期也就结束了,所以后续不会继续请求),所以标记移到倒数第二条消息——那是与主线程共享前缀的最后一个点,标记在那里意味着写入是一个空操作(Mycro 已经有这个条目了)。

getCacheControl() 缓存控制对象生成(claude.ts:358-374)

export function getCacheControl({  scope,  querySource,}: {  scope?: CacheScope  querySource?: 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-in  if (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<stringCachedSchema>()

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,               // 工具 schema  querySource,               // 查询来源  model,                     // 模型 ID  fastMode,                  // fast mode header(锁存值)  globalCacheStrategy,       // global 缓存策略  betas,                     // beta headers  autoModeActive,            // 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 = true  setAfkModeHeaderLatched(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亿吨的二氧化碳排放量。