提示缓存(三):开了却没命中,怎么查
提示缓存失效几乎不报错——推理成功、日志干净、只有账单变贵。本文按出现频率拆四类元凶:前缀混进易变内容、序列化顺序抖动、工具顺序不稳、中间层静默吞掉缓存标记,并给出一个跨厂商的命中率自检脚本。
先说结论。
提示缓存失效几乎从不报错。推理成功、结果正确、日志干净,只有账单在涨。这是它最难查的地方,也是唯一需要你主动建立监控的地方。
按我梳理下来的出现频率,元凶分四类:
- 前缀里混进了易变内容——时间戳、session ID、上下文压缩。最常见,也最好修。
- 序列化顺序抖动——同样的数据结构,两次序列化出的字节不同。
- 工具列表顺序不稳——
tools属于前缀,而它的顺序常常由文件系统或线程调度决定。 - 中间层静默吞掉缓存标记——你写了
cache_control,代理层删了。最阴的一类。
如果你手上已经有一个疑似不命中的服务,直接跳到最后一节的自检脚本,五分钟能得到答案。
先把术语校准一下#
中文技术圈很容易把这个现象叫「缓存穿透」,但两者机制完全不同:
| 术语 | 含义 | 场景 |
|---|---|---|
| cache miss / 缓存失效 | 前缀哈希不匹配,未命中已有缓存,按全价重算并重新写入 | LLM 提示缓存 |
| 缓存穿透(cache penetration) | 查询一个根本不存在的 key,请求穿过缓存层直达数据库 | Redis + MySQL 那类架构 |
提示缓存里没有「不存在的 key」这个概念——前缀在就命中,不在就重算。用「缓存失效」或直接用 cache miss 更准确,排查时也不会把思路带偏。
元凶一:前缀里混进了易变内容#
这一类占我见过的大多数,原因也最直白:注意力是因果的,改动点之后的 KV 全部作废,所以任何插在前面的易变内容都会让整条缓存归零。
三家文档都点名了具体场景:
prompt 开头的动态时间戳。 这是最经典的一个。"当前时间:2026-08-17 10:25:07" 放在 system prompt 开头,每秒变一次,命中率恒等于零。
system prompt 里带 session ID 或用户 ID。 每个会话不同,于是每个会话都要重新写一遍缓存,永远读不到。OpenAI 给了一条很实用的建议:只用于日志或调试的动态值,放进 request metadata,不要塞进 prompt。
上下文压缩(compaction)会重置前缀。 这一条最容易被忽略,因为压缩本身是个好优化。OpenAI 文档说得很清楚:截断、摘要和压缩能减小 prompt 体积,但也会重置可复用的前缀。两个优化在这里互相打架——你压缩省下的 token,可能少于失去缓存多付的钱。这个权衡要按自己的数据算,没有通用答案。
改写或重排历史消息。 多轮对话要 append,不要编辑。删掉一条早期消息、或者把历史消息重新排序,前缀就变了。
除此之外还有一批「参数也算前缀」的情况,都有官方出处:
- 图片的顺序或
detail参数变化 - 结构化输出的 schema 变化
- thinking 配置或
budget_tokens变化 output_config.effort变化tool_choice变化- web search / citations 开关(它们会改写 system prompt)
- fast mode 与标准速度之间切换
最后这几条的共同点是:它们看起来像”请求参数”,实际上被渲染进了 prompt。 一个 A/B 实验里动态调 thinking budget,就会让缓存全程不命中。
元凶二:序列化顺序抖动,以及官方文档点错的一个名#
Anthropic 的排错章节里有这么一句警告,大意是:确认 tool_use 内容块里的 key 顺序稳定,因为某些语言(例如 Swift、Go)在 JSON 转换时会随机化 key 顺序,从而破坏缓存。
方向是对的,但按语言点名不准确。我去核了 Go 官方文档。
pkg.go.dev 上 encoding/json 的说明写得很明确:map 的 key 是排序后用作 JSON object 的 key。v1/v2 差异章节还专门说明:v1 里 Go map 以确定性顺序序列化,而 v2 是非确定性的,由 jsonv2.Deterministic 选项控制。
所以标准库 v1 恰恰是安全的。真实的风险分布是这样:
json.Marshal(someMap) ✅ key 已排序,输出稳定
json.Marshal(someStruct) ✅ 按字段声明顺序,输出稳定
手动 for k, v := range m 拼串 ❌ Go 规范明确不保证 map 迭代顺序
encoding/json v2 ❌ 默认非确定性,需显式开 Deterministic
Swift JSONEncoder ❌ 默认不排序(.sortedKeys 是 opt-in)
Python json.dumps ⚠️ sort_keys 默认 False,但 3.7+ dict 保插入顺序
→ 每次同序构建则稳定,从无序来源合并则不稳定
JS JSON.stringify ⚠️ 整数 key 升序、字符串 key 按添加顺序
→ 对象字面量稳定,从 Map 转且遍历源不定则不稳定
真正的元凶不是”某个语言有问题”,而是三种写法:手动拼 JSON、用了 v2 的默认值、以及从无序来源构建集合。
Python 那一行是最容易踩的,因为它看起来很安全:
import json
TOOL_NAMES = ["search", "calculator", "browser", "code_exec"]
# 危险:从无序来源合并,插入顺序取决于上游
def build_unstable(source):
tools = {}
for name, schema in source: # source 顺序不定 → dict 顺序不定
tools[name] = schema
return json.dumps(tools) # sort_keys 默认 False,照抄插入顺序
# 安全:强制字母序,与构建顺序解耦
def build_stable(source):
tools = {name: schema for name, schema in source}
return json.dumps(tools, sort_keys=True)
dict 在 Python 3.7+ 保持插入顺序,这本身是好事——但它意味着序列化结果忠实反映了你构建的顺序。上游顺序一抖,前缀就变了。加一个 sort_keys=True 就把这条路堵死。
Go 侧的对照:
m := map[string]int{"zebra": 1, "apple": 2, "mango": 3}
b1, _ := json.Marshal(m)
b2, _ := json.Marshal(m)
// string(b1) == string(b2) 恒为 true —— 标准库 v1 已排序
for k, v := range m {
// 但这里的顺序每次运行都可能不同
// 自己拼 JSON 的话,前缀就不稳定了
_ = k; _ = v
}
元凶三:工具列表的顺序也算前缀#
这一条双方官方都写明了。
Anthropic 的表述是:修改工具定义(名称、描述、参数)会使 tools、system、messages 三级缓存全部失效——因为前缀的层级是 tools → system → messages,改前面一层,后面全塌。
OpenAI 更直接地点出了顺序:工具定义、工具顺序、以及结构化输出的 schema 都参与构成 prompt 前缀。
问题是,工程里让工具顺序抖动的来源特别多:
| 来源 | 为什么会抖 |
|---|---|
| 从 dict / map 收集工具 | 合并多个来源时插入顺序不定 |
| 插件动态注册 | 注册顺序取决于文件系统遍历或 import 顺序 |
| MCP 服务器返回的 tool list | MCP 协议没有规定 tools/list 的返回排序 |
| 并发注册 | 多个协程同时注册,最终顺序取决于竞争时序 |
| 按权限或 feature flag 条件启用 | 不同用户、不同开关下列表内容与顺序都不同 |
MCP 那一条值得单独强调:现在大量 agent 的工具是从 MCP 服务器动态拉的,而协议层没有排序保证。服务器重启一次、或者换个实现,顺序就可能变,而你的缓存会跟着全军覆没。
修法很简单,但必须显式做:收集完工具之后,按 name 排一次序再发出去。
tools = sorted(collected_tools, key=lambda t: t["name"])
一行代码,把一整类不确定性消掉。
元凶四:中间层静默吞掉缓存标记#
这是最阴的一类,因为你的代码是对的。
LiteLLM 上有多个已报 issue 属于这一类:
- #34797:经 SAP provider 转发时,
cache_control字段被 strip 掉,Anthropic 的缓存直接不可用 - #26625:经 Bedrock Application Inference Profiles 走
/v1/messages端点时,cache_control指令被静默丢弃 - 社区还记录过一次版本事故,导致命中率从约 90% 掉到 25–45%
共同特征:不报错、不告警、功能完全正常。你在代码里写了缓存断点,中间层删了它,请求照样成功,回答照样正确,只有账单在涨。
这类问题的教训不是「别用代理层」——网关有它存在的理由。而是:
凡是经过任何中间层的请求,都必须在最终响应上验证缓存生效,而不是相信自己的代码写了什么。
这就引出了最后一节。
怎么自检:两个脚本#
脚本一:前缀稳定性#
在不花任何 API 费用的前提下,先验证「我构建的前缀是不是每次都一样」。
import hashlib, json
def prefix_fingerprint(build_request) -> str:
"""把请求里参与缓存前缀的部分序列化后取哈希。
只包含断点之前的内容:tools、system,以及历史消息。
"""
req = build_request()
prefix = {
"tools": req.get("tools", []),
"system": req.get("system", ""),
"messages": req.get("messages", [])[:-1], # 最后一条是本轮输入,不算前缀
}
blob = json.dumps(prefix, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
# 连续构建若干次,指纹必须完全一致
prints = {prefix_fingerprint(build_my_request) for _ in range(20)}
if len(prints) > 1:
raise SystemExit(f"前缀不稳定,出现 {len(prints)} 种指纹:{prints}")
print("前缀稳定 ✓")
注意这里我故意用了 sort_keys=True——它衡量的是「语义上是否一致」。如果这个脚本通过了,但真实命中率仍然为零,那说明问题出在你的序列化路径上(顺序抖动),而不是内容上。这个区分能直接把排查范围砍半。
脚本二:真实命中率#
三家的 usage 字段名都不一样,而且都有同一个陷阱:input_tokens 只表示「既没命中也没写入」的那部分,不是总输入。
def cache_stats(usage: dict, provider: str) -> dict:
"""从各家 usage 里抽出统一的三个数。"""
if provider == "anthropic": # 一方 API / Vertex / Azure Foundry 的 Claude
read = usage.get("cache_read_input_tokens", 0)
write = usage.get("cache_creation_input_tokens", 0)
fresh = usage.get("input_tokens", 0)
elif provider == "bedrock": # Converse / ConverseStream
read = usage.get("cacheReadInputTokens", 0)
write = usage.get("cacheWriteInputTokens", 0)
fresh = usage.get("inputTokens", 0)
elif provider == "openai": # Responses API;Chat Completions 用 prompt_tokens_details
d = usage.get("input_tokens_details", {})
read = d.get("cached_tokens", 0)
write = d.get("cache_write_tokens", 0)
fresh = usage.get("input_tokens", 0)
elif provider == "gemini":
read = usage.get("total_cached_tokens", 0) or usage.get("cachedContentTokenCount", 0)
write = 0 # Gemini 不单独计缓存写入
fresh = usage.get("promptTokenCount", 0) - read
else:
raise ValueError(provider)
total = read + write + fresh
return {
"total_input": total,
"hit_ratio": round(read / total, 3) if total else 0.0,
"read": read, "write": write, "fresh": fresh,
}
拿它跑一段真实流量,看三种信号:
read 恒为 0 且 write 恒为 0 → 压根没缓存。最可能是前缀没达到最小 token 数。别照抄文档里的门槛数字——我在 Bedrock 上实测过,Claude Sonnet 4.5 的真实门槛是 1,024 而文档写 4,096,差 4 倍。自己验一次只要两个请求,方法和四个模型的完整数据在实测篇。想先量自己 prompt 有多少 token,用 token 计数器。
write 持续高而 read 持续低 → 前缀每次都在变。这是元凶一到三的典型特征,先跑脚本一定位是内容问题还是序列化问题。
本地构建正确但 read 为 0 → 高度怀疑中间层。把代理层摘掉直连一次,对比同一请求的 usage。
如果你用 Anthropic 一方 API,还有个官方工具#
Anthropic 提供了 Cache Diagnostics(beta,请求头 cache-diagnosis-2026-04-07):让 API 自己比较相邻两次请求,直接告诉你前缀在哪一块分岔,并给出类型化的失效原因——model_changed、system_changed、tools_changed、messages_changed、previous_message_not_found,以及归入 unavailable 的其他参数变化。
这基本上把上面的排查工作自动化了。但文档写明了限制:Claude API only,Bedrock 和 Google Cloud 都不支持。
所以有个略讽刺的局面:门槛最高、最容易静默失败的平台,恰好是拿不到诊断工具的平台。 在 Bedrock 和 Vertex 上,脚本二就是你唯一的眼睛。
防御清单#
按投入产出排序:
结构上 把静态内容全部前置:工具定义、system prompt、参考文档。时间戳、session ID、用户输入放在缓存断点之后。只用于日志的动态值放进 request metadata。
序列化上
工具列表发出前按 name 排序。Python 用 sort_keys=True。Go 别手动遍历 map 拼 JSON,用 encoding/json(v1),若用 v2 显式开 Deterministic。Swift 显式设 .sortedKeys。
配置上
固定 tool_choice、thinking 配置、effort、结构化输出 schema。这些看着像参数,实际上进了 prompt。评估 compaction 时算一下:省下的 token 是否多于丢掉缓存的代价。
监控上
把脚本二的三个数打进指标。read 恒零就告警——这是唯一能让静默失败发出声音的办法。
保温上
如果确实需要 ping,用官方预热形式(Anthropic 是 max_tokens: 0,输出不计费)。间隔要小于 TTL,且要考虑「TTL 从请求开始计时、流式输出时间计入」这条规则。顺带一提,Aider 是最早公开实现缓存保温的工具,它用的间隔是 5 分钟,正好等于 Anthropic 的 TTL——在长流式响应场景下这个间隔有越界风险,越界后每次 ping 都会变成一次全价重算。
小结#
缓存失效不报错,所以它需要被监控,而不是被相信。 代码写了 cache_control 不等于缓存生效,中间层可能已经删掉了它。
四类元凶按频率: 前缀混进易变内容、序列化顺序抖动、工具顺序不稳、中间层静默吞标记。
两个脚本能覆盖绝大多数排查:前缀指纹判断是否稳定,usage 三数判断是否真的命中。前者不花钱。
厂商文档也会点错名。 Anthropic 说 Go 会随机化 key 顺序,但标准库 v1 对 map 是排序的——真正的风险在手动拼 JSON、v2 默认值和无序来源。查这类问题时,官方排错清单是线索,不是结论。
这个系列到这里结束:第一篇讲原理,第二篇比平台账单,这篇讲落地排查。所有数据都是 2026 年 8 月中旬的当期官方文档——而这个领域的定价和门槛变得比文章过期还快,用之前请自己核一遍。
参考资料
- Prompt caching — Troubleshooting common issues — Anthropic 官方文档
- Cache diagnostics — Anthropic 官方文档
- Prompt caching — Troubleshoot common caching issues — OpenAI 官方文档
- encoding/json — Go 官方包文档
- The Go Programming Language Specification — For statements with range clause — Go 语言规范
- Caching with Aider — Aider 官方文档
常见问题
缓存没命中会报错吗?
这个现象叫「缓存穿透」吗?
Go 的 JSON 序列化真的会打乱 key 顺序吗?
微信扫码
用微信扫一扫,把文章带到聊天或朋友圈。
继续阅读
- AI 与 Agent
怎么向你老婆解释什么是 Agent?
从'飞书机器人算不对数'到'养一只自己的 AI 龙虾'——一篇给普通人看的智能体科普。13 个灵魂拷问讲透 LLM、Token、Tools、MCP、RAG、Skills、Memory、Multi-Agent 和 2026 年的模型价格:AI 不是蠢,是还没养好。
- AI 与 Agent
如何用 LangChain 和 Elasticsearch 构建 RAG 系统
从零开始构建检索增强生成(RAG)的实战指南——从向量嵌入到上下文增强的 LLM 答案。
- 技术深潜
AI 编程 Agent 究竟是如何工作的:一次源码深度剖析
我们追踪了 Amazon Q CLI 和 Claude Code 的源代码,深入理解 AI 编程 Agent 底层的真实运作方式。
- 编码工具
如何用 Python 构建 AI 视频课程生成器
借助 LLM、文字转语音和 FFmpeg,将 PowerPoint 幻灯片全自动转换为带旁白的视频课程。