Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第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 两阶段检测架构

设计思路

缓存中断检测面临一个时序问题:

  1. 变化发生在请求发送前:系统提示词变了、工具增删了、beta header 翻转了
  2. 中断确认在响应返回后:只有看到 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(),
  })
}

注意两个关键设计决策:

  1. 排除 defer_loading 工具:API 会自动剥离延迟加载的工具,它们不影响实际的缓存键。包含它们会在工具发现或 MCP 服务器重连时产生误报。
  2. 传入锁存后的值fastModeHeaderLatchedafkHeaderLatchedcacheEditingHeaderLatched 是锁存后的值,而非实时状态。因为缓存键由实际发送的 header 决定,而非用户当前的设置。

阶段 2 在 API 响应处理完成后调用,传入响应中的缓存 token 统计数据。


14.2 PreviousState:全量状态快照

阶段 1 的核心是 PreviousState 类型——它捕获了所有可能影响服务端缓存键的客户端状态。

字段清单

PreviousState 定义在 promptCacheBreakDetection.ts(第28-69行),包含 15+ 个字段:

字段类型作用变化来源
systemHashnumber系统提示词内容哈希(不含 cache_control)提示词内容变化
toolsHashnumber工具 Schema 聚合哈希(不含 cache_control)工具增删或定义变化
cacheControlHashnumber系统块的 cache_control 哈希范围或 TTL 翻转
toolNamesstring[]工具名称列表工具增删
perToolHashesRecord<string, number>每个工具的独立哈希单个工具 Schema 变化
systemCharCountnumber系统提示词字符总数内容增减
modelstring当前模型标识模型切换
fastModebooleanFast Mode 状态(锁存后)Fast Mode 激活
globalCacheStrategystring缓存策略类型MCP 工具发现/移除
betasstring[]排序后的 beta header 列表Beta header 变化
autoModeActivebooleanAFK Mode 状态(锁存后)Auto Mode 激活
isUsingOverageboolean超额使用状态(锁存后)配额状态变化
cachedMCEnabledboolean缓存编辑状态(锁存后)Cached MC 激活
effortValuestring解析后的 effort 值Effort 配置变化
extraBodyHashnumber额外请求体参数哈希CLAUDE_CODE_EXTRA_BODY 变化
callCountnumber当前 tracking key 的调用次数自增
pendingChangesPendingChanges | null阶段 1 检测到的变化阶段 1 对比结果
prevCacheReadTokensnumber | null上次响应的缓存读取 token 数阶段 2 更新
cacheDeletionsPendingboolean是否有 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
}

几个重要的设计决策:

  1. compact 共享 main thread 的跟踪状态:压缩操作使用相同的 cacheSafeParams,共享缓存键,所以应该共享检测状态
  2. 子 Agent 使用 agentId 隔离:防止同类型的多个并发 Agent 实例之间产生误报
  3. 不跟踪的查询源返回 nullspeculationsession_memoryprompt_suggestion 等短生命周期的 Agent 只运行 1-3 轮,没有前后对比的价值
  4. Map 容量上限MAX_TRACKED_SOURCES = 10,防止大量子 Agent 的 agentId 导致内存无限增长

14.3 阶段 1:recordPromptState() 详解

首次调用:建立基线

首次调用 recordPromptState() 时,没有前一个状态可以对比,函数只做两件事:

  1. 检查 Map 容量,如果达到上限则驱逐最旧的条目
  2. 创建初始 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
}

判定使用双重门槛:

  1. 相对阈值:缓存读取 token 数下降超过 5%(< prevCacheRead * 0.95
  2. 绝对阈值:下降超过 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% 的缓存中断归因于服务端。具体原因包括:

  1. 服务端路由变化:请求被路由到不同的服务器实例,该实例没有缓存
  2. 服务端缓存驱逐:在高负载期间,服务端主动驱逐低优先级的缓存条目
  3. 计费/推理不一致:实际推理使用了缓存,但计费系统报告了不同的 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 事件数据直接驱动了多个优化模式的发现和验证。


用户能做什么

基于本章分析的缓存中断检测机制,以下是监控和诊断缓存中断的实践要点:

  1. 在你的应用中建立缓存基线:记录正常会话中 cache_read_input_tokens 的典型值。没有基线就无法判断下降是否异常。Claude Code 使用双重门槛(>5% 且 >2,000 tokens)来过滤噪声,你也应该根据自己的场景设定合理的阈值。

  2. 区分客户端变化与服务端原因:当观察到缓存命中率下降时,先检查客户端是否有变化(系统提示词、工具定义、beta header 等)。如果客户端没有变化且时间间隔在 TTL 之内,大概率是服务端路由或驱逐导致的——不要浪费时间追查不存在的客户端 bug。

  3. 为你的请求建立状态快照机制:如果你需要诊断缓存中断,在每次请求前记录关键状态(系统提示词哈希、工具 Schema 哈希、请求 header 列表)。只有在请求前捕获状态,才能在响应后回溯变化原因。

  4. 注意 TTL 过期是常见的合法原因:如果用户在两次请求之间有较长停顿(超过 5 分钟或 1 小时,取决于你的 TTL 等级),缓存自然过期是正常现象,不需要特别处理。

  5. 对工具变化做精细归因:如果你的应用使用动态工具集(MCP 等),在检测到工具 Schema 变化时,进一步区分是工具增删还是单个工具的 Schema 变化。后者更常见(Claude Code 数据显示 77% 的工具变化属于此类),也更容易通过会话级缓存解决。


小结

本章深入分析了 Claude Code 的缓存中断检测系统:

  1. 两阶段架构recordPromptState() 在请求前捕获状态并检测变化,checkResponseForCacheBreak() 在响应后确认中断并生成诊断
  2. PreviousState 15+ 字段:覆盖了所有可能影响服务端缓存键的客户端状态
  3. 中断解释引擎:区分客户端变化、TTL 过期和服务端原因,提供精确的归因
  4. 数据驱动的洞察:“90% 的中断是服务端原因“这一发现改变了整个缓存优化策略

下一章将转向主动优化——Claude Code 如何通过 7+ 个命名的缓存优化模式,在源头减少缓存中断的发生。