第15章:缓存优化模式
为什么这很重要
第13章分析了缓存架构的防御层,第14章构建了缓存中断的检测能力。本章将转向进攻——Claude Code 如何通过一系列命名的优化模式,从源头消除或减少缓存中断的发生。
这些优化模式并非一次性设计出来的。每一个模式都源自第14章介绍的缓存中断检测系统在 BigQuery 中捕获的真实数据。当 tengu_prompt_cache_break 事件揭示某个特定的中断原因反复出现时,工程团队就会设计一个针对性的优化模式来消除它。
本章将介绍 7 个以上的命名缓存优化模式,从简单的日期记忆化到复杂的工具 Schema 缓存。每个模式都遵循同一个框架:识别变化源 → 理解变化本质 → 将动态变为静态。
模式汇总
在深入每个模式之前,先看一个全局视图:
| # | 模式名称 | 变化源 | 优化策略 | 关键文件 | 影响范围 |
|---|---|---|---|---|---|
| 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+ 缓存优化模式汇总
15.1 模式一:日期记忆化 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。这一个字符的变化就足以击穿整个系统提示词前缀的缓存——约 11,000 tokens 需要重新计算。
解决方案
// constants/common.ts:24
export const getSessionStartDate = memoize(getLocalISODate)
getSessionStartDate 使用 lodash 的 memoize 包装 getLocalISODate——函数在首次调用时捕获日期,此后永远返回相同的值,无论实际日期是否已经变化。
源码注释(第17-23行)详细解释了这个决策的权衡:
// 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 费用。
15.2 模式二:月度粒度 getLocalMonthYear()
问题
日期记忆化解决了系统提示词中的跨天问题,但工具提示词中也需要时间信息。如果工具提示词使用完整日期(YYYY-MM-DD),每天凌晨都会导致包含该工具的 Schema 缓存失效。而工具 Schema 位于 API 请求的前端位置,其变化的破坏性比系统提示词更大。
解决方案
// 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”),而非完整日期。变化频率从每日降低到每月。
注释(第27行)说明了设计意图:
// 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 请求前端的内容,越需要更低的变化频率。
15.3 模式三:Agent 列表从工具描述移至消息附件
问题
AgentTool 的工具描述中嵌入了可用 Agent 的列表——每个 Agent 的名称、类型和描述。这个列表是动态的:MCP 服务器异步连接会带来新的 Agent、/reload-plugins 命令会刷新插件列表、权限模式变化会改变可用 Agent 集合。
每次列表变化,AgentTool 的工具 Schema 就会改变,导致整个工具 Schema 数组的缓存失效。工具 Schema 在 API 请求中位于系统提示词之后,它的变化不仅废弃自身的缓存,还会废弃下游所有消息的缓存。
源码注释(tools/AgentTool/prompt.ts,第50-57行)量化了这个问题的严重性:
// 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 归因于这个问题。
解决方案
// 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)
}
解决方案是将动态的 Agent 列表从 AgentTool 的工具描述中移出,改为通过消息附件(attachment)注入。工具描述变为静态文本,只描述 AgentTool 的通用功能;可用 Agent 的列表作为 agent_listing_delta 附件追加在用户消息中。
这个迁移的关键洞察是:附件追加在消息尾部,不影响前缀缓存。Agent 列表的变化只增加新消息的 token 成本,不会废弃已缓存的工具 Schema。
影响
消除了 10.2% 的 cache_creation tokens——这是所有优化模式中影响最大的单一改进。通过 GrowthBook feature flag tengu_agent_list_attach 控制灰度发布,同时保留环境变量 CLAUDE_CODE_AGENT_LIST_IN_MESSAGES 作为手动覆盖。
15.4 模式四:技能列表预算(1% Context Window)
问题
SkillTool 类似于 AgentTool,其工具描述中嵌入了可用技能的列表。随着技能生态的增长(内置技能 + 项目技能 + 插件技能),列表可能变得非常长。更重要的是,技能的加载是动态的——不同项目有不同的 .claude/ 配置,插件可能在会话中途加载或卸载。
解决方案
// 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
SkillTool 对技能列表施加了严格的预算限制:列表总大小不超过上下文窗口的 1%。对于 200K 的上下文窗口,这约为 8,000 个字符。
预算计算函数(第31-41行):
// 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
}
此外,每个技能条目的描述也被截断:
// tools/SkillTool/prompt.ts:29
export const MAX_LISTING_DESC_CHARS = 250
注释(第25-28行)解释了设计逻辑:
// 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.
缓存优化的本质
1% 预算控制的缓存优化效果体现在两个方面:
- 限制工具描述大小:更短的描述意味着更少的字节需要精确匹配
- 预算裁剪减少抖动:当新技能被加载但预算已满时,它不会被包含在列表中——列表不变,缓存不破
这是一个“预算即稳定“的模式:通过限制动态内容的最大尺寸,间接控制了缓存键的变化幅度。
15.5 模式五:$TMPDIR 占位符
问题
BashTool 的提示词中需要告诉模型可以写入的临时目录路径。Claude Code 使用 getClaudeTempDir() 获取这个路径,格式通常为 /private/tmp/claude-{UID}/,其中 {UID} 是用户的系统 UID。
问题在于:不同用户有不同的 UID,因此路径字符串不同。如果这个路径嵌入在工具提示词中,它会阻止跨用户的全局缓存命中。用户 A 的 /private/tmp/claude-1001/ 和用户 B 的 /private/tmp/claude-1002/ 是不同的字节序列,即使在全局缓存范围内也无法共享。
解决方案
// 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 设置为正确的目录,模型使用 $TMPDIR 引用临时目录与使用绝对路径效果相同。
提示词中还明确告知模型使用 $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.',
影响
这个优化使得 BashTool 的提示词在所有用户之间逐字节一致,从而允许全局缓存范围的前缀共享。对于 BashTool 这个最常用的工具,其 Schema 的全局缓存命中意味着显著的成本节约。
15.6 模式六:条件段落省略
问题
系统提示词中有一些段落只在特定条件下出现:某个 feature flag 启用时添加一段说明,某个功能可用时插入一段指导。当这些条件在会话中途翻转(例如 GrowthBook 的远程配置更新),段落的出现/消失会改变系统提示词的内容,导致缓存中断。
解决方案
条件段落省略模式的核心原则是:宁可不说,不要说了又删。具体实施方式包括:
- 用静态文本替代条件段落:如果一段说明对模型行为影响不大,干脆总是包含它(或总是不包含),避免条件判断
- 将条件内容移到动态边界之后:如果必须条件性包含,将其放在
SYSTEM_PROMPT_DYNAMIC_BOUNDARY之后,此区域不参与全局缓存(详见第13章) - 用附件机制替代内联条件:类似模式三的 Agent 列表,将条件内容作为附件追加在消息尾部
这个模式没有单一的实现位置——它是一个设计原则,贯穿于系统提示词和工具提示词的构建过程中。其本质是确保 API 请求前缀中的系统提示词块在会话生命周期内保持单调稳定:内容要么始终存在,要么始终不存在,不会因外部条件翻转而出现/消失。
15.7 模式七:工具 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<string, CachedSchema>()
// export function getToolSchemaCache(): Map<string, CachedSchema> {
return TOOL_SCHEMA_CACHE
}
// export function clearToolSchemaCache(): void {
TOOL_SCHEMA_CACHE.clear()
}
TOOL_SCHEMA_CACHE 是一个模块级 Map,以工具名(或包含 inputJSONSchema 的复合键)为键,缓存完整的序列化 Schema。一旦工具的 Schema 在首次请求中被渲染并缓存,后续请求直接复用缓存值,不再调用 tool.prompt() 或重新评估 GrowthBook flag。
缓存键设计
缓存键的设计有一个细微但关键的考量(utils/api.ts,第147-149行):
// 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 包含在缓存键中解决了这个问题。
生命周期
TOOL_SCHEMA_CACHE 的生命周期与会话绑定:
- 创建:第一次调用
toolToAPISchema()时逐工具填充 - 读取:后续每次 API 请求复用缓存的 Schema
- 清除:
clearToolSchemaCache()在用户登出时调用(通过auth.ts),确保新会话不会复用旧会话的陈旧 Schema
注意 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,需要仔细的模块拆分来避免循环依赖——这是大型 TypeScript 项目中的常见挑战。
15.8 模式的共同本质
回顾这七个模式,下图展示了所有模式共同遵循的优化决策流程:
flowchart TD
Start[识别动态内容] --> Q1{内容是否必须\n出现在前缀中?}
Q1 -- 否 --> Move[移至消息尾部/附件]
Move --> Done[缓存安全]
Q1 -- 是 --> Q2{能否消除\n用户维度差异?}
Q2 -- 是 --> Placeholder[使用占位符/标准化]
Placeholder --> Done
Q2 -- 否 --> Q3{能否降低\n变化频率?}
Q3 -- 是 --> Reduce[记忆化/降低精度/会话级缓存]
Reduce --> Done
Q3 -- 否 --> Q4{能否限制\n变化幅度?}
Q4 -- 是 --> Budget[预算控制/条件段落省略]
Budget --> Done
Q4 -- 否 --> Accept[标记为动态区域\nscope: null]
Accept --> Done
style Start fill:#f9f,stroke:#333
style Done fill:#9f9,stroke:#333
图 15-1:缓存优化模式决策流程
可以提取出几个共性原则:
原则一:将动态内容推向请求尾部
API 请求的前缀匹配模型意味着:越靠前的内容,其变化的破坏性越大。因此:
- 日期记忆化(模式一)锁定系统提示词中的日期
- Agent 列表附件化(模式三)将动态列表从工具 Schema(前端)移到消息附件(尾部)
- 条件段落省略(模式六)确保前缀中的内容不抖动
原则二:降低变化频率
当内容必须出现在前缀中时,降低其变化频率是次优选择:
- 月度粒度(模式二)将日期变化从每日降到每月
- 技能列表预算(模式四)通过预算裁剪减少列表变化
- 工具 Schema 缓存(模式七)将变化频率从每请求降到每会话
原则三:消除用户维度的差异
全局缓存的前提是所有用户看到相同的前缀:
- $TMPDIR 占位符(模式五)消除了用户 UID 带来的路径差异
- 日期记忆化也间接服务于此——不同时区用户在同一时刻可能有不同日期
原则四:先测量,再优化
每一个模式的发现都依赖第14章的缓存中断检测系统:
- 10.2% cache_creation tokens 归因于 Agent 列表——这个数字来自 BigQuery 分析
- 77% 的工具变化是单个工具 Schema 变化——这驱动了工具 Schema 缓存的设计
- GrowthBook flag 翻转是中断原因——这驱动了会话级缓存的引入
没有可观测性基础设施,这些模式不会被发现。
用户能做什么
这些模式不仅适用于 Claude Code——任何使用 Anthropic API(或类似前缀缓存机制的 API)的应用都可以借鉴。
对 API 调用者的建议
- 审计你的系统提示词:识别其中的动态内容(日期、用户名、配置值),将它们推到系统提示词的末尾或移至消息中
- 锁定工具 Schema:工具定义在会话内应该保持不变。如果必须动态改变工具列表,考虑使用消息附件替代
- 监控 cache_read_input_tokens:这是判断缓存是否正常工作的唯一指标。如果它在会话中意外下降,你就有了一个缓存中断
- 理解前缀顺序:
cache_control断点之前的内容变化会废弃该断点的缓存。在请求构建时,将最稳定的内容放在最前面
常见陷阱
| 陷阱 | 原因 | 解决方案 |
|---|---|---|
| 在系统提示词中嵌入时间戳 | 每次请求都变 | 使用会话级记忆化 |
| 动态工具列表 | MCP 连接/断开改变列表 | 附件机制或 defer_loading |
| 用户特定路径 | 不同用户不同字节 | 环境变量占位符 |
| Feature flag 直接影响 Schema | 远程配置刷新 | 会话级缓存 |
| 频繁切换模型 | 模型是缓存键的一部分 | 尽量固定模型选择 |
小结
本章介绍了 Claude Code 的 7 个缓存优化模式:
- 日期记忆化:
memoize(getLocalISODate)消除跨天缓存击穿 - 月度粒度:
getLocalMonthYear()将工具提示词的日期变化频率从每日降至每月 - Agent 列表附件化:消除了 10.2% 的 cache_creation tokens
- 技能列表预算:1% context window 的硬预算控制列表大小和变化
- $TMPDIR 占位符:消除用户维度差异,启用全局缓存
- 条件段落省略:确保前缀内容不因功能开关抖动
- 工具 Schema 缓存:会话级 Map 隔离 GrowthBook 翻转和动态内容
这些模式共同体现了一个核心洞察:缓存优化不是一个独立的关注点,而是渗透到系统每一个产生动态内容的位置。从日期格式到路径字符串,从工具描述到 feature flag——任何“看起来不重要“的变化都可能导致成千上万 tokens 的缓存失效。Claude Code 的做法是将缓存稳定性视为一等公民,在每个产生动态内容的位置都显式地做出缓存友好的设计决策。
至此,第四篇“提示词缓存“完结。第13章建立了缓存架构的防御层(范围、TTL、锁存),第14章构建了检测能力(两阶段检测、解释引擎),第15章展示了进攻手段(7+ 优化模式)。三章共同构成了一个完整的缓存工程体系:防御 → 检测 → 优化。
下一篇将转向安全与权限系统——另一个需要系统性工程思维的领域。详见第16章。