本节摘要:提示缓存(prompt caching)是 Agent 应用的头号成本利器——把每次请求都重复发送的长前缀(大型 system prompt、工具定义、检索文档、少样本示例)缓存住,命中时输入成本降到原来的十分之一,延迟也大幅下降。但缓存有一条铁律:前缀匹配(prefix match),前缀里任何位置的一个字节变化,都会让其后的所有缓存失效。本节围绕这条铁律展开:讲清渲染顺序(
tools → system → messages)、四种典型放置模式、如何用cache_read_input_tokens验证命中、如何排查「沉默失效」(时间戳、未排序 JSON、动态工具集),以及并发预热、TTL 选型、20 块回看窗口等进阶要点。读完本节,你能让长上下文 Agent 的成本骤降,并能在缓存不命中时快速定位元凶。
内容来源:Anthropic 官方 Claude API 文档
shared/prompt-caching.md(随 Claude Code 分发,从泄露素材库提取),汉化并套用体系化模板。
阅读完本节,你应当能够:
cache_creation_input_tokens、cache_read_input_tokens、input_tokens 验证缓存是否命中,并能排查沉默失效。提示缓存的一切设计都源自一条不变量:
提示缓存是前缀匹配。前缀里任何位置的一个字节变化,都会让其后的所有断点失效。
缓存键由渲染后提示的「精确字节」推导而来,直到每个 cache_control 断点位置。第 N 位哪怕差一个字节——一个时间戳、一个重排的 JSON 键、工具列表里换了一个工具——所有位置 ≥ N 的断点缓存全部作废。
渲染顺序固定为 tools → system → messages。在最后一个 system 块上放断点,会把 tools 与 system 一起缓存。设计提示组装代码时,必须围绕这个约束来——把顺序排对,大部分缓存自动生效;排错了,加再多 cache_control 标记也无济于事。
💡 设计心法:把提示组装路径当成「按稳定性分层」来设计——永不变化的内容放最前(任何断点之前),按会话变化的放中间,按请求变化的时间戳/UUID 放最后或干脆删掉。稳定内容必须物理上排在易变内容之前。
许多请求共享一个大型 system prompt。把断点放在最后一个 system 文本块上——如果还有工具,工具排在 system 之前,这个标记会同时缓存 tools + system:
"system": [ {"type": "text", "text": "<大型共享提示>", "cache_control": {"type": "ephemeral"}} ]
最省事的做法是用顶层自动缓存:给 messages.create() 传顶层 cache_control,SDK 自动放在最后一个可缓存块上:
response = client.messages.create( model="claude-opus-4-8", max_tokens=16000, cache_control={"type": "ephemeral"}, # 自动缓存最后一个可缓存块 system="You are an expert on this large document...", messages=[{"role": "user", "content": "Summarize the key points"}], )
把断点放在「最近追加的那一轮的最后一个内容块」上。后续每次请求复用整段历史前缀,早先的断点仍是有效读点,所以命中随对话增长而累积:
# 最后一个 user 轮的最后一个内容块 messages[-1].content[-1].cache_control = {"type": "ephemeral"}
多轮场景同样推荐用顶层自动缓存,无需手动标每轮。
许多请求共享一大段固定前言(少样本示例、检索文档、指令),但最后的问题不同。此时断点要放在共享部分的末尾,而不是整段提示的末尾——否则每个请求都写一个独立缓存条目,永远没人读:
"messages": [{"role": "user", "content": [ {"type": "text", "text": "<共享上下文>", "cache_control": {"type": "ephemeral"}}, {"type": "text", "text": "<每次不同的问题>"} # 不打标记——每次都变 ]}]
当一条操作员指令在对话进行中到达(模式切换、更新上下文、注入状态),不要去改顶层 system——改了它会让整段历史的缓存全废。正确做法是作为 {"role": "system", ...} 追加到 messages 末尾,它位于历史之后,保留缓存前缀不动(详见第 1 章 01 节):
"system": [{"type": "text", "text": "<稳定核心>", "cache_control": {"type": "ephemeral"}}], "messages": [ *history, {"role": "user", "content": "..."}, {"role": "system", "content": "Terse mode enabled — keep responses under 40 words."} ]
⚠️ 不要缓存的场景:如果每次请求的前 1K token 都不同,就没有可复用前缀。加
cache_control只会白白付写溢价却零命中,直接关掉。
断点放置只是细节,真正决定缓存能不能生效的是几个架构层决策,优先修这些:
保持 system prompt 冻结。不要往 system prompt 里插「当前日期:X、模式:Y、用户名:Z」——它们位于前缀最前端,会让其后一切失效。动态上下文应放进 messages(用 role: "system" 消息或 user 消息文本),第 5 轮的消息不会让第 5 轮之前的内容失效。
不要在对话中途换工具或换模型。工具排在位置 0,增删或重排工具会让整段缓存作废;模型切换同理(缓存按模型隔离)。需要「模式」时,别换工具集——给模型一个记录模式切换的工具,或把模式作为消息内容传。工具按名字排序,保证确定性序列化。
派生操作必须复用父请求的精确前缀。摘要、压缩、子代理这类侧路计算常会另起一次 API 调用。如果派生调用重建的 system/tools/model 有任何差异,就完全错过父请求的缓存——把父请求的 system、tools、model 原样复制,再在末尾追加派生专属内容。
如果反复用相同前缀请求,cache_read_input_tokens 却始终为零,八成是「沉默失效」在作怪。审查所有喂给前缀的代码,greap 这些模式:
| 模式 | 为何破坏缓存 |
|---|---|
system prompt 里的 datetime.now() / Date.now() / time.time() |
每次请求前缀都变 |
内容早期的 uuid4() / crypto.randomUUID() / 请求 ID |
同上,每次都唯一 |
json.dumps(d) 不加 sort_keys=True、遍历 set |
序列化不确定 → 前缀字节不同 |
| f-string 把会话/用户 ID 插进 system prompt | 每用户一个前缀,无法跨用户共享 |
条件性 system 片段(if flag: system += ...) |
每种 flag 组合都是独立前缀 |
tools=build_tools(user),工具集随用户变 |
工具排在位置 0,无法跨用户缓存 |
修复办法:把动态部分挪到最后一个断点之后,或让它确定化(排序、固定顺序),或干脆删掉非必要的东西。
响应的 usage 对象报告缓存活动:
| 字段 | 含义 |
|---|---|
cache_creation_input_tokens |
本次请求写入缓存的 token 数(你付了约 1.25 倍写溢价) |
cache_read_input_tokens |
本次请求从缓存读取的 token 数(你付了约 0.1 倍) |
input_tokens |
按全价处理的未缓存 token 数 |
⚠️
input_tokens只是未缓存余量。提示总长 =input_tokens + cache_creation_input_tokens + cache_read_input_tokens。如果你的 agent 跑了几小时,input_tokens却只显示 4K,说明其余都从缓存读了——看总和,别看单字段。
print(response.usage.cache_creation_input_tokens) # 写入缓存(~1.25x) print(response.usage.cache_read_input_tokens) # 命中缓存(~0.1x) print(response.usage.input_tokens) # 未缓存(全价)
经济账:缓存读约 0.1 倍输入价;缓存写 5 分钟 TTL 约 1.25 倍、1 小时 TTL 约 2 倍。盈亏平衡点取决于 TTL——5 分钟 TTL 下,两次请求即盈亏平衡(1.25× + 0.1× = 1.35×,对比两次不缓存的 2×);1 小时 TTL 下至少要三次(2× + 0.2× = 2.2×,对比 3×)。1 小时 TTL 能让条目在突发流量的间隙中存活,但双倍写成本意味着需要更多读才能回本。
并非每个参数变化都会让一切失效。API 有三层缓存,变化只让其所在层及以下失效:
| 变化 | tools 缓存 | system 缓存 | messages 缓存 |
|---|---|---|---|
| 工具定义(增删/重排) | 失效 | 失效 | 失效 |
| 切换模型 | 失效 | 失效 | 失效 |
speed、网页搜索、引用开关 |
保留 | 失效 | 失效 |
| system prompt 内容 | 保留 | 失效 | 失效 |
tool_choice、图片、thinking 开关 |
保留 | 保留 | 失效 |
| 消息内容 | 保留 | 保留 | 失效 |
💡 推论:你可以按请求改
tool_choice或开关thinking而不丢 tools+system 缓存。不必过度担心这些——只有工具定义与模型切换才强制全量重建。
20 块回看窗口:每个断点最多向前回看 20 个内容块找先前缓存条目。如果单轮追加超过 20 块(agent 循环里大量 tool_use/tool_result 对很常见),下一次请求的断点找不到上一轮缓存,沉默丢失。修复:在长轮里每约 15 块放一个中间断点。
并发请求时序:缓存条目只有在第一个响应开始流式后才变得可读。N 个并发请求用相同前缀,全都付全价——谁都读不到别人还在写的东西。扇出模式正确做法:先发 1 个,等首个流式 token(不必等完整响应),再发其余 N−1 个,它们就能读到第一个刚写的缓存。
预热缓存:要消除第一个真实请求的缓存未命中延迟,在启动时发一个 max_tokens: 0 的请求——API 跑 prefill、在你断点处写缓存、立即返回空内容,零输出 token 计费。何时值得预热?三者同时满足时:(a)首请求延迟用户可见(聊天/语音,非后台任务);(b)共享前缀大到冷写明显慢;(c)流量前有可发预热的时刻(应用启动、worker 启动、部署后)。
client.messages.create( model="claude-opus-4-8", max_tokens=0, system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}], messages=[{"role": "user", "content": "warmup"}], )
⚠️ 预热的放置要点:把
cache_control放在与真实请求共享的最后一个块上(system 或工具定义),不要放在占位 user 消息上,也不要用顶层自动缓存(否则缓存键会被占位消息绑定)。占位消息可以是任意非空白字符串,prefill 时读但不作答。max_tokens: 0与stream: true、thinking.type: "enabled"、tool_choice: {type:"tool"}等组合不兼容会报错。
tools → system → messages。role: "system" 消息而非改顶层 system)。cache_read_input_tokens 验证命中。tool_choice/thinking 不丢 tools+system 缓存。max_tokens: 0 预热。下一节讲 Token 计数(token counting)——发请求前预估输入 token 数与成本,并理解为什么不能用 OpenAI 的
tiktoken来数 Claude 的 token。