第14章:缓存中断检测系统
为什么这很重要
在第13章中,我们看到 Claude Code 通过锁存机制和精心设计的缓存范围来预防缓存中断。但即使有这些防护措施,缓存中断仍然会发生——工具定义可能因为 MCP 服务器重连而变化,系统提示词可能因为新的附件而增长,模型切换、effort 调整、甚至 GrowthBook 的远程配置更新都可能改变 API 请求的前缀。
更棘手的是,缓存中断是“静默“的。API 响应中的 cache_read_input_tokens 下降了,但没有任何错误信息告诉你为什么。开发者只会注意到成本上升了、延迟增加了,却不知道根因在哪里。
Claude Code 构建了一套两阶段缓存中断检测系统来解决这个问题。整个系统实现在 services/api/promptCacheBreakDetection.ts(728行),是 Claude Code 中为数不多专门服务于可观测性(observability)而非功能的子系统。
14.1 两阶段检测架构
设计思路
缓存中断检测面临一个时序问题:
- 变化发生在请求发送前:系统提示词变了、工具增删了、beta header 翻转了
- 中断确认在响应返回后:只有看到
cache_read_input_tokens的下降才能确认缓存确实被击穿了
仅有阶段 2 是不够的——当检测到 token 下降时,请求已经发送,之前的状态已经丢失,无法回溯原因。仅有阶段 1 也不够——很多客户端变化并不一定导致服务端缓存中断(例如,服务端可能恰好还没有缓存该前缀)。
Claude Code 的解决方案是将检测分为两个阶段:
flowchart LR
subgraph Phase1["阶段 1(请求前)<br />recordPromptState()"]
A1[捕获当前状态] --> A2[对比前次状态]
A2 --> A3[记录变化清单]
A3 --> A4[存为 pendingChanges]
Phase1 -- "API 请求/响应" --> Phase2
subgraph Phase2["阶段 2(响应后)<br />checkResponseForCacheBreak()"]
B1[检查 cache tokens] --> B2[确认是否真正中断]
B2 --> B3[用阶段 1 的变化解释原因]
B3 --> B4[输出诊断信息]
B4 --> B5[发送 analytics 事件]
图 14-1:两阶段检测时序图
调用位置
两个阶段的调用位置在 services/api/claude.ts 中:
阶段 1 在构建 API 请求时调用(第1460-1486行):
// services/api/claude.ts:1460-1486
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
const toolsForCacheDetection = allTools.filter(
t => !('defer_loading' in t && t.defer_loading),
)
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(),
})
}
注意两个关键设计决策:
- 排除 defer_loading 工具:API 会自动剥离延迟加载的工具,它们不影响实际的缓存键。包含它们会在工具发现或 MCP 服务器重连时产生误报。
- 传入锁存后的值:
fastModeHeaderLatched、afkHeaderLatched、cacheEditingHeaderLatched是锁存后的值,而非实时状态。因为缓存键由实际发送的 header 决定,而非用户当前的设置。
阶段 2 在 API 响应处理完成后调用,传入响应中的缓存 token 统计数据。
14.2 PreviousState:全量状态快照
阶段 1 的核心是 PreviousState 类型——它捕获了所有可能影响服务端缓存键的客户端状态。
字段清单
PreviousState 定义在 promptCacheBreakDetection.ts(第28-69行),包含 15+ 个字段:
| 字段 | 类型 | 作用 | 变化来源 |
|---|---|---|---|
systemHash | number | 系统提示词内容哈希(不含 cache_control) | 提示词内容变化 |
toolsHash | number | 工具 Schema 聚合哈希(不含 cache_control) | 工具增删或定义变化 |
cacheControlHash | number | 系统块的 cache_control 哈希 | 范围或 TTL 翻转 |
toolNames | string[] | 工具名称列表 | 工具增删 |
perToolHashes | Record<string, number> | 每个工具的独立哈希 | 单个工具 Schema 变化 |
systemCharCount | number | 系统提示词字符总数 | 内容增减 |
model | string | 当前模型标识 | 模型切换 |
fastMode | boolean | Fast Mode 状态(锁存后) | Fast Mode 激活 |
globalCacheStrategy | string | 缓存策略类型 | MCP 工具发现/移除 |
betas | string[] | 排序后的 beta header 列表 | Beta header 变化 |
autoModeActive | boolean | AFK Mode 状态(锁存后) | Auto Mode 激活 |
isUsingOverage | boolean | 超额使用状态(锁存后) | 配额状态变化 |
cachedMCEnabled | boolean | 缓存编辑状态(锁存后) | Cached MC 激活 |
effortValue | string | 解析后的 effort 值 | Effort 配置变化 |
extraBodyHash | number | 额外请求体参数哈希 | CLAUDE_CODE_EXTRA_BODY 变化 |
callCount | number | 当前 tracking key 的调用次数 | 自增 |
pendingChanges | PendingChanges | null | 阶段 1 检测到的变化 | 阶段 1 对比结果 |
prevCacheReadTokens | number | null | 上次响应的缓存读取 token 数 | 阶段 2 更新 |
cacheDeletionsPending | boolean | 是否有 cache_edits 删除操作待确认 | Cached MC 删除操作 |
buildDiffableContent | () => string | 懒计算的可 diff 内容 | 用于调试输出 |
表 14-1:PreviousState 完整字段清单
哈希策略
PreviousState 中有多个哈希字段,它们服务于不同的检测粒度:
// promptCacheBreakDetection.ts:170-179
// function computeHash(data: unknown): number {
const str = jsonStringify(data)
if (typeof Bun !== 'undefined') {
const hash = Bun.hash(str)
return typeof hash === 'bigint' ? Number(hash & 0xffffffffn) : hash
}
return djb2Hash(str)
}
systemHash vs cacheControlHash 的分离设计值得特别关注:
// promptCacheBreakDetection.ts:274-281
const systemHash = computeHash(strippedSystem) // 不含 cache_control
const cacheControlHash = computeHash( // 只有 cache_control
system.map(b => ('cache_control' in b ? b.cache_control : null)),
)
systemHash 对系统提示词内容做哈希,先通过 stripCacheControl() 移除 cache_control 标记。cacheControlHash 只对 cache_control 标记做哈希。为什么要分离?因为缓存范围翻转(global → org)或 TTL 翻转(1h → 5m)不会改变提示词的文本内容——如果只看 systemHash,这些翻转会被遗漏。分离后,cacheControlChanged 可以独立捕获这类变化。
perToolHashes 的按需计算也是一个性能优化:
// promptCacheBreakDetection.ts:285-286
const computeToolHashes = () =>
computePerToolHashes(strippedTools, toolNames)
perToolHashes 是一个逐工具的哈希表,用于在工具 Schema 聚合哈希变化时精确定位是哪个工具发生了变化。但逐工具计算哈希的成本较高(N 次 jsonStringify),因此只在 toolsHash 变化时才触发计算。注释(第37行)引用了 BigQuery 数据:77% 的工具 Schema 变化是单个工具的描述改变,而非工具增删。perToolHashes 正是为了精确诊断这 77% 的场景。
跟踪键与隔离策略
每个查询源(query source)维护独立的 PreviousState,存储在一个 Map 中:
// promptCacheBreakDetection.ts:101-107
const previousStateBySource = new Map<string, PreviousState>()
const MAX_TRACKED_SOURCES = 10
const TRACKED_SOURCE_PREFIXES = [
'repl_main_thread',
'sdk',
'agent:custom',
'agent:default',
'agent:builtin',
]
跟踪键通过 getTrackingKey() 函数计算(第149-158行):
// promptCacheBreakDetection.ts:149-158
// function getTrackingKey(
querySource: QuerySource,
agentId?: AgentId,
): string | null {
if (querySource === 'compact') return 'repl_main_thread'
for (const prefix of TRACKED_SOURCE_PREFIXES) {
if (querySource.startsWith(prefix)) return agentId || querySource
}
return null
}
几个重要的设计决策:
- compact 共享 main thread 的跟踪状态:压缩操作使用相同的
cacheSafeParams,共享缓存键,所以应该共享检测状态 - 子 Agent 使用 agentId 隔离:防止同类型的多个并发 Agent 实例之间产生误报
- 不跟踪的查询源返回
null:speculation、session_memory、prompt_suggestion等短生命周期的 Agent 只运行 1-3 轮,没有前后对比的价值 - Map 容量上限:
MAX_TRACKED_SOURCES = 10,防止大量子 Agent 的 agentId 导致内存无限增长
14.3 阶段 1:recordPromptState() 详解
首次调用:建立基线
首次调用 recordPromptState() 时,没有前一个状态可以对比,函数只做两件事:
- 检查 Map 容量,如果达到上限则驱逐最旧的条目
- 创建初始
PreviousState快照,pendingChanges设为null
// promptCacheBreakDetection.ts:298-328
if (!prev) {
while (previousStateBySource.size >= MAX_TRACKED_SOURCES) {
const oldest = previousStateBySource.keys().next().value
if (oldest !== undefined) previousStateBySource.delete(oldest)
}
previousStateBySource.set(key, {
systemHash,
toolsHash,
cacheControlHash,
toolNames,
// ... 所有初始值
callCount: 1,
pendingChanges: null,
prevCacheReadTokens: null,
cacheDeletionsPending: false,
buildDiffableContent: lazyDiffableContent,
perToolHashes: computeToolHashes(),
})
return
}
后续调用:变化检测
后续调用时,函数逐字段对比当前值与前一个状态:
// promptCacheBreakDetection.ts:332-346
const systemPromptChanged = systemHash !== prev.systemHash
const toolSchemasChanged = toolsHash !== prev.toolsHash
const modelChanged = model !== prev.model
const fastModeChanged = isFastMode !== prev.fastMode
const cacheControlChanged = cacheControlHash !== prev.cacheControlHash
const globalCacheStrategyChanged =
globalCacheStrategy !== prev.globalCacheStrategy
const betasChanged =
sortedBetas.length !== prev.betas.length ||
sortedBetas.some((b, i) => b !== prev.betas[i])
const autoModeChanged = autoModeActive !== prev.autoModeActive
const overageChanged = isUsingOverage !== prev.isUsingOverage
const cachedMCChanged = cachedMCEnabled !== prev.cachedMCEnabled
const effortChanged = effortStr !== prev.effortValue
const extraBodyChanged = extraBodyHash !== prev.extraBodyHash
如果任何字段发生变化,函数构建 PendingChanges 对象:
// promptCacheBreakDetection.ts:71-99
type PendingChanges = {
systemPromptChanged: boolean
toolSchemasChanged: boolean
modelChanged: boolean
fastModeChanged: boolean
cacheControlChanged: boolean
globalCacheStrategyChanged: boolean
betasChanged: boolean
autoModeChanged: boolean
overageChanged: boolean
cachedMCChanged: boolean
effortChanged: boolean
extraBodyChanged: boolean
addedToolCount: number
removedToolCount: number
systemCharDelta: number
addedTools: string[]
removedTools: string[]
changedToolSchemas: string[]
previousModel: string
newModel: string
prevGlobalCacheStrategy: string
newGlobalCacheStrategy: string
addedBetas: string[]
removedBetas: string[]
prevEffortValue: string
newEffortValue: string
buildPrevDiffableContent: () => string
}
PendingChanges 不仅记录是否变化(boolean 标志),还记录如何变化(增减了哪些工具、beta header 的增删列表、字符数变化量等)。这些详细信息在阶段 2 的中断解释中至关重要。
工具变化的精确归因
当 toolSchemasChanged 为真时,系统进一步分析是哪些工具发生了变化:
// promptCacheBreakDetection.ts:366-378
if (toolSchemasChanged) {
const newHashes = computeToolHashes()
for (const name of toolNames) {
if (!prevToolSet.has(name)) continue
if (newHashes[name] !== prev.perToolHashes[name]) {
changedToolSchemas.push(name)
}
}
prev.perToolHashes = newHashes
}
这段代码将工具变化分为三类:
- 新增工具:在新列表中但不在旧列表中(
addedTools) - 移除工具:在旧列表中但不在新列表中(
removedTools) - Schema 变化:工具仍在,但 Schema 哈希不同(
changedToolSchemas)
第三类是最常见的——AgentTool 和 SkillTool 的描述中嵌入了动态的 Agent 列表和命令列表,这些内容随会话状态变化。
14.4 阶段 2:checkResponseForCacheBreak() 详解
中断判定标准
阶段 2 在 API 响应返回后调用,核心逻辑是判断缓存是否真正被击穿:
// promptCacheBreakDetection.ts:485-493
const tokenDrop = prevCacheRead - cacheReadTokens
if (
cacheReadTokens >= prevCacheRead * 0.95 ||
tokenDrop < MIN_CACHE_MISS_TOKENS
) {
state.pendingChanges = null
return
}
判定使用双重门槛:
- 相对阈值:缓存读取 token 数下降超过 5%(
< prevCacheRead * 0.95) - 绝对阈值:下降超过 2,000 tokens(
MIN_CACHE_MISS_TOKENS = 2_000)
两个条件必须同时满足才触发中断告警。这避免了两类误报:
- 小幅波动:缓存 token 数的自然变化(几百 tokens)不触发告警
- 比例放大:当基线很小时(例如 1,000 tokens),5% 的波动只有 50 tokens,不值得告警
特殊情况:Cache Deletion
缓存编辑(Cached Microcompact)可以通过 cache_edits 主动删除缓存中的内容块。这会导致 cache_read_input_tokens 合法地下降——这是预期行为,不应触发中断告警:
// promptCacheBreakDetection.ts:473-481
if (state.cacheDeletionsPending) {
state.cacheDeletionsPending = false
logForDebugging(
`[PROMPT CACHE] cache deletion applied, cache read: ` +
`${prevCacheRead} → ${cacheReadTokens} (expected drop)`,
)
state.pendingChanges = null
return
}
cacheDeletionsPending 标志通过 notifyCacheDeletion() 函数设置(第673-682行),由缓存编辑模块在发送删除操作时调用。
特殊情况:Compaction
压缩操作(/compact)会大幅减少消息数量,导致缓存读取 token 数自然下降。notifyCompaction() 函数(第689-698行)通过将 prevCacheReadTokens 重置为 null 来处理这种情况——下一次调用被视为“首次调用“,不做对比:
// promptCacheBreakDetection.ts:689-698
// 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
}
}
14.5 中断解释引擎
当确认缓存中断后,系统使用阶段 1 收集的 PendingChanges 构建人类可读的解释。解释引擎位于 checkResponseForCacheBreak() 的第495-588行:
客户端归因
如果 PendingChanges 中有变化标志为真,系统生成对应的解释文本:
// promptCacheBreakDetection.ts:496-563(简化)
const parts: string[] = []
if (changes) {
if (changes.modelChanged) {
parts.push(`model changed (${changes.previousModel} → ${changes.newModel})`)
}
if (changes.systemPromptChanged) {
const charInfo = charDelta > 0 ? ` (+${charDelta} chars)` : ` (${charDelta} chars)`
parts.push(`system prompt changed${charInfo}`)
}
if (changes.toolSchemasChanged) {
const toolDiff = changes.addedToolCount > 0 || changes.removedToolCount > 0
? ` (+${changes.addedToolCount}/-${changes.removedToolCount} tools)`
: ' (tool prompt/schema changed, same tool set)'
parts.push(`tools changed${toolDiff}`)
}
if (changes.betasChanged) {
const added = changes.addedBetas.length ? `+${changes.addedBetas.join(',')}` : ''
const removed = changes.removedBetas.length ? `-${changes.removedBetas.join(',')}` : ''
parts.push(`betas changed (${[added, removed].filter(Boolean).join(' ')})`)
}
// ... 其他字段的解释逻辑类似
}
解释引擎的设计原则是具体胜于抽象:不是简单地说“缓存中断了“,而是精确列出哪些字段变化了、变化了多少。
cacheControl 变化的独立报告逻辑
在解释引擎中,cacheControlChanged 有一个特殊的报告条件:
// promptCacheBreakDetection.ts:528-535
if (
changes.cacheControlChanged &&
!changes.globalCacheStrategyChanged &&
!changes.systemPromptChanged
) {
parts.push('cache_control changed (scope or TTL)')
}
只有在全局缓存策略和系统提示词都没变的情况下,才单独报告 cacheControlChanged。原因是:如果全局缓存策略变了(例如从 tool_based 切换到 system_prompt),cache_control 的变化只是策略变化的后果,不需要重复报告。同理,如果系统提示词变了,cache_control 可能只是因为新的内容块结构调整了缓存标记。
TTL 过期检测
当没有客户端变化被检测到时(parts.length === 0),系统检查是否可能是 TTL 过期导致的缓存失效:
// promptCacheBreakDetection.ts:566-588
const lastAssistantMsgOver5minAgo =
timeSinceLastAssistantMsg !== null &&
timeSinceLastAssistantMsg > CACHE_TTL_5MIN_MS
const lastAssistantMsgOver1hAgo =
timeSinceLastAssistantMsg !== null &&
timeSinceLastAssistantMsg > CACHE_TTL_1HOUR_MS
let reason: string
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'
}
TTL 过期检测通过查找消息历史中最近的 assistant 消息时间戳来计算时间间隔。两个 TTL 常量定义在文件顶部(第125-126行):
// promptCacheBreakDetection.ts:125-126
const CACHE_TTL_5MIN_MS = 5 * 60 * 1000
export const CACHE_TTL_1HOUR_MS = 60 * 60 * 1000
服务端归因:“90% 的中断是服务端原因”
最关键的一段注释位于第573-576行:
// promptCacheBreakDetection.ts:573-576
// Post PR #19823 BQ analysis:
// when all client-side flags are false and the gap is under TTL, ~90% of breaks
// are server-side routing/eviction or billed/inference disagreement. Label
// accordingly instead of implying a CC bug hunt.
这段注释引用了一次 BigQuery 数据分析的结论:当客户端没有检测到任何变化,且时间间隔在 TTL 之内时,约 90% 的缓存中断归因于服务端。具体原因包括:
- 服务端路由变化:请求被路由到不同的服务器实例,该实例没有缓存
- 服务端缓存驱逐:在高负载期间,服务端主动驱逐低优先级的缓存条目
- 计费/推理不一致:实际推理使用了缓存,但计费系统报告了不同的 token 数
这个发现改变了中断解释的措辞——从暗示“Claude Code 有 bug“变为明确标记“可能是服务端原因“(likely server-side),避免开发者浪费时间追查不存在的客户端问题。
14.6 诊断输出
中断检测的最终输出包含两部分:
Analytics 事件
tengu_prompt_cache_break 事件发送到 BigQuery 用于全量分析:
// promptCacheBreakDetection.ts:590-644
logEvent('tengu_prompt_cache_break', {
systemPromptChanged: changes?.systemPromptChanged ?? false,
toolSchemasChanged: changes?.toolSchemasChanged ?? false,
modelChanged: changes?.modelChanged ?? false,
// ... 所有变化标志
addedTools: (changes?.addedTools ?? []).map(sanitizeToolName).join(','),
removedTools: (changes?.removedTools ?? []).map(sanitizeToolName).join(','),
changedToolSchemas: (changes?.changedToolSchemas ?? []).map(sanitizeToolName).join(','),
addedBetas: (changes?.addedBetas ?? []).join(','),
removedBetas: (changes?.removedBetas ?? []).join(','),
callNumber: state.callCount,
prevCacheReadTokens: prevCacheRead,
cacheReadTokens,
cacheCreationTokens,
timeSinceLastAssistantMsg: timeSinceLastAssistantMsg ?? -1,
lastAssistantMsgOver5minAgo,
lastAssistantMsgOver1hAgo,
requestId: requestId ?? '',
})
Analytics 事件记录了完整的变化标志集合、token 统计、时间间隔和请求 ID,使得后续的 BigQuery 分析可以切分不同维度(按变化类型、按时间窗口、按查询源等)。
调试 Diff 文件与日志
当检测到客户端变化时,系统生成一个 diff 文件,展示前后状态的逐行差异:
// promptCacheBreakDetection.ts:648-660
let diffPath: string | undefined
if (changes?.buildPrevDiffableContent) {
diffPath = await writeCacheBreakDiff(
changes.buildPrevDiffableContent(),
state.buildDiffableContent(),
)
}
const summary = `[PROMPT CACHE BREAK] ${reason} ` +
`[source=${querySource}, call #${state.callCount}, ` +
`cache read: ${prevCacheRead} → ${cacheReadTokens}, ` +
`creation: ${cacheCreationTokens}${diffSuffix}]`
logForDebugging(summary, { level: 'warn' })
diff 文件通过 writeCacheBreakDiff() 生成(第708-727行),使用 createPatch 库创建标准的 unified diff 格式,保存在临时目录中。文件名包含随机后缀避免冲突。
工具名称安全化
中断检测系统需要在 analytics 事件中报告发生变化的工具名称。但 MCP 工具的名称由用户配置,可能包含文件路径或其他敏感信息。sanitizeToolName() 函数(第183-185行)解决了这个问题:
// promptCacheBreakDetection.ts:183-185
// function sanitizeToolName(name: string): string {
return name.startsWith('mcp__') ? 'mcp' : name
}
所有以 mcp__ 开头的工具名称被统一替换为 'mcp',而内置工具的名称是一个固定词汇表,可以安全地包含在 analytics 中。
14.7 完整检测流程
将两个阶段串联起来,完整的缓存中断检测流程如下:
用户输入新查询
│
▼
┌──────────────────────────────────┐
│ 构建 API 请求 │
│ (系统提示词 + 工具 + 消息) │
└────────────────┬─────────────────┘
│
▼
┌──────────────────────────────────┐
│ recordPromptState() [阶段 1] │
│ │
│ ① 计算所有哈希值 │
│ ② 查找 previousState │
│ ③ 无 prev → 创建初始快照 │
│ ④ 有 prev → 逐字段对比 │
│ ⑤ 发现变化 → 生成 PendingChanges │
│ ⑥ 更新 previousState │
└────────────────┬─────────────────┘
│
▼
[发送 API 请求]
│
▼
[收到 API 响应]
│
▼
┌──────────────────────────────────┐
│ checkResponseForCacheBreak() │
│ [阶段 2] │
│ │
│ ① 获取 previousState │
│ ② 排除 haiku 模型 │
│ ③ 检查 cacheDeletionsPending │
│ ④ 计算 token 下降量 │
│ ⑤ 应用双重门槛判定 │
│ (> 5% 且 > 2,000 tokens) │
│ ⑥ 未中断 → 清除 pending, 返回 │
│ ⑦ 中断确认 → 构建解释 │
│ - 有客户端变化 → 列举变化 │
│ - 无变化 + 超 TTL → TTL 过期 │
│ - 无变化 + 未超 TTL → 服务端 │
│ ⑧ 发送 analytics 事件 │
│ ⑨ 写入 diff 文件 │
│ ⑩ 输出调试日志 │
└──────────────────────────────────┘
图 14-2:完整缓存中断检测流程图
14.8 排除模型与清理机制
排除模型
并非所有模型都适合缓存中断检测:
// promptCacheBreakDetection.ts:129-131
// function isExcludedModel(model: string): boolean {
return model.includes('haiku')
}
Haiku 模型因为有不同的缓存行为,被排除在检测之外。这避免了因模型差异导致的误报。
清理机制
系统提供三个清理函数,分别应对不同场景:
// promptCacheBreakDetection.ts:700-706
// Agent 结束时清理其跟踪状态
// export function cleanupAgentTracking(agentId: AgentId): void {
previousStateBySource.delete(agentId)
}
// 完全重置(/clear 命令)
// export function resetPromptCacheBreakDetection(): void {
previousStateBySource.clear()
}
cleanupAgentTracking 在子 Agent 结束时调用,释放其 PreviousState 占用的内存。resetPromptCacheBreakDetection 在用户执行 /clear 时调用,清除所有跟踪状态。
14.9 设计洞察
两阶段是唯一正确的架构
缓存中断检测的两阶段架构不是一个设计选择,而是由问题的时序约束决定的唯一正确方案。原因在于:原始状态只存在于请求发送前,中断确认只能在响应返回后。任何试图在单一阶段完成两项工作的方案都会丢失关键信息。
“90% 服务端“改变了工程决策
发现大部分缓存中断是服务端原因后,Claude Code 团队的优化重点从“消除所有客户端变化“转向“确保客户端变化可控“。这解释了为什么第13章的锁存机制如此重要——它们不需要消除 100% 的缓存中断,只需要确保客户端可控的那 10% 不再出问题。
可观测性先于优化
整个缓存中断检测系统不做任何缓存优化——它纯粹是可观测性基础设施。但正是这套可观测性使得第15章的优化模式成为可能:没有精确的中断检测,就无法量化优化的效果,也无法发现新的优化机会。BigQuery 中的 tengu_prompt_cache_break 事件数据直接驱动了多个优化模式的发现和验证。
用户能做什么
基于本章分析的缓存中断检测机制,以下是监控和诊断缓存中断的实践要点:
-
在你的应用中建立缓存基线:记录正常会话中
cache_read_input_tokens的典型值。没有基线就无法判断下降是否异常。Claude Code 使用双重门槛(>5% 且 >2,000 tokens)来过滤噪声,你也应该根据自己的场景设定合理的阈值。 -
区分客户端变化与服务端原因:当观察到缓存命中率下降时,先检查客户端是否有变化(系统提示词、工具定义、beta header 等)。如果客户端没有变化且时间间隔在 TTL 之内,大概率是服务端路由或驱逐导致的——不要浪费时间追查不存在的客户端 bug。
-
为你的请求建立状态快照机制:如果你需要诊断缓存中断,在每次请求前记录关键状态(系统提示词哈希、工具 Schema 哈希、请求 header 列表)。只有在请求前捕获状态,才能在响应后回溯变化原因。
-
注意 TTL 过期是常见的合法原因:如果用户在两次请求之间有较长停顿(超过 5 分钟或 1 小时,取决于你的 TTL 等级),缓存自然过期是正常现象,不需要特别处理。
-
对工具变化做精细归因:如果你的应用使用动态工具集(MCP 等),在检测到工具 Schema 变化时,进一步区分是工具增删还是单个工具的 Schema 变化。后者更常见(Claude Code 数据显示 77% 的工具变化属于此类),也更容易通过会话级缓存解决。
小结
本章深入分析了 Claude Code 的缓存中断检测系统:
- 两阶段架构:
recordPromptState()在请求前捕获状态并检测变化,checkResponseForCacheBreak()在响应后确认中断并生成诊断 - PreviousState 15+ 字段:覆盖了所有可能影响服务端缓存键的客户端状态
- 中断解释引擎:区分客户端变化、TTL 过期和服务端原因,提供精确的归因
- 数据驱动的洞察:“90% 的中断是服务端原因“这一发现改变了整个缓存优化策略
下一章将转向主动优化——Claude Code 如何通过 7+ 个命名的缓存优化模式,在源头减少缓存中断的发生。