Anthropic Prompt Caching 生产实践:一份能省钱的工程手册
Prompt Cache 是 Claude API 上单点收益最大的成本杠杆,但大多数团队还把它扔在地上没捡。不是因为它难开,而是让它真正生效的写法,和写一次普通 LLM 调用的写法不太一样。这份手册是我们希望第一次结账之前就有人塞给我们的版本。
为什么 Prompt Cache 是最大的成本杠杆
看一份真实的 Claude 生产负载,输入这边的账单几乎都被"跨调用不变的内容"占据。带策略和语气说明的 system prompt、写了十几个函数定义的 tools schema、few-shot 示例、用户在追问三轮的同一段检索文档——这些每次都是一模一样地传上去。请求最底下的 user turn 往往是整个 payload 里最小的一部分,但只要不开缓存,你就要为它上方的每一个字节、每一次调用,按全价输入付钱。
Prompt Cache 把这件事翻过来。它不再把每次请求当独立的算一遍,而是把你标记为可缓存的前缀存到服务端,后续命中的请求直接跳过重算。账面差距非常硬:cache read 是基础输入价格的 0.1 倍。这不是"打个折",这是数量级差异。一个高频 agent 从不开缓存切到合理放置 marker,输入开销通常下降 70% 到 90%——模型不用换、prompt 内容不用改、输出质量不变。
杠杆是真实存在的。它没被用上的原因是:Prompt Cache 想真的省钱,前缀必须跨调用字节级完全一致,而大多数应用代码自然写出来不会保证这件事。一旦你内化了这个约束,后面的章节就是机械活了。
Prompt Cache 实际做了什么
Anthropic Messages API 暴露 Prompt Cache 的方式只有一个机制:在某个 content block 上挂一个 cache_control 字段。请求到服务端后,系统把这个 block 之前(含本身)的所有内容做哈希,然后:
- 要么创建一个新的 cache 条目,把这段前缀的编码形式存起来,按 cache write 价格计费;
- 要么找到一个完全匹配的已有条目,跳过计算,按 cache read 价格计费。
cache_control 当前只有一种结构:
{"type": "ephemeral"} # 默认 5 分钟 TTL
{"type": "ephemeral", "ttl": "1h"} # 1 小时 TTL
marker 可以放三个区域:system 数组、tools 数组里的某个条目、messages 里的某个 content block。一个请求最多带 4 个 marker,每个都是一个独立的 breakpoint。下一次调用,服务端会自动选最长匹配的那个 breakpoint,所以放 marker 时可以大胆一点——最长稳定前缀总会赢。
下面所有决策都建立在两个不那么直观的事实上:
- 前缀必须字节级精确匹配。多一个空格、字段顺序变了、system prompt 里换了一个浮点数字面量、模型 ID 换了,任何一个都会 miss。生成可缓存前缀的代码必须是确定性的。
- 有最小前缀长度。大约 1024 token 以下(Haiku 是 2048),marker 会被静默忽略,按"没开缓存"计费。给小 system prompt 打 marker 是空操作。
价格算账:5 万 token system prompt,100 次调用
把节省感算清楚最快的办法是用一个真实形状跑一遍。假设一段 5 万 token 的 system prompt(策略、语气、schema 描述、few-shot)在 Claude Sonnet 上跑,5 分钟内被调用 100 次。Anthropic 公布的价格如下:
| 模型 | 基础输入 | 5 分钟写入(1.25x) | 1 小时写入(2x) | Cache 读取(0.1x) |
|---|---|---|---|---|
| claude-opus-4-8 | $5.00 | $6.25 | $10.00 | $0.50 |
| claude-sonnet-4-6 | $3.00 | $3.75 | $6.00 | $0.30 |
| claude-haiku-4-5 | $1.00 | $1.25 | $2.00 | $0.10 |
所有数字都是每百万输入 token。实时价格(包含网关折扣)在 /api/pricing;本节用的是 Anthropic 一级价目,这样倍数关系最直观。
不开缓存,Sonnet,100 次,每次 5 万 token。
100 次 x 50,000 token = 5,000,000 输入 token
5,000,000 / 1,000,000 x $3.00 = $15.00
5 分钟缓存,Sonnet,100 次。第一次写入,后续 99 次读取。
写入: 50,000 / 1,000,000 x $3.75 = $0.1875
读取: 99 x 50,000 / 1,000,000 x $0.30 = $1.4850
合计: $1.6725
缓存前缀这部分省了 89%。每次请求底部的 user turn 不受影响,仍按标准输入价计费,但在大多数负载里 user turn 只占总输入 token 的一小部分。
1 小时缓存,同样形状。
写入: 50,000 / 1,000,000 x $6.00 = $0.30
读取: 99 x 50,000 / 1,000,000 x $0.30 = $1.485
合计: $1.785
在 5 分钟窗口内,1 小时 TTL 严格更贵——写入倍数高。它的价值完全来自把成本摊薄到更长的时间间隔上。这就直接引出下一个问题。
5 分钟还是 1 小时:盈亏点在哪
选哪种 TTL,本质是两个变量的函数:这段前缀在过期前会被读多少次,以及读取之间的间隔通常多长。
把两次写入的成本差,跟"一次读取 vs 一次按全价输入计费"的成本差对照一下:
- 选 1 小时而不是 5 分钟,每个缓存 token 多付的写入成本:
0.75x 基础输入。 - 每个缓存 token,一次命中读取相比按全价计费节省的成本:
0.9x 基础输入。
所以,只要超过 5 分钟窗口后还能多命中 1 次(这次是 5 分钟版本接不住的),1 小时 TTL 就回本。还是 50K Sonnet 的例子,多付的写入是 50,000 / 1,000,000 x ($6.00 - $3.75) = $0.1125。一次 1 小时命中的成本 $0.015,对应一次按全价的 $0.15,净省 $0.135。多一次命中 1 小时 TTL 就转正了。
大多数负载可以直接套用的默认值:
- 交互式聊天或编码 agent,几分钟内会有突发流量:system prompt 和 tools 用 5 分钟 TTL。
- 后台流水线、文档审阅、批处理,调用之间隔几十分钟但同一段前缀跨小时复用:1 小时 TTL。
- 用户在一个工作 session 里反复追问的长检索文档:文档 block 上 1 小时,对话轮次上 5 分钟。
要避免的反模式是在高频负载上"为了保险"选 1 小时 TTL。每次新建条目都要付高写入倍数,对那种本来就经常 cache-cold 的前缀(用户级状态、请求级数据),反而把账单拉高了。
能跑通的代码模式
Anthropic 的 Python SDK 接受在结构化 content block 上挂 cache_control。下面所有例子假设 client 已经把 base_url="https://buzzai.cc" 设好;API 表面和 Anthropic 一级完全一致,所有缓存指令字节透传。
1. 单 session 的 system prompt + tools
最常见的入门姿势。system prompt 是单个 text block,tools 数组短而稳定,在两边各放一个 marker 收尾。两个 breakpoint,在 agent 整个生命周期都稳定。
from anthropic import Anthropic
client = Anthropic(
base_url="https://buzzai.cc",
api_key="sk-...", # BUZZ key
)
SYSTEM_PROMPT = """You are an internal compliance assistant.
[... 50K tokens of policies, tone rules, schema definitions ...]"""
TOOLS = [
{
"name": "lookup_customer",
"description": "Fetch customer record by id.",
"input_schema": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
},
},
# ... more tool definitions ...
]
# 在最后一个 tool 上挂 cache_control,把 tools 数组整段封顶
TOOLS[-1] = {**TOOLS[-1], "cache_control": {"type": "ephemeral"}}
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=[
{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}
],
tools=TOOLS,
messages=[
{"role": "user", "content": "Look up customer 12345 and summarize their last 3 tickets."}
],
)
print(resp.usage)
# Usage(input_tokens=42, cache_creation_input_tokens=51234,
# cache_read_input_tokens=0, output_tokens=187)
TTL 内第二次调用、保持同一段 system prompt 和 tools 数组,你会看到 cache_creation_input_tokens=0 和 cache_read_input_tokens=51234。底部的 user turn 仍按标准输入价计费。
2. 多轮对话
聊天 agent 想缓存的前缀会随时间增长:第 1 轮在第 2 轮时已稳定,第 1-3 轮在第 4 轮时已稳定,以此类推。正确做法是把 cache_control 挂在上一轮的 assistant 回复上,这样到这里为止的所有内容都成为可缓存前缀。
def build_messages(history, new_user_input):
messages = []
for i, turn in enumerate(history):
block = {"type": "text", "text": turn["text"]}
# 把最近一条 assistant 回复标为 cache breakpoint
if i == len(history) - 1 and turn["role"] == "assistant":
block["cache_control"] = {"type": "ephemeral"}
messages.append({"role": turn["role"], "content": [block]})
messages.append({"role": "user", "content": new_user_input})
return messages
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=[{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}],
messages=build_messages(history, "follow-up question..."),
)
这样最多用掉 4 个 marker 中的 3 个(system、上一轮 assistant、当前 user 如果需要),让服务端每次自动选最长匹配。会话越长,节省越多——每加一轮,都是在延长上一次已经缓存好的前缀。
3. 长文档,整段缓存
文档问答和代码审阅类负载里,文档才是要缓存的资产,变化的是 user turn。把文档作为 user message 第一个 content block 单独放,直接挂 marker。
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=[{"type": "text", "text": "You are a careful technical reviewer."}],
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": large_document_text, # 80K tokens
"cache_control": {"type": "ephemeral", "ttl": "1h"},
},
{
"type": "text",
"text": "Summarize the security implications in section 4.",
},
],
}
],
)
第一轮按 1 小时写入价把文档存进去。一小时之内对同一份文档的所有追问都按 0.1x 读取。这是长 context 负载里最能省钱的形态——一次写入摊到整个阅读 session。
走网关:什么会变,什么不变
如果你把请求打到 BUZZ AI Gateway 而不是 api.anthropic.com,从应用代码角度看 Prompt Cache 行为完全一致。BUZZ 是一个透明转发器,请求体的每一个字节,包括 cache_control marker、system block 顺序、tool 定义顺序、message 内容,都原样上行。响应,包括带 cache_creation_input_tokens 和 cache_read_input_tokens 的 usage 对象,也原样下行。
具体的契约是:
- BUZZ 不规范化空白、不重排 JSON 字段、不删 null、不改写 system prompt。字节级前缀匹配被完整保留。
- BUZZ 不注入自己的 system 内容、tool 定义或 guidance preamble。前缀长度就是你发出去的长度。
- BUZZ 不静默换模型。
claude-sonnet-4-6到 Anthropic 那边还是claude-sonnet-4-6,缓存条目的作用域不会被打乱。 - BUZZ 是零留存运行的。请求体不落盘,只保留计费元数据(模型、token 计数、时间戳)。缓存状态在 Anthropic 上游,不在网关。
值得反复强调一句:重写请求的网关会破坏缓存,字节透传的网关不会。完整的可用模型清单,包括本文涉及的所有支持缓存的型号,在 /models;实时价格包括各种 cache 倍数在 /api/pricing。
Claude Code 用户可以用一行命令把 CLI 配置成走网关:
curl -fsSL https://buzzai.cc/sh/claudecode.sh | bash
Claude Code 自己对 system prompt 和 tools 的缓存逻辑会继续生效,因为它发出去的请求形态从头到尾不变。
悄悄破坏缓存的常见错误
大多数缓存相关的 bug 是看不见的:请求成功、响应正确,只有账单比该有的多。下面这些是最常见的失败模式。
system prompt 里有非确定性内容
如果你的 system prompt 通过字符串拼接把当前日期、request id、user id 或任何按请求变化的值放在 marker 之前,每次调用都会落到一条新的 cache 条目上。把这些易变值挪到 marker 之后,放到 user turn 里,或者塞进单独的非缓存 block。一个有用的体检方法是每次调用记一下缓存前缀的哈希:本应复用缓存的两次调用如果哈希不同,泄漏点就找到了。
tools 数组或 system block 顺序不稳定
tools 经常从 registry 加载,而 dict 顺序在不同 Python 版本或不同进程里不一定稳定。发出去前用确定性方式排序,marker 永远挂在最后一个元素上。多 block 的 system 数组同理。
后续调用漏带 marker
缓存是逐次 opt-in 的。如果只有第一次请求带了 cache_control、后面都没带,服务端就没有可查的 key,即使条目还在也按全价计费。把 marker 写在共享代码里,不要放在第一次调用点。
前缀低于最小阈值
1024 token 以下(Haiku 是 2048)的 marker 会被忽略。开了缓存之后,如果第一次调用看不到 cache_creation_input_tokens,基本是前缀太短了。要么把更多内容合并到 marker 之前,要么接受这段 prompt 不够大、用不上缓存。
block 顺序放反
marker 缓存的是它上方的内容,不是下方。把 cache_control 放到当前用户问题上,等于让服务端去缓存每次都变化的内容,永远不会命中。marker 应该挂在稳定 block 上:system、tools、历史轮次、长文档的早期部分。
TTL 漂移
用一个声明 5 分钟 TTL 的请求去命中已经存在的 1 小时条目,是可以读到的;但你不能通过混用 TTL 创建新的 1 小时条目。每个 breakpoint 选一种 TTL,在缓存的整个生命周期里保持一致。
切模型
缓存条目按模型隔离。把"难一点"的请求从 claude-sonnet-4-6 换到 claude-opus-4-8,对那批请求来说就是完整 miss——更高的单价打在没缓存的前缀上。如果你在做模型 A/B,要预期两边各自维护各自的缓存。
一份观测清单
看不到的缓存就调不动。最少有用的埋点是每次调用三个计数器,全部能从响应的 usage 对象里拿到:
input_tokens:没缓存的前缀加上本轮新内容,按全价输入计费。cache_creation_input_tokens:写入新缓存条目的 token 数,按写入倍数计费。cache_read_input_tokens:从缓存读出的 token 数,按 0.1x 读取价计费。
按路由或功能聚合,然后画 cache_read / (cache_read + input + cache_creation) 这个比例随时间的曲线。一个健康的生产 agent 预热后会稳定在 0.7 以上。高频路由低于 0.3 就是调优空间,几乎一定能追溯到上一节的某个错误。
BUZZ 在响应体里原样暴露这些 usage 字段,所以你为 Anthropic 直连写的指标管道,把 base_url 切到 网关之后照样能用。
FAQ
BUZZ 这边需要做什么特殊配置才能开缓存吗?
不需要。缓存是上游特性,完全由请求体决定。cache_control 写对了,网关原样转发,Anthropic 处理后续。
开缓存会改变模型行为吗?
不会。编码后的前缀重放出来跟新算一遍是等价的,模型看到的 context 完全一样。如果开了缓存之后行为漂移,大概率是同时改了别的东西导致的。
如果 Anthropic 提前淘汰了缓存条目会怎样?
下一次本来该命中的请求会按全新写入计费。没有报错也没有 warning,只是那一次账单稍微高一点。在公布的 TTL 内淘汰很少见,但不保证一定不发生。
能跨用户共享缓存吗?
可以,只要前缀字节一致并且调用走的是同一个账号。被所有用户共用的 system prompt 和 tools 数组只对应一个缓存条目,不管是哪个用户触发了写入。
缓存会改变 rate limit 吗?
cache 读取仍然计入 TPM(每分钟 token 数),只是按更便宜的价格计入。RPM(每分钟请求数)和缓存无关。
所有 Claude 模型都支持缓存吗?
当前 Sonnet、Opus、Haiku 系列都支持,包括 claude-opus-4-8、claude-sonnet-4-6、claude-haiku-4-5。最小前缀长度不同,Haiku 需要 2048 token 才会让 marker 生效。
OpenAI 兼容路径能用 Prompt Cache 吗?
缓存是 Anthropic Messages API 的特性。要用它必须走 Claude 原生接口(BUZZ 上是 https://buzzai.cc 配 Anthropic SDK)。https://buzzai.cc/v1 上的 OpenAI 兼容接口适合迁移和复用代码,但不暴露 cache_control。
few-shot 示例值得缓存吗?
几乎一定值得。few-shot 通常是 prompt 里最长的稳定段、又跨调用一字不差地复用。把它们放在 system block 里、marker 之上。
如果 system prompt 刚好差一点点不到最小长度怎么办?
用真正有用的内容把它补足:显式格式示例、边界情况说明、术语表。为了凑长度而凑长度对质量没好处,经常还会让模型表现变差。
结论
Prompt Cache 是少有的"开起来几乎不花成本、不用换模型、动辄把输入开销砍掉一个数量级"的优化。它没被用上的原因是工程层面的而不是技术层面的:跨调用产生字节一致的前缀,需要在 prompt 拼装上稍微讲一点纪律,而大多数代码一开始不是这么写的。
手册其实很短。给 system prompt 和 tools 数组挂上 marker。根据前缀复用频率选 TTL。把易变值放到 marker 之下。盯紧 cache_read_input_tokens,调到它占主导为止。如果你通过 BUZZ 调 Claude,所有 cache 指令原样转发,所有 usage 字段原样回来,网关账单上看到的省钱效果和直连一模一样,价格在 /api/pricing。支持的模型清单(包括所有可缓存型号)在 /models;Claude Code 用户用 /sh/claudecode.sh 一行命令就能切到网关。
读完这篇文章如果只做一件事,就是给最高频的 Claude 路由埋好那三个 usage 计数器。一小时之内,你的账单和它"应该是多少"之间的差距就会浮出来。
发表时间:2026-05-22
最近审阅:2026-05-22