文章目录
-
- 引言
-
- 目标读者
- 结论速览
- 1. 背景与现状
-
- 1.1 硬件与拓扑
- 1.2 两套已部署的模型栈
- 1.3 为什么必须"切换"而不是"并存"
- 2. 方案设计
-
- 2.1 设计约束
- 2.2 三个关键决策
- 2.3 切换流程
- 3. 切换脚本
-
- 3.1 用法
- 3.2 脚本结构
- 3.3 实测耗时
- 3.4 完整源码
- 4. 模型栈启动定义
-
- 4.1 DeepSeek 栈(双机 TP=2)
- 4.2 Qwen 栈(单机)
- 4.3 Qwen 栈(双机 TP=2)
- 5. 模型别名与网关
-
- 5.1 别名方案与验证
- 5.2 网关联动配置
- 5.3 注意事项
- 6. 性能实测
-
- 6.1 测试说明
- 6.2 Qwen 栈:vLLM 与 SGLang 基线对比
- 6.3 单机与双机 TP=2 对比
- 7. 踩坑记录与风险防范
-
- 7.1 镜像层:Qwen GDN 预热断言崩溃
- 7.2 编排层:launcher 复用同名容器
- 7.3 集群层:卷挂载在每个节点生效
- 7.4 网关层:内存保护误伤全量请求
- 7.5 风险清单
- 8. 运维手册
-
- 8.1 日常操作
- 8.2 人工兜底
- 8.3 回滚
- 8.4 新增模型
- 8.5 删除模型
- 9. 实施与验证结果
- 10. 术语表
- 11. 参考
引言
想在一台机器上同时服务两个大模型,通常会卡在三件事上:内存装不下两个、推理端口只有一个、换一次要停机数分钟。不少团队的做法是为每个模型维护一套独立环境,代价是重复的部署与重复的调试。
本文记录我们在两台 DGX Spark GB10 上的另一种解法:用一套脚本在 DeepSeek-V4-Flash 与 Qwen3.8-27B 之间切换,客户端只认一个模型名 vllm,切换时无需改动任何客户端配置。 方案已在生产环境完成往返演练,下文包含设计取舍、性能实测、四类真实踩坑,以及后续增删模型的运维方法。
目标读者
- 正在单机或多机上部署 vLLM 推理服务、且需要同时服务多个模型的工程师
- 硬件内存有限,希望在「全都想要」与「装不下」之间找到平衡点的团队
- 想直接拿走一个可用的切换脚本,或关注 DGX Spark / GB10 统一内存推理实践的读者
结论速览
| 切换耗时 | 切到 DeepSeek 约 181–225s,切到 Qwen 约 225–285s |
| 客户端改动 | 零改动,统一使用模型名 vllm |
| 失败保护 | 目标栈 10 分钟内未就绪则自动回滚原栈 |
| Qwen 双机收益 | KV 池 +120%,生成吞吐 +55%(对比单机) |
| 已验证范围 | 往返切换演练、网关端到端、单机与双机两种形态 |
1. 背景与现状
1.1 硬件与拓扑
测试环境为两台 DGX Spark GB10,每台配备 128GB 统一内存(UMA)。两节点通过高速互联组成一个集群,可以运行张量并行(TP)推理。
#mermaid-svg-l4iSvjpyBfH8fDW9{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-l4iSvjpyBfH8fDW9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-l4iSvjpyBfH8fDW9 .error-icon{fill:#552222;}#mermaid-svg-l4iSvjpyBfH8fDW9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-l4iSvjpyBfH8fDW9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-l4iSvjpyBfH8fDW9 .marker.cross{stroke:#333333;}#mermaid-svg-l4iSvjpyBfH8fDW9 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-l4iSvjpyBfH8fDW9 p{margin:0;}#mermaid-svg-l4iSvjpyBfH8fDW9 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-l4iSvjpyBfH8fDW9 .cluster-label text{fill:#333;}#mermaid-svg-l4iSvjpyBfH8fDW9 .cluster-label span{color:#333;}#mermaid-svg-l4iSvjpyBfH8fDW9 .cluster-label span p{background-color:transparent;}#mermaid-svg-l4iSvjpyBfH8fDW9 .label text,#mermaid-svg-l4iSvjpyBfH8fDW9 span{fill:#333;color:#333;}#mermaid-svg-l4iSvjpyBfH8fDW9 .node rect,#mermaid-svg-l4iSvjpyBfH8fDW9 .node circle,#mermaid-svg-l4iSvjpyBfH8fDW9 .node ellipse,#mermaid-svg-l4iSvjpyBfH8fDW9 .node polygon,#mermaid-svg-l4iSvjpyBfH8fDW9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-l4iSvjpyBfH8fDW9 .rough-node .label text,#mermaid-svg-l4iSvjpyBfH8fDW9 .node .label text,#mermaid-svg-l4iSvjpyBfH8fDW9 .image-shape .label,#mermaid-svg-l4iSvjpyBfH8fDW9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-l4iSvjpyBfH8fDW9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-l4iSvjpyBfH8fDW9 .rough-node .label,#mermaid-svg-l4iSvjpyBfH8fDW9 .node .label,#mermaid-svg-l4iSvjpyBfH8fDW9 .image-shape .label,#mermaid-svg-l4iSvjpyBfH8fDW9 .icon-shape .label{text-align:center;}#mermaid-svg-l4iSvjpyBfH8fDW9 .node.clickable{cursor:pointer;}#mermaid-svg-l4iSvjpyBfH8fDW9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-l4iSvjpyBfH8fDW9 .arrowheadPath{fill:#333333;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-l4iSvjpyBfH8fDW9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l4iSvjpyBfH8fDW9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-l4iSvjpyBfH8fDW9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l4iSvjpyBfH8fDW9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-l4iSvjpyBfH8fDW9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-l4iSvjpyBfH8fDW9 .cluster text{fill:#333;}#mermaid-svg-l4iSvjpyBfH8fDW9 .cluster span{color:#333;}#mermaid-svg-l4iSvjpyBfH8fDW9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-l4iSvjpyBfH8fDW9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-l4iSvjpyBfH8fDW9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-l4iSvjpyBfH8fDW9 .icon-shape,#mermaid-svg-l4iSvjpyBfH8fDW9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l4iSvjpyBfH8fDW9 .icon-shape p,#mermaid-svg-l4iSvjpyBfH8fDW9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-l4iSvjpyBfH8fDW9 .icon-shape rect,#mermaid-svg-l4iSvjpyBfH8fDW9 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l4iSvjpyBfH8fDW9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-l4iSvjpyBfH8fDW9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-l4iSvjpyBfH8fDW9 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Worker · DGX Spark GB10 · 128GB 统一内存
Head · DGX Spark GB10 · 128GB 统一内存
new-api 网关(可选):3000
客户端
直连 :8000
经网关 :3000
TP=2 · RoCE/IB 互联
OpenAI 兼容 SDK / curlmodel 统一填 vllm
渠道统一指向推理服务
vllm_node 容器 :8000
vllm_node 容器
关键点在于统一内存:GB10 的 CPU 与 GPU 共享同一个内存池,推理服务吃掉的内存会直接挤压系统可用内存。这既是它能跑大模型的原因,也是后文诸多约束的根源。
1.2 两套已部署的模型栈
| A. DeepSeek 栈 | vLLM TP=2(双机 vllm_node) | deepseek-ai/DeepSeek-V4-Flash-0731(156G,两端各一份) | 256K | 当前生产 |
| B. Qwen 栈 | vLLM 单机;另有双机 TP=2 形态(见 4.3 节) | nvidia/Qwen3.8-27B-NVFP4(21G)+ z-lab/Qwen3.8-27B-DFlash2(3.6G),两节点均已同步 | 256K | 已实测通过(单机与双机),待命 |
Qwen 栈原方案为 SGLang + FP8。2026-09-19 实测确认 vLLM 路线可行且性能与 SGLang 基线打平(见 6.2 节),因此定档 vLLM——两套栈共用同一镜像与同一套 run-recipe 工具链,显著降低维护成本。权重与草稿模型已通过 hf-download.sh -c –copy-parallel 同步到 Worker,双机 TP=2 形态亦已验证。
1.3 为什么必须"切换"而不是"并存"
两个模型无法同时提供服务,原因有二:
- 内存互斥:DeepSeek 以 gpu-mem 0.88 吃满两节点的统一内存;Qwen 单机形态也需要 gpu-memory-utilization 0.7。两者叠加远超硬件上限。
- 端口互斥:两套栈都使用 8000 端口。
结论:任一时刻只能服务一个模型。所谓"切换",本质就是「停旧栈 → 起新栈 → 等就绪 → 校验」这条流水线。
2. 方案设计
2.1 设计约束
| 统一内存互斥 | 切换必须彻底释放旧栈容器,不能只做进程级重启 |
| 端口固定 8000 | 新栈启动前必须确认旧容器已消失,否则端口占用导致启动失败 |
| 单次启动耗时数分钟 | 必须有就绪探测与失败回滚,避免"停了旧的、起不来新的" |
| 客户端已在使用 | 切换过程对客户端应尽量透明,不能要求改配置 |
2.2 三个关键决策
决策一:用模型别名实现客户端零改动
vLLM 的 –served-model-name 支持注册多个名字。两套栈都额外注册一个常驻别名 vllm,客户端统一使用这个名字,切换时无需任何改动。同时各栈保留自己的专用名(DeepSeek-V4-Flash / qwen3.8-27b),旧客户端不受影响。
决策二:用单一脚本收敛全部操作
把「参数、启动、探测、回滚、报告」封装进一个脚本,避免人工操作时漏步骤——尤其是"忘了先删同名容器"这类高频失误(见 7.2 节)。
决策三:以就绪探测为准,而非容器状态
容器 Up 不等于服务可用。脚本以 /v1/models 返回 HTTP 200 作为唯一就绪判据,就绪后再发一次真实推理请求做冒烟校验。
2.3 切换流程
#mermaid-svg-p7rl3mLUdW5swQIh{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-p7rl3mLUdW5swQIh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-p7rl3mLUdW5swQIh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-p7rl3mLUdW5swQIh .error-icon{fill:#552222;}#mermaid-svg-p7rl3mLUdW5swQIh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-p7rl3mLUdW5swQIh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-p7rl3mLUdW5swQIh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-p7rl3mLUdW5swQIh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-p7rl3mLUdW5swQIh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-p7rl3mLUdW5swQIh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-p7rl3mLUdW5swQIh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-p7rl3mLUdW5swQIh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-p7rl3mLUdW5swQIh .marker.cross{stroke:#333333;}#mermaid-svg-p7rl3mLUdW5swQIh svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-p7rl3mLUdW5swQIh p{margin:0;}#mermaid-svg-p7rl3mLUdW5swQIh .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-p7rl3mLUdW5swQIh .cluster-label text{fill:#333;}#mermaid-svg-p7rl3mLUdW5swQIh .cluster-label span{color:#333;}#mermaid-svg-p7rl3mLUdW5swQIh .cluster-label span p{background-color:transparent;}#mermaid-svg-p7rl3mLUdW5swQIh .label text,#mermaid-svg-p7rl3mLUdW5swQIh span{fill:#333;color:#333;}#mermaid-svg-p7rl3mLUdW5swQIh .node rect,#mermaid-svg-p7rl3mLUdW5swQIh .node circle,#mermaid-svg-p7rl3mLUdW5swQIh .node ellipse,#mermaid-svg-p7rl3mLUdW5swQIh .node polygon,#mermaid-svg-p7rl3mLUdW5swQIh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-p7rl3mLUdW5swQIh .rough-node .label text,#mermaid-svg-p7rl3mLUdW5swQIh .node .label text,#mermaid-svg-p7rl3mLUdW5swQIh .image-shape .label,#mermaid-svg-p7rl3mLUdW5swQIh .icon-shape .label{text-anchor:middle;}#mermaid-svg-p7rl3mLUdW5swQIh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-p7rl3mLUdW5swQIh .rough-node .label,#mermaid-svg-p7rl3mLUdW5swQIh .node .label,#mermaid-svg-p7rl3mLUdW5swQIh .image-shape .label,#mermaid-svg-p7rl3mLUdW5swQIh .icon-shape .label{text-align:center;}#mermaid-svg-p7rl3mLUdW5swQIh .node.clickable{cursor:pointer;}#mermaid-svg-p7rl3mLUdW5swQIh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-p7rl3mLUdW5swQIh .arrowheadPath{fill:#333333;}#mermaid-svg-p7rl3mLUdW5swQIh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-p7rl3mLUdW5swQIh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-p7rl3mLUdW5swQIh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p7rl3mLUdW5swQIh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-p7rl3mLUdW5swQIh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p7rl3mLUdW5swQIh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-p7rl3mLUdW5swQIh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-p7rl3mLUdW5swQIh .cluster text{fill:#333;}#mermaid-svg-p7rl3mLUdW5swQIh .cluster span{color:#333;}#mermaid-svg-p7rl3mLUdW5swQIh div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-p7rl3mLUdW5swQIh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-p7rl3mLUdW5swQIh rect.text{fill:none;stroke-width:0;}#mermaid-svg-p7rl3mLUdW5swQIh .icon-shape,#mermaid-svg-p7rl3mLUdW5swQIh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p7rl3mLUdW5swQIh .icon-shape p,#mermaid-svg-p7rl3mLUdW5swQIh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-p7rl3mLUdW5swQIh .icon-shape rect,#mermaid-svg-p7rl3mLUdW5swQIh .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p7rl3mLUdW5swQIh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-p7rl3mLUdW5swQIh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-p7rl3mLUdW5swQIh :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
是
否
是
否
执行 switch-model.sh 目标栈
检测当前栈容器状态 + 模型别名双重判断
目标栈已在运行?
直接退出,不做任何变更
停止旧栈两节点各 docker rm -f vllm_node
按预置参数组启动目标栈
轮询 /v1/models 直至返回 200
600s 内就绪?
发送冒烟推理请求
输出就绪耗时与冒烟结果
打印容器日志尾部
停止新栈,重启原栈
退出码 1,提示人工介入
| 1 状态检测 | 识别当前栈 | 容器状态 + /v1/models 别名双重判断;目标已在跑则直接退出 |
| 2 停旧栈 | 释放内存与端口 | Head/Worker 各执行 docker rm -f vllm_node,并确认容器消失 |
| 3 起新栈 | 锁定验证过的参数 | 见第 4 章的参数组 |
| 4 等就绪 | 轮询 /v1/models | 上限 600s;就绪后发送冒烟请求(max_tokens=64,兼容思考模式) |
| 5 失败回滚 | 自动恢复原栈 | 目标栈 10 分钟未就绪 → 打印日志尾部 → 重启原栈 |
| 6 结果报告 | 就绪耗时 / 冒烟结果 | 失败时以退出码 1 结束,便于接入监控 |
3. 切换脚本
3.1 用法
脚本:~/github/switch-model.sh(在 Head 节点执行)
建议为它建一个软链或别名,避免每次敲完整路径:
# 加软链(~/.local/bin 通常已在 PATH 中)
ln -sfn ~/github/switch-model.sh ~/.local/bin/switch-model
# 之后可在任意目录直接调用
switch-model status # 查看当前栈(容器 / 模型列表 / KV 池)
switch-model deepseek # 切到双机 DeepSeek-V4-Flash(256K · DSpark n=3)
switch-model qwen # 切到单机 Qwen3.8-27B(NVFP4 + DFlash2 投机)
3.2 脚本结构
脚本内部按职责划分为五组函数,便于按需扩展:
| current_stack() | 通过 /v1/models 返回的专用别名反推当前运行的是哪套栈 |
| stop_all() | 清理两节点的同名容器,并轮询确认容器已消失(确保内存已释放) |
| launch_deepseek() / launch_qwen() | 按预置参数组启动目标栈 |
| wait_ready() | 轮询 /v1/models 直到返回 200,返回就绪耗时 |
| smoke() | 发送一次真实推理请求,校验服务真正可用 |
| cmd_status() / cmd_switch() | 两个对外命令的实现,后者串联"停旧 → 起新 → 等就绪 → 冒烟 → 失败回滚" |
3.3 实测耗时
| → DeepSeek | 181–225s | 2026-09-19 实测:210–225s;演练 200s;Qwen TP=2 后回切 181s |
| → Qwen(单机) | 281–285s | 2026-09-19 实测 285s;演练 281s |
| → Qwen(双机 TP=2) | 225s | 2026-09-19 实测,手工按 4.3 节命令启动 |
全流程演练(2026-09-19):deepseek → qwen → deepseek 往返各一次成功,未触发回滚;含中途验证的整窗口实测约 12 分钟,与预案估算一致。
首次冷启动会更慢:FlashInfer 需要 JIT 编译算子,首次编译耗时数分钟。编译结果缓存在 ~/.cache/flashinfer,只要该目录挂载持久化,后续启动即回到上表水平。
3.4 完整源码
脚本全文如下。开头的 WORKER 变量请改成你自己的 Worker 节点地址(为便于公开,此处已用示例值)。
#!/usr/bin/env bash
# =============================================================================
# switch-model.sh — DGX Spark 双栈模型快速切换(DeepSeek-V4-Flash ⇄ Qwen3.8-27B)
#
# 用法(在 Head 节点执行):
# ./switch-model.sh status # 查看当前栈
# ./switch-model.sh deepseek # 切到双机 DeepSeek-V4-Flash(256K)
# ./switch-model.sh qwen # 切到单机 Qwen3.8-27B(vLLM solo,含 DFlash2 投机)
#
# 说明:
# – 两栈互斥(统一内存 + 8000 端口),切换 = 停旧栈 → 起新栈 → 等就绪 → 冒烟;
# – 失败自动回滚到切换前的栈;
# – 两栈均注册模型名 "vllm"(常驻别名)+ 各自专用别名;
# – Qwen 栈挂载 qwen_triton_warmup No-op 补丁(镜像对 Qwen3.8 GDN 预热的断言 bug)。
#
# 验证记录(2026-09-19):
# – DeepSeek 就绪 ~3.5 分钟;Qwen(vLLM solo) 就绪 ~4.75 分钟;
# – Qwen vLLM 基准核心中位 56.9 tok/s(与 SGLang 基线 56.0 打平)。
# =============================================================================
set -euo pipefail
# ———- 配置 ———-
WORKER="spark@10.0.0.12"
SPARK_REPO="$HOME/github/spark-vllm-docker"
IMAGE="vllm-node-b12x:latest"
PATCH="$SPARK_REPO/patches/qwen_triton_warmup.noop.py"
PATCH_DST="/usr/local/lib/python3.12/dist-packages/vllm/model_executor/warmup/qwen_triton_warmup.py"
API="http://127.0.0.1:8000"
READY_TIMEOUT=600 # 就绪等待上限(秒)
SMOKE_MODEL="vllm"
# DeepSeek 栈(双机 TP=2 · 256K · DSpark n=3)
DS_RECIPE="recipes/deepseek-v4-flash-0731.yaml"
DS_ARGS=(
–max-model-len 262144
–gpu-mem 0.88
–max-cudagraph-capture-size 16
–served-model-name vllm DeepSeek-V4-Flash
-e HF_HUB_OFFLINE=1
-d
–speculative-config '{"method":"dspark","num_speculative_tokens":3,"draft_sample_method":"probabilistic","attention_backend":"B12X"}'
)
# Qwen 栈(单机 solo · 256K · NVFP4 + DFlash2 投机)
Q_RECIPE="recipes/qwen3.8-27b-nvfp4-dflash2.yaml"
Q_ARGS=(
–solo –tp 1
–max-model-len 262144
–gpu-memory-utilization 0.7
–served-model-name vllm qwen3.8-27b
-e HF_HUB_OFFLINE=1
-d
-t "$IMAGE"
-v "$PATCH:$PATCH_DST:ro"
)
# ———- 工具函数 ———-
log() { printf '\\n\\033[1;36m==> %s\\033[0m\\n' "$*"; }
err() { printf '\\n\\033[1;31mERROR: %s\\033[0m\\n' "$*" >&2; }
current_stack() {
# 通过 /v1/models 的专用别名判断当前在跑哪个栈
if ! docker ps –format '{{.Names}}' | grep -qx vllm_node; then
echo none; return
fi
local ids
ids=$(curl -s -m 5 "$API/v1/models" 2>/dev/null \\
| python3 -c "import json,sys; print(' '.join(m['id'] for m in json.load(sys.stdin)['data']))" \\
2>/dev/null || true)
case "$ids" in
*DeepSeek-V4-Flash*) echo deepseek ;;
*qwen3.8-27b*) echo qwen ;;
"") echo starting ;;
*) echo unknown ;;
esac
}
stop_all() {
# 两节点都清理:互斥场景下不会误伤(同时只应存在一个栈)
docker rm -f vllm_node >/dev/null 2>&1 || true
ssh -o BatchMode=yes -o ConnectTimeout=8 "$WORKER" \\
'docker rm -f vllm_node >/dev/null 2>&1 || true' >/dev/null 2>&1 || true
# 确认容器已消失(内存释放)
for _ in $(seq 1 20); do
docker ps –format '{{.Names}}' | grep -qx vllm_node || break
sleep 1
done
}
launch_deepseek() {
log "启动 DeepSeek 栈(双机 TP=2 · 256K · DSpark n=3)"
(cd "$SPARK_REPO" && ./run-recipe.sh "$DS_RECIPE" "${DS_ARGS[@]}")
}
launch_qwen() {
log "启动 Qwen 栈(vLLM solo · NVFP4 + DFlash2 · 含预热补丁)"
[ -f "$PATCH" ] || { err "缺少补丁文件:$PATCH"; return 1; }
(cd "$SPARK_REPO" && ./run-recipe.sh "$Q_RECIPE" "${Q_ARGS[@]}")
}
wait_ready() {
local t0=$SECONDS
while (( SECONDS – t0 < READY_TIMEOUT )); do
if [ "$(curl -s -m 4 -o /dev/null -w '%{http_code}' "$API/v1/models")" = "200" ]; then
echo $((SECONDS – t0)); return 0
fi
sleep 10
done
return 1
}
smoke() {
curl -s -m 180 "$API/v1/chat/completions" -H 'Content-Type: application/json' \\
-d "{\\"model\\":\\"$SMOKE_MODEL\\",\\"messages\\":[{\\"role\\":\\"user\\",\\"content\\":\\"回复OK\\"}],\\"max_tokens\\":64,\\"temperature\\":0}" \\
| python3 -c "
import json,sys
d=json.load(sys.stdin)
assert d.get('choices'), 'no choices in response'
print('冒烟通过:', (d['choices'][0]['message'].get('content') or '(thinking…)')[:30])
"
}
# ———- 命令 ———-
cmd_status() {
log "容器状态"
echo "Head: $(docker ps –format '{{.Names}} ({{.Status}})' | grep vllm_node || echo '无')"
echo "Worker: $(ssh -o BatchMode=yes -o ConnectTimeout=8 "$WORKER" \\
"docker ps –format '{{.Names}} ({{.Status}})' | grep vllm_node || echo 无" 2>/dev/null || echo '不可达')"
local cur; cur=$(current_stack)
echo "当前栈: $cur"
if [ "$cur" = deepseek ] || [ "$cur" = qwen ]; then
echo "模型列表: $(curl -s -m 5 "$API/v1/models" \\
| python3 -c "import json,sys; print(', '.join(m['id'] for m in json.load(sys.stdin)['data']))" 2>/dev/null)"
docker logs vllm_node 2>&1 | grep -E 'GPU KV cache size' | tail -1 | sed 's/^/KV: /'
fi
}
cmd_switch() {
local target="$1"
local cur; cur=$(current_stack)
if [ "$cur" = "$target" ]; then
log "当前已在运行 $target 栈($SMOKE_MODEL),无需切换"
return 0
fi
log "切换:$cur → $target"
log "停止现有栈(Head + Worker)"
stop_all
case "$target" in
deepseek) launch_deepseek ;;
qwen) launch_qwen ;;
*) err "未知目标:$target(可用:deepseek | qwen)"; exit 2 ;;
esac
log "等待就绪(上限 ${READY_TIMEOUT}s,容器 Up ≠ 服务可用)"
local secs
if secs=$(wait_ready); then
log "服务就绪(${secs}s),发送冒烟请求"
smoke || err "冒烟失败,请检查 docker logs vllm_node"
echo
echo "✓ 切换完成:$target|模型名 vllm(+ 专用别名)|地址 $API/v1"
else
err "目标栈 ${READY_TIMEOUT}s 内未就绪,自动回滚到 $cur"
docker logs –tail 20 vllm_node 2>&1 | sed 's/^/ | /' || true
stop_all
case "$cur" in
deepseek) launch_deepseek && wait_ready && smoke ;;
qwen) launch_qwen && wait_ready && smoke ;;
*) err "原栈为 $cur,未自动重启,请人工处理" ;;
esac
exit 1
fi
}
case "${1:-status}" in
status) cmd_status ;;
deepseek) cmd_switch deepseek ;;
qwen) cmd_switch qwen ;;
*) echo "用法: $0 [status|deepseek|qwen]"; exit 2 ;;
esac
4. 模型栈启动定义
以下是脚本内固化的参数组,也是需要人工启动时的完整命令。
4.1 DeepSeek 栈(双机 TP=2)
cd ~/github/spark-vllm-docker
./run-recipe.sh recipes/deepseek-v4-flash-0731.yaml \\
–max-model-len 262144 –gpu-mem 0.88 –max-cudagraph-capture-size 16 \\
–served-model-name vllm DeepSeek-V4-Flash \\
-e HF_HUB_OFFLINE=1 -d \\
–speculative-config '{"method":"dspark","num_speculative_tokens":3,"draft_sample_method":"probabilistic","attention_backend":"B12X"}'
参数要点:
- 投机深度取 3。深度 5 在长生成场景下有挂死风险(2026-09-19 定位),不要盲目加大。
- KV 池大小随启动时可用统一内存波动,实测 594,535–669,749 tokens(256K 上下文并发 2.27x–2.55x),以 switch-model status 实时输出为准。
4.2 Qwen 栈(单机)
cd ~/github/spark-vllm-docker
./run-recipe.sh recipes/qwen3.8-27b-nvfp4-dflash2.yaml \\
–solo –tp 1 –max-model-len 262144 –gpu-memory-utilization 0.7 \\
–served-model-name vllm qwen3.8-27b \\
-e HF_HUB_OFFLINE=1 -d \\
-t vllm-node-b12x:latest \\
-v ~/github/spark-vllm-docker/patches/qwen_triton_warmup.noop.py:/usr/local/lib/python3.12/dist-packages/vllm/model_executor/warmup/qwen_triton_warmup.py:ro
有三个必须项,缺一不可:
| -t vllm-node-b12x:latest | recipe 默认镜像名 vllm-node 本地不存在,需显式指向 B12X 镜像 |
| -v …/qwen_triton_warmup.noop.py:…:ro | 镜像 bug:qwen_triton_warmup 对 Qwen3.8 的 GDN 层归一预热时触发断言崩溃(assert weight.shape == (N,),位于 _warm_layer_norm_kernel),直接打死引擎。补丁将其置为 No-op,仅跳过预热,功能无影响。详见 7.1 节 |
| 启动前先 docker rm -f vllm_node | launcher 检测到同名容器会复用而不重建,改挂载或改参数均不生效。详见 7.2 节 |
4.3 Qwen 栈(双机 TP=2)
cd ~/github/spark-vllm-docker
./run-recipe.sh recipes/qwen3.8-27b-nvfp4-dflash2.yaml \\
–max-model-len 262144 –gpu-memory-utilization 0.7 \\
–served-model-name vllm qwen3.8-27b \\
-e HF_HUB_OFFLINE=1 -d \\
-t vllm-node-b12x:latest \\
-v ~/github/spark-vllm-docker/patches/qwen_triton_warmup.noop.py:/usr/local/lib/python3.12/dist-packages/vllm/model_executor/warmup/qwen_triton_warmup.py:ro
与单机形态的唯一差异是去掉 –solo –tp 1:recipe 在集群下默认 tensor_parallel: 2,draft_tensor_parallel_size 随之变为 2。另有两个额外必须项:
| Worker 需具备 NVFP4 与 DFlash2 权重 | 执行 ./hf-download.sh <model> -c –copy-parallel 同步(已完成) |
| 预热补丁需同步到 Worker 的同路径 | 集群模式下 -v 在每个节点生效,主机路径须在各节点有效。详见 7.3 节 |
switch-model.sh qwen 固化的是单机形态。若要使用 TP=2,请按上面的命令手工启动。
5. 模型别名与网关
5.1 别名方案与验证
| vLLM 多别名支持 | 支持。–served-model-name vllm DeepSeek-V4-Flash 与 vllm qwen3.8-27b 均经 –dry-run 验证可正确透传到 vllm serve |
| 客户端零改动切换 | 客户端统一使用 vllm,两套栈均可命中 |
| 向后兼容 | DeepSeek 栈保留 DeepSeek-V4-Flash,Qwen 栈保留 qwen3.8-27b,旧客户端不受影响 |
客户端调用示例:
# 方式一:直连推理服务
curl http://127.0.0.1:8000/v1/chat/completions \\
-H 'Content-Type: application/json' \\
-d '{"model":"vllm","messages":[{"role":"user","content":"你好"}]}'
# 方式二:经 new-api 网关(:3000),携带令牌
curl http://127.0.0.1:3000/v1/chat/completions \\
-H "Authorization: Bearer <你的令牌>" \\
-H 'Content-Type: application/json' \\
-d '{"model":"vllm","messages":[{"role":"user","content":"你好"}]}'
5.2 网关联动配置
若前面挂了 new-api 之类的网关,仅配置渠道模型列表不够——用户可用模型由 abilities 表驱动。三个位置必须同步,缺一个就会出现"模型列表里有、实际调用却报无可用渠道"。
| channels.models | 渠道声明的模型清单 | 渠道不认该模型 |
| abilities 表 | 实际路由依据,按「分组 + 模型」建索引 | vllm 查不到渠道,请求直接失败 |
| 令牌的 model_limits | 令牌级模型白名单 | 被令牌级拦截,返回权限错误 |
两个额外注意点:
- 内存过载保护:网关按 (total – free – buff/cache) / total 计算内存水位,默认阈值 90%;而 DeepSeek 运行时该值稳定在 93.4%,会导致所有请求(含原有 DeepSeek 模型)被拒。需将 performance_setting.monitor_memory_threshold 提到 97。详见 7.4 节。
- DB 写入方式:网关 DB 为 SQLite,文件可能归 root 所有而宿主账号无写权限。稳妥做法是停容器后,用带 Python 的镜像以 root 挂载数据目录执行 sqlite3 模块写入,再启动容器。改动前务必备份 one-api.db。
5.3 注意事项
- 两套栈共用别名 vllm,日志与监控无法仅凭模型名区分当前服务者(两者互斥运行,影响有限);
- 原 Qwen 客户端使用的 qwen3.8-27b 仍然可用,无需强制迁移;
- 新增或删除模型时,上述三处网关配置都要同步增删,详见 8.4、8.5 节。
6. 性能实测
6.1 测试说明
以下数据来自固定 prompt 集的同款探针,按 code、reasoning、math、prose 四类场景分别测量生成吞吐(tok/s),取核心场景中位数作为主指标。均为单次运行结果,用于横向对比而非绝对性能承诺。
6.2 Qwen 栈:vLLM 与 SGLang 基线对比
| code | 50.5 tok/s | 54.4–54.8 | -8% |
| reasoning | 57.7 | 56.0–56.3 | +2% |
| math | 56.9 | 58.5–59.2 | -3% |
| prose(最差场景) | 23.4 | ~24–26 | 持平 |
| 核心中位 | 56.9 | 56.0 | +1.6% |
| TTFT | 0.24–0.28 s | 0.205 s | 略慢 |
结论:性能在测量噪声范围内打平。选择 vLLM 的收益不体现在吞吐上,而在于与 DeepSeek 栈统一了镜像与工具链。
6.3 单机与双机 TP=2 对比
| 就绪耗时 | 281–285s | 225s | 略快 |
| KV 池 | 1,309,495 tokens(并发 5.00x) | 2,877,459 tokens(并发 10.98x) | +120% |
| prose 吞吐(同口径探针) | 23.7 tok/s | 36–37 tok/s | +55% |
| 别名 | vllm / qwen3.8-27b | vllm / qwen3.8-27b | 一致 |
结论:TP=2 在 KV 池与生成吞吐上均显著优于单机形态,代价是同时占用两节点的统一内存(因此与 DeepSeek 栈同样互斥),且权重与预热补丁必须在两节点齐备。
7. 踩坑记录与风险防范
这一节的四个问题都真实消耗了排查时间,记录下来供后来者绕行。
7.1 镜像层:Qwen GDN 预热断言崩溃
现象:Qwen 栈启动后引擎立即退出,日志停在归一化预热阶段。
根因:镜像中的 qwen_triton_warmup 在对 Qwen3.8 的 GDN 层做 LayerNorm 预热时,断言 assert weight.shape == (N,) 失败(_warm_layer_norm_kernel),异常直接终止引擎。
解法:从容器内取出同名模块 vllm/model_executor/warmup/qwen_triton_warmup.py,将其中的预热函数体改为直接返回(No-op),再通过 -v 挂回容器内同路径覆盖。仅跳过预热,对功能与精度无影响。若该断言在你的版本中仍然存在,也建议向 vLLM 上游反馈。
7.2 编排层:launcher 复用同名容器
现象:改了挂载或启动参数后重新启动,发现改动完全不生效。
根因:launcher 检测到已存在同名容器时会直接复用,而不是销毁重建。
解法:切换脚本在启动前强制执行 docker rm -f vllm_node,并轮询确认容器真正消失。这也是"内存互斥"场景下的必需动作——只有容器销毁,统一内存才会释放。
7.3 集群层:卷挂载在每个节点生效
现象:单机形态正常的补丁挂载,切到双机 TP=2 后失效。
根因:集群模式下 -v 的卷映射会在每个被启动的节点上生效,因此主机侧路径必须在所有节点都存在且内容一致。只同步了 Head,Worker 上自然挂载失败。
解法:把补丁文件同步到 Worker 的完全相同路径。这条规则适用于一切 -v 挂载,不限于补丁文件。
7.4 网关层:内存保护误伤全量请求
现象:网关返回 system memory overloaded,不仅新模型不可用,连原本正常的 DeepSeek 请求也全部失败。
根因:网关的内存水位算法为 (total – free – buff/cache) / total,默认阈值 90%。而 DeepSeek 运行时统一内存本就吃满,该值稳定在 93.4%,因此所有请求都被判定为过载。
解法:把 performance_setting.monitor_memory_threshold 提到 97——既放行正常工况,又保留对真正内存耗尽的保护。这个阈值是按"模型刻意占满统一内存"这一使用模式调整的,网关重建后需要重设。
7.5 风险清单
| 镜像预热 bug 复现 | 无补丁则引擎启动即崩 | 脚本已固化补丁挂载 |
| launcher 复用容器 | 改参数不重建 | 切换前必 docker rm -f |
| 首次冷启动慢 | FlashInfer JIT 首次编译需数分钟 | 缓存 ~/.cache/flashinfer 并持久化挂载 |
| 切换窗口业务中断 | 窗口期内服务不可用 | 低峰执行;脚本实时输出进度 |
| 权重未同步到 Worker | 双机形态启动失败 | 换机/重装后重新执行 hf-download.sh -c –copy-parallel |
| 补丁路径缺失 | 集群模式 -v 在每个节点生效,Worker 缺同路径文件则挂载失效 | 已在两节点同路径就位;换机后需重做 |
| 网关内存保护误伤 | 默认阈值 90%,DeepSeek 运行时 93.4% → 全量请求被拒 | 阈值提至 97;网关重建后须重设 |
| 冒烟请求语义 | 思考模式默认开启,短输出可能只含思考内容 | 冒烟只校验 HTTP 200 与 choices 存在,不校验文本内容 |
| 回滚失败 | 目标栈起不来且原栈同时异常 | 脚本打印日志尾部;按 8.2 节人工处理 |
8. 运维手册
8.1 日常操作
switch-model status # 状态总览:容器 / 模型列表 / KV 池
docker logs -f –tail 100 vllm_node # 滚动查看推理日志(Head 节点)
8.2 人工兜底
脚本不可用时,按以下步骤手工操作:
# 1. 停当前栈(两节点都要停,否则内存与端口不会释放)
docker rm -f vllm_node
ssh spark@10.0.0.12 'docker rm -f vllm_node'
# 2. 起目标栈:使用第 4 章的完整命令
8.3 回滚
两个方向互为目标,重跑一次即可回滚:
switch-model deepseek # 从 Qwen 回滚到 DeepSeek
switch-model qwen # 从 DeepSeek 回滚到 Qwen
脚本在"目标栈 10 分钟未就绪"时会自动执行回滚,无需人工介入。
8.4 新增模型
新增一套模型栈需要改动五个层面。本方案使用的 ~/github/spark-vllm-docker/recipes/ 目录下已有 30 余个现成 recipe(覆盖 GLM、MiniMax、Nemotron、Qwen 系列等),因此多数情况下不需要新写 recipe,工作量集中在脚本与网关两处。
| 1 权重 | ./hf-download.sh <模型名> -c –copy-parallel | 两节点 du -sh ~/.cache/huggingface/hub/models–* |
| 2 recipe | 优先复用现成 recipe;无则新建 YAML | ./run-recipe.sh <recipe> –dry-run |
| 3 形态 | 决定单机还是双机;双机需同步权重与补丁到 Worker | dry-run 输出中的节点数与 –tensor-parallel-size |
| 4 脚本 | 按下表改 6 处 | switch-model status 能正确识别 |
| 5 网关 | 三处联动增配(见 5.2 节) | 经网关实调一次 |
脚本需要改动的位置(按函数定位,而非行号):
| 配置区 | 新增一组 <栈名>_RECIPE 与 <栈名>_ARGS 参数数组 |
| launch_*() | 新增对应的启动函数 |
| current_stack() | 新增该栈专用别名的匹配分支 |
| cmd_switch() 启动分支 | 新增 case 分支 |
| cmd_switch() 回滚分支 | 新增 case 分支 |
| 末尾命令分发 | 新增命令行参数分支 |
current_stack() 与 cmd_switch() 回滚分支是最容易漏的两处:前者漏了会让新栈被判定为 unknown,后者漏了会导致回滚时无法自动恢复原栈。
可选优化:当栈数量超过 2 个时,建议把上述分散的分支收敛成集中的数据结构(如以栈名为键的关联数组),让"新增模型"退化为只改一处配置。
8.5 删除模型
按新增的反向操作执行,注意三点:
9. 实施与验证结果
| 模型权重准备 | nvidia/Qwen3.8-27B-NVFP4(21G)与 z-lab/Qwen3.8-27B-DFlash2(3.6G)已下载并同步至两节点 |
| 镜像 bug 绕过 | 已定位 GDN 预热断言问题并交付 No-op 补丁 |
| Qwen 单机启动验证 | 就绪 281–285s,KV 池 130.9 万 tokens |
| 性能基准对比 | 核心中位 56.9 tok/s,对比基线 56.0 打平 |
| 切换脚本交付 | 已通过 status 命令验证 |
| 全流程演练 | deepseek → qwen → deepseek 往返各一次成功,未触发回滚,整窗口约 12 分钟 |
| 别名生效确认 | DeepSeek 侧重启后模型列表为 vllm, DeepSeek-V4-Flash |
| 网关端到端 | 渠道、令牌、内存阈值均已配置并实测调通 |
| Qwen 双机 TP=2 | 就绪 225s,KV 池 287.7 万 tokens,吞吐较单机 +55% |
10. 术语表
| DGX Spark GB10 | NVIDIA 桌面级 AI 计算平台,单机配备 128GB 统一内存 |
| UMA(Unified Memory Architecture) | 统一内存架构,CPU 与 GPU 共享同一内存池,二者不再有独立显存边界 |
| 模型栈 | 一套「推理引擎 + 模型权重 + 启动参数」的组合;本方案中同一时刻只运行一套 |
| TP(Tensor Parallelism,张量并行) | 将单个张量运算按维度切分到多设备并行计算,再通过通信合并结果 |
| KV Cache / KV 池 | 缓存已处理 token 的注意力 Key/Value,避免重复计算;池容量决定可并发的上下文总量 |
| 投机解码(Speculative Decoding) | 先用轻量草稿模型预生成候选 token,再由主模型批量校验,以提升生成吞吐 |
| 草稿模型(Draft Model) | 投机解码中负责预生成的轻量模型 |
| DSpark | DeepSeek 栈使用的投机解码方法(recipe 中 method: dspark) |
| DFlash2 | Qwen 栈使用的草稿模型及其对应方法名(method: dflash) |
| NVFP4 / FP8 | NVIDIA 的 4-bit / 8-bit 浮点量化格式,用于压缩权重体积与内存占用 |
| recipe | 本方案工具链中的 YAML 描述文件,声明模型、容器镜像与 vllm serve 命令 |
| 别名 / served-model-name | 推理服务对外暴露的模型名,一次服务可注册多个名字 |
| 就绪(ready) | /v1/models 返回 HTTP 200,表示服务已可接受请求;容器 Up 并不等于就绪 |
11. 参考
- vLLM Docker Optimized for DGX Spark:本文所用的容器镜像、run-recipe 工具链与 recipe 定义,均来自这个上游开源项目




