Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第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工具提示词
3Agent 列表附件化Agent 列表动态变化从工具描述移至消息附件tools/AgentTool/prompt.ts工具 Schema(10.2% cache_creation)
4技能列表预算技能数量增长限制为 1% context windowtools/SkillTool/prompt.ts工具 Schema
5$TMPDIR 占位符用户 UID 嵌入路径替换为 $TMPDIRtools/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 缓存全量击穿。选择过时日期是因为:

  1. 日期信息对大多数编程任务不关键
  2. 当午夜确实发生时,getDateChangeAttachments 会在消息尾部追加新日期——这不影响前缀缓存
  3. 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% 预算控制的缓存优化效果体现在两个方面:

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

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


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 的远程配置更新),段落的出现/消失会改变系统提示词的内容,导致缓存中断。

解决方案

条件段落省略模式的核心原则是:宁可不说,不要说了又删。具体实施方式包括:

  1. 用静态文本替代条件段落:如果一段说明对模型行为影响不大,干脆总是包含它(或总是不包含),避免条件判断
  2. 将条件内容移到动态边界之后:如果必须条件性包含,将其放在 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 之后,此区域不参与全局缓存(详见第13章)
  3. 用附件机制替代内联条件:类似模式三的 Agent 列表,将条件内容作为附件追加在消息尾部

这个模式没有单一的实现位置——它是一个设计原则,贯穿于系统提示词和工具提示词的构建过程中。其本质是确保 API 请求前缀中的系统提示词块在会话生命周期内保持单调稳定:内容要么始终存在,要么始终不存在,不会因外部条件翻转而出现/消失。


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

问题

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

  1. GrowthBook feature flagtengu_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<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 调用者的建议

  1. 审计你的系统提示词:识别其中的动态内容(日期、用户名、配置值),将它们推到系统提示词的末尾或移至消息中
  2. 锁定工具 Schema:工具定义在会话内应该保持不变。如果必须动态改变工具列表,考虑使用消息附件替代
  3. 监控 cache_read_input_tokens:这是判断缓存是否正常工作的唯一指标。如果它在会话中意外下降,你就有了一个缓存中断
  4. 理解前缀顺序cache_control 断点之前的内容变化会废弃该断点的缓存。在请求构建时,将最稳定的内容放在最前面

常见陷阱

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

小结

本章介绍了 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 的做法是将缓存稳定性视为一等公民,在每个产生动态内容的位置都显式地做出缓存友好的设计决策。

至此,第四篇“提示词缓存“完结。第13章建立了缓存架构的防御层(范围、TTL、锁存),第14章构建了检测能力(两阶段检测、解释引擎),第15章展示了进攻手段(7+ 优化模式)。三章共同构成了一个完整的缓存工程体系:防御 → 检测 → 优化

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