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.ts | getCacheControl() | 生成统一的 cache_control 对象,决定 ttl 和 scope |
utils/api.ts | splitSysPromptPrefix() | 按 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 和缓存策略切分系统提示词 |
services/api/claude.ts | should1hCacheTTL() | 判断是否使用 1 小时 TTL,并把资格和 allowlist 锁存 |
bootstrap/state.ts | getPromptCache1hEligible()、setPromptCache1hEligible() | 存放 TTL 资格锁存状态 |
services/api/claude.ts | afkModeHeaderLatched、fastModeHeaderLatched、cacheEditingHeaderLatched | 防止动态 beta headers 中途改变缓存键 |
services/api/promptCacheBreakDetection.ts | recordPromptState() | 请求前记录系统提示词、工具、headers、模型等状态快照 |
services/api/promptCacheBreakDetection.ts | checkResponseForCacheBreak() | 响应后根据 cache_read_input_tokens 下降确认并解释缓存中断 |
utils/toolSchemaCache.ts | getToolSchemaCache()、clearToolSchemaCache() | 会话级缓存工具 Schema,避免 GrowthBook 和动态 prompt 抖动 |
tools/AgentTool/prompt.ts | shouldInjectAgentListInMessages() | 把动态 agent 列表从工具描述迁移到消息附件 |
tools/SkillTool/prompt.ts | SKILL_BUDGET_CONTEXT_PERCENT | 限制技能列表预算,减少工具 Schema 动态膨胀 |
这些入口共同指向一个判断:Claude Code 不只是“使用 Prompt Cache”,而是在代码结构里持续隔离动态内容。
总流程图
这条链路里,缓存优化和缓存观测是串起来的:先尽力让前缀稳定,再在失效时知道是谁动了前缀。
缓存断点: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: 'ephemeral'这类临时缓存断点。 - 1 小时 TTL 不是默认字段,而是由
should1hCacheTTL()条件加入。 - 只有
scope === 'global'时才显式写入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: '1h' 是否出现会改变 cache_control 的序列化结果。一次配额翻转或远程配置刷新,就可能击穿 20K 级的系统提示词和工具缓存。
| 条件 | TTL 倾向 | 是否锁存 |
|---|---|---|
Bedrock + ENABLE_PROMPT_CACHING_1H_BEDROCK | 1 小时 | 环境变量决定 |
USER_TYPE === 'ant' | 1 小时 | 是 |
| Claude AI 订阅者且未 overage | 1 小时 | 是 |
GrowthBook allowlist 匹配 querySource | 允许 1 小时 | 是 |
| 其他情况 | 默认 5 分钟 | 不写 ttl: '1h' |
这里的工程取舍是:宁可会话内使用略微过时的资格判断,也不要让缓存键中途变形。
请求头锁存: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 在合适范围内出现,就避免它在同一会话中来回消失。
| Header 类别 | 锁存变量 | 首次触发条件 | 重置 |
|---|---|---|---|
| AFK / Auto Mode | afkModeHeaderLatched | transcript classifier + agentic + 1P + auto mode 活跃 | /clear、/compact |
| Fast Mode | fastModeHeaderLatched | 当前请求进入 fast mode | /clear、/compact |
| Cache Editing | cacheEditingHeaderLatched | cached 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 |
cacheControlHash | scope、ttl 等缓存控制是否变化 |
toolsHash | 工具 Schema 聚合是否变化 |
perToolHashes | 哪个具体工具的 Schema 变了 |
toolNames | 工具增删 |
model | 模型切换 |
betas | beta header 增删 |
fastMode、autoModeActive、cachedMCEnabled | 锁存后动态 header 状态 |
effortValue | effort 切换 |
extraBodyHash | 额外请求体变化 |
pendingChanges | 请求前检测到的变化,等待响应确认 |
prevCacheReadTokens | 上一次响应的缓存读取 token |
cacheDeletionsPending | 微压缩 cache edits 导致的预期下降 |
一个关键设计是 systemHash 和 cacheControlHash 分开算。
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,避免错误引导开发者追本地变化。
状态和数据结构
| 状态/结构 | 位置 | 关键字段 | 影响 |
|---|---|---|---|
SystemPromptBlock | utils/api.ts | text、cacheScope | 决定系统提示词块进入 global、org 还是 null |
cache_control | API block | type、ttl、scope | 决定服务端缓存断点和 TTL |
STATE.promptCache1hEligible | bootstrap/state.ts | boolean \| null | 锁存 1 小时 TTL 资格 |
| beta header latches | services/api/claude.ts | afkModeHeaderLatched、fastModeHeaderLatched、cacheEditingHeaderLatched | 防止动态 headers 来回改变缓存键 |
TOOL_SCHEMA_CACHE | utils/toolSchemaCache.ts | Map@@INLINE_0@@ | 会话内固定工具 schema 序列化结果 |
PreviousState | promptCacheBreakDetection.ts | 多个 hash、状态字段、pendingChanges | 请求前后对比和中断归因 |
PendingChanges | promptCacheBreakDetection.ts | changed flags、added/removed tools、betas、model diff | 给响应后的 cache drop 提供原因 |
设计取舍
| 取舍 | 源码证据 | 工程判断 |
|---|---|---|
动态段标记 null 而非强行缓存 | SYSTEM_PROMPT_DYNAMIC_BOUNDARY 后 cacheScope: null | 高频变化内容不值得污染缓存断点 |
| TTL 资格锁存 | getPromptCache1hEligible() / setPromptCache1hEligible() | 避免 overage 或 GrowthBook 刷新导致 ttl 翻转 |
| beta header sticky-on | fastModeHeaderLatched 等 | 牺牲一点 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 |
读源码抓手
- 先看
getCacheControl(),确认ttl和scope是怎么进请求的。 - 再看
splitSysPromptPrefix(),追SYSTEM_PROMPT_DYNAMIC_BOUNDARY前后分别进入哪个 cache scope。 - 接着读
should1hCacheTTL(),重点看资格和 allowlist 为什么要锁存。 - 然后读 beta header latch 三段代码,理解 sticky-on 和
/clear、/compact重置边界。 - 再看
getToolSchemaCache()和toolToAPISchema()的缓存键,尤其是StructuredOutput特例。 - 接着读
AgentTool/prompt.ts和SkillTool/prompt.ts,看动态列表如何从工具描述迁移或被预算约束。 - 最后读
promptCacheBreakDetection.ts,按recordPromptState()到checkResponseForCacheBreak()的顺序追两阶段检测。
小结
小结
Claude Code 的 Prompt Cache 是一套前缀稳定工程,而不是一个单点缓存开关。
cache_control定义断点,但真正的难点是断点前的系统提示词、工具 Schema 和 headers 要稳定。splitSysPromptPrefix()用global、org、null分层处理静态、组织级和动态内容。- TTL 和 beta headers 都要锁存,因为它们虽然不是提示词文本,却会改变服务端缓存键。
- 工具 Schema 缓存、agent 列表附件化、技能列表预算、
$TMPDIR标准化,本质都是把动态字节从前缀里赶出去。 - 两阶段缓存中断检测把“请求前发生了什么”和“响应后是否真的掉缓存”接起来,避免凭感觉排查缓存问题。