为什么你的 Prompt Cache 永远打不中
系统提示的内容一个字没改,只把其中一行挪了个位置,二十轮的账单差了 7 倍
前言
设想这样一个场景。
你的 Agent 上线第一周,账单很稳定。第二周开始不对劲------同样的任务量,费用翻了一倍。
你把日志翻出来,每一轮请求都带着 cache_read_input_tokens: 0。一次都没命中。
于是开始排查:模型换了吗,没有。参数改了吗,没有。SDK 升级了吗,升级了,但版本降回去还是一样。
问题出在提示词的拼装方式上。
Prompt Cache 的命中判定是前缀匹配 。它要求你这一轮发出去的开头,和上一轮一个字节都不能差。
不是内容不对,是顺序不对。你把一个每轮都在变的部件放在了前面,它后面那几千个 token 全部作废。
顺序错一处,代价有多大?把系统提示里那个每轮都变的部件依次放在最前、中间、最后,二十轮对话跑下来:最差的那种比不用缓存还贵 25%,最好的省下 84%。内容完全相同。
先分清三个"缓存"
这三个词经常被混着用,但它们根本不在同一层,能控制的手也完全不同。
| 名字 | 在哪一层 | 谁控制 | 干什么 |
|---|---|---|---|
| KV Cache | 模型推理引擎内部 | 谁都控制不了 | 缓存注意力计算里的 K 和 V 矩阵,避免重复算 |
| Prompt Cache | API 层 | 开发者 | 前缀没变就不用重新算,按折扣价计费 |
| Context Collapse | 上下文里 | 开发者 | 把老消息折叠成一个标记,腾出窗口 |
KV Cache 是模型自己实现的推理优化,你写不写代码它都在跑,能感知到的只有速度。注意力机制里,每生成一个 token 都要拿它的 Q 去和前面所有 token 的 K 做点积------这个过程的中间结果就是 KV Cache 缓下的东西。它不解决成本问题,因为算力省了不等于 token 不收费。
Context Collapse 属于压缩手段的另一支。老消息不再直接删掉,而是存到外部,上下文里只留一个折叠标记------这个思路在上篇讲卸载的时候展开过。
真正和账单直接挂钩的是中间那个。Prompt Cache 是 API 层按前缀做的复用:你的请求有一个开头,只要这个开头和上一次完全一致,服务端就不用重算这一段,命中的部分按大概十分之一的价格计费。
所以这是一笔纯粹的、白送的折扣,前提是你得接得住。
命中是怎么判定的
有几条规则必须先摆清楚,后面所有的坑都是它们的推论。
前缀从第一个字节开始匹配。 服务端不认识"这段内容很重要",它只做字符串比对。第一个不同的位置一旦出现,从那里往后全部作废------注意,是作废,不是"降级"。
缓存是按规范顺序拼的:tools → system → messages。 你把工具定义写在系统提示里还是单独传,最终都被拼成这个顺序。这个顺序很重要,因为它决定了"谁在前谁在后"的边界在哪,下面的分析都建立在它上面。
太短的前缀服务端不收。 Claude 3.5 及以后有最小可缓存前缀这条线,从 1024 token 起步,新一些的模型还会更高(2048、4096 都有)。短于这条线就静默不缓存------不报错,只是白打了一个断点。这一点很容易踩:你以为自己打了缓存,实际上什么都没发生。
断点不是越多越好,一条请求最多 4 个。 断点标记的是"缓存到这里为止",所以你需要想清楚哪几段值得缓存,而不是见一段标一段。
至于计费,Anthropic 的规则是这样的:
| 操作 | 价格倍数 | 说明 |
|---|---|---|
| 普通输入(未命中) | 1.0× | 基准价 |
| 缓存写入,5 分钟 | 1.25× | 多付 25% |
| 缓存写入,1 小时 | 2.0× | 多付 100% |
| 缓存读取 | 0.1× | 打一折,两个 TTL 都一样 |
这张表里藏着本文开头那句话的来源:写入是要加价的。 你打断点的意思是"这段我要缓存",服务端就真的去存、真的收你 1.25 倍。所以如果前缀每轮都变,你每轮都在付加价------比不打缓存的 1.0 倍还贵。
还有个细节值得知道:读取会刷新缓存的寿命。 被频繁读到的缓存,会一直以读取价延续下去,活得比名义上的 TTL 长。时钟从请求发出时开始算,不是从响应返回。
盈亏平衡点因此低得惊人:5 分钟档只要读一次就回本,1 小时档需要读到第二次才划算。如果你的 Agent 是短时间连续跑很多轮的形态,缓存几乎是稳赚的。 这也让"打不中"显得格外可惜------它本来是最不该亏的一笔。
杀死缓存的那几件事
下面这几条,任何一条单独出现都足以让命中率归零。它们的共同点是:看起来都无害,甚至都是"好习惯"。
系统提示里带时间戳。 这是最常见的一条,也最容易被当成好习惯。为了让模型知道"现在几点",很多人在系统提示开头加一行当前时间------缓存从这一行断裂,后面全废。同样的道理适用于日期、星期几、"本次请求 ID"。
工具列表的顺序不稳定。 工具通常存在一个对象或 Map 里,遍历出来的顺序取决于插入顺序、哈希实现,甚至过滤器跑过几遍。你静态注册一次没问题,一旦工具有了延迟加载、按需发现、按调用频率排序这类机制,顺序每轮都可能变。而工具定义在规范顺序里排在 system 之前------它在最前面,它一乱,整个请求全乱。
会话信息放在开头。 把 session id 或"当前用户是谁"写在系统提示最前面,是排版上的直觉,也是缓存上的自杀。
在中间插入每轮都变的内容。 这一条最隐蔽。即使你把易变的东西从开头挪走了,只要它落在中途,从插入点往后的全部作废。上面几件事的共同机制其实都是它,只是插入点不同。
反过来看,有一件事是安全的:在末尾追加。 上下文变长、工具表里多出一个刚发现的工具、对话轮次增加------这些都是在尾部追加,前面的部分原封不动。追加安全,重排致命。 这一条记住了,很多设计问题不用算就知道答案。
一段真实的系统提示,是拼出来的
道理讲完,来看一个具体的实现。
我的 Agent 里,系统提示不是一个字符串常量,而是一条管道(Prompt Pipe)------每个函数负责产出其中一段,按注册顺序拼接:
less
const builder = new PromptBuilder()
.pipe('coreRules', coreRules()) // 行为准则,几乎不变
.pipe('toolGuide', toolGuide()) // 工具数量,很少变
.pipe('deferredTools', deferredTools()) // 延迟工具摘要,搜到工具才变
.pipe('memoryContext', memoryContext(memoryStore)) // 记忆索引,写记忆才变
.pipe('ragContext', ragContext(vectorStore)) // 知识库概况,导入文档才变
.pipe('skillContext', () => skillLoader.buildPromptSection(activeSkills))
.pipe('sessionContext', sessionContext()) // 会话信息,每轮都变 ------ 只能放最后
排序原则只有一条:越稳定的越靠前,越易变的越靠后。
最值得看的是最后两行。sessionContext 产出的是这么一句话:
css
[会话信息] 当前会话 sess-8f3a,已有 12 条历史消息。
这里面有两个每轮都变的东西------会话 ID 和消息条数。它的内容是全篇最不稳定的,所以它的位置是全篇最靠后的。 往前挪一个 pipe,它后面所有内容就都进入"每轮重写"的范畴。
这条管道的价值不在于写得好看,在于它把"顺序"变成了一个可以 review 的东西。系统提示拼成一个字符串常量的时候,没人会去检查第五行是否稳定;拆成有名字的一段一段,谁在前谁在后就是一行代码,改动时看得见。
三种缓存模式,和怎么确认它真的命中了
各家的 API 对缓存有三种做法,写代码前得先知道自己面对的是哪种。
隐式缓存 是服务端自动做的,你什么都不用标。OpenAI 和 Qwen 都有这一档,能不能命中完全取决于你的前缀稳不稳。想确认命中情况,只能去 usage 里找返回的缓存字段。
显式标记是你自己在请求里插断点,Anthropic 的写法是这样:
json
{
"model": "claude-sonnet-4-6",
"system": [
{
"type": "text",
"text": "......几千 token 的行为规则......",
"cache_control": { "type": "ephemeral" }
}
],
"messages": [
{ "role": "user", "content": "帮我看看 auth.ts" }
]
}
ephemeral 就是那个断点,加 "ttl": "1h" 可以换成 1 小时档。还有一种是显式缓存创建,先调一次拿到缓存对象的 ID,之后带着 ID 请求------适合有一大段几乎永不变化的背景资料(比如一份产品文档),可以跨会话复用。
然后是最容易翻车的一步:确认到底命中了没有。
问题在于 usage 里那几类 token 的字段名,各家不一样,而且有一家的语义是反的:
| 来源 | 读取字段 | 写入字段 | 坑 |
|---|---|---|---|
| AI SDK 标准化后 | cachedInputTokens |
--- | 各 provider 的字段被统一到这一层 |
| Anthropic | cache_read_input_tokens |
cache_creation_input_tokens |
写入单独一个字段,不混在输入里 |
| OpenAI | prompt_tokens_details.cached_tokens |
无 | prompt_tokens 本身包含了命中部分,要自己减 |
最后一行是真正会算错的:OpenAI 的 prompt_tokens 把命中的部分也算进去了,你直接拿它乘单价会重复计费。所以我在归一化那一步做了一次减法------碰到 OpenAI 系,输入 token 减去缓存读取数,才是真正按全价付的那部分。
统一之后按四类分别记账:普通输入、缓存写入、缓存读取、输出。有了这四个数,命中率和"如果没有缓存要花多少"就都能算出来:
perl
◎ Input 8,420 tokens
◈ Cache write 1,610 tokens
◉ Cache read 29,250 tokens (94.8% hit)
◇ Output 3,100 tokens
Cache hit rate ████████████████████████████░░ 94.8%
Cost $0.0153
Without cache $0.0931
Saved $0.0778 (83.6% off)
这个视图值得天天看。命中率掉到 60% 以下,就说明有东西在抖,去管道里找出那一段就行。
完整代码
下面这个 demo 把同一份系统提示拼成四种样子,跑二十轮,把账单打出来。三种缓存拼装的内容一字不差,区别只有那个每轮都变的部件放在哪------最前、中间、最后。它想让你看见的正是这件事:位置本身就是成本。
javascript
// 同一份系统提示,四种拼装方式,二十轮的账单
// 运行:node prompt-cache.js
//
// 缓存按"最长公共前缀"复用:这一轮和上一轮开头对得上的部分按读取价计,
// 对不上的部分按写入价计。断点打在系统提示的末尾。
const TURNS = 20
const PRICE = { input: 3.00, write: 3.75, read: 0.30 } // $/1M,claude-sonnet-4-6
const MIN_CACHE = 1024 // 短于这个长度的前缀,服务端根本不缓存
const tok = s => Math.ceil(s.length / 3.5)
const width = s => [...s].reduce((a, c) => a + (c.charCodeAt(0) > 255 ? 2 : 1), 0)
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - width(s)))
// ── 系统提示的三个部件 ──────────────────────────────────
// 行为准则:真实项目的这一段通常几千字符,是系统提示里最长的一块
const RULE_SECTIONS = [
['身份', '你是 DeepThink Agent,一个跑在终端里的编码助手。你能读文件、写文件、执行命令、检索知识库,所有这些动作都通过工具完成。不要描述你"打算"做什么,直接调用工具去做。'],
['动手之前', '先读文件再修改,不要凭记忆编辑。读到什么就改什么,不要顺手重构没被要求的部分,也不要加没被要求的功能。改动范围越小,出问题的概率越低。'],
['工具失败时', '工具调用返回错误,先看错误信息本身,换一个思路重试,不要原样重复同一次调用。同一个工具连续失败三次以上,停下来向用户说明情况,不要继续硬试。'],
['不确定的时候', '宁可多读一个文件,也不要猜。猜错的代价是一次错误的改动加上一轮返工,多读一个文件的代价只是几百个 token,这个交换任何时候都划算。'],
['回答的粒度', '回答要简洁直接,先给结论再给理由。不要把工具返回的原始内容整段复述给用户,挑出和问题相关的几行就够了,其余留在上下文里自己用。'],
['文件路径', '所有路径都当作相对于当前工作目录处理。读到不存在的文件时,先用 glob 或 list_directory 确认真实路径,再重新读,不要假设路径大小写。'],
['写文件', '写文件用整体覆盖,改文件用精确替换。替换时 old_string 必须是原文里唯一出现的一段,如果不唯一,多带几行上下文让它唯一,不要凭印象截取。'],
['执行命令', '执行命令前先确认它在当前平台上存在,Windows 和 Unix 的常用命令差别不小。命令要能非交互地跑完,任何需要等待输入的命令都会把整个循环挂住。'],
['长输出', '命令输出超过几百行时,先用管道过滤掉无关部分再返回。真的需要看完整输出时,把它存到文件里,然后用 read_file 分段读,不要一次性灌进上下文。'],
['检索顺序', '查找代码时,先用 glob 缩小文件范围,再用 grep 定位到具体行,最后才 read_file 读全文。反过来做会在一开始就塞进大量用不上的内容。'],
['并发调用', '互相独立的工具调用可以放在同一轮里并行发出,有先后依赖的必须分开。判断标准很简单:后一个调用需不需要前一个的返回值,需要就分开。'],
['改动之后', '改完代码要验证。能跑测试就跑测试,跑不了测试至少读一遍改动附近的上下文,确认没有破坏调用方的假设。不要改完就说完成了。'],
['涉及删除', '任何删除操作都要先确认目标存在且确实是用户要求删的那个。删之前把内容显示出来,让用户有机会叫停。不可逆的操作慢一点没关系。'],
['依赖外部服务', '调用外部服务前先确认凭证配置好了。没配置就如实说明需要什么,不要伪造一个看起来像样的返回结果,那比直接失败更糟。'],
['安全性', '不要在代码、日志或回答里暴露任何密钥、令牌、连接串。用户贴出来的凭证信息,引用时一律用占位符替换掉真实值。'],
['上下文有限', '你的上下文窗口是有限的,装不下就什么都做不了。读大文件前先估一下体积,读进来之后尽快把结论写下来,原始内容能丢就丢。'],
['记忆', '需要跨会话记住的事情,写进记忆文件,不要指望上下文。判断标准是:这件事如果这个会话结束了,下次还有用吗?有就写下来。'],
['不确定的结论', '推断和事实要分开说。你读到的代码是事实,你从代码推出来的结论是推断,回答时标明哪个是哪个,不要把推断说得像事实一样确定。'],
['遇到阻塞', '卡住的时候先说明卡在哪一步、已经排除了哪些可能,再问用户。上来就问"我该怎么办"等于把已经做过的工作全部作废了。'],
['风格', '和用户对话用中文,代码、路径、命令、报错原文保持原样不翻译。术语第一次出现时用括号给出中文,之后直接用英文,不要反复解释同一个词。'],
['禁止事项', '不要为了通过测试而修改测试;不要为了让报错消失而注释掉报错的代码;不要在没看懂的情况下照搬网上的写法。这三件事都会把问题往后推。'],
['结束条件', '任务完成后停下来,不要自动开始下一个任务。用户没要求的事情,即使你觉得有价值,也先问一句再做。'],
]
const RULES = RULE_SECTIONS.map(([h, b]) => `## ${h}\n${b}\n`).join('\n')
// 16 个工具的描述。这一截也是几千字符
const TOOLS = Array.from({ length: 16 }, (_, i) => (
`## 工具 ${i + 1}: tool_${i}\n` +
`用途:在仓库里执行第 ${i + 1} 类操作,按模式查找、按内容过滤、统计引用次数都可以。\n` +
`参数 path(字符串,必填)指定搜索起点,默认当前工作目录;pattern 支持 glob 语法。\n` +
`参数 maxResults 默认 50,超过上限时按修改时间由近到远截断,并附带省略提示。\n` +
`返回:命中条目列表,每行格式为"相对路径:行号:匹配内容",空结果返回一句提示而非报错。\n` +
`注意:路径穿越会被拒绝;符号链接不跟随;二进制文件跳过不报错。\n`
)).join('\n')
// 这两个每轮都变
const sessionInfo = turn => `[会话信息] 当前会话 sess-8f3a,已有 ${turn} 条历史消息。\n`
const timestamp = turn => `当前时间:2026-09-15 14:${String(30 + turn).padStart(2, '0')}:07\n`
const userTurn = turn => `第 ${turn} 轮:看看 src/agent/loop.ts`
// ── 四种拼装:只有部件的顺序不同 ────────────────────────
// 工具表在前、行为准则在后,这是 API 的规范顺序:tools → system → messages。
// 三种缓存拼装的**内容一字不差**,只有那个每轮都变的部件换了位置。
const ASSEMBLIES = [
{ name: '不用缓存(基线)', cache: false, build: t => TOOLS + RULES + sessionInfo(t) },
{ name: 'A 易变的放在最前面', cache: true, build: t => timestamp(t) + TOOLS + RULES },
{ name: 'B 易变的夹在中间', cache: true, build: t => TOOLS + sessionInfo(t) + RULES },
{ name: 'C 易变的放到最后', cache: true, build: t => TOOLS + RULES + sessionInfo(t) },
]
function commonPrefixTokens(a, b) {
const n = Math.min(a.length, b.length)
let i = 0
while (i < n && a[i] === b[i]) i++
return Math.floor(i / 3.5)
}
// ── 跑二十轮,记四类账 ──────────────────────────────────
function run({ cache, build }) {
let prev = null
let read = 0, write = 0, plain = 0, hits = 0, total = 0
for (let turn = 1; turn <= TURNS; turn++) {
const marked = build(turn) // 断点覆盖这一段
const msg = userTurn(turn) // 用户消息在断点之外,按普通输入计
const markedTok = tok(marked)
total += markedTok + tok(msg)
if (!cache || markedTok < MIN_CACHE) {
// 没打缓存,或者这段短到服务端不收 ------ 全额按输入价
plain += markedTok
} else if (prev === null) {
write += markedTok // 第一轮没有可复用的前缀,全段写入
} else {
// 对得上的部分能不能省,还要看它够不够最小可缓存长度 ------
// 只对上几十个 token 的话,服务端压根没存过这么短的缓存
const sharedRaw = commonPrefixTokens(prev, marked)
const shared = sharedRaw >= MIN_CACHE ? sharedRaw : 0
read += shared
write += markedTok - shared
if (shared > 0) hits++
}
plain += tok(msg)
prev = marked
}
const cost = (read * PRICE.read + write * PRICE.write + plain * PRICE.input) / 1e6
const rate = read + write > 0 ? read / (read + write) : 0
return { read, write, plain, hits, total, cost, rate }
}
// ── 打表 ────────────────────────────────────────────────
function main() {
console.log(`=== 同一份系统提示,${TURNS} 轮对话(价格按 claude-sonnet-4-6)===`)
console.log(`输入 $${PRICE.input}/M · 写入 $${PRICE.write}/M · 读取 $${PRICE.read}/M · 最小可缓存前缀 ${MIN_CACHE} tokens\n`)
const rows = ASSEMBLIES.map(a => [a.name, run(a)])
const base = rows[0][1].cost
console.log(pad('拼装方式', 30) + pad('命中', 10) + pad('命中率', 10) +
pad('读取', 12) + pad('写入', 12) + pad('输入侧成本', 14) + '对比')
for (const [name, r] of rows) {
const diff = Math.abs(r.cost - base) / base < 0.005 ? '' :
(r.cost > base ? `贵 ${((r.cost / base - 1) * 100).toFixed(0)}%` : `省 ${((1 - r.cost / base) * 100).toFixed(0)}%`)
console.log(pad(name, 30) + pad(`${r.hits}/${TURNS}`, 10) + pad(`${(r.rate * 100).toFixed(1)}%`, 10) +
pad(`${r.read}`, 12) + pad(`${r.write}`, 12) + pad(`$${r.cost.toFixed(4)}`, 14) + diff)
}
console.log(`\n(系统提示共 ${tok(RULES + TOOLS)} tokens,用户消息每轮另计;` +
`写入价是输入价的 ${(PRICE.write / PRICE.input).toFixed(2)} 倍,读取价是 ${(PRICE.read / PRICE.input).toFixed(2)} 倍)`)
}
main()
跑出来是这样:
bash
=== 同一份系统提示,20 轮对话(价格按 claude-sonnet-4-6)===
输入 $3/M · 写入 $3.75/M · 读取 $0.3/M · 最小可缓存前缀 1024 tokens
拼装方式 命中 命中率 读取 写入 输入侧成本 对比
不用缓存(基线) 0/20 0.0% 0 0 $0.0931
A 易变的放在最前面 0/20 0.0% 0 30800 $0.1160 贵 25%
B 易变的夹在中间 19/20 65.8% 20292 10568 $0.0462 省 50%
C 易变的放到最后 19/20 94.8% 29250 1610 $0.0153 省 84%
(系统提示共 1533 tokens,用户消息每轮另计;写入价是输入价的 1.25 倍,读取价是 0.10 倍)
四行数字,每一行都对应前面讲过的一条规则。
最上面那行是没有缓存的基准线。 所有 token 都按 1.0 倍付,$0.0931。后面三行都要和它比,不是互相之间比。
A 行比基准线还贵 25%。 这是整张表里最该盯住的一行。它打了断点、也确实在写缓存,只是每一轮写的都是全新的内容------于是每一轮都付 1.25 倍的写入价,一次读取都没发生。付了缓存的溢价,拿不到缓存的好处。 打不中不可怕,可怕的是打不中还一直在付钱。
B 行的命中率是 65.8%,省了一半。 它和 C 行的内容完全一样,只是那个易变部件从末尾挪到了中间。挪这一下,它后面的准则段全部作废,每轮重写。命中率就是这么掉下来的。
C 行是正确写法,94.8%。 唯一一轮没命中的是第一轮------那时候还没有任何缓存可复用,全段按写入价付。从第二轮开始,前面那一千五百多个 token 全部按 0.1 倍读。
把 A 和 C 放在一起看:同样的内容、同样的长度、同样的模型,成本差 7.6 倍。 差别只有一个部件的位置。
回头再看那条管道,sessionContext 被放在最后一行,理由就在这张表里。
总结
做了什么
- 把三个"缓存"分开了:KV Cache 在推理引擎里、开发者控制不了;Prompt Cache 在 API 层、按前缀命中;Context Collapse 在上下文里。只有中间那个直接改变账单。
- 把命中规则讲清楚了:从第一个字节开始匹配、规范顺序是 tools → system → messages、最短 1024 token、最多 4 个断点、写入要加价而读取打一折。
- 列全了杀死缓存的那几件事:时间戳、工具顺序不稳、会话信息在开头、中间插入易变内容------它们的共同机制是同一条,只是插入点不同。
- 给了一条能记住的判据:追加安全,重排致命。
- 写了一个能跑的 demo,把同一份系统提示按四种方式拼装跑二十轮,位置不同带来的成本差直接打在屏幕上。
核心价值
- 缓存打不中,先查顺序,别查内容。 前缀匹配是字节级的,服务端不认识"这段很重要"。顺序错一处,后面全废------这不是性能问题,是正确性问题。
- 打不中比不打更贵,因为写入是加价的。 Anthropic 的写入价是 1.25 倍,你每轮都在付这个溢价却一次都没读到命中。所以要么不打,要么确保稳定,最怕的是打了但让它每轮变。
- 顺序是一个可以被 review 的工程对象。 系统提示拼成一个字符串常量时,没人检查第五行稳不稳定;拆成有名字的一段一段之后,谁在前谁在后就是一行代码,改动能被看见。
可延伸的方向
- 在管道里加一个稳定性标注,每一项声明自己会不会变,把易变项自动排到末尾。
- 打上显式断点,让命中从"服务端心情"变成"我自己的选择",顺带能吃到 1 小时档。
- 把上下文里的消息也纳入同一套前缀分析------工具结果的修剪和清理同样会打断前缀,那是另一个方向的坑。
一句话收尾:Prompt Cache 是一笔白送的折扣,但它只认前缀。你的提示词顺序就是你的账单。