# KV Cache 与 Prompt Cache: 缓存的到底是什么, key 又是什么 ```{contents} ``` ## 先回答你的三个问题 你的疑问可以拆成三句, 先把答案放在这里: | 疑问 | 一句话回答 | |------|-----------| | "每个 token 都要和其他 token 查关联关系, 那缓存能省掉什么?" | 省掉的**不是**关联查询, 而是"**把 K、V 这两个向量算出来**"这一步。关联查询(Q·K)仍然老老实实做, 但它只是乘加, 不需要重新过一遍模型的所有权重矩阵 | | "缓存的 key 是什么?" | 逻辑上是**"前缀的 token id 序列"**(不是文本、不是语义); 实现上是按固定分块算出的**哈希 / 前缀树路径**。所以 key 不是"某个 token", 而是"**某一串 token 构成的前缀**" | | "两段意思差不多的话, 中间改一点, 还能用上缓存吗?" | 只有**第一处改动之前**的那一段能命中, 改动点之后全部失效。缓存是**逐 token 精确前缀匹配**, 完全不懂"意思差不多" | 下面逐条展开。 ## 一、为什么"逐 token 查关联"还能用缓存 ### 1.1 关键前提: K 和 V 一旦算出来, 就不会再变 Transformer 每一层对每个 token 都要算三个向量(见 [AI 学习笔记](./README.md) 里 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 都要把**整个序列**重新跑一遍模型: ```text 第 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: ```text 第 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](https://developers.openai.com/api/docs/guides/prompt-caching)) 存的不是文本, 不是 token id, 也不是 embedding, 而是**每一层、每个注意力头**的 K 和 V 张量: ```text 缓存条目 = { 第 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 字节): ```text 每 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 序列 一句话: ```text 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`, 然后**从头逐个比**: ```text 请求 1: 请把 | 这段 | 代码 | 重构 | 一下 请求 2: 请把 | 这段 | 代码 | 优化 | 一下 ↑ 第 4 个 token 开始不同 → 命中前 3 个 token 的 K/V, 从第 4 个 token 起全部重新算 ``` 就这一条规则, 别的都是它的推论: 命中的是**最长公共前缀**, 失效率等于"改动点离结尾有多远"。 ### 3.2 分歧点的位置决定收益 | 改动位置 | 命中长度 | 说明 | |---------|---------|------| | 完全没改 | 全命中 | 缓存读取永远是最便宜的输入 token | | 改在**末尾**(追加内容) | 几乎全命中 | 多轮对话、追加数据的最优形态 | | 改在**中间** | 只能命中改动点之前 | 改得越靠前亏得越多 | | 改在**开头** | 命中可能为 0 | 系统提示里塞时间戳、随机 ID 就是这种情况 | | 改了一个**标点/空格/大小写** | 从受影响的那个 token 开始 | tokenize 结果变了, 精确匹配就失败 | **高频踩坑的等价说法**: "我只是把两条消息合并了一下"/"我只是重新排了下工具顺序"/"我只是在 system 里加了个日期", 在前缀缓存眼里都等于"**从那个位置起整段都是新内容**"。 ### 3.3 为什么"意思一样但措辞不同"不行 因为缓存做的是**逐 token 的精确匹配**, 而不是语义比对。看一个典型例子(具体切分随分词器不同, 只看形状): ```text "请把这段代码重构一下" → 请把 | 这段 | 代码 | 重构 | 一下 "请把这段代码优化一下" → 请把 | 这段 | 代码 | 优化 | 一下 ↑ 从第 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, 只把"向量矩阵乘法"换成了显式的乘加计数)的小实验, 直接跑: ```bash 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 节的账。 实测输出: ```text [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 里; - **改一点会怎样**: 从第一处不同开始失效, 之前的部分照常命中 —— 所以"稳定内容在前, 变化内容在后"是唯一正确的排版; - **"意思差不多"**: 精确前缀缓存不认; 认这个的是你自己在上层做的语义缓存, 两码事。 ## 参考资料 - [Prompt caching | OpenAI](https://developers.openai.com/api/docs/guides/prompt-caching) —— KV tensors、1024 token 门槛、断点与 TTL 的权威说明 - [Prompt caching | Anthropic](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) —— `cache_control`、20 块回退匹配、5 分钟 / 1 小时 TTL 与计价 - [Context Caching | DeepSeek](https://api-docs.deepseek.com/guides/kv_cache) —— "缓存前缀单元必须完整匹配"与 A+B / A+C / A+D 的例子 - [vLLM Automatic Prefix Caching](https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html) —— 自建推理服务里按块哈希共享前缀 延伸阅读: [AI 学习笔记(Transformer 与 Q/K/V)](./README.md)、[RAG: 检索增强生成](./rag.md)、[从0开始搭建本地 AI 服务](./local-ai-service.md)。