vLLM这几年的热度,但凡做过大模型推理落地的人都深有体会。所谓高吞吐推理引擎,核心目标就是在有限的GPU显存里,把本地高并发推理服务的单位时间请求处理量做到最大化。我最初被vLLM吸引,就是因为一个很现实的问题:同样的模型,用普通框架部署时并发一上来就显存爆炸、请求排队,换成vLLM之后,吞吐直接翻了几倍,而GPU利用率终于跑到了该有的水平。这篇文章我打算围绕vLLM在本地高并发推理服务中的落地实践,把核心原理、部署方式、参数调优和踩坑记录一次性讲清楚。不管你是在做内部大模型API,还是在折腾个人推理服务,只要被并发和显存卡过,这篇内容应该能直接帮你省掉不少弯路。
1. 项目核心:vLLM到底解决了什么问题
1.1 传统推理框架的瓶颈出在哪
先聊聊我最早用原生transformers代码部署推理时的痛苦。模型加载没问题,单条请求推理速度也能接受,但只要并发请求一多,整套服务就像得了肠胃炎——要么显存溢出直接OOM,要么请求全部卡在队列里慢慢等。
问题本质不在计算,而在KV Cache。Transformer模型在生成每个token时,都要读取历史token的Key和Value向量,这些向量存在显存里就是KV Cache。传统推理框架为了简单,通常按请求的最大序列长度一次性把显存预留出来。比如一个模型最大支持4096 token的上下文,那每个请求的KV Cache就按4096个token的体量去占显存,哪怕这个请求实际只生成了几十个token,预留的空间也不会释放。
我粗略算过一笔账。一个70亿参数模型,假设40层、隐层维度4096,FP16精度下单个token的KV Cache大约占 40 × 4096 × 2 × 2 字节,也就是600多KB。如果按满上下文4096 token预留,单请求就要吃掉256MB显存。32路并发同时进来,光KV Cache就是8GB。显存被白白浪费,并发能力自然上不去。
除此之外,传统框架的批处理方式也很粗糙。多个请求凑成一个batch后,必须等batch里最慢的那个序列生成完,整个batch才能释放,中途GPU会出现大量空闲气泡。在长文本生成场景里,这种浪费尤其明显。
1.2 vLLM的三个关键设计
vLLM针对上面两个痛点做了三件核心事:PagedAttention、Continuous Batching和显存感知调度。
第一,PagedAttention把KV Cache分页管理。不再为每个请求预占整块连续显存,而是像操作系统管理内存一样,按固定大小的块分配,用到多少分配多少。显存碎片的利用率大幅提高,同样的显存能容纳更多并发请求。
第二,Continuous Batching实现了迭代级调度。传统方案是请求级调度,一个batch落地后直到所有序列结束才换下一批;vLLM则在每次decode迭代之后,完成的请求立即退出,新进来的请求马上补位,GPU永远在满负荷工作。
第三,调度器会做显存感知。每个请求进来时,vLLM会估算它需要的KV Cache块数量,显存足够就接收,不够就挂起等待,而不是等到解码中途才发现显存不够导致崩溃。这套设计让服务在高并发下具备可预期的兜底行为。
1.3 适合什么场景,不适合什么场景
直接说结论。vLLM最擅长的是高并发API服务、多轮对话、长上下文推理,以及离线批量跑分这类场景,特征是并发高、token吞吐量大、对单请求延迟要求不是极端苛刻。
但如果你的场景是单请求超低延迟,比如语音助手那种要求几十毫秒内响应的实时交互,vLLM未必比专门优化过的低延迟方案更合适。另外,如果你需要对解码过程做大量自定义算子改动,或者要接入某些特殊的采样逻辑,vLLM的调度框架反而会带来限制。选型时先看业务形态,别盲目追新。
2. 核心原理:PagedAttention与连续批处理
2.1 PagedAttention:给KV Cache做“虚拟内存”
PagedAttention是vLLM最核心的设计,理解它就理解了vLLM的一大半。
传统显存管理可以类比成订酒店:为了让客户住得舒服,直接包下一整层楼,不管客户实际住几间。请求数量一多,楼层不够用,后面的客户只能在楼下排队。PagedAttention的做法变成了按房间按天订:客户住几天就订几间,房间分布在楼层不同位置也没关系,只需要记录房间号就行。
具体到实现,vLLM把KV Cache划分成固定大小的逻辑块,默认每个块可以存16个token的KV向量。每个请求有一个逻辑块表,记录它当前占用哪些物理块。当请求生成了新的token,就往表里追加对应的物理块;如果物理块不够了,调度器会先分配新的块,分配不到就暂停请求。
这里有个关键收益:因为块是等大小的,显存中几乎不存在碎片问题。一块内存不管属于哪个请求,只要没被用上,就能立刻分配给其他请求。传统方案中预留后不可复用的情况彻底消失了。
2.2 Continuous Batching:迭代级调度让GPU一直“满座”
再讲Continuous Batching。vLLM对批次的处理粒度非常细,调度发生在每一次decode迭代之后。比如当前batch里有8个请求在生成,其中3个已经生成到结束标记,它们立刻退出,腾出的显存块马上可以被新进入的请求使用。
这和传统batching的差别非常大。传统batching是短板效应:batch里有一个请求要生成500个token,其他几个只要50个,GPU也得陪着跑完500步。vLLM则让短请求提前下车,新请求随时上车,GPU在每一轮迭代都处于相对满负荷的状态。
我自己的体会是,这个机制对混合负载尤其有效。实际业务里请求长度参差不齐,有的问一句就结束,有的要生成一大段代码。Continuous Batching能把长短请求混在一起调度,互不拖累,GPU平均利用率能上一个台阶。
2.3 为什么整体提升能到几十倍
很多人看到公开benchmark里vLLM吞吐提升5倍、10倍甚至20倍的数字,觉得夸张。其实这些数字不是玄学,而是几个因素叠加的结果。
第一,显存利用率提高,直接转化为更大的并发批大小。以前只能并发4路请求,现在能并发16路,吞吐自然涨。第二,decode阶段的batch更大,GPU矩阵运算的并行效率更高。第三,PagedAttention对prefill阶段也有优化,超长prompt可以按块切分处理,首token时间有所改善。
我实测过的效果是:在显存受限的高并发场景,提升幅度最明显,三四倍是保底;如果你的请求普遍很长、上下文很大,那收益会更夸张,接近公开数据里的上限。反过来,短请求、低并发、算力本来就闲的场景,提升幅度没那么大。所以判断收益前,先看清自己的负载特征。
3. 本地部署实操:从安装到启动服务
3.1 环境准备与安装
部署vLLM之前,先确认三件事:GPU型号和显存、GPU驱动版本、Python环境。我的建议是至少8GB显存起步,否则很多主流开源模型放进去之后,剩下的显存连KV Cache都挤不出多少,并发根本没戏。
Python版本建议3.9到3.12之间。CUDA方面,vLLM打包的预编译版本通常对应CUDA 11.8或12.1,你本地的GPU驱动需要支持对应的CUDA版本。安装时最省心的方式就是用官方软件仓库直接装:
pip install vllm装完验证一下版本:
python -c "import vllm; print(vllm.__version__)"如果网络受限,配置国内镜像源:
pip install vllm -i https://pypi.tuna.tsinghua.edu.cn/simple这里有个坑:如果你的机器上之前装过旧版vLLM或者一堆深度学习的依赖包,建议先建一个干净的虚拟环境再装。vLLM对torch、transformers的版本要求比较严格,混装容易出现各种莫名其妙的冲突。我在内网机器上踩过不少次,最后统一用虚拟环境隔离才稳定下来。
3.2 启动服务:核心参数逐个讲
vLLM装好之后,启动推理服务的方式很简洁。官方入口是vllm serve命令,直接指定模型路径或模型名,它就会拉起一个OpenAI兼容的HTTP服务。我常用的一条启动命令是:
vllm serve Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.92 \ --max-model-len 8192 \ --max-num-seqs 128 \ --tensor-parallel-size 1 \ --enable-prefix-caching这些参数每一个都影响并发能力,挨个说清楚:
--gpu-memory-utilization控制KV Cache最多占用多少比例的GPU显存。我通常设0.85到0.95之间,不建议拉满到0.99,因为CUDA context、计算图、激活值也要占显存,拉满容易在运行时碰壁。
--max-model-len是支持的最大上下文长度,包括输入加输出。它会影响KV Cache块的分配策略和prefill的切分方式。不是越大越好,得按业务实际需求来,盲目设32K会让单请求占用显存大幅上升,压缩并发空间。
--max-num-seqs是单个批次最多容纳的序列数,直接决定并发上限。设太小,请求来了只能排队;设太大,batch过大后每个序列的解码速度会下降。初始建议64或128,后续通过压测再微调。
--tensor-parallel-size是多卡并行参数。当单卡放不下模型时,设为2或4,vLLM会通过Tensor Parallel把模型切到多张卡上。--enable-prefix-caching开启前缀缓存,多个请求共用相同prompt前缀时,可以复用KV Cache,多轮对话场景收益很大。
3.3 调用接口与本机验证
服务启动后,先验证是否健康:
curl http://127.0.0.1:8000/v1/models这个接口会返回当前加载的模型信息,能通就说明服务起来了。
vLLM提供了OpenAI兼容的接口格式,意味着业务侧可以用标准的SDK直接调用,几乎零成本切换。之前我在内部项目里接多个推理框架,业务代码完全不用改,只换了base_url就把vLLM接入进去了。调用示例:
from openai import OpenAI client = OpenAI( base_url="http://127.0.0.1:8000/v1", api_key="local", ) resp = client.chat.completions.create( model="Qwen/Qwen2.5-7B-Instruct", messages=[{"role": "user", "content": "用一句话解释什么是KV Cache"}], max_tokens=256, temperature=0.7, ) print(resp.choices[0].message.content)流式输出也是一样的用法,只需要加stream=True。vLLM对流式场景的优化也做得不错,迭代级的Continuous Batching在流式输出下更能体现吞吐优势。
4. 高并发实战:吞吐量分析与参数调优
4.1 压测脚本与关键指标
启动服务之后,不能凭感觉判断性能,一定要做压测。我用过最简单的并发压测脚本,基于httpx的异步客户端,可以模拟不同并发度下的压力。核心代码如下:
import asyncio import time import statistics import httpx URL = "http://127.0.0.1:8000/v1/chat/completions" MODEL = "Qwen/Qwen2.5-7B-Instruct" async def send_one(client, payload, semaphore): async with semaphore: start = time.time() resp = await client.post(URL, json=payload) resp.raise_for_status() data = resp.json() ttft = data.get("timings", {}).get("first_token_time", None) return time.time() - start, ttft async def run_load(concurrency, total_requests, payload, semaphore): async with httpx.AsyncClient(timeout=300) as client: tasks = [] for _ in range(total_requests): tasks.append(send_one(client, payload, semaphore)) results = await asyncio.gather(*tasks) return results if __name__ == "__main__": payload = { "model": MODEL, "messages": [{"role": "user", "content": "写一个200字的自我介绍"}], "max_tokens": 256, "temperature": 0.7, } for concurrency in [8, 16, 32, 64, 128]: semaphore = asyncio.Semaphore(concurrency) results = asyncio.run(run_load(concurrency, 200, payload, semaphore)) latencies = [r[0] for r in results] tps = len(results) / (sum(latencies) / concurrency) avg_latency = statistics.mean(latencies) print(f"concurrency={concurrency}, TPS={tps:.2f}, avg_latency={avg_latency:.2f}s")这里的TPS计算方式比较简单,准确的压测应该统计从开始到所有请求完成的完整窗口,而不是用平均延迟除以并发数。建议在实际压测时用固定时间窗口来统计完成的请求数。
需要重点关注的指标有三个:
TTFT,也就是首Token时间。它反映排队和prefill的开销,多轮对话场景下这个指标尤其重要。如果TTFT飙升,说明请求在调度器里排队太久了。
TPOT,也就是每个Token的生成耗时,反映decode阶段的batch负载。如果TPOT从20ms涨到100ms,说明一个batch里塞了太多序列,每个序列分到的算力被稀释了。
TPS和整体吞吐,单位时间完成的请求数或生成的token总数。这两个指标是最终的性能标尺,所有调优最后都要落到它们上面。
4.2 调优经验:从吞吐曲线找最佳值
我之前在单张80GB显存的显卡上测试过一套70亿参数的模型,把并发数从8一路加到256,TPS的变化趋势让我印象很深。在低并发区间,TPS基本线性增长;到达某个拐点后,增速放缓;继续加压,TPS不再增加,反而平均延迟明显恶化。这个拐点附近,就是这台机器的合理并发上限。
针对不同负载特征,调优优先级不一样。
短输出场景,比如分类、抽取、关键词生成,输出token数少,瓶颈往往是prefill。优先调大--max-num-seqs,把更多请求塞进一个有prefill能力的batch;同时开启--enable-prefix-caching。这类场景里,--max-model-len不用设太大,反而应该压到能覆盖业务输入上限即可,省下的显存全给KV Cache。
长输出场景,比如生成文章、代码、报告,decode是主要耗时,每个序列长期占着显存块。这时优先保证单序列的decode速度,--max-num-seqs不宜无限放大,否则TPOT恶化,用户体感会明显变差。最佳做法是压测不同并发下的TPOT曲线,找到一个“TPS还在涨、TPOT还能接受”的平衡点。
--gpu-memory-utilization的调优也讲究策略。我一般先设0.90跑一轮压测,确认不OOM后,再适当往0.95试。如果业务高峰期请求排队严重,而是KV Cache不够导致的,可以调大这个值。前提是留出足够余量给其他显存开销,我见过有人设0.99然后运行中崩溃的。
4.3 多卡并行与量化方案
单卡放不下模型或者想进一步扩大并发时,两个方向可以参考。
多卡方面,--tensor-parallel-size 2或4,vLLM会把模型权重和计算切到多张卡上。注意显存带宽成为新的瓶颈,跨卡通信有开销,不是卡越多越快,4卡以上的收益增长会明显放缓。有条件的话,建议优先用NVLink互联的卡,效果更好。
量化方面,对这个场景帮助很大。70亿参数的模型FP16权重要14GB显存,用AWQ或GPTQ量化为4bit后只剩约4GB,剩下的显存几乎全给KV Cache,并发能力可以翻不少。量化方式不影响OpenAI接口,模型推理照常走。我自己的实测是,4bit量化在对话类任务上质量损耗不大,但吞吐收益很实在。
量化模型启动方式和普通模型一致:
vllm serve ./models/coder-7b-awq \ --quantization awq \ --gpu-memory-utilization 0.92 \ --max-num-seqs 128有一点要留意:量化模型如果本身是手工转化或社区版本,精度差异可能不小,上线前一定要跑一批评测集做对比,不能只看指标好看就切过去。
5. 常见问题与排查实录
5.1 显存不足与KV Cache耗尽
最典型的错误是启动或运行时报 “No available memory for the cache blocks”。这表示KV Cache的可用块已经被占满了。遇到这个问题,排查顺序我一般是这样:
第一,把--max-num-seqs调小,观察是否恢复。第二,把--gpu-memory-utilization从0.95降到0.85附近,给其他开销留出空间。第三,检查--max-model-len是否设得过大,如果业务并不需要32K上下文,压到8K或4K能释放大量KV Cache。最后一个隐藏点:如果你开启了多卡Tensor Parallel,每张卡上还要额外留一部分显存给通信缓冲,这时显存利用率更不该拉满。
5.2 首Token时间高企与请求排队
如果压测时TPS不高,但TTFT持续走高,大概率是请求在调度器里排队,而不是生成太慢。通过vLLM日志里的running和waiting数量可以确认:如果waiting一直很高,说明--max-num-seqs限制了并发窗口。
先说结论:优先调大--max-num-seqs。如果已经是256还排队,那就是算力或显存真的饱和了,这时候可以开--enable-prefix-caching减少重复prefill,或者上多卡和量化。记住一个判断技巧:TPS上不去但TTFT高,是排队问题;TPS在涨但TPOT明显变差,是batch内过度竞争,两个问题的解决方向完全不同。
5.3 依赖与版本兼容问题
vLLM对底层依赖的要求出了名的严格。我遇到过几次很典型的问题。
一个是启动时提示CUDA不可用,报错类似“Torch is not compiled with CUDA enabled”。这通常是因为环境里装了一个CPU版或CUDA版本不匹配的torch。解决办法很直接:把当前环境清理干净,重新安装一份与vLLM匹配的torch和CUDA版本。
一个是FlashAttention相关的报错。vLLM在许多GPU上依赖FlashAttention加速,而FlashAttention对GPU架构和CUDA版本非常敏感。如果启动时崩溃在FlashAttention初始化,优先考虑升级GPU驱动、更换CUDA版本,或者安装vLLM预编译版本。不建议自己编译,编译时间很长,而且不同硬件平台差异很大。
还有一个通用建议:不要轻易把vLLM和相关依赖一起批量升级。vLLM的小版本更新经常附带底层算子变更,升级前仔细读更新说明,并且在测试环境跑一遍回归,再动生产。
5.4 问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 启动报 No available memory for cache blocks | KV Cache占用已满 | 降低 max-num-seqs、降低 gpu-memory-utilization、缩小 max-model-len |
| TTFT高但TPS不高 | 请求在排队 | 调大 max-num-seqs、开启 prefix-caching |
| TPOT明显变差 | batch内序列数过多 | 适当减小 max-num-seqs,平衡吞吐与单请求速度 |
| 报 Torch CUDA 相关错误 | torch 与 CUDA 版本不匹配 | 重建虚拟环境,重装匹配版本的 torch 和 vLLM |
| FlashAttention 初始化崩溃 | GPU架构或 CUDA 版本不兼容 | 升级驱动、更换 CUDA 版本,或用预编译安装包 |
| 多卡启动报 NCCL 错误 | 多卡通信异常 | 检查显卡互联方式、驱动版本,降低 tensor-parallel-size 测试 |
| 量化模型输出质量下降 | 量化精度损失或校验集不足 | 换用不同量化位宽,先跑评测对比再上线 |
写在最后的一点经验
整个项目折腾下来,我最深的体会是:vLLM的收益上限取决于你的负载特征,而不是参数本身。抄网上的配置没有任何意义,必须自己压测、画曲线、找拐点。启动服务一句vllm serve就能跑起来,但真正把它调成一台稳定扛住高并发推理的服务,靠的是对显存、调度和业务特征的综合理解。个人项目建议从单卡加70亿参数模型起步,max-num-seqs从64开始压测,再逐步往上加。后续如果要扩展,可以把vLLM接入统一的模型网关,做多模型路由、自动扩缩容,那时候你会发现,高并发推理这件事,真正的大头全在调度和资源管理上。