第 2 章 · 03 提示缓存(prompt caching)


第 2 章 · 03 提示缓存(prompt caching)

本节摘要:提示缓存(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 分发,从泄露素材库提取),汉化并套用体系化模板。

学习目标

阅读完本节,你应当能够:

  1. 复述提示缓存的铁律(前缀匹配),并解释为什么 system prompt 里插一个时间戳会让全部缓存失效。
  2. 为四种典型场景(大型共享 system、多轮对话、共享前缀+可变后缀、会话中途 system 消息)选择正确的断点放置方式。
  3. 用响应里的 cache_creation_input_tokens、cache_read_input_tokens、input_tokens 验证缓存是否命中,并能排查沉默失效。
  4. 说清两层缓存经济:读约 0.1 倍、5 分钟写约 1.25 倍、1 小时写约 2 倍,以及何时该预热。
  5. 掌握并发扇出的正确顺序(先发一个、等首个 token、再发其余),并理解 20 块回看窗口对长 agent 循环的影响。

一、铁律:前缀匹配

提示缓存的一切设计都源自一条不变量:

提示缓存是前缀匹配。前缀里任何位置的一个字节变化,都会让其后的所有断点失效。

缓存键由渲染后提示的「精确字节」推导而来,直到每个 cache_control 断点位置。第 N 位哪怕差一个字节——一个时间戳、一个重排的 JSON 键、工具列表里换了一个工具——所有位置 ≥ N 的断点缓存全部作废。

渲染顺序固定为 tools → system → messages。在最后一个 system 块上放断点,会把 tools 与 system 一起缓存。设计提示组装代码时,必须围绕这个约束来——把顺序排对,大部分缓存自动生效;排错了,加再多 cache_control 标记也无济于事。

💡 设计心法:把提示组装路径当成「按稳定性分层」来设计——永不变化的内容放最前(任何断点之前),按会话变化的放中间,按请求变化的时间戳/UUID 放最后或干脆删掉。稳定内容必须物理上排在易变内容之前。

二、四种典型放置模式

模式一:大型共享 system prompt

许多请求共享一个大型 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 消息(不破坏缓存)

当一条操作员指令在对话进行中到达(模式切换、更新上下文、注入状态),不要去改顶层 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"} 等组合不兼容会报错。

本节要点回顾

  1. 铁律:前缀匹配,前缀任何位置一字节变化让其后的缓存全失效;渲染顺序固定 tools → system → messages。
  2. 四种放置模式:大型共享 system(顶层自动缓存最省事)、多轮对话(最近轮末块)、共享前缀+可变后缀(断点在共享末尾)、会话中途指令(用 role: "system" 消息而非改顶层 system)。
  3. 架构优先:system prompt 冻结、不中途换工具/模型、派生调用原样复用父前缀——这些比断点放置更重要。
  4. 沉默失效元凶:时间戳、UUID、未排序 JSON dumps、按用户的工具集、条件性 system 片段;用 cache_read_input_tokens 验证命中。
  5. 三层失效:只有工具定义与模型切换强制全量重建;改 tool_choice/thinking 不丢 tools+system 缓存。
  6. 经济账:读约 0.1×、5 分钟写约 1.25×、1 小时写约 2×;5 分钟 TTL 两次回本,1 小时 TTL 三次回本。
  7. 进阶:20 块回看窗口(长轮每 15 块加中间断点);并发先发一个等首 token 再扇出;首请求延迟敏感时用 max_tokens: 0 预热。

下一节讲 Token 计数(token counting)——发请求前预估输入 token 数与成本,并理解为什么不能用 OpenAI 的 tiktoken 来数 Claude 的 token。


作者与出处
原作者: 灏天文库
来源:asgeirtj
许可证:CC BY-NC-SA 1.0
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: 灏天文库 转发
评论区 (0)
U