KV Cache 与 Prompt Cache: 缓存的到底是什么, key 又是什么

先回答你的三个问题

你的疑问可以拆成三句, 先把答案放在这里:

疑问

一句话回答

"每个 token 都要和其他 token 查关联关系, 那缓存能省掉什么?"

省掉的不是关联查询, 而是"把 K、V 这两个向量算出来"这一步。关联查询(Q·K)仍然老老实实做, 但它只是乘加, 不需要重新过一遍模型的所有权重矩阵

"缓存的 key 是什么?"

逻辑上是**"前缀的 token id 序列"(不是文本、不是语义); 实现上是按固定分块算出的哈希 / 前缀树路径**。所以 key 不是"某个 token", 而是"某一串 token 构成的前缀"

"两段意思差不多的话, 中间改一点, 还能用上缓存吗?"

只有第一处改动之前的那一段能命中, 改动点之后全部失效。缓存是逐 token 精确前缀匹配, 完全不懂"意思差不多"

下面逐条展开。

一、为什么"逐 token 查关联"还能用缓存

1.1 关键前提: K 和 V 一旦算出来, 就不会再变

Transformer 每一层对每个 token 都要算三个向量(见 AI 学习笔记 里 Q/K/V 的部分):

Q = x · Wq     Q (Query): "我想找什么"
K = x · Wk     K (Key):   "我是什么"
V = x · Wv     V (Value): "我的实际内容"

其中 x 是"输入向量 + 位置编码"的结果, 而位置编码按绝对位置加, 所以:

第 i 个 token 在第 l 层的 K、V, 只由"前 i 个 token 是什么"决定, 和后面出现什么、和模型将要生成什么, 完全无关。

这句话就是整个缓存的全部根基。因为**因果掩码(causal mask)**的存在, 第 i 个 token 看不到第 i 个之后的 token, 所以它的 K、V 不可能被后面的内容"改写"。

一次请求里, 输入的 token 序列是固定的; 那么这 N 个 token 的 K、V 算完之后, 就是一组常量, 存起来永远不会因为"下一步生成了别的字"而失效。

1.2 省掉的到底是什么: 不重复算 K/V

没有缓存时, 每生成一个新 token 都要把整个序列重新跑一遍模型:

第 1 步: 算 1 个 token 的 K/V
第 2 步: 算 2 个 token 的 K/V   ← 第 1 个 token 又算了一遍
第 3 步: 算 3 个 token 的 K/V   ← 前 2 个又算了一遍
...
第 n 步: 算 n 个 token 的 K/V   ← 前 n-1 个又算了一遍

有缓存时, 每步只算新来的那 1 个 token:

第 k 步: 只算第 k 个 token 的 K/V, 追加到缓存里
       然后拿它的 Q 去和缓存里的全部 K 做点积

注意下面这个容易误解的细节, 关联查询没有被省掉:

  • 第 k 步仍然要算 q_k · K_1 ... q_k · K_k 共 k 次点积 —— 这一步确实一次都没少;

  • 省掉的是"把 K_1...K_{k-1}、V_1...V_{k-1} 重新算出来"。

也就是说, 缓存省的是造子弹的功夫, 不是开枪的功夫。反过来说也成立: 没有缓存的话, 开枪之前还得把已经打过的子弹全部重造一遍。

顺便把一个常见说法纠正一下: 严格数浮点运算, 两者都是 O(n²), 因为逐 token 生成时"和全部历史 K 做点积"这一项本身也是平方级的。真正的差别在常数和硬件特性:

每生成一个 token 要干的事

硬件上是什么瓶颈

无缓存

对全部 i 个 token 重跑一遍前向: 一堆大矩阵乘法(Wq/Wk/Wv/FFN)

算力密集型, 矩阵乘把 GPU 的计算单元喂满

有 KV cache

只算 1 个 token 的矩阵乘法, 再流式地读缓存做注意力和加权求和

访存密集型, 瓶颈在显存带宽

所以真实的加速来源是: 不再一遍遍地把大矩阵乘法做重复劳动。实测(见 3.5 节)在一个注意力层上, 生成 64 个 token 的乘加次数从 439 万降到 26 万, 约 16.7 倍; 上下文越长, 这个差距的绝对量越大, 而 prefill 阶段的收益更是直接和前缀长度成正比。

1.3 一次请求内的两步: prefill 和 decode

阶段

在干什么

能不能并行

KV cache 的作用

prefill(预填充)

一次性吃下整个 prompt, 算出所有 token 的 K/V

能, 矩阵乘法一次算完

写入缓存

decode(解码)

一个一个吐 token, 每个新 token 复用已有 K/V

不能(自回归)

读取缓存, 只追加新 token 的 K/V

prefill 是"算力密集"的(吃满 GPU), decode 是"访存密集"的(每次只算一个 token, 但要读整个缓存)。这也解释了两件事:

  1. 首 token 延迟(TTFT)主要由 prefill 决定 —— 所以跨请求复用前缀的 prompt cache 直接省的就是这段;

  2. KV cache 占显存, 长上下文时它会成为瓶颈, 后面 1.5 节算笔账。

1.4 缓存里到底存了什么(别和"存 token"搞混)

OpenAI 的文档写得很直白:

The prompt cache stores key-value (KV) tensors, not the tokens themselves. (Prompt caching | OpenAI)

存的不是文本, 不是 token id, 也不是 embedding, 而是每一层、每个注意力头的 K 和 V 张量:

缓存条目 = { 第 0 层: (K, V), 第 1 层: (K, V), ..., 第 L-1 层: (K, V) }
每个 (K, V) 的形状: [头数, 序列长度, 每头维度]
  • 逐层独立: 每层有自己的一套 Wq/Wk/Wv, 所以每层都要各存一份, 不能共用;

  • 逐头独立: 多头注意力里每个头各算各的, 也是分开存;

  • token 与向量是一一对应的, 所以"命中 100 个 token"等价于"命中了这 100 个 token 在所有层的 K/V"。

1.5 顺便算笔账: 为什么快, 以及为什么贵

以 LLaMA-2-7B 的常见配置为例: 32 层、32 个头、每头 128 维、FP16(每参数 2 字节):

每 token 每层每头: 2(K和V) × 128 × 2 字节 = 512 字节
每 token 全部层全部头: 512 × 32 层 × 32 头 = 512 KB
4K 上下文: 512 KB × 4096 ≈ 2 GB

7B 的模型, 4K 上下文, KV cache 就要 2GB 显存。 结论很清楚:

  • 省掉的是重复计算(prefill 的时间), 收益是数量级的延迟下降;

  • 代价是显存和存储, 所以缓存必须限制在"最近用过的前缀"上, 还要设 TTL 过期 —— 这就是为什么各家 API 都有 5 分钟 / 30 分钟 / 最长几小时的生命周期。

二、缓存的 key 是什么

2.1 逻辑 key: 前缀的 token id 序列

一句话:

key = hash(模型 + 分词器版本 + 渲染后的完整前缀的 token id 序列)
value = 这段前缀在所有层的 K/V 张量

要点都藏在"完整前缀"这四个字里:

你以为参与 key 的

实际参与 key 的

文本字符串

token id 序列(分词结果), 文本一样但切分不同也算不同

用户发的那段话

渲染后的完整 prompt: 系统提示、工具定义、聊天模板里的特殊标记、历史消息、图片/文档块全都算

消息内容

消息顺序、角色、边界都算, 把两条消息合成一条前缀就变了

当前这条请求

模型(和版本)、影响前缀的请求参数也影响能否命中

这条"渲染后的完整前缀"在 OpenAI 文档里的原话是: 缓存的是 model、tools、system、messages 等设置共同渲染出来的前缀(包含 OpenAI 自己加的隐藏系统提示); Anthropic 的说法是缓存引用 tools → system → messages 按这个顺序拼成的完整前缀。

由此推出四个很实用的结论:

  1. 改工具定义会让缓存全废: 工具名、描述、schema、顺序变一个字, 前缀就变了。所以最佳实践是"只追加、不改动"(append-only);

  2. 换模型 = 换缓存: 不同模型权重不同, K/V 算出来不一样, 缓存不能跨模型用;

  3. 带时间戳的系统提示是缓存杀手: current_time: 2026-03-15 10:00:00 放在 prefix 里, 每一秒都是全新的 key。正确做法是把它挪到 prompt 末尾;

  4. 不同分词器/不同 chat template 的"同样文字"是两个 key: 这也是为什么"我另一个客户端明明发的一模一样却不命中"。

2.2 物理实现: 分块哈希 + 前缀树

Key 在实现上怎么组织? 主流做法是把 token 序列切成固定大小的块(block), 对每块及其之前的内容做增量哈希, 用哈希值去查表。这样:

  • 查找是 O(1) 的哈希查询, 不需要逐 token 比对文本;

  • 多个请求可以共享前缀块(vLLM 的 automatic prefix caching、SGLang 的 RadixAttention 都是这个思路);

  • 块大小决定了命中的粒度: 比如按 16 个 token 一块, 那么"命中 100 个 token"实际可能只命中 96 个(向下取整到块边界)。

2.3 三个层次, 三种"key"

日常说的"缓存"其实是三件不同粒度的事, 混在一起讲就会绕晕:

局部 KV cache

Prompt cache(前缀缓存)

语义缓存(semantic cache)

存活范围

一次请求内, 逐 token 追加

跨请求, 多条请求共享同一前缀

跨请求, 按语义相似度匹配

key

无需显式 key(就这一条序列)

精确前缀的 token id 序列(分块哈希)

问题的 embedding, 比相似度阈值

命中条件

天然命中

前缀逐 token 完全相同

意思相近即可, 容错

存储

GPU 显存(张量)

显存/内存/磁盘(张量)

向量库 + 答案文本

典型代表

所有推理框架

OpenAI / Anthropic / DeepSeek / vLLM 前缀缓存

GPTCache 一类的应用层方案

谁实现

推理引擎内部

服务商

你自己(框架不提供)

你问题里"两段含义差不多的话"能不能命中, 取决于用的是哪一种:

  • 前缀缓存: 不能。"意思差不多"它不认识;

  • 语义缓存: 能。它存的甚至不是 K/V, 而是"这个问题问过, 答案直接给你", 代价是有答错的风险(相似不等于同一个问题), 必须设相似度阈值。

2.4 各家的 key 与命中粒度的差异

同一套原理, 工程细节不同, 直接影响你的命中率:

OpenAI(GPT-5.6 及以后)

Anthropic Claude

DeepSeek

怎么指定缓存点

隐式(默认在最后一条可用消息末尾)或显式 prompt_cache_breakpoint

显式 cache_control 打在块上, 或顶层自动缓存; 最多 4 个断点

全自动, 无需改代码

最小可缓存长度

1024 个可见输入 token

按模型有最小 token 阈值

无显式门槛

命中粒度

显式模式按断点; 早先模型按 128 token 向下取整

从断点往前以 20 个块为一个查找窗口逐段回退匹配

"缓存前缀单元"必须完整匹配(滑动窗口注意力导致), 单元落在: 用户输入结束处、模型输出结束处、多请求公共前缀、以及固定 token 间隔

生命周期

默认 30 分钟(每次使用刷新)

默认 5 分钟, 每次使用免费刷新; 可付费延长到 1 小时

用完自动清理, 一般几小时到几天, best-effort

计费

写入 1.25×, 读取 0.1×(相对普通输入价)

写入 1.25×(5 分钟)/ 2×(1 小时), 读取 0.1×

命中 token 单价远低于未命中(见官方定价页)

怎么查看命中

usage.input_tokens_details.cached_tokens

cache_read_input_tokens / cache_creation_input_tokens

prompt_cache_hit_tokens / prompt_cache_miss_tokens


DeepSeek 那份文档里的两个例子特别适合回答"改一点会怎样", 原文是这么说的:

Example 2: A user's first-round request is A + B, and the second-round request is A + C. The second request cannot hit the cache, because A + C does not fully match the first round's cache prefix unit (A + B). However, at this point the system will detect that the two requests share a common prefix A, and persist A as a cache prefix unit. When a third-round request A + D arrives, it can fully match the cache prefix unit A, hitting the cache for A.

翻译成结论: A+B 之后改成 A+C, 这一发不命中; 但系统发现大家都以 A 开头, 会把 A 单独存成前缀单元, 于是第三次请求 A+D 就能命中 A。 这就是"改一点"最真实的体验: 第一次改动白干, 之后开始命中共享的那一段。

还有一个容易被忽略的前提: 缓存是"单机"的。OpenAI 的文档明确写了 cached states live on individual machines, 请求必须路由到持有该缓存条目的那台机器才可能命中; 高流量下超过 15 请求/分钟的缓存前缀还有可能被溢出到别的机器。所以除了 prompt 内容本身, 路由也是命中率的一部分 —— 这解释了"同样的 prompt, 有时命中有时不命中"。

三、"改一点点"到底怎么算

3.1 命中规则: 最长公共前缀, 精确到 token

把两段 prompt 都 tokenize 成 id 序列 t1 t2 t3 ... tn, 然后从头逐个比:

请求 1:  请把 | 这段 | 代码 | 重构 | 一下
请求 2:  请把 | 这段 | 代码 | 优化 | 一下
                        ↑
                  第 4 个 token 开始不同

→ 命中前 3 个 token 的 K/V, 从第 4 个 token 起全部重新算

就这一条规则, 别的都是它的推论: 命中的是最长公共前缀, 失效率等于"改动点离结尾有多远"。

3.2 分歧点的位置决定收益

改动位置

命中长度

说明

完全没改

全命中

缓存读取永远是最便宜的输入 token

改在末尾(追加内容)

几乎全命中

多轮对话、追加数据的最优形态

改在中间

只能命中改动点之前

改得越靠前亏得越多

改在开头

命中可能为 0

系统提示里塞时间戳、随机 ID 就是这种情况

改了一个标点/空格/大小写

从受影响的那个 token 开始

tokenize 结果变了, 精确匹配就失败

高频踩坑的等价说法: "我只是把两条消息合并了一下"/"我只是重新排了下工具顺序"/"我只是在 system 里加了个日期", 在前缀缓存眼里都等于"从那个位置起整段都是新内容"。

3.3 为什么"意思一样但措辞不同"不行

因为缓存做的是逐 token 的精确匹配, 而不是语义比对。看一个典型例子(具体切分随分词器不同, 只看形状):

"请把这段代码重构一下"  →  请把 | 这段 | 代码 | 重构 | 一下
"请把这段代码优化一下"  →  请把 | 这段 | 代码 | 优化 | 一下
                                        ↑ 从第 4 个 token 起失效

命中 3 个 token, 后面全废 —— 对于动辄几千 token 的前缀, 这等于没命中(各家还有 1024 / 128 之类的最小长度和取整门槛)。想要"意思相近也命中", 那是语义缓存的活, 需要自己在上层做 embedding 检索 —— 并且要接受"相似问题被当成同一个问题"的风险。

3.4 怎么把缓存真正用上(工程实践)

  1. 稳定内容放前面, 变化内容放后面: 系统提示 → 工具定义 → 参考文档 → 少样本示例 → 用户问题 → 临时变量(时间戳、session id、随机数放最后);

  2. 只追加, 不改写历史: 多轮对话里别去编辑、总结、压缩早期消息 —— 一改前缀就断; 需要压缩时接受"压缩那一刻的 cache miss";

  3. 显式打缓存断点: 变化频率不同的几段分开打点(Anthropic 的 cache_control、OpenAI 的 prompt_cache_breakpoint), 让"每轮都变的部分"别把"从不变的部分"一起写脏;

  4. 按会话/租户分桶: 会话历史天然是最长的可复用前缀, 老模型上还可以用 prompt_cache_key 让相关请求被路由到同一台机器(缓存是单机的, 路由不对照样 miss);

  5. 盯住三个指标: 命中 token 数、未命中 token 数、首次 token 延迟。命中率高但延迟没降, 通常是网络或输出阶段的问题;

  6. 算清成本: 写入不免费(OpenAI/Anthropic 约 1.25×)。只被用一次的缓存是亏的, 用两次回本、用得越多越赚;

  7. 别让动态内容混进工具定义: 工具 schema 里塞"当前用户 ID"这类字段, 等于每次请求都换一套工具, 缓存全灭。

3.5 一个能跑的最小验证

ai/kv_cache_demo/ 下有一个零依赖纯 Python(不需要 numpy, 只把"向量矩阵乘法"换成了显式的乘加计数)的小实验, 直接跑:

python3 ai/kv_cache_demo/kv_cache_demo.py    # 全部跑完约 0.2 秒

它做三件事:

  1. 实现一个单头注意力的 forward, 分别用"每次重算全部"和"KV cache 只算新 token"两种方式跑逐 token 生成, 打印两种方式的乘加次数 —— 能直接看到重复计算的代价有多大;

  2. 对多组前缀做"追加 / 中间改一个 token / 开头改一个 token", 打印命中的 token 数与失效位置, 验证 3.2 节的表格;

  3. 打印一份 KV cache 显存占用的估算, 验证 1.5 节的账。

实测输出:

[1] 逐 token 生成 64 步的乘加次数(dim=32)
    无缓存(每步重算全部):     乘加  4,391,936 次
    有 KV cache(只算新 token): 乘加    263,168 次
    比值: 16.7x

[2] 相似 prompt 的缓存命中情况(逐 token 精确前缀匹配)
    基准:       '请把这段代码重构一下'
    基准 token: 请把 | 这段 | 代码 | 重构 | 一下  (共 5 个)
    追加(末尾加内容)        命中  5 token / 本句共 9 个   第 6 个 token 起失效
    中间改一个词           命中  3 token / 本句共 5 个   第 4 个 token 起失效
    开头加日期            命中  0 token / 本句共 6 个   第 1 个 token 起失效
    只改一个标点           命中  5 token / 本句共 6 个   第 6 个 token 起失效

[3] 缓存 key 是什么
    前缀:      '你是运维助手。工具: get_disk_usage/restart_service。'
    token id:  你是 | 运维 | 助手 | 。工 | 具: | get_disk_usage/restart_service。
    分块哈希:  sha256(前缀 token 序列)[:16] = f3d615b2f72f74bc

数字怎么读:

  • 生成 64 个 token, 无缓存要 439 万次乘加, 有缓存只要 26 万次, 16.7 倍。注意这还只是一个注意力层, 真实模型 32 层、每层还有 FFN, 重复前向的开销比这个比值更吓人;

  • 缓存的收益只覆盖"改动点之前": 末尾追加命中 5 个, 中间改一个词只剩 3 个, 开头加个日期直接归零;

  • "只改一个标点"看起来命中 5 个, 但那 5 个是基准句的全部——它刚好没动到前面的内容, 真正失效的是新增的那个 token。反过来, 如果改动落在句首附近, 后面再长的内容也一起作废。

四、一句话总结

  • 缓存的对象: 每层每头的 K、V 张量, 不是文本、不是 embedding;

  • 能缓存的原因: 因果掩码保证了"第 i 个 token 的 K/V 只由前 i 个 token 决定", 算出来就是常量;

  • 缓存 key: 渲染后前缀的 token id 序列(实现上是分块哈希); 模型、工具定义、系统提示、消息顺序、分词结果, 全在 key 里;

  • 改一点会怎样: 从第一处不同开始失效, 之前的部分照常命中 —— 所以"稳定内容在前, 变化内容在后"是唯一正确的排版;

  • "意思差不多": 精确前缀缓存不认; 认这个的是你自己在上层做的语义缓存, 两码事。

参考资料

延伸阅读: AI 学习笔记(Transformer 与 Q/K/V)、RAG: 检索增强生成、从0开始搭建本地 AI 服务。