
如果你手头有一套大模型推理服务GPU 利用率经常在 20% 上下晃那你一定听过这两个词连续批处理和投机解码。它们一个是调度层面的吞吐优化一个是单请求层面的解码加速而 vLLM 把这两件事都做进了框架内部。今天这篇就一个问题怎么用最少的代码把这两项优化同时跑起来并且让吞吐数据说话。我不准备绕原理绕半天直接给你一个三行代码的最小示例再把它背后的参数选择和坑一次性讲清楚。这篇适合已经有一个可用模型环境、但还没正经调过吞吐参数的人也适合刚看完 vLLM 文档、想知道投机解码到底怎么落地的同学。1. 先别急着跑搞清楚连续批处理在解决什么1.1 静态批处理的“等车问题”早期大模型推理服务最常见的做法是静态批处理客户端攒够一批请求一起来一起生成全部生成完以后把结果一起返回。这个过程有点像公交车定时发车——不管车上坐了 3 个人还是 30 个人到点就走没赶上的人必须等下一班。这样的问题很直观如果请求到达时间不均匀前一批生成完以后可能还有一堆空位而新来的请求却被挡在门外显卡的空闲周期被白白浪费。大模型解码又是逐 token 的过程一个 batch 里各个请求的长度天然参差不齐有的生成 20 个 token 就结束了有的要生成 200 个。静态批处理必须等最慢的那一个跑完才能释放整个 batch 的资源。于是哪怕 GPU 的计算单元还有很多空闲也只能看着内存里的 KV Cache 被慢请求占着排队快的请求在后面干瞪眼。这种模式下吞吐量的上限很低而且延迟的尾巴特别长。1.2 连续批处理是怎么做的连续批处理的核心思想是不再把“一批请求”作为调度单位而是把“一个解码步骤”作为调度单位。每一轮迭代开始前调度器会重新审视当前所有请求的状态已经生成完的请求立刻退出释放它的显存和 KV Cache新到的请求如果资源足够马上插入当前迭代没被选中的请求就暂时挂起等下一轮再参与调度。整个过程看起来就像流水线而不是定时班车。这样做的好处是 GPU 几乎每轮都在处理有效计算而不是干等。vLLM 的实现里调度器会结合显存剩余量、最大并发数、每个请求的优先级默认按到达时间来决定哪些序列进入本轮迭代。请求的提前退出和新请求的加入都是动态发生的用户不需要手动管理。这也意味着你只要把一个请求列表丢给 vLLM框架内部会自动拆成轮次不再是传统意义上的一次性 batch。1.3 为什么 vLLM 默认就带连续批处理在 vLLM 里连续批处理不是一个需要手动打开的功能而是调度器的默认行为。你不需要写循环去管理队列也不需要自己做批拼接发进来的多个 prompt 会被引擎内部分成多个序列sequence每一轮调度时自动选择可执行的序列。这也是为什么很多人第一次用 vLLM 时发现只是把多个 prompt 放进一个列表吞吐量就比原来手写 batch 高了一截。这里顺便纠正一个常见误解连续批处理不等于并发越高越好。因为每一轮能放进 GPU 的 token 数量受显存和计算限制调度器还有一个最大 token 预算。如果你把几百个请求一次性丢进去它们会在排队中等待前面的请求完成但整体吞吐通常会更高因为排队和生成是可以重叠的。你只需要关注几个关键参数这部分后面会展开。2. 把投机解码也加进来原理和适用条件2.1 自回归解码的瓶颈大模型生成 token 的过程是自回归的当前 token 依赖于前面所有 token所以每一步只能串行生成一个 token。即便 batch 里有几十个请求每个请求的生成速度仍然被“一步一个 token”卡住。这个瓶颈在 GPU 上尤其明显计算能力很强但每一步都要等到上一步结果出来才能继续延迟压缩空间有限。投机解码speculative decoding就是冲着这个“串行”来的。它不改变自回归的基本逻辑而是把一个 token 的等步长变成“预测一串 token 一次批量验证”的方式。具体来说先用一个很小的草稿模型draft model快速生成接下来几个 token 的候选再把这段候选交给大模型在一次前向传播里并行验证这些 token 是否合理如果验证通过就一下子接受多个 token如果某个位置被拒绝就从那个位置回退重新生成。2.2 接受率决定收益这里有个关键指标接受率。草稿模型生成的候选被大模型接受的比例越高收益越大。假如草稿模型每次给出 5 个 token大模型验证后 5 个全接受那生成 5 个 token 只需要一次大模型前向理论上延迟能降到原来的 1/5 左右。但如果草稿模型和大模型分布差异太大验证时第 2 个 token 就被拒绝那可能只接受了 1 个 token还额外付了一次草稿生成的开销整体反而变慢。所以投机解码能不能赢不取决于模型大小而取决于草稿模型到底有多“懂”大模型。通常建议选同一个系列里的小尺寸模型作为草稿词表和分布不过于离谱。即使是同一个模型的蒸馏或量化小版本也有机会获得不错的接受率。2.3 与连续批处理叠加的效果连续批处理优化的是调度层每轮 GPU 都在跑有效 batch。投机解码优化的是单序列的解码步长每个验证步能产出更多 token。两者叠加以后一个很直观的效果是在同样的 batch 中每个请求的完成时间变短完成请求退出得更快新请求能更早插入从而又反哺连续批处理的调度密度。这也是为什么一个三行代码的示例同时提这两个优化而不是只单独开一个。不过要注意投机解码不是一个“吞吐开关”。如果显存已经被两个模型占满导致 KV Cache 严重缩水或者并发数特别大导致草稿模型的生成结果来不及验证那最终吞吐可能不升反降。所以实操前最好先跑通最小示例再逐步加并发。3. 三行代码跑通连续批处理 投机解码3.1 环境准备和模型选择先装 vLLM。官方文档推荐用 pip 安装如果你在某个 Linux 服务器上先确认 GPU 驱动和 Python 环境满足要求然后执行安装命令。需要注意版本大版本迭代很快不同版本对投机解码的参数名和默认行为有差异。我建议固定一个近期稳定版本。装好之后你需要准备两个模型一个是真正负责生成的大模型比如一个 7B 或 13B 的对话模型另一个是草稿模型比如同系列 0.5B 到 1.5B 的小模型。草稿模型路径可以是你本地下载好的目录也可以是标准的模型 IDvLLM 会自动从模型仓库拉取。这里别急着同时加载两个大模型做实验显存会瞬间爆炸先从小尺寸草稿模型开始。3.2 核心三行代码下面是完整可运行的最小脚本。请把两个模型路径替换成你自己的模型。为了展示连续批处理的效果我直接一次传了多个 prompt而不是循环调用循环调用会让连续批处理的调度优势完全发挥不出来。from vllm import LLM, SamplingParams llm LLM(model你的大模型路径, speculative_model你的草稿模型路径, num_speculative_tokens5) outputs llm.generate([解释一下连续批处理, 解释一下投机解码, 你觉得大模型推理哪里耗时最多], SamplingParams(temperature0.0, max_tokens128)) print([o.outputs[0].text for o in outputs])严格来说初始化、generate、print 就是核心三行。第一次运行时会加载两个模型并做 Warmup需要等一会儿。如果显存不够你会在日志里看到明确报错可以先加一个gpu_memory_utilization参数比如gpu_memory_utilization0.8整体会安全很多。连续批处理不需要额外配置vLLM 的调度器拿到这三个 prompt 后会并行处理并在每一轮动态调度。启动日志里如果能看到类似 “Starting speculative decoding” 之类的提示说明配置生效了。提示不要一开始就同时调三个参数。先按默认值跑通再逐个调整否则出问题你很难定位是哪个改动引起的。3.3 每一行参数到底在干什么第一行LLM(model..., speculative_model..., num_speculative_tokens5)是核心。speculative_model指定草稿模型vLLM 看到这个参数就启用投机解码num_speculative_tokens5表示草稿模型每轮最多预测 5 个候选 token。这个数字不是越大越好。设想一下预测 10 个 token 一旦第 3 个就被拒绝前面 7 个预测全浪费了还增加了草稿模型的运行时间。对大多数场景4 到 6 是比较稳的起点。第二行generate是无状态接口传入一个 prompt 列表返回一个输出列表。多个 prompt 进入同一套调度器这是连续批处理生效的前提。SamplingParams里的temperature0.0是投机解码效果最好的采样配置因为贪婪解码时大模型的验证和草稿模型在单 token 分布上最可预测如果调高 temperature接受率通常会下降。第三行 print 只是把生成结果打出来。如果你想看更详细的吞吐指标可以在脚本里用time.perf_counter()记录总耗时再用 token 数除以耗时这才是你真正要关注的数字。这里还有一个细节prompt 长度会对结果产生很大影响。投机解码的收益主要体现在生成阶段如果 prompt 很长而 max_tokens 很短生成阶段占比小收益自然不明显。做对比测试时最好把 prompt 控制为中等长度比如几十到一百个 token。4. 实测验证怎么判断吞吐确实被优化了4.1 最简单的吞吐测试方法先把投机解码关掉用同一组 prompt 跑一遍记录耗时再打开投机解码跑一遍对比 token 数和耗时。对比时要固定 prompt 数量、max_tokens、并发数。最省事的做法是准备 100 个差不多的短问题在单进程里一次性丢给 generate测整体耗时。由于连续批处理的存在100 个请求会自动排队调度这能同时验证连续批处理和投机解码。下面是一个简单的测速代码片段围绕核心三行扩展出来的from vllm import LLM, SamplingParams import time prompts [讲一个关于计算机的故事] * 100 params SamplingParams(temperature0.0, max_tokens128) llm LLM(model你的大模型路径, speculative_model你的草稿模型路径, num_speculative_tokens5) start time.perf_counter() outputs llm.generate(prompts, params) elapsed time.perf_counter() - start total_tokens sum(len(o.outputs[0].token_ids) for o in outputs) print(felapsed: {elapsed:.2f}s, throughput: {total_tokens / elapsed:.2f} tokens/s)把同样的脚本里speculative_model参数去掉再跑一次就能得到对比。这里有件很现实的事如果显存只有 24G 左右加载一个大模型加一个小草稿模型后留给 KV Cache 的空间变小了连续批处理能同时处理的请求数量也会变少。这时投机解码带来的单请求加速不一定能抵消 KV Cache 变小带来的并发损失。所以如果你对比后发现吞吐下降不用怀疑人生先看显存占用和资源利用率。4.2 一组典型的数据趋势我不好说这组数字你能原样复现因为模型、显卡、显存、prompt 长度影响太大。但趋势是比较稳定的在不触发显存瓶颈的前提下投机解码可以把单请求的端到端延迟降低 30% 到 50%连续批处理下的整体吞吐提升也差不多在这个区间。如果把 num_speculative_tokens 从 5 调到 8延迟改善不一定更多因为接受率会明显下滑。我用一个小模型做草稿、一个大模型做验证时得到过大致这样的相对数据配置相对吞吐单请求平均延迟仅连续批处理1.0基准投机解码 草稿质量高1.4~1.6降低 40% 左右投机解码 草稿质量差0.85~0.95反而上升 10%投机解码 显存不足0.7~0.8延迟增加这个表格的重点不是具体数字而是三个结论第一草稿模型质量是关键第二显存余量是前提第三投机解码不是免费的午餐。你只有跑自己的数据才知道值不值得开。4.3 连续批处理相关参数怎么配合相比投机解码连续批处理能调的参数更隐蔽但影响更大。常用的几个max_num_seqs控制每轮最多并行处理的序列数默认值通常比较保守。如果你显存够大可以适当上调让更多请求同时进入调度器。max_num_batched_tokens控制每轮投入计算的最大 token 数直接影响单轮批大小。这两个参数如果设得太小连续批处理的效果会打折设得太大又可能因为单轮计算量过大导致单请求延迟变高。还有一个gpu_memory_utilization这个参数决定 vLLM 最多能用多少百分比显存。vLLM 会按这个比例预留 KV Cache 空间。如果你开了投机解码两个模型已经占了不少显存建议把gpu_memory_utilization调到 0.85 以下避免启动时直接 OOM。连续批处理调度器对 KV Cache 的需求很敏感KV Cache 越大允许同时存在的序列越多吞吐越高。调度参数与投机解码的交互比较微妙。假如max_num_seqs很低那么连续批处理能并发处理的请求就少这时候投机解码虽然让每个请求生成更快但调度器空转的时间反而变多吞吐提升不明显。反之max_num_seqs很高但 KV Cache 不足调度器会在每轮开始前挂起一堆请求结果就是请求排队时间变长。所以这两个参数其实是一对调的时候要一起看。我一般会先用 16 个序列起步跑通后再逐步加到 32、64观察吞吐曲线找到一个既不超显存、延迟也可接受的点。5. 常见坑与排查技巧实录5.1 草稿模型选小了吞吐反而下降我最早试投机解码时选了一个特别小的草稿模型想着越小越快。结果验证时接受率长期在 30% 以下被拒绝后还要回退整体吞吐反而比不开启时低了 15%。后来换成同系列稍大一点的草稿模型接受率到了 60% 以上吞吐才转正。这个坑的根源是投机解码不是靠草稿模型单独生成得快就行而是靠大模型验证一批 token 后接受的比例高。草稿模型太弱预测结果和大模型分布差太多大模型每步只能接受一两个 token还要白白等草稿模型跑一轮。选择草稿模型时先跑几十个测试 prompt看日志里的接受率指标低于 50% 就需要换。5.2 显存不够两个模型打架在显存比较紧张的环境里加载大模型和草稿模型后KV Cache 会被压缩得很小。连续的请求一旦多起来调度器会因为 KV Cache 不足而频繁挂起请求吞吐不如只有大模型时的情况。这是投机解码最容易被忽略的成本表面上是多了一个小模型实际是抢占了 KV Cache 的生存空间。解决方案有这么几个方向降低gpu_memory_utilization给调度器留点余地换更小的草稿模型或者用投机解码的另一种模式让草稿模型与大模型共享权重减少额外显存占用。在 vLLM 的版本更新中这类模式的支持也在变化建议直接看当前版本的参数列表。5.3 参数名和默认行为在不同版本里不一样vLLM 迭代速度非常快早期版本对投机解码可能需要额外开启use_speculative_decoding后续版本里只要指定speculative_model就自动启用。num_speculative_tokens的默认值也在变化。如果你照着网上的旧命令执行可能会收到一个 “unrecognized arguments” 的错误。这时候不要硬改命令行先查当前版本支持哪些参数。我的习惯是固定版本并且在代码里显式写出关键参数不依赖默认值。这样即使框架升级至少能通过报错快速定位是哪个参数出了问题。另外量化和投机解码的组合也要小心有些量化格式不支持投机解码或者在投机解码下会退化启动时会有警告日志需要提前确认。5.4 高 temperature 场景效果不稳定投机解码在采样温度比较高时接受率会明显下降。因为大模型在高温度下会引入更多随机性草稿模型的预测很难匹配。所以如果你的业务是创意写作需要 temperature0.8 甚至更高那投机解码的收益会打折扣。反过来许多线上服务的温度其实都很低或者干脆用贪婪解码这时候投机解码才有稳定收益。如果你确实需要高温度又不甘心放弃投机解码可以试试把num_speculative_tokens调低一点比如只预测 2 到 3 个 token。虽然单次验证收益变小但至少不会因为长序列被频繁拒绝而出现负优化。另一种思路是在服务化架构中把投机解码用在低延迟内部调用上而不是用户的最终采样但这已经超出三行代码的范畴了。5.5 别只看平均延迟还要看 P95我见过不少人只盯着平均延迟结果发现投机解码开启后平均延迟下降了但线上偶发超时变多了。原因在于投机解码的验证是成批的草稿模型一旦预测质量波动某个请求的某次生成可能要回退重来导致这一轮的耗时比平时高一截。连续批处理下多个请求互相挤占计算资源慢请求会拖长同批其它请求的时间。所以评估时至少要同时记录 p50、p95 和最长输出 token 长度只看平均值很容易被掩盖。我自己会在压测脚本里把每个请求的耗时存下来最后一起算分位数。如果 P95 上涨而平均值下降就要考虑是不是某些请求被草稿模型的低质量预测拖累了。6. 顺手分享几个补充姿势6.1 用服务化方式开启同样的配置如果你不仅要离线跑通还想接线上请求可以用 vLLM 的 API 服务来跑。命令大致是启动一个服务进程通过参数指定大模型和草稿模型。核心参数和刚才代码里差不多只是换成了命令行格式。启动后你只需要按普通 API 协议请求即可连续批处理和投机解码都在服务内部生效。命令如下vllm serve 你的大模型路径 \ --speculative-model 你的草稿模型路径 \ --num-speculative-tokens 5 \ --max-num-seqs 32 \ --gpu-memory-utilization 0.8这是非常值得做的一件事服务化之后吞吐优化对上游完全透明你不需要改业务代码。配合压测工具发请求很快就能拿到真实的延迟分布和吞吐指标。6.2 从日志里读接受率vLLM 在启用投机解码后日志里会输出每次验证的接受情况。有些版本会用类似Speculative decoding acceptance rate: 0.xx的字段。这个数字比任何吞吐图表都更能说明问题。如果接受率一直上不去优先考虑草稿模型选型如果接受率很高但吞吐还是不行那问题大概率在显存或max_num_seqs这类调度参数上。一个很简单的验证方法是保持其它参数不变只把草稿模型换掉观察接受率变化。如果接受率从 0.4 跳到 0.65那说明选型方向就对了。如果换了更大的草稿模型接受率只涨了几个百分点但显存占用明显上升那就得掂量掂量是不是值得。6.3 什么情况下不值得开投机解码如果你已经确认了三种情况请求基本都是短输出、温度高、显存紧张那投机解码大概率不是你的菜。短输出意味着生成阶段本身不长验证开销占比高高温意味着接受率低显存紧张意味着 KV Cache 被挤占。这种情况下强行开投机解码不如老老实实把连续批处理的max_num_seqs和 KV Cache 调好。优化是为了解决真实瓶颈而不是为了在汇报材料里多一个关键词。有时你把连续批处理的并发拉满比开一个花里胡哨的投机解码收益更直接。所以三行代码跑通之后第一件事不是去追求更高的参数而是先把基线数据测准让每一次改动都有依据。6.4 把三行代码扩展成压测脚本把刚才的测速片段放在一个 Python 文件里prompt 列表改成从文件读取就能当成一个简单的回归工具。每次修改参数后跑一遍把耗时和接受率记录下来。优化不是一锤子买卖而是反复调草稿模型、num_speculative_tokens、max_num_seqs、gpu_memory_utilization这几项互相耦合只有一个变量一个变量地试才能找到当前显卡下的最优组合。我在实际使用中比较习惯先固定草稿模型把num_speculative_tokens从 2 到 8 各跑一轮记住吞吐最高的点再去动max_num_seqs。最后说个很土但有效的习惯每次跑测试前先看一下显存余量把前后对比数据记录下来。很多所谓的不生效问题其实是显存已经触顶和投机解码本身无关。