在本地跑大模型的同学,很多都经历过这种尴尬:一张顶配显卡,代码写得很漂亮,但推理速度就是上不去。尤其当出现并发请求时,排队、超时、显存溢出轮着来,恨不得把服务器扔出窗外。我自己也踩过这个坑,直到换上了 vLLM 这个高吞吐推理引擎,才算把本地高并发推理服务这件事彻底理顺。
简单说,vLLM 解决了三个最让人头疼的问题:显存利用率太低、并发请求被阻塞、推理速度不够快。如果你需要在本地部署一个能扛住多个用户同时调用的推理服务,或者单纯想让单卡吞吐量翻几倍,vLLM 基本是目前最省心的选择。这篇就把我实际部署和压测中总结的经验完整写出来,从设计思路、参数计算、部署实操到问题排查,一步一步给你拆开讲。
1. 核心设计思路:vLLM 凭什么能做到高吞吐
1.1 传统推理服务慢在哪儿
很多人在刚接触 LLM 服务时,用的还是最直接的写法:收到请求,跑一遍 forward,返回结果。看起来没问题,但一旦并发上来,问题就全暴露了。
首先是显存管理。大模型推理时,KV Cache 会占用大量显存。传统框架通常按最大序列长度预分配显存,而实际请求的序列长度往往远小于这个上限,于是大量显存被白白预留。更麻烦的是,不同请求长短不一,预分配策略很难兼顾效率和灵活性,显存空间经常处于“要么不够用、要么在浪费”的状态。
其次是调度方式。传统方式下,每个请求独立申请显存、独立计算,多个请求同时到来时只能排队或者乱抢资源。GPU 的算力明明还有富余,却被显存瓶颈卡住,导致利用率上不去。
另外还有一次只能处理一个 batch 的问题。单请求推理时,GPU 的并行计算能力被严重浪费,吞吐量自然高不起来。
1.2 vLLM 的三板斧:PagedAttention、Continuous Batching、动态显存管理
vLLM 对以上问题的回应非常直接。
第一板斧是 PagedAttention,也就是“分页注意力”。它把 KV Cache 按固定大小的块来管理,类似操作系统里的虚拟内存分页。每个请求不再需要一段连续的大显存空间,而是可以按需分配若干小的 cache block。显存碎片被消除,利用率能稳定提升,官方说法是能到 90% 以上。实际使用中,我观察到整卡显存几乎可以被吃满,这在传统方案里很难想象。
第二板斧是 Continuous Batching。这相当于给请求调度加了一个“插队机制”。传统 batching 是凑够一批再跑,短请求必须等长请求跑完才能退出;而 continuous batching 允许一个请求结束计算后,立刻让排队中的新请求补充进入当前 batch,GPU 永远在处理最活跃的请求集合,彻底消除了空闲等待。
第三板斧是动态显存管理。vLLM 会实时观测剩余显存,动态调整 KV Cache 的容量。服务启动时设好上限,运行过程中它会自动在推理和缓存之间平衡。这种机制配合 PagedAttention,让显存这个最贵的资源被用到了极致。
1.3 为什么拿它做本地高并发服务是合适的选择
本地方案和云上方案不一样,硬件资源有限,通常是单机单卡或单机多卡,没有弹性扩容能力。想提升并发能力,做好的途径就是榨干单卡性能,而 vLLM 的思路恰好就是为单机高吞吐设计的。
它支持 OpenAI 风格的 API 接口,这意味着你不需要改调用端代码,就能把后端从任意服务切换成 vLLM。配合量化、张量并行等策略,单张消费级显卡也能扛住几十路并发。而且 vLLM 是纯 Python + PyTorch 生态,部署和调试门槛低,对开发者非常友好。
2. 本地部署与基础配置:把这些细节做扎实
2.1 环境准备和硬件选型
先把环境要求说清楚。vLLM 支持 CUDA 和 ROCm,常见显卡基本都覆盖了。我用的是 NVIDIA 卡的方案,驱动版本建议不低于 535,CUDA 版本建议 11.8 或 12.1 以上。如果驱动太低,会遇到算子编译不过或 runtime 初始化失败的问题。
硬件方面,显存决定了你能跑的模型规模和并发上限。7B 模型做 int4 量化大约需要 6GB 显存,13B 模型大概需要 10-12GB,70B 模型即使做了 int4 量化,也需要 40GB 以上。如果显存不够,优先考虑 Awq 或 GPTQ 量化。
Python 环境建议用 3.10 以上版本,避免老版本在算子兼容上出问题。安装 vLLM 本身非常简单:
pip install vllm如果你对性能要求更高,可以从源码编译,能针对当前机器的 CPU 指令集做优化。不过日常使用,pip 安装的预编译包已经足够稳定。
2.2 核心运行参数解析
启动 vLLM 服务时,最常调整的参数有这些。我给你逐个说清楚含义和推荐范围。
- model:模型路径或 HuggingFace 上的模型名。
- tensor-parallel-size:张量并行度,即使用几张卡并行推理。单卡就设 1,多卡按实际卡数设。
- gpu-memory-utilization:GPU 显存利用上限,默认 0.9,意思是允许用到 90% 的显存。如果与其他程序共用显卡,建议调低,比如 0.6。
- max-model-len:允许的最大序列长度,包括输入和输出。这个参数非常影响显存分配,改小一点可以腾出更多空间给 KV Cache。
- enforce-eager:是否强制使用 eager 模式,默认 False,使用 CUDA graph 加速。如果显存紧张,可以打开,但会牺牲一些速度。
- quantization:量化方式,支持 awq、gptq 等,加载量化模型时指定。
一个基础的启动命令长这样:
python -m vllm.entrypoints.openai.api_server \ --model /models/llama2-7b-chat \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.85 \ --max-model-len 4096 \ --port 8080这里把 max-model-len 设为 4096,是因为我的业务场景以短文本为主,给 KV Cache 留了更多空间,并发能力会显著提升。如果业务确实需要长上下文,就不要硬压这个值,否则会频繁报 context length 超限。
2.3 详细启动与首次验证流程
启动前先做几件事。一是确认显存状态,用nvidia-smi查看当前占用;二是确认模型文件完整,用 HuggingFace 格式的目录,不要放成单个 safetensors 文件目录,否则加载会报错。
启动后,日志里出现这样的信息表示服务已经就绪:
INFO: Started server process INFO: Waiting for application startup INFO: Application startup complete然后我用一个简单的 Python 请求验证服务是否正常:
import json import requests payload = { "model": "default", "messages": [ {"role": "user", "content": "用一句话解释什么是注意力机制"} ], "max_tokens": 128, "temperature": 0.7 } resp = requests.post( "http://localhost:8080/v1/chat/completions", json=payload, timeout=60 ) print(resp.json()["choices"][0]["message"]["content"])能正常输出内容,就说明基础服务通了。这里有一个小细节值得注意:模型名直接填default就行,因为 vLLM 当前只加载了一个模型,API 不会做模型路由。
2.4 量化方案选择与对比
要在本地实现高并发,量化几乎是一个必选项。量化以后显存占用下降,KV Cache 可用空间变大,批处理规模也能扩大。我实际对比过三类方案。
- GPTQ:离线量化,精度损失小,适合 NVIDIA 卡,但需要提前对模型做量化处理。
- AWQ:基于激活感知的量化,速度和精度比较均衡,对长文本更友好。
- FP16/BF16:不量化,直接用半精度权重,精度最高但显存占用最大。
如果显卡是 24G 显存,跑 13B 模型建议直接上 AWQ 4bit,量化后权重部分大概 7GB 左右,剩余空间可以留给长序列和并发 batch 使用。如果显卡只有 8G,那么 7B 模型的 AWQ 4bit 是非常稳的组合。
加载量化模型时,只需要指定模型路径和 quantization 参数即可:
python -m vllm.entrypoints.openai.api_server \ --model /models/llama2-13b-awq \ --quantization awq \ --max-model-len 4096 \ --gpu-memory-utilization 0.9vLLM 对 AWQ 的支持很成熟,推理时会根据量化参数自动调整计算方式。如果用 GPTQ,注意要搭配--quantization gptq,加载的是已经量化过的 GPTQ 模型目录。
3. 压测与性能调参:真实数据比感觉重要
3.1 并发场景与压测方法
很多项目在部署后“感觉很快”,但一上压测就现原形。我的做法是先确定业务场景的并发上限,再用工具模拟请求,观察服务在极限条件下的表现。
压测工具我常用的是两个。一是ab,适合最简单的人工测试;二是自写 Python 脚本,用 asyncio 配合 aiohttp 做请求风暴。核心观测指标有三个:首 token 延迟、总生成时间、吞吐量,单位是 tokens/s。
拿我本地 24G 显卡跑 7B AWQ 模型为例,压测结果长这样:
| 并发数 | 吞吐量 (tokens/s) | 平均首 token 延迟 | 是否报错 |
|---|---|---|---|
| 1 | 85 | 300ms | 无 |
| 8 | 620 | 380ms | 无 |
| 16 | 1050 | 460ms | 无 |
| 32 | 1450 | 620ms | 少量慢请求 |
| 48 | 1600 | 890ms | 出现过载风险 |
从数据可以看到,并发数从 1 提到 32,吞吐量提升非常明显,这就是 continuous batching 的功劳。但到了 48 并发,收益就减小了,说明 GPU 算力已经接近饱和。这时候再堆并发,只会增加排队时间。根据这个结果,我把线上限流阈值定在 32 并发,既保证吞吐,又不至于让延迟失控。
3.2 关键参数联动:max-num-seqs、max-model-len 与吞吐的关系
vLLM 里有一个容易被忽略的参数:max-num-seqs,它决定了单个 step 内最多同时处理的序列数量。默认值往往偏保守,如果把它提高,batch size 可以更大,GPU 利用率更满。
但这里有一个联动关系。max-num-seqs 变大,每个 step 的显存消耗也会变大。假如 max-model-len 也很大,那么 KV Cache 的消耗会快速上升,甚至导致不够分。实际操作时,我一般先用公式估算显存:
KV Cache 总量约等于 2(K 和 V)乘以层数、多头数、头维度、序列长度、batch size、精度字节数。
举个例子,7B 模型 28 层,40 个注意力头,每个头维度 128,采用 FP16,KV Cache 每 token 大约是 2×28×40×128×2 字节,算下来约 1.4MB/token。如果 max-model-len 是 4096,batch size 是 32,KV Cache 最多就是要 1.4MB×4096×32,约 180MB 左右,看起来不大,但如果序列长度拉到 32768,这个数值就会迅速膨胀到 GB 级。因此调参时不能只看某一个参数,要整体看。
我常用的组合是:max-model-len 4096,max-num-seqs 256。在这种配置下,短文本场景的吞吐量最高。如果业务中长文本占比高,我会把 max-model-len 调到 8192,同时把 max-num-seqs 降到 128,保证 KV Cache 总量可控。
3.3 在真实业务场景下的参数调整
实际业务不能光看压测数据,还要考虑用户体验。一个线上问答机器人,如果用户问的是技术文档,输入可能很长,输出也不会短。此时如果把 max-tokens 限制得太死,经常会出现回答被截断的情况。
我建议在服务层做两件事:一是根据业务场景设置合理的 max-tokens 上限;二是在调用方做超时控制和重试,避免个别慢请求拖垮整体用户体验。另外,可以启用 vLLM 的 logprobs 功能来观测生成概率分布,这在调试模型输出质量时非常有用。
还有一点,如果服务需要支持多个模型切换,可以用多个 vLLM 实例分别部署,每个实例监听不同端口,前面加一层简单的路由转发。这样不仅隔离性更好,还能避免单实例切换模型时产生的高额显存开销。
4. 进阶玩法与性能再优化
4.1 前缀缓存与多轮对话优化
多轮对话是 vLLM 最容易踩坑的场景。每一轮请求都会把之前的对话历史重新拼接,然后当作新的 prompt 去推理。如果系统不做任何缓存,同样的前缀内容就要重新计算一次 attention,白白浪费算力。
vLLM 提供了一个非常实用的功能:自动前缀缓存。当请求的 prompt 前缀与之前某个请求相同时,vLLM 会直接复用之前计算好的 KV Cache,跳过前缀部分的重新计算。这个功能在某些版本中是自动开启的,但对 prompt 的格式有要求,前缀必须完全一致,包括标点、空格和系统提示词。
我在实际项目里验证过,采用固定 system prompt 加历史对话拼接格式,多轮对话场景的推理延迟能降低 30% 以上。具体做法是,尽量保证每轮请求的 prompt 都由“系统提示词 + 历史消息 + 当前输入”三部分固定拼接,不要在前面加时间戳之类的变化内容。
4.2 多卡部署的张量并行
当单卡显存放不下模型时,多卡是必然选择。vLLM 的张量并行会把模型的不同层切分到多张卡上。以 2 张卡跑 13B 模型为例,需要设置--tensor-parallel-size 2。这个参数意味着模型权重、attention 计算都会拆分到两张卡上协同工作。
需要注意的是,张量并行依赖高速卡间互联,PCIe 带宽和 NVLink 的差异会直接体现在性能上。我用两卡“NVLink 互联”跑 13B 模型,整体吞吐比单卡 7B 模型高出一截;但如果只是 PCIe 3.0 互联,通信开销会吃掉部分收益。所以如果条件允许,多卡互联优先选 NVLink,或者考虑用“流水线并行”来减少通信瓶颈。
如果你有两张卡但不想做张量并行,也可以把两张卡分别部署两个不同模型实例,各自监听一个端口,然后通过负载均衡分发请求。这种方式实现简单、互不干扰,适合模型规模不大、并发要求不高的场景。
4.3 对 CUDA Graph 和 eager 模式的理解
vLLM 默认启用 CUDA Graph 来捕获和复用 GPU 计算图,可以显著降低单步调度开销。这个机制对短请求场景特别有效,因为短请求中计算本身的耗时占比低,调度开销就成了瓶颈。
但 CUDA Graph 有一个特点,捕获图时就会锁定一部分显存,这部分显存无法被其他功能复用。因此如果你的显存非常紧张,可以设置--enforce-eager关闭 CUDA Graph,换取更大的 KV Cache 空间。代价是每个 step 都会有额外的 kernel 启动开销,延迟会高一些。
我在做压测的时候发现,同一个模型在 eager 模式下吞吐下降了大约 15%-20%,但显存峰值也下降了一截。如果你的显卡充裕,默认模式就是最优解;只有显存极度吃紧时才考虑 eager。
4.4 借助结构化输出提升服务稳定性
高并发服务不仅要快,还要稳。一个常见的坑是模型输出格式不稳定。比如你做了提示词让模型返回 JSON,但它偶尔会多输出一行说明文字,前端直接解析失败。
vLLM 对结构化输出有内置支持,包括 JSON Schema 约束和函数调用格式。启用后,模型在生成过程中就会被约束到合法格式,不会出现乱输出。这在高并发场景下非常有价值,因为调用方不需要再写复杂的容错解析逻辑,服务整体的可用性也更高。
5. 常见问题与排查技巧实录
5.1 显存溢出与 KV Cache 不足
这是本地部署遇到最多的问题。现象是服务运行一段时间后,日志中报 CUDA out of memory,或请求返回 500 错误。原因主要有三个。
一是 max-model-len 设置过大,导致 KV Cache 的预分配空间超出显存余额。解决办法是调低 max-model-len,或者调低 gpu-memory-utilization,给权重加载留出余量。
二是并发数过高,新请求需要分配的 cache block 不够了。这种情况可以适当降低 max-num-seqs,或者减少同时接入的请求数。
三是权重本身已经占满了显存,没有给 KV Cache 留空间。这种情况需要检查量化配置,将权重换成 AWQ 或 GPTQ 4bit,再从显存里挤出空间。
一个实用的排查思路是:启动时观察日志输出,vLLM 会打印 “Total number of blocks” 和 “Maximum number of tokens” 等提示,这些数字可以直观看出当前配置下 KV Cache 能支撑的请求上限。
5.2 推理延迟高但显存富余
如果显存明明很充裕,但吞吐就是上不去,问题大概率不在显存,而在批处理和调度设置上。检查顺序如下:
先看 max-num-seqs 是否太小。我见过配置文件里默认只有 32,结果高并发请求全在排队。调成 128 或 256,吞吐立刻起来。
再看是否启用了 eager 模式。如果是,CUDA Graph 带来的调度优化就完全被关闭了。去掉--enforce-eager参数,再测一次。
最后检查磁盘 IO。如果模型是从机械硬盘加载的,服务启动时权重加载会非常慢,但运行时影响小。如果使用磁盘直接映射做权重的按需换入换出,那磁盘带宽就会成为瓶颈,建议把模型文件放到 NVMe 上。
5.3 请求返回内容被截断或乱码
被截断通常是 max-tokens 设置太小,模型在生成完之前就触发了停止条件。解决方式是调大请求参数中的 max-tokens,同时确认服务端的 max-model-len 足够覆盖输入和输出长度之和。
乱码问题往往出在编码上。vLLM 的 API 默认使用 UTF-8,如果调用方传入的是 bytes 类型且没有指定编码,可能就会出现乱码。建议所有请求都用 UTF-8 显式编码,返回内容也统一按 UTF-8 解码。
另外,如果模型本身是多语言模型且未做语言指令约束,输出偶尔会混入其他语言。这种情况可以通过修改 system prompt 来强化语言约束,也可以在解码参数里调高 repetition penalty,减少语言漂移的概率。
5.4 监控与日志
生产环境的推理服务不能靠感觉运维。我的习惯是搭建一套最小化的监控方案:vLLM 自带 Prometheus 指标接口,可以采集吞吐量、延迟分布、正在处理的请求数等关键指标。配合 Grafana 简单画几张图,服务的健康状况一目了然。
日志方面,vLLM 默认日志会输出每个请求的处理时间和 token 数。如果想要追溯单次请求的完整情况,可以在调用端生成 request_id,然后在日志里做关联。自己写一个日志中间件,把 request_id、prompt 长度、max_tokens、实际生成 token 数、耗时全部记录下来,问题排查会轻松很多。
5.5 版本兼容与依赖冲突
vLLM 依赖的库较多,尤其是 torch 和 transformers,版本冲突是非常常见的问题。症状是启动时报“undefined symbol”或“module has no attribute”,排查思路也比较固定:确认 torch 版本与 vLLM 官方要求的版本范围匹配,确认 transformers 版本不过高或过低,建议使用与 vLLM 同源的环境,避免自行升级相关依赖。
我自己吃过一次大亏:为了一个 API 的补丁版本,单独升级了 transformers,结果 vLLM 直接无法加载模型,最后只能回滚版本。所以跑生产服务的机器,依赖版本尽量锁定。
6. 一个被忽略的经验:善用日志与性能追踪
最后分享一个我最常建议别人做的事:把 vLLM 的日志级别调高,并开启请求追踪。很多人部署完就丢在那不管,出了问题才想起来看日志。但实际上,vLLM 的日志里隐藏着大量性能线索,包括每次推理耗时、KV Cache 命中率、批处理大小等。
调日志级别的方式很简单:
export VLLM_LOGGING_LEVEL=DEBUG开启后,你会在日志里看到每个请求的详细处理过程。我一次排查吞吐骤降时,就是从 DEBUG 日志中发现某个请求的 prompt 特别长,把 batch 中的其他请求也都拖慢了。找到根因后,我在网关层对长 prompt 请求做了单独分流,吞吐立刻恢复了正常。
根据我的经验,vLLM 的上限并不完全取决于框架本身,更多取决于你对硬件资源、业务特征和调度参数的理解深度。本地高并发推理服务,本质上是一个“显存换吞吐、参数换性能”的平衡游戏,只要把 vLLM 的调度机制摸透,并配合合理的限流和监控,单机也能做出很可观的服务能力。