Prompt Caching 为什么总不命中?我把 4 家 LLM API 的缓存规则拆开了

0 阅读9分钟

TL;DR:你以为自己“复用同一个 system prompt”,其实每次请求的 prefix 并不相同。Prompt caching 的命中条件比大多数人想的更苛刻:严格前缀、字节级一致、还要过最小 token 门槛 + TTL

我最近在做多模型接入(同一套工具/指令,要在不同厂商模型间切换),结果 prompt caching 的命中率非常飘:有时省一大截,有时完全不命中。

最烦的是:不命中也不会报错——你只是在账单上默默多付了一次“prefill 税”。

这篇我不讲“省钱攻略”,只讲一件更工程的问题:为什么同样的 system prompt 会 cache miss? 以及:不同厂商到底在缓存什么、怎么判定命中、哪些细节会把缓存打碎。

(封面图在本地:./images/cover.png

先把概念说清楚:Prompt Caching 缓存的不是文本,而是 KV Cache

Prompt caching 的本质是复用“prefill”阶段的中间状态。

  • 第一次请求:模型把你的 prompt 前缀读一遍,算出注意力层里的 key/value 张量(KV cache)。
  • 第二次请求:如果“前缀完全一致”,服务端直接把这段 KV cache 读出来,跳过重复计算。

所以它缓存的不是“你发过的文字”,而是“模型已经算过的那一段”。这也是为什么:哪怕你只改了前缀里一个空格/一个工具字段顺序,都可能导致 cache miss

你以为的“同一个 prompt”,在 API 看来经常不一样

我见过最多的误区就两个:

  1. “system 字段一样就行”

不一定。很多 SDK 会在你看不见的地方帮你补字段、重排 JSON、或者把 tools/response_format 变成另一种序列化形式。

  1. “我只是追加了新消息,前缀当然一致”

也不一定。某些平台把 system、tools、messages 合并成一个内部序列列;只要你动了前缀区间内的任何结构(顺序、分隔、甚至空字符串),就不是同一个前缀

如果你现在正在 debug “为什么缓存不命中”,建议你先做一件很朴素的事:

  • 把请求体(JSON)完整 dump 出来
  • 只截取“你希望被缓存的前缀部分”
  • 计算 hash(比如 sha256)

你会很快发现:两次请求的 prefix hash 其实不同。

四家 API 的缓存行为:我关心的是 4 个维度

我用同一套问题拆它们:

  • 缓存 key:到底按什么判定“前缀一致”?
  • 最小 token 门槛:多短的前缀根本不会触发缓存?
  • TTL:缓存活多久?是否能延长?
  • 可观测性:我怎么知道命中没命中?

下面这张表是我整理的“工程视角对比”。注意:不同厂商细节会变,我更看重“它的设计倾向”。

Provider命中判定最小门槛(常见)TTL(常见)可观测性/信号
某海外大模型厂商前缀 byte-for-byte(内容块可标 cache_control)Sonnet 4.6 常见 2048;Opus/Haiku 常见 4096(按公开资料)5 分钟为主;可选 1 小时(更高写入成本)usage 里可拿到 cache_creation / cache_read token
某海外大模型厂商多为自动缓存(以 prefix 为主),你不一定能完全控制常见 1024 起(按公开资料)5–10 分钟常见;部分模型可更长(按公开资料)账单/usage 字段(不同接口差异大)
DeepSeek有缓存能力(具体接口/字段随版本变化)依文档而定依文档而定需要看 response 的 usage/计费字段
某海外模型有缓存/上下文复用能力(形态更偏“上下文对象”)依文档而定依文档而定需要看 SDK/返回字段

我这篇会重点讲 某海外大模型厂商,因为它的机制最“显性”,很多坑也最典型:你把它搞明白,再看其它家会容易很多。

某海外大模型厂商:cache_control 不难,加错位置才要命

某海外大模型厂商 的 prompt caching 很工程化:你可以在内容块上打 cache_control: { type: "ephemeral" },让系统把“某个位置之前的前缀”当成缓存边界。

关键点 1:它要求前缀严格一致(byte-for-byte)

也就是说下面这种“看起来一样”的改动,都可能把缓存打碎:

  • tools 数组顺序变了
  • system prompt 里多了一个换行
  • 你把 JSON 序列化从 \n 变成了真正的换行
  • 你把一个字段从 null 变成了 ""

关键点 2:有最小 token 门槛

公开资料里提到(以 Sonnet 4.6 为例)前缀小于 2048 token 时,调用会成功,但 cache_creation_input_tokens 可能是 0 —— 等于你以为自己开了缓存,其实完全没生效。

Python 示例:把“稳定前缀”固定成一个块

下面这个写法的意图很明确:

  • system prompt 是稳定前缀 → 让它可缓存
  • user 的问题是变化尾巴 → 不缓存
import os
# import (sdk omitted)

client = anthropic.某海外大模型厂商(api_key=os.environ["API_KEY"])

SYSTEM_PROMPT = """你是一个资深后端工程师。回答要给出可落地的工程建议。\n
你会收到:系统约束、工具列表、以及用户问题。\n
要求:
- 输出中文
- 给出结论 + 原因 + 可操作清单
""".strip()

def ask(question: str):
    msg = client.messages.create(
        model="deepseek-reasoner",
        max_tokens=800,
        system=[{
            "type": "text",
            "text": SYSTEM_PROMPT,
            # 让这段成为缓存边界的一部分
            "cache_control": {"type": "ephemeral"},
        }],
        messages=[{"role": "user", "content": question}],
    )

    usage = getattr(msg, "usage", None)
    # 不同 SDK 版本字段名可能略有差异,这里只展示思路
    print("usage:", usage)
    return msg.content[0].text

print(ask("解释一下 prompt caching 为什么会不命中,给我排查清单"))

这段代码不是为了教你“怎么开缓存”(文档都有),而是为了强调一个工程习惯:

  • 稳定前缀要写成稳定的数据结构(别在不同调用里拼字符串)
  • 序列化方式要固定(尤其是 JSON/换行/空格)

Node.js 示例:把 tools 定义也固定住

工具定义(tools)往往比 system prompt 更容易“悄悄变动”。

我推荐你把它做成一个常量文件,避免每次启动时动态生成。

import 某海外大模型厂商 from "(某海外 SDK)";

const client = new 某海外大模型厂商({ apiKey: process.env.API_KEY });

const SYSTEM = "你是资深后端工程师,回答要给出工程建议。";

const TOOLS = [
  {
    name: "lookup_cache_stats",
    description: "查询缓存命中统计",
    input_schema: {
      type: "object",
      properties: { key: { type: "string" } },
      required: ["key"],
    },
  },
];

export async function ask(question) {
  const res = await client.messages.create({
    model: "deepseek-reasoner",
    max_tokens: 600,
    system: [
      { type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } },
      // 也可以把 tools 描述作为可缓存内容块(视你是否把 tools 放 system 里)
    ],
    tools: TOOLS,
    messages: [{ role: "user", content: question }],
  });

  console.log("usage", res.usage);
  return res.content?.[0]?.text;
}

那个 5 分钟 TTL 的坑:为什么你在生产里突然“变贵了”

我最近看到一个很典型的事故分析:某些平台把 prompt cache 的 TTL 从 60 分钟下调到了 5 分钟。

它带来的工程后果是:

  • 以前“每隔 10 分钟跑一次”的任务可以复用缓存
  • 现在每次都要重新写 cache,甚至还要付写入溢价

如果你的 workload 是“间歇式”的(cron、定时批处理、用户会话中断),你会感到缓存极其不稳定。

这时候别硬凹“我要命中缓存”,更现实的做法是:

  • 只缓存真正大的前缀(长文档/工具目录)
  • 把请求合并(batch)
  • 或者用 keepalive/ping 保温(如果你的平台允许且算得过账)

下面给一个简化的 keepalive 思路(仅供参考,别在没算账的情况下上线):

import time
import threading

def keepalive(ping_fn, every_seconds=240):
    def run():
        while True:
            time.sleep(every_seconds)
            try:
                ping_fn()
            except Exception:
                pass
    t = threading.Thread(target=run, daemon=True)
    t.start()

cache miss 的排查清单(我自己用的)

我把它拆成 6 步,基本能定位 90% 的 miss:

  1. 确认你缓存的内容足够长:有没有过最小 token 门槛?
  2. 确认前缀顺序稳定:system、tools、messages 的拼接顺序是否固定?
  3. 确认序列化稳定:换行、空格、JSON key 顺序是否一致?
  4. 确认 TTL:两次请求间隔是否超过 TTL?
  5. 确认模型/区域:同一个模型 ID?同一个 region?(有些实现是按后端实例缓存)
  6. 看 usage:有没有 cache_read_* 的 token?如果一直是 0,别自我安慰。

多模型时代的工程建议:把“稳定前缀”抽成一个可复用层

当你同时接 3-5 家模型时,缓存问题会被放大:

  • 你想共享同一套 system prompt / tools
  • 但每家 API 的字段形态都不一样

我的做法是把“稳定前缀”抽象成一个中间层:

  • 先把 system + tools + 固定上下文,变成一种“规范化结构”(canonical form)
  • 再由不同 provider 的 adapter 去序列化

这样你至少能保证:同一家 provider 的两次请求,prefix 是可控且可复现的。

(我自己做多模型统一调用时,用的就是 API 网关把不同模型的请求层收敛到一个形态里。这个点我只提一次,不展开。)

常见问题(FAQ)

Q:Prompt Caching 和我自己在业务里做 Redis 缓存有什么区别?

A:Redis 缓存的是“模型输出结果”,命中条件通常是你自己定义的 key;Prompt caching 缓存的是“模型 prefill 的中间状态(KV cache)”,命中条件是服务端定义的严格前缀一致。两者可以叠加,但解决的问题不同。

Q:我只缓存 system prompt 有意义吗?

A:有,但前提是它足够长且足够稳定。短 system prompt 往往过不了最小 token 门槛;频繁变动的 system prompt(比如动态拼接用户画像)会让缓存形同虚设。

Q:为什么我把同样的内容放在 messages[0] 里,缓存反而更差?

A:因为你不一定控制得住 messages 的“前缀稳定性”:消息数组顺序、role、分隔符、甚至 SDK 的内部序列化策略,都可能让“看起来一样”的内容变成不同的前缀。稳定前缀更适合放在专门的 system/content 块里。

一个更“阴险”的 miss:你换了 SDK 版本

这事我踩过一次,属于那种“你不写单测永远发现不了”的坑。

同样的业务代码,只是把 SDK 从一个小版本升级到另一个小版本,缓存命中率直接从很稳定变成几乎 0。原因通常不是模型变了,而是 请求体序列化的细节变了

  • JSON 字段顺序变化(你以为不影响,其实服务端可能把它当 byte 序列)
  • tools/schema 的默认值补齐策略变化(比如 additionalPropertiesrequired 的生成)
  • SDK 对换行/转义的处理变化

所以我的建议是:

  • 把“可缓存前缀”的生成做成一个独立函数,并输出 hash
  • 在 CI 里放一个非常便宜的断言:同一份配置生成的 prefix hash 必须稳定

这一步不一定能保证 100% 命中(因为 TTL/后端实例也会影响),但至少能保证:缓存 miss 不是你自己把前缀玩坏了。