☰
KV Cache与PagedAttention:LLM推理显存优化实战
2026/10/17 14:53:39 网站建设 项目流程

如果你在线上跑过 LLM 推理服务,大概率见过这个场景:模型权重明明只有十几 G,显卡一开跑,占用却直奔五六十 G。以前我调一个 7B 模型的服务,batch 从 4 提到 8,直接 OOM,查遍权重和算子,最后发现罪魁祸首是 KV Cache——Transformer 自回归生成过程中最容易被忽略、也最吃显存的一块。

这篇文章是系列的第 03 篇,专门把两件事聊透:KV Cache 为什么这么占显存,以及 PagedAttention 是怎么把它管起来的。前者是所有 LLM 推理躲不开的基础,后者是 vLLM 把吞吐做上去的关键设计。适合正在调推理服务的人、被 OOM 折磨过的人,以及想搞懂服务端 LLM 推理为什么吃显存的人。我会把公式、算账过程和踩过的坑都摊开讲,尽量让新手也能照着算。

1. KV Cache 到底在缓存什么:把重复计算变成一次性投入

1.1 自回归生成里的“重复造轮子”

Transformer 生成文本是逐 token 进行的。每生成一个新 token,模型都需要参考前面所有 token 的信息,这个机制靠的是注意力计算:当前 token 的 Query 要和历史上每个 token 的 Key 做点积,算出权重后,再对对应的 Value 做加权求和。

问题就在于“历史上每个 token”这几个字。如果不做任何缓存,生成第 5 个 token 时,要把前 4 个 token 完整重算一遍;生成第 6 个 token 时,再把前 5 个 token 完整重算一遍。每一步的计算量都随序列长度线性增长,整个生成过程的累计成本就是 O(n²)。打个比方,这就跟做饭一样,每加一道菜都要把之前所有菜重新炒一遍,才知道锅里到底有什么。

KV Cache 的思路很简单:既然下一轮计算还要用之前 token 的 Key 和 Value,那就把它们提前算好、缓存在显存里,后面直接读。实际运行时,处理输入 prompt 的阶段(prefill)会把整个 prompt 的 Key 和 Value 全部算出来存好;之后逐 token 生成的阶段(decode),每步只需要计算当前新 token 的 Key、Value,追加到缓存尾部,再用当前 token 的 Query 去跟整份缓存的 Key、Value 做注意力计算。这样一来,重复的计算消失了,历史信息变成了纯粹的显存读取。

你可能会问,Query 为什么不缓存?因为 Query 是“当前问问题的人”的身份表示,每个新 token 都有自己的 Query,用完即弃,不存在跨步骤复用。真正要被未来查询反复引用的,只有 Key 和 Value,所以缓存的对象也正好是这两个。理解这一点,后面看显存公式就不会犯糊涂。

1.2 一张公式看懂 KV Cache 的体量

KV Cache 的占用可以精确算出来,公式不复杂:

每个 token 占用的 KV 缓存 = 2(Key 和 Value 两份)× 层数 × KV 头数 × 头维度 × 数据类型字节数

举一个实际例子。以常见的 7B 规模模型为例,假设它是 32 层、32 个注意力头、每个头维度 128、FP16 精度(2 字节),那么每个 token 的 KV 缓存就是:

2 × 32 × 32 × 128 × 2 = 524,288 字节 = 512 KiB

也就是说,这个模型每多处理一个 token,就要多占半 MB 显存。单个请求上下文 4096 token,KV Cache 就是 2 GiB;如果同时跑 16 个请求,光 KV 就是 32 GiB。而模型权重本身才 14 GiB 左右,KV Cache 轻松超过权重,成为推理期显存的第一大头。

更要注意的是,decode 阶段每生成一个 token,都要把整份 KV Cache 从头到尾读一遍去做注意力计算。所以 KV Cache 不仅占显存,还烧显存带宽,它的大小直接决定你在高并发下每生成一个 token 要花多久。这也是为什么近年来的模型大量使用 GQA(分组查询注意力)而不是早期的多头注意力:KV 头数从 64 降到 8,KV Cache 直接缩小 8 倍,生成速度也明显受益。

模型配置示例层数KV 头数头维度每 token KV 占用
7B,全量多头注意力3232128512 KiB
70B,假设全量多头注意力80641282.5 MiB
70B,GQA 分组注意力808128320 KiB

看到这个数量级,你就能理解为什么推理服务调优时,KV Cache 往往比模型权重更值得优先优化。它跟着并发数和序列长度线性放大,线上稍微放开一点并发,显存就告急。

2. 朴素显存管理为什么撑不住:碎片化是真正的隐形杀手

2.1 按最坏情况预分配,短请求也占着全尺寸

既然 KV Cache 这么大,那把它管理好不就行了?问题就出在管理方式上。最朴素的做法,是在请求进来时按“最大可能长度”一次性把显存分配好:假设系统允许单请求最长 2048 token,那就给每个请求预留 2048 token 的 KV 空间。

这个方案的毛病显而易见:实际请求哪有那么规矩。线上很多请求只聊三五句话,生成的 token 也就一两百,但预留的 2048 token 空间一分不少地占着。等于你按五星级酒店的标准给每个客人订了房间,结果很多人只进来睡了个午觉,房间却整天被锁住。这类浪费叫内部碎片,在短请求占大头的业务里,显存有效利用率可能只有百分之十几。

有人会说,那分配的时候按请求实际长度来不就行了?也不行,因为生成是流式的,你根本不知道这个请求最后会聊到多长。要么承担频繁重新分配和拷贝的开销,要么干脆按上限预留,二选一的情况下,大部分早期实现都选了后者,于是浪费成了常态。

2.2 请求结束时间不一致,显存空着也用不上

就算你把预分配做得稍微聪明一点,比如按当前实际长度逐步扩容,也逃不过第二种浪费:外部碎片。线上请求长短不一,先结束的请求会先释放显存。释放出来的区域零零散散夹在仍然活跃的请求之间,新来的请求想要一块大的连续区域时,分配器只能在碎片堆里挑挑拣拣,经常挑不出一个足够大的连续块。

这跟操作系统的内存碎片是一个道理。内存明明总量还有富余,但因为有大量“中间空一块、两边被占住”的缝隙,新请求要么进不来,要么只能强行打断别的请求腾地方。我在排查一个高并发推理服务时,看到显存统计里空闲总量还有十几个 G,但新请求就是持续排队失败,就是因为空闲区域全部碎片化了。外部碎片在高并发、请求长短差异大的场景下极其严重,有效利用率腰斩是常有的事。

2.3 公共前缀重复保存,一份内容被存了很多份

还有一类浪费经常被忽略:重复。线上请求往往共享相同的系统提示词(system prompt),比如固定的角色设定、工具说明、安全约束;做 beam search 时,多个候选分支会共享同一个前缀;做并行采样时,同一个 prompt 要生成好几个不同的结果。这些场景下,前缀部分的 KV Cache 内容完全一样,可朴素实现会给每个序列各存一份。

假设一个系统 prompt 占 2048 token,同时有 16 个请求共用它,那光是这段公共前缀就白白重复存了 15 份。换算成刚才的 7B 模型,就是 15 × 1 GiB 的浪费。而且 decode 阶段每个请求都要把各自的副本从头读一遍,显存浪费的同时,带宽也被重复消耗。这个问题在 agent 类应用、多轮对话场景里尤其突出,因为你每个请求都带着一大段固定前缀来回跑。

3. PagedAttention 怎么把显存“页”起来:向操作系统取经

3.1 虚拟内存的分页思想,搬到 KV Cache 上

操作系统早就遇到过同样的碎片问题,并且给出了教科书级的解法:分页。虚拟地址空间可以连续,但物理内存按固定大小的页来分配,虚拟页可以映射到任意一个空闲物理页,物理页之间不需要连续;当程序真的访问到某个页时,才把它装入物理内存。

PagedAttention 干的事情,就是把这套分页思想原封不动搬到 KV Cache 上。它把每 token 的 Key 和 Value 看成基本单位,按固定数量打包成物理块(默认一个块装 16 个 token 的 KV),整张显卡里所有空闲块放在一个统一池子里。每个请求不再申请一块连续的大显存,而是维护一张“块表”(Block Table),记录自己逻辑上的第 0 块、第 1 块、第 2 块分别对应物理池里的哪一块。

这样做的好处非常直观:物理块不需要连续,碎片问题从根源上被消解了。块表可以理解成一张地图,你走的是逻辑路线,但每一步具体踩在哪块物理砖上,由地图说了算。请求只需要保证逻辑上连续,物理上东一块西一块完全无所谓。

3.2 按需分配和块表机制:浪费被压到每请求不到一块

vLLM 的块管理器会维护一个全局空闲块列表。请求进来时不预分配全长,而是边生成边按需拿块:当前块用完了,再从空闲池里取一块新的,绑定到块表尾部。这样,真正被用掉的显存只跟实际生成的 token 数量相关,而不是跟“最大可能长度”相关。

唯一的浪费来自每个请求的最后一个块——它往往装不满 16 个 token。也就是说,无论请求长短,浪费被严格限制在“每个请求最多一个块”的量级,对比预分配动不动浪费几千 token 的空间,几乎可以忽略不计。碎片率从之前可能超过一半,被压到接近零。

代价也不是没有。块表查询、非连续块的寻址,都会带来一点额外开销;某些算子对非连续内存的访问效率也不如连续内存。但相比省下来的显存和提升的并发能力,这点开销完全可以接受。实际部署中,vLLM 靠这套机制把显存的利用率做到了非常夸张的水平,这也是它敢把gpu_memory_utilization默认开到 0.9 的底气。

3.3 Copy-on-Write 与跨请求前缀复用

把 KV 块做成物理资源后,还会出现一个朴素实现根本做不到的玩法:物理块共享。因为块表和物理块是解耦的,多个序列的块表完全可以指向同一个物理块。beam search 展开分支之前,所有分支共享同一段前缀的 KV 块,完全不用复制;直到某个分支要生成第一个不同的 token 时,才触发 copy-on-write——分配一个新块,把原来的内容复制过去,再写入新的 token。其它没分叉的分支照旧共享原来的块。

并行采样也是同理,n 个输出共享同一个 prompt 的 KV,只在生成结果分叉后才开始独立占用。这个特性在显存上的收益很大,等于把公共前缀的存储从 n 份压缩成了 1 份加少量增量。

再进一步,vLLM 后来的前缀缓存(Prefix Caching)机制把这个思想推到了跨请求维度。它对每个 KV 块做内容哈希,当新请求的 prompt 前缀跟之前某个请求完全相同时,直接复用对方缓存好的物理块,连 prefill 阶段的计算量都省了。这个对带长 system prompt 的多轮对话、批量跑同类指令的场景帮助特别大,命中一次,面前的 KV 占用瞬间清零。

3.4 端到端效应:碎片少了,batch 大了,吞吐上去了

把前面几节串起来,PagedAttention 的收益就很清楚了。碎片消失意味着同样的显存能装下更多 token 的 KV,能支撑更大的并发 batch;前缀共享意味着重复内容不再占用双份显存;按需分配意味着显存永远不会被“空占”。这些因素叠加在一起,就是同一个 GPU 上可服务的请求数大幅增加。

而 LLM 推理的吞吐,很大程度上取决于你能同时塞进多少个请求:decode 阶段是带宽密集型操作,batch 越大,单位显存带宽内产出的 token 就越多。vLLM 论文里对比当时的成熟推理系统,吞吐能做到 2 到 4 倍的提升,主要就是靠这一套显存管理设计。调度层面的连续批处理(Continuous Batching)跟它是互补关系,一个负责“把请求喂进来”,一个负责“把显存放得下”,两者配合才是完整的提速逻辑。

4. vLLM 落地实操:显存预算与关键参数怎么定

4.1 几个出场率最高的配置项

理解原理之后,落地到 vLLM 这类推理框架,真正影响 KV Cache 行为的配置项其实就那么几个。

配置项作用我的默认建议
gpu_memory_utilization允许框架使用的显存比例,默认 0.9单卡服务从 0.9 起步,多服务共享一张卡时调到 0.7 以下
max-model-len单请求最大上下文长度(prompt + 生成)按业务实际需要设,别无脑拉满
block_size(部分框架暴露)每个物理块容纳的 token 数默认 16,大多数场景别动
swap-space允许放到 CPU 内存的 KV 交换量显存紧张但 CPU 内存宽裕时按需开启
enable-prefix-caching跨请求前缀缓存开关多轮对话、固定 system prompt 场景建议打开

gpu_memory_utilization之所以默认就是 0.9,是因为框架要把剩下的 10% 留给模型权重加载的临时缓冲、CUDA context、以及 prefill 阶段的激活值。调到 1.0 基本等于找死,我试过,启动起来简单,第一个长请求进来就 OOM。

4.2 手把手算账:80G 卡能装下多少 KV

很多人在配置服务时只关心模型占多大,忽略了 KV 预算,导致上线后频繁 OOM。我建议每次部署前都认真做一次显存账,方法很固定。

以一张 80G 显存的卡跑 7B FP16 模型为例。总显存预算:80 × 0.9 = 72 GiB。模型权重:约 14 GiB。KV 之外的临时激活:留出 4 GiB 左右比较稳。剩余可给 KV 的预算就是 72 - 14 - 4 = 54 GiB。之前算过这个模型每 token 占用 512 KiB,那么:

kv_budget_gib = 54 bytes_per_token = 2 * 32 * 32 * 128 * 2 # 512 KiB kv_tokens = int(kv_budget_gib * 1024**3 / bytes_per_token) print(kv_tokens) # 约 110592,也就是约 11 万个 token 的 KV 容量

这 11 万 token 是所有请求共享的。如果平均每个请求占 4096 token(prompt 加生成),那同时只能容纳约 27 个请求;如果你希望支撑 64 个并发请求,要么换 GQA 模型,要么做 KV 量化,要么下调单请求长度上限。用这个方式倒推,就知道为什么说“先算账再定参数”比拍脑袋靠谱得多。

4.3 同方向还能叠加的优化组合

KV Cache 这只吞显圣兽,能压的手段不止分页这一种。GQA 是模型结构层面的降维,几个 Query 头共享一组 KV 头,KV 总量直接除以分组数;KV 量化则是把缓存从 FP16 降到 FP8 甚至 INT8,体积再砍一半到四分之一,代价是精度和部分场景下的生成质量,需要实测权衡;CPU swap 相当于操作系统的换页,把不活跃的物理块挪到 CPU 内存,GPU 显存紧张时能兜底,但交换带宽有限,别指望它治本。

这几招可以叠加:GQA 模型 + FP8 KV 量化 + 前缀缓存,通常能把同样显存下可服务的并发数放大一个数量级。我生产环境里现在基本都是这个组合,效果比单开任何一项都明显。不过每多开一层优化,排查问题的复杂度也会上升,建议先跑通基础配置,再逐步叠加,别一上来全开。

5. 常见问题与排查技巧实录

5.1 utilization 设了 0.9 还是 OOM

这是最经典的坑。原因往往不是 KV Cache 预算不够,而是 prefill 阶段的临时激活把显存瞬间吃爆了。长 prompt 一次性计算注意力时,中间矩阵的尺寸是 batch × 长度 × 头维度,峰值比 decode 阶段高得多,而gpu_memory_utilization只管 KV 池子,管不住这些临时激活。

我的处理顺序是:先降到 0.8 看是否稳定;再限制max_num_seqs,防止瞬时并发过高;如果业务长 prompt 很多,开启 chunked prefill,把长输入切成小块逐个处理。改完这三步,绝大部分 OOM 都能解决。仍有问题的话,就需要考虑多卡分片或量化了。

5.2 max-model-len 到底怎么设才不翻车

设小了,长上下文请求被硬生生截断,业务不答应;设大了,KV 预算撑不住,长请求直接 OOM。正确做法是拿第 4.2 节的算账结果反向推算:先确定业务最大需要的上下文长度,再算这个长度下能支撑多少并发,如果并发不达标,就该降长度、上量化或者加卡,而不是继续硬调大max-model-len。

实际踩坑经验是,新版本 vLLM 如果不显式设置max-model-len,它会根据显存自动推断一个默认值,看着很方便,但推断出来的往往比我业务需要的短一截,或者在某些极端配置下偏大。所以我现在一律显式设置,每换一次模型都重新算一遍账。

5.3 block_size 要不要调成别的

默认 16 token 一个块是大量场景下权衡后的结果,绝大多数情况别动它。块越大,块表条目越少,连续内存访问更友好,但尾块浪费更严重;块越小,尾部浪费越少,但块表变大、跨块寻址开销变多。如果你的业务以几百 token 的短请求为主,可以尝试更小的块;如果全是长文生成,16 或 32 都行,实测差异不大。

要提醒的是,非连续块访问在部分 GPU 算子上的效率确实比连续内存低,这是 PagedAttention 固有的成本。块大小调完务必做一轮吞吐对比,不要只看显存占用降低了就收工。

5.4 上线后如何确认 KV Cache 被用到了极致

调完参数心里没底,可以靠两个指标验证。一是 vLLM 暴露的 Prometheus 指标,里面有当前空闲物理块数、运行中请求数、KV 块使用量,空闲块数如果长期很低,说明显存被吃透了;二是前缀缓存的命中率,命中率高说明公共前缀复用做得好,prefill 算力被省下来不少。

我用得比较多的是观测日志里的 prompt 吞吐和生成吞吐。如果生成吞吐明显偏低,先怀疑是不是 KV Cache 太大、每次都要读太多历史导致带宽饱和,对策是缩短上下文或做 KV 量化;如果空闲块还一大把、吞吐却上不去,问题往往在调度层,而不是 KV 这一侧。

最后说一点个人体会。调 KV Cache 相关参数,我的顺序永远是先算账再设参数:先按公式算出每个 token 占多大、这张卡能装多少 token,再去定max-model-len和并发目标。之前我图省事,直接把max-model-len拉到 32K,结果长上下文服务一晚上 OOM 三次,后来才发现 KV 预算根本不够。KV Cache 的优化空间还很大,后面有时间可以接着聊 KV 量化和前缀缓存的实战细节。先把这笔账算明白,比什么都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询