BUZZ AI Gateway
首页 · 博客 · Prompt Cache 反模式与修复

Prompt Cache 命中率从 30% 拉到 90%:cache_control 7 个反模式与修复

你给 Claude 请求加了 cache_control,但每次返回的 usage.cache_creation_input_tokens 都是 0,cache_read_input_tokens 也是 0。账单一分没省。这不是 bug —— 是你打 cache_control 的方式让 Anthropic 根本没机会建缓存。本文拆解 7 个最常见的反模式,每个都给出 ❌ 错误代码 和 ✅ 修复代码,以及一份从 usage 字段反推问题的诊断指南。

2026-05-26 · 阅读约 12 分钟 · 适合 Anthropic API / Claude Code 用户
30% → 92%实测命中率提升
7反模式
1024最低 token 阈值
10×命中后输入降价

Anthropic Prompt Cache 简要回顾

先把规则讲清楚,后面所有反模式都建立在这几条规则上:

记住一句话:缓存匹配是前缀匹配,不是子串匹配。前缀里任何一个字节变了,缓存就断了。

用 usage 字段确认缓存有没有生效

// 一次健康的命中长这样
{
  "usage": {
    "input_tokens": 50,                      // 本次新增非缓存输入
    "cache_creation_input_tokens": 0,        // 没有新写入缓存
    "cache_read_input_tokens": 18742,        // 命中 18742 tokens
    "output_tokens": 350
  }
}

// 命中率 = 18742 / (50 + 0 + 18742) ≈ 99.7%

如果 cache_creation_input_tokens 一直是 0 且 cache_read_input_tokens 也是 0,意味着你的请求从未触发过缓存创建。下面 7 个反模式按这种症状从最常见到最隐蔽排序。

反模式 1:在动态内容上设置 cache_control

这是新手最常犯的错。你想缓存 system prompt + 用户输入,顺手把 cache_control 打在用户消息上。但用户消息每轮都不一样,缓存创建后立刻失效,下一轮从头再建,永远命中不了。

❌ 错误

const message = await anthropic.messages.create({
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  system: "你是一个代码审查助手...(2K tokens 的 system prompt)",
  messages: [
    {
      role: "user",
      content: [
        {
          type: "text",
          text: userQuery,                        // ← 每次都不一样
          cache_control: { type: "ephemeral" }    // ← 缓存这块没意义
        }
      ]
    }
  ]
});

// 结果:
// cache_creation_input_tokens: 2050  (每次都重建)
// cache_read_input_tokens: 0         (永远不命中)

✅ 修复

const message = await anthropic.messages.create({
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  system: [
    {
      type: "text",
      text: "你是一个代码审查助手...(2K tokens 的 system prompt)",
      cache_control: { type: "ephemeral" }       // ← 缓存稳定的 system
    }
  ],
  messages: [
    { role: "user", content: userQuery }         // ← 用户内容不打 cache_control
  ]
});

// 第一次:cache_creation_input_tokens: 2050
// 第二次起:cache_read_input_tokens: 2050,只为新增的 userQuery 付全价

原则:cache_control 只打在长期不变的内容末尾。变的部分往后放,不打断点。

反模式 2:断点放在变化最频繁的位置

稍微进阶的版本:你知道要缓存稳定内容,但你的"稳定 system prompt"末尾又拼了一个用户 ID 或会话时间戳。看起来人畜无害的一句"当前用户:user_42",每个用户都不同,意味着每个用户都建一份新缓存,跨用户完全无法复用。

❌ 错误

function buildSystemPrompt(userId, sessionTime) {
  return `你是 BUZZ 的助手,负责回答关于 API 的问题。
项目文档如下:
... (10K tokens 的项目文档) ...

当前用户:${userId}
会话开始时间:${sessionTime}`;
}

const message = await anthropic.messages.create({
  model: "claude-opus-4-8",
  system: [
    {
      type: "text",
      text: buildSystemPrompt(userId, sessionTime),
      cache_control: { type: "ephemeral" }
    }
  ],
  messages: [...]
});

// 结果:每个 (userId, sessionTime) 组合都是一次新建
// cache_creation_input_tokens: 10050  (重建)
// cache_read_input_tokens: 0          (跨用户/跨会话不命中)

✅ 修复

// 把动态部分挪到 messages 里,system 保持纯静态
const STATIC_SYSTEM = `你是 BUZZ 的助手,负责回答关于 API 的问题。
项目文档如下:
... (10K tokens 的项目文档) ...`;

const message = await anthropic.messages.create({
  model: "claude-opus-4-8",
  system: [
    {
      type: "text",
      text: STATIC_SYSTEM,
      cache_control: { type: "ephemeral" }   // ← 跨所有用户共享
    }
  ],
  messages: [
    {
      role: "user",
      content: `[元数据] 用户:${userId} 时间:${sessionTime}\n\n${userQuery}`
    }
  ]
});

// 结果:第一个用户首次请求建立缓存,之后所有用户都命中
// cache_read_input_tokens: 10050

把动态片段(用户 ID、时间、随机种子、session token)从被缓存的前缀里挪出去,放到 messages 里或者放到 cache_control 之后。

反模式 3:4 个断点用错位置

Anthropic 给你 4 个 cache_control 断点。很多人不知道这点,只用 1 个;或者知道有 4 个但用错位置,比如把 4 个全打在前缀的末尾,等于只生效一个。

正确用法:让 4 个断点对应稳定性递减的 4 段内容。这样即使最末段在变,前面 3 段还能命中。

❌ 错误(4 个断点全堆在末尾)

// tools / system / docs / examples 都没打,只在 user 消息开头打了 4 次
{
  tools: [...],
  system: "...",
  messages: [
    {
      role: "user",
      content: [
        { type: "text", text: "项目文档...", cache_control: {type: "ephemeral"} },
        { type: "text", text: "few-shot 示例...", cache_control: {type: "ephemeral"} },
        { type: "text", text: "上下文...", cache_control: {type: "ephemeral"} },
        { type: "text", text: userQuery, cache_control: {type: "ephemeral"} }
      ]
    }
  ]
}

// 4 个断点全在最后一个用户消息里,而 userQuery 每次变
// 实际只命中第一个断点之前的内容(前 3 块只重复建)

✅ 修复(按稳定性递减分布)

{
  tools: [
    // ... 工具定义 ...
    // 最后一个 tool 上打第一个断点(tools 几乎不变)
    { name: "last_tool", ..., cache_control: { type: "ephemeral" } }
  ],
  system: [
    {
      type: "text",
      text: STATIC_SYSTEM_PROMPT,
      cache_control: { type: "ephemeral" }    // 第二个断点
    }
  ],
  messages: [
    {
      role: "user",
      content: [
        {
          type: "text",
          text: ragDocuments,                  // RAG 文档,每个 query 不同但
          cache_control: { type: "ephemeral" } // 同一 query 多轮交互可复用
        },                                     // 第三个断点
        {
          type: "text",
          text: fewShotExamples,
          cache_control: { type: "ephemeral" } // 第四个断点
        },
        { type: "text", text: userQuery }      // 不打断点
      ]
    }
  ]
}

// 即使 userQuery 变了,前 4 个断点的内容都能命中

排布原则:tools(几乎永不变) → system(稳定) → 文档/RAG(中期稳定) → few-shot(短期稳定) → user 消息(每次变),前 4 段每段末尾打一个断点。

反模式 4:时间戳 / UUID 进 prompt

这是最隐蔽的杀手。你以为 system prompt 是静态的,但里面藏着 new Date().toISOString()uuid.v4()。每次请求生成的字符串都不一样,前缀就断了,缓存永远建不起来。

❌ 错误

function getSystemPrompt() {
  return `当前时间:${new Date().toISOString()}
你是一个助手...(后续 5K tokens)`;
}

await anthropic.messages.create({
  system: [
    { type: "text", text: getSystemPrompt(), cache_control: {type: "ephemeral"} }
  ],
  messages: [...]
});

// 每次请求 new Date() 都不同 → 缓存前缀永远不一致
// cache_creation_input_tokens: 5050 (每次重建)
// cache_read_input_tokens: 0

✅ 修复

// 把时间戳挪到 cache_control 之后,或者挪到 messages
const STATIC_SYSTEM = `你是一个助手...(5K tokens)`;

await anthropic.messages.create({
  system: [
    { type: "text", text: STATIC_SYSTEM, cache_control: {type: "ephemeral"} }
  ],
  messages: [
    {
      role: "user",
      content: `[当前时间:${new Date().toISOString()}]\n\n${userQuery}`
    }
  ]
});

// 缓存前缀稳定,跨所有请求命中

常见的"前缀污染源",自查列表:

反模式 5:缓存命中阈值不够 1024 tokens

你的 system prompt 写得很精炼,只有 800 tokens。打了 cache_control,但 usage 永远是 cache_creation_input_tokens: 0

原因:Sonnet 和 Opus 的最低缓存阈值是 1024 tokens,Haiku 是 2048 tokens。低于阈值 Anthropic 直接不建缓存,cache_control 被忽略。

❌ 错误

const SHORT_SYSTEM = "你是一个 SQL 优化助手,擅长 PostgreSQL 索引设计。";
// 大约 30 tokens

await anthropic.messages.create({
  model: "claude-sonnet-4-6",
  system: [
    { type: "text", text: SHORT_SYSTEM, cache_control: {type: "ephemeral"} }
  ],
  messages: [...]
});

// 30 tokens < 1024,cache_control 被忽略
// cache_creation_input_tokens: 0
// cache_read_input_tokens: 0

✅ 修复(把可缓存内容堆到阈值以上)

// 把更多稳定内容(few-shot 例子、schema 定义、规则文档)塞到断点前
const RICH_SYSTEM = `你是一个 SQL 优化助手...

# 数据库 schema
${schemaDDL}                        // 8K tokens 的 DDL

# few-shot 示例
${optimizationExamples}             // 4K tokens 的优化案例

# 规则
${rulesDoc}                         // 3K tokens 的规则
`;
// 总计 ~15K tokens,远超阈值

await anthropic.messages.create({
  model: "claude-sonnet-4-6",
  system: [
    { type: "text", text: RICH_SYSTEM, cache_control: {type: "ephemeral"} }
  ],
  messages: [...]
});

// cache_creation_input_tokens: 15000 (首次)
// cache_read_input_tokens: 15000     (后续命中)

反过来说,如果你的整个 prompt 不到 1024 tokens,根本没必要打 cache_control,模型本身就快。

反模式 6:跨用户共享 system prompt 含 user-specific 内容

多租户场景下,你想把"通用平台 system prompt"做成一份在所有用户间共享。但你不小心在里面拼了用户级数据(用户偏好、历史记录),结果每个用户都建一份独立缓存,平台总缓存命中率被稀释到接近 0。

❌ 错误

function buildSystem(user) {
  return `你是 BUZZ 助手。

# 平台规则
${PLATFORM_RULES}              // 6K tokens 全局共享

# 用户偏好
语言:${user.language}          // ← 每个用户不同
时区:${user.timezone}
风格:${user.writingStyle}
最近 5 条历史:${user.recentHistory}   // ← 每用户每会话不同
`;
}

await anthropic.messages.create({
  system: [
    { type: "text", text: buildSystem(user), cache_control: {type: "ephemeral"} }
  ]
});

// 每个用户独立缓存
// 1000 个用户 = 1000 份独立 6K 缓存,首次都全价
// 整体命中率被稀释

✅ 修复(分层 cache_control)

// 把"全局共享"和"用户专属"切分开,分别打断点
await anthropic.messages.create({
  system: [
    {
      type: "text",
      text: PLATFORM_RULES,           // 6K tokens 平台共享
      cache_control: { type: "ephemeral" }    // 第一个断点,跨用户命中
    },
    {
      type: "text",
      text: buildUserContext(user),   // 1.5K tokens 用户专属
      cache_control: { type: "ephemeral" }    // 第二个断点,同用户多轮命中
    }
  ],
  messages: [...]
});

// 第一个断点:全平台所有用户共享缓存(高命中)
// 第二个断点:每个用户独立,但同一用户多轮会话都能命中

顺序很关键:共享内容必须在前,用户专属内容在后。前缀匹配是从头开始的,反过来排列会让共享部分也变成 user-specific。

反模式 7:走中转站 / 网关时 cache_control 字段被剥

这一条最让人崩溃 —— 你的代码完全正确,本地直连 Anthropic 命中率 95%,但接到某个中转站后命中率掉到 0,cache_creation_input_tokens 永远是 0。原因是中转站主动剥掉了 cache_control 字段

为什么剥?三个动机:

❌ 错误(走某些中转站)

// 你发送的请求(完全正确)
{
  "system": [
    {
      "type": "text",
      "text": "...(15K tokens)...",
      "cache_control": { "type": "ephemeral" }
    }
  ],
  ...
}

// 中转站转发给 Anthropic 时变成
{
  "system": [
    {
      "type": "text",
      "text": "...(15K tokens)..."
      // cache_control 字段被剥了 ❌
    }
  ],
  ...
}

// 你看到的 usage:
// input_tokens: 15050
// cache_creation_input_tokens: 0
// cache_read_input_tokens: 0
// 每次都全价

✅ 修复(走透明转发的网关)

# 用 BUZZ 这种字节级透传的网关
export ANTHROPIC_BASE_URL=https://buzzai.cc
export ANTHROPIC_AUTH_TOKEN=<你的 BUZZ key>

# 代码完全不用改
claude   # 或者 SDK 调用
// 走 BUZZ 后,cache_control 原样转发到 Anthropic
// 你看到的 usage 和直连 Anthropic 完全一致
{
  "input_tokens": 50,
  "cache_creation_input_tokens": 15000,    // 首次
  "cache_read_input_tokens": 0,
  "output_tokens": 200
}

// 第二次同样请求:
{
  "input_tokens": 50,
  "cache_creation_input_tokens": 0,
  "cache_read_input_tokens": 15000,        // ← 命中
  "output_tokens": 200
}

判断网关有没有剥 cache_control 的方法很简单:

  1. 同一个请求,本地直连 Anthropic 跑一次,记下 cache_creation_input_tokens
  2. 等 5 分钟内再发同样请求,看 cache_read_input_tokens 是否非 0。
  3. 切换到目标网关,重复以上两步。
  4. 如果网关下 cache_creation_input_tokens 显著小于直连,或 cache_read 一直为 0,基本可以确定它在剥字段。

BUZZ 的设计原则就是字节级透传:你发出去什么字节,Anthropic 收到什么字节;Anthropic 返回什么字节,你拿到什么字节。cache_control、tools 定义、JSON schema、Streaming 事件全部不动。

诊断工具:从 usage 字段读懂 cache 行为

遇到"打了 cache_control 但不省钱"时,别盲改代码,先把 usage 字段抄出来对照下表:

症状(usage)含义下一步
cache_creation = 0
cache_read = 0
缓存压根没建 检查反模式 1/2/4/5/7
cache_creation 每次都很大
cache_read = 0
每次都重建,前缀在变 反模式 2/4/6,找前缀污染源
cache_creation
cache_read 很小
命中长度不够 反模式 3,把更多稳定内容放断点前
cache_read
cache_creation 接近 0
健康 不用管
命中突然消失 5 分钟 TTL 过期 考虑 1h TTL,或者增加请求频率

一段诊断代码,直接拷贝用

function diagnoseCache(usage) {
  const total = usage.input_tokens
              + (usage.cache_creation_input_tokens || 0)
              + (usage.cache_read_input_tokens || 0);

  const hitRate = (usage.cache_read_input_tokens || 0) / total;

  console.log(`
[Cache 诊断]
  本次新增输入:        ${usage.input_tokens}
  本次写入缓存:        ${usage.cache_creation_input_tokens || 0}
  本次命中缓存:        ${usage.cache_read_input_tokens || 0}
  本次输出:           ${usage.output_tokens}
  命中率:             ${(hitRate * 100).toFixed(1)}%
  状态:               ${
    hitRate > 0.8  ? '健康' :
    hitRate > 0.3  ? '可优化' :
    usage.cache_creation_input_tokens > 0 ? '前缀在变,反模式 2/4/6' :
    total < 1024   ? '不到 1024 阈值,反模式 5' :
    '前缀有问题或 cache_control 被剥,反模式 1/4/7'
  }
  `);
}

// 用法
const message = await client.messages.create({...});
diagnoseCache(message.usage);

实测:从 30% 到 92% 命中率

下面是一个典型的 RAG 应用,从有问题的初版迭代到最终稳态的过程。场景:用户问关于一份 50 页 PDF 文档的问题,每次会话平均 5-10 轮。

版本关键改动命中率单轮成本(相对)
v1 初版cache_control 打在 user 消息0%100%
v2挪到 system,但 system 里有 timestamp0%100%
v3去掉 timestamp,但 system 只有 600 tokens0%100%
v4把 PDF 文档全文塞到 system(15K tokens)30%72%
v5tools / system / docs / examples 各打一个断点78%30%
v6从某个网关切到 BUZZ(cache_control 不再被剥)92%14%

v6 到稳态的关键是:

从 v1 到 v6,单轮成本下降到原来的 14%,响应延迟也从平均 4.2s 降到 1.8s(命中部分跳过了 prefill)。

BUZZ 在这个链条里做了什么

说回反模式 7。BUZZ 作为 Anthropic API 网关,在 Prompt Cache 链条里只做一件事:什么都不做

这意味着你之前在直连 Anthropic 时调好的 cache_control 策略,接到 BUZZ 后行为完全一致。Claude Code 用 BUZZ 长会话能稳定 90%+ 命中率,就是因为 Claude Code 的 prompt 结构和 BUZZ 的透传策略天然匹配。

# 切到 BUZZ 不需要改代码
export ANTHROPIC_BASE_URL=https://buzzai.cc
export ANTHROPIC_AUTH_TOKEN=<你的 BUZZ key>

# Claude Code / Anthropic SDK / 自研 client 全都即开即用
claude

FAQ

Q1: 为什么 cache_creation_input_tokens 一直是 0?

三种可能:cache_control 之前的内容不足 1024 tokens(反模式 5),前缀里有动态内容每次变(反模式 2/4),网关在剥字段(反模式 7)。先确认 input_tokens 总量是否够 1024,再排查前缀稳定性,最后用直连对比验证网关。

Q2: cache_control 应该放在哪里?

放在最稳定、最长内容的末尾。典型顺序:tools 定义 → 长 system prompt → RAG 文档 → few-shot 示例 → 用户当前 messages。前 4 个稳定块各打一个断点,用户 messages 不打。最多 4 个断点,Anthropic 从前往后匹配最长前缀。

Q3: Prompt Cache 的最低 token 阈值是多少?

Sonnet 和 Opus 是 1024 tokens,Haiku 是 2048 tokens。这是 cache_control 之前累计的输入 token 数,不是单条 message 的长度。低于阈值 cache_control 会被静默忽略,usage 里 cache_creation_input_tokens 会是 0。

Q4: cache_control 的 ephemeral 是什么意思?

ephemeral 表示这个缓存条目是临时的,默认 5 分钟 TTL,每次命中刷新窗口。也可以指定 ttl: "1h"(1 小时)。命中后 input 价格降到原价的约 10%,首次创建会有约 25% 的 surcharge。

Q5: 为什么 Claude Code 长会话能稳定 90%+ 命中?

Claude Code 把项目文件、tool schemas、system prompt 全部放在前缀,只在 messages 末尾追加用户输入和 tool_result,且自动给前缀打 cache_control。每次工具调用都能命中之前的缓存,5 分钟内连续操作几乎全部 cache_read。BUZZ 透传 cache_control,Claude Code 走 BUZZ 时命中行为和直连一致。

Q6: 怎么从 usage 字段判断缓存是否生效?

看四个字段:input_tokens(本次新增非缓存输入),cache_creation_input_tokens(本次写入缓存),cache_read_input_tokens(本次命中缓存),output_tokens(输出)。命中率 = cache_read / (cache_read + input + cache_creation)。健康的长会话该比值应该 80% 以上。

Q7: 为什么走某些网关后 Prompt Cache 就失效了?

很多中转站会主动剥掉 cache_control 字段:它们的内部架构无法维护 cache state,或者按全价计费再黑掉折扣差价。判断方法:同一请求分别走网关和直连,对比 cache_creation_input_tokens 是否一致。BUZZ 是字节级透明转发,cache_control 完整透传,折扣按 Anthropic 官方计算。

Q8: cache_control 最多能放几个断点?

最多 4 个。Anthropic 从前往后匹配最长前缀,所以 4 个断点应按内容稳定性递减排列:最稳定的内容在最前(打第一个断点),次稳定的在第二位(第二个断点),依此类推。把 4 个断点全打在动态变化位置等于浪费,只会命中最前面那块。

用 BUZZ 跑 Prompt Cache,字节不动

注册 BUZZ → 充值 → 设置 ANTHROPIC_BASE_URL=https://buzzai.cc → cache_control 行为和直连 Anthropic 完全一致。Claude Code 长会话开箱稳定 90%+ 命中。

立即注册

本文最初发表于 2026-05-26,作者 BUZZ AI Gateway 团队。

如果文章有事实错误或代码示例不工作,请通过 工单系统反馈。