
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、随机值这类每次都变的内容拼进了固定前缀。这一步不需要改任何架构,往往就能解释掉大部分「加了缓存但没省下钱」的情况。