AB
AiBoss站
教程

Claude

教程

Claude API 提示缓存实战:用 TypeScript 把命中率测出来

给 Claude API 加上 cache_control 之后账单没怎么降,多半是缓存根本没命中。这篇教程用 TypeScript 走一遍完整流程:先搭最小可运行结构,再用 usage 里的三个 token 字段判断命中,然后逐条排查失配原因,最后把命中率监控接进代码。

给 Claude API 的请求加上 cache_control,只多了一行,账单却没怎么降——这种情况通常不是缓存机制失效,而是缓存压根没有命中,只是没人发现。提示缓存(prompt caching)省的是「重复前缀」的钱,它不会报错,也不会警告,命中不了就静默地按普通输入计费。所以真正要解决的问题不是「怎么加这一行」,而是「怎么知道自己加对了」。这篇教程用 TypeScript 走一遍完整路径:先用最小结构跑通,再用 usage 字段量化命中情况,然后逐条排查失配原因,最后把命中率监控接进代码。如果你只是想先在对话界面里试一下这个模型,可以看站内的 Claude;下面讲的是走 API 的工程做法。

准备工作

开始之前需要确认几件事,缺一件都会让后面的排查变得没有意义。

  • API 密钥:需要一个可用的 Anthropic API 密钥。官方 SDK 默认从环境变量 ANTHROPIC_API_KEY 读取,不需要在代码里显式传参。密钥不要写进仓库。
  • Node.js 与包管理器:示例使用 pnpm 安装依赖,用 npm 或 yarn 也可以,命令自行替换。
  • 官方 SDK:pnpm add @anthropic-ai/sdk。示例代码按官方 SDK 的接口书写,但具体类型定义可能随版本变化,遇到类型报错时以当前安装版本的声明文件为准。
  • 一段足够长的固定文本:缓存有最小 token 门槛,太短的文本不会进入缓存。准备一份真实会重复使用的长文档,比如编码规范、评审清单、产品说明,存成 guideline.md 放在项目里。
  • 可重复的测试问题:准备三到五个针对同一份文档的问题,用来观察多次调用之间的缓存行为。

还有一个前提必须先建立起来,否则后面所有判断都会跑偏:被缓存的只有「从请求开头算起、到 cache_control 所在块为止」的那段前缀,而且必须完全一致。请求的拼装顺序是 tools → system → messages,所以前缀的边界由这个顺序决定。由此推出两条设计原则:

  • 不变的东西放前面:工具定义、长指令、参考文档。
  • 会变的东西放后面:当前时间、请求 ID、用户输入。

只要前缀里有一个字符变了,它后面的一切缓存都会失效。这是后面排查失配时反复要用到的判断依据。

操作步骤

第一步:写出最小可运行结构

先不要考虑多轮对话、工具调用这些复杂场景,把结构压到最小:一个固定的 system 块,加一个变化的用户问题。

import Anthropic from "@anthropic-ai/sdk";
import { readFileSync } from "node:fs";

const client = new Anthropic(); // ANTHROPIC_API_KEY 从环境变量读取

// 需要超过最小 token 数,所以这里读入一份较长的固定文档
const guideline = readFileSync("./guideline.md", "utf8");

export async function ask(question: string) {
  const res = await client.messages.create({
    model: "claude-sonnet-5-5",
    max_tokens: 512,
    system: [
      {
        type: "text",
        text: "你是一名负责代码评审的工程师。",
      },
      {
        type: "text",
        text: guideline,
        cache_control: { type: "ephemeral" }, // 到这里为止作为前缀被缓存
      },
    ],
    messages: [{ role: "user", content: question }],
  });
  return res;
}

关键点在于 cache_control 的位置:它放在 system 里最后一个固定块上。问题文本在它后面的 messages 里,所以问题怎么换,前缀都不受影响。如果把 cache_control 放在第一个短块上,被缓存的就只有那句角色设定,长文档反而落在缓存之外,等于白加。

模型名、最小 token 门槛、缓存倍率、TTL 这些都属于会调整的参数,动手前请以官网当前的 Prompt caching 文档为准,不要照抄本文里的字符串。

第二步:用 usage 判断到底有没有命中

响应里的 usage 对象会告诉你缓存的真实情况,三个字段各管一段:

字段含义
cache_creation_input_tokens本次写入缓存的 token 数
cache_read_input_tokens本次从缓存读取的 token 数
input_tokens没有进入缓存的普通输入 token 数

把这三个数汇总成一个命中率,连续问几个问题观察变化:

import { ask } from "./ask";

function summarize(u: {
  input_tokens: number;
  cache_creation_input_tokens?: number | null;
  cache_read_input_tokens?: number | null;
}) {
  const write = u.cache_creation_input_tokens ?? 0;
  const read = u.cache_read_input_tokens ?? 0;
  const total = u.input_tokens + write + read;
  return {
    write,
    read,
    uncached: u.input_tokens,
    hitRate: total ? read / total : 0,
  };
}

for (const q of ["命名规范是什么?", "错误处理的原则是什么?", "测试粒度怎么定?"]) {
  const res = await ask(q);
  console.log(q, summarize(res.usage));
}

预期行为是这样的:

  • 第一次调用:write 很大,read 为 0。这是正常的,缓存需要先写进去。
  • 第二次及以后:read 很大,write 为 0 或很小。
  • 三次调用都只有 write、read 始终为 0:缓存没有命中,直接进入下一步排查。

这里要强调一点:不要只看账单判断缓存是否生效。写入缓存的单价通常高于普通输入,如果前缀根本没被复用,你付出的成本反而比不加缓存更高。判断依据只能是 cache_read_input_tokens。

第三步:逐条排查失配原因

缓存不命中基本逃不出下面四种情况,按顺序检查效率最高。

原因一:固定部分没达到最小 token 数。这是最容易被忽略的一种。长度不够时不会报错,也不会给任何提示,就是静默地不缓存,表现为 write 和 read 都是 0。最小门槛按模型不同而不同,具体数值请查官网当前文档。判断方法很简单:如果第二步的日志里两个字段都长期为零,先怀疑长度不够,把固定文档换长一点再试。

原因二:前缀里混进了每次都变的值。最典型的写法是把当前时间或请求 ID 拼进 system:

// 反例:只要有一个字符变化,它后面的缓存全部失效
system: [
  {
    type: "text",
    text: `当前时间: ${new Date().toISOString()}\n${guideline}`,
    cache_control: { type: "ephemeral" },
  },
]

// 正例:把变化的值挪到 cache_control 之后(messages 一侧)
messages: [
  {
    role: "user",
    content: [
      { type: "text", text: `当前时间: ${now}\n问题: ${question}` },
    ],
  },
]

除了时间戳,还有几类隐蔽的变动源:tools 的定义顺序在每次运行时不同、序列化 JSON 时键的顺序不稳定、从对象展开拼装 system 时字段顺序漂移。工具定义请先排序再传入,保证每次生成的字节序列一致。

原因三:首次请求并发发出。缓存要等第一个响应开始返回之后才可读。如果同一个前缀一次性并发十个请求,这十个可能全部走写入路径,谁也没读到缓存。正确做法是先单独等第一个请求完成,再放其余请求:

const [first, ...rest] = questions;
await ask(first); // 预热
const results = await Promise.all(rest.map(ask));

原因四:TTL 已过期。缓存有默认存活时间,被读取时会顺延。如果调用间隔很长,比如定时批处理每隔几小时跑一次,那么每次都是写入、从不读取,等于持续为写入的溢价买单。这种情况下要么缩短调用间隔,要么干脆不要对这段前缀启用缓存。默认 TTL 的具体时长同样以官网当前信息为准。

第四步:扩展到多轮对话

对话越聊越长时,除了 system 末尾那个断点,还要在最近一条 user 消息的末尾再放一个 cache_control。这样下一轮请求时,此前累积的整段历史就能作为前缀被读取。

function withCacheOnLast(
  messages: Anthropic.MessageParam[]
): Anthropic.MessageParam[] {
  return messages.map((m, i) => {
    if (i !== messages.length - 1 || m.role !== "user") return m;

    const blocks =
      typeof m.content === "string"
        ? [{ type: "text" as const, text: m.content }]
        : [...m.content];

    const last = blocks[blocks.length - 1];
    blocks[blocks.length - 1] = {
      ...last,
      cache_control: { type: "ephemeral" },
    };

    return { ...m, content: blocks };
  });
}

要注意,能放 cache_control 的位置(断点)数量是有上限的。如果每一轮都往上加,很快就会撞到上限。稳妥的做法是固定成两个:system 末尾一个,最新一条 user 消息一个。上限的具体数值请查官网当前文档。

第五步:把命中率监控常驻在代码里

缓存不是「配好就完事」的东西。某天有人改了一句 system 提示词,缓存就可能悄悄失效,而账单要过一阵子才反映出来。所以监控要和功能一起上线,而不是事后补。

let totalRead = 0;
let totalAll = 0;

export function record(u: Anthropic.Usage) {
  const read = u.cache_read_input_tokens ?? 0;
  totalRead += read;
  totalAll +=
    u.input_tokens + (u.cache_creation_input_tokens ?? 0) + read;

  const rate = totalAll ? totalRead / totalAll : 0;

  if (totalAll > 50_000 && rate < 0.5) {
    console.warn(`cache hit rate low: ${(rate * 100).toFixed(1)}%`);
  }
}

这里的 0.5 只是一个占位阈值,不要直接照搬。正确做法是先在自己的真实负载上跑一段时间,测出命中率的正常区间,再据此设定告警线。样本量太小的时候(示例里用 50000 token 作为下限)命中率波动很大,容易误报,所以加了一个累计量的门槛。

一个完整示例

把上面的片段串成一个可以跑通的最小程序。目录结构:

project/
  guideline.md
  ask.ts
  main.ts

ask.ts 负责封装请求,把固定文档放在 system 末尾并打上缓存标记:

import Anthropic from "@anthropic-ai/sdk";
import { readFileSync } from "node:fs";

const client = new Anthropic();
const guideline = readFileSync("./guideline.md", "utf8");

export async function ask(question: string) {
  return client.messages.create({
    model: "claude-sonnet-5-5",
    max_tokens: 512,
    system: [
      { type: "text", text: "你是一名负责代码评审的工程师。" },
      {
        type: "text",
        text: guideline,
        cache_control: { type: "ephemeral" },
      },
    ],
    messages: [{ role: "user", content: question }],
  });
}

main.ts 负责串行提问、统计命中率、并在命中率异常时告警:

import { ask } from "./ask";

let totalRead = 0;
let totalAll = 0;

function record(u: any) {
  const read = u.cache_read_input_tokens ?? 0;
  const write = u.cache_creation_input_tokens ?? 0;
  totalRead += read;
  totalAll += u.input_tokens + write + read;
  return {
    write,
    read,
    uncached: u.input_tokens,
    hitRate: totalAll ? totalRead / totalAll : 0,
  };
}

const questions = [
  "命名规范是什么?",
  "错误处理的原则是什么?",
  "测试粒度怎么定?",
];

// 先单独跑第一个请求做预热,避免并发写入
const first = await ask(questions[0]);
console.log(questions[0], record(first.usage));

for (const q of questions.slice(1)) {
  const res = await ask(q);
  console.log(q, record(res.usage));
}

if (totalAll > 50_000 && totalRead / totalAll < 0.5) {
  console.warn(
    `cache hit rate low: ${((totalRead / totalAll) * 100).toFixed(1)}%`
  );
}

运行后按顺序核对输出:第一条应该是 write 明显大于 0、read 为 0;第二、三条应该 read 明显大于 0、write 接近 0。如果三条的 read 全是 0,回到第三步,从「长度是否够」开始逐条排查。

注意事项

  • 缓存匹配的是前缀,不是内容片段。请求开头到 cache_control 之间的部分必须逐字节一致,顺序固定为 tools → system → messages。任何一处变动都会让其后所有缓存失效。
  • 长度不足不会报错。低于最小 token 门槛时缓存静默跳过,只能通过 usage 里的字段发现。最小门槛按模型不同,请以官网当前信息为准。
  • 写入比普通输入贵。如果这段前缀不会被复用,启用缓存反而增加成本。判断是否值得,看的是 cache_read_input_tokens 而不是账单总额。
  • TTL 会过期,读取会顺延。调用间隔长的批处理场景容易只写不读。默认时长以官网当前信息为准。
  • 断点数量有上限。多轮对话里不要每轮都追加断点,固定成「system 末尾一个 + 最新 user 一个」最稳。
  • 并发首次请求会互相抢写。同一个前缀的第一次调用要单独等待完成,再放其余请求。
  • 工具定义要排序。数组顺序、JSON 键顺序不稳定都会导致前缀不一致,进而失配。
  • 价格、倍率、最小 token 数、TTL、断点上限都可能调整。本文只讲方法,具体数值请以官网当前文档为准。
  • 命中率阈值要自己测。示例里的 0.5 只是占位值,不同负载的正常区间差别很大,先测量再设告警线。

最后给一个低成本的起点:先在自己项目的 system 拼装代码里搜一遍,看有没有把时间戳、请求 ID、随机值这类每次都变的内容拼进了固定前缀。这一步不需要改任何架构,往往就能解释掉大部分「加了缓存但没省下钱」的情况。