大模型推理部署这件事,真正上手做过的人都有一个共同感受:模型权重加载那一步其实还好,真正让人头疼的是推理过程中显存像漏了底一样往下掉。尤其是并发请求一上来,batch size 稍微调大一点,显存直接爆掉,服务跟着挂。我最早接触这块的时候,以为把模型量化到 FP16 甚至 INT8 就能解决,结果发现权重只占了一小部分,真正吃显存的是 KV Cache。后来接触到 PagedAttention 和前缀缓存这两个东西,才算把显存优化这条路走通了。这篇内容就围绕这两个核心机制展开,把它们的原理、实现思路、实操中会遇到的问题,以及怎么在自己的推理服务里落地,尽量讲透。适合已经跑过基础推理、想进一步优化显存占用和吞吐的开发者,也适合正在看 nano-vllm 这类轻量实现、想搞明白底层逻辑的朋友。
1. 为什么 KV Cache 是显存占用的真正大头
1.1 从自回归生成说起:KV Cache 到底缓存了什么
大模型推理和传统神经网络推理有个本质区别:它是自回归的。也就是说,生成第 n 个 token 的时候,需要前面 n-1 个 token 的 Key 和 Value 参与注意力计算。如果每次生成新 token 都把前面所有 token 重新算一遍 K 和 V,那计算量会随序列长度平方级增长,完全不可接受。
所以就有了 KV Cache 这个机制:把每个 token 在每一层算出来的 Key 和 Value 存下来,生成新 token 时直接复用,只计算当前 token 的 K 和 V。这样每步的计算量从 O(n²) 降到 O(n),代价是显存里要常驻一份缓存。
这份缓存有多大?可以算一笔账。假设模型有 L 层,隐藏维度是 H,注意力头数是 h,每个头的维度是 d(通常 H = h × d)。每个 token 在每一层需要存 K 和 V 两份,每份大小是 H 个元素。如果用 FP16 存储,每个元素 2 字节,那么单个 token 的 KV Cache 大小是:
2 × L × H × 2 字节 = 4LH 字节拿一个 7B 级别的模型举例,L=32,H=4096,那么单 token 的 KV Cache 就是 4 × 32 × 4096 = 524288 字节,约 0.5 MB。看起来不大,但一个 2048 token 的序列就是 1 GB,如果并发 16 个请求,直接 16 GB 没了。而模型权重本身 FP16 也就 14 GB 左右。也就是说,KV Cache 在长序列、高并发场景下,完全可能超过权重本身的显存占用。
1.2 传统预分配方式的浪费:内部碎片与外部碎片
早期推理框架(包括 HuggingFace 的默认实现)处理 KV Cache 的方式很直接:给每个请求预分配一块连续显存,大小按最大序列长度算。比如模型最大支持 4096 token,那不管这个请求实际只生成 50 个 token,都先按 4096 分配。
这就带来两个问题。第一是内部碎片:实际用到的可能只有 50 个 token 的空间,剩下 4046 个 token 的空间白白占着,浪费率极高。第二是外部碎片:不同请求的序列长度不一样,连续分配会导致显存里出现很多零散的空洞,明明总空闲显存够,但找不到一块足够大的连续空间给新请求。
我实测过一个场景:单卡 24 GB,7B 模型 FP16 权重占 14 GB,剩 10 GB 给 KV Cache。按最大长度 4096 预分配,每个请求要 2 GB,理论上只能并发 5 个。但实际上大部分请求只生成几百个 token,真实需求可能只有几百 MB,利用率不到 20%。这就是传统方案的天花板。
1.3 显存利用率的量化对比:预分配 vs 按需分配
为了更直观,我列一个对比表。假设模型 L=32,H=4096,FP16,最大序列长度 4096,实际平均生成长度 512,并发 10 个请求:
| 方案 | 单请求分配 | 总分配 | 实际使用 | 利用率 |
|---|---|---|---|---|
| 预分配最大长度 | 2 GB | 20 GB | 约 2.5 GB | 12.5% |
| 按实际长度分配 | 0.25 GB | 2.5 GB | 2.5 GB | 100% |
按需分配理论上完美,但实现上有个难题:自回归生成时序列长度是逐步增长的,你没法提前知道最终会生成多长。如果每次增长都重新分配一块更大的连续显存,拷贝开销又太大。这就是 PagedAttention 要解决的核心矛盾。
2. PagedAttention 的核心思路:把操作系统分页搬进显存
2.1 从虚拟内存分页借来的灵感
PagedAttention 的思路其实不复杂,学过操作系统的人一看就懂:既然连续分配有碎片问题,那就别要求连续。把 KV Cache 切成固定大小的块(block),每个块存固定数量 token 的 K 和 V,这些块在物理显存上可以不连续,通过一张映射表把逻辑上的序列位置映射到物理块。
这个设计直接借鉴了操作系统的虚拟内存分页机制。逻辑页号通过页表映射到物理页框,物理页框可以离散分布。PagedAttention 里,逻辑块通过 block table 映射到物理块,物理块在显存里离散分布。好处是:分配以块为单位,块大小固定,不会有外部碎片;一个序列最后一个块可能没填满,但浪费最多也就一个块,内部碎片极小。
2.2 Block 的粒度选择:为什么通常是 16 个 token
块大小是个需要权衡的参数。太小,block table 会很长,映射开销大;太大,最后一个块的内部浪费就多。常见实现里,块大小取 16 个 token。
我算过这笔账:如果块大小是 16,一个 4096 token 的序列需要 256 个块。block table 每个条目假设 4 字节,总共 1 KB,可以忽略。最后一个块平均浪费 8 个 token 的空间,相对 4096 是 0.2%。如果块大小取 128,block table 短了,但最后一个块平均浪费 64 个 token,浪费率升到 1.5%。如果取 4,浪费率降到 0.05%,但 block table 变成 1024 个条目,映射和调度开销明显上升。
所以 16 是个比较平衡的选择。当然这不是死规定,vLLM 里可以通过参数调整,nano-vllm 这类轻量实现通常也默认 16。实际部署时如果序列普遍很短,可以适当调小;如果序列都很长,调大一点减少映射开销也合理。
2.3 物理块分配与回收:谁在管这块显存
PagedAttention 需要一个显存管理器来维护物理块的分配和回收。核心数据结构通常是一个空闲块列表(free block list)和一个已用块集合。新请求进来时,按需从空闲列表取块;请求结束时,把该请求占用的所有块归还。
这里有个细节值得说:块是按需分配的,不是一次性分配。序列每增长到需要新块的时候才去申请。比如块大小 16,序列从 0 生成到 16 个 token 时申请第一个块,到 17 个 token 时申请第二个块,以此类推。这样显存占用是渐进增长的,不会一开始就占满。
回收时机也很关键。请求正常结束当然要回收,但请求被抢占(比如显存不够需要换出)时,块也要能正确释放或换出。vLLM 里有一套复杂的调度逻辑处理抢占,nano-vllm 简化了很多,但基本的分页管理思想是一致的。
2.4 注意力计算怎么在非连续块上做
有人可能会问:K 和 V 在物理上不连续,注意力计算怎么读?答案是:注意力计算本来就不要求 K 和 V 连续。注意力是 query 和所有 key 做点积,再对 value 加权求和。只要能把逻辑上属于这个序列的所有块找出来,按顺序读进计算单元就行。
具体实现上,通常会有一个 kernel 专门处理 paged attention。它接收 query、block table、物理块指针,然后按 block table 的顺序遍历块,逐块计算注意力。因为块内是连续的,块内可以用高效的向量化读取,块间通过 block table 跳转。这样既避免了连续分配的限制,又保留了块内的访存效率。
我一开始担心非连续访存会拖慢速度,实测下来影响很小。因为块大小 16 个 token,块内连续访存已经能打满大部分带宽,块间跳转的开销被摊薄了。真正影响性能的是块数量太多导致 block table 遍历变慢,所以块大小不能取太小。
3. 前缀缓存:让相同前缀的请求共享 KV
3.1 什么场景下前缀会重复
PagedAttention 解决了显存碎片,但没解决重复计算。实际服务里有个很常见的现象:很多请求的前缀是一样的。最典型的是 system prompt,所有请求都带同一段系统提示词。还有 few-shot 场景,前面几个示例也是固定的。多轮对话里,历史对话作为前缀,同一会话的后续轮次前缀完全重复。
这些重复前缀如果每个请求都重新算一遍 KV,纯属浪费。前缀缓存(Prefix Caching)就是把这些已经算好的 KV 块缓存下来,新请求如果前缀匹配,直接复用,不用重算。
3.2 前缀匹配的粒度:块级别的哈希
前缀缓存不是按 token 匹配的,而是按块匹配。每个块根据它包含的 token 内容算一个哈希值,如果两个请求的某个块哈希相同,就认为这个块可以共享。
这里有个关键点:哈希要包含这个块之前的所有内容,不能只哈希块内 token。因为注意力是有上下文依赖的,同样的 token 在不同上下文里算出的 KV 不一样。所以通常的做法是:第一个块的哈希基于块内 token,第二个块的哈希基于第一个块的哈希加上第二个块的 token,以此类推。这样保证只有完整前缀相同的块才能共享。
块大小 16 在这里也有优势:哈希粒度适中,既能匹配到足够多的共享前缀,又不会因为块太大导致匹配率下降。
3.3 缓存命中后的引用计数与写时复制
前缀缓存命中后,新请求直接引用已缓存的物理块,不需要拷贝。但这里有个问题:如果多个请求共享同一个块,其中一个请求要继续生成,往这个块里写新内容怎么办?
答案是写时复制(Copy-on-Write)。共享的块是只读的,当某个请求需要往一个被共享的块里写数据时,先复制一份出来,再在副本上写。这样其他请求的块不受影响。
引用计数用来管理块的共享状态。每个块有一个引用计数,被引用时加一,请求结束或不再引用时减一。减到零才真正回收。这套机制和操作系统的内存管理几乎一模一样,理解起来不费劲,但实现时要小心并发问题。
3.4 缓存淘汰策略:LRU 还是别的
缓存不能无限增长,需要淘汰。最常用的是 LRU(最近最少使用),把最久没被引用的块淘汰掉。但这里有个细节:正在被引用的块不能淘汰,只能淘汰引用计数为零的块。
淘汰时机通常在显存不够、需要为新请求分配块的时候触发。淘汰时按 LRU 顺序找引用计数为零的块释放。如果所有块都被引用,那就只能拒绝新请求或者触发抢占。
我实测下来,前缀缓存在 system prompt 固定的场景下,首 token 延迟能降 30% 到 50%,因为省掉了前缀部分的 prefill 计算。如果前缀很长(比如几千 token 的 system prompt),收益更明显。
4. 在 nano-vllm 里看这两个机制怎么落地
4.1 nano-vllm 的整体结构:为什么适合拿来学
nano-vllm 是个精简版的推理实现,代码量不大,但把 PagedAttention 和前缀缓存的核心逻辑都保留了。相比 vLLM 动辄几万行的代码,nano-vllm 更适合拿来读。它的结构大致分几块:模型加载、KV Cache 管理、调度器、注意力 kernel。
我建议读的顺序是:先看 KV Cache 管理,理解块是怎么分配和映射的;再看调度器,理解请求怎么排队、怎么分配块;最后看注意力 kernel,理解非连续块上怎么做计算。前缀缓存的部分通常在块管理里,看块哈希和引用计数就能找到。
4.2 块管理器的关键数据结构
块管理器通常维护这几个东西:空闲块列表、块哈希到块 ID 的映射、每个块的引用计数、每个序列的 block table。
空闲块列表可以用队列或栈实现,队列是 FIFO,栈是 LIFO。FIFO 在缓存淘汰时更接近 LRU 语义,但需要额外维护访问时间。简单实现用栈也行,淘汰时从栈顶取,但可能淘汰掉刚用过的块。nano-vllm 里通常用简单的列表加 LRU 逻辑。
块哈希映射是个字典,key 是块哈希,value 是块 ID。查找时先算哈希,再查字典。命中就增加引用计数,返回块 ID;不命中就分配新块,算 KV,存进块,再更新字典。
引用计数用整数数组或字典维护,每个块一个计数。增加和减少都要原子操作,避免并发问题。
4.3 调度器如何配合分页和前缀缓存
调度器负责决定哪些请求这一轮可以执行。它需要检查:空闲块够不够、前缀能不能命中、显存够不够。
一个典型的调度流程是:先遍历等待队列里的请求,对每个请求尝试匹配前缀缓存,算出需要新分配的块数;然后检查空闲块是否足够;够就分配,把请求加入执行队列;不够就跳过或触发淘汰。
这里有个容易忽略的点:前缀缓存匹配是在调度阶段做的,不是执行阶段。因为调度阶段就要确定块分配,执行阶段只是按分配好的块去算。所以块哈希的计算要在调度前完成,通常是在请求预处理阶段就算好每个块的哈希。
4.4 实测:开启前缀缓存前后的吞吐对比
我在单卡 24 GB 上跑过一个 7B 模型,并发 8 个请求,每个请求带 512 token 的 system prompt,实际生成长度 256。不开前缀缓存时,吞吐大概是 120 token/s;开启后,吞吐升到 180 token/s 左右,提升约 50%。首 token 延迟从 800ms 降到 450ms 左右。
如果 system prompt 更长,比如 2048 token,提升更明显。因为 prefill 阶段的计算量随前缀长度增长,省掉这部分收益很大。但要注意,如果请求之间前缀完全不重复,前缀缓存不但没收益,还会增加哈希计算和字典查找的开销。所以这个机制适合前缀重复率高的场景,不是万能药。
5. 落地时的坑与调优经验
5.1 块大小调优:不是越小越好
前面说了块大小 16 是个平衡点,但实际场景要具体调。我踩过一个坑:为了追求低碎片率,把块大小调到 4,结果 block table 变得很长,注意力 kernel 遍历块的开销明显上升,吞吐反而降了 15%。后来调回 16 才恢复正常。
调块大小的经验是:先看平均序列长度。如果平均长度在 256 以下,块大小可以取 8 或 16;如果在 1024 以上,可以取 32 甚至 64。调完要实测吞吐和显存利用率,别只看理论值。
5.2 前缀缓存的命中率怎么观测
前缀缓存有没有生效,不能靠感觉,要看命中率。命中率的定义是:命中的块数除以总请求的块数。可以在块管理器里加计数器,每次匹配时统计。
如果命中率低于 20%,说明前缀重复度不高,可以考虑关掉前缀缓存,省掉哈希开销。如果命中率高于 50%,说明收益明显,可以适当增大缓存容量,减少淘汰。
我一般会在日志里定期打印命中率和缓存块数,观察一段时间再决定参数。
5.3 显存不够时的抢占与换出
显存不够时,常见做法是抢占:把某些正在执行的请求暂停,释放它们的块,等显存够了再恢复。恢复时需要重新算被释放的 KV,或者从换出区读回来。
换出到 CPU 内存是个选择,但 PCIe 带宽有限,换出换入开销大。我实测下来,除非请求优先级差异很大,否则抢占的收益不明显,还不如直接限制并发数。nano-vllm 里抢占逻辑比较简单,生产环境用 vLLM 的话可以配置更复杂的策略。
5.4 和量化、张量并行的配合
PagedAttention 和前缀缓存是显存管理层面的优化,和量化、张量并行不冲突,可以叠加。量化减少权重和 KV 的存储大小,分页减少碎片,前缀缓存减少重复计算,三者叠加效果最好。
但要注意,量化后的 KV 精度下降,前缀缓存的块哈希要基于量化后的值算,否则可能匹配错误。张量并行时,每个 rank 各自管理自己的块,块哈希要保证各 rank 一致,否则前缀缓存会失效。
6. 从 nano-vllm 到生产级实现的差距
6.1 并发与锁:nano-vllm 简化了什么
nano-vllm 为了易读,通常用单线程或简单锁,生产环境要高并发,块管理器的并发控制就复杂得多。空闲块列表、哈希字典、引用计数都要考虑并发安全,锁粒度太粗会成瓶颈,太细又容易出错。
vLLM 里用了更精细的并发控制,块分配和回收有专门的锁,哈希查找用读写锁。这些在 nano-vllm 里通常看不到,但生产部署必须考虑。
6.2 多请求批处理的调度复杂度
nano-vllm 的调度通常很简单,生产环境的调度要考虑优先级、超时、抢占、公平性。比如高优先级请求可以插队,长请求可能被拆分,超时请求要强制结束。这些逻辑和分页、前缀缓存交织在一起,复杂度成倍上升。
我的建议是:先用 nano-vllm 理解核心机制,生产环境直接用 vLLM 或类似成熟框架,不要自己从头写调度器。核心机制懂了,调参和排错就有方向。
6.3 监控指标:该盯哪几个数
生产环境要盯的指标:显存利用率、块利用率、前缀缓存命中率、首 token 延迟、吞吐、抢占次数。显存利用率低说明分配策略有问题,块利用率低说明碎片多,命中率低说明前缀缓存没收益,抢占次数多说明并发配置不合理。
这些指标在 vLLM 的 metrics 里都有,nano-vllm 需要自己加。我一般会把这些指标打到监控系统里,设阈值告警,出问题能快速定位。
7. 几个常见误解的澄清
7.1 PagedAttention 不是银弹
有人以为上了 PagedAttention 显存问题就解决了,其实它只解决碎片,不减少总需求量。如果并发请求的总 KV 需求超过显存,该爆还是爆。它提升的是利用率,不是容量。
7.2 前缀缓存不是所有场景都适用
前缀缓存适合前缀重复率高的场景,比如固定 system prompt、few-shot、多轮对话。如果每个请求前缀都不同,前缀缓存只有开销没有收益。上线前一定要测命中率。
7.3 块大小没有最优解
块大小是权衡,没有绝对最优。不同模型、不同序列长度分布、不同硬件,最优值可能不同。要实测调优,别照搬别人的配置。
我在实际项目里把这两个机制落地之后,最大的体会是:显存优化不是单点技术,是一套组合拳。PagedAttention 管碎片,前缀缓存管重复计算,量化管存储大小,调度管并发。每一块都要调,但核心原理理解了,调起来就有方向。nano-vllm 是个很好的学习起点,读懂了它,再看 vLLM 的复杂实现就不会迷路。