Claude Code Harness 第15章:缓存优化模式
前言:从防御到进攻
在之前的两章中,我们建立了完整的缓存防护体系:
- 第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 缓存全量击穿。选择过时日期是因为:
- 日期信息对大多数编程任务不关键——模型在代码生成时很少需要精确的当前日期
- 午夜后的补救机制——当午夜确实发生时,
getDateChangeAttachments会在消息尾部追加新日期,这不影响前缀缓存 - 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 作为手动覆盖。
这种设计体现了生产环境优化的最佳实践:
- Feature flag 控制——可以逐步推送给用户群体,监控影响
- 环境变量覆盖——开发者可以在本地测试新行为
- 向后兼容——如果出现问题,可以快速回滚
影响量化
消除 10.2% 的 cache_creation tokens——这是所有优化模式中影响最大的单一改进。
对于一个每天处理 1 亿次 API 请求的系统,这意味着每天减少约 1000 万次缓存失效事件。
15.5 模式四:技能列表预算(1% Context Window)
问题识别
SkillTool 类似于 AgentTool,其工具描述中嵌入可用技能的列表。随着技能生态的增长,挑战包括:
- 数量增长:内置技能 + 项目技能 + 插件技能,列表可能变得非常长
- 动态加载:不同项目有不同的
.claude/配置 - 会话中途变化:插件可能在会话中途加载或卸载
如果不加控制,技能列表可能无限增长,导致:
- 工具描述过大——增加 token 消耗
- 缓存不稳定——频繁的列表变化导致缓存失效
- 性能下降——模型需要处理更长的上下文
优化方案
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
}三个优先级:
- 环境变量覆盖——
SLASH_COMMAND_TOOL_CHAR_BUDGET,用于测试和调试 - 动态计算——基于实际的 context window 大小
- 默认值——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% 预算控制的缓存优化效果体现在两个方面:
- 限制工具描述大小——更短的描述意味着更少的字节需要精确匹配
- 预算裁剪减少抖动——当新技能被加载但预算已满时,它不会被包含在列表中——列表不变,缓存不破
这是一个"预算即稳定"的模式:通过限制动态内容的最大尺寸,间接控制了缓存键的变化幅度。
实现细节
技能列表的裁剪逻辑在 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')
}这个实现确保了:
- 预算严格限制——永远不会超过预算
- 优先级保留——重要的技能(在列表前面)更可能被包含
- 可预测性——相同的技能列表总是产生相同的裁剪结果
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 的全局缓存命中意味着:
- 显著的成本节约——热门工具定义可以被数百万用户共享
- 更快的冷启动——新用户的第一条请求就能命中全局缓存
- 减少计算负载——服务端不需要为每个用户重新处理相同的工具定义
更通用的原则
这个模式揭示了一个更通用的缓存优化原则:
消除用户维度的差异,启用全局缓存
任何嵌入用户特定信息(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())是一个复杂过程,涉及多个运行时决策:
- GrowthBook feature flag——
tengu_tool_pear(strict mode)、tengu_fgts(fine-grained tool streaming)等 flag 控制 Schema 中的可选字段 - tool.prompt() 的动态输出——部分工具的描述文本包含运行时信息
- 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 个缓存优化模式:
- 日期记忆化:
memoize(getLocalISODate)消除跨天缓存击穿 - 月度粒度:
getLocalMonthYear()将工具提示词的日期变化频率从每日降至每月 - Agent 列表附件化:消除了 10.2% 的 cache_creation tokens
- 技能列表预算:1% context window 的硬预算控制列表大小和变化
- $TMPDIR 占位符:消除用户维度差异,启用全局缓存
- 条件段落省略:确保前缀内容不因功能开关抖动
- 工具 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防御 → 检测 → 优化——三者构成一个闭环:
- 防御:从架构层面预防缓存中断
- 检测:实时监控和诊断缓存失效
- 优化:基于数据驱动的系统性改进
下一步
至此,第四篇"提示词缓存"完结。我们建立了:
- 第13章:缓存架构与断点设计——防御层
- 第14章:缓存中断检测系统——检测层
- 第15章:缓存优化模式——优化层
这三章共同构成了一个完整的缓存工程体系。
下一篇将转向安全与权限系统——另一个需要系统性工程思维的领域。详见第16章。
参考资料
- Claude Code 官方文档:https://docs.anthropic.com/claude-code
- Anthropic API 提示词缓存文档:https://docs.anthropic.com/api/prompt-caching
- 原始仓库:https://github.com/anthropics/claude-code
- 本系列仓库:https://github.com/yaya888/claude-code-harness