在 8× A800 上把 DeepSeek-V4-Flash 榨到极限:从 5 倍提速到 TP+EP 双实例的完整实测
单机 8 卡,双实例,一路从 50 tok/s 到 479 tok/s------我把每一步的决策、测量和踩坑都写在这里。
前言
- 8 张 A800 80GB(PCIe 版,仅 GPU 两两成对经 NVLink Bridge 相连),目标是把开源的 DeepSeek-V4-Flash-0731(稀疏 MoE 模型)在本地跑起来,既要应付偶尔的超长上下文,又要扛住 60K 量级的高并发批量对话。
这篇文章不是配置清单,而是一次完整的实测记录:从最差的方案一路演进到最终形态,每一步都带真实数字。你可以直接照着部署,也能从里面的瓶颈分析学到 MoE 服务化该往哪个方向使劲。
先说结论:
- 8 卡双实例:GPU0-3 跑 1M 上下文专机,GPU4-7 跑 512K + TP+EP + 30 并发批量。
- TP+EP 在 24+ 并发时比纯 TP 快 15~19%,而且白拿更大的 KV 缓存;16 并发以下两者持平,没有任何退步。
- GPU 平均利用率只有 ~49%(峰值 98%)------瓶颈在 MoE 的跨卡集合通信走 PCIe(NVLink 仅成对,跨对仍是 PCIe),软件层面已经压到硬件天花板。
- 衡量口径提示:正文所称"5 倍提速"来自早期纯 decode 单流 50 → 235 tok/s 的取整 (~4.7 倍),与后文 TP+EP 阶段的端到端口径不同,详见 §九 演进表。
一 为什么混合推理在这台机器上是浪费
最初的方案来自模型仓库的官方 README:让 CPU 和 GPU 协同,GPU 只负责"常住显存的层",MoE 专家计算放 CPU。这套思路本来是为"显存装不下模型"设计的(比如 2 张 3090 塞 155GB 模型)。但我有 4 张 80GB = 320GB,装得下 155GB 的模型。于是出现了一个非常反直觉的观测:
- 混合模式(NUMA=1) :GPU 利用率 0%,CPU 冲到 ~3000%(30 个线程),解码只有 ~100 tok/s。为什么?MoE 的解码计算全在 CPU 上跑,GPU 只是"放权重的架子",4 张卡全程闲置。
- 纯 GPU 模式(NUMA=0):GPU 利用率 61-95%,解码 111-235 tok/s。
结论很干脆:显存够的情况下,混合推理是纯浪费。 我用纯 GPU 模式,把 4 张卡的算力真正用起来。
二 dspark 是单实例的"杀手锏"
DeepSeek-V4-Flash-0731 模型自带的投机解码(dspark,基于 MTP 草稿模型)是第二个决定性优化。它每次不是只走一步,而是用草稿模型批量猜一串候选 token,再让主模型一次验收。
实测对比(纯 GPU 4 卡):
| 指标 | 无 dspark | 有 dspark(=3) |
|---|---|---|
| 单流解码 | 111-128 tok/s | 221-235 tok/s |
| 20 并发聚合 | ~207 tok/s | 829 tok/s |
| 20 并发延迟 P50 | 48s | 11.8s |
单流提升约 2 倍,20 并发聚合直接翻 4 倍。dspark 的接受率约 34-54%,平均接受 2.7-3.7 个 token------参数量从线上值 3 开始调,我没用到仓库默认的 5,因为 5 的 4、5 位接受率极低(0.40、0.34),大部分被浪费,3 更平滑。这一步让我从 50 到 235 tok/s,是量级最大的跳跃 ------ 这一跳正是标题"5 倍提速"(≈4.7 倍取整)的出处。
三 8 卡双实例:Tensor Parallelism + Expert Parallelism
单实例 4 卡已经摸到单机 4×80GB 的天花板了,下一步是扩容。我选了双实例而不是 8 卡 TP=8:
- 本机 GPU0-3 在 NUMA node0,GPU4-7 在 NUMA node1,跨节点全是慢速 SYS 链路。TP=8 的每层 all-reduce 都要走这条慢链路,预填充很可能比 4 卡还慢。
- 所以拆成 两个 TP=4 实例(GPU0-3 + GPU4-7),实例内互联优于跨 NUMA(成对 NVLink + 组内 PIX,无 SYS),总吞吐和 KV 池各乘 2。
3.1 第二个实例的"最佳实践"配置(GPU4-7,端口 8002)
本文所称脚本名为对外通称,完整脱敏脚本见**§十一附录**。
bash
bash scripts/deploy_ds4_tp_ep.sh
# 默认 = 512K 上下文 / TP+EP / 30 并发 / 25600 预填充
| 项 | 值 |
|---|---|
| TP_SIZE / EP_SIZE | 4 / 4(--enable-expert-parallel) |
| MAX_MODEL_LEN | 524288(512K) |
| MAX_NUM_SEQS | 30 |
| MAX_BATCHED_TOKENS | 25600 |
| KV cache / dtype | fp8_ds_mla / bfloat16 |
| dspark / watermark / 显存 | 3 / 0.1 / 0.9 |
| 功能验证 | functional_test 4/4 通过,512K 上限正常受理 |
值得注意的是 512K 上下文是"白赚"的 ------KV 池大小由显存决定,不随 MAX_MODEL_LEN 上限改变。把上限从 256K 抬到 512K,64K 并发的吞吐完全不受影响,却让单请求能带上 512K 的长文档。这是我权衡后认为"无负优化 + 有长上下文能力"的最佳折中。
四 重头戏:TP+EP vs 纯 TP,到底差多少?
这是本文最有价值的部分------一台仅成对 NVLink(NVLink Bridge 只在 4-5、6-7 之间)的 A800 上,专家并行究竟值不值?我做了严格的对比分析:同一台 GPU4-7、同一负载脚本、同一 512-token 生成任务,只切换 ENABLE_EP(1=TP+EP, 0=纯 TP),其余所有参数完全一致。
4.1 分档并发实测(tok/s)
| 并发 | TP+EP | 纯 TP | 差异 | 判定 |
|---|---|---|---|---|
| 1 | 101.5 | 97.6 | +4% | 噪声内 |
| 4 | 205.2 | 213.5 | −4% | 噪声内 |
| 8 | 341.8* | 196.4 | +74%* | *纯噪声(EP 重测仅 180.8) |
| 16 | 315.3 / 326.9 | 327.1 | ≈0 | 持平(两次) |
| 24 | 392.4 / 386.2 | 330.6 | +15~19% | EP 胜(可复现) |
| 32 | 380.1 | 443.0 | −14% | ⚠️ 超 30 席位封顶,失真 |
先说测量噪声 :这模型默认开思考模式,每次实际输出的 token 数不可控,所以 tok/s 天生波动大(同一并发测两次能差近 2 倍,比如并发 8 那次)。我只采纳可复现的差异:16 并发持平、24 并发 EP 胜。
4.2 为什么 EP 在低并发看不到好处?
我用一句话解释核心机制:
- 纯 TP :每个被命中的专家,4 张卡都要算自己的分片,然后 all-reduce 合并成完整输出。每层跨卡流量大,而且不随稀疏度下降而减少。
- EP :专家按整卡驻留(每卡 64 个),每层只做一次 all-gather,只交换被激活的那一小撮专家的输出。稀疏度(每 token 只激活 6/256 ≈ 2.3%)直接转化为通信节省。
但 EP 的 all-gather 有个固定开销 (每层一次同步点)。batch 小的时候,这个固定开销把省下来的通信量给抵消了------所以 16 并发以下两者持平;当 batch 大到能把固定开销摊薄,EP 的优势就显现出来了。这台机器的收益拐点出现在 ~24 并发。
4.3 附带收益:更大的 KV 池
| 配置 | KV 池 |
|---|---|
| 纯 TP | ~81 万 token |
| TP+EP | 928,878 token |
EP 让专家权重/激活按卡驻留,省下的显存直接让给 KV cache。这是纯白赚的。
4.4 结论:选 EP,决策风险为零
- 所有并发档无系统退步
- 24+ 并发有可复现的 15~19% 增益
- 白拿更大 KV 池
所以我保留 TP+EP 作为默认。对 MoE + PCIe(NVLink 仅成对)这套组合,EP 是把稀疏度转化为实实在在吞吐的正确手段。
五 其他旋钮我也试过:别在里面找奇迹
为了"榨干"最后一点,我还做了另外两组实验,结论都是别改:
实验 A:把并发上限从 30 抬到 64 + 关掉节能
| 配置 | 30 并发 tok/s | GPU 均载 | 16 并发 tok/s |
|---|---|---|---|
| 默认(30/25600/节能开) | 479 | 49% | 508 |
| 调优(64/25600/节能关) | 448 | 45% | 237 |
并发上限 64 并没能增大单轮 decode 的 batch(30 负载下席位本就有余),反而在 16 并发时明显退步;节能开关也不是瓶颈(GPU 本来就只跑 77W/300W,非功耗受限)。
实验 B:max-num-batched-tokens 不要提到 32768
预填充 chunk 与 KV 池是直接权衡:32768 会把 KV 池从 282 万 token 塌缩到 35 万,并发从 10.7× 掉到 1.34×。25600 是我实测后确认能兼顾预填充速度与KV的值。
六 瓶颈到底在哪:GPU 为什么只有 ~49%?
这是最常被追问的问题------"GPU 没跑满是不是哪里没配好?"
| 观测 | 值 |
|---|---|
| GPU 均载(30 并发持续) | ~45-49% |
| GPU 峰值 | 98% |
| SM 时钟 | 1410/1410 MHz(P0 满频,无降频) |
| 功耗 | ~77W/300W(无功耗墙) |
| 互联(本机) | GPU 两两成对 NVLink(4-5、6-7 等各 NV8), 跨对走 PCIe(PIX, Gen4×16 52GB/s) |
| CPU | 每 worker ~5 核,不饱和 |
这是典型的"计算爆发-等待交替 "曲线:GPU 以接近满速(98%)算完一轮,然后就卡在等跨卡数据搬运(EP 的 all-gather 走 PCIe)。因为 MoE 每 token 只激活 6/256 个专家,真正的计算量很小,于是通信(搬运)的相对占比被放大了。需要强调的是:NVLink 只成对桥接(4-5、6-7),而 TP=4/EP=4 的集合通信必然跨对走 PCIe,所以瓶颈本质是被跨对的 PCIe 链路卡住,不是"完全没有 NVLink"。
想在这台机器上把 GPU 均载推到 80%+,只有三条路,都不是调参:
- 上 NVLink 全互联/NVSwitch------A800 PCIe 版只有成对桥接,没有全连通;
- 减 EP 组规模(TP=2+EP=2)------通信量级降,但每卡算更大的分片,收益需另测;
- 接受现状------对 64K 批量业务,479 tok/s 已经够用。
我选择的是第 3 条:认清楚这是硬件形态决定的边界,不是配置失误,然后针对业务做出最优解。
七 踩过的坑
给后来人三个最痛的:
坑 1:KV 池被前缀缓存占满 → 并发排队爆炸
运行一段时间后,系统提示词/工具定义/历史前缀会被永久缓存进 KV 池,实测累积到池的 80%。新请求一来没有头寸,就开始驱逐+重算,7 个 token 的小请求 TTFT 能飙到 151 秒。
解法:--watermark 0.1(永久保留 10% KV 空闲)+ 上下文封顶 + 依赖 vLLM 自带的按需驱逐缓存。不用重启清缓存,vLLM 空闲时会自动把缓存堆满,需要时再驱逐,这是最优缓存行为。
坑 2:预填充 chunk 与 KV 池是此消彼长
| MAX_BATCHED_TOKENS | KV 池(token) | 满载并发 |
|---|---|---|
| 8192 | ~177 万 | ~6.8× |
| 16384 | 89 万 | 3.4× |
| 32768 | 35 万 | 1.34× |
还有老生常谈的 libnuma 损坏(NUMA not available! abort.)、dspark 图捕获 OOM(0.98 显存)等,属于部署期常见问题的通用经验。
坑 3(反直觉判例):自愈脚本的启动宽限不足会把"正常冷启动"误判成宕机 → 无限重建
这是部署 DeepSeek 系列后踩到的最隐蔽一坑,与性能无关、却直接决定"服务看起来稳不稳"。在给 GPU4-7 加自愈 supervisor 时,最初把"重建后冷却/宽限"定成 180s。但 vLLM 完整启动实测要 ~6-7 分钟(模型加载 + 大量 TileLang JIT 编译 + CUDA graph 捕获 ~95s + API 启动)。
于是发生了这样的事:容器每次启动还没跑完 → supervisor 的 3×30s = 90s 连续失败判定 → 判死重建 → 又等 6 分钟 → 又超 90s → 再重建...... 在没有任何真实流量、服务完全正常的情况下,self-heal 已累计重建 940 次。表面看"GPU4-7 很不稳",其实是被自愈脚本自己反复杀掉正在正常启动的进程。对照同为 TP+EP 的 GPU0-3(未配 supervisor)稳定运行 8 天,根因一目了然。
解法:把冷却改为基于容器实际启动时间的宽限 STARTUP_GRACE(默认 600s) ,StartedAt 距今 <600s 一律跳过健康探测。修复后健康服务零误杀(实测重建次数 0)。这条的教训:"加自愈"不等于"更稳",自愈参数若不匹配服务启动时长,反而制造故障。量体裁衣,先量启动时间,再设宽限。
八 稳定性:一次 48 小时后的宕机复盘
同期发布的《长时运行宕机复盘 + 稳定性加固》一文有完整证据链,这里只讲结论。
长跑 48h 后,GPU4-7 实例突然 EngineCore fatal,API 永久挂死,容器进程却还活着(vLLM 不自愈),只能手工重建。表象很像"前缀缓存满了",但证据链指向完全不同的根因。
根因不是前缀缓存 ------崩溃时 KV 池占用仅 39.6%,LRU 淘汰机制正常。真正的元凶是单个超长上下文请求:
- 一个 512K 级请求独霸池的 98.4% 历史(Running:1);
- 上下文越长,decode 单步的稀疏 attention 越重 → 单步耗时超线性增长,生成掉到 1.6 tok/s;
- 单步是全 TP 组同步的,一步卡住所有 worker 一起等;
- EngineCore 等结果超时(
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS,默认 300)抛 fatal; - vLLM 不会自愈,API 永久僵死,残留 worker 占着显存。
三件事把它兜住(已落地):
- 加大模型执行硬超时到 600s (
EXEC_MODEL_TIMEOUT):给"慢但能恢复"的 512K 单步留足窗口,减少误杀。 - 外部自愈 supervisor (
scripts/supervisor.sh):探测到僵死(HTTP 失败 ×3 或日志命中 EngineCore fatal)→docker rm -f+ 自动重建,把恢复时间从"手工发现"压到 1-2 分钟。 - 生成吞吐冻结探测 (
scripts/monitor.sh watch):Running>0但生成 <1 tok/s 持续 30s → 告警 + 自动联动 supervisor 重建。
三个都可用一键托管:bash scripts/start_trio.sh(幂等 start/status/stop)。
取舍 :要么降 MAX_MODEL_LEN 到 256K(单请求 decode 上界减半,池并发度 1.59→3.1),要么保留 512K 但必须开 supervisor 兜底。真怕卡死就上 KV offload(KV_OFFLOAD_SIZE=256,KV 放 CPU,decode 变慢换池自由)。我的选择是保留 512K + 三件套。
这条比性能调参更值得记住:长上下文是天然的并发/时延风险,服务必须可自愈。
九 演进总结(带数字的完整脉络)
阶段 1-4 的"单流/聚合"是纯 decode(只算生成,不含 prefill/思考)的基准;阶段 5 起是端到端完整生成(含 prefill 与思考)的基准,两者测量基准不同,数字不可直接相减。阶段 5 是单个 4 卡 TP+EP 实例(gpu4-7),阶段 6 是它在双实例中的实际角色。
| 阶段 | 配置 | 实例范围 | 单流 tok/s | 并发聚合 | GPU 利用 | 口径 |
|---|---|---|---|---|---|---|
| 1 | 2卡混合(NUMA) | 早期 2 卡 | 50-66 | ~2 并发 | 0% | 纯 decode |
| 2 | 4卡全显存混合(NUMA) | 早期单实例 | ~100 | ~200 (20并发) | 0% | 纯 decode |
| 3 | 4卡纯GPU(无 dspark) | 早期单实例 | 111-128 | ~207 (20并发) | 61-91% | 纯 decode |
| 4 | 4卡纯GPU+dspark | 早期单实例 | 221-235 | 829 (20并发) | 88-95% | 纯 decode |
| 5 | 4卡 TP+EP + 512K + 30并发 | 单 4 卡实例 (gpu4-7) | ~102* | 479 (30并发) / 508 (16并发) | ~49% | 512-token 含思考 |
| 6^^ | 8卡双实例(0-3 + 4-7) | 整机 | --- | ~2× 单实例 | ~49% | --- |
关于"测量基准"(过去称"口径"):阶段 1-4 的单流 221-235 是纯 decode 速率 = 只算模型逐 token 生成的速度,不含首 token 等待(prefill)、不含思考 token。阶段 5 的 ~102 是端到端完整生成速率 = 从发请求到整条回复结束的总 token 数 ÷ 总耗时,包含了 prefill、思考 token、首 token 延迟。同一硬件,两种测法数字不同,不是硬件变慢,是测的指标定义不同。
- 阶段 5 单流 ~102 是 conc=1 的 512-token 端到端生成(含思考),与阶段 4 单流 221-235 的纯 decode 基准不同,不可直接比较。
- 阶段 6 为双实例总吞吐的理论上界(≈2× 单实例);双实例各自服务不同 agent,不做跨实例合流聚合。
十 最终部署与结论
bash
bash scripts/deploy_ds4_tp_ep.sh # gpu4-7: 512K / TP+EP / 30 并发 / 25600 (完整脚本见 §11.1)
bash scripts/start_trio.sh # 稳定性加固三件套: supervisor + monitor (见 §11.2-11.4)
- 演进:2 卡混合 → 4 卡纯 GPU + dspark → 8 卡双实例 TP+EP。
- 双实例分工:GPU0-3(8000)= 1M 专机;GPU4-7(8002)= 512K + 30 并发批量 + TP+EP。
- TP+EP 结论:24+ 并发 +15~19%,16 以下持平,白拿更大 KV 池,无退步 → 保留 EP。
- 硬件天花板:NVLink 仅成对 + 跨对 PCIe 下 GPU 均载 ~49%,这是形态决定,不是配置失误。
- 稳定性 :长上下文需自愈兜底,
EXEC_MODEL_TIMEOUT600 + supervisor + monitor 三件套。 - 对 64K 批量业务:479 tok/s / 30 并发是这台 8 卡机器的合理水平。
如果这篇对你有帮助,欢迎分享;如果你在自己机器上复现后数字有出入,先检查互联方式(NVLink 是全互联还是仅成对)和测量基准------这两点最影响结论。
十一 附录:完整部署与自愈脚本
为便于直接复现,这里给出与正文一致的脱敏完整脚本 。归档为
deploy_ds4_tp_ep.sh(部署)+supervisor.sh(自愈)+start_trio.sh(三件套托管)。monitor.sh为关联监控脚本,篇幅所限本文给出调用方式。
11.1 部署脚本 deploy_ds4_tp_ep.sh
bash
#!/usr/bin/env bash
# =============================================================================
# DeepSeek-V4-Flash-0731 部署脚本 (TP+EP 第二实例, 端口 8002)
# -----------------------------------------------------------------------------
# 用途: 8 卡双实例方案 ------ GPU0-3 (8000, 1M 上下文专机) + GPU4-7 (8002, 本脚本)。
# 本脚本为 GPU 4-7 部署"最佳实践"配置: TP+EP 专家并行 + 512K 上下文 +
# 30 并发 + 25600 预填充, 长上下文与 64K 高并发批量都能较好兼顾。
#
# 关键环境变量 (均可覆盖):
# GPUS 设备选择, 默认 "device=4,5,6,7"
# TP_SIZE 张量并行度, 默认 4
# EP_SIZE 专家并行度, 默认 4 (--enable-expert-parallel 组大小)
# ENABLE_EP 专家并行开关, 默认 1 (=0 回退纯 TP)
# MAX_NUM_SEQS 并发序列上限, 默认 30
# LK_THREADS lk_moe 线程数, 默认 14 (NUMA node1 56 CPU / TP4)
# ENABLE_DSPARK 投机解码开关, 默认 1 (dspark=3)
# GPU_MEM_UTIL 显存利用率, 默认 0.9
# PATCH_VLLM 思考参数 vLLM 补丁, 默认 1 (5 文件只读挂载)
# EXEC_MODEL_TIMEOUT EngineCore 模型执行硬超时秒, 默认 600 (宕机复盘加固)
# =============================================================================
set -e
MODEL_HOST_PATH="${MODEL_HOST_PATH:-/path/to/DeepSeek-V4-Flash-0731}"
MODEL_CTR_PATH="/model"
IMAGE="${IMAGE:-garenleeasa/lvllmds4x:v2.3.9-cuda-12.8.1-ubuntu-22.04}"
CONTAINER="${CONTAINER:-lvllm-ds4-tp4-ep4}"
# 服务端口 (宿主机映射 = 容器内端口)
PORT="${PORT:-8002}"
SERVED_MODEL="DeepSeek-V4-Flash-0731"
# ---- 可覆盖参数 ----
GPUS="${GPUS:-device=4,5,6,7}"
TP_SIZE="${TP_SIZE:-4}"
EP_SIZE="${EP_SIZE:-4}"
MAX_NUM_SEQS="${MAX_NUM_SEQS:-30}"
LK_THREADS="${LK_THREADS:-14}"
GPU_RESIDENT_MOE_LAYERS="${GPU_RESIDENT_MOE_LAYERS:-0-42}"
GPU_MEM_UTIL="${GPU_MEM_UTIL:-0.9}"
WATERMARK="${WATERMARK:-0.1}"
KV_OFFLOAD_SIZE="${KV_OFFLOAD_SIZE:-}"
MAX_BATCHED_TOKENS="${MAX_BATCHED_TOKENS:-25600}"
MAX_MODEL_LEN="${MAX_MODEL_LEN:-524288}"
ENABLE_DSPARK="${ENABLE_DSPARK:-1}"
DSPARK_SPEC_TOKENS="${DSPARK_SPEC_TOKENS:-3}"
ENABLE_NUMA="${ENABLE_NUMA:-0}"
PATCH_VLLM="${PATCH_VLLM:-1}"
ENABLE_EP="${ENABLE_EP:-1}"
LK_POWER_SAVING="${LK_POWER_SAVING:-1}"
# ---- 稳定性加固 (宕机复盘新增) ----
# EXEC_MODEL_TIMEOUT: EngineCore 等 worker 单步模型执行结果的硬超时 (秒)。
# ⚠️ 镜像默认 300。512K 超长上下文 decode 单步可超 300s → EngineCore fatal 且
# vLLM 不自愈 (容器 running 但 API 永久挂死)。调大 (如 600) 可避免"慢但可恢复"被误杀,
# 但真卡死仍需外部 supervisor 兜底。设为 0 表示不设超时。
EXEC_MODEL_TIMEOUT="${EXEC_MODEL_TIMEOUT:-600}"
# ---- 容器内 CUDA_VISIBLE_DEVICES ----
# ⚠️ --gpus "device=4,5,6,7" 将宿主机 GPU 4-7 传入容器后被重新编号为 0..TP-1
# (容器内 nvidia-smi 显示 0-3)。因此容器内 CUDA_VISIBLE_DEVICES 必须是 0,1,..,TP-1,
# 不能直接用宿主机物理索引 (4,5,6,7), 否则 vLLM 的 physical_device_id 映射到不存在的
# NVML 索引 4 → NVMLError_InvalidArgument。
CONTAINER_CUDA_VISIBLE_DEVICES="$(seq -s, 0 $((TP_SIZE-1)))"
if [ "${ENABLE_DSPARK}" = "1" ]; then
SPEC_ARGS=(--speculative-config "{\"method\":\"dspark\",\"num_speculative_tokens\":${DSPARK_SPEC_TOKENS},\"draft_sample_method\":\"probabilistic\"}")
else
SPEC_ARGS=()
echo "[deploy] NOTE: dspark DISABLED (高并发稳定); ENABLE_DSPARK=1 可启用以提升单流速度"
fi
if [ -n "${KV_OFFLOAD_SIZE}" ]; then
OFFLOAD_ARGS=(--kv-offloading-size "${KV_OFFLOAD_SIZE}")
echo "[deploy] KV offload ON: ${KV_OFFLOAD_SIZE} GiB 到 CPU 内存 (长上下文解码会变慢)"
else
OFFLOAD_ARGS=()
echo "[deploy] KV offload OFF (纯 GPU KV)"
fi
if [ "${ENABLE_NUMA}" = "1" ]; then
NUMA_ENV=( -e LVLLM_MOE_NUMA_ENABLED=1 -e LVLLM_GPU_RESIDENT_MOE_LAYERS="${GPU_RESIDENT_MOE_LAYERS}" -e LVLLM_GPU_PREFETCH_WINDOW=1 -e LVLLM_GPU_PREFILL_MIN_BATCH_SIZE=128 )
echo "[deploy] NUMA 混合推理 ON (CPU+GPU); GPU_RESIDENT_MOE_LAYERS=${GPU_RESIDENT_MOE_LAYERS}"
else
NUMA_ENV=( -e LVLLM_MOE_NUMA_ENABLED=0 )
echo "[deploy] 纯 GPU 模式 (NUMA OFF): 解码走 GPU, 不浪费 GPU 算力"
fi
# 专家并行 (MoE): --enable-expert-parallel, EP 组大小 = TP 组大小
# 稀疏 MoE (6/256) 下跨卡通信只交换被激活专家输出, 降通信 + 省驻留显存。
if [ "${ENABLE_EP}" = "1" ]; then
EP_ARGS=(--enable-expert-parallel)
echo "[deploy] expert-parallel ON (MoE 专家切分到 ${EP_SIZE} 卡; TP=${TP_SIZE} EP=${EP_SIZE})"
else
EP_ARGS=()
echo "[deploy] expert-parallel OFF (纯 TP, ENABLE_EP=1 可启用)"
fi
# 捆绑 libnuma 修复: 宿主机 libnuma 覆盖镜像内 RHEL9 libnuma (避免 `NUMA not available! abort.`)
HOST_LIBNUMA="${HOST_LIBNUMA:-/lib/x86_64-linux-gnu/libnuma.so.1.0.0}"
CTR_LIBNUMA="${CTR_LIBNUMA:-/root/miniconda3/lib/python3.12/site-packages/lk_moe.libs/libnuma-14905a75.so.1.0.0}"
if [ ! -f "${HOST_LIBNUMA}" ]; then
echo "ERROR: ${HOST_LIBNUMA} not found on host" >&2
exit 1
fi
# 思考参数 vLLM 补丁 (只读挂载覆盖镜像内同名文件); 每个文件必须存在
PATCHES_DIR="$(dirname "$(dirname "$(realpath "$0")")")/patches/vllm"
PATCH_FILES=(
"entrypoints/openai/chat_completion/protocol.py"
"entrypoints/anthropic/protocol.py"
"entrypoints/anthropic/serving.py"
"tokenizers/deepseek_v4.py"
"tokenizers/deepseek_v4_encoding.py"
)
PATCH_BINDS=()
if [ "${PATCH_VLLM}" = "1" ]; then
for pf in "${PATCH_FILES[@]}"; do
if [ ! -f "${PATCHES_DIR}/${pf}" ]; then
echo "ERROR: patch file missing: ${PATCHES_DIR}/${pf} (PATCH_VLLM=0 to disable)" >&2
exit 1
fi
PATCH_BINDS+=(-v "${PATCHES_DIR}/${pf}:/root/miniconda3/lib/python3.12/site-packages/vllm/${pf}:ro")
done
echo "[deploy] vLLM thinking-patches ON (${#PATCH_FILES[@]} files mounted read-only)"
else
echo "[deploy] vLLM thinking-patches OFF (PATCH_VLLM=0, 镜像原版行为)"
fi
docker rm -f "${CONTAINER}" 2>/dev/null || true
echo "[deploy] ${CONTAINER} | GPUS=${GPUS} TP=${TP_SIZE} max-num-seqs=${MAX_NUM_SEQS} LK_THREADS=${LK_THREADS} GPU_MEM_UTIL=${GPU_MEM_UTIL}"
echo "[deploy] MAX_MODEL_LEN=${MAX_MODEL_LEN} MAX_BATCHED_TOKENS=${MAX_BATCHED_TOKENS} dspark=${DSPARK_SPEC_TOKENS}"
echo "[deploy] EXEC_MODEL_TIMEOUT=${EXEC_MODEL_TIMEOUT}s (EngineCore 模型执行硬超时)"
echo "[deploy] LVLLM_GPU_RESIDENT_MOE_LAYERS=${GPU_RESIDENT_MOE_LAYERS} (MoE 常驻显存)"
docker run -d --name "${CONTAINER}" \
--gpus "\"${GPUS}\"" \
--ipc=host --shm-size=16g \
--cap-add=SYS_NICE \
-p ${PORT}:${PORT} \
-v "${MODEL_HOST_PATH}:${MODEL_CTR_PATH}" \
-v "${HOST_LIBNUMA}:${CTR_LIBNUMA}:ro" \
"${PATCH_BINDS[@]}" \
-v "${CACHE_DIR:-/path/to/cache}:/root/.cache" \
-e CUDA_DEVICE_ORDER=PCI_BUS_ID \
-e CUDA_VISIBLE_DEVICES="${CONTAINER_CUDA_VISIBLE_DEVICES}" \
-e PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
"${NUMA_ENV[@]}" \
-e LK_THREADS="${LK_THREADS}" \
-e OMP_NUM_THREADS="${LK_THREADS}" \
-e LK_THREAD_BINDING=CPU_CORE \
-e LK_POWER_SAVING="${LK_POWER_SAVING}" \
-e FLASHINFER_DISABLE_VERSION_CHECK=1 \
-e VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS="${EXEC_MODEL_TIMEOUT}" \
"${IMAGE}" \
"${MODEL_CTR_PATH}" \
--host 0.0.0.0 \
--port ${PORT} \
--tensor-parallel-size "${TP_SIZE}" \
--max-model-len "${MAX_MODEL_LEN}" \
--gpu-memory-utilization "${GPU_MEM_UTIL}" \
--trust-remote-code \
--served-model-name "${SERVED_MODEL}" \
--compilation_config.cudagraph_mode FULL_DECODE_ONLY \
--enable-prefix-caching \
--enable-chunked-prefill \
--watermark "${WATERMARK}" \
"${OFFLOAD_ARGS[@]}" \
--max-num-batched-tokens "${MAX_BATCHED_TOKENS}" \
--dtype bfloat16 \
--max-num-seqs "${MAX_NUM_SEQS}" \
--enable-auto-tool-choice \
--kv-cache-dtype fp8_ds_mla \
--tokenizer-mode deepseek_v4 \
--tool-call-parser deepseek_v4 \
--reasoning-parser deepseek_v4 \
--default-chat-template-kwargs '{"enable_thinking": true}' \
"${EP_ARGS[@]}" \
"${SPEC_ARGS[@]}" \
--disable-custom-all-reduce
echo "[deploy] container started. follow logs: docker logs -f ${CONTAINER}"
- 正确调用命令:默认即为正文的"最佳实践"配置(512K / TP+EP / 30 并发 / 25600 预填充 / 8002),直接运行即可:
bash
# 一步部署 (默认最佳实践: 512K / TP+EP / 30 并发 / 25600 预填充 / 8002)
bash scripts/deploy_ds4_tp_ep.sh
所有关键参数均为环境变量、可显式覆盖(不改脚本):
bash
# 显式覆盖参数 (值即默认, 仅为演示; 按需改写)
PORT=8002 MAX_MODEL_LEN=524288 MAX_BATCHED_TOKENS=25600 MAX_NUM_SEQS=30 \
ENABLE_EP=1 bash scripts/deploy_ds4_tp_ep.sh
# 回退纯 TP (关 EP): ENABLE_EP=0
ENABLE_EP=0 bash scripts/deploy_ds4_tp_ep.sh
# 回退旧配置 (256K / 24 并发 / 8192 预填充 / 纯 TP)
MAX_MODEL_LEN=262144 MAX_NUM_SEQS=24 MAX_BATCHED_TOKENS=8192 ENABLE_EP=0 \
bash scripts/deploy_ds4_tp_ep.sh
# KV offload: 长上下文 KV 放 CPU 内存 (decode 变慢换池自由)
KV_OFFLOAD_SIZE=256 bash scripts/deploy_ds4_tp_ep.sh
# dspark 投机解码开关 (默认 1): 高并发稳定可关
ENABLE_DSPARK=0 bash scripts/deploy_ds4_tp_ep.sh
可覆盖环境变量速查(默认值即最佳实践):
| 变量 | 默认 | 说明 |
|---|---|---|
GPUS |
device=4,5,6,7 |
设备选择 |
TP_SIZE / EP_SIZE |
4 / 4 | 张量 / 专家并行度 |
ENABLE_EP |
1 | 1=TP+EP, 0=纯 TP |
MAX_MODEL_LEN |
524288 (512K) | 长上下文上限 |
MAX_NUM_SEQS |
30 | 并发席位 |
MAX_BATCHED_TOKENS |
25600 | 预填充 chunk |
ENABLE_DSPARK / DSPARK_SPEC_TOKENS |
1 / 3 | 投机解码 |
KV_OFFLOAD_SIZE |
(空) | 非空则 KV 放 CPU |
GPU_MEM_UTIL |
0.9 | 显存利用率 |
WATERMARK |
0.1 | KV 空闲保留 |
EXEC_MODEL_TIMEOUT |
600 | EngineCore 执行硬超时秒 |
LK_THREADS |
14 | lk_moe 线程数 |
镜像 garenleeasa/lvllmds4x:v2.3.9-cuda-12.8.1-ubuntu-22.04 为公开 Docker 镜像。
11.2 自愈 supervisor supervisor.sh
bash
#!/usr/bin/env bash
# =============================================================================
# GPU4-7 (lvllm-ds4-tp4-ep4, 8002) 自愈看门狗
# -----------------------------------------------------------------------------
# 背景 (宕机复盘):
# vLLM EngineCore 遇 worker 单步执行超时 (execute_model RPC 超
# VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS 后) 会抛 fatal, 但 vLLM 不会自愈:
# 容器进程仍存活 (docker running=true), API 却永久不可用, 遗留残 worker 占显存。
#
# 作为外部 supervisor 兜底: 周期性探测服务, 一旦发现"僵死"即自动重建
# (docker rm -f + 重新跑 deploy_ds4_tp_ep.sh), 把故障恢复时间从"手工发现"压到分钟级。
#
# 检测逻辑:
# 1) 健康探测: 对 ${BASE_URL}/health (或 /metrics) 做 HTTP 探测, N 次连续失败即判定故障;
# - 也扫描容器日志中 EngineCore fatal / TimeoutError 关键字 (中毒的强证据)。
# 2) 重建: docker rm -f "${CONTAINER}" → bash deploy_ds4_tp_ep.sh (阻塞等起)。
#
# 用法:
# ./supervisor.sh 前台循环 (推荐配 systemd / nohup)
# ./supervisor.sh once 只检查一次 (便于 cron 调用)
#
# 参数 (env 覆盖):
# BASE_URL 默认 http://127.0.0.1:8002
# INTERVAL 探测间隔秒, 默认 30
# FAIL_THRESH 连续失败次数, 默认 3 (约 90s 判定故障)
# HEALTH_PATH 健康路径, 默认 /health
# STARTUP_GRACE 容器启动宽限期秒, 默认 600。容器实际启动时间(StartedAt)距今 < 该值
# → 跳过健康探测 (避免把正常冷启动误判为宕机)。
# ⚠️ 必须 ≥ vLLM 完整启动耗时 (模型加载+JIT 编译+CUDA graph 捕获, 实测 ~6-7 分钟)。
# ⚠️⚠️ 若设太小 (如默认早先的 180s), 启动未完成即被连续判死 → 无限重建循环。
# LOG_KEY DOCKER 日志中的致命关键字 (正则); 命中即重建, 默认
# "EngineCore encountered a fatal error|RPC call to .*timed out"
# ENABLED =0 关闭 (只输出不动作)
# =============================================================================
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONTAINER="${CONTAINER:-lvllm-ds4-tp4-ep4}"
DEPLOY="${SCRIPT_DIR}/deploy_ds4_tp_ep.sh"
BASE_URL="${BASE_URL:-http://127.0.0.1:8002}"
INTERVAL="${INTERVAL:-30}"
FAIL_THRESH="${FAIL_THRESH:-3}"
HEALTH_PATH="${HEALTH_PATH:-/health}"
STARTUP_GRACE="${STARTUP_GRACE:-600}"
ENABLED="${ENABLED:-1}"
LOG_KEY="${LOG_KEY:-EngineCore encountered a fatal error|RPC call to .* timed out}"
TS() { date '+%F %T'; }
fail=0
rebuild() {
local why="$1"
echo "[$(TS)] ⚠️ 触发自愈 ($why) --- 重建 ${CONTAINER}..."
docker rm -f "${CONTAINER}" 2>/dev/null
if [ "${ENABLED}" = "1" ]; then
bash "${DEPLOY}" && {
echo "[$(TS)] ✅ ${CONTAINER} 重建完成"
fail=0
} || {
echo "[$(TS)] ❌ 重建失败, ${INTERVAL} 秒后重试"
fail=$((FAIL_THRESH + 1)) # 强制下轮继续尝试
}
else
echo "[$(TS)] ENABLED=0, 跳过实际重建 (仅报告)"
fi
}
once() {
# 1) 健康探测
if curl -sf -m 10 "${BASE_URL}${HEALTH_PATH}" >/dev/null 2>&1; then
fail=0
return 0
fi
# 2) 日志中毒检测 (服务也许还没起来, 但日志已现 fatal → 直接重建)
if docker logs --tail 200 "${CONTAINER}" 2>/dev/null | grep -qE "${LOG_KEY}"; then
echo "[$(TS)] 🔴 检测到引擎致命错误日志 (EngineCore fatal), 判定僵死"
rebuild "日志命中致命关键字"
return 0
fi
# HTTP 失败但不含致命日志 → 累计失败计数
fail=$((fail + 1))
echo "[$(TS)] ⚠️ 健康探测失败 ${fail}/${FAIL_THRESH} (${BASE_URL}${HEALTH_PATH})"
if [ "${fail}" -ge "${FAIL_THRESH}" ]; then
rebuild "连续 ${fail} 次健康检查失败"
fi
return 0
}
# once 模式
if [ "${1:-}" = "once" ]; then
once
exit 0
fi
# 前台循环
echo "[$(TS)] supervisor 启动: ${BASE_URL} | interval=${INTERVAL}s | fail_thresh=${FAIL_THRESH} | grace=${STARTUP_GRACE}s | enabled=${ENABLED}"
while true; do
# 冷启动宽限: 以容器实际启动时间为准 (docker inspect StartedAt)
# StartedAt 距今 < STARTUP_GRACE → 判定仍在加载/启动期, 跳过健康探测
# (避免把正常冷启动 (~6-7 分钟) 误判为宕机 → 无限重建循环)
CONTAINER_STARTED=$(docker inspect -f '{{.State.StartedAt}}' "${CONTAINER}" 2>/dev/null)
if [ -n "${CONTAINER_STARTED}" ]; then
STARTED_EPOCH=$(date -d "${CONTAINER_STARTED}" +%s 2>/dev/null || echo 0)
NOW_EPOCH=$(date +%s)
if [ "$(( NOW_EPOCH - STARTED_EPOCH ))" -lt "${STARTUP_GRACE}" ]; then
sleep "${INTERVAL}"
continue
fi
fi
once
sleep "${INTERVAL}"
done
⚠️ 教训(2026-08-25) :早期版本用 180s 冷却,而 vLLM 完整启动实测需 ~6-7 分钟 → 每次启动未完成即被判死重建,在无任何真实流量下累计重建 940 次 ,表面上看"GPU4-7 很不稳"。已改为基于容器实际启动时间的 600s 宽限,修复后健康服务零误杀。
11.3 三件套一键托管 start_trio.sh
bash
#!/usr/bin/env bash
# =============================================================================
# GPU4-7 (lvllm-ds4-tp4-ep4, 8002) 稳定性三件套一键启动
# -----------------------------------------------------------------------------
# 三件套: ① 参数由 deploy 脚本注入 (EXEC_MODEL_TIMEOUT)
# ② supervisor.sh (自愈兜底)
# ③ monitor.sh watch (生成吞吐冻结前兆探测 + AUTO_HEAL 联动)
#
# 用法:
# bash scripts/start_trio.sh 启动三件套 (幂等: 已运行则先清理重启)
# bash scripts/start_trio.sh status 查看当前状态/守护进程 PID
# bash scripts/start_trio.sh stop 停止全部守护进程 (不删容器)
#
# 参数 (env 覆盖):
# BASE_URL 默认 http://127.0.0.1:8002 (GPU4-7)
# LOG_DIR 守护进程日志目录, 默认 ${SCRIPT_DIR}/logs
# =============================================================================
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_URL="${BASE_URL:-http://127.0.0.1:8002}"
LOG_DIR="${LOG_DIR:-${SCRIPT_DIR}/logs}"
mkdir -p "${LOG_DIR}"
SUP="${SCRIPT_DIR}/supervisor.sh"
MON="${SCRIPT_DIR}/monitor.sh"
stop_all() {
pkill -f "supervisor.sh" 2>/dev/null && echo "[stop] supervisor 已停止" || echo "[stop] supervisor 未在运行"
pkill -f "monitor.sh watch" 2>/dev/null && echo "[stop] monitor 已停止" || echo "[stop] monitor 未在运行"
sleep 1
}
status() {
echo "=== GPU4-7 稳定性三件套状态 ==="
echo "BASE_URL=${BASE_URL}"
local sup mon
sup="$(pgrep -f "supervisor.sh" | head -1)"
mon="$(pgrep -f "monitor.sh watch" | head -1)"
echo "supervisor: ${sup:-未运行}"
echo "monitor : ${mon:-未运行}"
curl -sf -m 5 -o /dev/null "${BASE_URL}/health" && echo "8002 /health: 200 (健康)" || echo "8002 /health: 不可用"
}
start() {
# 幂等: 先停旧进程再启, 避免重复实例
stop_all
# ② supervisor: 自愈兜底 (默认 ENABLED=1)
( nohup env BASE_URL="${BASE_URL}" bash "${SUP}" >"${LOG_DIR}/supervisor.log" 2>&1 & )
sleep 1
# ③ monitor: watch 模式, 生成吞吐冻结探测 + 自动调 supervisor 重建
( nohup env BASE_URL="${BASE_URL}" AUTO_HEAL=1 bash "${MON}" watch >"${LOG_DIR}/monitor.log" 2>&1 & )
sleep 1
echo "[start] 三件套已启动:"
echo " supervisor → ${LOG_DIR}/supervisor.log (自愈重建兜底)"
echo " monitor → ${LOG_DIR}/monitor.log (吞吐冻结前兆 + AUTO_HEAL)"
echo " 参数层 (EXEC_MODEL_TIMEOUT) 由 deploy_ds4_tp_ep.sh 注入, 本脚本不重复设置"
status
}
case "${1:-start}" in
start) start ;;
stop) stop_all ;;
status) status ;;
*) echo "用法: $0 [start|stop|status]"; exit 1 ;;
esac
11.4 关联监控 monitor.sh 调用方式
monitor.sh 是本方案的生成吞吐冻结前兆探测,与 supervisor 正交。watch 模式调用:
bash
BASE_URL=http://127.0.0.1:8002 GEN_MIN_TPS=1.0 GEN_STALL_SEC=30 \
AUTO_HEAL=1 bash scripts/monitor.sh watch &
- 当
Running>0但生成吞吐< GEN_MIN_TPS(默认 1.0 tok/s)持续GEN_STALL_SEC(默认 30s)→ 告警"worker 疑似卡死"(这正是宕机前 16:07 的前兆); AUTO_HEAL=1时自动回调 supervisor 重建;- 与 KV 告警正交:KV 高 + 吞吐冻结 = 卡死高概率信号。
11.5 使用说明
- 部署 :先改脚本顶部的
MODEL_HOST_PATH、IMAGE、CACHE_DIR、GPUS为你环境的实际值,再bash deploy_ds4_tp_ep.sh。 - 三件套托管 :
bash start_trio.sh(status/stop见脚本用法)。 - 脚本间依赖 :
supervisor.sh内部调用deploy_ds4_tp_ep.sh重建;start_trio.sh托管supervisor.sh+monitor.sh,参数层(EXEC_MODEL_TIMEOUT)由部署脚本注入,不在托管脚本重复设置。 - 可覆盖项 :所有关键参数均为环境变量(默认值即正文最佳实践),如
MAX_MODEL_LEN、MAX_NUM_SEQS、MAX_BATCHED_TOKENS、ENABLE_EP、EXEC_MODEL_TIMEOUT,无需改脚本即可调。