大模型推理部署这件事真正上手做过的人都有一个共同感受模型权重加载那一步其实还好真正让人头疼的是推理过程中显存像漏了底一样往下掉。尤其是并发请求一上来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 级别的模型举例L32H4096那么单 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 GB7B 模型 FP16 权重占 14 GB剩 10 GB 给 KV Cache。按最大长度 4096 预分配每个请求要 2 GB理论上只能并发 5 个。但实际上大部分请求只生成几百个 token真实需求可能只有几百 MB利用率不到 20%。这就是传统方案的天花板。1.3 显存利用率的量化对比预分配 vs 按需分配为了更直观我列一个对比表。假设模型 L32H4096FP16最大序列长度 4096实际平均生成长度 512并发 10 个请求方案单请求分配总分配实际使用利用率预分配最大长度2 GB20 GB约 2.5 GB12.5%按实际长度分配0.25 GB2.5 GB2.5 GB100%按需分配理论上完美但实现上有个难题自回归生成时序列长度是逐步增长的你没法提前知道最终会生成多长。如果每次增长都重新分配一块更大的连续显存拷贝开销又太大。这就是 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%。如果块大小取 128block 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. 前缀缓存让相同前缀的请求共享 KV3.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 的复杂实现就不会迷路。