LLM 的 KV Cache:一个时间戳,能让你的账单比不开缓存还贵

Posted on 一 31 8月 2026 in AI

Abstract LLM 的 KV Cache 原理、命中规则与工程实践
Authors Walter Fan
Category AI
Status v1.0
Updated 2026-08-31
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 核心结论:KV Cache 不是省钱技巧,是提示词的架构约束——命中要求前缀逐 token 一致,这条要求反过来决定了你怎么排 system prompt、工具定义和历史消息。
  • 先分清两个 KV Cache:单次请求内的(引擎默认干的,你管不着)和跨请求的 prefix caching(账单上那个,你说了算)。
  • 命中规则:从第 0 个 token 起的精确前缀匹配、块粒度、最小长度门槛、TTL、路由亲和。四家厂商的具体数字列了张表。
  • 省钱之外:首字延迟(TTFT)、GPU 显存与吞吐、长上下文 Agent 的可行性。
  • 一笔能对的账:20 轮 Agent 会话,开缓存省 82%;但前缀带秒级时间戳时,反而比不开缓存贵 25%。
  • 真实数据:我扫了自己四个 harness 最近 30 天的日志——怀疑的对象是清白的(88% 命中);而那个吓人的低命中率是没埋点造成的假象,我还因为统计粒度不对,被同一个坑绊了两次。附可直接运行的脚本。
  • 最佳实践:分层排版、确定性序列化、mask 而非 remove、append-only、断点放哪、路由亲和、把命中率做成指标。
  • 常见陷阱:把 RAG 结果放前面、动态工具集、摘要压缩重写历史、盲目上 1h TTL、多租户缓存串味、以及"以为缓存等于模型记住了"。
  • 自查清单:一张能直接抄走的表。

我先说个让人不太舒服的算术。

假设你在跑一个 Agent:system prompt 加工具定义一共 12K token,每轮对话追加 1K,一次任务跑 20 轮。用 Claude Sonnet 的价目表算(输入 $3/MTok,缓存读 $0.30/MTok,5 分钟缓存写 $3.75/MTok):

  • 不开缓存:43 万输入 token,$1.29
  • 开缓存且前缀稳定:$0.236,省掉 82%
  • 开了缓存,但 system prompt 顶部有个精确到秒的时间戳:$1.61

看清楚第三行。你老老实实打了 cache_control,结果比什么都不做还贵了 25%。因为每轮前缀都变,你每轮都在付 1.25 倍的缓存写入费,却一次都没读到过。

这不是玄学,是算术:缓存写比正常输入贵,缓存读比正常输入便宜。你只在"写一次、读多次"的时候赚钱。前缀一不稳,你就只写不读,纯亏。

所以这篇文章想让你带走的那句话是:

KV Cache 不是一个能顺手薅的省钱技巧,它是一条架构约束——它规定了你的提示词必须怎么排版。

省 token 只是遵守这条约束之后的副产品。真正的变化是,你以后组织 system prompt、工具定义和对话历史时,得像维护一个对外 API 一样谨慎:前缀是接口,改它是破坏性变更。

关于 token 成本的整体治理思路,我在 《LLM API 越来越贵,别让 token 像自来水一样哗哗流》《上下文管理之渐进式披露》 里写过。这篇专门啃 KV Cache 这一块硬骨头。


一、先把两个 "KV Cache" 分开

这是最容易混的一点,不分清后面全是糊涂账。"KV Cache" 这四个字在两个层面上被使用,含义完全不同。

1. 请求内的 KV Cache:推理引擎默认就干的事

Transformer 生成文本是自回归的:吐第 N 个 token 时,要让它跟前面 N-1 个 token 做注意力计算。注意力的核心是三个东西——Query(我在找什么)、Key(每个位置的索引)、Value(每个位置的内容)。

关键在于:已经生成的 token,它的 K 和 V 是不变的。 第 5 个 token 的 Key 向量,在生成第 6 个、第 60 个、第 600 个 token 时,永远是同一个值。

那还每次重算干嘛?存下来就行了。这就是 KV Cache 最原始的意思——把每一层、每个位置算出来的 K 和 V 缓存在显存里,生成下一个 token 时直接查表。

省了多少?没有它,生成 N 个 token 的注意力计算是 O(N²);有了它,每步只算新 token 的那一份,降到 O(N)。这是所有推理引擎(vLLM、SGLang、TensorRT-LLM,以及所有云 API 背后)的默认行为,不需要你做任何事。

代价是显存。每个 token 要存多少字节,有个很好记的公式:

每 token 的 KV 字节数 = 2 × 层数 × KV头数 × 每头维度 × 每元素字节数
                        ↑
                    K 和 V 各存一份

拿 Llama 3.1 70B 举例(80 层,GQA 下 8 个 KV 头,每头 128 维,BF16 即 2 字节):

2 × 80 × 8 × 128 × 2 = 327,680 字节 ≈ 320 KB / token

32K 上下文的一个请求,光 KV Cache 就吃掉 10.7 GB 显存。如果这模型用的是老式的 MHA(64 个 KV 头),这个数字要乘 8——86 GB,一张 H100 装不下一个请求。

这就是为什么现在的模型几乎全部改用 GQA(多个 Query 头共享一组 KV 头)或者 MLA(把 KV 压缩到低维隐空间)。不是为了跑得快,是为了跑得起。 DeepSeek V3 用 MLA 把每 token 压到约 50 KB,32K 上下文只要 1.6 GB,671B 的模型才有可能开 128K 窗口。

2. 跨请求的 Prefix Caching:账单上那个

请求内的缓存,请求一结束就该释放了。但工程师很快发现一件事:不同请求的开头,往往长得一模一样。

你的 Agent 有 12K token 的 system prompt 加工具定义,一天调用十万次——这 12K token 的 K/V,每次都要从头算一遍 prefill,纯属浪费。

于是有了 prefix caching(Anthropic 和 OpenAI 叫 prompt caching,DeepSeek 叫上下文硬盘缓存):把已经算好的前缀 KV 存起来,下次来一个请求,如果开头能对上,直接复用。

vLLM 的实现很值得一看,因为它把命中条件讲得最直白。它把 KV Cache 切成固定大小的 block,每个 block 用一个哈希标识:

block_hash = hash(父block的哈希, 本block的token序列, 额外因子)
                    ↑                                   ↑
              绑定了整条前缀链              LoRA id / 多模态输入 / cache_salt

三件事从这个公式里直接能读出来:

  1. 哈希链绑定了整条前缀。 第 3 个 block 的哈希包含第 2 个的哈希,第 2 个包含第 1 个的。任何一个 token 变了,从它往后的所有 block 全部作废。
  2. 只缓存完整的 block。 半个 block 不算数,所以命中是有粒度的,不是逐 token 的。
  3. 额外因子决定了隔离边界。 多租户环境里,vLLM 支持 cache_salt——加了盐,只有同盐的请求才能复用同一份 KV。这个设计不是为了性能,是为了安全,后面会讲。

淘汰策略是 LRU:显存不够时,先赶引用计数为 0 的、最久没用的 block。

这一层才是你能控制、也必须控制的。 后面说的"命中",全指这一层。


二、命中规则:五个必须同时满足的条件

写代码之前,先把游戏规则背下来。

规则 1:从第 0 个 token 起的精确前缀匹配

这是最硬的一条。缓存匹配的是从头开始、连续不断的一段 token,不是"内容相似",也不是"包含关系"。

上次请求:[你是一个代码审查助手][工具定义...][用户: 看看这段 Go]
本次请求:[你是一个代码审查助手][工具定义...][用户: 看看这段 Python]
                ↑─────────── 命中 ───────────↑  ↑─ 从这里开始重算 ─↑

上次请求:[2026-09-01 21:30:15][你是一个代码审查助手][工具定义...]
本次请求:[2026-09-01 21:30:47][你是一个代码审查助手][工具定义...]
           ↑ 第一个 token 就不一样 → 后面全部重算,一个字都没省

第二个例子就是那个经典的自伤操作。Manus 团队在他们的 Agent 工程复盘里点名批评过:在 system prompt 开头放精确到秒的时间戳,"确实能让模型知道现在几点,但也顺手杀死了你的缓存命中率"。

规则 2:有最小长度门槛,短 prompt 根本不缓存

低于门槛,你标什么都没用,请求会被当成没有缓存处理。各家的门槛不一样:

厂商 最小可缓存长度 缓存读价格 缓存写价格 有效期
OpenAI(GPT-5.6+) 1,024 token 0.1× 输入价 1.25× 输入价 闲置 5–10 分钟清除,最长 1 小时
OpenAI(更早的模型) 2,048 token 约 5 折 不额外收费 同上
Anthropic Claude(Opus/Sonnet) 1,024 token 0.1× 输入价 1.25×(5 分钟)/ 2×(1 小时) 5 分钟或 1 小时,每次命中自动续期
Anthropic Claude(Haiku) 2,048 token 0.1× 输入价 同上 同上
Gemini 2.5 Flash / Pro 2,048 token 约 0.1× 输入价 隐式缓存不额外收费 由平台管理
Gemini 3.x 系列 4,096 token 同上 同上 同上
DeepSeek 自动,无需声明 约 1/10 输入价 不额外收费 数小时到数天,best-effort

(数字以各家官方文档为准,且变动频繁——这张表的意义是让你知道要去查哪几个数,别拿它当长期依据。)

有两个门道值得单说:

  • OpenAI 的命中是 128 token 步进的。 门槛 1024 之后,实际命中数只会是 1024、1152、1280…… 所以你的稳定前缀是 1100 token 的话,只有 1024 个能命中,剩下 76 个白算。
  • Anthropic 是两段计价。 写比正常输入贵,读比正常输入便宜。5 分钟档写 1.25×、读 0.1×,意味着命中一次就回本;1 小时档写 2×,要命中两次才回本。这个账后面还要算。

规则 3:TTL 会到期,而且是静默的

缓存不是永久的。Anthropic 默认 5 分钟(每次命中自动续期),OpenAI 大约闲置 5–10 分钟清除、最长一小时内必删,DeepSeek 是几小时到几天不等且明确说了是 best-effort。

麻烦在于过期不会报错。你的请求照样成功返回,只是这次全价。所以你必须自己盯着响应里的字段,不然亏钱亏得悄无声息。

规则 4:得路由到同一台机器

云 API 会尽量把带相同前缀的请求路由到最近处理过它的服务器上。自建的话,这事得你自己管——vLLM 集群里,同一个会话的请求必须落到同一个 worker,缓存才在那儿。做法通常是按 session id 做一致性哈希路由。

忘了这一步,你会看到一个诡异现象:本地单机测试命中率 90%,上线之后掉到 20%。 不是代码错了,是请求被负载均衡打散了。

规则 5:序列化必须逐字节确定

这条最阴,因为它看起来根本不像 bug。

# 危险:字典的 key 顺序在某些场景下不保证稳定,
# 而且 json.dumps 默认会在逗号后加空格
payload = json.dumps(tool_schema)

# 安全:固定 key 顺序、固定分隔符、关掉 ASCII 转义
payload = json.dumps(tool_schema, sort_keys=True,
                     separators=(",", ":"), ensure_ascii=False)

内容一模一样,序列化出来差一个空格,token 序列就不同,缓存就没了。跨语言、跨版本的序列化差异更是重灾区——vLLM 甚至专门提供了 sha256_cbor 哈希算法,就是为了保证跨语言、跨版本可复现。


三、省 token 之外,你还赚到什么

如果只盯着账单,你会低估这件事。

首字延迟(TTFT):用户能直接感觉到

缓存命中省掉的是 prefill 阶段的计算。prefill 就是模型"读完你的提示词"的那段时间,用户看到的"转圈圈"主要就是它。

2026 年 1 月有篇专门测这个的论文(Don't Break the Cache: An Evaluation of Prompt Caching for Long-Horizon Agentic Tasks,arXiv:2601.06007),在 DeepResearch Bench 上跑了 500 多个 Agent 会话、10K token 的 system prompt,横跨 OpenAI、Anthropic、Google 三家。结论是:成本降低 41–80%,TTFT 改善 13–31%。

13–31% 什么概念?一个 3 秒的首字延迟压到 2.2 秒,用户是能感觉到"这个应用变利索了"的。而且这个收益不用改模型、不用加机器。

显存和吞吐:自建推理的隐藏收益

如果你自己跑 vLLM,prefix caching 的收益是双份的:

  • 算力:命中的部分不用重算 prefill,GPU 算力腾出来给别的请求
  • 显存:共享同一前缀的多个请求,映射到同一批物理 KV block,不用各存一份

一百个并发会话共享同一个 12K 的 system prompt,只占一份显存而不是一百份。这直接决定了你的单卡并发上限。

长上下文 Agent 才变得可行

Manus 团队给了个数字:他们的 Agent 平均输入输出比是 100:1。也就是说,Agent 干活时读的远比写的多,而且读的大部分是同样的东西——同一套 system prompt、同一批工具定义、同一段越滚越长的历史。

在这种负载下,缓存命中率不是优化项,是生死线。所以他们把话说得很重:

KV-cache 命中率是生产级 AI Agent 最重要的单一指标。

不是准确率,不是延迟,是一个缓存指标。第一次看到这句我也觉得夸张,算完那笔 20 轮的账之后就理解了——对一个多轮 Agent 来说,前缀的重复读取占了成本的绝大部分,别的优化再努力也是在小头上抠。

最后一个收益:它逼你把上下文管好

这是我最看重的一条,也是文章开头那句话的意思。

为了让缓存命中,你不得不做这几件事:把不变的东西和变的东西分开、给上下文定一个稳定的排列顺序、不许中途改历史、序列化必须确定。

这些事,就算没有缓存,也是对的。 一个 system prompt 里到处塞动态变量的 Agent,本来就难调试、难复现、难 review。缓存只是给了你一个硬性的、能量化的理由去做正确的事——而且这个理由每天都会出现在账单上。


四、这笔账到底怎么算

回到开头那个例子,把过程摊开。场景:system prompt + 工具定义 12K token,每轮追加 1K,共 20 轮,Claude Sonnet 价目表。

情况 A:不开缓存

每轮输入是累积的:第 1 轮 12K,第 2 轮 13K…… 第 20 轮 31K。

总输入 = 20 × 12,000 + 1,000 × (0+1+...+19) = 430,000 token
成本   = 0.43 MTok × $3 = $1.29

情况 B:开缓存,前缀稳定,append-only

43 万 token 里,独一无二的只有 31,000 个(12K 底座 + 19 轮追加),剩下 399,000 全是重复读取。

写入 = 31,000  × $3.75 / 1M = $0.116
读取 = 399,000 × $0.30 / 1M = $0.120
合计 = $0.236          ← 相比 A 省 82%

情况 C:开缓存,但 system prompt 顶部有秒级时间戳

每轮前缀都不一样,每轮都是全新的缓存写入,而且一次都读不到(下一轮时间戳又变了):

成本 = 430,000 × $3.75 / 1M = $1.61   ← 比 A 还贵 25%

(这三笔都是按公开价目表推算的理想模型,没算 TTL 过期、块粒度对齐、路由失配这些损耗。真实数字只会更差一点,但相对关系不变。)

情况 C 是我认为最该记住的:缓存不是"开了就赚"的东西。开了却不满足命中条件,是负收益。

而这个负收益特别隐蔽——你的功能是好的,测试是过的,日志是干净的,只有月底的账单在默默变胖。


五、我把自己的日志扫了一遍,结果跟猜的不一样

上面都是推算。推算这东西的问题在于,它总是对的——因为它是从公式倒推出来的。真要知道钱花在哪,得去翻日志。

好在几个主流的 AI coding harness 都把 token 用量写在本地了,字段还挺全。我写了个脚本(kv_cache_report.py,已开源,用法见文末)扫了一遍自己最近 30 天的记录,四个 harness 一起看。

第一个意外:我怀疑错了对象

起因是我觉得 Superpowers 那套 brainstorming skill 特别烧钱——它要反复追问、反复改设计,直觉上就是缓存杀手。

扫出来是这样:

── brainstorming 会话 ──
   命中率  ███████████████████···   88.0%
   缓存读 1.6M   缓存写 213.4K   输出 13.5K
   估算 $3.25   不用缓存要 $9.21   省下 65%

88%,缓存干得挺好。 我冤枉它了。

那钱花哪了?同一份数据里还有两个数:

上下文从 50,096 涨到 94,301 token(1.9 倍)
输入输出比 131:1

真正的开销是上下文膨胀:多轮设计对话每轮都在追加,窗口越滚越大,每轮都得把之前所有内容重读一遍。哪怕每个 token 都打了一折,读的量本身在翻倍。

这是 brainstorming 这类任务的固有成本,不是 bug。缓存已经尽力了,剩下的得靠控制上下文长度——比如讨论到一定程度就落盘成 spec、开个新会话接着聊。

不过脚本还是揪出了两处真问题:

   缓存断裂点(前缀被打断,重新建缓存):
   第 15 次调用  命中 85.4K → 18.1K  重建写入 68.8K
   第 18 次调用  命中 87.1K → 18.1K  重建写入 70.7K

两次都精准掉回 18.1K——说明前缀被打断,从那个点往后全部重算。加起来白扔了 14 万 token,而且这个会话用的是 1 小时档(写入 2 倍价),这两次重建是纯亏。

这就是"断裂点"这个指标的价值:它能告诉你是第几次调用出的事,而不是只给你一个平均值。

第二个意外:连着被同一个坑绊了两次

四个 harness 横着一比,我第一眼看到的是这个:

Harness 命中率
Claude Code 96.5%
Codex 94.2%
OpenCode 16.2%
Cursor 本地无数据

OpenCode 才 16%?那可太值得查了。我正准备去翻它的提示词构造,顺手先按月拆了一下:

2026-06  claude-opus-4-8    0.0%   cache_read =           0
2026-07  claude-opus-4-8  100.0%   cache_read = 1,297,594,097
2026-08  claude-opus-4-8    0.0%   cache_read =           0

0% → 100% → 0%。没有任何真实的缓存行为长这样。

决定性的证据是另一个字段:这些月份里,缓存读和缓存写同时为 0

这不可能。想想情况 C——真正的缓存失效会留下大量写入痕迹,因为每次前缀被打断,系统都得重新写一份缓存。只有"根本没上报",才会读写同时归零。

所以这不是命中率低,是这段时间的 provider 没回传缓存字段。我给脚本加了个过滤,把这类会话剔除,重算得到 50.3%——比 16% 可信多了。

然后我又栽了第二跤。

50.3% 还是偏低,我下钻去看是哪些会话拖的,发现一个诡异的:某个会话 4180 万 token,命中率 3.6%。按模型拆开一看:

claude-opus-4-8      207 次调用   cache_read = 0          ← 没埋点
deepseek-v4-flash     10 次调用   cache_read = 1,498,112  ← 正常

一个会话里混用了两个模型。 deepseek 那部分缓存工作得好好的,但 opus 占了 207/217 次调用、几乎全部 token,它的假 0 把整个会话的命中率拖到了 3.6%。

我的过滤是按会话判断的——只要会话里混进一个没埋点的模型,整个会话的真实数据就跟着一起被算坏。正确的粒度是 (会话, 模型):同一个会话里,opus 的假 0 该剔除,deepseek 的真数据该留下。

改成按模型分组之后:

Harness 命中率 说明
Claude Code 96.5% 健康
Codex 94.2% 健康
OpenCode 92.3% 也健康
总体 94.7%

那个"3.6%"的会话,真实命中率是 90.0%

OpenCode 从头到尾就没问题。16% 是假象,50.3% 也是假象——两次我都差点信了。

这件事给我的教训比省下的钱更值钱:

在你根据一个指标动手之前,先问一句:这个指标本身可信吗?

具体到缓存这件事,有一条判据可以直接抄走:

  • 读低、写高 → 真的没命中,前缀有问题,去查
  • 读写同时为 0,但输入量很大 → 多半是没埋点,先确认数据源,别急着改代码

判据要用对粒度。 我第一版按会话判断,结果混用模型的会话全被算坏;改成按 (会话, 模型) 才对。这跟缓存本身的道理是同一个——聚合会掩盖问题,得拆到"真正独立变化"的那一层去看。

更值得说的是:我写这个脚本,本来就是为了避免"被错误的指标误导"。结果第一版脚本自己就是个错误的指标,还让我对着 OpenCode 白紧张了一轮。一个会误导人的监控,比没有监控更危险——这句话我在文章里写下来的时候,自己正踩在这个坑里。

顺带发现:Cursor 本地什么都不存

四个里面 Cursor 是唯一拿不到数的。它本地只有一个 ai-code-tracking.db,记的是代码改动归因,没有任何 token 字段——用量在服务端账户页。

脚本里我没有假装能算,而是如实打印出来说明原因。报告里出现"无法获取",比编一个数字出来强。

(另外提醒一句:我调试时复制过一份 Cursor 的 state.vscdb 到临时目录,后来才想起来那里面存着 auth token,赶紧删了。扒本地日志的时候留个心眼,这些文件里什么都有。

脚本长什么样

四个 harness 的字段结构完全不同,这是踩过的坑:

Harness 位置 口径
Claude Code ~/.claude/projects/**/*.jsonl 逐次请求,区分 5m/1h 同一条 message 会重复写好几遍,必须按 id 去重,否则数字翻倍
Codex ~/.codex/sessions/**/*.jsonl 累计值 取最后一条;input_tokens 含 cached,要减掉
OpenCode ~/.local/share/opencode/opencode.db 逐次请求 SQLite,用只读模式打开,别干扰正在跑的进程
Cursor 本地无 token 数据

还有一条不分 harness 的通用坑:统计要按 (会话, 模型) 分组。一个会话里混用多个模型是常态,只要有一个没上报缓存,按会话汇总就会把其它模型的真实数据一起算坏。

常用命令:

# 全局扫一遍
python3 scripts/kv_cache_report.py

# 单独看某个 skill,并找出缓存断裂点
python3 scripts/kv_cache_report.py --grep brainstorming --breaks --by-session

# 揪出最贵的会话
python3 scripts/kv_cache_report.py --harness opencode --by-session --top 15

一个提醒:脚本算出来的金额是按公开价目表估的,不是账单。 订阅套餐根本不按 token 计费。这个数只用来看相对大小——哪个 harness、哪个会话最该优化。


六、最佳实践清单

按重要性排序,从上往下做。

1. 分层排版:静态在前,动态在后

这是所有做法的总纲。把上下文按"变化频率"排序,越不变的越靠前:

flowchart TB
    subgraph STABLE["稳定前缀 · 目标占 80% 以上 · 永不变动 · 可缓存"]
        direction TB
        A["① 工具定义 / function schema<br/>—— 版本发布才变"]
        B["② system prompt / 角色与规则<br/>—— 版本发布才变"]
        C["③ few-shot 示例<br/>—— 版本发布才变"]
        D["④ 长期不变的背景文档<br/>—— 天级变"]
        A --> B --> C --> D
    end

    BP{{"← 缓存断点打在这里 →"}}

    subgraph VOLATILE["动态后缀 · 每轮变化 · 不缓存"]
        direction TB
        E["⑤ 对话历史(只追加,不修改)<br/>—— 每轮追加"]
        F["⑥ RAG 检索结果 / 工具返回<br/>—— 每轮变"]
        G["⑦ 用户当前输入 / 时间戳 / 随机 id<br/>—— 每次都变"]
        E --> F --> G
    end

    STABLE --> BP --> VOLATILE

    classDef stable fill:#E8F0E8,stroke:#2E7D32,color:#1B3A1B;
    classDef volatile fill:#FBEAEA,stroke:#C62828,color:#4A1414;
    classDef breakpoint fill:#FFF3CD,stroke:#B8860B,color:#5A4300;
    class A,B,C,D stable;
    class E,F,G volatile;
    class BP breakpoint;

注意 Anthropic 的缓存前缀是按 toolssystemmessages 的顺序构建的,工具定义天然在最前面——这也是为什么动态增删工具的破坏力那么大。

时间戳怎么办? 三个选择,按优先级:

  1. 真不需要就删掉(大部分场景模型并不需要知道现在几点)
  2. 降精度——精确到小时甚至天,一天最多失效 24 次而不是 86400 次
  3. 挪到最尾部,紧挨着用户输入

2. 序列化确定化

前面讲过了,这里给一条可以直接抄的规矩:所有进入稳定前缀的结构化数据,序列化时必须固定 key 顺序、固定分隔符、固定缩进、禁止任何随机或时变字段。

值得给这段代码单独写一个单元测试:同样的输入序列化两次,断言字节完全相等。听起来傻,但这种 bug 你不测出来,就只能从账单上读出来。

3. 工具用 mask,不要 remove

场景:不同阶段该给模型不同的工具,规划阶段给搜索,执行阶段给写文件。

直觉做法:动态增删 tools 数组。这是灾难——工具定义在最前面,删一个,整个缓存归零。

正确做法:工具全集永远保持在稳定前缀里,用别的方式限制模型的选择:

  • 自建推理:直接对 logits 做 mask,从解码层面禁止不该调的工具
  • 用云 API:靠 tool_choice 参数,或者在尾部的动态区加一句"本轮只允许使用 X 和 Y"

Manus 把这条总结成一句口诀:mask, don't remove

4. Append-only:不许改历史

历史消息只能往后追加,不能回头修改、重排、删除。改了哪一条,从那条往后的缓存全废。

这条会跟一个常见需求打架:上下文太长了要压缩。摘要压缩的本质就是"把前面十条换成一条摘要",属于典型的改写历史,必然导致缓存全失效。

务实的做法:

  • 别在中间压。 压缩的时候把断点往前推,保住最前面那段真正稳定的底座(system + tools),只压中段。
  • 接受一次性成本。 压缩是低频操作,一次全量重建可以接受,但要明确知道自己付了这笔钱,别每 3 轮压一次。
  • 想让模型"记住"目标,就在尾部复述,别去改开头。 Manus 用的是不断在上下文末尾重写一份 todo.md 的做法——追加不破坏前缀,改开头就完了。

5. 断点打在哪

Anthropic 允许最多 4 个 cache_control 断点,系统会自动匹配最长的那个命中前缀。分配原则:

  • 最起码要有一个断点在 system prompt 末尾。 这是保底。
  • 剩下的按"变化频率的台阶"打——比如一个在工具定义后,一个在长背景文档后,一个在历史消息的稳定段后。
  • 别把断点打在高频变化的内容后面,那等于每轮都在付写入费。

OpenAI、DeepSeek 是自动的,不用你标;但正因为自动,你更要靠排版去引导它。

6. 5 分钟还是 1 小时?算一下再决定

Anthropic 提供两档 TTL,价格不同,这笔账很好算:

  • 5 分钟档:写 1.25×,读 0.1× → 命中 1 次就回本
  • 1 小时档:写 2×,读 0.1× → 命中 2 次才回本

判断规则:

  • 请求间隔稳定小于 5 分钟(在线聊天、连续跑的 Agent)→ 用 5 分钟档。每次命中自动续期,等于免费长存。
  • 请求间隔忽长忽短、经常超过 5 分钟(低频的批处理、人工间断介入的工作流)→ 算一下平均命中次数,超过 2 次再上 1 小时档。
  • 千万别无脑上 1 小时档。 如果你的前缀一小时内只被读 1 次,你花了 2× 的写入费换 0.1× 的一次读取,纯亏。

7. 批量任务按前缀分组

跑批的时候,把共享同一前缀的任务排在一起连续发,别打散混在别的任务里。中间插进来的其它前缀会挤占缓存空间,触发 LRU 淘汰,等你转回来时缓存已经没了。

这个优化几乎零成本——就是给任务队列加个排序键——但对批量场景的效果很明显。

8. 把命中率做成指标,挂到监控上

不度量,前面七条都是玄学。 每家 API 都在响应里给了字段:

# OpenAI
usage.prompt_tokens_details.cached_tokens

# Anthropic
usage.cache_read_input_tokens      # 命中读取
usage.cache_creation_input_tokens  # 缓存写入

# DeepSeek
usage.prompt_cache_hit_tokens
usage.prompt_cache_miss_tokens

至少埋这三个指标:

指标 算法 看什么
缓存命中率 命中 token / 总输入 token 主指标。突然下跌 = 有人动了前缀
写读比 写入 token / 读取 token 持续偏高说明前缀不稳,可能在做负收益缓存
有效单价 实际花费 / 总输入 token 直接对得上账单的那个数

再加一条来自上一节的教训:给指标本身做健康检查。 读写同时为 0 时,先怀疑埋点,别怀疑前缀(判据见第五节)。

最关键的动作:给命中率配一条告警。 前缀是一个所有人都能随手改的地方——加一行提示词、调一下工具描述、改一个 JSON 字段名——改完功能全对,测试全过,只有命中率悄悄从 85% 掉到 12%。有告警,你当天就知道;没告警,你月底才知道。


七、常见陷阱

前面的实践是"该做什么",这里是"别做什么"。有几条是前面正面讲过的反面,我只列没提过的。

陷阱 1:以为缓存能省输出的钱

输出 token 一分钱不省。 缓存复用的是 prefill 阶段的 K/V,跟 decode 阶段生成新 token 毫无关系。

所以如果你的应用是"短输入、长输出"(比如写文章、生成代码),缓存的收益天然有限。反过来,Agent 这种 100:1 的负载才是缓存的主场。先看清自己的输入输出比,再决定花多少力气在这上面。

陷阱 2:把 RAG 检索结果放在前面

这个错误特别常见,因为它看起来很合理:检索到的资料是"背景知识",背景知识不该放前面吗?

不该。判断标准不是"这内容是不是背景",是"这内容每次请求会不会变"。 RAG 结果每次都随 query 变,放在 system prompt 后面,等于把它下面的所有内容都拖下水。

正确位置是紧挨着用户问题,在最尾部。

陷阱 3:全量缓存反而更慢

那篇 arXiv 论文测出一个反直觉结果:naive 的全上下文缓存,可能反而增加延迟。

原因是缓存本身有开销——写入、查找、块管理都不是免费的。如果你把大量高频变化的内容也标进缓存区,就是在为一堆永远命中不了的内容反复付管理成本。

论文的建议很明确:有策略的分块缓存(动态内容放末尾、排除动态工具结果)比无脑全量缓存更稳定。 也就是前面说的分层排版。

陷阱 4:多租户共享缓存的串味风险

自建推理时这条是安全问题,不只是性能问题。

如果租户 A 和租户 B 的请求共用一个缓存池,理论上存在两种风险:哈希碰撞导致 B 读到 A 的 KV block(vLLM 从 v0.11 起默认用 sha256 就是为了堵这个,用 xxhash 换速度时要清楚自己在冒什么险);以及时序侧信道——B 构造一个 prompt,通过响应快慢反推 A 是否发过类似内容。

vLLM 给的解法是 cache_salt:每个请求带一个盐值,参与第一个 block 的哈希,只有同盐的请求能互相复用。

规矩很简单:跨租户边界,宁可牺牲命中率也要做隔离。 同租户内部再去优化命中率。

陷阱 5:以为"缓存命中"等于"模型记住了"

这是概念层面的误解,但影响很实际。

缓存复用的是计算结果,不是记忆。 模型每次推理,看到的仍然是完整的、从头到尾的上下文;缓存只是让"算出这段上下文的 K/V"这一步跳过了。

所以:缓存命中不会让上下文变短,不会绕过上下文窗口上限,不会让模型"更了解"之前的对话。你的上下文该多长还是多长,该爆窗口还是爆窗口。

我见过有人以为"反正缓存便宜,那就往 system prompt 里多塞点"——这是在拿一个便宜的读取价格,去换更差的模型注意力分配。便宜不等于免费,更不等于没有副作用。 该做的上下文精简一样得做。

陷阱 6:本地和线上前缀不一致

本地调试时你可能开了 debug 模式、加了额外的提示、用了不同的工具子集。结果是:本地测出来的命中率、成本、延迟,跟线上完全对不上。

做法:把稳定前缀的构造抽成一个纯函数,输入是配置,输出是确定的字符串。所有环境走同一个函数,差异只体现在配置里。顺便你还能给它写测试(见实践 2)。


八、上线前的自查表

一张能直接抄走的表,Code Review 的时候对着过一遍。

# 检查项 通过标准
1 稳定前缀有多长? 超过目标模型的最小门槛(1024 / 2048 / 4096)
2 前缀里有时变内容吗? 时间戳、随机 id、session id、用户名——一个都不能有
3 工具定义会动态增删吗? 不会。用 mask 或 tool_choice 代替
4 RAG 结果 / 工具返回放在哪? 尾部,紧挨用户输入
5 序列化确定吗? sort_keys=True + 固定 separators,且有单元测试断言字节相等
6 历史消息是 append-only 吗? 是。压缩策略明确,且不在高频路径上
7 缓存断点打在哪? 至少一个在 system prompt 末尾
8 TTL 档位选对了吗? 5 分钟档命中 1 次回本,1 小时档要 2 次——算过再选
9 请求路由有亲和性吗? 自建集群按 session id 一致性哈希
10 命中率有监控吗? 有指标 + 有告警阈值
11 多租户隔离了吗? 跨租户用 cache_salt 或独立缓存池
12 前缀构造是纯函数吗? 各环境共用同一个构造逻辑
13 指标本身可信吗? 读写同时为 0 = 先查埋点,别改代码
14 指标的粒度对吗? 按 (会话, 模型) 分组统计,混用模型时别按会话汇总

如果 1–5 有任何一条不达标,先别管别的,那几条就能解释你 80% 的成本问题。


总结:前缀是接口,改它要像改 API 一样谨慎

回到开头那笔账:同一个 Agent,同一份活儿,账单能差 6.8 倍——从 $1.61 到 $0.236。中间隔着的不是什么高深优化,就是"前缀稳不稳"这一件事。

所以我更愿意这么理解 KV Cache:它把"提示词排版"从一件审美的事,变成了一件有价格标签的事。

以前你把工具定义放前面还是后面,是个人习惯问题,谁也说服不了谁。现在有价目表了:放错位置,每天多烧几十上百刀。这种约束虽然烦,但对工程来说是好事——一个有明确成本函数的设计决策,才是能被讨论、被 review、被守住的决策。

所以,请把你的稳定前缀当成一个对外发布的 API 契约来对待:

  • 它有版本,改动要走发布流程,不能谁想加一行就加一行
  • 它有测试,序列化的字节要能断言
  • 它有监控,命中率掉了要有人被叫醒
  • 它有 owner,出了问题知道找谁

明天上班能做的第一件事,不是重构提示词,是把日志扫一遍,看看你现在的命中率到底是多少。文末那个脚本可以直接拿去用。

但请记住我在第五节栽的那两个跟头:扫出来的数字,也要先确认它可信,还要确认它的粒度对不对。 我怀疑的对象是清白的,那两个吓人的低命中率也都是假象。真花钱的地方,和我以为的完全是两回事。

省钱是结果,看清楚才是方法。

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* LLM KV Cache
** 两层含义
*** 请求内: 避免 O(N²) 重算, 引擎默认
*** 跨请求: prefix caching, 账单相关
*** 显存公式: 2×层×KV头×头维×字节
*** GQA / MLA 是为了跑得起
** 命中五条件
*** 从 token 0 起精确前缀匹配
*** 满足最小长度门槛
*** TTL 未过期 (静默失效)
*** 路由到同一台机器
*** 序列化逐字节确定
** 收益
*** 成本降 41-80%
*** TTFT 改善 13-31%
*** 显存共享, 并发上限提高
*** 倒逼上下文分层
** 最佳实践
*** 静态在前, 动态在后
*** mask 工具, 不要 remove
*** append-only, 不改历史
*** 断点至少一个在 system 末尾
*** TTL 档位算过再选
*** 命中率做成指标加告警
** 常见陷阱
*** 输出 token 一分不省
*** RAG 结果放前面
*** 全量缓存反而更慢
*** 多租户缓存串味
*** 以为缓存 = 模型记住了
*** 前缀不稳时开缓存反而更贵
** 实测教训
*** 先扫日志, 别靠猜
*** 读写同时为 0 = 没埋点
*** 按 (会话,模型) 分组, 别按会话
*** 会误导的监控比没监控更危险
*** 上下文膨胀 ≠ 缓存失效
@endmindmap

LLM KV Cache - 思维导图

延伸阅读

  1. Prompt caching - Anthropic Docs
  2. Prompt caching - OpenAI API
  3. Context caching - Gemini API
  4. Context Caching - DeepSeek API Docs
  5. Automatic Prefix Caching - vLLM Design Docs
  6. Context Engineering for AI Agents: Lessons from Building Manus
  7. Don't Break the Cache: An Evaluation of Prompt Caching for Long-Horizon Agentic Tasks (arXiv:2601.06007)

附:命中率扫描脚本

本文第五节用的脚本开源在这里:scripts/kv_cache_report.py。支持 Claude Code、Codex、OpenCode 三家的本地日志(Cursor 本地不存 token 用量),会自动识别"没埋点"的可疑会话并剔除。

python3 scripts/kv_cache_report.py                    # 最近 30 天全部
python3 scripts/kv_cache_report.py --grep <skill名> --breaks --by-session
python3 scripts/kv_cache_report.py --harness opencode --by-session --top 15
python3 scripts/kv_cache_report.py --json report.json

价格表在脚本头部的 PRICING 里,按自己的模型改。金额只用于横向比较,不等于账单。


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。