说实话,这个标题里的“3行代码”其实带点标题党。真正让我在推理吞吐上拿到翻倍收益的,不是某一段炫技代码,而是vLLM连续批处理配合投机解码之后,整套启动参数和调度逻辑的重新理解。这篇文章把我从基线测试到参数调优的完整过程写出来,包括哪些参数可以直接照抄、哪些要根据你的模型和显存现场改,以及我踩过的掉速坑。适合已经能跑通vLLM、但对吞吐优化还停留在“换个更大的卡”阶段的人。
1. 先把“3行代码”摆出来:一份可以照抄的启动配置
1.1 三行关键参数和它们各自管什么
我先直接给结论。如果你用的是vLLM的OpenAI兼容服务,核心配置是这样一组启动参数:
vllm serve Qwen/Qwen2.5-7B-Instruct \ --tensor-parallel-size 1 \ --max-num-seqs 256 \ --enable-chunked-prefill \ --speculative-model Qwen/Qwen2.5-0.5B-Instruct \ --num-speculative-tokens 5其中真正起决定性作用的,是最后三行:
--enable-chunked-prefill:开启分块预填充。它让长提示词的prefill阶段不要一次性占满GPU,而是切成小块和decode混合执行。这是连续批处理能跑得顺的关键前置,不是可有可无的开关。--speculative-model Qwen/Qwen2.5-0.5B-Instruct:指定一个“草稿模型”。它负责快速猜测接下来的几个token,再由7B主模型一次性验证。--num-speculative-tokens 5:让草稿模型一次猜几个token。5是我试下来收益和风险比较平衡的值,不是越大越好。
如果你不走服务化,而是直接在Python里调用,那更接近“3行代码”的形态:
from vllm import LLM, SamplingParams llm = LLM( model="Qwen/Qwen2.5-7B-Instruct", speculative_model="Qwen/Qwen2.5-0.5B-Instruct", num_speculative_tokens=5, ) outputs = llm.generate(["给我写一段Python快速排序"], SamplingParams(temperature=0))我理解标题里说的“3行代码”,本质就是这个:不换模型、不写自定义算子、不碰CUDA kernel,只靠vLLM暴露出来的调度与投机解码配置,把同一张卡的产出逼出来。后文我会解释为什么这几行配置能生效,以及它们各自的收益边界在哪。
1.2 我的实测环境和版本选择
先交代环境,因为吞吐数据脱离硬件和版本没有意义。我用来跑通这套配置的机器是单张A100 40G,PyTorch 2.x,CUDA 12.4,vLLM版本在0.7系列。主模型是Qwen2.5-7B-Instruct,草稿模型是Qwen2.5-0.5B-Instruct,词表完全一致,这很重要。
如果你在Windows上折腾,我先泼一盆冷水。vLLM在Windows上虽然有社区版,但很多高级特性、显存管理和CUDA图模式的稳定性都不如Linux。我见过不少人在Windows上开投机解码直接报torch.cuda.OutOfMemoryError,不是参数问题,是平台兼容问题。生产环境老老实实用Linux容器。另外,如果你装了最新的CUDA 12.8,记得选和它匹配的vLLM版本,否则大概率要自己编译,编译一次够你喝一壶的。
至于LM Studio、Ollama和vLLM/SGLang的对比,我的观点是:个人尝鲜用Ollama没毛病,但你要做吞吐优化、要精确控制批处理大小和投机解码参数,Ollama暴露出来的配置项太少了。SGLang和vLLM是直接竞品,我在另一篇文章里拆过两者的调度差异,这篇我们聚焦vLLM。
2. 连续批处理:吞吐翻倍的第一个来源,藏在一个默认参数里
2.1 从“一锅端”到“流水线”:连续批处理的调度逻辑
很多人没意识到,vLLM的连续批处理技术(continuous batching)其实是吞吐提升的第一大来源,投机解码是叠加在上面的第二层收益。要先理解连续批处理,得先看看传统推理服务是怎么干的。
传统静态批处理的做法是:攒够一批请求,同时送进GPU,等这一批全部生成完毕,再统一释放显存、接收下一批。问题很明显:一批里面只要有一个长序列没生成完,其他早该结束的短序列也得陪着等;新来的请求哪怕很紧急,也得等当前这整批清空。这就像食堂打饭,一锅菜没炒完,所有人都得在窗口前干等着。
连续批处理的思路完全不同。它把调度粒度从“请求级”降到“token级”:GPU每执行一次前向,调度器会重新看一遍当前所有还在生成中的序列,哪些能继续跑、哪些已经生成了结束符,就立刻腾出显存槽位,把等待队列里的新请求塞进来。这样一来,短请求不会被长请求拖死,新请求也不用等一整批结束,GPU的利用率自然就上去了。
这个机制在vLLM里其实是默认开启的,但你未必吃到了它的红利,因为默认的--max-num-seqs偏保守。假如默认值是64,而你的显存明明能同时塞下256个序列,那连续批处理再聪明,也只能在64个槽位里转悠,吞吐天花板被锁死了。
2.2 chunked-prefill:为什么它和连续批处理是天生一对
连续批处理解决了“请求怎么在batch里进出”的问题,但还留了一个尾巴:prefill(预填充)阶段。一个请求如果带着很长的提示词进来,prefill计算量很大,会一次性霸占GPU很长时间。如果按照传统方式把整个prompt的prefill一次性做完,这段时间里其他序列的decode全部被阻塞,batch里明明有256个槽位,实际能用的只有正在prefill的那一个。
chunked-prefill就是来收拾这个局面的。它把一次prefill拆成多个小段,每算完一小段,就让调度器插几个decode步骤进去。你可以把它理解成:一个大任务不再独占CPU,而是每跑一会儿就让出时间片,让大家轮流用。这样GPU的流水线始终是满的,不会出现“一个人吃饭、所有人围观”的尴尬。
所以我的建议是:调大--max-num-seqs的同时,一定要配合--enable-chunked-prefill。两者配合的效果,我在后面验证部分会给数据。有些vLLM新版本可能在特定条件下默认开启了chunked prefill,如果你的版本提示这个参数已经默认启用,去掉也不会报错;但如果你的服务日志里能看到chunked prefill相关配置,就说明它在工作。
2.3 先测一次基线,再谈优化
我强烈建议你在动任何优化参数之前,先跑一次基线。优化的前提是可测量,否则你都不知道改完参数是变快还是变慢。
最省事的办法是用vLLM自带的离线吞吐基准:
python -m vllm.benchmark.benchmark_throughput \ --model Qwen/Qwen2.5-7B-Instruct \ --tensor-parallel-size 1 \ --num-prompts 1000 \ --input-len 512 \ --output-len 256我环境下的基线大概是:默认--max-num-seqs(64)时,稳定吞吐约1100 tokens/s。注意,这个数字只对“我这台机器+这个数据集”有意义,但同一环境下的相对关系是有参考价值的。拿到基线后,再逐步加参数,才不会变成玄学调优。
3. 投机解码:用“草稿+验证”缩短单token产出时间
3.1 草稿模型的工作方式:猜得越快,验证越省
连续批处理解决的是“GPU满负荷运转”的问题,但单次decode仍然只生成一个token。投机解码(speculative decoding)解决的是“单次前向产出太少”的问题,思路非常反直觉:让一个更小更快的模型先猜接下来K个token,然后大模型一次前向把这些猜测全部验证掉。
这里的关键在于,大模型验证K个token的成本,远小于大模型自己逐个生成K个token的成本。因为大模型验证时,可以并行处理这K个token对应的位置,本质上是“一次前向,多个输出位置同时算概率”。而在传统自回归里,每个token必须等前一个token算完才能开始,这个串行依赖才是慢的根源。
用个不严谨但好懂的说法:你让实习生先写5行代码,然后你一次看完,觉得没问题就全用了;而不是每一行都自己从头写。只要实习生水平别太差,你的产出速度一定比逐字写快。投机解码的数学收益取决于“接受率”(accepted rate):大模型对草稿模型猜的token有多大比例愿意放行。
如果草稿模型的接受率是α,草稿长度是K,理想情况下每个验证步产出的token数期望大约是:
[ E = \frac{1 - \alpha^{K+1}}{1 - \alpha} - 1 ]
这个公式你不用硬记,记住两个极端就行:α接近0.8以上时,K=5通常能带来2到3倍的产出提升;α在0.4以下时,收益会被验证开销吃掉,甚至不如不开。这也是为什么很多人开了投机解码反而掉速——不是vLLM的问题,是草稿模型选得不好。
3.2 草稿模型怎么选:同源、同词表、尺寸合适
我踩过最大的坑是草稿模型和目标模型词表不一致。当时我图省事,拿了一个很流行的通用小模型当草稿,结果目标模型生成的token ID和小模型猜的token ID根本对不上,接受率接近0,速度反而比不开投机还慢。后来我才理解:投机解码验证的是token序列,不是字符串,两个模型的词表必须完全一致,否则“猜”和“验”根本不在同一个坐标系里。
所以草稿模型的第一原则是:和目标模型同源、同词表。比如目标模型用Qwen2.5-7B,草稿模型就选Qwen2.5-0.5B或1.5B;目标模型用Llama-3.1-8B,草稿模型就选Llama-3.1-8B的同系列小模型。尺寸方面,我的经验是草稿模型大概为主模型的十分之一到五分之一比较合适:太大了挤占显存,主模型跑不快;太小了接受率低,投机没意义。
如果你部署的是DeepSeek这类通过蒸馏得到的开源模型,要特别注意草稿模型是否和目标模型用了同一套tokenizer。不要只看名字像不像,要在启动后观察日志或者实测接受率。vLLM里也有n-gram草稿、EAGLE这样更高级的草稿策略,但新手我不建议一上来就碰,先用标准草稿模型把链路跑通,比追求极限收益重要。
3.3 采样参数对投机解码的影响:temperature越高,收益越缩水
一个经常被忽略的事实:投机解码的接受率和采样参数强相关。当你用greedy(temperature=0)时,目标模型的输出是确定的,草稿模型猜中的概率最高。一旦你调高temperature,或者把top_p压得很低,目标模型会在概率分布上随机采样,草稿模型哪怕概率排第一,也经常因为随机性被拒绝。换句话说,你的业务如果要求高随机性、高多样性输出,投机解码的收益会明显打折。
我在实测里发现,temperature从0调到0.7,同样的草稿模型,整体吞吐能掉20%到30%。不是说不能用,而是你要有预期:投机解码在低温度场景下收益最稳定。如果你做的是代码生成、结构化输出、固定格式填充这类任务,放心大胆开;如果你做的是创意写作、开放对话,先小流量验证一下再说。
另外,--num-speculative-tokens不是越大越好。草稿模型一次猜的token越多,大模型验证时的显存和计算开销也越大,而且猜得越长,后半段接受率通常越低,边际收益递减。我试过3、5、7、10,最终5是综合最优。10的话显存涨了一截,吞吐反而和5差不多,因为多出来的猜测大部分被拒绝了。
4. 从安装到压测:完整复现这套优化的链路
4.1 安装和版本选择:别让环境坑了你
安装vLLM本身不复杂,复杂的是版本匹配。我推荐在干净的虚拟环境里装,避免和PyTorch相关的包冲突。
pip install -U vllm如果你要跑CUDA 12.8,务必确认当前vLLM版本是否提供了对应的预编译wheel。装完之后,先别急着跑投机解码,跑一次vllm serve主模型,确认环境本身没问题。如果启动阶段就报torch.cuda相关错误,先排查CUDA驱动和容器运行时,不要急着怀疑参数。
Windows这一节我再多说一句。vLLM的Windows社区版在简单推理场景下可用,但我在社区版上跑--enable-chunked-prefill加投机解码的组合时,遇到过CUDA graph捕获失败和显存碎片化的问题。如果你是个人电脑想体验,用WSL2的Docker镜像都比原生Windows稳。生产环境直接上Linux,不要纠结。
4.2 启动命令与“到底生效了没有”的确认方法
完整启一套带优化的服务,我用的命令类似这样:
vllm serve Qwen/Qwen2.5-7B-Instruct \ --tensor-parallel-size 1 \ --max-num-seqs 256 \ --max-num-batched-tokens 8192 \ --enable-chunked-prefill \ --speculative-model Qwen/Qwen2.5-0.5B-Instruct \ --num-speculative-tokens 5 \ --gpu-memory-utilization 0.92 \ --port 8000--max-num-batched-tokens控制一次前向最多处理多少个token。调大--max-num-seqs之后,建议同步观察显存,适当调整这个上限,防止显存溢出。
启动之后,怎么确认投机解码真的生效了?看日志。vLLM启动时如果成功加载草稿模型,日志里会出现类似Speculative decoding、draft model的字样。如果不生效,它通常会在启动阶段直接报错或警告,而不是默默忽略。另一个办法是看显存占用:加载了小模型后,显存占用会比单模型启动时高出一截。如果你瞄一眼显存和单模型一模一样,那投机解码大概率没配上。
chunked prefill和连续批处理有没有生效,可以通过观察服务负载和吞吐变化来判断,也可以在日志里看调度配置打印。不过我更推荐用压测数据说话,毕竟配置打印了不等于效果达标。
4.3 用离线脚本压测,记录三档对照数据
我把优化过程分成三档对比,这样能清楚看到每个参数的贡献:
| 配置 | 吞吐(tokens/s) | 备注 |
|---|---|---|
| 默认参数基线 | 约1100 | max-num-seqs保持64 |
| 调大max-num-seqs + chunked prefill | 约1750 | 显存占用明显上升 |
| 再叠加投机解码(K=5) | 约2800 | 草稿模型同源0.5B |
这套数据是在我环境下的离线基准测出来的,输入长度512、输出长度256、1000条prompt。你可以看到,连续批处理相关参数把吞吐拉高了约60%,投机解码在此基础上又叠加了约60%。两个优化方向不冲突,先把连续批处理吃到嘴里,再谈投机。
如果你想压测的是OpenAI兼容服务,而不是离线脚本,vLLM仓库里的benchmark_serving.py更合适:
python benchmark_serving.py \ --model Qwen/Qwen2.5-7B-Instruct \ --tokenizer Qwen/Qwen2.5-7B-Instruct \ --request-rate 10 \ --dataset sharegpt \ --num-prompts 500在线压测时除了看吞吐,还要盯住TTFT(首token延迟)和TPOT(每token延迟)。优化吞吐的前提是不能无限牺牲延迟。开投机解码之后TPOT通常会下降,因为一个验证步能吐出更多token;但如果你把--max-num-seqs调得过大,请求排队时间变长,TTFT可能上升,这在交互式场景里需要权衡。
5. 开投机反而掉速?排查链路与调参顺序
5.1 现象:配置都对,但吞吐不升反降
我见过不止一次:有人严格按照文档开了投机解码,压测出来比不开还慢。这时候不要慌,按下面几条逐个排查。第一条是草稿模型词表不一致,前面说过了,token ID对不上,接受率直接崩溃。第二条是采样参数太激进,temperature偏高。第三条是显存不够导致swap或者preemption,日志里如果出现preemption字样,说明显存已经吃紧,草稿模型挤占了主模型的空间,主模型被频繁抢占,性能自然崩。
还有一类情况是目标模型本身太小,比如你在跑3B、1.5B这种模型,草稿模型很难比它“明显更快”,投机解码的验证开销反而成了净负担。投机解码吃的是“大模型大而慢,小模型小而快”的剪刀差,如果剪刀差不够大,这个方案就不成立。
5.2 排查顺序:从日志到消融实验
我的习惯是先确认启动日志没有报错,再确认草稿模型加载后显存有变化,接着做消融实验。消融实验的方法很简单:同一份压测数据,依次跑“纯基线”、“只开连续批处理相关参数”、“只开投机解码”、“全开”。每次只加一个变量,对比吞吐和延迟曲线。不需要复杂的监控系统,vLLM自带的benchmark工具够了。
如果在“只开投机解码”这一档就掉速,直接换草稿模型或减小K。如果这一档正常,全开反而掉速,重点查显存上限和批处理参数。我还遇到过一种情况:主模型和草稿模型都加载成功了,但因为模型量化方式不兼容,实际推理没走投机路径。这种问题在日志里通常有warning,建议更新到较新版本再试。
5.3 我推荐的调参顺序:先拿稳收益,再叠加
不要一上来就把所有参数拉满,否则出了问题你根本不知道是哪一步导致的。我推荐的顺序是:
- 先调连续批处理:把
--max-num-seqs从默认值往上涨,配合--enable-chunked-prefill,观察吞吐到哪一步停止增长、显存什么时候逼近上限。 - 稳定之后开投机解码:先选同源草稿模型,
--num-speculative-tokens从3开始,跑出接受率和吞吐。 - 逐步调K:3、5、7各跑一轮,选吞吐峰值对应的K。通常5够用,超过7收益递减。
- 最后再回头看延迟指标:如果TTFT超标,适当降低
--max-num-seqs,或者限制并发,而不是直接关掉投机解码。
还有两个细节容易被忽略。一是--gpu-memory-utilization留一点余量,别把显存吃到100%,否则草稿模型和主模型之间稍微波动一下就开始preemption。二是长prompt场景下,投机解码只优化decode阶段,对prefill没帮助,甚至因为草稿模型占显存而拖累prefill。如果你主要处理超长输入,连续批处理和prefix caching可能比投机解码更值得投入。
5.4 别指望魔法:什么场景收益有限
说了这么多,我得浇点冷水。投机解码不是万能的。你的输入如果以极长prompt为主,那整体时间大头在prefill,decode阶段的加速再猛也拉不高总吞吐;你的业务如果强制temperature很高,接受率掉了,收益自然缩水;你的GPU如果本来就很空闲,只跑一两个并发请求,连续批处理连batch都凑不满,投机解码的验证开销还会让单请求延迟略微升高。
我个人的经验是:这套组合拳最适合高并发、低temperature、输出长度相对稳定的服务化场景。先把这些条件对齐,然后再去压榨每一个参数,你会看到吞吐像挤牙膏一样一点一点往上走,那种感觉比直接换卡有意思多了。