Prompt Caching 深度:原理与命中优化
Prompt Caching 是当前 LLM 降本最有效的单点优化,但很多人用错了——命中率低、缓存失效、成本没降反增。本文讲清它的原理(前缀缓存)、命中条件(前缀逐字节相同)、如何设计 prompt 结构提升命中率、TTL 与失效、监控命中率,让 prompt cache 真正省钱。
本文要点
- 主题聚焦:AI、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
一、为什么说 Prompt Caching 是降本的"第一杠杆"
在所有 LLM 降本手段里——量化、蒸馏、换小模型、批处理——Prompt Caching(提示缓存)是少有的"几乎不损失质量、改动小、收益立竿见影"的优化。它的核心收益一句话能说清:稳定的前缀部分,命中后按约 1/10 的输入价格计费。
对一个典型的 Agent 应用,系统提示 + 工具定义 + 长文档往往占据输入 token 的大头,而这些内容每轮都不变。不做缓存,每一轮都在为同样的 prefill 计算和同样的输入 token 全价付费;一旦命中,这部分 token 的计费直接降到 1/10。在长会话、RAG、代码 Agent 这类输入动辄几万 token 的场景里,整体输入成本下降几成到 50% 以上是常态。
但很多人"开了缓存却没省钱",原因几乎都在三个地方:没命中、命中了但写入太多次、低频流量下缓存早已过期。本文把这些坑讲透。
二、原理:缓存的是 KV,省的是 prefill
理解原理,才能理解为什么命中条件这么苛刻。
Transformer 在生成第一个输出 token 之前,要先把整段 prompt 做 prefill——逐层计算每一层、每个 token 的 Key/Value 张量(KV cache)。这部分计算量正比于输入长度,是长 prompt 延迟和成本的主要来源。Prompt Caching 的做法是:把某个前缀对应的 KV cache 在服务端存住,下一次请求如果前缀相同,直接复用,跳过这段 prefill 计算。计费上按是否命中分别报价:
- cache_read(命中读取):约为基础输入价的 1/10;
- cache_creation(首次写入):比基础输入价贵——5 分钟 TTL 是 1.25 倍,1 小时 TTL 是 2 倍;
- input(未命中余量):按原价。
注意 write 比 read 贵得多。这意味着缓存不是"开了就赚",而是"命中次数足够多才赚"。粗略的盈亏平衡点:5 分钟 TTL 下,约第 2 次命中开始净赚;1 小时 TTL 因为写入更贵,要约第 3 次命中才回本。低频、一次性请求开缓存,只会更贵。
三、命中条件:前缀必须逐字节相同
这是整个机制里最关键、也最容易被忽视的一条:
缓存是前缀匹配。前缀中任何一个字节不同,从那个位置往后的所有缓存全部失效。
服务端的缓存键,是从 prompt 渲染后的开头逐字节计算到每个 cache_control 断点的。一个时间戳、一个重排的 JSON key、一个换序的 few-shot,都会让该位置之后的所有断点失效。
渲染顺序固定为:tools → system → messages。工具定义在最前,其次是 system prompt,最后是 messages。这带出一个重要的失效层级:
- 改一个工具定义 → 整个请求的缓存全废(工具在最前);
- 改 system prompt 的一个字 → messages 部分全部重算;
- 只在 messages 末尾追加新一轮 → 前面所有轮次的缓存都还在。
理解了这个层级关系,后面所有的优化技巧都只是它的推论。
四、设计"能命中"的 prompt 结构
把上面这条规则反过来用,就是设计 prompt 的核心原则:稳定的内容放最前面,变化的内容放最后面。 一个能持续命中的 Agent 请求应该长这样:
system = [{
"type": "text",
"text": FROZEN_SYSTEM_PROMPT, # 永不变的规则、人设、输出格式
"cache_control": {"type": "ephemeral"} # 断点打在"最后一个稳定块"
}]
messages = [
*history, # 历史对话逐轮追加,前缀稳定
{"role": "user", "content": this_turn_input} # 本轮用户输入,每次都变
]
断点(cache_control)要放在最后一个稳定块上,而不是整个 prompt 的末尾。如果你把断点打在变化的问题之后,每一次请求都会写入一个不同的缓存项,永远不会被读——这是最常见的"假缓存":标记加了、钱花了、命中率是 0。
多轮对话里,把断点放在"当前最后一轮的最后一个内容块"上;下一轮请求会复用整段历史前缀,并且随着对话变长,命中是增量累积的。
值得注意,Opus 4.8 这类新模型支持"对话中追加 system message"(在 messages 里写 {"role": "system", ...}),它不会改动顶层的 system,因此不会让已缓存的历史前缀失效——这是中途下发操作指令(切换模式、注入上下文)的正确姿势,而不是去编辑 top-level system。
五、命中失败的六大常见原因
实战中,命中率低基本逃不出下面这张"静默失效清单"。凡是喂给前缀的代码,grep 一遍这些模式:
| 模式 | 为什么会失效 |
|---|---|
system prompt 里插 datetime.now() / Date.now() | 每次请求前缀都不同 |
system prompt 里插 uuid4() / 请求 ID / 随机数 | 同上,每请求唯一 |
json.dumps(d) 没加 sort_keys=True、遍历 set | 序列化不确定,字节顺序变 |
system prompt 里 f-string 插入 user_id / session_id | 每个用户一个前缀,跨用户无法共享 |
条件分支拼 system(if flag: system += ...) | 每种 flag 组合都是独立前缀 |
tools = build_tools(user),工具集因用户而异 | 工具在最前,任何变化全盘失效 |
修复手法只有三种:把动态内容挪到最后一个断点之后、让它确定化(排序、固定顺序)、不承载关键逻辑就删掉。
特别强调一句:时间戳、当前日期这类信息,不要塞进 system prompt。要注入就放到当轮 user message 里——一个在第 5 轮出现的 message,不会让它前 5 轮的缓存失效;而一个写在 system 开头的 当前时间: 14:03:02,会让每一次请求都 miss。
六、TTL 与失效:低频流量天然命中低
缓存有有效期。默认 5 分钟 TTL,也可显式设 1 小时(ttl: "1h")。超过 TTL 没被再次命中,条目就被清掉,下一次请求只能重新写入。
这带来一个反直觉的现象:流量越低,命中率越低。 一个只在白天偶发的内部工具,两次请求间隔经常超过 5 分钟,几乎每次都在重新写缓存,不但没省,反而因为写入溢价更贵。这种情况要么别开缓存,要么改用 1 小时 TTL(代价是写入贵一倍,需要更多次读才能回本)。
另一个容易踩的点是并发请求时序:缓存条目要等第一个请求开始流式输出后才变得可读。同时 fan-out N 个相同前缀的请求,它们会全部全价付费——谁都读不到别人正在写的缓存。正确做法是先发一个,等到第一个 token 流出来,再发剩下 N-1 个。
如果首请求延迟敏感(聊天、语音),可以在启动时发一个 max_tokens: 0 的预热请求,只付一次写入费,换掉用户第一次的真实延迟。
七、监控:别凭感觉,看命中率
缓存有没有生效,只看账单是看不出来的,必须看响应里的 usage 字段:
cache_read_input_tokens:本次命中、按 1/10 计费的部分;cache_creation_input_tokens:本次写入、按溢价计费的部分;input_tokens:未命中的余量,全价。
一个简单的命中率指标:
命中率 = cache_read_input_tokens
/ (cache_read + cache_creation + input_tokens)
判断标准:用相同前缀连续发多次请求,cache_read_input_tokens 却一直是 0,几乎可以断定有静默失效——把两次请求的渲染字节 diff 一下,就能定位到那个变化的字节。命中率上去之后,再拉"启用前后每千次请求输入成本"做对比,而不是看单次。
一个常见误区:看到 input_tokens 很小就以为没花钱。其实它只是"未命中余量",总输入 = 三者之和。Agent 跑了一小时 input_tokens 只有 4K,不代表便宜——剩下的大头可能在 cache_creation(写入溢价)里。三个字段一起看。
八、局限与边界
最后,几个需要认清的硬约束:
- 不是所有 provider / 模型都支持。 自动缓存(auto caching)在很多第三方平台不可用,部分老模型也不支持。上线前查清楚你的 provider 和模型组合。
- 缓存按模型隔离。 切模型 = 缓存全失效,即使 prompt 一字未改。A/B 测试时不同模型各跑各的缓存,互不相通。
- 跨请求、跨用户隔离。 用户 A 的缓存,用户 B 用不到——除非他们的前缀逐字节相同(比如共用同一份 system + 文档)。想靠缓存做"全局共享",前提是 prompt 设计上就抹掉了用户级差异。
- 最小可缓存前缀。 前缀太短会"静默不缓存":没有报错,只是
cache_creation始终为 0。不同模型阈值不同(从 1024 到 4096 token 不等),短 prompt 别强开。 - 断点数量有上限。 单次请求最多 4 个
cache_control,长对话要规划好断点位置;另外单个断点最多向前回看 20 个内容块,工具结果密集的长轮次要在中段补断点。
小结
- Prompt Caching 省的是 prefill,按命中折扣计费(约 1/10),写入有溢价——它是"高频复用前缀"场景的降本利器,低频一次性场景反而更贵。
- 命中条件只有一条:前缀逐字节相同。 渲染顺序 tools → system → messages,任何一处变化都会让其后的缓存失效。
- 设计原则:稳定在前,变化在后。 断点打在最后一个稳定块上;时间戳、用户 ID 这类动态内容一律放进当轮 message,绝不进 system。
- 必查静默失效清单:时间戳、UUID、未排序 JSON、按用户拼接的 system、动态工具集——命中率长期为 0,根因几乎都在这里。
- 用
cache_read_input_tokens算命中率,别只看账单;低频流量要么不开,要么换 1 小时 TTL。
一句话:Prompt Caching 不是加个标记就能省钱的开关,而是一种把 prompt 当成不可变结构来设计的工程纪律。 命中率是设计出来的,不是调出来的。
Related