用量与计费

Claude API 提示缓存(prompt caching):断点、TTL 的账,以及它为什么会静默失效

Kenji Watanabe

提示缓存(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 一遍:

模式

为什么会破坏缓存

系统提示里有 datetime.now() 或请求 ID

每次请求的前缀都不同

序列化字典时没排序键、或直接遍历 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 字段,是「缓存真实存在」的唯一证据。