如果你自己部署过开源大模型,或者常调大模型 API,多半遇到过这样一个典型现象:同一个对话里,开头几个 token 出得飞快,越往后越慢;上下文稍微一长,显存肉眼可见地往上顶。很多人第一反应是显卡性能不够、网络延迟,其实大多数情况下,背后真正起作用的就是 KV Cache。它同时也是大模型推理中影响显存占用和吞吐量的关键结构之一。这篇内容想做的事情很简单:把 KV Cache 的原理、为什么只缓存 KV 而不缓存 QKV、它和长上下文场景里常见的“Lost in Middle”以及“Prompt Cache”有什么关联一次讲清楚,再给出一份能直接照着算显存、照着写代码验证的实操笔记。适合刚接触大模型推理的开发者,也适合准备做本地私有化部署、或者正在排查长文本推理性能的同学。
1. 为什么大模型生成越聊越慢:KV Cache 要解决的那个问题
1.1 自回归生成:每一轮都在“重新理解”整段对话
大模型生成文本并不是一次算完一整句话,而是自回归的,一个 token 一个 token 往外蹦。生成第 n 个 token 时,输入是前面 n-1 个 token 组成的序列,经过若干层 Transformer 计算后,得到下一个 token 的概率分布。生成第 n+1 个 token 时,输入理论上只需要新增的这一个 token 就行,但注意力机制不允许你这么做——新 token 的查询需要和历史里所有 token 做注意力计算,于是前文的 Key 和 Value 必须还在。
很多人没细想的是,在没有 KV Cache 的朴素实现里,每生成一个 token,模型都会把整个对话历史重新过一遍,包括重新做 Embedding、重新过每一层的 QKV 投影、重新算整张注意力矩阵、重新跑一遍前馈网络。对话长到几百上千 token 之后,这个重复计算量会变得极其夸张,GPU 直接被这些无用功吃满。
1.2 没有缓存时,复杂度是惊人的
简单算一下复杂度就知道问题有多严重。假设当前序列长度是 n,Transformer 单层里,注意力得生成一个 n×n 的注意力矩阵,复杂度是 O(n²)。生成 n 个 token 时,每次都从头对越来越长的序列做计算,总复杂度大约就是 O(n³)。这个增长在代码里非常直观:序列长度翻倍,没有缓存的生成耗时大约翻八倍,显存压力也会成倍放大。
我在早期做小模型推理实验时,第一次没做任何缓存优化,生成 50 个 token 都还勉强,到 200 个 token 就开始明显卡顿。后来加上 KV Cache,增量阶段的单步计算量被压到了近似 O(n),总生成复杂度也从立方量级降到了平方量级。这也是 KV Cache 是所有主流推理框架默认开启优化的根本原因。
1.3 一个朴素的优化:把算过的 K/V 存起来
KV Cache 的核心思想非常朴素:既然历史 token 的 K 和 V 已经算过了,而且它们在后续每一步都会被反复查询,那就把这两个矩阵存进显存里,每一步只对最新 token 做一次 QKV 投影,然后用新的 Query 去和缓存的历史 K 做点积,拿到注意力权重后去加权历史 V,得到输出。这样每一步省掉的,是历史 token 全部重复过 Transformer 的开销。
从工程角度看,这个缓存在 GPU 显存里以张量形式存放,不同批次、不同请求的缓存放法还有讲究,但原理就是“存起来复用”。很多刚接触推理优化的人会把 KV Cache 想象成一个字典或哈希表,实际上它是一组按层、按注意力头组织的多维张量,后面我会专门讲它的 Shape 和显存占用。
2. 从 Attention 公式说起:为什么只缓存 KV,而不是 QKV
2.1 注意力公式里每个矩阵扮演的角色
要理解为什么只缓存 KV,得回到注意力公式本身。Transformer 的核心计算是:
Attention(Q, K, V) = softmax(Q × Kᵀ / √d) × V
这里 Q 是 Query,代表“我在找什么”,是当前 token 作为查询方向历史提出的问题;K 是 Key,代表“我是什么”,相当于每个历史 token 的索引标签,用来和 Query 匹配;V 是 Value,代表“我存储了什么内容”,一旦某个位置的 Key 被 Query 匹配上,它提供的实际信息就会被加权取出。
这个概念可以类比成图书馆找资料:Q 是你想搜的主题,K 是图书的标签索引,V 是书页里的实际内容。搜过一次之后,你的需求 Q 就不需要再保留了,后续你可能换一个主题继续搜;但图书馆的书还得留在那里,标签 K 和内容 V 都必须一直可用。
2.2 生成一个 token 时,到底需要哪些历史信息
从注意力公式可以看到,当一个新的 token 进入模型时,它的输出是:
Out_new = Σ softmax(Q_new · K_histᵀ / √d) · V_hist
这里 Q_new 是新 token 的查询向量,K_hist 是历史所有 token 的 Key 矩阵,V_hist 是历史所有 token 的 Value 矩阵。注意力分数的计算只涉及 Q_new 和 K_hist,最终结果的加权只涉及 V_hist。你会发现,整个公式里根本没有历史位置的 Q 出现。
换句话说,无论历史有多长,当前步需要读取的只有历史 K 和历史 V。历史 token 的 Q 在它自己作为“查询方”的那一步就已经完成了使命,后面不会再被任何计算引用。所以缓存集合固定为每一层的 K 和 V,而不是 QKV 三者。
2.3 历史 Q 为什么不缓存
我们换个角度理解“为什么不缓存 Q”:在自回归解码中,每个位置的 token 在自己的时间步里充当查询方,用它的 Q 去扫描历史;但一旦它被生成了,它的角色就从“查询方”变成了“被查询方”,后续步骤里它只以 K 和 V 的身份出现在别人的注意力计算中。它的 Q 不会再被读取,缓存一个永远不会再用到的张量,等于白白占用显存带宽和存储空间。
有同学可能会问,那当前 token 的 Q 呢?它当然需要现场计算,因为它只服务当前这一个时间步的注意力。正因为 Q 只需要“新鲜”的一份,所以推理框架在解码阶段通常只维护一份 Q 的临时张量,用完立即释放。这也是 KV Cache 为什么体积通常远小于整份模型权重,但依然随序列长度线性增长的原因。
2.4 顺带理解 GQA:缓存里 kv head 为什么可以比 q head 少
标准 Transformer 的 MHA(Multi-Head Attention)中,每层有 h 个 Q 头、h 个 K 头、h 个 V 头,一一对应。但这几年主流开源模型普遍改用 GQA(Grouped Query Attention)甚至 MQA(Multi-Query Attention),让多个 Query 头共享一组 K/V 头。比如 Llama-2 7B 是 32 个 Q 头但只有 8 个 K/V 头,Qwen 系列 7B 模型也普遍采用类似设计。
GQA 之所以能大幅减少缓存量且质量损失很小,本质原因就是 K/V 代表的是“被动被查”的信息。多个查询头完全可以共用同一套标签索引和内容存储,就像图书馆多个检索台共享同一套书架索引。既然 K/V 头数量变少,KV Cache 的体积也就按比例下降,等于直接把这个缓存的显存成本压到了四分之一甚至更小。近两年模型训练阶段就开始引入 GQA,不单是为了推理快,更是为了缓存可控,不然超长上下文只能停留在论文里。
3. 一张浅显的内存账:KV Cache 的形状与显存计算
3.1 缓存张量的 Shape 与 dtype
KV Cache 看起来像“缓存”,实际是几个高维张量。每层模型各有一份 K Cache 和 V Cache,形状通常是:
[B, num_kv_heads, seq_len, head_dim]
其中 B 是批量大小,num_kv_heads 是 K/V 头的数量,seq_len 是当前已缓存的 token 数,head_dim 是每个注意力头的维度。dtype 常见是 fp16 或 bf16,每个元素占 2 字节;如果使用 fp32 则是 4 字节,int8 量化则 1 字节。
注意,这个形状是“逻辑形状”。不同推理框架实现时,可能以连续内存块的形式分配,也可能用按块分配的方式管理,比如 vLLM 里的 PagedAttention。但无论底层怎么排布,最终占据显存的总字节数都逃不开这个公式:每层 K 和 V 的字节数为 2 × B × num_kv_heads × seq_len × head_dim × dtype_bytes,然后乘层数 L。
3.2 以 7B 模型为例手把手算一遍
我们拿一个具体的模型配置来算,这样印象更深。以 Llama-2 7B 为例:层数 L=32,K/V 头数 num_kv_heads=8,head_dim=128,精度用 fp16。代入计算单 token 的缓存开销:
每个 token 的 K 缓存 = L × num_kv_heads × head_dim × 2 字节 = 32 × 8 × 128 × 2 = 65536 字节,也就是 64KB; K 和 V 各一份,所以一个 token 总共占用 128KB; 当上下文长度达到 4096 时,KV Cache 总占用 = 128KB × 4096 ≈ 512MB; 如果上下文拉到 128K,占用 = 128KB × 131072 = 16GB。
这还只是一个 7B 模型,权重本身大约 14GB,再塞进 16GB 的 KV Cache,单卡 24GB 显存的显卡已经非常紧张。如果用 MHA 的 32 个 K/V 头,同样 128K 上下文得 64GB,单卡完全不可能。我做了一张表格方便对照:
| 配置 | 每 token 缓存 | 4K 上下文 | 128K 上下文 |
|---|---|---|---|
| MHA,32 kv heads,fp16 | 512KB | 2GB | 64GB |
| GQA,8 kv heads,fp16 | 128KB | 512MB | 16GB |
这个差异解释了为什么长上下文模型几乎没有不出 GQA 的。也解释了为什么很多免费大模型 API 只给你几千 token 的上下文窗口,显存账算下来,长上下文真的不是白来的。
3.3 为什么长上下文是“显存杀手”
KV Cache 是随序列长度线性增长的。上下文长度翻一倍,缓存显存就翻一倍。这还只是最乐观的情况,实际推理时还有激活值、临时张量、调度器元数据等额外开销,都会挤占显存。所以很多模型标称能支持 128K 上下文,但你真的在单卡上跑起来会发现,上下文稍微长一点就 OOM,大概率不是模型“算不了”,而是 KV Cache 装不下。
理解了这笔账之后,再去看那些宣称超长上下文的模型和 API,就需要多问一句:它是不是用了窗口缓存、KV 量化、或者极端剪枝手段,有没有牺牲实际效果。这也是我为什么建议做部署前先按公式自己算一遍缓存量,别等到推理时才被显存打了个措手不及。
4. Lost in Middle 与 Prompt Cache:和 KV Cache 同源的另外两个高频词
4.1 中间内容为什么容易被遗忘
“Lost in the Middle”是指当输入上下文很长时,模型更倾向于利用开头和结尾的信息,而对中间部分内容的记忆和推理效果明显变差。这个现象在长文档问答、多轮对话携带大量历史信息时非常常见。原因是多方面的,包括注意力分数经过 softmax 归一化后,长序列里中间位置容易被两边稀释;RoPE 这类相对位置编码在超长距离上也会引入振荡;以及训练数据里关键信息往往习惯放在开头或结尾,模型可能形成了某种位置偏置。
KV Cache 和 Lost in Middle 的关系在于:即使我们把整个前文的 K 和 V 都完整缓存下来了,查询方的注意力权重分布依然可能偏向首尾。缓存只是避免重复计算,它不会自动提升模型对中间信息的关注度。所以你会看到,长对话里中间几轮的结论被忽略,模型更记得最近一轮和开场设定。
4.2 KV Cache 是否会加剧 Lost in Middle
严格来说,KV Cache 本身不会改变注意力分布,因为它只是把历史 token 的 K/V“原样保存”。但工程实现中,为了让缓存可控,很多推理框架会做窗口裁剪或者丢弃历史 token。比如只保留最近 N 个 token 的 KV,超出部分直接丢掉。这种情况下,中间内容不是被注意力机制忽略了,而是直接被缓存策略移除了,Lost in Middle 会被进一步放大。
我自己的排查经验是:如果长上下文里明显出现“中间信息抓不住”,先去检查推理框架的 KV cache 策略是不是默认开了 sliding window。窗口之外的 token 并非模型不记得,而是根本没有参与后续注意力计算。这个“窗口外失忆”和学术界说的 Lost in Middle 有细微区别,但对业务影响是一样的:关键信息要尽量放到 prompt 开头或结尾,或者单独用检索把相关内容拉出来。
4.3 Prompt Cache / 前缀缓存:把 KV Cache 复用到下一次请求
在实际应用中,同一个系统的请求往往有大量重复前缀:系统提示词、few-shot 示例、工具定义、角色设定,每次都从零开始重新计算这部分 KV,浪费非常严重。Prompt Cache,也就是不同平台里常见的前缀缓存、上下文缓存、KV cache reuse,本质是把一段稳定前缀的 KV 缓存起来,后续请求命中后直接复用,不再重新跑一遍前缀的 prefill 计算。
这和 KV Cache 是同一套机制,只是把缓存生命周期从“同一个请求内部”扩展到了“跨请求”。不少大模型 API 平台都实现了类似机制,并且会对命中缓存的 token 提供更低的计费单价。但要注意,前缀缓存通常要求“前缀逐字一致”,系统提示词里任何一个标点或空格的变化,都可能导致缓存无法完整命中。理解这一点后,当你发现 API 费用异常时,应该先检查请求前缀是否被拼接进了动态内容,比如把时间戳、随机数塞到了 system 部分。
5. 动手验证:一个带缓存的最小 Decoder 示例
5.1 无缓存版的朴素实现
如果只停留在概念层面,你很难真正建立直觉。这里我用一个最小可运行的 Python 示例,把 KV Cache 的完整执行过程走一遍。我故意省略 tokenizer、embedding、多层堆叠和最终 lm_head,只保留单层注意力的骨架,让你能看清到底哪一步被缓存了。
import numpy as np def softmax(x): e = np.exp(x - x.max(axis=-1, keepdims=True)) return e / e.sum(axis=-1, keepdims=True) def next_embedding(x, Wq, Wk, Wv, Wo): q = x @ Wq k = x @ Wk v = x @ Wv scores = q @ k.T / (k.shape[-1] ** 0.5) # 构造 causal mask,保证当前位置只能看到自己和过去 n = scores.shape[0] mask = np.triu(np.ones((n, n)), k=1) * -1e9 scores = scores + mask attn = softmax(scores) out = attn @ v @ Wo return out[-1] # 只返回序列最后一个位置的输出 def no_cache_generate(x, Wq, Wk, Wv, Wo, steps): hist = x preds = [] for _ in range(steps): logits = next_embedding(hist, Wq, Wk, Wv, Wo) # 简化:直接把 logits 缩放后当作下一个 token 的 embedding next_emb = logits * 0.01 preds.append(next_emb) hist = np.concatenate([hist, next_emb[None, ...]], axis=0) return hist, preds这段代码里,每次生成一个新 token,next_embedding都会把整个历史hist重新过一遍 QKV 投影和注意力计算。序列越来越长,每次计算量越来越大,复杂度直观地体现出来。注意看那个 causal mask,它每次都要重新构造一个 n×n 的上三角掩码矩阵。
5.2 带 KV Cache 的增量解码版
接下来是带 KV Cache 的版本。核心差异在于:我们只在循环外的初始阶段对提示词算一次 K 和 V,之后每一步只处理最新那一个 token。
def kv_cache_generate(x, Wq, Wk, Wv, Wo, steps): k_cache = x @ Wk v_cache = x @ Wv hist = x preds = [] for _ in range(steps): # 关键:只用最后一个 token 计算 Q q = hist[-1][None, ...] @ Wq scores = q @ k_cache.T / (k_cache.shape[-1] ** 0.5) # 这里不需要 causal mask,因为查询只和一个已经完整的历史做注意力 attn = softmax(scores) out = attn @ v_cache @ Wo next_emb = out[-1] * 0.01 preds.append(next_emb) # 新 token 进入历史,并增量更新缓存 hist = np.concatenate([hist, next_emb[None, ...]], axis=0) k_new = next_emb[None, ...] @ Wk v_new = next_emb[None, ...] @ Wv k_cache = np.concatenate([k_cache, k_new], axis=0) v_cache = np.concatenate([v_cache, v_new], axis=0) return hist, preds对比无缓存版本,你会发现带缓存的循环里不再出现历史 token 的 QKV 重复投影,不再构造因果掩码,因为当前查询向量只需要和已经生成完的历史 K 做点积,未来信息天然不存在。每一步的计算量只是“一个 token 的 Q 和 K/V 投影 + 一行注意力计算”,复杂度从每次 O(n²) 降到了 O(n)。
5.3 运行验证与核心结论
用随机初始化的权重跑一次,确认两个版本的输出高度一致:
np.random.seed(0) x = np.random.randn(4, 16).astype(np.float32) Wq = np.random.randn(16, 16).astype(np.float32) * 0.1 Wk = np.random.randn(16, 16).astype(np.float32) * 0.1 Wv = np.random.randn(16, 16).astype(np.float32) * 0.1 Wo = np.random.randn(16, 16).astype(np.float32) * 0.1 h1, _ = no_cache_generate(x, Wq, Wk, Wv, Wo, 5) h2, _ = kv_cache_generate(x, Wq, Wk, Wv, Wo, 5) print(h1.shape, h2.shape)运行后,两个版本生成的序列形状完全一样,浮点数值也一致。这个结果说明:KV Cache 不是在改变模型的计算语义,它只是把历史计算结果提前存好,避免重复劳动。每次生成新 token 时,新 Q 和历史 K 的注意力计算是必须的,但历史 K 的投影计算不是必须的——这就是它的全部秘密。
6. KV Cache 的工程化优化:GQA、窗口缓存、量化与位置编码
6.1 模型结构侧:MQA/GQA 怎么减少缓存量
模型自身的结构设计对 KV Cache 的影响最大。标准 MHA 每个 Query 头都有独立的 K 和 V,头数越多,缓存越大。GQA 的思路是在多个 Query 头之间共享同一组 K/V 头,比如 32 个 Query 头只配 8 个 KV 头,缓存量直接降到四分之一。MQA 更极端,所有 Query 头共享一个 KV 头,缓存量降到 h 分之一,但质量损失会略微增大,一般只有对效果不那么敏感的场景才会用。
实际上,从 Llama-2 开始,几乎所有能支撑长上下文的开源模型都在解码层采用 GQA。我做模型选型时,如果当前硬件显存有限,会优先看模型的 KV 头数配置,而不是只看参数量。两个参数量相同的模型,如果 KV 头数差四倍,长上下文下的实际吞吐可能差出一大截。
6.2 推理系统侧:vLLM 的 PagedAttention 思路
模型结构再优化,缓存还是要占显存。vLLM 引入的 PagedAttention 是把 KV Cache 像操作系统虚拟内存一样按页管理。传统推理框架为每个请求预留连续显存,长对话时很容易产生内部碎片,空闲显存利用率不高。PagedAttention 把 KV Cache 切成固定大小的块,按需分配,通过 KV block 映射表和索引来拼接逻辑连续但物理不连续的缓存。
这个设计对高并发场景特别重要。多个请求可以共享相同的系统提示词前缀块,而无需各自复制一份完整缓存。比如 100 个并发请求都有相同的 system prompt,那么这部分 KV 只需要存一份,每个请求通过索引引用即可。我在做服务端部署时实测过,光这一项就能让显存占用在并发场景下降不少,吞吐提升也很明显。
6.3 数值侧:KV 量化的经验
除了结构,KV Cache 本身可以量化。常见做法是把 fp16 的 K/V 压到 int8 或 fp8,缓存体积减半甚至减到四分之一。但量化的坑在于 K 和 V 的敏感度不一样:K 需要保留通道方向的数值分布,适合 per-channel 量化;V 的误差会沿 token 方向累积,更适合 per-token 量化。
我在量化 KV Cache 时踩过几次坑,最明显的是直接用简单的 min/max 归一化去量化 K,长文本生成时效果会突然崩坏。后来改成用一批有代表性的 prompt 做校准,统计 K 和 V 的数值范围,再按通道/按 token 分别设置缩放因子,效果才稳定下来。如果你只是想在本地快速减显存,可以先用 int8 量化 V,观察效果,再考虑压 K。
6.4 位置编码与缓存:RoPE 带来的隐藏细节
最后提一个容易被忽略的细节:RoPE 旋转位置编码只作用于 Q 和 K,不作用于 V。在建 KV Cache 时,有些推理框架直接缓存旋转后的 K,这样新 Q 与历史 K 点积时,相对位置信息已经包含在乘积里,计算最快;有些框架为了支持窗口滑动、位置偏移重映射、或者长文本外推,会缓存未旋转的 K,在每次计算注意力时按最新位置动态补旋转。
两种方案没有绝对优劣,前者省时间,后者更灵活。但如果排查长文本下注意力异常,可以留意一下框架这个选择。比如模型明明支持 32K 上下文,超过某个长度后效果断崖式下跌,除了 ROPE 本身的外推限制,也值得检查 KV Cache 里的 K 是否在滑动窗口滚动后丢失了应有的位置旋转信息。
7. 常见问题排查与速查表
7.1 一张问题诊断表
下面这张表是我在实际部署和调优中经常参考的速查表,按现象排到根因,最后给处理建议:
| 现象 | 根因 | 排查/处理建议 |
|---|---|---|
| 上下文一长就 OOM | KV Cache 线性增长,加上激活值占用 | 降低 max_length,开启 sliding window,使用 KV 量化,换 GQA 模型 |
| 首 token 极慢但后续 token 正常 | prefill 阶段需要先算完整前缀的 KV,这是正常流程 | 优化提示词长度,或使用前缀缓存复用 |
| 每个 token 都像首 token 一样慢 | KV Cache 没有生效,或缓存每次都被清空 | 检查推理框架参数,确认没有每次重新 prefill |
| API 费用异常偏高 | 请求前缀变化导致前缀缓存命中率低 | 保持 system 和 few-shot 部分逐字一致,不要把动态内容塞进前缀 |
| 长上下文中间内容被忽略 | Lost in Middle 或缓存窗口被截断 | 关键信息放开头/结尾,或通过检索注入,检查 sliding window 大小 |
| KV 量化后质量明显下降 | 量化策略或校准集不合适 | K 用 per-channel、V 用 per-token 量化,校准 prompt 贴近真实场景 |
7.2 两个我自己常用的验证技巧
如果你怀疑某个推理框架到底有没有开 KV Cache,有个很简单的验证方法:对比首 token 耗时和后续每个 token 的耗时。首 token 因为要 prefill 整段上下文,慢是正常的;但如果后续每个 token 的耗时都和首 token 差不多,那说明框架八成在每一步都重新算了一遍完整序列,等于 KV Cache 没生效。
另一个技巧是看显存曲线。拿上面 Llama-2 7B GQA 的例子,一个 token 的 KV Cache 约 128KB。启动模型后记下初始显存,再跑几轮对话,观察显存增长量除以新增 token 数,如果明显大于预期,可能是激活值缓存太多,或者 KV 被复制了多份。这个办法在排查服务端并发场景下的显存泄漏尤其好用。
我自己在实际操作中的体会是,KV Cache 的优化是典型的一分钱一分货:模型结构是天花板,推理框架是地板,量化是在地板上再往下挖一点空间。刚开始不用追求一次性把缓存压到最小,先把“为什么需要它”和“缓存哪些张量”这两件事彻底弄明白,再去看 vLLM、llama.cpp 这些框架的配置参数,会顺畅很多。多跑几次上面的最小示例,亲手算一遍显存账,你以后遇到“长对话卡顿”“长上下文 OOM”这类问题,基本一眼就能定位到根因。