ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

KV Cache 深度解析:大模型推理显存优化与 GQA、PagedAttention 实战

KV Cache 深度解析:大模型推理显存优化与 GQA、PagedAttention 实战

1. 为什么每个搞大模型的人都绕不开 KV Cache

如果你部署过任何一个像样的语言模型,不管是 7B 还是 70B,你迟早会撞上同一个问题:显存不够用,吞吐上不去,延迟下不来。很多人第一反应是去优化模型权重、量化、剪枝,折腾一圈发现效果有限。真正卡脖子的地方,往往不在权重本身,而在推理过程中那个被反复读写、疯狂吃显存的东西——KV Cache。

我最早接触这个概念是在做本地部署的时候。当时用一张消费级显卡跑一个 13B 的模型,单条对话还行,一旦并发上来,显存直接爆掉,服务直接挂。查了半天日志,发现显存占用跟上下文长度几乎成正比增长,而且增长得非常夸张。后来才明白,这就是 KV Cache 在作祟。它既是让自回归生成变得高效的关键机制,也是吃掉显存、限制并发和上下文长度的头号元凶。

这篇文章我想把 KV Cache 这件事从头到尾讲透。不是那种只告诉你"缓存 Key 和 Value 可以加速"的泛泛之谈,而是从它为什么存在、它到底占多少显存、怎么算、怎么优化、工程上怎么落地这几个角度,把我在实际部署和调优中踩过的坑、算过的账、用过的方案都摊开来讲。适合正在做推理优化、模型部署、或者单纯想把大模型原理搞明白的读者。哪怕你只是用 Ollama 或者 vLLM 跑过模型,看完也能明白那些显存数字背后到底发生了什么。

2. KV Cache 的本质:用空间换时间的经典交易

2.1 自回归生成到底在重复算什么

要理解 KV Cache,得先理解 Transformer 解码器是怎么生成文本的。大模型生成是自回归的,也就是一个 token 一个 token 往外蹦。每生成一个新 token,模型都要把"到目前为止的所有 token"重新过一遍注意力计算。

问题就出在这里。假设你要生成第 100 个 token,按照最朴素的做法,模型需要把前面 99 个 token 加上当前这个,一共 100 个 token 全部送进网络,重新计算一遍注意力。而生成第 101 个 token 时,又要重新算这 101 个。这种重复计算是巨大的浪费,因为前面那些 token 的 Key 和 Value 投影结果,其实根本没有变。

注意力的计算公式是:

Attention(Q, K, V) = softmax(QK^T / sqrt(d)) V

其中 Q 是当前 token 的查询向量,K 和 V 是序列中所有 token 的键和值。对于已经处理过的历史 token,它们的 K 和 V 是固定的,不会因为后面来了新 token 而改变。既然不变,为什么每次都要重算?答案就是——缓存起来。

2.2 KV Cache 的核心思想

KV Cache 做的事情非常直白:把每一层、每个历史 token 计算出来的 Key 和 Value 张量存下来,生成新 token 时直接复用,只计算当前这个新 token 的 Q、K、V。

这样一来,每生成一个 token,注意力计算从"对全序列重算"变成了"当前 token 的 Q 去和缓存的 K、V 做一次注意力"。计算复杂度从每步 O(n²) 降到 O(n),整个生成过程的复杂度从 O(n³) 降到 O(n²)。这个提升是数量级的,没有 KV Cache,现在的大模型推理根本不可能实用。

用一个生活化的类比:你在写一份长报告,每写一句都要回头把前面所有句子重新读一遍才能保证连贯。KV Cache 相当于你给前面每句话都做了摘要卡片,写新句子时只需要翻卡片,不用重读全文。卡片就是缓存,翻卡片就是复用。

2.3 为什么是 Key 和 Value,不是 Query

这里有个很多人会疑惑的点:为什么只缓存 K 和 V,不缓存 Q?

原因在于 Q 的角色。Query 是"当前这个 token 想查询什么",它只跟当前 token 有关,每个新 token 都会产生一个新的 Q,历史 token 的 Q 在后续计算中根本用不到。而 K 和 V 是"历史 token 提供的信息",它们会被后续所有 token 的注意力反复查询,所以必须缓存。

换句话说,Q 是一次性的,K 和 V 是长期资产。缓存长期资产,丢弃一次性消耗品,这就是 KV Cache 的经济学。

3. 算一笔账:KV Cache 到底吃掉多少显存

3.1 显存占用的计算公式

很多人对 KV Cache 的显存占用没有概念,觉得"缓存嘛,能有多大"。实际算一下你就知道为什么它这么可怕。

KV Cache 的显存占用公式是:

KV Cache 显存 = 2 × batch_size × seq_len × num_layers × num_kv_heads × head_dim × dtype_bytes

逐个解释这些参数:

  • 2:因为要存 Key 和 Value 两份
  • batch_size:并发处理的序列数
  • seq_len:序列长度,也就是上下文长度
  • num_layers:Transformer 层数
  • num_kv_heads:KV 注意力头数量(注意不是 Q 的头数,后面讲 GQA 会展开)
  • head_dim:每个注意力头的维度
  • dtype_bytes:数据类型字节数,FP16 是 2,FP8 是 1,INT8 是 1

3.2 一个真实模型的显存账

拿一个典型的 13B 模型举例,假设配置是:40 层,hidden size 5120,32 个注意力头,head_dim 128,FP16 精度。

在标准的多头注意力(MHA)下,num_kv_heads 等于 num_heads,也就是 32。那么单个 token 的 KV Cache 占用是:

2 × 1 × 1 × 40 × 32 × 128 × 2 = 655,360 字节 ≈ 0.625 MB

看起来不多?但这是单个 token。如果上下文长度是 4096,单个序列就是:

0.625 MB × 4096 ≈ 2.56 GB

如果并发 8 条序列,就是 20.5 GB。而模型权重本身 FP16 下大约 26 GB。也就是说,KV Cache 的显存占用几乎和模型权重一个量级,甚至在高并发长上下文场景下会超过权重。

这就是为什么很多人发现:模型能加载进去,但一跑长对话就 OOM。权重是固定的,KV Cache 是随上下文和并发动态增长的,它才是那个不可控的变量。

3.3 不同配置下的显存对比

我把几种常见配置的 KV Cache 占用整理成表格,方便你对照自己的场景估算:

模型规模层数KV头数上下文并发FP16 KV Cache
7B323220481约 0.5 GB
7B323281928约 8 GB
13B403240968约 20 GB
70B808 (GQA)819216约 21 GB
70B8064 (MHA)819216约 168 GB

最后一行那个 168 GB 不是笔误。70B 模型如果用标准 MHA,KV Cache 会大到离谱,这也是为什么现在几乎所有大模型都改用 GQA 的原因。这个表你可以直接拿去估算自己的部署方案,把并发和上下文代进去,心里就有数了。

提示:估算时一定要把 batch_size 和 seq_len 都考虑进去,很多人只算单条序列,结果上线一并发就崩。

4. 从 MHA 到 GQA:KV Cache 优化的主战场

4.1 多头注意力为什么这么费缓存

标准的多头注意力(MHA,Multi-Head Attention)里,每个注意力头都有自己独立的 Q、K、V 投影。32 个头就是 32 组 K 和 V,全部要缓存。头越多,缓存越大。

但研究者后来发现,KV 头其实没必要和 Q 头一样多。Q 需要多个头来捕捉不同的查询视角,但 K 和 V 提供的信息可以在多个 Q 头之间共享。这个洞察直接催生了 MQA 和 GQA。

4.2 MQA:激进但有效的共享

MQA(Multi-Query Attention)的做法是:所有 Q 头共享同一组K 和 V。也就是说 num_kv_heads = 1。

这样 KV Cache 直接缩小到原来的 1/num_heads。32 个头的话,缓存变成原来的 1/32,效果立竿见影。但代价是模型质量会下降,因为 K 和 V 的表达能力被压缩得太狠,注意力机制失去了部分区分度。

MQA 在推理速度上很香,但质量损失让很多团队不敢直接用。

4.3 GQA:质量和效率的平衡点

GQA(Grouped-Query Attention,分组查询注意力)是目前的主流方案。它的思路是折中:把 Q 头分成若干组,每组共享一组 K 和 V。

比如 32 个 Q 头,分成 8 组,每组 4 个 Q 头共享一组 KV,那么 num_kv_heads = 8。KV Cache 缩小到原来的 8/32 = 1/4,质量损失却比 MQA 小得多。

这也是为什么 Llama 2 70B、Llama 3、Qwen 等主流模型都用 GQA。它把 KV Cache 这个显存大户压到了一个可接受的范围,同时保住了模型效果。

注意力类型KV头数相对缓存大小质量典型使用者
MHA= Q头数100%最好早期模型
GQAQ头数/组数25% 左右接近 MHALlama 3、Qwen
MQA13% 左右有损失部分推理优化场景

4.4 实操心得:选模型时先看 KV 头数

我在选型的时候有个习惯:拿到一个模型,先看它的 config 里 num_key_value_heads 是多少。如果这个数等于 num_attention_heads,说明是标准 MHA,部署时显存压力会很大,并发上不去。如果是 GQA,心里就踏实一半。

这个细节很多人忽略,但它直接决定了你的部署成本和并发能力。同样参数量的两个模型,GQA 的那个可能能多扛 3 到 4 倍的并发。

5. PagedAttention:把操作系统内存管理搬进显存

5.1 传统 KV Cache 的浪费在哪

即便用了 GQA,KV Cache 的显存管理还是有问题。传统做法是为每条序列预分配一块连续的显存,大小按最大可能长度算。

这就带来两个浪费:

第一,预留浪费。你按 4096 长度预分配,但实际对话可能只用了 500 个 token,剩下 3596 个位置的显存白白占着,别的请求用不了。

第二,碎片浪费。不同序列长度不一,连续分配会导致显存碎片,最后明明总量够,却找不到一块连续空间。

实测下来,传统方案下 KV Cache 的显存利用率经常只有 20% 到 40%,大部分显存都在空转。

5.2 PagedAttention 的核心机制

vLLM 提出的 PagedAttention 借用了操作系统的虚拟内存分页思想。它把 KV Cache 切成固定大小的块(block),比如每块存 16 个 token 的 KV。序列的 KV 不再要求连续存放,而是通过一张块表(block table)把逻辑上的连续序列映射到物理上分散的块。

这样做的好处:

  • 按需分配:用到多少分配多少,不再预留
  • 消除碎片:块大小固定,任何空闲块都能用
  • 共享前缀:多条序列如果开头相同(比如相同的 system prompt),可以共享同一批块,省显存

vLLM 的实测数据显示,PagedAttention 能把 KV Cache 的显存利用率从不到 40% 提升到 90% 以上,吞吐量提升 2 到 4 倍。这个提升在长上下文和高并发场景下尤其明显。

5.3 前缀共享:一个被低估的省钱技巧

PagedAttention 带来的前缀共享能力,在实际业务里价值巨大。很多应用场景下,大量请求共享同一段很长的 system prompt 或者 few-shot 示例。传统方案下,每个请求都要为这段前缀单独存一份 KV,浪费惊人。

用 PagedAttention,这段前缀的块只存一份,所有请求的块表都指向它。我做过一个测试,在一个 system prompt 长达 2000 token 的场景下,开启前缀共享后显存占用直接降了 60% 以上,并发能力翻倍。

注意:前缀共享要求前缀完全一致,包括每一个 token。差一个字符都会导致无法共享,所以设计 prompt 模板时要尽量标准化。

6. 工程落地:KV Cache 优化的实操清单

6.1 量化 KV Cache

除了结构优化,最直接的省显存手段是量化 KV Cache。把 KV Cache 从 FP16 降到 FP8 或 INT8,显存直接减半。

现在 vLLM、TensorRT-LLM 等框架都支持 KV Cache 量化。FP8 量化对质量影响很小,基本可以无脑开。INT8 稍微激进一点,但在大多数场景下也够用。

我的一般建议是:显存紧张就上 FP8,还紧张再考虑 INT8,同时监控输出质量有没有明显退化。

6.2 合理设置 max_model_len 和并发

很多人部署时把 max_model_len 设成模型支持的最大值,比如 128K,觉得越大越好。但实际上,max_model_len 直接决定了 KV Cache 的峰值占用。设成 128K,框架就要为可能的 128K 长度准备资源。

我的做法是根据实际业务需求设置。如果业务里 99% 的请求都在 8K 以内,那就设 8K 或 16K,别为了那 1% 的极端情况牺牲整体并发。

6.3 调度策略:抢占和交换

当显存真的不够时,框架需要决定牺牲谁。常见策略有两种:

  • 交换(Swap):把暂时不活跃序列的 KV Cache 换出到 CPU 内存,需要时再换回来
  • 重计算(Recompute):直接丢弃某些序列的 KV Cache,需要时重新算一遍

交换省算力但费带宽,重计算省显存但费算力。vLLM 会根据情况自动选择。理解这个机制有助于你判断为什么某些请求会突然变慢——很可能是它的 KV Cache 被换出或丢弃了。

6.4 常见问题速查表

现象可能原因排查方向
加载成功但一对话就 OOMKV Cache 峰值超显存降 max_model_len、开量化、减并发
并发上不去KV Cache 占用过高换 GQA 模型、开 PagedAttention
长对话越来越慢KV Cache 换出/重计算检查显存余量、调调度参数
显存利用率低未用分页或碎片严重启用 PagedAttention、开前缀共享
输出质量下降KV 量化过度从 INT8 退回 FP8 或 FP16

7. 我在实际调优中的几点体会

KV Cache 这个东西,理论看着简单,真到工程里全是细节。我踩过最深的坑是早期用连续分配方案,显存明明显示还有 10G,但新请求就是进不来,最后发现是碎片问题,换成 PagedAttention 后立刻顺畅。

另一个体会是,不要迷信参数。同样标称 32K 上下文的模型,实际能稳定跑多长,取决于你的显存、并发和框架实现。我一般会做压力测试,从短到长逐步加,找到那个"再长就崩"的临界点,然后留 20% 余量作为生产配置。

还有一点,KV Cache 的优化不是孤立的。它和批处理策略、调度算法、量化方案是联动的。单独调一个参数往往效果有限,要整体看。比如你开了 PagedAttention,就可以更激进地提高并发,因为显存利用率上去了;反过来如果还用连续分配,提高并发只会加速 OOM。

最后分享一个小技巧:监控 KV Cache 的实际占用率。vLLM 会暴露 gpu_cache_usage 这类指标,盯着它看,你就能知道自己的显存到底是被浪费了还是真的用满了。这个数字比总显存占用有用得多,它直接告诉你还有多少优化空间。

返回列表