# 提示缓存（三）：开了却没命中，怎么查

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

- 作者: zhuermu
- 发布: 2026-08-17
- 网页版: https://zhuermu.com/blog/prompt-cache-3-debugging-cache-misses/

---
**先说结论。**

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

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

1. **前缀里混进了易变内容**——时间戳、session ID、上下文压缩。最常见，也最好修。
2. **序列化顺序抖动**——同样的数据结构，两次序列化出的字节不同。
3. **工具列表顺序不稳**——`tools` 属于前缀，而它的顺序常常由文件系统或线程调度决定。
4. **中间层静默吞掉缓存标记**——你写了 `cache_control`，代理层删了。最阴的一类。

如果你手上已经有一个疑似不命中的服务，直接跳到最后一节的自检脚本，五分钟能得到答案。

前两篇分别讲了[第一篇](/blog/prompt-cache-1-how-it-works/)和[第二篇](/blog/prompt-cache-2-platform-comparison/)。这篇是落地排查。

## 先把术语校准一下

中文技术圈很容易把这个现象叫「缓存穿透」，但两者机制完全不同：

| 术语 | 含义 | 场景 |
|---|---|---|
| **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 那一行是最容易踩的，因为它看起来很安全：

```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 侧的对照：

```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 排一次序再发出去。**

```python
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 费用的前提下，先验证「我构建的前缀是不是每次都一样」。

```python
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` 只表示「既没命中也没写入」的那部分，不是总输入。**

```python
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 倍。自己验一次只要两个请求，方法和四个模型的完整数据在[实测篇](/blog/prompt-cache-4-bedrock-threshold-test/)。想先量自己 prompt 有多少 token，用 [token 计数器](/tools/token-counter/)。

**`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 默认值和无序来源。查这类问题时，官方排错清单是线索，不是结论。

这个系列到这里结束：[第一篇](/blog/prompt-cache-1-how-it-works/)讲原理，[第二篇](/blog/prompt-cache-2-platform-comparison/)比平台账单，这篇讲落地排查。所有数据都是 2026 年 8 月中旬的当期官方文档——而这个领域的定价和门槛变得比文章过期还快，用之前请自己核一遍。

---

## 常见问题

### 缓存没命中会报错吗？

不会。前缀不匹配就是一次 cache miss，推理照常成功、结果照常正确，只是按全价计费并重新写入缓存。前缀太短则连缓存都不写，同样不报错。唯一的检测手段是读响应的 usage 字段。

### 这个现象叫「缓存穿透」吗？

不叫。准确说法是 cache miss 或缓存失效（invalidation）。数据库领域的缓存穿透指查询一个根本不存在的 key、请求穿过缓存层打到数据库，机制完全不同。

### Go 的 JSON 序列化真的会打乱 key 顺序吗？

标准库 v1 不会——encoding/json 对 map 是排序输出的，对 struct 按字段声明顺序。真正不稳定的是手动遍历 map 拼 JSON（Go 规范明确不保证 map 迭代顺序）、以及 encoding/json v2 默认的非确定性顺序。


---

## 参考资料

- [Prompt caching — Troubleshooting common issues](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) — Anthropic 官方文档
- [Cache diagnostics](https://platform.claude.com/docs/en/build-with-claude/cache-diagnostics) — Anthropic 官方文档
- [Prompt caching — Troubleshoot common caching issues](https://platform.openai.com/docs/guides/prompt-caching) — OpenAI 官方文档
- [encoding/json](https://pkg.go.dev/encoding/json) — Go 官方包文档
- [The Go Programming Language Specification — For statements with range clause](https://go.dev/ref/spec#For_statements) — Go 语言规范
- [Caching with Aider](https://aider.chat/docs/usage/caching.html) — Aider 官方文档
