news 2026/10/12 6:07:11

vLLM连续批处理与投机解码:三行代码实测吞吐优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
vLLM连续批处理与投机解码:三行代码实测吞吐优化

如果你手头有一套大模型推理服务,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 的小模型。草稿模型路径可以是你本地下载好的目录,也可以是标准的模型 ID,vLLM 会自动从模型仓库拉取。这里别急着同时加载两个大模型做实验,显存会瞬间爆炸,先从小尺寸草稿模型开始。

3.2 核心三行代码

下面是完整可运行的最小脚本。请把两个模型路径替换成你自己的模型。为了展示连续批处理的效果,我直接一次传了多个 prompt,而不是循环调用;循环调用会让连续批处理的调度优势完全发挥不出来。

from vllm import LLM, SamplingParams llm = LLM(model="你的大模型路径", speculative_model="你的草稿模型路径", num_speculative_tokens=5) outputs = llm.generate(["解释一下连续批处理", "解释一下投机解码", "你觉得大模型推理哪里耗时最多"], SamplingParams(temperature=0.0, max_tokens=128)) print([o.outputs[0].text for o in outputs])

严格来说,初始化、generate、print 就是核心三行。第一次运行时会加载两个模型,并做 Warmup,需要等一会儿。如果显存不够,你会在日志里看到明确报错,可以先加一个gpu_memory_utilization参数,比如gpu_memory_utilization=0.8,整体会安全很多。连续批处理不需要额外配置,vLLM 的调度器拿到这三个 prompt 后会并行处理,并在每一轮动态调度。启动日志里如果能看到类似 “Starting speculative decoding” 之类的提示,说明配置生效了。

提示:不要一开始就同时调三个参数。先按默认值跑通,再逐个调整,否则出问题你很难定位是哪个改动引起的。

3.3 每一行参数到底在干什么

第一行LLM(model=..., speculative_model=..., num_speculative_tokens=5)是核心。speculative_model指定草稿模型,vLLM 看到这个参数就启用投机解码;num_speculative_tokens=5表示草稿模型每轮最多预测 5 个候选 token。这个数字不是越大越好。设想一下,预测 10 个 token 一旦第 3 个就被拒绝,前面 7 个预测全浪费了,还增加了草稿模型的运行时间。对大多数场景,4 到 6 是比较稳的起点。

第二行generate是无状态接口,传入一个 prompt 列表,返回一个输出列表。多个 prompt 进入同一套调度器,这是连续批处理生效的前提。SamplingParams里的temperature=0.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(temperature=0.0, max_tokens=128) llm = LLM(model="你的大模型路径", speculative_model="你的草稿模型路径", num_speculative_tokens=5) 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(f"elapsed: {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 场景效果不稳定

投机解码在采样温度比较高时,接受率会明显下降。因为大模型在高温度下会引入更多随机性,草稿模型的预测很难匹配。所以如果你的业务是创意写作,需要 temperature=0.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。最后说个很土但有效的习惯:每次跑测试前,先看一下显存余量,把前后对比数据记录下来。很多所谓的不生效问题,其实是显存已经触顶,和投机解码本身无关。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 6:05:49

AnyPS5项目名称解析与技术定位澄清

项目标题为"AnyPS5",但提供的输入内容中:项目正文为空;关键词未给出;摘要描述缺失;网络搜索内容区块为空(内无实际信息);所有上下文均未提供任何实质性线索——既无技术指…

作者头像 李华
网站建设 2026/10/12 6:01:35

MinGW-w64 posix-seh编译链:Windows下实现POSIX线程与SEH异常兼容

简介:这是一套专为Windows平台C/C开发者准备的64位MinGW编译环境,面向需要本地构建原生代码、开发JNI接口或生成DLL的中高级程序员。资源完整封装了mingw_x86_64-posix-seh工具链,支持POSIX信号处理与Windows结构化异常处理(SEH&a…

作者头像 李华
网站建设 2026/10/12 6:00:39

AI芯片软硬件协同设计的三大核心维度

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华