资讯详情

KV Cache 深度解析:从计算原理到 PagedAttention 工程实战

📅 2026/10/27 20:14:44 | 华诺云谱 👁 阅读
KV Cache 深度解析:从计算原理到 PagedAttention 工程实战
1. 为什么每个聊大模型的人都绕不开 KV Cache如果你最近在折腾大模型推理部署不管是拿 vLLM 跑 Qwen还是用 llama.cpp 在本地加载一个 7B 模型你大概率会在日志里、显存监控里、或者各种技术群里反复撞见一个词——KV Cache。很多人第一次看到它的时候会下意识觉得这是个缓存优化的小技巧跟 Redis 缓存数据库查询结果差不多属于锦上添花的东西。但实际情况恰恰相反KV Cache 不是优化项它是自回归生成这件事能不能跑起来的必要条件之一。没有它你每生成一个 token 都要把前面所有 token 重新算一遍推理成本会随序列长度呈平方级增长别说部署了demo 都跑不动。我拿一个具体的数字让你感受一下。假设模型有 32 层隐藏维度 4096你用 FP16 精度推理。每生成一个 token如果不做缓存注意力机制需要重新计算整个序列的 Key 和 Value 矩阵。序列长度到 2048 的时候光是 K 和 V 的重复计算量就已经非常夸张了。而有了 KV Cache 之后每个 token 的 K、V 只算一次后续直接复用计算量从 O(n²) 降到 O(n)。这个差距不是快一点和慢一点的区别是能用和不能用的区别。但 KV Cache 带来的问题同样棘手它吃显存。而且吃得非常凶。一个 13B 模型在长上下文场景下KV Cache 占用的显存甚至可能超过模型权重本身。这就引出了一连串工程上的核心问题——怎么管理这些缓存块、怎么减少缓存体积、怎么在有限显存里塞进更多并发请求。PagedAttention、分组查询注意力GQA、MQA 这些技术本质上都是在回答KV Cache 太占地方怎么办这个问题。这篇文章我打算把 KV Cache 这件事从头到尾讲透。不是那种只给你一个公式就完事的科普而是把它的计算逻辑、显存账本、工程管理方案、以及实际部署中会踩的坑都摊开来说。适合已经跑过至少一次大模型推理、想搞清楚底层到底发生了什么的人。如果你还没跑过也没关系我会尽量用生活化的类比把原理讲清楚你跟着看下去不会卡壳。2. KV Cache 的计算逻辑从自回归生成的重复劳动说起2.1 自回归生成到底在重复算什么大模型生成文本的方式是自回归的也就是一个 token 一个 token 往外蹦。你给它一个 prompt它预测下一个 token然后把新 token 拼到输入后面再预测下一个如此循环。问题就出在这个拼到输入后面再预测的动作上。在 Transformer 的注意力机制里每个 token 都会生成三个向量QueryQ、KeyK、ValueV。注意力计算的过程是当前 token 的 Q 去和序列里所有 token 的 K 做点积得到注意力权重再用这些权重去加权求和所有 token 的 V。注意这里的关键是所有 token 的 K 和 V。那么在没有缓存的情况下当你生成第 n 个 token 时你需要为前面 n-1 个 token 重新计算一遍 K 和 V。而生成第 n1 个 token 时又要为前面 n 个 token 再算一遍。前面那些 token 的 K 和 V 明明没变过却每次都被重新计算这就是纯粹的重复劳动。打个比方。你在做一道需要查历史资料的题每写一个字都要把前面查过的所有资料重新翻一遍。资料内容根本没变但你每次都要重新翻时间全浪费在重复翻资料上了。KV Cache 做的事情就是把查过的资料记在便签上下次直接用便签不再翻原书。2.2 缓存命中之后每步到底省了多少有了 KV Cache生成第 n 个 token 时前面 n-1 个 token 的 K、V 直接从缓存里取只需要计算当前这个新 token 的 Q、K、V。然后把新 token 的 K、V 追加到缓存里用新 token 的 Q 去和缓存里所有的 K 做注意力计算。这里有一个容易被忽略的细节缓存的 K、V 是逐层独立的。Transformer 有 L 层每一层都有自己的注意力模块所以每一层都要维护一份自己的 KV Cache。这也是为什么 KV Cache 的显存占用要用层数去乘。从计算量上看不做缓存时生成第 n 个 token 的注意力计算复杂度是 O(n·d)其中 d 是维度做完整序列生成时总复杂度是 O(n²·d)。有了缓存之后单步复杂度还是 O(n·d)但省掉了 K、V 的重复投影计算。真正的大头在于没有缓存时K、V 的线性投影也就是乘以 W_k、W_v 矩阵要重复做 n 次有缓存后只做一次。这个线性投影的计算量在长序列下非常可观。我用一个更直观的对比表格来说明场景每步 K/V 投影计算每步注意力计算总计算量趋势无 KV Cache需要重算全部历史 tokenO(n·d)O(n²·d)有 KV Cache只算当前 tokenO(n·d)O(n·d) 投影 O(n²·d) 注意力注意最后一行注意力部分仍然是 O(n²·d) 的总量因为每个新 token 都要和前面所有 token 做注意力。KV Cache 省掉的是投影部分的重复计算不是注意力本身。这一点很多人会搞混以为有了 KV Cache 注意力就变成线性了并不是。2.3 一个具体的显存账本光说原理不够我们算一笔实际的账。假设你部署一个 LLaMA-2 13B 模型FP16 精度具体参数如下层数 L 40注意力头数KV 头数n_kv_heads 40每头维度 d_head 128精度 2 字节FP16每个 token 的 KV Cache 大小 2K 和 V× L × n_kv_heads × d_head × 2 字节。代入数字2 × 40 × 40 × 128 × 2 819,200 字节约等于 0.78 MB。也就是说每存一个 token 的 KV Cache要花 0.78 MB 显存。如果你要处理一个 4096 token 的上下文单个请求的 KV Cache 就是 4096 × 0.78 MB ≈ 3.2 GB。而模型权重本身 FP16 下大约 26 GB。一张 40GB 的卡权重占 26GB剩下 14GB 给 KV Cache大概只能同时处理 4 个 4096 上下文的请求。这就是为什么长上下文和高并发在显存上是一对天然矛盾。如果换成 GQA 的模型比如 LLaMA-2 70B 用了 8 个 KV 头而不是 64 个KV Cache 直接缩小 8 倍。这就是分组查询注意力在工程上最直接的价值——它不是为了让模型更聪明而是为了让缓存更小。3. 显存墙下的工程博弈PagedAttention 与注意力变体3.1 传统 KV Cache 管理的浪费在哪里早期部署大模型的时候KV Cache 的管理方式很粗暴给每个请求预分配一块连续显存大小按最大可能序列长度来算。比如你设置 max_seq_len 是 4096那不管这个请求实际只用了 200 个 token系统都先给它留 4096 个 token 的缓存空间。这造成两种浪费。第一种是内部碎片请求实际用了 200 token但占了 4096 的空间剩下 3896 的空间空着不能用。第二种是外部碎片不同请求的缓存块大小不一释放之后留下的空洞可能没法被下一个请求利用。这两种碎片加起来显存利用率可能只有 20% 到 40%。也就是说你买了 80GB 的卡实际有效利用可能只有二三十 GB。这个问题在并发场景下尤其致命。你本来想在一张卡上跑 10 个并发请求结果因为碎片问题只能跑 3 个吞吐量直接砍掉一大半。3.2 PagedAttention 的核心思路把操作系统那套搬过来vLLM 提出的 PagedAttention 就是冲着这个碎片问题去的。它的核心思路非常朴素既然连续分配会造成碎片那就别连续分配了改成按块分配。具体做法是把 KV Cache 切成固定大小的块block比如每个 block 存 16 个 token 的 KV。一个请求的缓存不需要在显存里连续存放可以分散在多个不连续的 block 里。系统维护一张 block table记录每个请求的逻辑块和物理块的映射关系。这跟操作系统里的虚拟内存分页机制几乎一模一样。这样做的好处立竿见影。首先内部碎片被限制在最后一个 block 里最多浪费 15 个 token 的空间而不是几千个。其次block 是固定大小的释放和分配都很规整外部碎片基本消失。vLLM 的论文里给出的数据是PagedAttention 能把显存浪费控制在 4% 以内吞吐量相比传统方案提升 2 到 4 倍。还有一个更妙的点block 可以被共享。比如多个请求有相同的 system prompt那这段 prompt 的 KV Cache block 可以被多个请求共同引用不需要每个请求都存一份。这在多轮对话和批量推理场景下能省下大量显存。引用计数机制保证最后一个引用释放时才真正回收 block。3.3 GQA 和 MQA从模型结构上给缓存瘦身PagedAttention 解决的是怎么管的问题GQA 和 MQA 解决的是怎么少的问题。标准的多头注意力MHA里每个注意力头都有自己独立的 K 和 V。如果有 64 个头那 K 和 V 就有 64 份。MQAMulti-Query Attention的做法是所有头共享同一份 K 和 V只有 Q 保持多头。这样 KV Cache 直接缩小到原来的 1/64。代价是模型表达能力会下降效果可能变差。GQAGrouped-Query Attention是折中方案把 64 个头分成 8 组每组 8 个头共享一份 K 和 V。这样 KV Cache 缩小到 1/8效果损失比 MQA 小很多。LLaMA-2 70B、LLaMA-3、Qwen 系列很多模型都用了 GQA。从部署角度看GQA 带来的收益非常直接。同样一张卡用 GQA 的模型能支持的并发数或者上下文长度比 MHA 模型高好几倍。这也是为什么现在新出的模型基本都默认用 GQA很少再用纯 MHA 了。注意力类型KV 头数KV Cache 相对大小效果典型模型MHA等于 Q 头数1x最好GPT-3、早期 LLaMAGQAQ 头数的 1/8 左右约 1/8接近 MHALLaMA-2 70B、QwenMQA1约 1/64有损失PaLM、Falcon3.4 量化 KV Cache用精度换空间除了改结构还有一个方向是把 KV Cache 量化。模型权重可以量化KV Cache 同样可以。把 FP16 的缓存量化到 INT8显存直接减半量化到 INT4减到四分之一。但 KV Cache 量化比权重量化更敏感因为缓存里的数值分布会随着序列增长而变化而且注意力对 K 的精度比较敏感。实践中比较稳的做法是只量化 V或者用 per-channel 量化而不是 per-tensor 量化。INT8 量化通常效果损失很小可以放心用INT4 就要看具体模型和任务了有些场景下会明显掉点。我自己的经验是如果你的显存实在紧张优先考虑换 GQA 模型其次考虑 PagedAttention 这类管理优化最后才动量化。因为量化的收益虽然直接但调参和验证的成本不低而且不同模型的表现差异很大不能一概而论。4. 从零手写一个 KV Cache代码层面的真实逻辑4.1 不用缓存时的注意力计算长什么样要真正理解 KV Cache最好的办法是自己写一遍。我们先看不用缓存的版本。假设输入是一个序列的隐藏状态形状是 (batch, seq_len, hidden_dim)经过线性层得到 Q、K、Vimport torch import torch.nn.functional as F def attention_no_cache(x, W_q, W_k, W_v, num_heads, head_dim): batch, seq_len, hidden x.shape # 线性投影 q x W_q # (batch, seq_len, hidden) k x W_k v x W_v # 拆成多头 q q.view(batch, seq_len, num_heads, head_dim).transpose(1, 2) k k.view(batch, seq_len, num_heads, head_dim).transpose(1, 2) v v.view(batch, seq_len, num_heads, head_dim).transpose(1, 2) # 注意力计算 scores q k.transpose(-2, -1) / (head_dim ** 0.5) attn F.softmax(scores, dim-1) out attn v out out.transpose(1, 2).contiguous().view(batch, seq_len, hidden) return out这段代码每次调用都要把整个序列的 Q、K、V 重新算一遍。如果你要自回归生成 100 个 token就要调用 100 次每次都重算全部历史。4.2 加上缓存之后的增量计算有缓存之后我们只需要维护一个不断增长的 K 和 V 缓存。每次新来一个 token只算这个 token 的 Q、K、V然后把新的 K、V 拼到缓存后面class KVCacheAttention: def __init__(self, num_heads, head_dim): self.num_heads num_heads self.head_dim head_dim self.k_cache None self.v_cache None def forward(self, x, W_q, W_k, W_v): batch, seq_len, hidden x.shape q x W_q k x W_k v x W_v q q.view(batch, seq_len, self.num_heads, self.head_dim).transpose(1, 2) k k.view(batch, seq_len, self.num_heads, self.head_dim).transpose(1, 2) v v.view(batch, seq_len, self.num_heads, self.head_dim).transpose(1, 2) # 拼接缓存 if self.k_cache is not None: k torch.cat([self.k_cache, k], dim2) v torch.cat([self.v_cache, v], dim2) self.k_cache k self.v_cache v scores q k.transpose(-2, -1) / (self.head_dim ** 0.5) # 因果掩码防止看到未来 token mask torch.triu(torch.ones(seq_len, k.shape[2]), diagonalk.shape[2]-seq_len1).bool() scores scores.masked_fill(mask, float(-inf)) attn F.softmax(scores, dim-1) out attn v out out.transpose(1, 2).contiguous().view(batch, seq_len, hidden) return out注意这里的因果掩码处理。在增量解码时新 token 的 Q 只能看到缓存里所有历史 K 和当前 K不能看到未来。掩码的形状要根据当前 query 长度和总 key 长度来算这是手写时最容易出错的地方。4.3 手写过程中最容易踩的三个坑第一个坑是缓存拼接的效率。上面用torch.cat每次都会重新分配内存并复制序列长了之后开销很大。生产环境里一般用预分配的固定大小张量配合写入指针来避免反复分配。这也是 PagedAttention 要解决的问题之一。第二个坑是位置编码。有缓存之后新 token 的位置编码要接着缓存的长度来算不能从 0 开始。如果用 RoPE要确保旋转位置和实际序列位置对齐。这个错误很隐蔽模型不会报错但生成质量会明显下降。第三个坑是 batch 内不同请求的缓存长度不一致。真实部署时一个 batch 里可能有的请求已经生成了 500 个 token有的才 10 个。这时候要么用 padding 对齐要么用更复杂的变长处理。padding 会浪费计算变长处理则对实现要求更高。vLLM 的 PagedAttention 之所以复杂很大一部分原因就是在处理这种变长场景。5. 部署实战中 KV Cache 相关的真实问题排查5.1 显存明明够为什么还是 OOM这是我最常被问到的问题之一。有人算了一下模型权重占 26GB卡有 40GB觉得剩下 14GB 够用了结果一跑就 OOM。原因通常是忽略了 KV Cache 的预分配。很多推理框架会按 max_seq_len 和 max_batch_size 预分配 KV Cache。如果你设置 max_seq_len8192、max_batch_size32那框架可能一上来就预留 8192 × 32 × 0.78MB ≈ 200GB 的缓存空间当然直接爆掉。解决办法是合理设置这两个参数或者用支持按需分配的框架比如 vLLM 的 PagedAttention 就是按需分配 block。排查的时候先看框架的启动日志确认它预分配了多少显存给 KV Cache。然后根据实际业务需求调整 max_seq_len 和 max_batch_size。不要盲目设大够用就行。5.2 长上下文场景下吞吐量断崖式下跌另一个常见现象是短请求跑得好好的一旦上下文变长吞吐量就掉得厉害。这背后有两个原因。一是 KV Cache 占用随序列长度线性增长长请求会挤占其他请求的空间导致并发数下降。二是注意力计算本身是 O(n²)序列翻倍注意力计算量翻四倍。应对思路有几个。第一用 GQA 模型从源头减少缓存体积。第二开启 PagedAttention 或类似的块管理提高显存利用率。第三对 KV Cache 做量化INT8 通常安全。第四如果业务允许做请求分级长请求和短请求分开调度避免长请求拖累整体吞吐。5.3 缓存复用多轮对话和 system prompt 的优化空间多轮对话场景下每一轮都要把之前的对话历史重新作为输入。如果每轮都重新计算历史部分的 KV Cache浪费很大。实际上历史部分的 KV Cache 是可以复用的因为前面的 token 没变它们的 K、V 也不会变。vLLM 里的 prefix caching 就是干这个的。它把相同前缀的 KV Cache block 缓存下来下一个请求如果前缀相同直接复用这些 block不用重算。对于 system prompt 很长、多轮对话频繁的场景这个优化能省下大量计算。但要注意prefix caching 对前缀的匹配是精确匹配一个 token 不同就不算命中。所以如果你的 system prompt 里有动态内容比如时间戳缓存就失效了。实践中建议把动态内容放到 user message 里保持 system prompt 稳定这样缓存命中率才高。5.4 一个真实的排查案例我之前遇到过一个情况同一个模型同样的硬件A 服务跑得好好的B 服务频繁 OOM。排查下来发现A 服务用的是 vLLMB 服务用的是 HuggingFace 的默认 generate。HF 的默认实现里 KV Cache 是按 max_length 预分配的而且不支持块管理碎片严重。换成 vLLM 之后同样的并发量显存占用降了一半多。这个案例说明一个道理KV Cache 的管理策略对实际部署的影响可能比模型本身还大。选框架的时候不要只看它支持什么模型要看它的 KV Cache 管理做得好不好。PagedAttention 之所以成为现在推理框架的标配就是因为它在这一点上带来的提升太明显了。6. 几个容易被误解的 KV Cache 认知6.1 KV Cache 不是缓存计算结果那么简单很多人把 KV Cache 类比成 Web 缓存或者数据库查询缓存觉得它只是把算过的东西存起来。这个类比方向对但忽略了一个关键区别Web 缓存命中就省掉整个计算而 KV Cache 命中只是省掉 K、V 的投影计算注意力计算还是要做的。而且 KV Cache 是每个请求私有的不能跨请求共享除非前缀相同这跟共享缓存的性质完全不同。理解这一点很重要因为它决定了你优化时的方向。如果你以为 KV Cache 能省掉所有计算就会对长序列下的性能下降感到困惑。实际上注意力那部分 O(n²) 的计算是省不掉的只能通过算法改进比如稀疏注意力、滑动窗口注意力来缓解。6.2 缓存越大不一定越好有人觉得既然缓存能加速那就把缓存开大点。但缓存大小和并发能力是直接冲突的。你给单个请求分配的缓存越大能同时处理的请求就越少。在吞吐量优先的场景下合理限制单请求的缓存大小把省下来的显存用于提高并发往往整体收益更高。这其实是一个调度问题。你要根据业务特点来权衡是单请求延迟重要还是整体吞吐重要。在线服务通常更看重吞吐因为要服务大量用户离线批处理可能更看重单请求效率。没有标准答案要看具体场景。6.3 不是所有模型都适合开 KV Cache 量化KV Cache 量化听起来很美但不是万能的。有些模型对 K 的精度特别敏感量化后注意力分布会偏移生成质量明显下降。尤其是一些经过特殊训练或者微调的模型量化后的表现可能和原始模型差很多。我的建议是如果要用 KV Cache 量化一定要做充分的对比测试。不要只看 perplexity 这种指标要看你实际业务场景下的输出质量。有些任务对数值精度不敏感量化没问题有些任务则很敏感。测试成本不能省。7. 我在实际部署中总结的几条经验第一条选模型的时候优先看它用的是什么注意力机制。GQA 模型在部署上的优势是实打实的同样硬件能支持的并发和上下文长度比 MHA 模型高好几倍。如果两个模型效果差不多选 GQA 的那个。第二条推理框架的 KV Cache 管理能力比模型本身更影响部署成本。vLLM 的 PagedAttention 和 prefix caching 在实际生产里省下的显存和算力非常可观。如果还在用默认的 generate 接口建议尽早迁移。第三条max_seq_len 和 max_batch_size 这两个参数要按实际业务调不要照搬默认值或者网上的配置。先估算你的业务里典型请求有多长、并发有多少再反推这两个参数。设大了浪费显存设小了限制吞吐。第四条长上下文场景下KV Cache 量化可以作为最后的手段。优先用 GQA 和 PagedAttention这两招通常就够了。量化虽然能进一步压缩但引入的精度风险和验证成本要考虑进去。第五条多轮对话场景一定要开 prefix caching。system prompt 保持稳定不要塞动态内容。这个优化几乎零成本但收益很大尤其是对话轮次多的时候。KV Cache 这件事表面上看是个显存管理问题往深了看其实是整个推理系统设计的核心约束之一。理解了它你再看 vLLM、TensorRT-LLM 这些框架的设计决策就会明白它们为什么那么做。很多看起来复杂的设计追根溯源都是在跟 KV Cache 的显存占用做斗争。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑