vLLM多卡部署实战:从OOM到稳定上线的完整踩坑记录
本文记录了一次在内网环境将vLLM推理服务从单卡扩展到多卡部署的完整过程。涵盖环境兼容性排查、显存OOM根因分析、Tensor Parallel配置细节、NCCL通信问题解决、量化方案选型(AWQ vs GPTQ)、生产级性能调优参数,以及从首次启动到日均万级请求稳定的完整数据对比。适用于需要自建推理服务的后端工程师。
vLLM 多卡部署实战:从 OOM 到稳定上线的完整踩坑记录
作者:钝挫力PROGRAMER | 7 年后端开发,正在一线做 AI 落地
AI/RAG 实战系列 #2 | 上一篇:从零搭建企业级 RAG 系统:架构设计与技术选型实战
写在前面
上一篇文章讲了 RAG 系统的整体架构和技术选型。有读者问了一个很实际的问题:“你们 LLM 推理服务是怎么部署的?直接调 API 还是自建?”
答案是:我们两条路都走了。早期验证阶段直接调 DeepSeek 和通义的 API,快速跑通了 RAG 流程。但到了日均请求量上来之后,三个问题逼着我们考虑自建推理服务:
- 数据合规:内部文档不能出内网,API 方案意味着数据要传到外部
- 成本考量:当调用量稳定在每天几万次之后,自建 GPU 集群的边际成本开始低于 API 调用费
- 延迟要求:内网调用外网 API 的网络往返至少多出 50-100ms
我们最终选择了 vLLM 作为推理引擎。原因不复杂——OpenAI 兼容接口(迁移成本低)、PagedAttention(显存利用率高)、原生支持张量并行(多卡开箱即用)、社区活跃度在所有推理框架里排第一。
但从"能跑起来"到"稳定上线",中间经历的过程比预想的曲折得多。这篇文章把整个过程记录下来,重点放在踩过的坑和解决方法,希望能帮后来人少走弯路。
一、环境准备:第一关就卡住了
1.1 CUDA 版本兼容性问题
这是遇到的第一个坑,也是最容易被忽视的。
当时服务器上装的是 CUDA 11.8,直接 pip install vllm 之后启动就报错:
RuntimeError: CUDA error: no kernel image is available for execution on the device
这个错误信息非常具有误导性。第一反应是显卡驱动问题,查了 nvidia-smi 显示一切正常,CUDA 版本也对得上。折腾了半天才发现:vLLM 的 pip wheel 是预编译的,它绑定了特定版本的 CUDA toolkit。
比如 vLLM 0.7.x 默认用 CUDA 12.1 编译,你机器上是 CUDA 11.8,运行时加载的 CUDA kernel 和驱动对不上,就会报上面这个错。
解决方案有两种:
方案一:指定 PyTorch CUDA 版本(推荐,最稳妥)
# CUDA 12.1(vLLM 0.6.x/0.7.x 默认编译版本)
pip install vllm==0.6.6.post1 --extra-index-url https://download.pytorch.org/whl/cu121
# CUDA 11.8(老环境)
pip install vllm==0.6.6.post1 --extra-index-url https://download.pytorch.org/whl/cu118
这是 vLLM 官方文档推荐的方式。--extra-index-url 让 pip 从 PyTorch 官方预编译 wheel 仓库下载与目标 CUDA 版本匹配的 PyTorch,vLLM 会自动依赖对应版本。
为什么不用
uv pip install --torch-backend auto?
--torch-backend是 uv 扩展参数(非标准 pip 参数),虽然能自动探测驱动版本来选择 wheel,但在某些环境下可能匹配到不稳定的组合或找不到对应的预编译包。对于生产部署,显式指定 CUDA 版本更可控、可复现。
方案二:Docker 一把梭(生产环境首选)
docker run --gpus '"device=0,1"' \
-v /data/models:/models \
-p 8000:8000 \
--ulimit nofile=65536:65536 \
vllm/vllm-openai:v0.6.6 \
--model /models/deepseek-llm-7b-chat \
--tensor-parallel-size 2 \
--max-model-len 8192
官方镜像已经把 CUDA、PyTorch、vLLM 的版本关系理顺了。只要宿主机驱动版本不低于容器内的 CUDA 版本就能正常跑。这也是我们生产环境最终采用的方式。
1.2 另一个隐蔽坑:文件句柄限制
这个问题在单机低并发时不会暴露,一旦并发量上来立刻现原形:
OSError: [Errno 24] Too many open files
vLLM 的异步引擎(AsyncLLMEngine)每个请求会打开多个文件描述符用于日志和通信。Linux 默认的 ulimit -n 只有 1024,高并发下瞬间就被耗尽了。
修复方式:
# 临时生效(重启后失效)
ulimit -n 65536
# 永久生效,写入 limits 配置
echo "* soft nofile 65536" >> /etc/security/limits.conf
echo "* hard nofile 65536" >> /etc/security/limits.conf
如果是 Docker 部署,启动时加上 --ulimit nofile=65536:65536 即可。
建议把这个检查项加到部署 checklist 里——很多人都是线上出了问题才回过头来排查这个。
二、单卡起步:先让它跑起来
在谈多卡之前,先把单卡部署的基线跑通。这一步看似简单,但有几个参数设置不当同样会让你卡很久。
2.1 第一道坎:启动就 OOM
我们的测试模型是 DeepSeek-LLM-7B-Chat(BF16 精度),显卡是 A100 40GB。理论上 7B 模型 BF16 大约需要 14GB 显存,40GB 的卡绰绰有余。但实际启动命令一敲下去:
torch.cuda.OutOfMemoryError: CUDA out of memory.
Tried to allocate 48.00 GiB (GPU 0; 39.60 GiB total capacity)
48GB?模型本身才 14GB,剩下的 34GB 去哪了?
答案在 vLLM 的显存预分配机制 上。vLLM 启动时先把显存拆分如下:
| 组成部分 | 估算显存 | 说明 |
|---|---|---|
| 模型参数 (BF16, 7B) | ~14 GB | 加载权重本身 |
| KV Cache block pool | ~20-22 GB | A100 40G × 0.9 利用率 = 36G 可用,减模型参数后剩余 |
| CUDA context + 激活值 buffer + 其他 | ~2-4 GB | 推理中间结果、框架运行时开销 |
| 合计 | ~36-40 GB |
Block pool 是什么?
vLLM 使用 PagedAttention 机制管理 KV Cache:它把可用显存预先分配为一组固定大小的 block(默认每块 16 个 token),组成一个内存池。当请求到来时,从池中动态申请 block 来存放该请求的 K、V 状态。Block pool 就是 KV Cache 的物理存储区——它不是 KV Cache 之外的额外开销。
那为什么 max_model_len 会影响 block pool 的大小?因为 vLLM 会同时考虑两个维度:
- 单条序列的 KV 上限:由
max_model_len决定,即一个请求最多能占多少个 block - 总 token 容量:
max_num_seqs × max_model_len,即所有并发请求的 token 容量总和
关键在于后者。以 DeepSeek-LLM-7B-Chat 某些 checkpoint 为例,max_position_embeddings 可能设在 16384 甚至更高,max_num_seqs 默认值也很大(通常 256)。若不加限制,vLLM 会计算出需要容纳 256 × 16384 ≈ 420 万 tokens 的 KV Cache 总量——这远超 20 GB block pool 能承载的范围,导致启动阶段就尝试分配 48 GB。
所以 Tried to allocate 48.00 GiB 这行日志并不是一次纯粹的 KV Cache 分配,而是 vLLM 根据大预设值计算出的总容量需求。设了 --max-model-len 8192 之后,单条 KV 上限 + 总 token 容量双双压缩,block pool 的需求量自然降回合理范围。
这也引出一个重要原则:永远不要用模型默认的 max_model_len 上生产,必须根据业务需求手动限制。
解决方法很直接,手动限制上下文长度:
python -m vllm.entrypoints.openai.api_server \
--model /models/deepseek-llm-7b-chat \
--dtype bfloat16 \
--max-model-len 8192 \
--gpu-memory-utilization 0.90
--max-model-len 8192 把最大上下文限制在 8K token,KV Cache 的预分配大幅减少。如果还不确定该设多少,vLLM 新版支持 --max-model-len auto,它会自动根据可用显存推算一个最大值。
这里补充一个经验表格(以 Llama 系列典型 7B 模型为例,32 层、32 头、头维度 128、BF16 精度):
KV Cache 是怎么算的?
每个 token 的 KV Cache 占用 = 层数 × 2(K+V) × 注意力头数 × 头维度 × 字节数
= 32 × 2 × 32 × 128 × 2(BF16) = 524,288 字节 ≈ 0.5 MB/token所以 max_model_len = 4096 时,KV Cache ≈ 4096 × 0.5 MB ≈ 2 GB。
不同模型的层数和头维度略有差异,下表取的是典型值,实际以模型配置为准。
| max_model_len | 7B 模型额外 KV Cache 占用 (BF16) | 适用场景 |
|---|---|---|
| 2048 | ~1 GB | 短问答、分类任务 |
| 4096 | ~2 GB | 常规对话 |
| 8192 | ~4 GB | RAG 场景推荐(检索结果较长) |
| 16384 | ~8 GB | 长文档分析 |
| 32768 | ~16 GB | 需要大显存卡 |
注意:DeepSeek-V2/V3 等采用 MLA(Multi-Head Latent Attention)架构的模型,KV Cache 占用远低于上表估算值,这是它们的核心优势之一。
RAG 场景因为要把检索到的文档片段塞进 prompt,一般 8K 是个比较安全的起点。如果业务确认不需要超长上下文,设成 4096 能省下一半 KV Cache 显存给并发用。
2.2 量化方案:AWQ 还是 GPTQ?
当模型变大之后(14B、32B、70B),全精度部署的显存压力陡增。量化是必然的选择。目前 vLLM 原生支持两种主流格式:AWQ 和 GPTQ。
先看一组实测数据(基于 A100 80G + Qwen2.5-7B):
| 精度方式 | 模型显存占用 | 吞吐量 (tokens/s) | 相对 BF16 的吞吐损失 |
|---|---|---|---|
| BF16 全精度 | ~16 GB | 2800 | 基准 |
| FP16 全精度 | ~16 GB | 2650 | -5% |
| AWQ Int4 | ~6 GB | 1900 | -32% |
| GPTQ Int4 | ~6.5 GB | 1700 | -39% |
AWQ Int4 用了不到 BF16 40% 的显存,换来了约 30% 的吞吐损失。对于消费级显卡(RTX 3090/4090)来说这是关键差异——省下来的显存可以直接用来跑更大的模型或支撑更多并发。
但在使用 AWQ 时有一个致命细节容易踩坑:
# 正确写法
python -m vllm.entrypoints.openai.api_server \
--model /models/Qwen2.5-7B-Instruct-AWQ \
--quantization awq \
--dtype float16 # AWQ 必须搭配 float16!
# 错误写法(会报错)
--quantization awq --dtype bfloat16
# ValueError: AWQ quantization is not supported with bfloat16
AWQ 量化只支持 FP16 加载,用 BF16 会直接抛异常。这个限制没有写在明显的文档位置,第一次遇到很容易懵。
GPTQ 则两种都支持,但同级别下吞吐量比 AWQ 低一些。我们的选择原则是:有 AWQ 权重就用 AWQ,没有再用 GPTQ。
三、多卡 Tensor Parallel:真正的硬仗
前面的坑虽然烦但都有明确解法。真正花时间的,是从单卡扩展到多卡的 Tensor Parallel 配置。
3.1 什么是 Tensor Parallel
先解释一下概念,避免后面参数讨论时有理解偏差。
Tensor Parallel(张量并行)的核心思想很简单:把模型的每一层按矩阵维度切分到多张 GPU 上。比如一个矩阵乘法 Y = XW,把 W 切成 W_1 和 W_2 分别放在两张卡上,每张卡算一半结果,再通过通信同步合并。
对于使用者来说,只需要设一个参数:
--tensor-parallel-size 2 # 或者简写 -tp 2
vLLM 会自动完成模型切分、通信初始化、调度协调等所有工作。但"自动"不代表"不会出错"。
3.2 NCCL 超时:最常见的多卡启动失败
配置好双卡启动之后,最常遇到的错误是这个:
Watchdog caught collective operation timeout:
WorkNCCL(SeqNum=xxx, OpType=ALLREDUCE) ran for 600000 milliseconds
NCCL(NVIDIA Collective Communications Library)是多卡之间做数据同步的底层库。ALLREDUCE 是一种集合通信操作——每轮推理中,各卡需要把自己计算的梯度/激活值汇总同步。当这个过程超过默认超时时间(通常 600 秒 = 10 分钟),就直接杀掉进程。
造成超时的常见原因有三个:
原因一:卡间带宽不够
Tensor Parallel 对卡间通信带宽极其敏感。每生成一个 token,各卡之间都要做多次 ALLREDUCE 同步。
| 互联方式 | 带宽 | 双卡扩展效率 | 实际体验 |
|---|---|---|---|
| NVLink (A100/H100 SXM) | 600-900 GB/s | ~92% | 几乎无感 |
| PCIe Gen4 x16 (RTX 3090/4090) | ~32 GB/s | ~75% | 可用但有明显损耗 |
| PCIe Gen3 x16 (老卡) | ~16 GB/s | ~60% | 不推荐 |
如果用的是 PCIe 连接的消费级卡(比如两块 RTX 4090),通信走的是 PCIe 总线而不是 NVLink,带宽差了将近 30 倍。这种情况下需要调整 NCCL 参数:
# 无 NVLink 时,关闭 P2P 让通信走系统内存(有时反而更快)
export NCCL_P2P_DISABLE=1
# 增加超时时间(单位毫秒)
export NCCL_TIMEOUT=1800000 # 30 分钟
原因二:防火墙或网络隔离
如果是跨节点多机部署(不在同一台物理机上),确保参与训练/推理的所有机器之间的端口互通。NCCL 默认使用随机端口,可以通过以下方式固定:
export NCCL_SOCKET_IFNAME=eth0 # 指定网卡
export NCCL_PORT_RANGE=10000-10005 # 限定端口范围,方便防火墙放行
原因三:GPU 拓扑问题
有时候物理上插了两张卡但它们之间不通。用这个命令检查:
nvidia-smi topo -m
输出里关注 PIX 这一列——它表示两张卡之间的互联类型:
| 值 | 含义 | Tensor Parallel 表现 |
|---|---|---|
| NVL | NVLink 连接 | 最优 |
| PHB | 同一 PCI-E Host Bridge | 较好 |
| PIX | 同一 P2P 桥(经 CPU) | 一般 |
| SYS | 需要经过系统内存/QPI | 差,不建议用于 TP |
如果你看到目标 GPU 对之间显示 SYS,说明这两张卡之间没有直接的 P2P 通路,Tensor Parallel 效果会很差。这种情况下要么换插槽重新插卡,要么放弃多卡改用量化压缩到单卡。
3.3 tp_size 与 Attention Heads 数量必须整除
这是一个纯数学约束,但经常被忽略:
ValueError: Total number of attention heads (28) must be divisible by tensor parallel size (4).
Tensor Parallel 把模型的注意力头均分到各张卡上。如果模型有 28 个注意力头(比如 Qwen2.5-7B),你想用 4 张卡做 TP——28 除以 4 等于 7,没问题。但如果想用 3 张卡呢?28 / 3 = 9.33,除不尽,直接报错。
常见模型的 heads 数量和可选 TP 值速查:
| 模型 | Attention Heads | 可用的 TP 值 |
|---|---|---|
| Qwen2.5-7B | 32 | 1, 2, 4, 8, 16, 32 |
| Qwen2.5-14B | 40 | 1, 2, 4, 5, 8, 10, 20, 40 |
| Qwen2.5-7B (老版) | 28 | 1, 2, 4, 7, 14, 28 |
| Llama-3-8B | 32 | 1, 2, 4, 8, 16, 32 |
| DeepSeek-7B | 32 | 1, 2, 4, 8, 16, 32 |
注意 Qwen2.5-7B 有两个版本的 head 数不同(新版改为 32),选 TP 值之前最好确认一下自己用的具体 checkpoint。
另外一条铁律:多卡 Tensor Parallel 必须用相同规格的 GPU。混用 A100 80G 和 A100 40G 的话,vLLM 会按最小显存的卡来分配,大卡的大量显存被浪费。
3.4 一个完整的多卡启动命令
综合以上要点,这是我们最终在生产环境中使用的启动配置:
CUDA_VISIBLE_DEVICES=0,1,2,3 \
NCCL_P2P_LEVEL=NVL \
python -m vllm.entrypoints.openai.api_server \
--model /models/deepseek-vl-chat \
--tensor-parallel-size 4 \
--dtype bfloat16 \
--max-model-len 8192 \
--gpu-memory-utilization 0.90 \
--max-num-seqs 256 \
--enable-chunked-prefill \
--host 0.0.0.0 \
--port 8000
逐行解释一下关键参数的含义:
| 参数 | 值 | 为什么这么设 |
|---|---|---|
tensor-parallel-size |
4 | 我们用了 4 张 A100,TP 必须等于实际卡数 |
max-model-len |
8192 | RAG 场景 8K 够用,再大会吃掉太多 KV Cache 显存 |
gpu-memory-utilization |
0.90 | 比 0.85 默认值略激进,留 10% 给 CUDA 运行时足够了 |
max-num-seqs |
256 | 最大并发序列数;我们的峰值 QPS 约 50,256 绰绰有余 |
enable-chunked-prefill |
开启 | 当长短请求混合时(有的 prompt 2K 有的 8K),chunked prefill 可以避免长请求阻塞短请求 |
四、性能调优:从能跑到好用
服务跑起来只是第一步。要让它在生产环境扛住真实流量,还需要一系列调优。
4.1 首 Token 延迟(TTFT):不要开 enforce-eager
vLLM 默认使用 CUDA Graph 来优化推理。CUDA Graph 把 GPU 操作录制下来 replay,避免了每次推理的 kernel launch 开销。
但有个参数 --enforce-eager 会强制禁用 CUDA Graph,退回到 eager mode 执行。这个参数的唯一用途是 debug——生产环境绝对不能开。
实测对比(A100 + DeepSeek-7B + TP=2):
| 模式 | 首 Token 延迟 (TTFT) | 吞吐量 (tokens/s) |
|---|---|---|
| 默认(CUDA Graph 开启) | ~85 ms | 2800 |
--enforce-eager |
~620 ms | 980 |
首 token 延迟差了 7 倍,吞吐量掉了接近 65%。如果你发现线上 TTFT 异常高,先检查是不是误开了这个参数。
4.2 吞吐量与并发的关系
下面是我们压测得到的一组数据(4×A100 + DeepSeek-7B-BF16 + TP=4 + max_model_len=8192):
| 并发数 | P50 延迟 | P99 延迟 | 吞吐 (tok/s) | GPU 利用率 |
|---|---|---|---|---|
| 1 | 120ms | 150ms | 1800 | 25% |
| 8 | 350ms | 520ms | 4200 | 72% |
| 16 | 580ms | 850ms | 5100 | 88% |
| 32 | 1200ms | 2100ms | 4800 | 91% |
| 64 | OOM | - | - | - |
几个观察:
- 甜蜜点在 8-16 并发:GPU 利用率上了 80%,P99 延迟还在可接受范围(< 1s)
- 超过 32 并发后收益递减:GPU 已经接近饱和,继续加并发只会增加排队延迟
- 64 并发直接 OOM:KV Cache 被撑爆了。这时应该降低
max_num_seqs或max_model_len
对于 RAG 这种场景,大部分请求的生成长度不长(100-500 tokens),并发控制在 16-32 之间是比较合理的范围。
4.3 监控指标:怎么判断服务是否健康
上线之后需要持续监控这几个核心指标:
vLLM 自带 metrics 端点:GET /metrics 返回 Prometheus 格式的指标
关键指标清单:
| 指标名 | 含义 | 告警阈值 |
|---|---|---|
vllm:num_requests_running |
当前正在处理的请求数 | > max_num_seqs * 0.8 |
vllm:num_requests_waiting |
排队等待的请求数 | > 20 立即告警(说明服务过载) |
vllm:gpu_cache_usage_perc |
KV Cache 显存使用率 | > 0.95 需要扩容或降参 |
vllm:avg_generation_throughput_toks |
平均生成吞吐量 | 低于基线 50% 说明异常 |
其中 gpu_cache_usage_perc 这个指标最值得盯。如果长期高于 0.95,说明 KV Cache 压力很大,随时可能 OOM。应对措施依次为:
- 降低
max_model_len - 降低
max_num_seqs - 增加 GPU 数量
五、完整踩坑清单
最后整理一份从零到上线的完整 checklist。每一个条目都是我们实际踩过的坑:
| # | 坑位 | 具体现象 | 解决方法 |
|---|---|---|---|
| 1 | CUDA 版本不匹配 | no kernel image is available for execution on the device |
用 pip install vllm --extra-index-url https://download.pytorch.org/whl/cu121 指定 CUDA 版本;或直接用 Docker 官方镜像 |
| 2 | ulimit 文件句柄不足 | [Errno 24] Too many open files(高并发时触发) |
ulimit -n 65536,Docker 加 --ulimit nofile=65536:65536 |
| 3 | 启动即 OOM | Tried to allocate 48 GiB...out of memory |
检查模型 config.json 中 max_position_embeddings 的值,设置 --max-model-len 8192 覆盖默认值;必要时调低 --gpu-memory-utilization 0.85 |
| 4 | AWQ 量化 dtype 错误 | AWQ quantization is not supported with bfloat16 |
AWQ 必须配 --dtype float16,不能用 bfloat16 |
| 5 | NCCL 通信超时 | ALLREDUCE ran for 600000 milliseconds |
无 NVLink 设 NCCL_P2P_DISABLE=1 + NCCL_TIMEOUT=1800000;检查 nvidia-smi topo -m |
| 6 | TP 与 Heads 不整除 | must be divisible by tensor parallel size |
查模型 head 数(Qwen2.5-7B 新版 32 / 老版 28),选能整除的 TP 值 |
| 7 | 混用不同规格 GPU | 大卡显存严重浪费 | 务必使用完全相同的 GPU 型号和显存大小 |
| 8 | 误开 enforce-eager | TTFT 从 85ms 飙升到 620ms | 生产环境删掉此参数,仅 debug 时使用 |
| 9 | swap-space 参数报错 | unrecognized argument: --swap-space |
较新版本已移除此参数(CPU offload 方案改为其他实现),照抄老教程会踩坑 |
| 10 | Java/客户端连接池太小 | 高并发时 Connection pool exhausted |
OkHttp 连接池调到 50+;Requests 库加 pool_connections=50 |
六、什么时候该自建,什么时候老老实实调 API?
文章最后聊一个决策层面的问题。
不是所有场景都适合自建推理服务。根据我们的实践经验,给出一个简单的决策参考:
建议自建的场景:
- 数据不能出内网(金融、医疗、军工等行业)
- 日均调用量稳定在百万级以上(自建成本开始低于 API)
- 需要运行微调后的私有模型(API 不可能帮你托管自定义权重)
- 对延迟极度敏感(内网 < 50ms vs 外网 API > 150ms)
建议继续用 API 的场景:
- 日均调用量 < 10 万(按当前 DeepSeek V4 Flash ¥1/M token 的价格,成本很低)
- 团队没有 GPU 运维能力
- 业务处于快速迭代期,模型可能频繁更换
- 只是偶尔试用,不想维护一套基础设施
如果决定自建,建议从单卡 + 小模型开始验证,跑通全链路后再逐步扩展到多卡。本文记录的就是这条路径上的完整踩坑过程。
下一篇预告:向量数据库选型深度对比:Faiss vs Milvus vs ChromaDB(附 benchmark)
如果你也在自建 vLLM 推理服务,欢迎在评论区交流你的硬件配置和遇到的问题。
更多推荐

所有评论(0)