1. 为什么要在本地折腾高并发推理服务
1.1 从一次压测翻车说起
去年帮一个做智能客服的团队做性能优化,他们用某开源推理框架在本地部署了一个7B模型,单条请求响应时间大概1.2秒,看起来还行。但上线之后一到晚高峰,并发量上到30左右,整个服务直接雪崩——P99延迟飙到20秒以上,GPU利用率却只有40%出头。这个现象特别典型:显存里明明还有空间,计算卡也没跑满,但请求就是堵在那里出不来。
后来排查下来,问题出在推理引擎的调度策略上。传统方案基本是“一个请求占一份显存、跑完再放下一个”,batch size固定,KV Cache按最大长度预分配,短请求浪费大量显存,长请求又容易OOM。GPU大部分时间在等内存搬运,而不是在算矩阵乘。这就是为什么很多人觉得“本地部署大模型又慢又贵”的根源——不是硬件不行,是调度没做对。
vLLM这个项目就是冲着这个痛点来的。它的核心卖点用一句话概括:用更少的显存,跑出更高的并发吞吐。官方给出的数据是在同等硬件下吞吐量能到传统方案的数倍甚至十几倍,这个数字我第一次看到也觉得夸张,但自己搭起来压测之后发现,在合适的场景下确实能摸到这个量级。
1.2 这篇文章适合谁看
如果你属于下面几类人,这篇内容应该能帮你省下不少试错时间:
- 手里有一张或几张消费级/专业级显卡,想在本地跑一个能同时服务多个用户的推理接口;
- 已经在用某个推理框架,但并发一上去就崩,想搞清楚瓶颈到底在哪;
- 对PagedAttention、连续批处理这些概念听过但没动手验证过,想看看实际效果;
- 需要给团队内部搭一个模型服务,要求稳定、可观测、能压测。
我下面会从整体设计思路讲起,然后拆核心机制,再给一套完整的本地部署和压测流程,最后把踩过的坑整理成排查表。所有命令和配置都是我在实际环境里跑通的,你可以直接抄。
2. 整体设计思路:vLLM到底在解决什么问题
2.1 传统推理服务的三个死结
要理解vLLM的价值,得先看清楚传统方案卡在哪。我把常见的问题归纳成三个死结:
第一个死结是显存碎片化。传统做法给每个请求按最大序列长度预分配KV Cache,比如模型支持4096上下文,那不管用户实际输入是50个token还是4000个token,都按4096预留。一个7B模型在FP16下,单请求KV Cache可能就要占几百MB到1GB。显存就这么被切成一堆大小固定的块,短请求占着大块不放,新请求进不来,GPU却在空转。
第二个死结是批处理僵化。静态batch要求所有请求同时开始、同时结束。但真实场景里请求长度千差万别,一个batch里只要有一条长请求,其他短请求就得陪着等,等长请求跑完才能一起释放。这导致GPU利用率忽高忽低,平均下来很低。
第三个死结是调度粒度粗。请求级别的调度意味着要么整个请求排队,要么整个请求执行,没法在token级别做细粒度穿插。GPU的算力被浪费在等待和同步上。
2.2 vLLM的两个核心武器
vLLM针对上面三个死结,给出了两个关键设计,这也是它区别于其他框架的地方。
武器一:PagedAttention。这个名字借用了操作系统的虚拟内存分页思想。它把KV Cache切成固定大小的block(默认16个token一块),这些block在显存里不需要连续存放,通过一张block table来映射逻辑位置和物理位置。这样一来:
- 显存按需分配,用多少给多少,短请求不再浪费;
- block可以共享,比如多个请求有相同的前缀(system prompt),这部分KV Cache只存一份;
- 碎片被大幅消除,同样显存能容纳的并发请求数成倍增加。
我实测过一个对比:同样一张24GB显存的卡,跑7B模型,传统方案并发到8左右就开始OOM,vLLM能稳定跑到30以上,这个差距主要就是PagedAttention带来的。
武器二:连续批处理(Continuous Batching)。传统batch是“一批一起进、一起出”,vLLM改成“请求随到随进、跑完随出”。调度器在每个解码步都会检查:有没有新请求要加入?有没有请求已经生成完EOS可以退出?然后把当前所有活跃请求组成一个动态batch。这样GPU几乎不会空转,吞吐量自然上去了。
这两个机制配合起来,效果是乘法关系而不是加法关系。PagedAttention让显存能装下更多请求,连续批处理让这些请求能被高效调度,两者缺一不可。
2.3 方案选型的几个取舍
在决定用vLLM之前,我也对比过其他几条路线,这里把取舍逻辑说清楚,方便你判断是否适合自己。
| 方案类型 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| 传统单请求推理 | 部署简单,依赖少 | 并发差,显存浪费严重 | 个人调试、单用户 |
| 静态批处理 | 实现直观 | 长短请求互相拖累 | 请求长度均匀的离线任务 |
| vLLM连续批处理 | 吞吐高,显存省 | 配置项多,需要调优 | 在线服务、多用户并发 |
| 其他优化框架 | 各有侧重 | 生态和文档参差 | 特定硬件或场景 |
选vLLM的核心理由是它在“通用性”和“性能”之间平衡得比较好:支持主流模型架构,社区活跃,配置灵活,而且对硬件的要求相对宽容——不一定非要顶级卡,消费级显卡也能跑出明显收益。
注意:vLLM的性能优势在“并发”场景下才明显。如果你只是单用户单条请求,它和普通方案差别不大,甚至因为调度开销略慢一点点。别为了用而用。
3. 核心机制拆解与关键参数解析
3.1 PagedAttention的显存账怎么算
很多人对PagedAttention的理解停留在“省显存”三个字,但具体省多少、怎么算,心里没数。我带你算一遍,这样你配置的时候才知道该给多少显存。
假设模型是7B,FP16精度,层数32,隐藏维度4096,注意力头数32。KV Cache每个token占用的字节数大致是:
2(K和V)× 层数 × 头数 × 每头维度 × 2字节(FP16) = 2 × 32 × 32 × 128 × 2 = 524288 字节 ≈ 0.5 MB/token也就是说,每1000个token的KV Cache大约占500MB。如果按4096上下文预分配,单请求就是2GB左右。一张24GB的卡,扣掉模型权重(7B FP16约14GB),只剩10GB给KV Cache,传统方案只能塞5个请求。
换成PagedAttention,block大小16个token,每个block约8MB。实际请求平均长度假设500 token,那一个请求占约32个block,也就是256MB。10GB能塞下约40个请求。这就是为什么并发能力差了好几倍。
关键参数是block_size,默认16。调大能减少block table的管理开销,但内部碎片会增加;调小则相反。我一般保持默认,除非有特殊需求。
3.2 连续批处理的调度逻辑
连续批处理的核心是一个循环:每个解码步,调度器做三件事。
第一,检查等待队列里有没有新请求,如果有且显存够,就分配block、加入活跃集合。第二,遍历当前活跃请求,每个跑一步解码,生成一个新token。第三,检查有没有请求生成了结束符或达到最大长度,有就释放它的block、移出活跃集合。
这个循环的关键在于“每个解码步都重新组batch”。传统方案batch在请求开始时固定,vLLM的batch是动态的,每一刻的组成都可能不同。这带来一个副作用:不同请求的进度不一样,没法用简单的矩阵运算一次性处理。vLLM的解法是用attention mask和位置编码来区分,底层kernel做了专门优化。
实际调优时,max_num_seqs(最大并发序列数)和max_num_batched_tokens(单批最大token数)是两个要重点关注的参数。前者限制同时活跃的请求数,后者限制一个batch里所有请求的token总数。设太小浪费算力,设太大容易OOM。
3.3 显存利用率参数gpu_memory_utilization
这个参数我单独拎出来讲,因为它是最容易踩坑的地方。它表示vLLM可以使用的显存占总显存的比例,默认0.9。
为什么不是1.0?因为要留一部分给CUDA上下文、临时buffer、以及框架自身的开销。设成1.0大概率启动就OOM。但设得太低又浪费显存,降低并发能力。
我的经验值:
- 单卡独占、没有其他进程:0.90到0.92比较稳;
- 卡上还有别的进程(比如监控、其他服务):降到0.80到0.85;
- 多卡张量并行:每张卡都要留余量,0.85左右。
提示:这个参数不是越大越好。我见过有人设0.95,压测时短请求没事,一上长请求就OOM,因为临时buffer没算进去。留5%到10%的余量是保命的。
3.4 张量并行与流水线并行的选择
如果单卡装不下模型,或者想进一步提升吞吐,就要考虑多卡。vLLM支持张量并行(TP)和流水线并行(PP),但两者适用场景不同。
张量并行是把每一层的矩阵运算切到多张卡上,卡之间通信频繁,适合同一节点内的多卡(NVLink或PCIe)。流水线并行是把不同层分到不同卡上,通信量小但会有气泡,适合跨节点。
本地部署一般就一个节点,优先用张量并行。参数是tensor_parallel_size,设成卡数即可。比如两张卡就设2。注意这个值要能整除模型的注意力头数,否则会报错。
我实测过7B模型单卡和双卡TP的对比:双卡吞吐大约是单卡的1.7倍,不是2倍,因为通信有开销。13B以上模型单卡装不下时,TP就是必须的了。
4. 本地部署实操:从零到能压测
4.1 环境准备与依赖安装
先说硬件和系统。我用的是Ubuntu 22.04,显卡是两张24GB的专业卡,驱动版本535以上,CUDA 12.1。Python用3.10,太新的版本有时候某些依赖还没跟上。
安装步骤我整理成可直接执行的流程:
# 创建独立环境,避免污染系统Python python3.10 -m venv vllm-env source vllm-env/bin/activate # 升级pip,老版本装包容易出问题 pip install --upgrade pip # 安装vLLM,指定版本避免踩到最新版的坑 pip install vllm==0.4.2 # 验证安装 python -c "import vllm; print(vllm.__version__)"这里有个细节:vLLM会自带PyTorch,但如果你之前装过其他版本的torch,可能会冲突。建议在干净环境里装,或者先pip uninstall torch再装vLLM。
如果要用FlashAttention加速,还需要单独装对应的包,但vLLM新版本一般会自动选择最优的attention后端,不用手动干预。
4.2 启动服务的两种方式
vLLM提供两种启动方式:命令行和Python API。命令行适合快速验证,Python API适合集成到自己的服务里。
命令行方式:
python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ --served-model-name my-model \ --tensor-parallel-size 2 \ --gpu-memory-utilization 0.88 \ --max-model-len 4096 \ --max-num-seqs 64 \ --port 8000这个命令会起一个兼容OpenAI接口的服务,可以直接用openai的客户端调。几个参数解释一下:
--model:模型路径,可以是本地目录,也可以是HuggingFace上的模型名(但本地部署建议提前下载好);--served-model-name:对外暴露的模型名,客户端调用时用这个;--max-model-len:最大上下文长度,设太大占显存,设太小长请求会被截断;--max-num-seqs:最大并发序列数,根据显存和请求长度调。
Python API方式:
from vllm import LLM, SamplingParams llm = LLM( model="/path/to/your/model", tensor_parallel_size=2, gpu_memory_utilization=0.88, max_model_len=4096, ) sampling_params = SamplingParams(temperature=0.7, top_p=0.9, max_tokens=512) outputs = llm.generate(["你好,介绍一下你自己"], sampling_params) print(outputs[0].outputs[0].text)这种方式适合嵌入到已有的Python服务里,但要注意LLM对象初始化比较重,不要在每次请求时都创建。
4.3 模型权重的准备与量化选择
模型权重可以从公开渠道下载,放到本地目录。目录结构要符合HuggingFace格式,包含config.json、tokenizer.json、权重文件等。
如果显存紧张,可以考虑量化。vLLM支持AWQ、GPTQ、FP8等量化格式。量化的取舍:
| 量化方式 | 显存节省 | 精度损失 | 适用场景 |
|---|---|---|---|
| FP16 | 基准 | 无 | 显存充足 |
| AWQ 4bit | 约60% | 较小 | 显存紧张,追求吞吐 |
| GPTQ 4bit | 约60% | 较小 | 同上,生态更成熟 |
| FP8 | 约50% | 很小 | 新卡支持,平衡之选 |
我的建议是:如果显存够,优先FP16,省心。如果不够,AWQ是首选,实测下来精度损失在可接受范围,吞吐还能提升。量化模型需要提前用对应工具转换好,vLLM直接加载转换后的权重。
注意:量化不是万能的。4bit量化在长上下文、复杂推理任务上可能出现明显退化。如果你的场景对精度敏感,先小范围测试再全量上。
4.4 服务参数调优的实操记录
启动服务只是第一步,参数调优才是决定性能的关键。我记录了一次完整的调优过程,你可以参考这个思路。
初始配置:gpu_memory_utilization=0.9,max_num_seqs=256,max_model_len=8192。压测并发50,结果启动就OOM。原因:max_model_len设太大,KV Cache预留过多。
第一次调整:把max_model_len降到4096,max_num_seqs降到128。启动成功,但压测并发到40时P99延迟飙升。观察显存,发现接近满载,说明并发上限到了。
第二次调整:gpu_memory_utilization提到0.92,max_num_seqs保持128。并发50稳定,吞吐比初始配置提升约35%。
第三次调整:尝试block_size从16改到32,吞吐略降,因为内部碎片增加。改回16。
最终配置:gpu_memory_utilization=0.92,max_num_seqs=128,max_model_len=4096,block_size=16。这个配置在两张24GB卡上,7B模型,能稳定支撑50并发,P99延迟控制在3秒以内。
这个调优过程说明一个道理:参数之间是相互制约的,不能孤立地调。显存利用率、并发数、上下文长度三者要一起考虑。
5. 压测与性能验证:数据说话
5.1 压测工具的选择与脚本
压测我用的是一套自己写的脚本,基于asyncio并发发请求,统计吞吐和延迟。也可以用现成的工具,但自己写更灵活,能模拟真实请求分布。
核心逻辑是这样:
import asyncio import aiohttp import time import statistics async def send_request(session, prompt, max_tokens): payload = { "model": "my-model", "prompt": prompt, "max_tokens": max_tokens, "temperature": 0.7 } start = time.time() async with session.post("http://localhost:8000/v1/completions", json=payload) as resp: result = await resp.json() latency = time.time() - start return latency, result["usage"]["completion_tokens"] async def benchmark(concurrency, total_requests): async with aiohttp.ClientSession() as session: tasks = [] for i in range(total_requests): prompt = f"请解释第{i}个技术概念" tasks.append(send_request(session, prompt, 256)) if len(tasks) >= concurrency: await asyncio.gather(*tasks) tasks = [] if tasks: await asyncio.gather(*tasks) # 运行 asyncio.run(benchmark(concurrency=50, total_requests=500))这个脚本能测出平均延迟、P99延迟、总吞吐。关键是要模拟真实请求长度分布,不要全用固定长度,否则数据不准。
5.2 不同并发下的性能曲线
我用上面脚本跑了一组数据,模型7B FP16,两张24GB卡TP=2,上下文4096,输出256 token。结果如下:
| 并发数 | 吞吐(tokens/s) | 平均延迟(s) | P99延迟(s) | GPU利用率 |
|---|---|---|---|---|
| 1 | 45 | 5.7 | 5.9 | 25% |
| 10 | 380 | 6.8 | 8.2 | 68% |
| 30 | 920 | 8.4 | 11.5 | 88% |
| 50 | 1180 | 10.9 | 14.2 | 94% |
| 80 | 1240 | 16.5 | 24.8 | 96% |
| 100 | 1190 | 21.3 | 35.6 | 95% |
几个观察:
- 并发从1到30,吞吐几乎线性增长,说明调度效率高;
- 并发50之后吞吐趋于饱和,延迟开始明显上升;
- 并发80时P99延迟已经到25秒,用户体验开始变差;
- 并发100吞吐反而略降,说明过载了。
最佳工作点大概在并发50左右,此时吞吐接近峰值,延迟还能接受。这个点因硬件和模型而异,你需要自己压测找。
5.3 和传统方案的对比数据
为了验证vLLM的优势,我用同样的硬件和模型,对比了一个静态批处理的方案。结果:
| 指标 | 静态批处理 | vLLM | 提升倍数 |
|---|---|---|---|
| 最大并发 | 8 | 50 | 6.25x |
| 峰值吞吐(tokens/s) | 210 | 1180 | 5.6x |
| 显存利用率 | 55% | 94% | - |
| P99延迟(并发50) | 无法支撑 | 14.2s | - |
这个对比很直观:传统方案在并发8就撑不住了,vLLM能到50,吞吐差5倍多。差距的来源就是前面讲的PagedAttention和连续批处理。
提示:这个对比是在特定配置下测的,不代表所有场景都差这么多。但趋势是明确的——并发越高,vLLM优势越大。
6. 常见问题与排查技巧实录
6.1 启动阶段的高频报错
报错一:CUDA out of memory(启动时)。最常见。原因通常是gpu_memory_utilization设太高,或者max_model_len设太大。排查顺序:先降显存利用率到0.85,再降上下文长度,最后考虑量化。
报错二:tensor parallel size不匹配。报错信息一般是“attention heads not divisible”。原因是TP值不能整除注意力头数。比如头数是32,TP设3就会报错。改成2或4。
报错三:模型格式不支持。有些模型权重不是标准HuggingFace格式,vLLM加载会失败。解决方法是先用转换脚本转成标准格式,或者确认模型架构在vLLM支持列表里。
6.2 运行时的性能异常
现象一:吞吐上不去,GPU利用率低。可能原因:max_num_seqs设太小,活跃请求不够;或者请求长度太短,调度开销占比高。排查:看日志里的running序列数,如果远小于max_num_seqs,说明请求没喂饱。
现象二:延迟忽高忽低。通常是长请求和短请求混在一起,长请求拖累了整体。可以设置请求优先级,或者对超长请求单独限流。
现象三:显存缓慢增长直到OOM。这是内存泄漏的典型表现。vLLM本身比较稳定,但如果你在Python API里反复创建LLM对象,就会泄漏。解决:全局只创建一个LLM实例,复用。
6.3 排查速查表
我把常见问题和对应处理整理成表,方便你快速定位:
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 启动OOM | 显存利用率高/上下文长 | 降gpu_memory_utilization,降max_model_len |
| 并发上不去 | max_num_seqs小 | 适当调大,观察显存 |
| 延迟高 | 长请求拖累 | 限流长请求,或分离部署 |
| 吞吐饱和 | 达到硬件上限 | 加卡,或用量化提升容量 |
| 显存泄漏 | 重复创建实例 | 复用LLM对象 |
| 输出乱码 | 模型/tokenizer不匹配 | 检查模型目录完整性 |
6.4 几个独家避坑经验
经验一:不要迷信默认参数。vLLM的默认配置是通用值,不一定适合你的场景。我每次部署都会根据硬件和请求特征重新调一遍,通常能再榨出20%到30%的性能。
经验二:压测要用真实数据。我见过有人用固定长度的请求压测,结果上线后真实请求长度分布差异大,性能完全不一样。压测时最好从生产日志里采样真实请求。
经验三:监控要跟上。光看吞吐和延迟不够,还要监控显存、GPU利用率、活跃序列数、等待队列长度。这些指标能帮你提前发现瓶颈。我一般用Prometheus加Grafana,vLLM自带metrics接口,接起来很方便。
经验四:版本升级要谨慎。vLLM迭代很快,新版本可能引入性能回退或行为变化。升级前先在测试环境验证,别直接上生产。
经验五:长上下文要单独处理。如果业务里有超长请求(比如几万token),建议单独部署一个实例,配置不同的参数,避免影响普通请求。
7. 一些扩展方向和实际体会
这套方案跑通之后,我又做了几个扩展尝试,顺便说说体会。
一个是多模型共存。在同一台机器上跑多个vLLM实例,每个实例负责不同模型,通过网关路由。这样能充分利用硬件,但要注意显存分配,别让实例之间抢资源。我的做法是给每个实例设不同的gpu_memory_utilization,加起来不超过0.95。
另一个是动态扩缩容。根据请求量自动增减实例数,这个在K8s环境下比较好做。但vLLM实例启动比较慢(加载模型要时间),所以扩容要有预热机制,不能等请求来了才启动。
还有一个是前缀缓存。如果大量请求共享相同的system prompt,可以开启prefix caching,让这部分KV Cache只算一次。vLLM新版本支持这个特性,实测能再提升10%到20%的吞吐,尤其是system prompt很长的场景。
最后说个实际体会:vLLM确实把本地推理的门槛降下来了,但它不是银弹。它的优势在并发,在吞吐,在显存效率。如果你的场景是单用户、低并发、对延迟极度敏感,那它可能不是最优解。选型之前先想清楚自己的瓶颈在哪,别为了追新技术而追。
我在实际使用中发现,最花时间的不是部署,而是调参和压测。部署半小时能搞定,调参可能要花好几天。但这个时间值得花,因为调好之后,同样的硬件能多服务好几倍的用户,这个投入产出比是很高的。