1. 为什么每个搞大模型推理的人都绕不开 KV Cache
如果你最近在折腾大模型本地部署,或者读过 vLLM、TensorRT-LLM、SGLang 这些推理框架的源码,一定见过KV Cache这个词。它不是什么新鲜概念,但绝对是决定推理速度和显存占用的头号选手。我第一次在 24G 显存的卡上跑 7B 模型时,发现 batch size 死活上不去,显存监控里一大块都被一个叫 “kv_cache” 的东西吃掉了,当时就意识到:不把 KV Cache 搞明白,推理优化根本无从谈起。
简单说,KV Cache 是大模型自回归生成过程中,把历史 token 的 Key 和 Value 张量缓存下来,避免每生成一个新 token 都重新计算整段序列的注意力。没有它,生成第 N 个 token 的计算量会随序列长度线性增长,推理速度会慢到无法接受。有了它,每步只需要计算当前 token 的 Q、K、V,然后和缓存拼接即可。这个优化带来的加速比,在长上下文场景下可以达到几十倍甚至上百倍。
这篇文章适合谁看?如果你正在做大模型推理优化、私有化部署、显存调优,或者单纯想搞懂 Transformer 推理时显存到底花在哪了,那这篇内容就是为你准备的。我会从原理讲到实现,从朴素 KV Cache 讲到PagedAttention和分组查询注意力(GQA),再配上实际部署中踩过的坑和调参经验。不堆公式,尽量用生活化的类比把事说清楚。
2. KV Cache 到底缓存了什么:从注意力计算说起
2.1 自回归生成的核心矛盾
大模型的文本生成是自回归的:给定 prompt,模型预测下一个 token,然后把新 token 拼到输入后面,再预测下一个,如此循环。问题在于,标准 Transformer 的注意力机制每一步都要对整段序列计算 Q、K、V。假设序列长度是 L,那每生成一个 token,就要做一次 O(L²) 的注意力计算。生成 1000 个 token,就是 1000 次 O(L²),其中大量计算是重复的——因为历史 token 的 K 和 V 根本没变。
这就像你每次翻书查资料,都要把前面所有页重新读一遍。正常人不会这么干,而是记住之前读过的内容。KV Cache 做的就是这件事:把每一层、每个注意力头在历史位置上算出来的 K 和 V 存起来,下一步直接拿来用。
2.2 缓存的具体形状与显存占用
以 LLaMA 架构为例,KV Cache 的形状通常是[batch_size, num_layers, num_kv_heads, seq_len, head_dim]。注意这里有个关键点:num_kv_heads 不一定等于 num_heads,这就是 GQA 的由来,后面会细讲。
显存占用怎么算?假设一个 7B 模型,32 层,hidden size 4096,32 个注意力头,head_dim 128,FP16 精度(2 字节)。如果不用 GQA,num_kv_heads = 32,那么每个 token 的 KV Cache 大小是:
2 (K和V) × 32 (层) × 32 (头) × 128 (head_dim) × 2 (字节) = 524288 字节 ≈ 0.5 MB看起来不大,但序列长度到 4096 时,单个请求就是 2GB。如果 batch size 是 16,直接 32GB 显存没了。这就是为什么长上下文和高并发不能兼得——KV Cache 是线性增长的,而显存是固定的。
提示:很多人以为模型权重占显存大头,实际上在长序列、大 batch 场景下,KV Cache 往往才是显存杀手。部署时一定要把这块算清楚。
2.3 为什么不能简单丢掉缓存
有人会想,能不能只保留最近几个 token 的缓存?可以,但会损失长距离依赖。大模型之所以能处理长文档,靠的就是注意力能看到很远的位置。如果粗暴截断 KV Cache,模型就会“失忆”,回答质量断崖式下跌。所以工业界的方向不是丢弃,而是更高效地管理——这就引出了 PagedAttention。
3. 从朴素实现到 PagedAttention:KV Cache 的管理进化
3.1 朴素 KV Cache 的三大浪费
最直接的实现是给每个请求预分配一块连续显存,大小按最大序列长度算。这种做法有三个明显问题:
第一,内部碎片。你按 4096 长度预分配,但实际请求可能只生成 200 个 token,剩下 3896 个位置全浪费了。第二,外部碎片。不同请求的缓存块大小不一,频繁分配释放后,显存里会出现很多零散空洞,最后明明总空闲够,却找不到一块连续空间。第三,无法共享。多个请求如果有相同的前缀(比如相同的 system prompt),它们的 KV Cache 其实可以复用,但连续分配模式下很难做到。
我早期用 HuggingFace 的generate接口时就吃过这个亏:并发一高就 OOM,但nvidia-smi看显存占用率又没满,典型碎片问题。
3.2 PagedAttention 的核心思想:把显存当操作系统管
vLLM 提出的PagedAttention借鉴了操作系统的虚拟内存分页机制。它把 KV Cache 切成固定大小的 block(比如 16 个 token 一块),每个请求维护一张 block table,记录自己的逻辑块到物理块的映射。物理块可以不连续,按需分配。
这样做的好处非常直接:
- 消除内部碎片:最后一块没填满也没关系,浪费最多一个 block。
- 消除外部碎片:所有 block 大小一致,显存池化管理,随用随取。
- 支持前缀共享:相同前缀的请求可以指向同一批物理块,用引用计数管理,写时复制。
实测下来,vLLM 在相同显存下能把吞吐量提升 2 到 4 倍,长上下文场景提升更明显。这也是为什么现在几乎所有主流推理框架都在抄这套思路。
3.3 Block 大小怎么选:一个容易被忽略的调参点
PagedAttention 的 block size 默认是 16,但这个值不是随便定的。block 太小,block table 会很大,管理开销上升;block 太大,内部碎片又会增加。经验上:
| Block Size | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 8 | 碎片小,共享粒度细 | 表大,调度开销高 | 短序列、高并发 |
| 16 | 平衡 | 平衡 | 通用默认 |
| 32 | 表小,调度快 | 碎片略大 | 长序列、低并发 |
我一般先用默认 16 跑 benchmark,如果发现调度成为瓶颈(GPU 利用率上不去但 CPU 很忙),再尝试调到 32。反过来如果显存浪费严重,就降到 8 试试。
4. 分组查询注意力:从源头减少 KV Cache
4.1 MHA、MQA、GQA 的关系
PagedAttention 解决的是“怎么管”,而GQA(Grouped Query Attention)解决的是“怎么少”。标准多头注意力(MHA)里,每个 Query 头都有自己独立的 Key 和 Value 头,KV Cache 最大。多查询注意力(MQA)让所有 Query 头共享一组 K、V,缓存直接缩小到 1/num_heads,但效果会掉。GQA 是折中:把 Query 头分组,每组共享一组 K、V。
举个例子,32 个 Query 头,如果分成 8 组,那 num_kv_heads = 8,KV Cache 直接降到原来的 1/4。LLaMA 2 70B、LLaMA 3、Qwen 系列很多模型都用了 GQA,这不是偶然——在长上下文和高并发场景下,GQA 带来的显存节省是实打实的。
4.2 GQA 对推理性能的实际影响
有人担心 GQA 会损失模型质量。从公开的评测看,在同等参数量下,GQA 相比 MHA 的质量下降很小,但推理吞吐提升显著。原因在于,注意力机制里 K、V 的冗余度本来就高,共享并不会丢失太多信息。
实际部署时,如果你的模型是 MHA 架构,又想在长上下文场景下省显存,可以考虑用一些工具做注意力头的剪枝或合并,但这属于模型改造,风险较高。更稳妥的做法是直接选用原生支持 GQA 的模型。
4.3 代码层面怎么看 num_kv_heads
在 HuggingFace 的 config 里,这个参数叫num_key_value_heads。如果它等于num_attention_heads,就是 MHA;如果等于 1,就是 MQA;介于两者之间就是 GQA。加载模型时,KV Cache 的维度要按num_key_value_heads来算,千万别按num_attention_heads算,否则显存预估会差好几倍。
from transformers import AutoConfig config = AutoConfig.from_pretrained("meta-llama/Llama-2-7b-hf") print(config.num_attention_heads) # 32 print(config.num_key_value_heads) # 32 (LLaMA 2 7B 是 MHA) # LLaMA 2 70B 则是 64 和 8,GQA5. 实操:在本地部署中观测和调优 KV Cache
5.1 用 vLLM 跑一个对比实验
光说理论没感觉,直接上实验。我用一台 24G 显存的机器,跑 Qwen2.5-7B-Instruct(GQA,num_kv_heads=4),分别测试不同gpu_memory_utilization和max_model_len下的吞吐。
# 启动 vLLM 服务 python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-7B-Instruct \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ --max-num-seqs 64关键参数解释:gpu_memory_utilization控制 vLLM 预分配多少显存给模型权重加 KV Cache 池;max_model_len决定单个序列最长多少;max-num-seqs是并发请求上限。这三个参数互相制约,调大任何一个都可能挤压其他空间。
实测结果(仅供参考,不同硬件有差异):
| max_model_len | max_num_seqs | 吞吐 (tokens/s) | 是否 OOM |
|---|---|---|---|
| 4096 | 64 | 1850 | 否 |
| 8192 | 32 | 1420 | 否 |
| 8192 | 64 | - | 是 |
| 16384 | 16 | 980 | 否 |
可以看到,序列长度翻倍,并发就得砍半,否则直接 OOM。这就是 KV Cache 的线性增长在起作用。
5.2 监控 KV Cache 使用率
vLLM 的日志里会打印GPU KV cache usage,这个指标非常关键。如果它长期接近 100%,说明缓存池不够,请求会被排队甚至抢占。如果长期低于 50%,说明显存分配过多,可以调低gpu_memory_utilization给其他进程留空间。
我一般会压测到 KV cache usage 稳定在 80% 到 90% 之间,这样既不浪费显存,又有一定余量应对突发流量。
5.3 前缀共享的实战价值
如果你的应用有大量相同 system prompt 的请求(比如客服机器人、代码助手),一定要开启前缀共享。vLLM 默认支持,只要请求的 prompt 前缀一致,对应的 KV Cache block 就会被复用。我测过一个场景:system prompt 有 800 个 token,100 个并发请求,开启前缀共享后首 token 延迟从 320ms 降到 90ms,效果立竿见影。
注意:前缀共享要求 prompt 完全一致,包括空格和换行。如果 system prompt 里有动态内容(比如时间戳),共享就会失效。设计 prompt 时尽量把固定部分放前面。
6. 常见问题与排查技巧实录
6.1 为什么我的显存明明够,还是 OOM
这是最常被问到的问题。原因通常有三个:一是显存碎片,尤其是用非 PagedAttention 的框架时;二是 KV Cache 预估错误,比如按 MHA 算了但模型其实是 GQA,或者反过来;三是max_model_len设得太大,预分配就占满了。
排查顺序:先看框架日志里的 KV cache 分配量,再对比理论计算值。如果差很多,检查num_key_value_heads和精度设置。如果理论值就对不上,那就是碎片问题,换用支持 PagedAttention 的框架。
6.2 长上下文推理特别慢怎么办
长上下文慢,一部分是注意力计算本身 O(L²) 的锅,另一部分是 KV Cache 读写带宽的锅。优化方向:一是用 GQA 模型,从源头减少缓存量;二是开启 chunked prefill,把长 prompt 分块处理,避免一次性占用过多缓存;三是考虑量化 KV Cache,比如 FP8 或 INT8,能再省一半显存,但要注意精度损失。
6.3 并发上不去,GPU 利用率却很低
这种情况往往是调度瓶颈,不是计算瓶颈。检查 block size 是否太小导致 block table 管理开销过大,或者max-num-seqs设得太保守。另外,如果请求的输入输出长度差异很大,调度器可能被长请求拖住,可以尝试开启抢占式调度,让短请求优先完成。
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| OOM 但显存监控未满 | 碎片或预估错误 | 换 PagedAttention,核对 num_kv_heads |
| 长上下文慢 | 注意力计算 + 缓存带宽 | GQA、chunked prefill、KV 量化 |
| 并发低、GPU 闲 | 调度瓶颈 | 调 block size、max-num-seqs、抢占调度 |
| 首 token 延迟高 | 前缀未共享 | 固定 prompt 前缀,开启前缀缓存 |
6.4 一个容易被忽略的坑:精度与缓存
KV Cache 的精度默认跟随模型精度。如果模型是 FP16,缓存也是 FP16。有人为了省显存把缓存改成 FP8,结果发现输出质量下降明显,尤其是需要精确回忆细节的任务。我的建议是:先保证质量,再谈省显存。如果确实要量化,先在小流量下做 A/B 测试,确认质量可接受再全量。
7. 我对 KV Cache 优化的一点个人体会
折腾了这么多轮,我最大的感受是:KV Cache 不是孤立的技术点,它和模型架构、推理框架、硬件显存三者深度耦合。选模型时看 GQA,选框架时看 PagedAttention,调参时看显存和吞吐的平衡。任何一环没考虑到,都会在压测时暴露出来。
另外,不要迷信默认参数。vLLM 的默认值适合通用场景,但你的业务可能有特殊的长短分布。花半天时间做一轮参数扫描,往往比换硬件更有效。我见过太多人一 OOM 就想着加卡,其实把max_model_len从 16384 降到 8192,再配合前缀共享,同样的卡就能多扛一倍并发。
最后分享一个小技巧:压测时用真实业务的 prompt 分布,别用随机生成的等长序列。真实场景里长短差异极大,调度器的表现和均匀分布完全不同。这个细节,很多官方 benchmark 不会告诉你,但实际部署时能帮你少走很多弯路。