Back to Blog

Claude Code Harness 第13章:缓存架构与断点设计

2026-04-05
Claude Code Prompt Cache Caching Strategy Performance

前言:从重复计费到智能缓存

在前面的章节中,我们多次提到"上下文窗口"、"Token 预算"、"压缩策略"——这些机制的核心目标是管理 200K 上下文窗口内的空间。但有一个更隐蔽却同样重要的成本维度:即使上下文窗口内的内容完全相同,每次 API 调用仍然需要为系统提示词和工具定义重复付费。

想象一个典型的 50 轮编程会话:

  • 系统提示词:约 11,000 tokens,包含 Agent 角色定义、工具使用规范、约束条件等
  • 工具 Schema 定义:40+ 个工具,每个约 500 tokens,总计约 20,000 tokens
  • 固定开销:每轮调用都需要传输这 31,000 tokens
  • 50 轮总计:1,550,000 tokens 被重复处理

按 Anthropic 的定价模型(输入 tokens $3/1M),这 50 轮会话中仅固定开销就花费约 $4.65。对于一个活跃用户每天 10 个会话,月成本超过 $100。更糟糕的是,这些重复的 tokens 不仅增加成本,还增加延迟——每次调用都需要重新处理这 31,000 tokens。

Anthropic 的提示词缓存(Prompt Caching)机制正是为解决这个问题而生。如果 API 请求的前缀与之前的请求匹配,服务端可以直接复用已缓存的 KV(Key-Value)状态,将缓存命中部分的费用降低 90%,同时显著减少处理时间。原本需要处理的 31,000 tokens,现在只需处理 3,100 tokens 的缓存标记。

但缓存命中有严格的条件——前缀必须逐字节匹配。一个字符的变化、一个 tool definition 的增删、甚至一个 beta header 的变化,都会导致缓存未命中(cache miss),也就是"缓存中断"(cache break)。

Claude Code 围绕这个约束构建了一套精密的缓存架构:

  • 三级缓存范围:global、org、null,在不同粒度上平衡命中率和灵活性
  • 两种 TTL 层级:5 分钟默认、1 小时扩展,通过锁存机制保证会话稳定
  • 多重锁存机制:防止会话中途的状态变化导致缓存键改变
  • 智能断点设计:在系统提示词、工具定义、消息历史之间精心放置缓存断点

这套架构使得 Claude Code 在复杂的动态场景中(功能开关切换、MCP 工具连接断开、用户配额状态翻转)仍能保持极高的缓存命中率。本章将深入剖析这套架构的设计与实现,揭示其中的工程智慧和权衡考量。


13.1 前缀匹配模型:缓存的核心机制

逐字节比较的严格约束

Anthropic 的提示词缓存基于前缀匹配原则。服务端将 API 请求序列化成一个字节流,然后从头开始逐字节比较。一旦发现不匹配,缓存就在该位置"断裂"——之前的部分可以复用,之后的部分需要重新计算。

flowchart LR
    subgraph Request1["Request 1 (Cache Baseline)"]
        A1["System Prompt
11K tokens"] --> A2["Tool Definitions
20K tokens"] --> A3["User Messages
5K tokens"] --> A4["Assistant Reply
3K tokens"] end subgraph Request2["Request 2 (Partial Hit)"] B1["System Prompt
11K tokens ✓"] --> B2["Tool Definitions
20K tokens ✓"] --> B3["User Messages
5K tokens ✓"] --> B4["New Messages
2K tokens"] --> B5["Assistant Reply"] end subgraph Request3["Request 3 (Cache Break)"] C1["System Prompt
11K tokens ✓"] --> C2["Tool Definitions
+1 new tool
20.5K tokens ✗"] --> C3["Recompute All"] end A1 -.->|Full Reuse| B1 A2 -.->|Full Reuse| B2 A3 -.->|Full Reuse| B3 A1 -.->|Reuse| C1 A2 -.->|Break| C2 style B1 fill:#90EE90 style B2 fill:#90EE90 style B3 fill:#90EE90 style C1 fill:#90EE90 style C2 fill:#FFB6C1 style C3 fill:#FFB6C1

这个图展示了三种场景:

场景 1:完全命中(请求 2)——前缀完全匹配,只有新增的 2K tokens 需要付费,缓存命中的 36K tokens 享受 90% 折扣。

场景 2:缓存中断(请求 3)——仅仅添加了一个新工具(500 tokens),导致从工具定义开始的整个缓存失效,20.5K tokens 需要重新计费。

这种"断裂传播"效应是缓存设计的核心挑战。一个小的变化可能在某个位置中断缓存,导致之后所有内容的缓存都失效。因此,缓存断点的放置策略至关重要——必须将最不稳定的内容放在序列末尾,将最稳定的内容放在序列开头。

API 请求的序列化顺序

理解缓存的前提是理解 API 请求的序列化顺序。Anthropic API 的 Messages 端点接收的请求结构大致如下:

{
  model: "claude-sonnet-4-5-20250929",
  max_tokens: 8192,
  system: [
    { type: "text", text: "...", cache_control: {...} },
    { type: "text", text: "...", cache_control: {...} }
  ],
  messages: [
    { role: "user", content: [...] },
    { role: "assistant", content: [...] },
    // ... 更多消息
  ],
  tools: [
    { name: "read", input_schema: {...}, cache_control: {...} },
    { name: "write", input_schema: {...}, cache_control: {...} },
    // ... 更多工具
  ]
}

服务端的序列化顺序大致为:

[系统提示词 blocks] → [工具定义] → [消息历史]

这个顺序揭示了几个关键设计原则:

  1. 系统提示词最稳定——它在整个会话中基本不变,应该放在最前面
  2. 工具定义次稳定——MCP 工具可能在会话中途连接,但内置工具很少变化
  3. 消息历史最动态——每轮都会追加新消息,必须放在最后

cache_control 标记的基本形式

要启用缓存,需要在 API 请求的内容块上添加 cache_control 标记:

// 基本形式(所有用户可用)
{
  type: 'ephemeral'
}

// 扩展形式(1P 专属)
{
  type: 'ephemeral',
  scope: 'global' | 'org',   // 缓存范围
  ttl: '5m' | '1h'           // 缓存生存时间
}

type: 'ephemeral' 是唯一支持的缓存类型,表示这是一个临时缓存断点。ephemeral 的含义是缓存不持久化到磁盘,只在服务端内存中保持 TTL 时间。

扩展形式增加了两个维度:

  • scope(范围):控制缓存可以在哪些请求之间共享

    • global:跨组织、跨用户共享(最激进)
    • org:同一组织内的用户共享(中等)
    • 未指定:仅当前会话内共享(默认)
  • ttl(生存时间):控制缓存的有效期

    • 5m:5 分钟(默认)
    • 1h:1 小时(需要满足资格条件)

13.2 三级缓存范围:命中率的精细权衡

Claude Code 使用三种缓存范围(cache scope),每种范围对应不同的复用粒度和风险水平。这些范围通过 splitSysPromptPrefix() 函数分配给系统提示词的不同部分,实现了"能 global 就 global,不能就 org,都不行就放弃"的渐进式策略。

范围定义与使用场景

缓存范围 标识符 复用粒度 适用内容 TTL 命中率潜力 风险等级
全局缓存 'global' 跨组织、跨用户 所有 Claude Code 实例共享的静态提示词 5 分钟(默认) 最高(所有用户) 高(一个变化影响所有用户)
组织缓存 'org' 同一组织内的用户 包含组织特定但用户无关的内容 5 分钟 / 1 小时 中等(同组织) 中等
无缓存 null 不设置 cache_control 高度动态的内容 不适用 无(避免无效标记)

表 13-1:三级缓存范围对比

全局缓存范围:激进的优化

全局缓存是最激进的优化——标记为 global 的内容可以在所有 Claude Code 用户之间共享 KV 缓存。这意味着当用户 A 发起一个请求,缓存了系统提示词的静态部分后,用户 B 的下一个请求可以直接命中这个缓存,即使他们在不同的组织中。

适用条件非常严格:内容必须是完全不变的,不能包含任何:

  • 用户特定信息(用户名、邮箱、配额状态)
  • 组织特定信息(组织 ID、组织配置)
  • 时间特定信息(当前时间、会话 ID)
  • 功能开关状态(feature flag、实验配置)

Claude Code 通过一个"动态边界标记"(SYSTEM_PROMPT_DYNAMIC_BOUNDARY)将系统提示词分为静态和动态两部分:

// 伪代码:展示核心逻辑
const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '=== DYNAMIC CONTENT BOUNDARY ==='

const boundaryIndex = systemPrompt.findIndex(
  s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY
)

if (boundaryIndex !== -1) {
  // 边界之前的内容 → global 范围
  for (let i = 0; i < boundaryIndex; i++) {
    staticBlocks.push(systemPrompt[i])
  }
  
  // 边界之后的内容 → null 范围(不缓存)
  for (let i = boundaryIndex; i < systemPrompt.length; i++) {
    dynamicBlocks.push(systemPrompt[i])
  }
  
  // 添加缓存标记
  result.push({ 
    text: staticBlocks.join('\n\n'), 
    cacheScope: 'global' 
  })
  result.push({ 
    text: dynamicBlocks.join('\n\n'), 
    cacheScope: null 
  })
}

关键设计决策:边界之后的动态内容被标记为 cacheScope: null,而不是 'org'。为什么?因为动态内容的变化频率太高,即使组织级别的缓存命中率也很低。标记缓存断点反而增加了 API 请求的复杂度(需要在序列化时添加 cache_control 字段),却几乎不会带来命中收益。

组织缓存范围:平衡的默认值

当全局缓存不可用时(例如没有启用全局缓存功能,或内容包含组织特定信息),Claude Code 回退到 org 级别。这是最常用的缓存范围,因为大多数系统提示词内容都包含一些组织级别的差异(比如特定的 CLI 前缀、计费归属头等)。

组织缓存的分块策略揭示了一个重要细节:

// 伪代码:展示分块逻辑
const result: SystemPromptBlock[] = []

// 1. 计费归属头 → 不缓存(包含用户身份信息)
if (attributionHeader) {
  result.push({ 
    text: attributionHeader, 
    cacheScope: null 
  })
}

// 2. CLI 系统提示词前缀 → org 级别
if (systemPromptPrefix) {
  result.push({ 
    text: systemPromptPrefix, 
    cacheScope: 'org' 
  })
}

// 3. 剩余系统提示词内容 → org 级别
const restJoined = rest.join('\n\n')
if (restJoined) {
  result.push({ 
    text: restJoined, 
    cacheScope: 'org' 
  })
}

这个分块策略体现了细粒度控制的思想:即使是组织级别的缓存,也要识别哪些部分可以共享(CLI 前缀、通用提示词),哪些部分必须排除(计费归属头)。

无缓存范围:避免无效的复杂性

什么时候应该完全不设置 cache_control?答案很简单:内容变化频率高到缓存命中率几乎为零

不设置 cache_control 的好处是:

  • 减少序列化复杂度(不需要添加额外的字段)
  • 减少服务端处理开销(不需要查找缓存键)
  • 避免误导性的缓存未命中统计

Claude Code 识别了几类"无缓存内容":

  • 计费归属头(每用户不同)
  • 动态边界标记之后的内容(包含时间、会话等动态信息)
  • 频繁变化的工具定义(MCP 工具)

MCP 工具的特殊处理:降级策略

MCP(Model Context Protocol)工具的引入给缓存带来了严峻挑战。MCP 服务器可以在会话中途连接或断开,工具定义可以在任何时候变化。如果将 MCP 工具定义纳入全局缓存,会导致:

  1. 缓存中断频繁:每次 MCP 工具变化都击穿全局缓存
  2. 命中率下降:不同用户的 MCP 工具配置差异很大
  3. 传播效应:一个工具定义的变化导致后续所有内容缓存失效

Claude Code 的解决方案是降级策略:当检测到 MCP 工具存在时,系统提示词的全局缓存被降级为 org 级别,工具缓存策略也从系统提示词嵌入切换到独立的 tool_based 策略。

// 伪代码:展示降级逻辑
if (useGlobalCacheFeature && hasMCPTools) {
  logEvent('tengu_sysprompt_using_tool_based_cache', {
    promptBlockCount: systemPrompt.length,
  })
  
  // 所有内容降级为 org 范围,跳过边界标记
  for (const block of systemPrompt) {
    result.push({ 
      text: block, 
      cacheScope: 'org'  // 而不是 'global'
    })
  }
}

这种降级是保守但合理的——与其冒全局缓存被频繁击穿的风险,不如退回到命中率更稳定的 org 级别。

缓存范围选择的决策树

flowchart TD
    A["Start Cache Scope Assignment"] --> B{"Global Cache Enabled?"}
    B -->|No| C["Use org scope
as default strategy"] B -->|Yes| D{"MCP Tools Present?"} D -->|Yes| E["Downgrade to org scope
skip global cache"] D -->|No| F{"Dynamic Boundary Marker?"} F -->|No| G["Use org scope
boundary undefined"] F -->|Yes| H["Split System Prompt"] H --> I["Before boundary → global"] H --> J["After boundary → null"] C --> K["Apply block strategy:
1. attribution → null
2. prefix → org
3. rest → org"] E --> K style I fill:#90EE90 style J fill:#FFB6C1 style K fill:#87CEEB

13.3 缓存 TTL 层级:5 分钟 vs 1 小时

Anthropic 的提示词缓存默认 TTL(Time To Live)为 5 分钟。这意味着如果用户在 5 分钟内没有发起新的 API 请求,缓存就会过期,服务端需要重新处理之前的内容。

对于活跃的编程会话,5 分钟通常足够——用户在思考代码、查看文档、手动编辑文件时,通常会很快返回继续对话。但对于需要频繁查阅文档、长时间思考、或者处理复杂任务的场景,5 分钟可能不够。

Claude Code 支持将 TTL 提升到 1 小时,但这个功能不是无条件的——需要通过资格检查allowlist 验证两层机制。

TTL 决策函数

function should1hCacheTTL(querySource?: QuerySource): boolean {
  // 第一层:3P Bedrock 用户通过环境变量 opt-in
  if (
    getAPIProvider() === 'bedrock' &&
    isEnvTruthy(process.env.ENABLE_PROMPT_CACHING_1H_BEDROCK)
  ) {
    return true
  }

  // 第二层:用户资格检查(带锁存)
  let userEligible = getPromptCache1hEligible()
  if (userEligible === null) {
    userEligible =
      process.env.USER_TYPE === 'ant' ||
      (isClaudeAISubscriber() && !currentLimits.isUsingOverage)
    setPromptCache1hEligible(userEligible)
  }
  if (!userEligible) return false

  // 第三层:allowlist 检查(带锁存)
  let allowlist = getPromptCache1hAllowlist()
  if (allowlist === null) {
    const config = getFeatureValue('tengu_prompt_cache_1h_config', {})
    allowlist = config.allowlist ?? []
    setPromptCache1hAllowlist(allowlist)
  }

  return (
    querySource !== undefined &&
    allowlist.some(pattern =>
      pattern.endsWith('*')
        ? querySource.startsWith(pattern.slice(0, -1))
        : querySource === pattern
    )
  )
}

这个函数看似简单,但蕴含着深思熟虑的多层决策。每一层都有其特定的目的和权衡。

第一层:3P Bedrock 用户的环境变量 opt-in

Bedrock 是 AWS 的托管服务,3P(Third Party)用户通过 Bedrock 使用 Claude 时,计费模式与 Anthropic 直连不同。Bedrock 用户自行管理 AWS 账单和成本配额,因此 Anthropic 不限制他们的 1 小时 TTL 使用。

用户通过设置环境变量 ENABLE_PROMPT_CACHING_1H_BEDROCK=1 来 opt-in。这个设计避免了对 3P 用户的无谓限制,同时保持了默认的保守策略。

第二层:用户资格检查与锁存机制

Anthropic 直连的用户需要满足以下条件之一:

  1. Anthropic 员工USER_TYPE === 'ant'):内部用户,用于测试和开发
  2. Claude AI 订阅者且未超配额isClaudeAISubscriber() && !isUsingOverage):付费订阅用户,且仍在配额内

锁存机制的核心作用getPromptCache1hEligible()setPromptCache1hEligible() 将资格检查结果存入全局状态,确保整个会话期间使用同一个值。

// 伪代码:展示状态存储
const STATE = {
  promptCache1hEligible: boolean | null  // null = 未初始化
}

function getPromptCache1hEligible(): boolean | null {
  return STATE.promptCache1hEligible
}

function setPromptCache1hEligible(eligible: boolean | null): void {
  STATE.promptCache1hEligible = eligible
}

为什么需要锁存? 考虑以下场景:

  1. 会话开始时,用户在订阅配额内(isUsingOverage === false),获得 1 小时 TTL
  2. 会话进行到第 30 轮时,用户超出配额(isUsingOverage === true
  3. 如果此时 TTL 从 1 小时降回 5 分钟,cache_control 对象的序列化结果发生变化
  4. 这个变化导致 API 请求的前缀不再匹配——缓存中断

一次 overage 状态翻转导致 ~31,000 tokens 的系统提示词和工具定义缓存全部失效,这显然是不可接受的。锁存机制确保一旦会话开始时确定了 TTL 等级,整个会话期间保持不变。

核心原则:宁可使用略微过时的值,也不要让缓存键在会话中途发生变化。

第三层:Allowlist 检查与模式匹配

即使用户有资格使用 1 小时 TTL,也不是所有查询类型都适用。Allowlist 限制了 querySource(查询来源)的范围,只有在 allowlist 中的来源才使用 1 小时 TTL。

Allowlist 的格式

{
  "allowlist": [
    "repl_main_thread",           // REPL 主线程
    "repl_background_task",       // 后台任务
    "agentic_loop",               // Agentic 循环
    "auto_mode_*"                 // Auto mode 的所有子类型(通配符)
  ]
}

模式匹配规则

  • 精确匹配:"repl_main_thread" 只匹配这个来源
  • 前缀匹配:"auto_mode_*" 匹配所有以 auto_mode_ 开头的来源

这种设计允许 Anthropic 逐步放开 1 小时 TTL 的使用范围——先在核心查询类型上验证效果,然后扩展到更多场景。

Allowlist 的锁存机制

Allowlist 本身也需要锁存,因为它是通过 GrowthBook(远程配置服务)获取的,GrowthBook 的磁盘缓存可能在会话中途更新。

let allowlist = getPromptCache1hAllowlist()
if (allowlist === null) {
  const config = getFeatureValue('tengu_prompt_cache_1h_config', {})
  allowlist = config.allowlist ?? []
  setPromptCache1hAllowlist(allowlist)
}

如果 GrowthBook 在会话中途更新了 allowlist 配置,但锁存的值仍然使用旧版本,确保缓存键不会因为这个远程配置的变化而中断。

TTL 层级决策表

条件 TTL 备注
3P Bedrock + ENABLE_PROMPT_CACHING_1H_BEDROCK=1 1 小时 Bedrock 用户自行管理计费
Anthropic 员工 (USER_TYPE=ant) 1 小时 内部用户,用于测试
Claude AI 订阅者 + 未超配额 + allowlist 匹配 1 小时 需通过资格检查和 allowlist 验证
其他用户 5 分钟 默认 TTL

表 13-2:缓存 TTL 决策矩阵


13.4 Beta Header 锁存机制:防止功能开关导致缓存中断

问题:动态 Header 导致缓存击穿

Anthropic API 的请求中包含一组"beta headers",标识客户端使用的实验性功能:

headers: {
  'anthropic-beta': [
    'max-tokens-3-5-sonnet-2024-07-15',  // 功能 A
    'prompt-caching-2024-07-31',          // 功能 B
    'auto-mode-2024-10-15'                // 功能 C
  ]
}

这些 header 是服务端缓存键的一部分——添加或移除一个 header 就会改变缓存键,导致缓存中断。

Claude Code 有多个功能可以在会话中途动态激活或停用:

  • AFK 模式(Auto Mode):用户离开时自动执行任务
  • Fast Mode:使用更快但可能更贵的模型
  • 缓存编辑(Cached Microcompact):在缓存中进行增量编辑

每次这些功能的状态变化,对应的 beta header 就会被添加或移除,触发缓存中断。

代码注释明确描述了这个问题

// Sticky-on latches for dynamic beta headers. Each header, once first
// sent, keeps being sent for the rest of the session so mid-session
// toggles don't change the server-side cache key and bust ~50-70K tokens.
// Latches are cleared on /clear and /compact via clearBetaHeaderLatches().
// Per-call gates (isAgenticQuery, querySource===repl_main_thread) stay
// per-call so non-agentic queries keep their own stable header set.

解决方案:Sticky-On 锁存

Claude Code 的解决方案是"sticky-on"锁存——一旦某个 beta header 在会话中被发送过,它将在整个会话剩余时间内持续发送,即使触发该 header 的功能已经被停用。

AFK 模式 Header 锁存

let afkHeaderLatched = getAfkModeHeaderLatched() === true

if (feature('TRANSCRIPT_CLASSIFIER')) {
  if (
    !afkHeaderLatched &&
    isAgenticQuery &&
    shouldIncludeFirstPartyOnlyBetas() &&
    (autoModeStateModule?.isAutoModeActive() ?? false)
  ) {
    afkHeaderLatched = true
    setAfkModeHeaderLatched(true)
  }
}

前置条件组合

  1. TRANSCRIPT_CLASSIFIER 功能启用
  2. 是 agentic 查询(isAgenticQuery
  3. 应该包含 1P 专属 betas(shouldIncludeFirstPartyOnlyBetas()
  4. Auto mode 当前活跃(isAutoModeActive()

锁存逻辑

  • 首次满足条件时,设置 afkHeaderLatched = true 并持久化
  • 后续调用中,即使 isAutoModeActive() 变为 false,header 仍然发送
  • 只有 /clear/compact 命令会重置锁存状态

Fast Mode Header 锁存

let fastModeHeaderLatched = getFastModeHeaderLatched() === true

if (!fastModeHeaderLatched && isFastMode) {
  fastModeHeaderLatched = true
  setFastModeHeaderLatched(true)
}

更简单的锁存:Fast mode 的前置条件相对简单,只需要 isFastMode 为 true。锁存后,即使 fast mode 被停用,header 仍然发送。

缓存编辑 Header 锁存

let cacheEditingHeaderLatched = getCacheEditingHeaderLatched() === true

if (feature('CACHED_MICROCOMPACT')) {
  if (
    !cacheEditingHeaderLatched &&
    cachedMCEnabled &&
    getAPIProvider() === 'firstParty' &&
    options.querySource === 'repl_main_thread'
  ) {
    cacheEditingHeaderLatched = true
    setCacheEditingHeaderLatched(true)
  }
}

最严格的前置条件

  1. CACHED_MICROCOMPACT 功能启用
  2. 缓存微压缩功能启用(cachedMCEnabled
  3. 使用 1P API(getAPIProvider() === 'firstParty'
  4. 查询来源是主线程(querySource === 'repl_main_thread'

这个锁存只在最核心的查询类型上生效,避免在边缘场景中过早锁存。

锁存状态转换图

三个 beta header 的锁存遵循相同的状态转换模式:

stateDiagram-v2
    [*] --> Unlatched: Session Start
    Unlatched --> Latched: Condition True First Time
(feature active + prerequisites met) Latched --> Latched: Feature Deactivated
(keep latch unchanged) Latched --> Reset: /clear or /compact
(clearBetaHeaderLatches) Reset --> Unlatched: Next condition evaluation state Unlatched { [*] : latched = false/null note: check condition every call } state Latched { [*] : latched = true note: condition change doesn't affect state } state Reset { [*] : latched = false/null note: waiting for re-evaluation }

锁存汇总表

Beta Header 锁存变量 前置条件 重置时机
AFK Mode afkModeHeaderLatched TRANSCRIPT_CLASSIFIER + agentic 查询 + 1P 限定 + auto mode 活跃 /clear, /compact
Fast Mode fastModeHeaderLatched Fast mode 可用 + 无冷却 + 模型支持 + 请求启用 /clear, /compact
Cache Editing cacheEditingHeaderLatched CACHED_MICROCOMPACT + cachedMC 可用 + 1P + main thread /clear, /compact

表 13-3:Beta Header 锁存详情

Per-Call Gates 的隔离原则

代码注释提到了一个重要的设计原则:

"Per-call gates (isAgenticQuery, querySource===repl_main_thread) stay per-call so non-agentic queries keep their own stable header set."

这意味着某些条件(如 isAgenticQueryquerySource不参与锁存,它们在每次调用时重新评估。为什么?

考虑以下场景:

  1. 用户发起一个 agentic 查询,触发 AFK header 锁存
  2. 然后发起一个非 agentic 查询(如手动输入的简单问题)
  3. 如果 isAgenticQuery 也被锁存,非 agentic 查询会错误地发送 AFK header
  4. 这会导致非 agentic 查询有自己不相关的缓存键,降低命中率

因此,per-call gates 保持逐调用评估,确保不同类型的查询有自己稳定的 header 集,而不是被其他查询类型的锁存污染。


13.5 Thinking Clear 锁存:优化 1 小时 TTL 边界

除了 beta header 锁存外,还有一个特殊的锁存机制——thinkingClearLatched。它的作用是检测缓存的 1 小时 TTL 是否已经过期,并触发 thinking 块的清理优化。

触发条件

let thinkingClearLatched = getThinkingClearLatched() === true

if (!thinkingClearLatched && isAgenticQuery) {
  const lastCompletion = getLastApiCompletionTimestamp()
  if (
    lastCompletion !== null &&
    Date.now() - lastCompletion > CACHE_TTL_1HOUR_MS
  ) {
    thinkingClearLatched = true
    setThinkingClearLatched(true)
  }
}

触发条件

  1. 距离上次 API 完成超过 1 小时(CACHE_TTL_1HOUR_MS = 60 * 60 * 1000
  2. 当前是 agentic 查询

为什么需要这个锁存?

当使用 1 小时 TTL 时,如果用户在 1 小时后返回继续对话,服务端的缓存已经过期。此时:

  1. 缓存失效:之前的 KV 缓存已经被服务端清除
  2. Thinking 内容累积:如果会话中使用了 thinking 模式,thinking 块可能累积了较多内容
  3. 优化机会:既然缓存已经失效,可以清理累积的 thinking 内容,减少后续请求的 token 消耗

Thinking Clear 锁存利用这个信号优化 thinking 块的处理——在缓存失效的边界点进行一次"清理",为后续的会话轮次提供更轻量的上下文。

锁存的不可逆性

与其他锁存一样,thinkingClearLatched 一旦设置为 true,在整个会话期间保持不变。这意味着:

  1. 会话中的第一次 1 小时超时触发 thinking clear
  2. 后续的查询即使又超过 1 小时,也不会重复触发
  3. 只有 /clear/compact 会重置这个锁存

这种设计避免了频繁的 thinking 清理操作,保持了会话的稳定性。


13.6 缓存架构全景:多层防护体系

将上述所有机制组合起来,Claude Code 的缓存架构可以概括为以下层次:

graph TB
    subgraph API["API Request Construction Layer"]
        SP["System Prompt
global/org/null blocks"] T["Tool Definitions
org scope"] M["Message History
no cache"] SP --> T --> M end subgraph TTL["TTL Decision Layer"] TTL1["Eligibility Check
user type + subscription"] TTL2["Allowlist Verification
querySource match"] TTL3["Latch Mechanism
session stable"] TTL1 --> TTL2 --> TTL3 end subgraph BH["Beta Header Latch Layer"] BH1["AFK Mode Header"] BH2["Fast Mode Header"] BH3["Cache Editing Header"] BH1 --> BH2 --> BH3 end subgraph TC["Thinking Clear Latch Layer"] TC1["Detect 1h Timeout"] TC2["Trigger thinking cleanup"] TC1 --> TC2 end subgraph CM["Cache Break Detection Layer"] CM1["Monitor cache_read_input_tokens"] CM2["Analyze miss reasons"] CM3["Trigger optimization"] CM1 --> CM2 --> CM3 end API --> TTL TTL --> BH BH --> TC TC --> CM style SP fill:#90EE90 style T fill:#87CEEB style M fill:#FFB6C1 style TTL3 fill:#FFD700 style BH3 fill:#FFD700 style TC2 fill:#FFD700

层次职责划分

第一层:API 请求构建层

  • 将系统提示词、工具定义、消息历史按顺序序列化
  • 为不同内容块分配合适的缓存范围(global/org/null)
  • 添加 cache_control 标记到指定位置

第二层:TTL 决策层

  • 评估用户是否有资格使用 1 小时 TTL
  • 检查查询来源是否在 allowlist 中
  • 锁存资格和 allowlist 结果,保证会话稳定

第三层:Beta Header 锁存层

  • 检测功能开关(AFK、Fast Mode、Cache Editing)的状态
  • 使用 sticky-on 锁存防止 header 变化导致缓存中断
  • 保持 per-call gates 的隔离性

第四层:Thinking Clear 锁存层

  • 检测 1 小时 TTL 过期边界
  • 触发 thinking 内容的清理优化
  • 减少后续请求的 token 消耗

第五层:缓存中断检测层

  • 监控 cache_read_input_tokens 指标
  • 分析缓存未命中的原因
  • 触发相应的优化策略(详见第 14 章)

13.7 设计洞察:缓存架构的工程智慧

洞察 1:锁存是缓存稳定性的核心模式

Claude Code 在缓存相关的代码中反复使用同一个模式:首次评估 → 锁存 → 会话稳定。这个模式出现在:

  • TTL 资格检查(should1hCacheTTL
  • TTL allowlist 配置
  • Beta header 发送状态
  • Thinking clear 触发

每一处锁存都是为了同一个目的:防止会话中途的状态变化改变 API 请求的序列化结果,从而保护缓存前缀的完整性

核心原则:宁可使用略微过时的值,也不要让缓存键在会话中途发生变化。

这个原则在分布式系统中很常见——它类似于"最终一致性"的思想,为了保证系统的稳定性和可预测性,接受一定程度的"过时"是值得的。

洞察 2:缓存范围是成本与命中率的权衡

三级缓存范围体现了一个清晰的工程权衡:

缓存范围 命中率潜力 风险等级 适用场景
global 最高(所有用户) 高(一个变化影响所有用户) 完全静态的内容
org 中等(同组织) 中等(一个变化影响组织内用户) 组织级别的共享内容
null 高度动态的内容

渐进式降级策略

  1. 优先尝试 global 范围(最大化收益)
  2. 如果条件不满足,降级到 org 范围(保底收益)
  3. 如果 org 也无法保证命中率,放弃缓存标记(避免无效复杂性)

这种"能 global 就 global,不能就 org,都不行就放弃"的策略,比一刀切的策略更精细,也更有效。

洞察 3:MCP 工具是缓存的最大敌人

MCP 工具的引入给缓存带来了严峻挑战:

  1. 动态性:MCP 服务器可以在会话中途连接或断开
  2. 不可预测性:工具定义可以在任何时候变化,无法提前感知
  3. 差异性:不同用户的 MCP 工具配置差异很大

当检测到 MCP 工具存在时,系统提示词的全局缓存被降级为 org 级别,工具缓存策略也从系统提示词嵌入切换到独立的 tool_based 策略。这些降级措施是保守但合理的——与其冒全局缓存被频繁击穿的风险,不如退回到命中率更稳定的级别。

启示:在设计缓存架构时,必须识别"缓存敌人"——那些频繁变化、不可预测的内容。对于这些敌人,要么隔离它们(不纳入缓存),要么降级缓存策略(降低缓存范围)。

洞察 4:Per-Call Gates 保持查询隔离

Beta header 锁存机制中,isAgenticQueryquerySource 等条件保持逐调用评估,不参与锁存。这个设计确保不同类型的查询有自己稳定的 header 集。

为什么这很重要? 考虑以下场景:

  1. Agentic 查询触发 AFK header 锁存
  2. 用户手动输入一个简单问题(非 agentic)
  3. 如果 isAgenticQuery 也被锁存,非 agentic 查询会错误地发送 AFK header
  4. 这会导致非 agentic 查询有自己不相关的缓存键,降低命中率

启示:锁存机制必须识别哪些状态应该共享(全局功能开关),哪些状态应该隔离(查询类型)。共享状态的锁存可以提升缓存命中率,但隔离状态的独立评估同样重要。


13.8 缓存架构的性能影响

成本优化:90% 的折扣

假设一个典型的 50 轮会话:

无缓存场景

  • 系统提示词:11,000 tokens × 50 轮 = 550,000 tokens
  • 工具定义:20,000 tokens × 50 轮 = 1,000,000 tokens
  • 消息历史:平均 5,000 tokens/轮 × 50 轮 = 250,000 tokens
  • 总计:1,800,000 tokens
  • 成本:1,800,000 × $3/1M = $5.40

有缓存场景(90% 命中率)

  • 缓存命中部分:(11,000 + 20,000) × 10% × 50 轮 = 155,000 tokens(付费)
  • 消息历史:250,000 tokens(无缓存)
  • 总计:405,000 tokens
  • 成本:405,000 × $3/1M = $1.22

节省:$5.40 - $1.22 = $4.18(77% 成本降低)

如果考虑 1 小时 TTL 的场景(会话持续超过 1 小时),缓存命中率会下降,但仍然能节省 50-60% 的成本。

延迟优化:减少重复处理

缓存不仅降低成本,还减少延迟。服务端复用 KV 缓存时,不需要重新计算系统提示词和工具定义的注意力权重,直接使用缓存的值。

延迟对比(估算):

  • 无缓存:31,000 tokens 的处理时间 ≈ 2-3 秒
  • 有缓存:3,100 tokens 的处理时间 ≈ 0.2-0.3 秒
  • 节省:每轮 1.7-2.7 秒

在 50 轮会话中,累积节省 85-135 秒的延迟,显著改善用户体验。

缓存命中率的影响因素

因素 对命中率的影响 优化策略
会话轮次间隔 间隔越长,5 分钟 TTL 越容易过期 使用 1 小时 TTL(如果满足条件)
MCP 工具使用 MCP 工具变化会导致缓存中断 降级到 org 范围
功能开关切换 AFK/Fast Mode 切换会导致 header 变化 使用 sticky-on 锁存
用户配额状态 overage 翻转会导致 TTL 变化 锁存资格检查结果
查询类型分布 不同 querySource 可能有不同的缓存策略 使用 allowlist 限制 1 小时 TTL

表 13-4:缓存命中率影响因素分析


13.9 实践指南:构建缓存友好的 AI 应用

基于 Claude Code 的缓存架构设计,以下是构建缓存友好系统时的实践要点:

1. 理解前缀匹配的含义

Anthropic 的缓存是严格的前缀匹配。在构建 API 请求时,始终将最稳定、最不可能变化的内容放在最前面(系统提示词静态部分),将动态内容(用户消息、附件)放在最后。

序列化顺序建议

[完全静态的系统提示词] → [组织级别的系统提示词] → [工具定义] → [消息历史]

2. 为你的系统提示词设计缓存范围

如果你的应用服务多个用户,识别哪些提示词内容是:

  • 全局共享(适合 global 范围):角色定义、通用规范、约束条件
  • 组织级别(适合 org 范围):组织配置、团队规范
  • 完全动态(不标记 cache_control):用户特定信息、时间相关内容

一刀切的缓存策略会浪费命中率。细粒度的分块可以最大化缓存收益。

3. 使用锁存模式保护缓存键稳定性

任何可能在会话中途变化的配置项,如果它们影响 API 请求的序列化结果,都应该在会话开始时锁存:

// 伪代码:展示锁存模式
let configLatch = getConfigLatch() === true

if (!configLatch) {
  const currentValue = getDynamicConfig()
  configLatch = true
  setConfigLatch(true, currentValue)
}

// 使用锁存的值,而不是每次重新获取
const valueToUse = getLatchedConfigValue()

锁存的核心原则:宁可使用略微过时的值,也不要让缓存键在会话中途发生变化。

4. 警惕外部工具对缓存的影响

如果你的应用集成了外部工具(MCP 或类似机制),它们的动态性会显著降低缓存命中率。考虑:

  • 将外部工具的定义与核心工具分开处理
  • 在检测到外部工具时降级缓存策略(从 global 降到 org)
  • 为外部工具使用独立的缓存断点

5. 监控缓存健康状态

监控 cache_read_input_tokens 指标,这是判断缓存健康状态的唯一可靠指标。

建立基线

  • 正常情况下,cache_read_input_tokens 应占总输入 tokens 的 70-90%
  • 如果低于 50%,说明缓存命中率有问题
  • 如果低于 20%,说明缓存几乎完全失效

调查未命中原因

  • 检查是否有频繁的工具定义变化
  • 检查是否有功能开关在会话中途切换
  • 检查是否有动态内容混入了静态部分

13.10 小结:缓存架构是成本与性能的杠杆

本章深入剖析了 Claude Code 的提示词缓存架构,揭示了其中的工程智慧和权衡考量:

核心机制

  1. 前缀匹配模型:API 请求的前缀必须逐字节稳定,任何变化都会导致缓存中断
  2. 三级缓存范围(global/org/null):在命中率和灵活性之间做出精细权衡
  3. TTL 层级(5 分钟/1 小时):通过锁存机制保证会话内稳定
  4. Beta Header 锁存:使用 sticky-on 模式防止功能开关导致缓存键变化

设计原则

  1. 锁存保护稳定性:宁可使用略微过时的值,也不要让缓存键在会话中途发生变化
  2. 渐进式降级:能 global 就 global,不能就 org,都不行就放弃
  3. 查询隔离:不同类型的查询应该有自己稳定的配置集
  4. 缓存敌人识别:对于频繁变化的内容(MCP 工具),要么隔离,要么降级

性能影响

  • 成本优化:90% 的缓存折扣可以降低 70-80% 的 API 成本
  • 延迟优化:减少每轮 1.7-2.7 秒的处理时间
  • 用户体验:更快的响应速度、更低的成本、更稳定的性能

这些机制共同构成了缓存的"防护层"。但光有防护还不够——当缓存确实发生中断时,系统需要能够检测到并诊断原因。第 14 章将深入缓存中断检测系统的两阶段架构,揭示如何监控、分析、优化缓存健康状态。


参考资源

相关代码

API 文档

相关章节

  • 第 8 章:自动压缩机制
  • 第 9 章:文件状态保留
  • 第 10 章:微压缩
  • 第 11 章:上下文压缩策略
  • 第 12 章:Token 预算策略

下一步

  • 第 14 章:缓存中断检测系统(两阶段架构、健康指标监控)
  • 第 15 章:缓存优化模式(工具定义缓存、MCP 工具处理)
  • 第 16 章:高级缓存策略(多级缓存、预热机制)

关于作者

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

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

Enjoyed this article? Share it with others!