Back to Blog

Claude Code Harness 第15章:缓存优化模式

2026-04-05
Claude Code Prompt Cache Optimization Patterns Best Practices

前言:从防御到进攻

在之前的两章中,我们建立了完整的缓存防护体系:

  • 第13章构建了缓存架构的防御层——三级缓存范围、TTL 机制、智能断点设计、锁存策略,从架构层面预防缓存中断
  • 第14章构建了检测能力——两阶段检测系统、解释引擎、BigQuery 可观测性,让我们能够实时监控和理解缓存失效

但有了防御和检测,还需要进攻手段。当检测系统揭示某个特定的中断原因反复出现时,我们需要主动出击,从源头消除或减少这些中断的发生。

这正是本章的主题——缓存优化模式。Claude Code 通过一系列命名的优化模式,系统性地消除缓存中断的根本原因。这些模式并非一次性设计出来的,每一个都源自真实的缓存中断数据:

  • 10.2% 的 cache_creation tokens 归因于 Agent 列表的动态变化 → 设计"Agent 列表附件化"模式
  • 每日凌晨的日期变化导致全前缀缓存击穿 → 设计"日期记忆化"模式
  • 用户 UID 嵌入路径阻止跨用户缓存 → 设计"$TMPDIR 占位符"模式
  • GrowthBook feature flag 翻转导致工具 Schema 失效 → 设计"工具 Schema 缓存"模式

本章将深入剖析 7 个以上的缓存优化模式,每个模式都遵循同一个框架:识别变化源 → 理解变化本质 → 将动态变为静态。这些模式共同体现了一个核心洞察:缓存优化不是一个独立的关注点,而是渗透到系统每一个产生动态内容的位置。


15.1 优化模式全景图

在深入每个模式之前,先看一个全局视图。下表汇总了 Claude Code 的所有缓存优化模式:

# 模式名称 变化源 优化策略 关键文件 影响范围
1 日期记忆化 日期跨天变化 memoize(getLocalISODate) constants/common.ts 系统提示词
2 月度粒度 日期每日变化 使用 "Month YYYY" 而非完整日期 constants/common.ts 工具提示词
3 Agent 列表附件化 Agent 列表动态变化 从工具描述移至消息附件 tools/AgentTool/prompt.ts 工具 Schema(10.2% cache_creation)
4 技能列表预算 技能数量增长 限制为 1% context window tools/SkillTool/prompt.ts 工具 Schema
5 $TMPDIR 占位符 用户 UID 嵌入路径 替换为 $TMPDIR tools/BashTool/prompt.ts 工具提示词 / 全局缓存
6 条件段落省略 功能开关改变提示词 条件性省略而非添加 多处系统提示词 系统提示词前缀
7 工具 Schema 缓存 GrowthBook 翻转 / 动态内容 会话级 Map 缓存 utils/toolSchemaCache.ts 全部工具 Schema

表 15-1:7+ 缓存优化模式汇总

这些模式可以归纳为四大策略类别:

mindmap
  root((缓存优化策略))
    降低变化频率
      日期记忆化
      月度粒度
      工具 Schema 缓存
    减少变化影响
      Agent 列表附件化
      条件段落省略
    消除用户差异
      $TMPDIR 占位符
    控制变化幅度
      技能列表预算

接下来,我们将逐个深入这些模式,理解它们的设计原理、实现细节和权衡考量。


15.2 模式一:日期记忆化 getSessionStartDate()

问题识别

Claude Code 的系统提示词包含当前日期(currentDate),用于帮助模型理解时间上下文。这个日期通过 getLocalISODate() 函数获取:

// constants/common.ts:4-15
export function getLocalISODate(): string {
  if (process.env.CLAUDE_CODE_OVERRIDE_DATE) {
    return process.env.CLAUDE_CODE_OVERRIDE_DATE
  }

  const now = new Date()
  const year = now.getFullYear()
  const month = String(now.getMonth() + 1).padStart(2, '0')
  const day = String(now.getDate()).padStart(2, '0')
  return `${year}-${month}-${day}`
}

问题出在午夜跨天时刻:

23:59 - 系统提示词包含 "2026-04-01"
00:01 - 系统提示词包含 "2026-04-02"

这一个字符的变化(从 "1" 到 "2")足以击穿整个系统提示词前缀的缓存。系统提示词约 11,000 tokens,这意味着每天凌晨都会有一次约 11,000 tokens 的 cache_creation 事件。

优化方案

解决方案优雅而简洁:

// constants/common.ts:24
export const getSessionStartDate = memoize(getLocalISODate)

getSessionStartDate 使用 lodash 的 memoize 包装 getLocalISODate——函数在首次调用时捕获日期,此后永远返回相同的值,无论实际日期是否已经变化。

设计权衡

源码注释详细解释了这个决策的权衡:

// constants/common.ts:17-23
// Memoized for prompt-cache stability — captures the date once at session start.
// The main interactive path gets this behavior via memoize(getUserContext) in
// context.ts; simple mode (--bare) calls getSystemPrompt per-request and needs
// an explicit memoized date to avoid busting the cached prefix at midnight.
// When midnight rolls over, getDateChangeAttachments appends the new date at
// the tail (though simple mode disables attachments, so the trade-off there is:
// stale date after midnight vs. ~entire-conversation cache bust — stale wins).

权衡是清晰的:过时的日期 vs 缓存全量击穿。选择过时日期是因为:

  1. 日期信息对大多数编程任务不关键——模型在代码生成时很少需要精确的当前日期
  2. 午夜后的补救机制——当午夜确实发生时,getDateChangeAttachments 会在消息尾部追加新日期,这不影响前缀缓存
  3. Simple mode 的特殊考虑——--bare 模式禁用了附件机制,所以必须在源头做记忆化

影响量化

这个单行优化的影响:

  • 消除每日一次的全前缀缓存击穿——节省约 11,000 tokens 的 cache_creation 费用
  • 对跨午夜工作用户的特殊价值——对于在深夜工作的开发者,这避免了每次对话都被中断
  • 成本节约——按 $3/1M tokens 计算,每次跨天节省约 $0.033

看似微不足道,但对于全球用户基数的累积效应是显著的。


15.3 模式二:月度粒度 getLocalMonthYear()

问题识别

日期记忆化解决了系统提示词中的跨天问题,但工具提示词中也需要时间信息。如果工具提示词使用完整日期(YYYY-MM-DD),每天凌晨都会导致包含该工具的 Schema 缓存失效。

更棘手的是,工具 Schema 位于 API 请求的前端位置——在系统提示词之后、消息之前。其变化的破坏性比系统提示词更大,因为:

flowchart LR
    A[System Prompt
11K tokens] --> B[Tool Schemas
20K tokens] --> C[Messages
Variable] B1[Tool Schema 变化] B1 --> D[下游所有内容
缓存失效] style B fill:#FFB6C1 style D fill:#FFB6C1

如果某个工具的描述因为日期变化而改变,不仅该工具的缓存失效,之后所有工具和消息的缓存都会失效

优化方案

// constants/common.ts:28-33
export function getLocalMonthYear(): string {
  const date = process.env.CLAUDE_CODE_OVERRIDE_DATE
    ? new Date(process.env.CLAUDE_CODE_OVERRIDE_DATE)
    : new Date()
  return date.toLocaleString('en-US', { month: 'long', year: 'numeric' })
}

getLocalMonthYear() 返回 "Month YYYY" 格式(例如 "April 2026"),而非完整日期。变化频率从每日降低到每月

注释说明了设计意图:

// Returns "Month YYYY" (e.g. "February 2026") in the user's local timezone.
// Changes monthly, not daily — used in tool prompts to minimize cache busting.

两种时间精度的分工

使用场景 函数 精度 变化频率 位置
系统提示词 getSessionStartDate() 每会话一次 系统提示词
工具提示词 getLocalMonthYear() 每月一次 工具 Schema

这种分工反映了一个基本原则:越靠近 API 请求前端的内容,越需要更低的变化频率

工程洞察

这个模式揭示了一个更深层的工程原则:

当精度和缓存稳定性冲突时,选择满足功能需求的最粗精度

对于工具提示词中的时间信息,"April 2026" 已经足够让模型理解大致的时间上下文,无需精确到 "2026-04-05"。通过降低精度,我们获得了 30 倍的缓存稳定性提升(每月变化 vs 每日变化)。


15.4 模式三:Agent 列表附件化

问题识别

AgentTool 的工具描述中嵌入了可用 Agent 的列表——每个 Agent 的名称、类型和描述。这个列表是动态的:

  • MCP 服务器异步连接带来新的 Agent
  • /reload-plugins 命令刷新插件列表
  • 权限模式变化改变可用 Agent 集合

每次列表变化,AgentTool 的工具 Schema 就会改变,导致整个工具 Schema 数组的缓存失效。

源码注释量化了这个问题的严重性:

// tools/AgentTool/prompt.ts:50-57
// The dynamic agent list was ~10.2% of fleet cache_creation tokens: MCP async
// connect, /reload-plugins, or permission-mode changes mutate the list →
// description changes → full tool-schema cache bust.

10.2% 的全量 cache_creation tokens 归因于这个问题。 这不是一个小数目——这意味着每 10 次缓存失效中,就有 1 次是因为 Agent 列表的变化。

优化方案

解决方案的核心洞察是:将动态内容从工具描述移至消息附件

// tools/AgentTool/prompt.ts:59-64
export function shouldInjectAgentListInMessages(): boolean {
  if (isEnvTruthy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) return true
  if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES))
    return false
  return getFeatureValue_CACHED_MAY_BE_STALE('tengu_agent_list_attach', false)
}

工具描述变为静态文本,只描述 AgentTool 的通用功能:

Execute an agent with a specific role and capabilities.
Available agents are listed in the attached agent_listing_delta file.

可用 Agent 的列表作为 agent_listing_delta 附件追加在用户消息中。

为什么附件机制有效

这个迁移的关键洞察在于附件的位置优势

flowchart TD
    subgraph Before["优化前:Agent 列表在工具描述中"]
        A1[System Prompt] --> A2[Tool Schemas
包含动态 Agent 列表] --> A3[Messages] A2 --> A4[每次列表变化
缓存中断] end subgraph After["优化后:Agent 列表在附件中"] B1[System Prompt] --> B2[Tool Schemas
静态描述] --> B3[Messages] --> B4[Agent 列表附件] B4 --> B5[只影响本条消息
前缀缓存不受影响] end style A2 fill:#FFB6C1 style A4 fill:#FFB6C1 style B2 fill:#90EE90 style B5 fill:#90EE90

附件追加在消息尾部,不影响前缀缓存。Agent 列表的变化只增加新消息的 token 成本,不会废弃已缓存的工具 Schema 和系统提示词。

灰度发布策略

这个优化通过 GrowthBook feature flag tengu_agent_list_attach 控制灰度发布,同时保留环境变量 CLAUDE_CODE_AGENT_LIST_IN_MESSAGES 作为手动覆盖。

这种设计体现了生产环境优化的最佳实践:

  1. Feature flag 控制——可以逐步推送给用户群体,监控影响
  2. 环境变量覆盖——开发者可以在本地测试新行为
  3. 向后兼容——如果出现问题,可以快速回滚

影响量化

消除 10.2% 的 cache_creation tokens——这是所有优化模式中影响最大的单一改进

对于一个每天处理 1 亿次 API 请求的系统,这意味着每天减少约 1000 万次缓存失效事件。


15.5 模式四:技能列表预算(1% Context Window)

问题识别

SkillTool 类似于 AgentTool,其工具描述中嵌入可用技能的列表。随着技能生态的增长,挑战包括:

  • 数量增长:内置技能 + 项目技能 + 插件技能,列表可能变得非常长
  • 动态加载:不同项目有不同的 .claude/ 配置
  • 会话中途变化:插件可能在会话中途加载或卸载

如果不加控制,技能列表可能无限增长,导致:

  1. 工具描述过大——增加 token 消耗
  2. 缓存不稳定——频繁的列表变化导致缓存失效
  3. 性能下降——模型需要处理更长的上下文

优化方案

SkillTool 对技能列表施加了严格的预算限制:

// tools/SkillTool/prompt.ts:20-23
// Skill listing gets 1% of the context window (in characters)
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01
export const CHARS_PER_TOKEN = 4
export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 1% of 200k × 4

列表总大小不超过上下文窗口的 1%。对于 200K 的上下文窗口,这约为 8,000 个字符。

预算计算逻辑

预算计算函数考虑了多种场景:

// tools/SkillTool/prompt.ts:31-41
export function getCharBudget(contextWindowTokens?: number): number {
  if (Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)) {
    return Number(process.env.SLASH_COMMAND_TOOL_CHAR_BUDGET)
  }
  if (contextWindowTokens) {
    return Math.floor(
      contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT,
    )
  }
  return DEFAULT_CHAR_BUDGET
}

三个优先级:

  1. 环境变量覆盖——SLASH_COMMAND_TOOL_CHAR_BUDGET,用于测试和调试
  2. 动态计算——基于实际的 context window 大小
  3. 默认值——8,000 字符(200K tokens × 4 chars/token × 1%)

双重限制策略

除了总体预算,每个技能条目的描述也被截断:

// tools/SkillTool/prompt.ts:29
export const MAX_LISTING_DESC_CHARS = 250

注释解释了设计逻辑:

// Per-entry hard cap. The listing is for discovery only — the Skill tool loads
// full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation
// tokens without improving match rate.

这是一个关键的洞察:工具列表是用于发现的,不是用于执行的。模型在列表中看到简短描述,当用户调用技能时,SkillTool 会加载完整的技能内容。因此,冗长的描述不仅浪费 token,还降低缓存稳定性。

缓存优化的本质

1% 预算控制的缓存优化效果体现在两个方面:

  1. 限制工具描述大小——更短的描述意味着更少的字节需要精确匹配
  2. 预算裁剪减少抖动——当新技能被加载但预算已满时,它不会被包含在列表中——列表不变,缓存不破

这是一个"预算即稳定"的模式:通过限制动态内容的最大尺寸,间接控制了缓存键的变化幅度。

实现细节

技能列表的裁剪逻辑在 tools/SkillTool/prompt.ts 中的 getSkillListing() 函数:

// 伪代码示意
function getSkillListing(skills: Skill[], budget: number): string {
  let used = 0
  const listings: string[] = []
  
  for (const skill of skills) {
    const entry = formatSkillEntry(skill)
    if (used + entry.length > budget) break
    listings.push(entry)
    used += entry.length
  }
  
  return listings.join('\n')
}

这个实现确保了:

  1. 预算严格限制——永远不会超过预算
  2. 优先级保留——重要的技能(在列表前面)更可能被包含
  3. 可预测性——相同的技能列表总是产生相同的裁剪结果

15.6 模式五:$TMPDIR 占位符

问题识别

BashTool 的提示词中需要告诉模型可以写入的临时目录路径。Claude Code 使用 getClaudeTempDir() 获取这个路径,格式通常为:

/private/tmp/claude-{UID}/

其中 {UID} 是用户的系统 UID(例如 501、502 等)。

问题在于:不同用户有不同的 UID,因此路径字符串不同。如果这个路径嵌入在工具提示词中,它会阻止跨用户的全局缓存命中

flowchart TD
    A[用户 A 的请求] --> B[工具提示词包含
/private/tmp/claude-501/] C[用户 B 的请求] --> D[工具提示词包含
/private/tmp/claude-502/] B --> E[缓存键 A] D --> F[缓存键 B] E -.->|无法共享| F style B fill:#FFB6C1 style D fill:#FFB6C1 style E fill:#FFB6C1 style F fill:#FFB6C1

用户 A 的 /private/tmp/claude-501/ 和用户 B 的 /private/tmp/claude-502/ 是不同的字节序列,即使在全局缓存范围内也无法共享。

优化方案

解决方案优雅而简洁:

// tools/BashTool/prompt.ts:186-190
// Replace the per-UID temp dir literal (e.g. /private/tmp/claude-1001/) with
// "$TMPDIR" so the prompt is identical across users — avoids busting the
// cross-user global prompt cache. The sandbox already sets $TMPDIR at runtime.
const claudeTempDir = getClaudeTempDir()
const normalizeAllowOnly = (paths: string[]): string[] =>
  [...new Set(paths)].map(p => (p === claudeTempDir ? '$TMPDIR' : p))

将用户特定的临时目录路径替换为 $TMPDIR 占位符。

为什么这样做有效

Claude Code 的沙箱环境已经将 $TMPDIR 设置为正确的目录:

// tools/BashTool/prompt.ts:258-260
'For temporary files, always use the `$TMPDIR` environment variable. ' +
'TMPDIR is automatically set to the correct sandbox-writable directory ' +
'in sandbox mode. Do NOT use `/tmp` directly - use `$TMPDIR` instead.',

模型使用 $TMPDIR 引用临时目录,在运行时被 shell 解析为实际的目录路径。这与使用绝对路径效果相同,但提示词内容在所有用户之间逐字节一致

影响范围

这个优化使得 BashTool 的提示词在所有用户之间可以共享,从而允许全局缓存范围的前缀共享

对于 BashTool 这个最常用的工具,其 Schema 的全局缓存命中意味着:

  1. 显著的成本节约——热门工具定义可以被数百万用户共享
  2. 更快的冷启动——新用户的第一条请求就能命中全局缓存
  3. 减少计算负载——服务端不需要为每个用户重新处理相同的工具定义

更通用的原则

这个模式揭示了一个更通用的缓存优化原则:

消除用户维度的差异,启用全局缓存

任何嵌入用户特定信息(UID、用户名、主目录路径)的内容都会阻止全局缓存命中。通过使用标准化的占位符或环境变量,可以让这些内容在所有用户之间保持一致。


15.7 模式六:条件段落省略

问题识别

系统提示词中有一些段落只在特定条件下出现:

  • 某个 feature flag 启用时添加一段说明
  • 某个功能可用时插入一段指导
  • 某个实验开启时包含一段警告

当这些条件在会话中途翻转(例如 GrowthBook 的远程配置更新),段落的出现/消失会改变系统提示词的内容,导致缓存中断。

flowchart TD
    A[请求 N-1] --> B[系统提示词
包含段落 X] B --> C[缓存键 A] D[请求 N] --> E[系统提示词
不包含段落 X
(feature flag 翻转)] E --> F[缓存键 B] C -.->|缓存中断| F style B fill:#90EE90 style E fill:#FFB6C1 style C fill:#90EE90 style F fill:#FFB6C1

优化方案

条件段落省略模式的核心原则是:宁可不说,不要说了又删

具体实施方式包括:

1. 用静态文本替代条件段落

如果一段说明对模型行为影响不大,干脆总是包含它(或总是不包含),避免条件判断。

示例

// 不好的做法:条件性包含
let prompt = 'You are Claude Code...'
if (feature('ENABLE_EXPERIMENTAL_FEATURE')) {
  prompt += '\n\nExperimental feature X is available. Use it with caution.'
}

// 好的做法:总是包含(或总是不包含)
const prompt = 'You are Claude Code.\n\nExperimental features may be available. Use them with caution.'

2. 将条件内容移到动态边界之后

如果必须条件性包含,将其放在 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 之后,此区域不参与全局缓存。

// constants/systemPrompt.ts
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '=== DYNAMIC CONTENT ==='

const staticPrompt = '...静态内容...'
let dynamicPrompt = ''

if (feature('SOME_FEATURE')) {
  dynamicPrompt += '\n\nFeature-specific instructions...'
}

const fullPrompt = `${staticPrompt}\n\n${SYSTEM_PROMPT_DYNAMIC_BOUNDARY}\n\n${dynamicPrompt}`

3. 用附件机制替代内联条件

类似模式三的 Agent 列表,将条件内容作为附件追加在消息尾部。

// 不好的做法:在系统提示词中条件性包含
if (feature('INCLUDE_CUSTOM_RULES')) {
  systemPrompt += customRules
}

// 好的做法:通过附件传递
if (feature('INCLUDE_CUSTOM_RULES')) {
  attachments.push({
    name: 'custom_rules',
    content: customRules
  })
}

设计原则

这个模式没有单一的实现位置——它是一个设计原则,贯穿于系统提示词和工具提示词的构建过程中。

其本质是确保 API 请求前缀中的系统提示词块在会话生命周期内保持单调稳定

单调稳定性 = 内容只能增加,不能减少
            或者:内容要么始终存在,要么始终不存在

实际应用

在 Claude Code 的代码库中,这个原则体现在多个位置:

// services/api/claude.ts 中的系统提示词构建
const basePrompt = getBaseSystemPrompt()  // 总是包含的核心内容
const staticAdditions = getStaticAdditions()  // 总是包含的补充内容

// 只在特定条件下包含的内容被放在 DYNAMIC_BOUNDARY 之后
const dynamicContent = feature('EXPERIMENTAL_FEATURE') 
  ? getExperimentalInstructions() 
  : ''

const systemPrompt = [
  { type: 'text', text: basePrompt + '\n\n' + staticAdditions },
  { type: 'text', text: DYNAMIC_BOUNDARY, cache_control: { type: 'ephemeral' } },
  { type: 'text', text: dynamicContent }
]

15.8 模式七:工具 Schema 缓存 getToolSchemaCache()

问题识别

工具 Schema 的序列化(toolToAPISchema())是一个复杂过程,涉及多个运行时决策:

  1. GrowthBook feature flag——tengu_tool_pear(strict mode)、tengu_fgts(fine-grained tool streaming)等 flag 控制 Schema 中的可选字段
  2. tool.prompt() 的动态输出——部分工具的描述文本包含运行时信息
  3. MCP 工具的 Schema——外部服务器提供的 Schema 可能在会话中途变化

每次 API 请求都重新计算工具 Schema 意味着:如果 GrowthBook 在会话中途刷新了缓存,某个 flag 的值从 true 变为 false,工具 Schema 的序列化结果就会改变——缓存中断。

优化方案

// utils/toolSchemaCache.ts:1-27
// Session-scoped cache of rendered tool schemas. Tool schemas render at server
// position 2 (before system prompt), so any byte-level change busts the entire
// ~11K-token tool block AND everything downstream. GrowthBook gate flips
// (tengu_tool_pear, tengu_fgts), MCP reconnects, or dynamic content in
// tool.prompt() drift all cause this churn. Memoizing per-session locks the schema
// bytes at first render — mid-session GB refreshes no longer bust the cache.

type CachedSchema = BetaTool & {
  strict?: boolean
  eager_input_streaming?: boolean
}

const TOOL_SCHEMA_CACHE = new Map()

export function getToolSchemaCache(): Map {
  return TOOL_SCHEMA_CACHE
}

export function clearToolSchemaCache(): void {
  TOOL_SCHEMA_CACHE.clear()
}

TOOL_SCHEMA_CACHE 是一个模块级 Map,以工具名(或包含 inputJSONSchema 的复合键)为键,缓存完整的序列化 Schema。

工作原理

sequenceDiagram
    participant API as API Request
    participant Cache as Tool Schema Cache
    participant Render as toolToAPISchema()
    participant GB as GrowthBook
    
    API->>Cache: 请求工具 Schema
    Cache->>Cache: 检查缓存
    
    alt 缓存命中
        Cache-->>API: 返回缓存的 Schema
    else 缓存未命中
        Cache->>Render: 调用 toolToAPISchema()
        Render->>GB: 评估 feature flags
        Render->>Render: 序列化 Schema
        Render-->>Cache: 返回完整 Schema
        Cache->>Cache: 存入缓存
        Cache-->>API: 返回 Schema
    end

一旦工具的 Schema 在首次请求中被渲染并缓存,后续请求直接复用缓存值,不再调用 tool.prompt() 或重新评估 GrowthBook flag。

缓存键设计

缓存键的设计有一个细微但关键的考量:

// utils/api.ts:147-149
const cacheKey =
  'inputJSONSchema' in tool && tool.inputJSONSchema
    ? `${tool.name}:${jsonStringify(tool.inputJSONSchema)}`
    : tool.name

大多数工具以名称为键——每个工具名称唯一,Schema 在会话内不变。但 StructuredOutput 工具是特例:它的名称固定为 'StructuredOutput',但不同的工作流调用会传入不同的 inputJSONSchema

如果只用名称作为键,第一次调用缓存的 Schema 会在后续不同工作流中被错误复用。

源码注释提到了这个 bug 的严重性:

// StructuredOutput instances share the name 'StructuredOutput' but carry
// different schemas per workflow call — name-only keying returned a stale
// schema (5.4% → 51% err rate, see PR#25424).

错误率从 5.4% 飙升到 51%——这不是一个微妙的缓存一致性问题,而是一个严重的功能 bug。

通过将 inputJSONSchema 包含在缓存键中解决了这个问题:

// StructuredOutput 工具的缓存键示例
const cacheKey1 = 'StructuredOutput:{"type":"object","properties":{"name":{"type":"string"}}}'
const cacheKey2 = 'StructuredOutput:{"type":"object","properties":{"age":{"type":"integer"}}}'

不同的 inputJSONSchema 产生不同的缓存键,避免了错误的缓存复用。

生命周期管理

TOOL_SCHEMA_CACHE 的生命周期与会话绑定:

stateDiagram-v2
    [*] --> Empty: 会话开始
    Empty --> Populating: 首次 API 请求
    Populating --> Populated: Schema 渲染完成
    Populated --> Reusing: 后续 API 请求
    Reusing --> Populated: 缓存命中
    
    Populated --> Cleared: 用户登出
    Cleared --> Empty: 会话结束
    Empty --> [*]
    
    note right of Populating
        逐工具填充缓存
        每个工具调用一次
        toolToAPISchema()
    end note
    
    note right of Reusing
        直接复用缓存
        不重新评估
        GrowthBook flags
    end note
  • 创建:第一次调用 toolToAPISchema() 时逐工具填充
  • 读取:后续每次 API 请求复用缓存的 Schema
  • 清除clearToolSchemaCache() 在用户登出时调用

模块拆分的艺术

注意 clearToolSchemaCache 被放在 utils/toolSchemaCache.ts 这个独立的叶子模块中,而非 utils/api.ts

注释解释了原因:

// Lives in a leaf module so auth.ts can clear it without importing api.ts
// (which would create a cycle via plans→settings→file→growthbook→config→
// bridgeEnabled→auth).

一个看似简单的缓存 Map,需要仔细的模块拆分来避免循环依赖:

auth.ts → toolSchemaCache.ts (可以)
auth.ts → api.ts → growthbook.ts → config.ts → bridgeEnabled.ts → auth.ts (循环!)

这是大型 TypeScript 项目中的常见挑战——缓存的位置决定了依赖关系


15.9 模式的共同本质

回顾这七个模式,下图展示了所有模式共同遵循的优化决策流程:

flowchart TD
    Start[识别动态内容] --> Q1{内容是否必须
出现在前缀中?} Q1 -- 否 --> Move[移至消息尾部/附件] Move --> Done[缓存安全] Q1 -- 是 --> Q2{能否消除
用户维度差异?} Q2 -- 是 --> Placeholder[使用占位符/标准化] Placeholder --> Done Q2 -- 否 --> Q3{能否降低
变化频率?} Q3 -- 是 --> Reduce[记忆化/降低精度/会话级缓存] Reduce --> Done Q3 -- 否 --> Q4{能否限制
变化幅度?} Q4 -- 是 --> Budget[预算控制/条件段落省略] Budget --> Done Q4 -- 否 --> Accept[标记为动态区域
scope: null] Accept --> Done style Start fill:#e1f5fe style Done fill:#c8e6c9 style Q1 fill:#fff9c4 style Q2 fill:#fff9c4 style Q3 fill:#fff9c4 style Q4 fill:#fff9c4 style Move fill:#ffccbc style Placeholder fill:#ffccbc style Reduce fill:#ffccbc style Budget fill:#ffccbc style Accept fill:#ffccbc

四大核心原则

从这些模式中可以提取出四个共性原则:

原则一:将动态内容推向请求尾部

API 请求的前缀匹配模型意味着:越靠前的内容,其变化的破坏性越大

因此:

  • 日期记忆化(模式一)锁定系统提示词中的日期
  • Agent 列表附件化(模式三)将动态列表从工具 Schema(前端)移到消息附件(尾部)
  • 条件段落省略(模式六)确保前缀中的内容不抖动

原则二:降低变化频率

当内容必须出现在前缀中时,降低其变化频率是次优选择:

  • 月度粒度(模式二)将日期变化从每日降到每月
  • 技能列表预算(模式四)通过预算裁剪减少列表变化
  • 工具 Schema 缓存(模式七)将变化频率从每请求降到每会话

原则三:消除用户维度的差异

全局缓存的前提是所有用户看到相同的前缀:

  • $TMPDIR 占位符(模式五)消除了用户 UID 带来的路径差异
  • 日期记忆化也间接服务于此——不同时区用户在同一时刻可能有不同日期

原则四:先测量,再优化

每一个模式的发现都依赖第14章的缓存中断检测系统:

  • 10.2% cache_creation tokens归因于 Agent 列表——这个数字来自 BigQuery 分析
  • 77% 的工具变化是单个工具 Schema 变化——这驱动了工具 Schema 缓存的设计
  • GrowthBook flag 翻转是中断原因——这驱动了会话级缓存的引入

没有可观测性基础设施,这些模式不会被发现。

优化模式的层次结构

这七个模式并非平级——它们形成一个层次结构:

mindmap
  root((缓存优化))
    系统级
      工具 Schema 缓存
      Agent 列表附件化
    内容级
      日期记忆化
      月度粒度
      条件段落省略
    用户级
      $TMPDIR 占位符
    资源级
      技能列表预算
  • 系统级模式影响整个请求构建流程,需要架构层面的设计
  • 内容级模式针对特定类型的内容(日期、条件段落),需要内容设计的优化
  • 用户级模式关注跨用户缓存的优化,需要标准化的设计
  • 资源级模式控制资源使用(预算),需要权衡和限制策略

15.10 实战指南:应用这些模式

对 API 调用者的建议

这些模式不仅适用于 Claude Code——任何使用 Anthropic API(或类似前缀缓存机制的 API)的应用都可以借鉴。

1. 审计你的系统提示词

识别其中的动态内容(日期、用户名、配置值),将它们推到系统提示词的末尾或移至消息中。

检查清单

  • 是否包含当前日期/时间?→ 考虑记忆化或使用月度粒度
  • 是否包含用户特定信息?→ 考虑使用占位符
  • 是否有条件性段落?→ 考虑省略或移至附件
  • 是否有动态配置?→ 考虑会话级缓存

2. 锁定工具 Schema

工具定义在会话内应该保持不变。如果必须动态改变工具列表,考虑使用消息附件替代。

最佳实践

// 不好的做法:每次请求重新生成工具列表
const tools = getDynamicTools()  // 可能每次都不同

// 好的做法:会话级缓存
const tools = getToolSchemaCache().get('tool-name') 
  || cacheAndReturn('tool-name', getDynamicTools())

3. 监控 cache_read_input_tokens

这是判断缓存是否正常工作的唯一指标。如果它在会话中意外下降,你就有了一个缓存中断。

监控脚本示例

function monitorCacheHealth(response: APIResponse) {
  const cacheTokens = response.usage.cache_read_input_tokens
  const inputTokens = response.usage.input_tokens
  
  const cacheHitRate = cacheTokens / inputTokens
  
  if (cacheHitRate < 0.5) {
    console.warn(`Low cache hit rate: ${(cacheHitRate * 100).toFixed(1)}%`)
    // 触发缓存中断检测
  }
}

4. 理解前缀顺序

cache_control 断点之前的内容变化会废弃该断点的缓存。在请求构建时,将最稳定的内容放在最前面。

推荐顺序

1. 系统提示词(最稳定,会话级锁定)
2. 工具 Schema(较稳定,会话级缓存)
3. 消息历史(动态增长,不可避免)
4. 当前请求(每次都不同,无法缓存)

常见陷阱

陷阱 原因 解决方案
在系统提示词中嵌入时间戳 每次请求都变 使用会话级记忆化
动态工具列表 MCP 连接/断开改变列表 附件机制或 defer_loading
用户特定路径 不同用户不同字节 环境变量占位符
Feature flag 直接影响 Schema 远程配置刷新 会话级缓存
频繁切换模型 模型是缓存键的一部分 尽量固定模型选择

反模式:过度优化

在应用这些模式时,也要警惕过度优化的陷阱:

// 反模式:为了缓存而牺牲功能
function getDate(): string {
  // 为了缓存稳定,永远返回 "2026-01-01"
  return '2026-01-01'
}

// 好的做法:在稳定性和功能性之间权衡
function getSessionStartDate(): string {
  return memoize(getLocalISODate)  // 会话内稳定,日期正确
}

缓存优化的目标是在不显著影响功能的前提下提高缓存命中率,而不是为了缓存而牺牲所有动态性。


15.11 性能影响与成本节约

量化的影响

让我们量化这些优化模式的累积影响:

模式 影响范围 节约的 cache_creation tokens
日期记忆化 每日一次 ~11,000 tokens/用户/天
月度粒度 工具提示词 ~30 tokens/工具/天 → ~900 tokens/天
Agent 列表附件化 全量 cache_creation 10.2% 的全量 tokens
技能列表预算 工具提示词 取决于技能数量,平均 ~500 tokens/请求
$TMPDIR 占位符 全局缓存 启用跨用户共享,难以量化但显著
条件段落省略 系统提示词 取决于 feature flag 翻转频率
工具 Schema 缓存 所有工具 Schema ~20,000 tokens/会话

对于每天 1 亿次请求的系统,这些优化的累积影响是数亿到数十亿 tokens 的节约。

成本节约计算

假设:

  • 每天 1 亿次 API 请求
  • 平均每次请求 31,000 tokens 的前缀(系统提示词 + 工具 Schema)
  • 缓存命中率为 80%(应用这些优化后)
  • 输入 token 价格:$3/1M tokens

无优化场景

缓存命中率:30%
每次请求平均 cache_creation:31,000 × 70% = 21,700 tokens
每天总 cache_creation:21,700 × 100,000,000 = 2.17T tokens
每天成本:2.17T × $3/1M = $6,510

优化后场景

缓存命中率:80%
每次请求平均 cache_creation:31,000 × 20% = 6,200 tokens
每天总 cache_creation:6,200 × 100,000,000 = 620B tokens
每天成本:620B × $3/1M = $1,860

每天节约:$6,510 - $1,860 = $4,650 每月节约:$4,650 × 30 = $139,500 每年节约:$4,650 × 365 = $1,697,250

这只是 cache_creation tokens 的节约,还没有计算:

  • 延迟降低——缓存命中的请求处理时间显著减少
  • 用户体验提升——更快的响应时间
  • 服务器负载降低——更少的计算需求

延迟影响

缓存不仅降低成本,还降低延迟:

// 伪代码示意
function measureLatency(withCache: boolean): number {
  if (withCache) {
    // 缓存命中:服务端复用 KV 状态
    return 200 + Math.random() * 100  // 200-300ms
  } else {
    // 缓存未命中:服务端需要重新计算
    return 200 + Math.random() * 100 + 500  // 700-800ms
  }
}

缓存命中的请求比未命中的请求快约 500ms(假设 31,000 tokens 的处理时间)。

对于 50 轮的编程会话:

  • 无缓存:50 × 800ms = 40,000ms = 40 秒
  • 有缓存:50 × 300ms = 15,000ms = 15 秒

时间节约:25 秒/会话

这对于交互式工具的用户体验是显著的提升。


15.12 小结:缓存优化的艺术

本章介绍了 Claude Code 的 7 个缓存优化模式:

  1. 日期记忆化memoize(getLocalISODate) 消除跨天缓存击穿
  2. 月度粒度getLocalMonthYear() 将工具提示词的日期变化频率从每日降至每月
  3. Agent 列表附件化:消除了 10.2% 的 cache_creation tokens
  4. 技能列表预算:1% context window 的硬预算控制列表大小和变化
  5. $TMPDIR 占位符:消除用户维度差异,启用全局缓存
  6. 条件段落省略:确保前缀内容不因功能开关抖动
  7. 工具 Schema 缓存:会话级 Map 隔离 GrowthBook 翻转和动态内容

核心洞察

这些模式共同体现了一个核心洞察:

缓存优化不是一个独立的关注点,而是渗透到系统每一个产生动态内容的位置

从日期格式到路径字符串,从工具描述到 feature flag——任何"看起来不重要"的变化都可能导致成千上万 tokens 的缓存失效。Claude Code 的做法是将缓存稳定性视为一等公民,在每个产生动态内容的位置都显式地做出缓存友好的设计决策。

三步方法论

综合这三章的内容,我们有一个完整的缓存工程方法论:

flowchart LR
    A[第13章:防御] --> A1[三级缓存范围]
    A --> A2[智能断点设计]
    A --> A3[锁存机制]
    
    B[第14章:检测] --> B1[两阶段检测系统]
    B --> B2[解释引擎]
    B --> B3[BigQuery 可观测性]
    
    C[第15章:优化] --> C1[7+ 优化模式]
    C --> C2[量化影响]
    C --> C3[持续改进]
    
    A --> D[完整的缓存工程体系]
    B --> D
    C --> D
    
    style A fill:#e3f2fd
    style B fill:#fff3e0
    style C fill:#f3e5f5
    style D fill:#e8f5e9

防御 → 检测 → 优化——三者构成一个闭环:

  1. 防御:从架构层面预防缓存中断
  2. 检测:实时监控和诊断缓存失效
  3. 优化:基于数据驱动的系统性改进

下一步

至此,第四篇"提示词缓存"完结。我们建立了:

  • 第13章:缓存架构与断点设计——防御层
  • 第14章:缓存中断检测系统——检测层
  • 第15章:缓存优化模式——优化层

这三章共同构成了一个完整的缓存工程体系。

下一篇将转向安全与权限系统——另一个需要系统性工程思维的领域。详见第16章。


参考资料

Enjoyed this article? Share it with others!