Back to Blog

Claude Code Harness 第14章:缓存中断检测系统

2026-04-05
Claude Code Prompt Cache Cache Break Detection System Design

前言:静默的缓存失效

在第 13 章中,我们看到 Claude Code 通过精心设计的缓存架构——三级缓存范围、锁存机制、智能断点设计——来预防缓存中断的发生。但即使有这些防护措施,缓存中断仍然会发生:

  • MCP 服务器重连后,工具定义可能发生变化
  • 用户在会话中途上传新附件,系统提示词随之增长
  • 功能开关翻转、模型切换、effort 调整,都可能改变 API 请求的前缀
  • GrowthBook 的远程配置更新可能在静默中改变系统行为

更棘手的是,缓存中断是"静默"的。API 响应中的 cache_read_input_tokens 下降了,但没有任何错误信息告诉你为什么。开发者只会注意到成本上升了、延迟增加了,却不知道根因在哪里。

想象一个典型场景:

上午 10:00 - cache_read_input_tokens: 31,000(正常)
上午 10:05 - cache_read_input_tokens: 31,000(正常)
上午 10:10 - cache_read_input_tokens: 12,000(异常下降)
上午 10:15 - cache_read_input_tokens: 12,000(持续低位)

发生了什么?是缓存 TTL 过期了吗?是某个 MCP 工具变化了吗?还是服务端路由问题?没有检测系统,你只能靠猜测。

Claude Code 的解决方案是一套两阶段缓存中断检测系统,它能在请求发送前捕获状态变化,在响应返回后确认中断并生成精确的诊断报告。整个系统实现在 services/api/promptCacheBreakDetection.ts(728 行),是 Claude Code 中为数不多专门服务于可观测性(observability)而非功能的子系统。

本章将深入剖析这套检测系统的设计原理、实现细节和工程洞察,揭示如何构建一套生产级的缓存监控系统。


14.1 两阶段检测架构:时序问题的优雅解法

为什么需要两阶段

缓存中断检测面临一个根本性的时序问题:

  1. 变化发生在请求发送前:系统提示词变了、工具增删了、beta header 翻转了——这些变化都在 API 请求构建时就已经发生
  2. 中断确认在响应返回后:只有看到 cache_read_input_tokens 的下降才能确认缓存确实被击穿了

仅有阶段 2 是不够的——当检测到 token 下降时,请求已经发送,之前的状态已经丢失,无法回溯原因。仅有阶段 1 也不够——很多客户端变化并不一定导致服务端缓存中断(例如,服务端可能恰好还没有缓存该前缀)。

两阶段架构的必要性

sequenceDiagram
    participant Client as Client
    participant Phase1 as Phase 1
recordPromptState() participant API as Anthropic API participant Phase2 as Phase 2
checkResponseForCacheBreak() Client->>Phase1: 1. Capture current state Phase1->>Phase1: 2. Compare with previous state Phase1->>Phase1: 3. Record changes Phase1->>Phase1: 4. Store as pendingChanges Phase1-->>Client: State recorded Client->>API: 5. Send API request API-->>Client: 6. Return response
(cache token stats) Client->>Phase2: 7. Pass cache tokens Phase2->>Phase2: 8. Check token drop Phase2->>Phase2: 9. Explain with Phase 1 changes Phase2->>Phase2: 10. Output diagnostic info Phase2->>Phase2: 11. Send analytics event Phase2-->>Client: Detection complete

核心设计原则

  • 阶段 1 负责记录"什么变了"——捕获所有可能影响缓存键的客户端状态变化
  • 阶段 2 负责确认"是否真中断了"——通过 cache tokens 统计确认缓存是否真正失效
  • 两阶段协同:阶段 1 的变化清单 + 阶段 2 的中断确认 = 精确的根因分析

调用位置:在正确的时机拦截

两个阶段的调用位置在 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,             // Agent ID
    fastMode: fastModeHeaderLatched,      // 锁存后的 Fast Mode 状态
    globalCacheStrategy,                  // 缓存策略
    betas,                                // Beta header 列表
    autoModeActive: afkHeaderLatched,     // 锁存后的 AFK 状态
    isUsingOverage: currentLimits.isUsingOverage ?? false,
    cachedMCEnabled: cacheEditingHeaderLatched,
    effortValue: effort,                  // Effort 配置
    extraBodyParams: getExtraBodyParams(),
  })
}

两个关键设计决策

  1. 排除 defer_loading 工具

    • API 会自动剥离延迟加载的工具
    • 它们不影响实际的缓存键
    • 包含它们会在工具发现或 MCP 重连时产生误报
  2. 传入锁存后的值

    • fastModeHeaderLatchedafkHeaderLatched 等是锁存后的值
    • 缓存键由实际发送的 header 决定,而非用户当前的设置
    • 使用实时状态会产生大量误报

阶段 2 在 API 响应处理完成后调用:

// services/api/claude.ts(响应处理示意)
const response = await anthropic.messages.create(...)
const cacheReadTokens = response.usage.cache_read_input_tokens
const cacheCreationTokens = response.usage.cache_creation_input_tokens

if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
  checkResponseForCacheBreak({
    querySource: options.querySource,
    agentId: options.agentId,
    cacheReadTokens,
    cacheCreationTokens,
    requestId: response.id,
  })
}

阶段 2 接收响应中的缓存统计信息,结合阶段 1 记录的状态变化,生成最终的诊断报告。


14.2 PreviousState:全量状态快照的艺术

阶段 1 的核心是 PreviousState 类型——它捕获了所有可能影响服务端缓存键的客户端状态。这个类型设计体现了全量快照的思想:宁可记录多一些,也不要遗漏关键状态。

字段清单:15+ 个状态维度

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 完整字段清单

这些字段覆盖了三个维度的状态:

  1. 内容维度systemHashtoolsHashsystemCharCount——实际缓存的内容
  2. 元数据维度modelfastModebetaseffortValue——影响缓存键的元数据
  3. 控制维度pendingChangesprevCacheReadTokenscacheDeletionsPending——检测流程控制

哈希策略:分离内容与控制

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
// 移除 cache_control 标记
const strippedSystem = stripCacheControl(system)
const systemHash = computeHash(strippedSystem)

// 只提取 cache_control 标记
const cacheControlHash = computeHash(
  system.map(b => ('cache_control' in b ? b.cache_control : null)),
)

为什么要分离?

假设场景:缓存范围从 global 翻转到 org,或者 TTL 从 1 小时翻转到 5 分钟。这些翻转只改变 cache_control 标记,不改变提示词的文本内容。

  • 只有 systemHash:这些翻转会被遗漏(因为内容没变)
  • 只有 cacheControlHash:无法检测到内容变化(因为 cache_control 没变)
  • 两者分离:可以精确检测到内容和控制标记的独立变化

这种分离设计体现了关注点分离的原则——内容变化和控制变化是两个独立的维度,应该分别追踪。

perToolHashes:按需计算的精细诊断

perToolHashes 是一个逐工具的哈希表,用于精确定位是哪个工具发生了变化:

// promptCacheBreakDetection.ts:285-286
const computeToolHashes = () =>
  computePerToolHashes(strippedTools, toolNames)

性能优化设计

  • 逐工具计算哈希的成本较高(N 次 jsonStringify
  • 因此只在 toolsHash 变化时才触发计算
  • 首次调用时立即计算(建立基线)
  • 后续调用时懒计算(只在需要时)

数据驱动的设计

注释(第 37 行)引用了 BigQuery 数据分析结论:

77% 的工具 Schema 变化是单个工具的描述改变,而非工具增删

perToolHashes 正是为了精确诊断这 77% 的场景:

  • 工具增删:通过 toolNames 的集合差运算检测
  • 工具 Schema 变化:通过 perToolHashes 的哈希差运算检测

这种分层检测策略(粗粒度 → 精细粒度)平衡了性能和准确性。

跟踪键与隔离策略:防止状态污染

每个查询源(query source)维护独立的 PreviousState,存储在一个 Map 中:

// promptCacheBreakDetection.ts:101-107
const previousStateBySource = new Map()

const MAX_TRACKED_SOURCES = 10

const TRACKED_SOURCE_PREFIXES = [
  'repl_main_thread',
  'sdk',
  'agent:custom',
  'agent:default',
  'agent:builtin',
]

跟踪键计算逻辑

// promptCacheBreakDetection.ts:149-158
function getTrackingKey(
  querySource: QuerySource,
  agentId?: AgentId,
): string | null {
  // compact 共享 main thread 的状态
  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 共享状态

    • 压缩操作使用相同的 cacheSafeParams
    • 共享缓存键,所以应该共享检测状态
    • 避免重复记录和误报
  2. 子 Agent 隔离

    • 使用 agentId 隔离,而不是 querySource
    • 防止同类型的多个并发 Agent 实例之间产生误报
    • 例如:两个 agent:custom 实例应该有独立的状态
  3. 不跟踪短生命周期查询

    • speculationsession_memoryprompt_suggestion 返回 null
    • 这些 Agent 只运行 1-3 轮,没有前后对比的价值
    • 节省内存和计算资源
  4. Map 容量上限

    • MAX_TRACKED_SOURCES = 10
    • 防止大量子 Agent 的 agentId 导致内存无限增长
    • 使用 LRU 驱逐策略(最旧的条目先被删除)

14.3 阶段 1:recordPromptState() 详解

阶段 1 的核心函数 recordPromptState() 负责在每次 API 请求前捕获状态并检测变化。这个函数的设计体现了增量式状态管理的思想。

首次调用:建立基线

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

flowchart TD
    A["recordPromptState() First Call"] --> B{Map Capacity Check}
    B -->|Not Full| C["Create New State"]
    B -->|Full| D["Evict Oldest Entry"]
    D --> C
    
    C --> E["Compute All Hashes"]
    E --> F["Build PreviousState"]
    F --> G["pendingChanges = null"]
    G --> H["callCount = 1"]
    H --> I["Store in Map"]
    I --> J["Return"]
    
    style C fill:#90EE90
    style G fill:#FFD700

代码实现(第 298-328 行):

// promptCacheBreakDetection.ts:298-328(简化)
if (!prev) {
  // ① 检查 Map 容量
  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,
    systemCharCount,
    model,
    fastMode: isFastMode,
    globalCacheStrategy,
    betas: sortedBetas,
    autoModeActive,
    isUsingOverage,
    cachedMCEnabled,
    effortValue: effortStr,
    extraBodyHash,
    callCount: 1,
    pendingChanges: null,  // 首次调用无变化
    prevCacheReadTokens: null,
    cacheDeletionsPending: false,
    buildDiffableContent: lazyDiffableContent,
    perToolHashes: computeToolHashes(),
  })
  return
}

关键点

  • pendingChanges = null 表示无变化(因为没有前次状态)
  • callCount = 1 初始化调用计数器
  • prevCacheReadTokens = null 表示还没有缓存基线

后续调用:变化检测算法

后续调用时,函数逐字段对比当前值与前一个状态:

// 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:353-378(简化)
if (toolSchemasChanged) {
  const newToolSet = new Set(toolNames)
  const prevToolSet = new Set(prev.toolNames)
  
  // ① 检测新增工具
  for (const name of toolNames) {
    if (!prevToolSet.has(name)) addedTools.push(name)
  }
  
  // ② 检测移除工具
  for (const name of prev.toolNames) {
    if (!newToolSet.has(name)) removedTools.push(name)
  }
  
  // ③ 检测 Schema 变化
  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
}

三层检测逻辑

flowchart LR
    A[toolsHash Changed] --> B[Set Difference
Detect Add/Remove] B --> C[addedTools] B --> D[removedTools] B --> E[Hash Difference
Detect Schema Changes] E --> F[changedToolSchemas] C --> G["PendingChanges"] D --> G F --> G style A fill:#FFB6C1 style G fill:#90EE90

数据驱动的价值

  • 新增工具:通常来自 MCP 服务器连接或工具发现
  • 移除工具:通常来自 MCP 服务器断开或工具卸载
  • Schema 变化:通常来自 AgentTool/SkillTool 的动态描述更新

区分这三类变化,可以帮助开发者快速定位问题根源:

"缓存中断了,是 MCP 服务器断开(工具移除)还是 Agent 列表更新(Schema 变化)?"


14.4 阶段 2:checkResponseForCacheBreak() 详解

阶段 2 在 API 响应返回后调用,核心逻辑是判断缓存是否真正被击穿。这个函数的设计体现了保守但准确的原则——宁可漏报,也不要误报。

中断判定标准:双重门槛

阶段 2 使用双重门槛来判定缓存中断:

// promptCacheBreakDetection.ts:485-493
const tokenDrop = prevCacheRead - cacheReadTokens

if (
  cacheReadTokens >= prevCacheRead * 0.95 ||  // 相对门槛
  tokenDrop < MIN_CACHE_MISS_TOKENS          // 绝对门槛
) {
  // 未达到中断标准,清除 pendingChanges
  state.pendingChanges = null
  return
}

// 确认中断

双重门槛的作用

  1. 相对阈值(95%)

    • 缓存读取 token 数下降超过 5%
    • 避免小幅自然波动触发误报
    • 例如:31,000 → 29,450(下降 5%)不会触发
  2. 绝对阈值(2,000 tokens)

    • MIN_CACHE_MISS_TOKENS = 2_000
    • 避免基线较小时的比例放大
    • 例如:1,000 → 950(下降 5%),但只有 50 tokens,不值得告警

两个条件必须同时满足

flowchart TD
    A[Receive API Response] --> B[计算 token 下降量]
    B --> C{tokenDrop >= 2,000?}
    C -->|No| D[Below Absolute Threshold
No Report] C -->|Yes| E{cacheRead < prev * 0.95?} E -->|No| D E -->|Yes| F[Confirm Cache Break] style D fill:#90EE90 style F fill:#FFB6C1

为什么需要双重门槛?

考虑两个极端场景:

  1. 场景 1:小幅波动

    • 基线:31,000 tokens
    • 当前:30,500 tokens(下降 1.6%)
    • 判定:未达 5% 门槛 → 不报告
    • 原因:可能是正常的 token 计数浮动
  2. 场景 2:比例放大

    • 基线:1,000 tokens(可能是短会话)
    • 当前:950 tokens(下降 5%)
    • 判定:未达 2,000 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
}

工作流程

sequenceDiagram
    participant CacheEdit as Cache Edit Module
    participant Detect as Detection System
    participant API as Anthropic API
    
    CacheEdit->>Detect: notifyCacheDeletion()
    Detect->>Detect: cacheDeletionsPending = true
    
    CacheEdit->>API: Send cache_edits delete request
    API-->>CacheEdit: Return response (cache tokens dropped)
    
    CacheEdit->>Detect: checkResponseForCacheBreak()
    Detect->>Detect: Check cacheDeletionsPending
    Detect->>Detect: Mark as expected, no report

特殊情况处理:Compaction

压缩操作(/compact)会大幅减少消息数量,导致缓存读取 token 数自然下降。notifyCompaction() 函数通过重置基线来处理这种情况:

// 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  // 重置基线
  }
}

重置策略

  • prevCacheReadTokens = null 表示"没有前次基线"
  • 下一次调用被视为"首次调用",不做对比
  • 避免压缩后的大量"误报中断"

为什么不跟踪压缩后的变化?

压缩是一个破坏性操作——它改变了消息历史结构。压缩前的缓存基线与压缩后不可比,因此重置是最合理的策略。


14.5 中断解释引擎:从数据到洞察

当确认缓存中断后,系统使用阶段 1 收集的 PendingChanges 构建人类可读的解释。这个解释引擎是整个检测系统的"大脑",它将冰冷的数据转化为可操作的洞察。

客户端归因:精确的根因分析

如果 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 charDelta = changes.systemCharDelta
    const charInfo = charDelta > 0 
      ? ` (+${charDelta} chars)` 
      : ` (${charDelta} chars)`
    parts.push(`system prompt changed${charInfo}`)
  }
  
  // ③ 工具 Schema 变化
  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}`)
  }
  
  // ④ Beta header 变化
  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(' ')})`)
  }
  
  // ... 其他字段的解释逻辑类似
}

解释引擎的设计原则

  1. 具体胜于抽象

    • 不是简单地说"缓存中断了"
    • 而是精确列出"哪些字段变化了、变化了多少"
  2. 可操作的描述

    • model changed (claude-sonnet-4-5 → claude-opus-4-5) → 知道是模型切换
    • tools changed (+2/-0 tools) → 知道有新工具加入
    • betas changed (+computer-use-20241022) → 知道是新 beta 启用

cacheControl 变化的独立报告逻辑

在解释引擎中,cacheControlChanged 有一个特殊的报告条件:

// promptCacheBreakDetection.ts:528-535
if (
  changes.cacheControlChanged &&
  !changes.globalCacheStrategyChanged &&
  !changes.systemPromptChanged
) {
  parts.push('cache_control changed (scope or TTL)')
}

为什么不总是报告 cacheControl 变化?

因为 cacheControl 的变化往往是其他变化的后果,而不是根因:

  1. 全局缓存策略变化

    • tool_based 切换到 system_prompt
    • cache_control 标记必然变化
    • 但根因是策略变化,而非 cache_control 本身
  2. 系统提示词内容变化

    • 新增了一个内容块
    • cache_control 标记可能重新分布
    • 但根因是内容变化,而非 cache_control 本身

只有在策略和内容都没变,只有 cache_control 变了的情况下,才单独报告这种情况——这通常意味着 TTL 翻转(5 分钟 ↔ 1 小时)或范围翻转(global ↔ org)。

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) {
  // 无变化 + 超 1 小时 → 1 小时 TTL 过期
  reason = 'possible 1h TTL expiry (prompt unchanged)'
} else if (lastAssistantMsgOver5minAgo) {
  // 无变化 + 超 5 分钟 → 5 分钟 TTL 过期
  reason = 'possible 5min TTL expiry (prompt unchanged)'
} else if (timeSinceLastAssistantMsg !== null) {
  // 无变化 + 未超 TTL → 可能是服务端原因
  reason = 'likely server-side (prompt unchanged, <5min gap)'
} else {
  // 无时间数据 → 未知原因
  reason = 'unknown cause'
}

TTL 过期检测逻辑

flowchart TD
    A[Confirm Cache Break] --> B{有客户端变化?}
    B -->|Yes| C[Report Client Changes]
    B -->|No| D{Over 1 Hour?}
    D -->|Yes| E[Possible 1h TTL Expiry]
    D -->|No| F{Over 5 Minutes?}
    F -->|Yes| G[Possible 5min TTL Expiry]
    F -->|No| H[Likely Server-Side]
    
    style C fill:#FFB6C1
    style E fill:#FFD700
    style G fill:#FFD700
    style H fill:#87CEEB

时间间隔计算

系统通过查找消息历史中最近的 assistant 消息时间戳来计算:

// 伪代码:展示时间戳查找逻辑
const messages = getConversationHistory()
const lastAssistantMsg = messages
  .filter(m => m.role === 'assistant')
  .sort((a, b) => b.timestamp - a.timestamp)[0]

const timeSinceLastAssistantMsg = lastAssistantMsg 
  ? Date.now() - lastAssistantMsg.timestamp 
  : null

TTL 常量定义

// 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. 服务端路由变化

    • 请求被路由到不同的服务器实例
    • 该实例没有之前的缓存
    • 导致 cache miss
  2. 服务端缓存驱逐

    • 在高负载期间,服务端主动驱逐低优先级的缓存条目
    • 释放内存给更热门的请求
    • 导致 cache miss
  3. 计费/推理不一致

    • 实际推理使用了缓存
    • 但计费系统报告了不同的 token 数
    • 可能是内部系统的异步更新延迟

这个发现改变了工程决策

  • 之前的思路:"缓存中断了,一定是客户端有 bug,快去修"
  • 之后的思路:"缓存中断了,90% 概率是服务端原因,不要浪费时间追查客户端"

这也解释了为什么 Claude Code 团队后来在缓存优化上如此从容——既然大部分中断是服务端原因,客户端只需要控制好自己能控制的那 10%


14.6 诊断输出:可观测性的最后一公里

中断检测的最终输出包含两部分:analytics 事件用于全量分析,调试日志用于实时诊断。

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,
  fastModeChanged: changes?.fastModeChanged ?? false,
  cacheControlChanged: changes?.cacheControlChanged ?? false,
  globalCacheStrategyChanged: changes?.globalCacheStrategyChanged ?? false,
  betasChanged: changes?.betasChanged ?? false,
  autoModeChanged: changes?.autoModeChanged ?? false,
  overageChanged: changes?.overageChanged ?? false,
  cachedMCChanged: changes?.cachedMCChanged ?? false,
  effortChanged: changes?.effortChanged ?? false,
  extraBodyChanged: changes?.extraBodyChanged ?? false,
  
  // 工具变化详情
  addedTools: (changes?.addedTools ?? []).map(sanitizeToolName).join(','),
  removedTools: (changes?.removedTools ?? []).map(sanitizeToolName).join(','),
  changedToolSchemas: (changes?.changedToolSchemas ?? []).map(sanitizeToolName).join(','),
  
  // Beta header 变化详情
  addedBetas: (changes?.addedBetas ?? []).join(','),
  removedBetas: (changes?.removedBetas ?? []).join(','),
  
  // Token 统计
  callNumber: state.callCount,
  prevCacheReadTokens: prevCacheRead,
  cacheReadTokens,
  cacheCreationTokens,
  
  // 时间信息
  timeSinceLastAssistantMsg: timeSinceLastAssistantMsg ?? -1,
  lastAssistantMsgOver5minAgo,
  lastAssistantMsgOver1hAgo,
  
  // 调试支持
  requestId: requestId ?? '',
})

Analytics 事件的价值

  1. 全量分析:收集所有中断事件,不仅限于异常情况
  2. 多维切分:可以按变化类型、时间窗口、查询源等维度分析
  3. 趋势监控:发现缓存健康状态的长期趋势
  4. 异常检测:识别突然上升的中断频率

典型的 BigQuery 查询

-- 按变化类型统计中断频率
SELECT
  systemPromptChanged,
  toolSchemasChanged,
  modelChanged,
  COUNT(*) as break_count,
  AVG(prevCacheReadTokens - cacheReadTokens) as avg_token_drop
FROM `tengu_prompt_cache_break`
WHERE DATE(timestamp) = CURRENT_DATE()
GROUP BY 1, 2, 3
ORDER BY break_count DESC

调试 Diff 文件:可读的对比视图

当检测到客户端变化时,系统生成一个 diff 文件,展示前后状态的逐行差异:

// promptCacheBreakDetection.ts:648-660(简化)
let diffPath: string | undefined
if (changes?.buildPrevDiffableContent) {
  diffPath = await writeCacheBreakDiff(
    changes.buildPrevDiffableContent(),  // 前次状态
    state.buildDiffableContent(),        // 当前状态
  )
}

const diffSuffix = diffPath ? `, diff: ${diffPath}` : ''

const summary = `[PROMPT CACHE BREAK] ${reason} ` +
  `[source=${querySource}, call #${state.callCount}, ` +
  `cache read: ${prevCacheRead} → ${cacheReadTokens}, ` +
  `creation: ${cacheCreationTokens}${diffSuffix}]`

logForDebugging(summary, { level: 'warn' })

Diff 文件格式(unified diff):

--- before
+++ after
@@ -5,7 +5,7 @@
 {
   "name": "agentTool",
   "description": "Run sub-agents. Available agents: custom-deployer",
-  "input_schema": {...}
+  "input_schema": {...}
 }

日志输出示例

[PROMPT CACHE BREAK] tools changed (+1/-0 tools) [source=repl_main_thread, call #42, cache read: 31000 → 12000, creation: 5000, diff: /tmp/cache-break-abc123.diff]

工具名称安全化:隐私保护

中断检测系统需要在 analytics 事件中报告发生变化的工具名称。但 MCP 工具的名称由用户配置,可能包含文件路径或其他敏感信息。

sanitizeToolName() 函数解决了这个问题:

// promptCacheBreakDetection.ts:183-185
function sanitizeToolName(name: string): string {
  return name.startsWith('mcp__') ? 'mcp' : name
}

安全化逻辑

  • 所有以 mcp__ 开头的工具名称被统一替换为 'mcp'
  • 内置工具的名称是一个固定词汇表(readwritebash 等),可以安全地包含在 analytics 中
  • 避免泄露用户的文件路径、服务器名称等敏感信息

示例

sanitizeToolName('read')           // → 'read'(内置工具,保留)
sanitizeToolName('mcp__postgres')  // → 'mcp'(MCP 工具,脱敏)
sanitizeToolName('mcp__fs-./home/user/secret')  // → 'mcp'(脱敏)

14.7 完整检测流程:端到端视角

将两个阶段串联起来,完整的缓存中断检测流程如下:

flowchart TD
    Start([User Input]) --> Build[Build API Request
System + Tools + Messages] Build --> Phase1[recordPromptState
Phase 1] Phase1 --> Calc1[1. Compute All Hashes] Calc1 --> Lookup[2. Find previousState] Lookup --> NoPrev{Has prev?} NoPrev -->|No| Init[3. Create Initial Snapshot
pendingChanges = null] NoPrev -->|Yes| Compare[3. Compare Field by Field] Init --> Update1[4. Update previousState] Compare --> HasChanges{Changes?} HasChanges -->|No| Update1 HasChanges -->|Yes| BuildChanges[4. Build PendingChanges] BuildChanges --> Update1 Update1 --> Send[Send API Request] Send --> Receive[Receive API Response] Receive --> Phase2[checkResponseForCacheBreak
Phase 2] Phase2 --> GetState[1. Get previousState] GetState --> CheckModel{Is haiku model?} CheckModel -->|Yes| Skip[Exclude Detection] CheckModel -->|No| CheckDeletion[2. Check cacheDeletionsPending] CheckDeletion --> IsDeletion{Deletion Pending?} IsDeletion -->|Yes| ClearPending[Clear pendingChanges
No Report] IsDeletion -->|No| CalcDrop[3. Calculate Token Drop] CalcDrop --> CheckThreshold{Dual Threshold?} CheckThreshold -->|No| ClearPending CheckThreshold -->|Yes| Explain[7. Build Explanation] Explain --> HasClientChanges{Client Changes?} HasClientChanges -->|Yes| ClientReason[Report Client Changes] HasClientChanges -->|No| CheckTTL{Time Interval Check} CheckTTL --> Over1h{Over 1 Hour?} Over1h -->|Yes| TTL1h[Possible 1h TTL Expiry] Over1h -->|No| Over5min{Over 5 Minutes?} Over5min -->|Yes| TTL5min[Possible 5min TTL Expiry] Over5min -->|No| ServerReason[Likely Server-Side] ClientReason --> Analytics[8. Send Analytics Event] TTL1h --> Analytics TTL5min --> Analytics ServerReason --> Analytics Analytics --> WriteDiff[9. Write Diff File] WriteDiff --> Log[10. Output Debug Log] Log --> End([Detection Complete]) ClearPending --> End Skip --> End style Phase1 fill:#FFD700 style Phase2 fill:#87CEEB style Explain fill:#FFB6C1 style Analytics fill:#90EE90

流程要点

  1. 阶段 1 在请求前执行,捕获状态并检测变化
  2. 阶段 2 在响应后执行,确认中断并生成诊断
  3. 双重门槛确保只有显著的下降才触发告警
  4. 特殊情况处理避免误报(cache deletion、compaction)
  5. 解释引擎提供精确的根因分析
  6. Analytics + Diff提供全量和实时的可观测性

14.8 排除模型与清理机制

排除模型:避免误报

并非所有模型都适合缓存中断检测:

// promptCacheBreakDetection.ts:129-131
function isExcludedModel(model: string): boolean {
  return model.includes('haiku')
}

Haiku 模型被排除的原因

  1. 不同的缓存行为

    • Haiku 可能有不同的缓存实现
    • Token 计数方式可能不同
  2. 使用场景不同

    • Haiku 通常用于快速、低成本的请求
    • 缓存优化不是优先级

在阶段 2 中调用

// promptCacheBreakDetection.ts:468-471
if (isExcludedModel(state.model)) {
  state.pendingChanges = null
  return
}

清理机制:内存管理

系统提供三个清理函数,分别应对不同场景:

// promptCacheBreakDetection.ts:700-714

// Agent 结束时清理其跟踪状态
export function cleanupAgentTracking(agentId: AgentId): void {
  previousStateBySource.delete(agentId)
}

// 压缩后重置基线
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
  }
}

// 完全重置(/clear 命令)
export function resetPromptCacheBreakDetection(): void {
  previousStateBySource.clear()
}

清理场景

  1. Agent 结束

    • 子 Agent 完成任务后释放内存
    • 避免长时间运行的会话积累过多状态
  2. 压缩操作

    • 消息历史结构变化,旧基线不可用
    • 重置为"首次调用"状态
  3. 用户清理

    • 用户执行 /clear 命令
    • 清除所有跟踪状态,重新开始

14.9 设计洞察:工程智慧的结晶

洞察 1:两阶段是唯一正确的架构

缓存中断检测的两阶段架构不是一个设计选择,而是由问题的时序约束决定的唯一正确方案

原因在于

  • 原始状态只存在于请求发送前
  • 中断确认只能在响应返回后
  • 任何试图在单一阶段完成两项工作的方案都会丢失关键信息

类比

  • 这就像调试——你需要记录"执行前的状态"(阶段 1)和"执行后的结果"(阶段 2)
  • 只有对比两者,才能推断出"发生了什么"

洞察 2:"90% 服务端"改变了工程决策

发现大部分缓存中断是服务端原因后,Claude Code 团队的优化重点发生了根本性转变:

之前的思路

"缓存中断了,一定是客户端有 bug,要消除所有客户端变化"

之后的思路

"缓存中断了,90% 概率是服务端原因,客户端只需要控制好自己能控制的那 10%"

这解释了为什么:

  • 第 13 章的锁存机制不需要追求 100% 的完美
  • 缓存架构的目标是"足够好"而不是"完美"
  • 团队可以从容地接受一定比例的缓存中断

工程智慧

不要为无法控制的问题过度优化。专注于你能控制的 10%,接受剩下的 90%。

洞察 3:可观测性先于优化

整个缓存中断检测系统不做任何缓存优化——它纯粹是可观测性基础设施。

但正是这套可观测性:

  • 使得第 15 章的优化模式成为可能
  • 发现了"90% 服务端原因"这一关键洞察
  • 量化了各种变化对缓存的影响程度
  • 监控了缓存健康状态的长期趋势

类比

  • 这就像医疗诊断——先有精确的诊断系统,才能有有效的治疗方案
  • 没有诊断数据,所有优化都是盲人摸象

工程启示

在优化之前,先建立可观测性。没有数据,就没有优化。


14.10 实践指南:构建缓存监控系统

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

1. 建立缓存基线:无基线,不诊断

记录正常会话中 cache_read_input_tokens 的典型值:

// 伪代码:建立基线
const cacheBaseline = {
  normal: 31000,      // 正常情况
  warning: 20000,     // 警告阈值
  critical: 10000,    // 严重阈值
}

const healthStatus = cacheReadTokens > cacheBaseline.warning 
  ? 'healthy' 
  : cacheReadTokens > cacheBaseline.critical 
    ? 'degraded' 
    : 'critical'

Claude Code 的双重门槛

  • 相对阈值:下降超过 5%
  • 绝对阈值:下降超过 2,000 tokens

你也应该根据自己的场景设定合理的阈值。

2. 区分客户端变化与服务端原因

当观察到缓存命中率下降时,先检查客户端是否有变化:

// 伪代码:根因分析
if (hasClientChanges()) {
  // 客户端变化 → 可控
  return 'client-side: fix the changes'
} else if (timeSinceLastCall > TTL) {
  // TTL 过期 → 正常
  return 'ttl-expiry: expected behavior'
} else {
  // 服务端原因 → 不可控
  return 'server-side: accept and monitor'
}

关键原则

  • 不要浪费时间追查不存在的客户端 bug
  • 90% 的中断是服务端原因,接受这个现实

3. 为你的请求建立状态快照机制

在每次请求前记录关键状态:

// 伪代码:状态快照
interface RequestState {
  systemPromptHash: number
  toolSchemasHash: number
  headersHash: number
  timestamp: number
}

const states = new Map()

function recordState(requestId: string, state: RequestState) {
  states.set(requestId, state)
}

function detectChanges(requestId: string, newState: RequestState) {
  const prevState = states.get(requestId)
  if (!prevState) return null
  
  return {
    systemPromptChanged: prevState.systemPromptHash !== newState.systemPromptHash,
    toolSchemasChanged: prevState.toolSchemasHash !== newState.toolSchemasHash,
    headersChanged: prevState.headersHash !== newState.headersHash,
  }
}

关键点

  • 在请求捕获状态,才能在响应回溯原因
  • 使用哈希而不是完整内容,减少内存占用

4. 注意 TTL 过期是常见的合法原因

如果用户在两次请求之间有较长停顿,缓存自然过期是正常现象:

// 伪代码:TTL 检查
const TTL_5MIN = 5 * 60 * 1000
const TTL_1HOUR = 60 * 60 * 1000

const timeGap = Date.now() - lastRequestTime

if (timeGap > TTL_1HOUR) {
  return '1h TTL expiry: expected'
} else if (timeGap > TTL_5MIN) {
  return '5min TTL expiry: expected'
} else {
  return 'cache break: investigate'
}

不要误报

  • TTL 过期不是 bug,是正常行为
  • 不需要特别处理或告警

5. 对工具变化做精细归因

如果你的应用使用动态工具集,在检测到工具 Schema 变化时,进一步区分是工具增删还是单个工具的 Schema 变化:

// 伪代码:工具变化归因
function analyzeToolChanges(prevTools: string[], newTools: string[]) {
  const prevSet = new Set(prevTools)
  const newSet = new Set(newTools)
  
  const added = newTools.filter(t => !prevSet.has(t))
  const removed = prevTools.filter(t => !newSet.has(t))
  
  return {
    added,
    removed,
    count: added.length + removed.length,
    type: added.length > 0 || removed.length > 0 ? 'add-remove' : 'schema-change'
  }
}

数据驱动

  • Claude Code 数据显示 77% 的工具变化是 Schema 变化
  • 这类变化更容易通过会话级缓存解决

6. 发送 analytics 事件用于长期分析

将缓存中断事件发送到你的分析系统:

// 伪代码:analytics 事件
analytics.track('cache_break', {
  reason: 'tools changed',
  addedTools: 2,
  removedTools: 0,
  prevCacheTokens: 31000,
  currentCacheTokens: 12000,
  timeSinceLastRequest: 120000,
  model: 'claude-sonnet-4-5',
  userId: 'user-123',
})

价值

  • 发现长期趋势
  • 识别高频中断原因
  • 量化优化效果

14.11 小结:可观测性是优化的前提

本章深入剖析了 Claude Code 的缓存中断检测系统,揭示了一套生产级缓存监控系统的设计与实现。

核心机制

  1. 两阶段架构

    • 阶段 1:请求前捕获状态并检测变化
    • 阶段 2:响应后确认中断并生成诊断
    • 两阶段协同:变化清单 + 中断确认 = 精确根因分析
  2. PreviousState 全量快照

    • 15+ 个字段覆盖所有可能影响缓存键的状态
    • 哈希策略分离内容与控制
    • 按需计算平衡性能和准确性
  3. 中断解释引擎

    • 区分客户端变化、TTL 过期、服务端原因
    • 提供精确的归因和可操作的洞察
    • "90% 的中断是服务端原因"改变了工程决策
  4. 诊断输出

    • Analytics 事件用于全量分析
    • Diff 文件用于实时诊断
    • 工具名称安全化保护隐私

设计原则

  1. 两阶段是唯一正确的架构:由时序约束决定,不是设计选择
  2. 可观测性先于优化:没有数据,就没有优化
  3. 接受不可控的因素:专注于你能控制的 10%,接受剩下的 90%
  4. 保守但准确的判定:宁可漏报,也不要误报

工程价值

  • 成本优化:识别缓存中断原因,降低不必要的成本
  • 性能优化:监控缓存健康状态,改善用户体验
  • 数据驱动:通过 analytics 数据指导优化方向
  • 快速诊断:diff 文件和日志帮助快速定位问题

这套检测系统本身不做任何缓存优化,但它为第 15 章的优化模式提供了数据基础诊断工具。没有这套可观测性系统,所有的缓存优化都是盲人摸象——你不知道优化有没有效果,也不知道下一步应该优化什么。

这正是工程智慧的体现:在优化之前,先建立可观测性。有了可观测性,优化自然水到渠成。


参考资源

相关代码

API 文档

相关章节

  • 第 13 章:缓存架构与断点设计(缓存范围、TTL、锁存机制)
  • 第 15 章:缓存优化模式(工具定义缓存、MCP 工具处理)
  • 第 16 章:高级缓存策略(多级缓存、预热机制)

下一步

  • 第 15 章:缓存优化模式(7+ 个命名模式,在源头减少缓存中断)
  • 第 16 章:高级缓存策略(会话级缓存、预热机制、智能降级)

关于作者

本文是《Claude Code Harness》系列的第 14 章,深入解析缓存中断检测系统的设计与实现。这个系列旨在为 AI Native Engineer 提供构建生产级 AI 应用的深入指导。

欢迎在 GitHub 上提交问题和改进建议!

Enjoyed this article? Share it with others!