Claude Code Harness 第14章:缓存中断检测系统
前言:静默的缓存失效
在第 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 两阶段检测架构:时序问题的优雅解法
为什么需要两阶段
缓存中断检测面临一个根本性的时序问题:
- 变化发生在请求发送前:系统提示词变了、工具增删了、beta header 翻转了——这些变化都在 API 请求构建时就已经发生
- 中断确认在响应返回后:只有看到
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(),
})
}两个关键设计决策:
排除 defer_loading 工具:
- API 会自动剥离延迟加载的工具
- 它们不影响实际的缓存键
- 包含它们会在工具发现或 MCP 重连时产生误报
传入锁存后的值:
fastModeHeaderLatched、afkHeaderLatched等是锁存后的值- 缓存键由实际发送的 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 完整字段清单
这些字段覆盖了三个维度的状态:
- 内容维度:
systemHash、toolsHash、systemCharCount——实际缓存的内容 - 元数据维度:
model、fastMode、betas、effortValue——影响缓存键的元数据 - 控制维度:
pendingChanges、prevCacheReadTokens、cacheDeletionsPending——检测流程控制
哈希策略:分离内容与控制
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
}关键设计决策:
compact 共享状态:
- 压缩操作使用相同的
cacheSafeParams - 共享缓存键,所以应该共享检测状态
- 避免重复记录和误报
- 压缩操作使用相同的
子 Agent 隔离:
- 使用
agentId隔离,而不是querySource - 防止同类型的多个并发 Agent 实例之间产生误报
- 例如:两个
agent:custom实例应该有独立的状态
- 使用
不跟踪短生命周期查询:
speculation、session_memory、prompt_suggestion返回null- 这些 Agent 只运行 1-3 轮,没有前后对比的价值
- 节省内存和计算资源
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
}
// 确认中断双重门槛的作用:
相对阈值(95%):
- 缓存读取 token 数下降超过 5%
- 避免小幅自然波动触发误报
- 例如:31,000 → 29,450(下降 5%)不会触发
绝对阈值(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:小幅波动
- 基线:31,000 tokens
- 当前:30,500 tokens(下降 1.6%)
- 判定:未达 5% 门槛 → 不报告
- 原因:可能是正常的 token 计数浮动
场景 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(' ')})`)
}
// ... 其他字段的解释逻辑类似
}解释引擎的设计原则:
具体胜于抽象:
- 不是简单地说"缓存中断了"
- 而是精确列出"哪些字段变化了、变化了多少"
可操作的描述:
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 的变化往往是其他变化的后果,而不是根因:
全局缓存策略变化:
- 从
tool_based切换到system_prompt cache_control标记必然变化- 但根因是策略变化,而非 cache_control 本身
- 从
系统提示词内容变化:
- 新增了一个内容块
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
: nullTTL 常量定义:
// 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% 的缓存中断归因于服务端
服务端中断的具体原因:
服务端路由变化:
- 请求被路由到不同的服务器实例
- 该实例没有之前的缓存
- 导致 cache miss
服务端缓存驱逐:
- 在高负载期间,服务端主动驱逐低优先级的缓存条目
- 释放内存给更热门的请求
- 导致 cache miss
计费/推理不一致:
- 实际推理使用了缓存
- 但计费系统报告了不同的 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 事件的价值:
- 全量分析:收集所有中断事件,不仅限于异常情况
- 多维切分:可以按变化类型、时间窗口、查询源等维度分析
- 趋势监控:发现缓存健康状态的长期趋势
- 异常检测:识别突然上升的中断频率
典型的 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' - 内置工具的名称是一个固定词汇表(
read、write、bash等),可以安全地包含在 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 在请求前执行,捕获状态并检测变化
- 阶段 2 在响应后执行,确认中断并生成诊断
- 双重门槛确保只有显著的下降才触发告警
- 特殊情况处理避免误报(cache deletion、compaction)
- 解释引擎提供精确的根因分析
- Analytics + Diff提供全量和实时的可观测性
14.8 排除模型与清理机制
排除模型:避免误报
并非所有模型都适合缓存中断检测:
// promptCacheBreakDetection.ts:129-131
function isExcludedModel(model: string): boolean {
return model.includes('haiku')
}Haiku 模型被排除的原因:
不同的缓存行为:
- Haiku 可能有不同的缓存实现
- Token 计数方式可能不同
使用场景不同:
- 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()
}清理场景:
Agent 结束:
- 子 Agent 完成任务后释放内存
- 避免长时间运行的会话积累过多状态
压缩操作:
- 消息历史结构变化,旧基线不可用
- 重置为"首次调用"状态
用户清理:
- 用户执行
/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:请求前捕获状态并检测变化
- 阶段 2:响应后确认中断并生成诊断
- 两阶段协同:变化清单 + 中断确认 = 精确根因分析
PreviousState 全量快照:
- 15+ 个字段覆盖所有可能影响缓存键的状态
- 哈希策略分离内容与控制
- 按需计算平衡性能和准确性
中断解释引擎:
- 区分客户端变化、TTL 过期、服务端原因
- 提供精确的归因和可操作的洞察
- "90% 的中断是服务端原因"改变了工程决策
诊断输出:
- Analytics 事件用于全量分析
- Diff 文件用于实时诊断
- 工具名称安全化保护隐私
设计原则
- 两阶段是唯一正确的架构:由时序约束决定,不是设计选择
- 可观测性先于优化:没有数据,就没有优化
- 接受不可控的因素:专注于你能控制的 10%,接受剩下的 90%
- 保守但准确的判定:宁可漏报,也不要误报
工程价值
- 成本优化:识别缓存中断原因,降低不必要的成本
- 性能优化:监控缓存健康状态,改善用户体验
- 数据驱动:通过 analytics 数据指导优化方向
- 快速诊断:diff 文件和日志帮助快速定位问题
这套检测系统本身不做任何缓存优化,但它为第 15 章的优化模式提供了数据基础和诊断工具。没有这套可观测性系统,所有的缓存优化都是盲人摸象——你不知道优化有没有效果,也不知道下一步应该优化什么。
这正是工程智慧的体现:在优化之前,先建立可观测性。有了可观测性,优化自然水到渠成。
参考资源
相关代码
- Claude Code GitHub 仓库:https://github.com/anthropics/claude-code
- 参考书籍:Harness Engineering from CC to AI Coding - Part 4, Chapter 14
- 核心文件:
services/api/promptCacheBreakDetection.ts(728 行)
API 文档
- Anthropic Prompt Caching 文档:https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
- Messages API 参考:https://docs.anthropic.com/en/api/messages
相关章节
- 第 13 章:缓存架构与断点设计(缓存范围、TTL、锁存机制)
- 第 15 章:缓存优化模式(工具定义缓存、MCP 工具处理)
- 第 16 章:高级缓存策略(多级缓存、预热机制)
下一步
- 第 15 章:缓存优化模式(7+ 个命名模式,在源头减少缓存中断)
- 第 16 章:高级缓存策略(会话级缓存、预热机制、智能降级)
关于作者
本文是《Claude Code Harness》系列的第 14 章,深入解析缓存中断检测系统的设计与实现。这个系列旨在为 AI Native Engineer 提供构建生产级 AI 应用的深入指导。
欢迎在 GitHub 上提交问题和改进建议!