把模型部署到线上之前我一直以为“推理引擎”只是封装了模型调用的壳子加载权重、喂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层负责把请求塞进队列调度器决定队列里的请求什么时候真正占GPUSequence对象则把一次长任务拆成可中断、可恢复的单元。Nano-vLLM的代码虽然精简这层状态机也一个都没少。1.2 Prefill与Decode同一个模型两种完全不同的执行模式模型执行分两个阶段。第一个叫Prefill也就是“预填充”把用户的整段prompt一次性做前向所有输入token并行计算产出第一组KV Cache和第一个输出token。第二个叫Decode也就是“解码”每次只输入上一个新生成的token前向一次得到下一个token的分布然后采样、拼接再重复。Prefill阶段像把一整页纸同时扫一遍速度快、GPU利用率高Decode阶段像只从纸缝里抠一个新字出来每次都要完整跑一遍模型的所有层计算量没省多少但一次只前进一个token。更麻烦的是第t步生成的结果要作为第t1步的输入所以同样的位置不能并行重写。这两个阶段的计算效率和并发特性差异巨大调度器必须区别对待。很多新手把引擎理解成“一个做推理的函数”其实是理解成了一个无边界的循环加一批资源管理策略。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显存谁才是真正的“大户”我先摆一组典型参数来算账。假设一个中等规模的对话模型层数L32KV头数8每个头的维度是128权重FP16约15GB。KV Cache每个token占用多少字节公式是每个token的KV字节数 2K和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全部重新算一遍。于是总计算量变成123…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个请求就打包成一个batch8个序列同进同退。理想状态下这能提升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个tokenPrefill的大致并发配额可以控制在总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阶段的低并行度”较劲。你带着这两个标尺去读线上框架的源码会发现所有看似绕远的设计都有明确的目的。如果你的目标也是把模型真正跑到生产环境里我建议你先别急着看最新最强的优化技巧动手把这条主线走通一遍后面的路会顺畅很多。
阅读完成 · 觉得有帮助?