把模型部署到线上之前,我一直以为“推理引擎”只是封装了模型调用的壳子:加载权重、喂prompt、拿结果。真正动手写一个Nano-vLLM之后才发现,引擎内部是一条相当精密的流水线,显存管理、调度、批处理每一样都在跟“自回归生成”这个天然劣势较劲。这篇文章是Part 1,我会从一次请求的完整旅程讲起,算清楚显存为什么会暴涨,拆明白KV Cache为什么是核心矛盾,再看连续批处理和块式显存管理到底解决了什么问题。
适合正在部署对话模型但总感觉吞吐上不去的人,也适合读源码时被一堆Lock和Queue劝退的同学。看懂这一篇,再去看任何现代推理框架,你都能一眼认出主干结构。
1. 一次生成请求的完整旅程:推理引擎到底做了什么
1.1 从一行prompt到一组token,引擎里的“API层”与“状态机”
Nano-vLLM收到请求的第一步不是“调用模型”,而是先把原始文本切成token序列,并包装成一个内部对象。这个对象在引擎里叫Sequence,可以理解成一份流水单,上面记录了prompt对应的token id、当前生成了多少token、采样参数、以及最重要的状态标记:等待中、运行中、已结束。
为什么要搞得这么重?因为自回归生成天然是“一次吐一个token”的循环,这个循环可能持续几十步到上千步。引擎必须在这期间持续追踪序列的状态,而不是像传统推理那样“传一次参数、收一次结果”。
我见过不少人对引擎的第一印象停留在“模型前向”上,其实模型前向只是一小块。API层负责把请求塞进队列,调度器决定队列里的请求什么时候真正占GPU,Sequence对象则把一次长任务拆成可中断、可恢复的单元。Nano-vLLM的代码虽然精简,这层状态机也一个都没少。
1.2 Prefill与Decode:同一个模型,两种完全不同的执行模式
模型执行分两个阶段。第一个叫Prefill,也就是“预填充”:把用户的整段prompt一次性做前向,所有输入token并行计算,产出第一组KV Cache和第一个输出token。第二个叫Decode,也就是“解码”:每次只输入上一个新生成的token,前向一次,得到下一个token的分布,然后采样、拼接,再重复。
Prefill阶段像把一整页纸同时扫一遍,速度快、GPU利用率高;Decode阶段像只从纸缝里抠一个新字出来,每次都要完整跑一遍模型的所有层,计算量没省多少,但一次只前进一个token。更麻烦的是,第t步生成的结果要作为第t+1步的输入,所以同样的位置不能并行重写。
这两个阶段的计算效率和并发特性差异巨大,调度器必须区别对待。很多新手把引擎理解成“一个做推理的函数”,其实是理解成了一个无边界的循环加一批资源管理策略。Nano-vLLM里,调度器对Prefill和Decode分别计数、分别限流,比如同时允许多少个Prefill任务进入,避免大prompt把显存瞬间打爆。
1.3 一次请求的完整生命周期拆解
完整过程大致是这样:
- 接收请求,文本转token,包装成Sequence,放进等待队列。
- 调度器根据当前显存余量和GPU空闲情况,决定放行。
- 执行器执行Prefill:一次性处理全部输入token,写KV Cache,给出第一个输出token。
- 进入Decode循环:每步前向计算新token,更新KV Cache,采样,直到命中结束符或达到最大长度。
- 序列结束,释放它所占的显存Block,调度器从等待队列补进新请求。
这里面最反直觉的是:模型权重在整个生命周期中一直占着显存不走,但真正让显存“弹性波动”的,是每一层都为每个token保留的那份K和V矩阵。下面就来算这笔账。
2. 朴素实现为什么会“崩”:显存增长的数学账
2.1 权重显存与KV显存:谁才是真正的“大户”
我先摆一组典型参数来算账。假设一个中等规模的对话模型,层数L=32,KV头数8,每个头的维度是128,权重FP16约15GB。KV Cache每个token占用多少字节?公式是:
每个token的KV字节数 = 2(K和V两份) × 层数 × KV头数 × 头维度 × 2字节
代进去就是 2 × 32 × 8 × 128 × 2 = 131072字节,也就是128KB per token。这个数字意味着什么?一个序列如果上下文已经积累了2048个token,它的KV Cache就有2048 × 128KB = 256MB。注意,这只是“一个”请求。
同一时间如果有64个这样的请求,KV Cache总量会飙到16GB以上,直接超过权重本身。这还是用了8个KV头的情况,如果是早年没有做GQA压缩的模型,KV头数是32,这个数字还要再翻四倍。
我最初部署时犯过一个错误:盯着权重体积去估显存需求。比如15GB的权重,觉得24GB的卡够了,还能剩9GB跑batch。实际上一跑起来,显存被KV占得干干净净,甚至还没等Prefill结束就直接OOM。这不是模型权重的问题,是“权重是常量,KV是按token增长的可变负债”。
2.2 不缓存KV会怎样:计算复杂度从线性退化成平方
有人会问:既然KV占地方,那每步重新计算历史位置的行不行?理论上可以,但代价是灾难性的。Decode到第n个token时,如果不缓存,计算第n步的Attention就需要把前n-1个token的K、V全部重新算一遍。于是总计算量变成1+2+3+…+n,量级直接升到n²。
我跑过一个迷你实验:同样生成512个token,带缓存的版本耗时1秒出头,不带缓存的版本跑到一半就已经明显卡顿,生成相同的长度要接近20秒。n越大差距越夸张,线性和平方曲线,到2048、4096这个量级基本就是不可用的区别。
所以现代推理引擎的共同选择是:把每一层算出来的K和V留在显存里,新token只计算自己的K和V,然后追加到历史缓存后面。引擎做的一切优化,几乎都是在想方设法让这份“历史笔记”占用的空间更小、搬动更少、复用更多。
3. KV Cache是设计的中轴:为什么算完不能扔
3.1 从Attention公式看K和V凭什么必须留下来
一个被反复问到的疑问是:为什么只缓存K和V,不缓存Q?这要回到Attention的定义。对于当前位置的query q,它需要跟所有历史位置的key做点积,得到注意力分数,再归一化后乘上对应的value,最后加权求和。
也就是说,q只属于当前token,用完即弃;但后续每一个新token的q,都要跟之前所有位置的k做点积。K和V属于“被反复访问的材料”,只要序列还在生成,就必须一直保留着供未来所有token使用。
用生活化的话说:q是“当前这个问题”,k是“过去所有答案的索引卡片”,v是“过去所有答案的正文”。后续每问一个新问题,都要翻一遍全部卡片、再读一遍正文。这个翻阅和读取的成本,决定了K和V的缓存必须存在,也决定了它的大小直接挂钩序列长度。
3.2 KV Cache为什么要按层存、按块存
推理引擎里的KV Cache不是一个大数组,而是按“层”组织的。模型有32层,每一层都有一套自己的KV缓存;某个token的hidden state从第0层传到第1层,每一层都会为它计算一份K和V,然后单独存放。
这样做的原因是:Attention计算发生在每一层内部,第3层做计算时只能用到第3层的K和V,不能跑到第5层去取。所以引擎在显存布局上天然是“层维度”隔离的。Nano-vLLM的KV Cache管理器里,每个Layer都有一个BlockTable,表的每一行指向一段连续显存,这段显存里存了一批token的那一层的K、V数值。
为什么又得按块存?因为序列长度是动态增长的,今天不知道明天会写到多长。如果一开始就给每个请求按最大长度预分配,4096个token的配额可能实际只用了300个,显存浪费超过九成。一个并发跑到256请求的系统,这点浪费足够撑爆好几块卡。
3.3 为什么说KV Cache是引擎设计矛盾的集中点
KV Cache同时牵扯了三个互相打架的目标:显存占用尽量小、计算时读取尽量快、多个请求之间尽量能复用。想让显存小,可以把KV量化成8bit甚至4bit,但精度和速度要付出代价;想让读取快,最好把显存的物理布局紧贴计算方式,但这妨碍碎片整理;想让复用高,就得多支持公共前缀共享,那BlockTable的管理立刻复杂一个量级。
我写Nano-vLLM时最深的感受是:框架里最绕的代码几乎全跟KV Cache有关。调度器要问“剩余Block够不够放一个新序列”,执行器要问“前一次Prefill的数据有没有写入正确的Block”,显存管理器要想“同一份前缀是否能被多个序列共用”。追根到底,所有逻辑都是围绕着一份按token增长的K/V笔记在转。
4. 调度器与连续批处理:吞吐量翻倍的关键机制
4.1 静态Batching为什么让GPU“忙一阵、闲一阵”
早期做法是静态批处理:同一时间进来8个请求,就打包成一个batch,8个序列同进同退。理想状态下这能提升GPU利用率,但实际场景里序列长短差异极大:有的请求只要生成20个token就结束,有的要生成2000个token的文档。
静态批处理下,全体序列必须同步推进。短序列早就生成完了,它的KV Cache和计算槽位却一直占着,直到批里最后一个长序列完成才能释放。我实测过一个典型场景:8个请求混合长短任务,长任务生成了800个token,短任务20个token就结束了,结果短任务被迫等了将近800步才拿到最终结果,GPU在这段时间里算力拟合的是最长序列的消耗,空转和浪费非常明显。
更麻烦的是,新请求即使到了,也只能排队等当前这批全部跑完才能插进来。整个引擎表现得像一条只能按整批通行的单车道,尾气大、效率低、延迟还不可控。
4.2 连续批处理:按“迭代”换班而不是按“请求”换班
连续批处理(Continuous Batching)的核心思路,是把调度的颗粒度从“请求”细到“迭代”。模型做Decode,每一轮前向就是一个iteration;每个iteration结束后,调度器立刻查看当前running列表:有序列达到终止条件,就释放它的显存和计算位;有序列还在排队,如果显存够、计算位够,就让它补进来。
这样一来,短请求可能只占了五六个iteration就离开了,长请求一直占着位置但不会被短请求拖累;新请求不用等整批结束,等一两个iteration就能挤上车。这种机制也叫“在途批处理”,逻辑上类似高铁站“不等满员、先发短途车”的调度,但每个座位不用固定绑定某一个人。
Nano-vLLM里最核心的调度函数长这样:
def schedule(self): # 先清理已结束的序列,归还Block for seq in self.running: if seq.is_finished(): self.block_manager.free(seq) # 从等待队列补入新序列,直到资源满 while self.waiting and self.has_free_capacity(): seq = self.waiting.pop() self.block_manager.allocate(seq) seq.status = "running" self.running.append(seq)实际的线上框架会比这个复杂得多,还要计算预算、抢占、优先级。但这个骨架就是连续批处理的内核:每一步迭代结束都是重新分配资源的机会。
4.3 实际跑一次:长短混合场景下吞吐量翻倍
我用一个小实验对比了两种方式。假设场景是这样的:12个短请求(平均生成30个token)和3个长请求(生成1500个token)几乎同时到达。
静态批处理下,由于长请求的存在,这一批的总耗时被拉得很长,短请求的平均响应时间也跟着上去了。连续批处理下,短请求在几百个内部迭代里陆续完成并释放位置,后面排队的其他短请求依次补位,长请求专心推进,整条流水线的重叠度极高。
实测这个场景,连续批处理的总吞吐差不多是静态批处理的1.8到2.2倍,短请求的平均响应时间则从“跟长请求同生共死”变成“几十次迭代就返回”。连续批处理并没有让模型计算变快,它只是把GPU从“等人”的状态拉回“一直有事做”的状态。
4.4 调度器还要管什么:Prefill与Decode的配额博弈
调度器不是光把请求塞进running就完事,它还要做容量预算。Prefill一个长序列要一次写入大量KV Block,显存瞬间消耗剧烈;Decode一次只增加一个token的KV,消耗平缓但每步都要占GPU。
如果让一堆Prefill任务同时冲进去,显存峰值瞬间爆炸;如果只做Decode,新请求永远进不来,只能干等。Nano-vLLM的做法是分开预算:允许同时进行的最大的Prefill次数设一个上限,同时总KV Block数也要预留一部分给正在Decode的序列。这个比例不能拍脑袋定,得看实际业务里prompt的长度分布。
一个可以抄作业的经验:如果线上prompt普遍在1024到2048个token,Prefill的大致并发配额可以控制在总batch容量的一半以内,剩下的留给Decode连续性。不同模型、不同显存、不同请求分布,这个值都要重新调。
5. 页式显存与换进换出:Nano-vLLM的Block管理设计
5.1 按Block分配显存,解决了动态增长的碎片问题
KV Cache如果按token逐步分配,每次都要找一段连续显存,序列一多、长度一参差,碎片会非常严重。Nano-vLLM的做法是把显存切成固定大小的Block,每个Block可以容纳固定数量(比如16个)token的一层KV。
一个序列用多少Block就申请多少Block;生成慢的序列用完了可以再加,但不用提前预留超大连续空间。这有点像内存管理里的“页”,虚拟地址连续、物理地址可以东一块西一块。BlockTable就是一张页表,把“逻辑第k个Block”映射到“物理第m个Block”。
好处是很明显的:不会因为一个请求设置了很大上限就预占满整段显存;Block回收的单位小,可以立刻转给别的请求;公共前缀还能被多个序列共享同一批物理Block。我第一次跑Nano-vLLM时把最大序列长度设成8192,结果发现它并不像老实现那样开局就占满8K的显存空间,而是按实际长度慢慢长,这种“按需付费”的体验才是一个合格引擎应有的样子。
5.2 显存不足时的抉择:换出(Swap)与抢占(Preempt)的取舍
显存再精细也扛不住极端并发。当Block Pool不够空余时,调度器面临两个选择。一个是换出:把某些序列的KV Block从GPU搬到CPU内存,腾出GPU Block给新请求,等那个序列再次被调度时再搬回来。另一个是抢占:如果新请求优先级更高,或者旧序列已经被冷落太久,可以直接丢掉它的Block,让它重新Prefill。
换出和抢占比起来,换出更像“暂停存档”,抢占更像“删档重开”。两者都昂贵:GPU和CPU之间的PCIe带宽远低于显存带宽,搬一个Block的往返时间够GPU做好几步Decode;而丢掉缓存重新Prefill则是把已经算过的历史全部重算一次。
我在Nano-vLLM里观察到的现象是:一旦系统进入频繁换出状态,吞吐会断崖式下跌,因为每一步都要等Block从CPU搬回来。所以更好的策略是提前在调度器里设“水位线”,显存快满时宁可少接新请求,也不要让引擎陷入换出抖动。好比餐厅宁可排队等位,也不要把客人安排到需要临时去仓库搬桌子的状态。
5.3 Block引用计数与公共前缀复用
页式管理的另一大红利是前缀共享。对话场景非常常见的情况是:一大批请求都以同一个系统提示词开头,或者都使用同一组几轮few-shot示例。这些请求的初始几百个token完全一致,它们对应KV Cache也完全可以共用。
实现思路是给物理Block加引用计数。两个Sequence的BlockTable前几项指向同一批物理Block,只要还有一个序列在引用,物理Block就不释放。这能省下的显存非常可观:我见过一个评测类业务,所有请求前缀一样,前缀占整个上下文的四成,共享后整体KV显存直接降了约30%。
代价是管理复杂度上升:Block释放时要改引用计数,某个序列如果修改了共享段的后续内容,还要处理“写时复制”之类的语义。Nano-vLLM在Part 1阶段只做了最基础的引用计数,复杂的写时复制留给后面再展开。但你可以记住这个方向:理解引用计数,就理解了为什么高并发下“共享前缀”是衡量引擎是否成熟的一把标尺。
5.4 一个典型OOM问题的排查链路
分享一个实际排查案例。现象:并发从32升到48时,引擎偶发OOM,但显存监视曲线看起来还有不少剩余。第一反应是调大Block Pool总量,结果没用。
后来按链路一步步查:先看调度日志,发现Prefill和Decode的预算设置没问题;再看Block分配,发现异常时段内有几个请求突然抢了大量Block。最后定位到原因——那批请求的prompt里带了一份超长文档,长度接近上限,一次Prefill就消耗了上千个Block,把预留池直接击穿。
修正方案有两个:一是给单个序列设置更小的动态上限,超长请求直接拒绝或提示用户裁剪;二是把Prefill并发配额降下来,避免短时间几个大块申请同时发生。这个case给我最大的教训是:显存崩溃很少是“总量不够”,更多是“瞬时分配不均衡”。排查时先看“谁在什么时候申请了多少Block”,远比盯着总利用率有用。
6. 把模块拼起来:Nano-vLLM的整体架构与后续学习路线
6.1 模块分工与典型故障表现
Nano-vLLM的代码量不大,但模块边界非常清晰。这些模块合起来,就是现代推理引擎的主干骨架:
| 模块 | 职责 | 模块出问题时最直观的表现 |
|---|---|---|
| API/前端 | 收请求、切token、组Sequence、流式返回结果 | 吞吐奇低、响应迟迟不出字 |
| 调度器 | 决定每个iteration哪些Seq进GPU、哪些被换出 | GPU利用率忽高忽低、长任务频繁被打断 |
| Block管理器 | KV Cache按Block分配、释放、换入换出、引用计数 | OOM、显存碎片、Block泄漏 |
| 执行器/Worker | 真正把Sequence里的token拼成batch、调模型前向 | 无法多卡扩展、前向报错、形状不匹配 |
| 采样器 | 从logits里按温度、top-p抽token | 输出重复、逻辑跳跃、生成空内容 |
这个表是我给来问框架源码的人最常用的“地图”。读源码不要从第一行读到最后一行,要在心里握住这张地图,然后顺着一条请求走一遍代码路径。先走“调度器放行 → Block分配 → Prefill → Decode循环 → Block释放”这条主线,远比盲目阅读所有函数要快得多。
6.2 从零实现一个Nano-vLLM的务实路线
我建议的落地顺序是:
- 第一步:只做单序列生成,不管理KV Cache,模型每一步重新计算前文KV。先把tokenizer、采样、循环跑通。这一阶段纯粹是理解自回归。
- 第二步:加入KV Cache,序列的K/V按层缓存住,decode时只增量计算。这一步你就能直观感觉到速度变化,也能体会到缓存管理的位置。
- 第三步:做多序列并发,实现一个最简单的iteration级调度器。先把“能跑”搞定,再谈“跑得快”。这一阶段的心态是体验连续批处理的调度节奏。
- 第四步:把KV显存切成Block,做Block分配表和引用计数。这步最费时间,但完成后才算拥有引擎的“地基”。
- 第五步:再去碰优化——量化KV、前缀共享、投机解码、CUDA Graph。有了前面的骨架,优化只是往正确位置塞新模块。
很多人第一步做得太细,在tokenizer上花了一堆时间。我的建议反过来:tokenizer先“够用就行”,把核心精力压在KV Cache和调度上,因为这两个才是推理引擎区别于“模型调用脚本”的分水岭。
6.3 Part 2会涉及哪些主题
这篇文章解决了“引擎为什么这样设计”的宏观问题。到了Part 2,我会把镜头拉近到执行细节:页式KV Cache在Attention算子内部是怎么被读取的,Prefill阶段的计算形态和Decode阶段差在哪,CUDA Graph为什么能压掉一部分调度开销,以及投机采样是怎么用“小模型猜、大模型验”来加速生成。
还有一个我在Part 1里刻意没展开的点:采样器。很多线上问题——输出重复、回答空泛、自我纠正过多——根源在采样参数与模型规模的匹配,而不在推理引擎本身。这些都会放到后续篇章里去聊。
回头看我做Nano-vLLM这段过程,最值钱的收获不是写了几千行代码,而是建立起了一个直觉:推理引擎里的每一个设计,本质上都是在跟“KV Cache的显存压力”和“Decode阶段的低并行度”较劲。你带着这两个标尺去读线上框架的源码,会发现所有看似绕远的设计都有明确的目的。如果你的目标也是把模型真正跑到生产环境里,我建议你先别急着看最新最强的优化技巧,动手把这条主线走通一遍,后面的路会顺畅很多。