提示缓存(三):开了却没命中,怎么查

提示缓存失效几乎不报错——推理成功、日志干净、只有账单变贵。本文按出现频率拆四类元凶:前缀混进易变内容、序列化顺序抖动、工具顺序不稳、中间层静默吞掉缓存标记,并给出一个跨厂商的命中率自检脚本。

zhuermu··23 分钟阅读

先说结论。

提示缓存失效几乎从不报错。推理成功、结果正确、日志干净,只有账单在涨。这是它最难查的地方,也是唯一需要你主动建立监控的地方。

按我梳理下来的出现频率,元凶分四类:

  1. 前缀里混进了易变内容——时间戳、session ID、上下文压缩。最常见,也最好修。
  2. 序列化顺序抖动——同样的数据结构,两次序列化出的字节不同。
  3. 工具列表顺序不稳——tools 属于前缀,而它的顺序常常由文件系统或线程调度决定。
  4. 中间层静默吞掉缓存标记——你写了 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.devencoding/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 listMCP 协议没有规定 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_changedsystem_changedtools_changedmessages_changedprevious_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 月中旬的当期官方文档——而这个领域的定价和门槛变得比文章过期还快,用之前请自己核一遍。

参考资料

  1. Prompt caching — Troubleshooting common issues — Anthropic 官方文档
  2. Cache diagnostics — Anthropic 官方文档
  3. Prompt caching — Troubleshoot common caching issues — OpenAI 官方文档
  4. encoding/json — Go 官方包文档
  5. The Go Programming Language Specification — For statements with range clause — Go 语言规范
  6. Caching with Aider — Aider 官方文档

常见问题

缓存没命中会报错吗?
不会。前缀不匹配就是一次 cache miss,推理照常成功、结果照常正确,只是按全价计费并重新写入缓存。前缀太短则连缓存都不写,同样不报错。唯一的检测手段是读响应的 usage 字段。
这个现象叫「缓存穿透」吗?
不叫。准确说法是 cache miss 或缓存失效(invalidation)。数据库领域的缓存穿透指查询一个根本不存在的 key、请求穿过缓存层打到数据库,机制完全不同。
Go 的 JSON 序列化真的会打乱 key 顺序吗?
标准库 v1 不会——encoding/json 对 map 是排序输出的,对 struct 按字段声明顺序。真正不稳定的是手动遍历 map 拼 JSON(Go 规范明确不保证 map 迭代顺序)、以及 encoding/json v2 默认的非确定性顺序。
分享这篇文章 微博 X LinkedIn
微信扫码

用微信扫一扫,把文章带到聊天或朋友圈。

继续阅读