Free memory: 0.41 GiB。这是我第一次尝试在一张卡上跑两个模型时,vLLM 启动日志里的数字。然后它 crash 了。从第一次 OOM 到三组模型稳定运行,中间经历了 7 种不同的失败姿势。这篇是完整的踩坑记录。
目标:一卡双模型
需求很明确:每张 L40S 上跑一个 LLM(用于对话/推理)+ 一个 Embedding 模型(用于 RAG 检索)。三张卡,三组模型。
看起来显存够用:
LLM (27B FP8): ~28GB 权重 + ~10GB KV Cache = ~38GB
Embedding (0.6B): ~1.2GB 权重 + ~0.3GB KV Cache = ~1.5GB
总计: ~39.5GB / 48GB,还剩 8.5GB
实际情况远没这么简单。
坑 1:K8s GPU 隔离——两个容器不能各声明一张卡
最初的想法:Pod 里两个容器,各声明 nvidia.com/gpu: 1。
错了。 K8s NVIDIA Device Plugin 按容器分配 GPU,不是按 Pod。两个容器各声明 1 张 GPU = Pod 需要 2 张 GPU。
正确做法:只有 Embedding 容器声明 nvidia.com/gpu: 1,LLM 容器不声明 GPU,通过 NVIDIA_VISIBLE_DEVICES=all 访问所有 GPU。
坑 2:NVIDIA_VISIBLE_DEVICES=all 看到的是所有卡
LLM 容器设了 NVIDIA_VISIBLE_DEVICES=all 后,它能看到节点上所有 GPU,不只是 Embedding 拿到的那张。vLLM 默认用 cuda:0,但 cuda:0 可能是别的 Pod 正在用的卡。
结果:vLLM 启动时发现 cuda:0 只剩 0.41GB 可用显存,直接 crash。
ValueError: Free memory on device cuda:0 (0.41/44.39 GiB)
is less than desired GPU memory utilization (0.55, 24.42 GiB)
解决方案:GPU UUID 文件协调
Embedding 容器启动后,把自己拿到的 GPU UUID 写到共享 Volume:
# Embedding 启动脚本
GPU_UUID=$(nvidia-smi --query-gpu=gpu_uuid --format=csv,noheader)
echo "$GPU_UUID" > /mnt/models/.gpu-uuid-$MODEL_NAME
LLM 容器读取 UUID,转换成物理索引:
# LLM 启动脚本
GPU_UUID=$(cat /mnt/models/.gpu-uuid-$MODEL_NAME)
GPU_INDEX=$(nvidia-smi --query-gpu=index,gpu_uuid --format=csv,noheader \
| grep "$GPU_UUID" | cut -d',' -f1)
export CUDA_VISIBLE_DEVICES=$GPU_INDEX
坑 3:CUDA_VISIBLE_DEVICES 不接受 UUID
一开始我直接把 UUID 传给 CUDA_VISIBLE_DEVICES:
export CUDA_VISIBLE_DEVICES=GPU-47ecdc0c-acfd-cb3a-2768-b3da530ff5c1
vLLM 报错:
ValueError: invalid literal for int() with base 10: 'GPU-47ecdc...'
必须转成整数索引(0/1/2/3)。
坑 4:gpu-memory-utilization 是各算各的
两个 vLLM 进程各自独立计算 gpu-memory-utilization,都是基于整卡 48GB 算的。
如果 Embedding 设 0.08(3.8GB),LLM 设 0.92(43.2GB),加起来 47GB,看起来刚好。但每个进程还有 ~0.5GB 的 CUDA context 开销,实际总需求超过 48GB → OOM。
最终配置:Embedding 0.08 + LLM 0.87 = 0.95,留 5% 给双 CUDA context。
坑 5:MoE 模型加载全部参数
Qwen3.6-35B-A3B-FP8 标称"3B 活跃参数",但它有 128 个 Expert,总参数 35B。vLLM 加载时把全部 35B 权重(~34GB)都放到 GPU 上,不是只放 3B。
这意味着 35B MoE 在单卡 48GB 上非常紧张:34GB 权重 + CUDA context + KV Cache,几乎没有余量。最终只分配到 2.3GB KV Cache(约 53K tokens),勉强能用。
坑 6:Qwen3.6 的 torch.compile 吃显存
Qwen3.6 加载完 34GB 权重后,torch.compile 阶段会额外占用显存做 CUDA Graph capture。在 48GB 卡上,这个阶段直接 OOM。
尝试过的方案:
--compilation-config '{"cudagraph_capture_sizes": [1,2,4,8]}'— 减少 capture 数量,从 51 个降到 4 个。但 Helm YAML 转义 JSON 时出错,没生效。--enforce-eager— 完全跳过 torch.compile 和 CUDA Graph。这个有效。
代价是推理速度下降:TPOT 从理论的 ~55ms 涨到 88ms。但至少能跑起来。
坑 7:Probe 超时
多容器 Pod 的 LLM 容器有 90 秒启动延迟(等 Embedding 先启动),加上模型加载时间,总启动时间可能超过 5 分钟。
Kubernetes 的 liveness probe 默认 initialDelaySeconds 太短,模型还没加载完就被 kill 了,进入 CrashLoopBackOff。
最终配置:
| 模型 | initialDelaySeconds |
|---|---|
| 27B Dense | 180s |
| 35B MoE + MTP | 600s |
| 31B Dense | 360s |
最终显存分布
三组模型全部 2/2 Running,0 restarts 后的 nvidia-smi:
| GPU | 已用 | 剩余 | Pod |
|---|---|---|---|
| 0 | 44,600 MiB (96.8%) | 859 MiB | Qwen3.6-27B + Embedding |
| 1 | 44,322 MiB (96.2%) | 1,136 MiB | Qwen3.6-35B + Embedding |
| 2 | 44,398 MiB (96.5%) | 1,060 MiB | Gemma4-31B + Embedding |
三张卡都用到了 96% 以上。这就是为什么调参过程如此痛苦——每一个百分点的显存分配都可能决定模型能不能启动。
经验总结
- K8s 里多容器共享 GPU,只能一个容器声明 GPU 资源,另一个用
NVIDIA_VISIBLE_DEVICES=all+ UUID 文件协调 - gpu-memory-utilization 是各算各的,两个进程加起来不能超过 0.95
- MoE 模型按总参数算显存,不是活跃参数
- enforce-eager 是最后的救命稻草,牺牲速度换稳定性
- Probe 超时要按最慢的模型配置,35B MoE 需要 10 分钟
下一篇讲性能基准测试——同样的硬件,不同模型的吞吐差异有多大。