TL;DR:你以为自己“复用同一个 system prompt”,其实每次请求的 prefix 并不相同。Prompt caching 的命中条件比大多数人想的更苛刻:严格前缀、字节级一致、还要过最小 token 门槛 + TTL。
我最近在做多模型接入(同一套工具/指令,要在不同厂商模型间切换),结果 prompt caching 的命中率非常飘:有时省一大截,有时完全不命中。
最烦的是:不命中也不会报错——你只是在账单上默默多付了一次“prefill 税”。
这篇我不讲“省钱攻略”,只讲一件更工程的问题:为什么同样的 system prompt 会 cache miss? 以及:不同厂商到底在缓存什么、怎么判定命中、哪些细节会把缓存打碎。
(封面图在本地:./images/cover.png)
先把概念说清楚:Prompt Caching 缓存的不是文本,而是 KV Cache
Prompt caching 的本质是复用“prefill”阶段的中间状态。
- 第一次请求:模型把你的 prompt 前缀读一遍,算出注意力层里的 key/value 张量(KV cache)。
- 第二次请求:如果“前缀完全一致”,服务端直接把这段 KV cache 读出来,跳过重复计算。
所以它缓存的不是“你发过的文字”,而是“模型已经算过的那一段”。这也是为什么:哪怕你只改了前缀里一个空格/一个工具字段顺序,都可能导致 cache miss。
你以为的“同一个 prompt”,在 API 看来经常不一样
我见过最多的误区就两个:
- “system 字段一样就行”
不一定。很多 SDK 会在你看不见的地方帮你补字段、重排 JSON、或者把 tools/response_format 变成另一种序列化形式。
- “我只是追加了新消息,前缀当然一致”
也不一定。某些平台把 system、tools、messages 合并成一个内部序列列;只要你动了前缀区间内的任何结构(顺序、分隔、甚至空字符串),就不是同一个前缀。
如果你现在正在 debug “为什么缓存不命中”,建议你先做一件很朴素的事:
- 把请求体(JSON)完整 dump 出来
- 只截取“你希望被缓存的前缀部分”
- 计算 hash(比如 sha256)
你会很快发现:两次请求的 prefix hash 其实不同。
四家 API 的缓存行为:我关心的是 4 个维度
我用同一套问题拆它们:
- 缓存 key:到底按什么判定“前缀一致”?
- 最小 token 门槛:多短的前缀根本不会触发缓存?
- TTL:缓存活多久?是否能延长?
- 可观测性:我怎么知道命中没命中?
下面这张表是我整理的“工程视角对比”。注意:不同厂商细节会变,我更看重“它的设计倾向”。
| Provider | 命中判定 | 最小门槛(常见) | TTL(常见) | 可观测性/信号 |
|---|---|---|---|---|
| 某海外大模型厂商 | 前缀 byte-for-byte(内容块可标 cache_control) | Sonnet 4.6 常见 2048;Opus/Haiku 常见 4096(按公开资料) | 5 分钟为主;可选 1 小时(更高写入成本) | usage 里可拿到 cache_creation / cache_read token |
| 某海外大模型厂商 | 多为自动缓存(以 prefix 为主),你不一定能完全控制 | 常见 1024 起(按公开资料) | 5–10 分钟常见;部分模型可更长(按公开资料) | 账单/usage 字段(不同接口差异大) |
| DeepSeek | 有缓存能力(具体接口/字段随版本变化) | 依文档而定 | 依文档而定 | 需要看 response 的 usage/计费字段 |
| 某海外模型 | 有缓存/上下文复用能力(形态更偏“上下文对象”) | 依文档而定 | 依文档而定 | 需要看 SDK/返回字段 |
我这篇会重点讲 某海外大模型厂商,因为它的机制最“显性”,很多坑也最典型:你把它搞明白,再看其它家会容易很多。
某海外大模型厂商:cache_control 不难,加错位置才要命
某海外大模型厂商 的 prompt caching 很工程化:你可以在内容块上打 cache_control: { type: "ephemeral" },让系统把“某个位置之前的前缀”当成缓存边界。
关键点 1:它要求前缀严格一致(byte-for-byte)。
也就是说下面这种“看起来一样”的改动,都可能把缓存打碎:
- tools 数组顺序变了
- system prompt 里多了一个换行
- 你把 JSON 序列化从
\n变成了真正的换行 - 你把一个字段从
null变成了""
关键点 2:有最小 token 门槛。
公开资料里提到(以 Sonnet 4.6 为例)前缀小于 2048 token 时,调用会成功,但 cache_creation_input_tokens 可能是 0 —— 等于你以为自己开了缓存,其实完全没生效。
Python 示例:把“稳定前缀”固定成一个块
下面这个写法的意图很明确:
- system prompt 是稳定前缀 → 让它可缓存
- user 的问题是变化尾巴 → 不缓存
import os
# import (sdk omitted)
client = anthropic.某海外大模型厂商(api_key=os.environ["API_KEY"])
SYSTEM_PROMPT = """你是一个资深后端工程师。回答要给出可落地的工程建议。\n
你会收到:系统约束、工具列表、以及用户问题。\n
要求:
- 输出中文
- 给出结论 + 原因 + 可操作清单
""".strip()
def ask(question: str):
msg = client.messages.create(
model="deepseek-reasoner",
max_tokens=800,
system=[{
"type": "text",
"text": SYSTEM_PROMPT,
# 让这段成为缓存边界的一部分
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": question}],
)
usage = getattr(msg, "usage", None)
# 不同 SDK 版本字段名可能略有差异,这里只展示思路
print("usage:", usage)
return msg.content[0].text
print(ask("解释一下 prompt caching 为什么会不命中,给我排查清单"))
这段代码不是为了教你“怎么开缓存”(文档都有),而是为了强调一个工程习惯:
- 稳定前缀要写成稳定的数据结构(别在不同调用里拼字符串)
- 序列化方式要固定(尤其是 JSON/换行/空格)
Node.js 示例:把 tools 定义也固定住
工具定义(tools)往往比 system prompt 更容易“悄悄变动”。
我推荐你把它做成一个常量文件,避免每次启动时动态生成。
import 某海外大模型厂商 from "(某海外 SDK)";
const client = new 某海外大模型厂商({ apiKey: process.env.API_KEY });
const SYSTEM = "你是资深后端工程师,回答要给出工程建议。";
const TOOLS = [
{
name: "lookup_cache_stats",
description: "查询缓存命中统计",
input_schema: {
type: "object",
properties: { key: { type: "string" } },
required: ["key"],
},
},
];
export async function ask(question) {
const res = await client.messages.create({
model: "deepseek-reasoner",
max_tokens: 600,
system: [
{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } },
// 也可以把 tools 描述作为可缓存内容块(视你是否把 tools 放 system 里)
],
tools: TOOLS,
messages: [{ role: "user", content: question }],
});
console.log("usage", res.usage);
return res.content?.[0]?.text;
}
那个 5 分钟 TTL 的坑:为什么你在生产里突然“变贵了”
我最近看到一个很典型的事故分析:某些平台把 prompt cache 的 TTL 从 60 分钟下调到了 5 分钟。
它带来的工程后果是:
- 以前“每隔 10 分钟跑一次”的任务可以复用缓存
- 现在每次都要重新写 cache,甚至还要付写入溢价
如果你的 workload 是“间歇式”的(cron、定时批处理、用户会话中断),你会感到缓存极其不稳定。
这时候别硬凹“我要命中缓存”,更现实的做法是:
- 只缓存真正大的前缀(长文档/工具目录)
- 把请求合并(batch)
- 或者用 keepalive/ping 保温(如果你的平台允许且算得过账)
下面给一个简化的 keepalive 思路(仅供参考,别在没算账的情况下上线):
import time
import threading
def keepalive(ping_fn, every_seconds=240):
def run():
while True:
time.sleep(every_seconds)
try:
ping_fn()
except Exception:
pass
t = threading.Thread(target=run, daemon=True)
t.start()
cache miss 的排查清单(我自己用的)
我把它拆成 6 步,基本能定位 90% 的 miss:
- 确认你缓存的内容足够长:有没有过最小 token 门槛?
- 确认前缀顺序稳定:system、tools、messages 的拼接顺序是否固定?
- 确认序列化稳定:换行、空格、JSON key 顺序是否一致?
- 确认 TTL:两次请求间隔是否超过 TTL?
- 确认模型/区域:同一个模型 ID?同一个 region?(有些实现是按后端实例缓存)
- 看 usage:有没有
cache_read_*的 token?如果一直是 0,别自我安慰。
多模型时代的工程建议:把“稳定前缀”抽成一个可复用层
当你同时接 3-5 家模型时,缓存问题会被放大:
- 你想共享同一套 system prompt / tools
- 但每家 API 的字段形态都不一样
我的做法是把“稳定前缀”抽象成一个中间层:
- 先把 system + tools + 固定上下文,变成一种“规范化结构”(canonical form)
- 再由不同 provider 的 adapter 去序列化
这样你至少能保证:同一家 provider 的两次请求,prefix 是可控且可复现的。
(我自己做多模型统一调用时,用的就是 API 网关把不同模型的请求层收敛到一个形态里。这个点我只提一次,不展开。)
常见问题(FAQ)
Q:Prompt Caching 和我自己在业务里做 Redis 缓存有什么区别?
A:Redis 缓存的是“模型输出结果”,命中条件通常是你自己定义的 key;Prompt caching 缓存的是“模型 prefill 的中间状态(KV cache)”,命中条件是服务端定义的严格前缀一致。两者可以叠加,但解决的问题不同。
Q:我只缓存 system prompt 有意义吗?
A:有,但前提是它足够长且足够稳定。短 system prompt 往往过不了最小 token 门槛;频繁变动的 system prompt(比如动态拼接用户画像)会让缓存形同虚设。
Q:为什么我把同样的内容放在 messages[0] 里,缓存反而更差?
A:因为你不一定控制得住 messages 的“前缀稳定性”:消息数组顺序、role、分隔符、甚至 SDK 的内部序列化策略,都可能让“看起来一样”的内容变成不同的前缀。稳定前缀更适合放在专门的 system/content 块里。
一个更“阴险”的 miss:你换了 SDK 版本
这事我踩过一次,属于那种“你不写单测永远发现不了”的坑。
同样的业务代码,只是把 SDK 从一个小版本升级到另一个小版本,缓存命中率直接从很稳定变成几乎 0。原因通常不是模型变了,而是 请求体序列化的细节变了:
- JSON 字段顺序变化(你以为不影响,其实服务端可能把它当 byte 序列)
- tools/schema 的默认值补齐策略变化(比如
additionalProperties、required的生成) - SDK 对换行/转义的处理变化
所以我的建议是:
- 把“可缓存前缀”的生成做成一个独立函数,并输出 hash
- 在 CI 里放一个非常便宜的断言:同一份配置生成的 prefix hash 必须稳定
这一步不一定能保证 100% 命中(因为 TTL/后端实例也会影响),但至少能保证:缓存 miss 不是你自己把前缀玩坏了。