欢迎光临
我们一直在努力

DGX-Spark双栈模型切换

文章目录

    • 引言
      • 目标读者
      • 结论速览
    • 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 基线对比

探针vLLM(NVFP4 + DFlash2)SGLang 基线(2026-09-16)差异
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 对比

项单机双机 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 删除模型

按新增的反向操作执行,注意三点:

  • 先删脚本中的六处分支,尤其不要漏掉 current_stack() 与回滚分支,否则以该栈为原栈时将无法自动回滚;
  • 权重需在两节点同时删除:rm -rf ~/.cache/huggingface/hub/models–<org>–<name>;
  • 网关三处配置同步移除,abilities 表中对应行需整行删除,而不是修改字符串。

  • 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 定义,均来自这个上游开源项目
    赞(0)
    未经允许不得转载:171主机测评 » DGX-Spark双栈模型切换
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址