Claude API 提示缓存(prompt caching):断点、TTL 的账,以及它为什么会静默失效
提示缓存(prompt caching)能让你对反复发送的那部分提示,只付大约十分之一的输入价格——一段很长的系统提示、一组工具定义、一份你要反复提问的文档。机制本身很简单,但它的失效是静默的:缓存停止工作时不会报错,请求照常成功,唯一的症状是账单变大。这篇讲清楚它到底怎么工作、会在哪里断掉、以及怎么验证它真的开着。
一切都从这一条规则推出来
缓存是前缀匹配。缓存键取自「渲染后的提示、到每个断点为止」的确切字节。在位置 N 改动一个字节,位置 N 及其之后的所有缓存条目全部失效。
渲染顺序是固定的:先 tools,再 system,最后 messages。这个顺序就是整个设计约束。稳定的内容必须在物理位置上排在易变内容之前,因为易变性会污染它下游的一切。
这一条规则解释了大部分缓存失效。往系统提示开头插一个时间戳,不只是这一段缓存不了——它会让整个提示的其余部分都无法缓存,后面再放多少标记都没用。
怎么打断点
给某个内容块挂上 cache_control 就是打了一个缓存断点。
取值 {"type": "ephemeral"} 是默认的 5 分钟 TTL;加上 "ttl": "1h" 则是 1 小时 TTL。标记可以挂在系统文本块、工具定义、或消息内容块上——text、image、tool_use、tool_result、document 块都可以。
有两条约束值得背下来:
- 每个请求最多 4 个断点,要省着用。
- 挂在最后一个 system 块上的断点,会把 tools 和 system 一起缓存,因为 tools 渲染在前。不需要另外给 tools 挂标记。
请求上还有一个顶层的 cache_control 字段,它会自动把断点放在最后一个可缓存块上,并随对话增长向后移动。用于普通多轮对话时这是合适的默认值;但当你的提示结尾是每次请求都不同的内容时它就是错的工具——自动断点会落在那段独特的尾巴之后,于是每个请求都在为「没人会读回来的字节」付写入溢价。这种情况要改成在共享部分的末尾手动挂一个标记。
那条会把短提示悄悄吞掉的下限
短于该模型「最小可缓存前缀」的提示根本不会被缓存,而且不报错——只是缓存写入计数为 0。
这个下限因模型而异,而且并非随代际单调变化:
|
模型 |
最小可缓存前缀 |
|---|---|
|
Claude Opus 5、Fable 5 |
512 tokens |
|
Opus 4.8、Sonnet 5、Sonnet 4.6、Sonnet 4.5 |
1024 tokens |
|
Opus 4.7、Haiku 3.5 |
2048 tokens |
|
Opus 4.6、Opus 4.5、Haiku 4.5 |
4096 tokens |
一个 3000 tokens 的提示,在 Claude Opus 5 与 Sonnet 5 上能缓存,在 Opus 4.6 或 Haiku 4.5 上则静默地不能。如果你换了模型之后缓存命中突然消失,先查这张表再去审代码——同一份提示在一个模型上可缓存、在另一个模型上对缓存完全不可见。
TTL 的账:更长的缓存什么时候反而更差
缓存读取约为基础输入价格的 0.1 倍;写入则是 5 分钟 TTL 1.25 倍、1 小时 TTL 2 倍。正是这个写入溢价,让「更长的 TTL」不等于「更好」。
盈亏平衡点直接推得出来:
- 5 分钟 TTL:两次请求。一次写加一次读是 1.35 倍,对比不缓存的 2 倍。
- 1 小时 TTL:三次请求。2 倍加两次读是 2.2 倍,对比不缓存的 3 倍。
判断依据是共享同一前缀的请求之间的起始时间间隔,而不是对话持续了多久:
- 两次请求起始间隔小于 5 分钟:用 5 分钟 TTL。每次读取都会免费刷新计时器,所以连续流量能把 5 分钟条目无限续命。这种情况下 1 小时 TTL 只带来翻倍的写入价格,别的什么也没买到。
- 5 到 60 分钟:用 1 小时 TTL。这是唯一一个 2 倍写入能回本的窗口。
- 超过一小时:两者都救不了。要么定时预热,要么接受冷未命中。
有一个细节专坑 agent 循环:条目寿命是从「写入或读取它的那个请求开始的时刻」算起的,而生成耗时也算在里面。一轮生成花了 4 分钟,就只剩大约 1 分钟留给下一个请求启动,否则 5 分钟条目就过期了。
静默失效源
以下这些模式,要在所有构造提示前缀的代码里 grep 一遍:
|
模式 |
为什么会破坏缓存 |
|---|---|
|
系统提示里有 |
每次请求的前缀都不同 |
|
序列化字典时没排序键、或直接遍历 set |
字节顺序不确定 |
|
把用户 ID / 会话 ID 插进系统提示 |
前缀按用户分裂,用户之间无法共享 |
|
用 if 分支拼出来的条件系统段落 |
每种开关组合都是一个不同的前缀 |
|
工具列表随用户或模式变化 |
tools 渲染在位置 0——整份提示全都缓存不了 |
两条架构规则能挡掉其中大多数:
让系统提示保持冻结。 当前日期、模式、用户名,都不该出现在系统提示里。把动态上下文注入到消息列表靠后的位置,那样它只会让它之后的内容失效。
不要在对话中途改工具或换模型。 tools 渲染在最前面,增删或重排一个工具就会让全部失效;缓存又是按模型隔离的,换模型等于从零开始。需要「模式」的话,把模式作为消息内容传进去,别去换工具集。
派生调用里有个相关的坑:摘要、子 agent、压缩这类旁路计算,往往会自己另建一个请求。只要这个派生请求在 system、tools 或 model 上与父请求有任何差别,它就完全命中不了父请求的缓存。正确做法是把父请求的这三样原样复制过来,再把派生专用的内容追加到末尾。
怎么验证它真的在工作
响应里的 usage 对象是唯一的事实来源:
cache_creation_input_tokens——本次请求写入缓存的 token,按写入溢价计费cache_read_input_tokens——本次由缓存提供的 token,约按 0.1 倍计费input_tokens——仅仅是未缓存的剩余部分
最后这个字段最容易被读错。提示总量等于这三者之和。 如果你的 agent 跑了一个小时而 input_tokens 只显示 4K,其余部分都来自缓存——要看的是三者之和,不是单个字段。
一个健康的多轮循环,形态是:读取量逐轮增长、覆盖此前的整个前缀;写入量始终很小,大致等于上一轮助手输出加上新追加的输入;未缓存的输入就只是最后一个断点之后的尾巴。如果写入量在每个请求上都接近整段对话的体量,说明上游有东西在重写前缀。
每次改动之后都要验证,不只是接入时验一次。 代价最大的失效模式是回归:写的时候缓存是好的,几个月后系统提示里落进一个新的动态字段,从此每个请求都未命中。什么都不报错,只是账单变高。写一条集成测试,断言「第二次发送相同请求时 cache_read_input_tokens 大于 0」,成本几乎为零,却能覆盖这一整类问题。
如果重复发送相同前缀时读取量始终为 0,就把连续几个请求体打日志、两两做 diff。diff 之前先把 cache_control 标记剥掉——那个标记本来就会在请求之间移动,它不是元凶。重叠区域里第一处真正的差异,就是失效点。
走中转/网关时额外要确认的一件事
缓存按 workspace 隔离,且从不跨组织共享。同一份提示的流量被拆到两个 workspace,就会各写各的、各读各的——在你去追查「是不是序列化不确定」之前,先把这条排除掉。
如果你的请求不是直连、而是经过 OpenAI 兼容的中转或网关,那么「缓存字段能不能原样穿过来」是一个实测问题而非架构问题:把同一个前缀连发两次,读第二次响应的 usage 字段。cache_read_input_tokens 大于 0,说明缓存在整条链路上是通的;如果该字段缺失或恒为 0,而直连同一模型时能看到读取量,那就说明缓存语义没有穿过你这条路径——在你围绕缓存去设计提示布局之前,这件事值得先弄清楚。
ROIBest AI 是面向 Claude 及其他模型的 OpenAI 兼容 API 网关。无论走哪条路径,上面那个验证步骤都一样——第二次发送相同请求时的 usage 字段,是「缓存真实存在」的唯一证据。