# 第15章：缓存优化模式

> **定位**：本章分析7个以上命名缓存优化模式——从日期记忆化到工具Schema缓存，每个模式遵循"识别变化源→理解变化本质→将动态变为静态"框架。前置依赖：第13章（缓存架构）、第14章（缓存中断检测）。适用场景：想学习提示词缓存优化的具体技巧和可操作建议的读者。

## 为什么这很重要

第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()` 函数获取：

```typescript
// 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 需要重新计算。

### 解决方案

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

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

源码注释（第17-23行）详细解释了这个决策的权衡：

```typescript
// 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 请求的前端位置，其变化的破坏性比系统提示词更大。

### 解决方案

```typescript
// 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行）量化了这个问题的严重性：

```typescript
// 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 归因于这个问题。**

### 解决方案

```typescript
// 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/` 配置，插件可能在会话中途加载或卸载。

### 解决方案

```typescript
// 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行）：

```typescript
// 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
}
```

此外，每个技能条目的描述也被截断：

```typescript
// 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/` 是不同的字节序列，即使在全局缓存范围内也无法共享。

### 解决方案

```typescript
// 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`：

```typescript
// 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 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 的序列化结果就会改变——缓存中断。

### 解决方案

```typescript
// 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行）：

```typescript
// 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 模式的共同本质

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

```mermaid
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 用户的建议

1. **利用 1 小时缓存窗口**。CC 的 prompt cache TTL 为 1 小时——如果你在一个小时内持续工作，后续请求会享受越来越高的缓存命中率。避免长时间中断后期望缓存仍然有效
2. **复用会话而非频繁创建新会话**。新会话 = 新的缓存前缀 = 零命中。使用 `--resume` 恢复已有会话比创建新会话更省成本
3. **监控 `cache_creation_input_tokens` vs `cache_read_input_tokens`**。前者是你为缓存"付的学费"，后者是"缓存回报"。健康的会话应该是 creation 在前几轮偏高、之后 read 占主导
4. **如果构建 Agent，实现缓存编辑钉选**。CC 的 `pinCacheEdits()` / `consumePendingCacheEdits()` 模式允许在不破坏缓存前缀的情况下修改消息内容——这是一个值得借鉴的高级优化

---

## 小结

本章介绍了 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章。
