Claude Code 源码解剖

Claude Code 的 Prompt Cache

从 cache_control 断点、系统提示词分段、TTL 和 beta header 锁存、两阶段缓存中断检测、工具 Schema 缓存和动态内容后移几个源码机制,拆解 Claude Code 如何把 Prompt Cache 做成一套前缀稳定工程。

第 11 章 18 分钟

Claude Code 的 Prompt Cache

Prompt Cache 不是“给提示词加个缓存参数”。在 Claude Code 里,它是一套围绕前缀稳定性展开的工程系统。

缓存命中的前提很苛刻:API 请求前缀要逐字节匹配。系统提示词、工具定义、beta headers、缓存 TTL、MCP 工具集、动态 agent 列表,只要其中某个前置部分抖动,后面的缓存都可能被击穿。

这一章要看的是 Claude Code 如何做四件事:

  • 把系统提示词切成不同缓存范围。
  • 用锁存机制防止 TTL 和 beta header 中途翻转。
  • 用两阶段检测解释缓存为什么断。
  • 把动态内容从工具 Schema 或系统前缀里挪出去。

核心直觉

Prompt Cache 看的不是“语义有没有变”,而是“序列化后的前缀字节有没有变”。所以缓存优化的核心不是少写几个字,而是把会变的东西从前缀关键路径上移走。

先看源码入口

源码定位关键符号负责什么
services/api/claude.tsgetCacheControl()生成统一的 cache_control 对象,决定 ttlscope
utils/api.tssplitSysPromptPrefix()SYSTEM_PROMPT_DYNAMIC_BOUNDARY 和缓存策略切分系统提示词
services/api/claude.tsshould1hCacheTTL()判断是否使用 1 小时 TTL,并把资格和 allowlist 锁存
bootstrap/state.tsgetPromptCache1hEligible()setPromptCache1hEligible()存放 TTL 资格锁存状态
services/api/claude.tsafkModeHeaderLatchedfastModeHeaderLatchedcacheEditingHeaderLatched防止动态 beta headers 中途改变缓存键
services/api/promptCacheBreakDetection.tsrecordPromptState()请求前记录系统提示词、工具、headers、模型等状态快照
services/api/promptCacheBreakDetection.tscheckResponseForCacheBreak()响应后根据 cache_read_input_tokens 下降确认并解释缓存中断
utils/toolSchemaCache.tsgetToolSchemaCache()clearToolSchemaCache()会话级缓存工具 Schema,避免 GrowthBook 和动态 prompt 抖动
tools/AgentTool/prompt.tsshouldInjectAgentListInMessages()把动态 agent 列表从工具描述迁移到消息附件
tools/SkillTool/prompt.tsSKILL_BUDGET_CONTEXT_PERCENT限制技能列表预算,减少工具 Schema 动态膨胀

这些入口共同指向一个判断:Claude Code 不只是“使用 Prompt Cache”,而是在代码结构里持续隔离动态内容。

总流程图

flowchart TD A["构建 API 请求"] --> B["splitSysPromptPrefix()"] B --> C{"有 SYSTEM_PROMPT_DYNAMIC_BOUNDARY?"} C -->|是| D["静态段 cacheScope: global<br/>动态段 cacheScope: null"] C -->|否| E["attribution: null<br/>prefix/rest: org"] D --> F["getCacheControl()"] E --> F F --> G["should1hCacheTTL()<br/>TTL 资格 / allowlist 锁存"] G --> H["beta header sticky-on 锁存"] H --> I["toolToAPISchema()<br/>getToolSchemaCache() 复用工具 schema"] I --> J["recordPromptState()<br/>请求前快照"] J --> K["发送 API 请求"] K --> L["收到 usage / cache_read_input_tokens"] L --> M["checkResponseForCacheBreak()"] M --> N{"下降 > 5% 且 > 2000 tokens?"} N -->|否| O["更新基线,继续"] N -->|是| P{"cacheDeletionsPending 或 compaction?"} P -->|是| Q["视为预期下降"] P -->|否| R["用 PendingChanges 解释原因<br/>或标记 TTL / server-side"]

这条链路里,缓存优化和缓存观测是串起来的:先尽力让前缀稳定,再在失效时知道是谁动了前缀。

缓存断点:cache_control 不是装饰,而是前缀边界

getCacheControl() 是最小但很关键的入口。

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 }),
  }
}

这段代码证明了三个点:

  • Claude Code 只使用 type: &#39;ephemeral&#39; 这类临时缓存断点。
  • 1 小时 TTL 不是默认字段,而是由 should1hCacheTTL() 条件加入。
  • 只有 scope === &#39;global&#39; 时才显式写入 scope,其他范围用默认或调用侧策略处理。

缓存断点的工程含义是:断点前的前缀应该尽量稳定,断点后的内容允许持续追加。它不是让任意动态内容都变便宜,而是给稳定前缀一个复用边界。

系统提示词分段:global、org、null 三种范围

splitSysPromptPrefix() 负责把系统提示词拆成缓存块。最重要的分支是全局缓存开启且存在 SYSTEM_PROMPT_DYNAMIC_BOUNDARY

const boundaryIndex = systemPrompt.findIndex(
  s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
)

for (let i = 0; i < systemPrompt.length; i++) {
  if (i < boundaryIndex) {
    staticBlocks.push(block)
  } else {
    dynamicBlocks.push(block)
  }
}

if (staticJoined) {
  result.push({ text: staticJoined, cacheScope: 'global' })
}
if (dynamicJoined) {
  result.push({ text: dynamicJoined, cacheScope: null })
}

这段代码证明:Claude Code 会把边界之前的系统提示词作为 global 范围缓存,边界之后的动态内容直接标记为 null。它没有退而求其次放进 org,因为高频动态内容即使做组织级缓存,也很可能命中率很低,还增加缓存断点复杂度。

默认模式下,系统提示词会拆出计费归属头、CLI 前缀和剩余部分。

if (block.startsWith('x-anthropic-billing-header')) {
  attributionHeader = block
} else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
  systemPromptPrefix = block
} else {
  rest.push(block)
}

if (attributionHeader) {
  result.push({ text: attributionHeader, cacheScope: null })
}
if (systemPromptPrefix) {
  result.push({ text: systemPromptPrefix, cacheScope: 'org' })
}

这段代码证明:用户或计费相关内容即使在系统前缀里,也不能随便共享,所以 attributionHeader 不参与缓存。可共享的 CLI 前缀和剩余系统提示词则进入 org 范围。

范围复用粒度适合什么内容风险
global跨用户、跨组织完全静态的 Claude Code 通用提示词任何用户差异都会击穿
org同组织内复用组织级稳定内容、工具定义组织内动态变化仍会断
null不设置缓存断点计费头、动态提示词、尾部状态不复用,但不污染前缀缓存

容易误解

null 不是“忘了缓存”,而是主动放弃缓存标记。对于高频变化内容,强行打断点反而会制造更多不稳定前缀。

缓存时长锁存:1 小时资格不能中途翻转

should1hCacheTTL() 决定是否启用 1 小时缓存。关键不是判断本身,而是判断结果会被锁存。

let userEligible = getPromptCache1hEligible()
if (userEligible === null) {
  userEligible =
    process.env.USER_TYPE === 'ant' ||
    (isClaudeAISubscriber() && !currentLimits.isUsingOverage)
  setPromptCache1hEligible(userEligible)
}
if (!userEligible) return false

这段代码证明:1 小时 TTL 资格第一次算出来后,会写入 STATE.promptCache1hEligible。后续请求复用这个值,而不是每轮重新读取配额状态。

allowlist 也一样锁存。

let allowlist = getPromptCache1hAllowlist()
if (allowlist === null) {
  const config = getFeatureValue_CACHED_MAY_BE_STALE(
    'tengu_prompt_cache_1h_config',
    {},
  )
  allowlist = config.allowlist ?? []
  setPromptCache1hAllowlist(allowlist)
}

这段代码证明:GrowthBook 配置不会在会话中途自由改变 TTL 行为。原因很直接:ttl: &#39;1h&#39; 是否出现会改变 cache_control 的序列化结果。一次配额翻转或远程配置刷新,就可能击穿 20K 级的系统提示词和工具缓存。

条件TTL 倾向是否锁存
Bedrock + ENABLE_PROMPT_CACHING_1H_BEDROCK1 小时环境变量决定
USER_TYPE === &#39;ant&#39;1 小时
Claude AI 订阅者且未 overage1 小时
GrowthBook allowlist 匹配 querySource允许 1 小时
其他情况默认 5 分钟不写 ttl: &#39;1h&#39;

这里的工程取舍是:宁可会话内使用略微过时的资格判断,也不要让缓存键中途变形

请求头锁存:sticky-on 防止功能开关击穿缓存

除了 cache_control,beta headers 也是服务端缓存键的一部分。Claude Code 对动态 beta header 使用 sticky-on 锁存:一旦发过,就在当前会话继续发。

源码注释直接写明了动机:

// Sticky-on latches for dynamic beta headers. Each header, once first
// sent, keeps being sent for the rest of the session so mid-session
// toggles don't change the server-side cache key and bust ~50-70K tokens.
// Latches are cleared on /clear and /compact via clearBetaHeaderLatches().

Fast Mode 的锁存最简单。

let fastModeHeaderLatched = getFastModeHeaderLatched() === true
if (!fastModeHeaderLatched && isFastMode) {
  fastModeHeaderLatched = true
  setFastModeHeaderLatched(true)
}

缓存编辑 header 的条件更多。

let cacheEditingHeaderLatched = getCacheEditingHeaderLatched() === true
if (feature('CACHED_MICROCOMPACT')) {
  if (
    !cacheEditingHeaderLatched &&
    cachedMCEnabled &&
    getAPIProvider() === 'firstParty' &&
    options.querySource === 'repl_main_thread'
  ) {
    cacheEditingHeaderLatched = true
    setCacheEditingHeaderLatched(true)
  }
}

这两段代码证明:锁存不是“所有请求都加所有 header”。它仍然保留 per-call 前置条件,比如 1P、main thread、agentic query;但一旦某个动态 header 在合适范围内出现,就避免它在同一会话中来回消失。

stateDiagram-v2 [*] --> 未锁存 未锁存 --> 已锁存: 条件首次为真 已锁存 --> 已锁存: 功能停用但继续发送 已锁存 --> 未锁存: /clear 或 /compact 重置
Header 类别锁存变量首次触发条件重置
AFK / Auto ModeafkModeHeaderLatchedtranscript classifier + agentic + 1P + auto mode 活跃/clear/compact
Fast ModefastModeHeaderLatched当前请求进入 fast mode/clear/compact
Cache EditingcacheEditingHeaderLatchedcached MC 启用 + 1P + main thread/clear/compact

工具 Schema 缓存:会话内固定序列化结果

工具 Schema 位于请求前部,它的变化很贵。utils/toolSchemaCache.ts 用一个模块级 Map 固定会话内渲染结果。

type CachedSchema = BetaTool & {
  strict?: boolean
  eager_input_streaming?: boolean
}

const TOOL_SCHEMA_CACHE = new Map<string, CachedSchema>()

export function getToolSchemaCache(): Map<string, CachedSchema> {
  return TOOL_SCHEMA_CACHE
}

export function clearToolSchemaCache(): void {
  TOOL_SCHEMA_CACHE.clear()
}

源码注释解释了为什么要缓存:工具 Schema 在服务端位置 2,位于系统 prompt 之前或附近;GrowthBook gate 翻转、MCP reconnect、tool.prompt() 里的动态内容都会导致字节漂移。会话级缓存把变化频率从“每次请求”降成“首次渲染一次”。

缓存键也不是一律用工具名。

const cacheKey =
  'inputJSONSchema' in tool && tool.inputJSONSchema
    ? `${tool.name}:${jsonStringify(tool.inputJSONSchema)}`
    : tool.name

这段代码证明:StructuredOutput 这类同名但 schema 不同的工具不能只按名称缓存。原文记录过 name-only keying 导致 stale schema,错误率从 5.4% 上升到 51%。所以 key 必须包含 inputJSONSchema

核心判断

工具 Schema 缓存解决的不是性能计算开销,而是前缀字节稳定性。少跑一次 tool.prompt() 只是副作用,真正目标是避免动态描述和远程 flag 刷新污染缓存键。

动态内容后移:Agent 列表不要写进工具描述

AgentTool 曾经把动态 agent 列表放进工具描述。原文里的源码注释给了一个非常直接的数据:动态 agent list 造成了 fleet cache_creation tokens 的约 10.2%。

解决入口是 shouldInjectAgentListInMessages()

export function shouldInjectAgentListInMessages(): boolean {
  if (isEnvTruthy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) return true
  if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) {
    return false
  }
  return getFeatureValue_CACHED_MAY_BE_STALE('tengu_agent_list_attach', false)
}

这段代码证明:Claude Code 用环境变量和 GrowthBook 灰度控制迁移,把 agent 列表从工具 Schema 移到消息附件。工具描述保持静态,动态列表变成 agent_listing_delta 这类尾部附件。

这背后的原则很重要:动态能力列表不是不能给模型,而是不应该放在缓存前缀的工具描述里

同样的思路也出现在 SkillTool。技能列表只用于发现,完整内容由 SkillTool 调用时加载,所以列表要有预算。

export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000
export const MAX_LISTING_DESC_CHARS = 250

这段代码证明:技能列表最多约占 1% context window,每条描述最多 250 字符。列表越长,越容易导致工具 Schema 变化;预算不仅省 token,也限制了动态内容对缓存前缀的扰动。

路径标准化:$TMPDIR 消除用户维度差异

BashTool 提示词里不能直接写用户专属临时目录。不同用户的 UID 会让路径不同,从而破坏 global cache。

const claudeTempDir = getClaudeTempDir()
const normalizeAllowOnly = (paths: string[]): string[] =>
  [...new Set(paths)].map(p => (p === claudeTempDir ? '$TMPDIR' : p))

这段代码证明:Claude Code 会把 /private/tmp/claude-{UID}/ 这类用户专属路径替换成 $TMPDIR。运行时沙箱会设置 $TMPDIR,所以模型行为不受影响,但提示词字节在用户之间一致。

这类优化很小,但非常典型:global cache 要求跨用户字节一致,用户路径、UID、临时目录都要从前缀中消失

缓存中断检测:请求前快照,响应后归因

再好的锁存也不能消灭所有缓存中断。Claude Code 用 promptCacheBreakDetection.ts 做两阶段检测。

阶段一:recordPromptState()

在构建 API 请求时,Claude Code 记录会影响缓存键的状态。

recordPromptState({
  system,
  toolSchemas: toolsForCacheDetection,
  querySource: options.querySource,
  model: options.model,
  agentId: options.agentId,
  fastMode: fastModeHeaderLatched,
  globalCacheStrategy,
  betas,
  autoModeActive: afkHeaderLatched,
  isUsingOverage: currentLimits.isUsingOverage ?? false,
  cachedMCEnabled: cacheEditingHeaderLatched,
  effortValue: effort,
  extraBodyParams: getExtraBodyParams(),
})

这段代码证明:检测用的是实际发送的锁存后值,不是 UI 当前开关值。否则检测会报告“用户设置变了”,但服务端缓存键可能并没有变。

它还会排除 defer loading 工具:

const toolsForCacheDetection = allTools.filter(
  t => !('defer_loading' in t && t.defer_loading),
)

这段代码证明:延迟加载工具不会进入实际工具 schema 缓存键,纳入检测会制造误报。

PreviousState 记录什么

PreviousState 是缓存检测的核心账本。

字段检测什么
systemHash系统提示词文本是否变化,不含 cache_control
cacheControlHashscopettl 等缓存控制是否变化
toolsHash工具 Schema 聚合是否变化
perToolHashes哪个具体工具的 Schema 变了
toolNames工具增删
model模型切换
betasbeta header 增删
fastModeautoModeActivecachedMCEnabled锁存后动态 header 状态
effortValueeffort 切换
extraBodyHash额外请求体变化
pendingChanges请求前检测到的变化,等待响应确认
prevCacheReadTokens上一次响应的缓存读取 token
cacheDeletionsPending微压缩 cache edits 导致的预期下降

一个关键设计是 systemHashcacheControlHash 分开算。

const systemHash = computeHash(strippedSystem)
const cacheControlHash = computeHash(
  system.map(b => ('cache_control' in b ? b.cache_control : null)),
)

这段代码证明:提示词文本不变,也可能因为 TTL 或 scope 翻转导致缓存键变化。两个 hash 分离,才能把“文本变化”和“缓存控制变化”区分开。

阶段二:checkResponseForCacheBreak()

响应回来后,检测系统看 cache_read_input_tokens 是否明显下降。

const tokenDrop = prevCacheRead - cacheReadTokens
if (
  cacheReadTokens >= prevCacheRead * 0.95 ||
  tokenDrop < MIN_CACHE_MISS_TOKENS
) {
  state.pendingChanges = null
  return
}

这段代码证明缓存中断有双重门槛:

  • 相对下降超过 5%。
  • 绝对下降超过 MIN_CACHE_MISS_TOKENS = 2_000

两个条件同时满足,才认为是值得记录的缓存中断。这避免小幅波动和小基线比例放大造成误报。

缓存删除和压缩有独立处理。

if (state.cacheDeletionsPending) {
  state.cacheDeletionsPending = false
  state.pendingChanges = null
  return
}
export function notifyCompaction(
  querySource: QuerySource,
  agentId?: AgentId,
): void {
  const key = getTrackingKey(querySource, agentId)
  const state = key ? previousStateBySource.get(key) : undefined
  if (state) {
    state.prevCacheReadTokens = null
  }
}

这两段代码证明:微压缩的 cache deletion 是预期下降,完整压缩则直接重置 baseline。否则上下文管理自己的优化动作会被误报成缓存中断。

归因:客户端变化、TTL、服务端原因

当确认中断后,系统用 PendingChanges 生成解释。如果客户端没有变化,就看时间间隔是否超过 TTL。

if (parts.length > 0) {
  reason = parts.join(', ')
} else if (lastAssistantMsgOver1hAgo) {
  reason = 'possible 1h TTL expiry (prompt unchanged)'
} else if (lastAssistantMsgOver5minAgo) {
  reason = 'possible 5min TTL expiry (prompt unchanged)'
} else if (timeSinceLastAssistantMsg !== null) {
  reason = 'likely server-side (prompt unchanged, <5min gap)'
} else {
  reason = 'unknown cause'
}

这段代码证明:缓存中断不总是客户端 bug。原文注释里提到 BigQuery 分析:当客户端无变化且未超 TTL 时,约 90% 的中断来自服务端路由、驱逐或计费/推理差异。因此检测系统会标记 likely server-side,避免错误引导开发者追本地变化。

状态和数据结构

状态/结构位置关键字段影响
SystemPromptBlockutils/api.tstextcacheScope决定系统提示词块进入 global、org 还是 null
cache_controlAPI blocktypettlscope决定服务端缓存断点和 TTL
STATE.promptCache1hEligiblebootstrap/state.tsboolean \| null锁存 1 小时 TTL 资格
beta header latchesservices/api/claude.tsafkModeHeaderLatchedfastModeHeaderLatchedcacheEditingHeaderLatched防止动态 headers 来回改变缓存键
TOOL_SCHEMA_CACHEutils/toolSchemaCache.tsMap@@INLINE_0@@会话内固定工具 schema 序列化结果
PreviousStatepromptCacheBreakDetection.ts多个 hash、状态字段、pendingChanges请求前后对比和中断归因
PendingChangespromptCacheBreakDetection.tschanged flags、added/removed tools、betas、model diff给响应后的 cache drop 提供原因

设计取舍

取舍源码证据工程判断
动态段标记 null 而非强行缓存SYSTEM_PROMPT_DYNAMIC_BOUNDARYcacheScope: null高频变化内容不值得污染缓存断点
TTL 资格锁存getPromptCache1hEligible() / setPromptCache1hEligible()避免 overage 或 GrowthBook 刷新导致 ttl 翻转
beta header sticky-onfastModeHeaderLatched牺牲一点 header 精确性,换缓存键稳定
工具 Schema 会话级缓存TOOL_SCHEMA_CACHE阻断 GB flag、MCP reconnect、动态 prompt 对前缀的扰动
StructuredOutput key 包含 schema${tool.name}:${jsonStringify(inputJSONSchema)}防止同名不同 schema 被错误复用
Agent 列表附件化shouldInjectAgentListInMessages()把动态能力列表从工具描述挪到消息尾部
技能列表 1% 预算SKILL_BUDGET_CONTEXT_PERCENT = 0.01限制工具 Schema 里动态列表的体积和变化幅度
$TMPDIR 占位符normalizeAllowOnly()消除用户 UID 差异,保护 global cache
两阶段检测recordPromptState() + checkResponseForCacheBreak()请求前保存原因,响应后确认影响
服务端归因likely server-side 分支不把客户端无变化的缓存下降误报为本地 bug

读源码抓手

  1. 先看 getCacheControl(),确认 ttlscope 是怎么进请求的。
  2. 再看 splitSysPromptPrefix(),追 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 前后分别进入哪个 cache scope。
  3. 接着读 should1hCacheTTL(),重点看资格和 allowlist 为什么要锁存。
  4. 然后读 beta header latch 三段代码,理解 sticky-on 和 /clear/compact 重置边界。
  5. 再看 getToolSchemaCache()toolToAPISchema() 的缓存键,尤其是 StructuredOutput 特例。
  6. 接着读 AgentTool/prompt.tsSkillTool/prompt.ts,看动态列表如何从工具描述迁移或被预算约束。
  7. 最后读 promptCacheBreakDetection.ts,按 recordPromptState()checkResponseForCacheBreak() 的顺序追两阶段检测。

小结

小结

Claude Code 的 Prompt Cache 是一套前缀稳定工程,而不是一个单点缓存开关。

  • cache_control 定义断点,但真正的难点是断点前的系统提示词、工具 Schema 和 headers 要稳定。
  • splitSysPromptPrefix()globalorgnull 分层处理静态、组织级和动态内容。
  • TTL 和 beta headers 都要锁存,因为它们虽然不是提示词文本,却会改变服务端缓存键。
  • 工具 Schema 缓存、agent 列表附件化、技能列表预算、$TMPDIR 标准化,本质都是把动态字节从前缀里赶出去。
  • 两阶段缓存中断检测把“请求前发生了什么”和“响应后是否真的掉缓存”接起来,避免凭感觉排查缓存问题。