DeepSeek-V4.1 的权重里自带一个投机解码模块,叫 DSpark。
打开它只需要加一行启动参数。我们在 4 张 H200 上把它完整测了一遍:JSON 场景吞吐是基线的 3.8 倍 ,代码场景为 3.0 倍 ,散文场景则为 1.8 倍 。不同任务的草稿接受率从 17.6% 到 89.1% ,相差超过 5 倍------差距不在模型,而在于你让它生成什么。
过程中还遇到 两个会在本次版本组合中导致引擎崩溃的配置问题,报错原文和三轮复测记录都贴在下面,帮大家省点时间。
本次测试不是裸跑 vllm serve,全程在 GPUStack 上完成。为什么强调这一点,先看下一节。
部署与测试环境
| 项目 | 配置 |
|---|---|
| 部署栈 | GPUStack v2.2.3 + vLLM v0.1.dev20904 |
| 硬件环境 | 单机 8 × NVIDIA H200 143.8 GiB |
| 本次占用 | GPU 4--7,TP=4 |
| 模型 | DeepSeek-V4.1-Flash FP8(476 GB) |
| 权重量化 | FP8(W8A8) |
| KV Cache dtype | bfloat16 |
| 最大模型长度 | 1000K |
说明:本文数据仅代表上述硬件、版本与测试负载,不应直接外推到其他环境。所有加速倍率均由同场景下的 DSpark 组与基线组对比得出。
为什么这次实验用 GPUStack
GPUStack 在这套流程里提供了三个直接价值。
第一,显存与拓扑编排 。这台 8 卡机器上同时跑着 4 个推理服务(GLM-5.3-Flash、Qwen3.8-Flash、MiniMax-H3 等),本次实验要独占 GPU 4--7,采用 4 卡 TP=4。GPUStack 的 gpu_selector 让"指定并固定 4 张卡"变成模型定义里的一行配置,不用手工维护 CUDA_VISIBLE_DEVICES,也能避免资源冲突。
第二,低成本 A/B 对照 。验证投机解码收益的关键是 同一份权重、相同卡数,唯一差异是 DSpark 开关 。GPUStack 里停止不等于删除:replicas=0 会保留全部定义和 GPU 绑定,腾卡做对照组只需一条 PUT,测完改回 1 即可恢复。
第三,一行参数即可上线、回滚 。投机解码在 vLLM 里是一行 --speculative-config,在 GPUStack 里就是模型定义的一个字段------改完重建实例生效,回滚就是删掉这行。
下面所有操作,都是在 GPUStack 的模型定义层完成的,没有手写任何 Docker 命令。
一、先纠正一个认知:是 dspark,不是 mtp
权重目录里明晃晃躺着 mtp.0.*、mtp.1.*、mtp.2.*,直觉会让人写:
bash
--speculative-config '{"method": "mtp", ...}' # 错误写法
vLLM 会直接拒绝,报错信息给得很明白:
text
ValueError: DeepSeek V4.1 has no classic-MTP draft: its checkpoints ship
DSpark stages under mtp.* (main_proj/markov_head/confidence_head) and carry
no e_proj/h_proj/enorm/hnorm/hc_head weights. Use speculative method 'dspark'
instead of 'mtp'.
(源码位置:vllm/config/speculative.py,model_type == "deepseek_v41" 时硬拒。)
那三层 mtp.* 装的并不是经典 MTP 的 draft 头,而是 DSpark 的三阶段结构:
text
mtp.0.main_proj # 主投影
mtp.1.markov_head # 马尔可夫头
mtp.2.confidence_head # 置信度头(adaptive 用)
权重 config.json 里也有对应的 DSpark 专有字段:
json
{
"num_nextn_predict_layers": 3,
"dspark_block_size": 5,
"dspark_target_layer_ids": [37, 38, 39],
"dspark_markov_rank": 256,
"dspark_noise_token_id": 128799,
"dspark_n_routed_experts": 128,
"dspark_num_experts_per_tok": 3
}
注意 dspark_block_size = 5:这个检查点按 5-token 块训练,因此本文保持 num_speculative_tokens=5;后文也会展示在本次版本中将其改为 10 后出现的问题。
正确的配置只有一行:
diff
--tokenizer-mode=deepseek_v41
--tensor-parallel-size=4
--tool-call-parser=deepseek_v41
--enable-auto-tool-choice
--reasoning-parser=deepseek_v41
--mm-encoder-tp-mode=data
--max-model-len=1000K
--gpu-memory-utilization=0.96
--safetensors-load-strategy=prefetch
+ --speculative-config '{"method": "dspark", "model": "/model/DeepSeek-V4.1-Flash-FP8"}'
num_speculative_tokens 不用写,默认值就是 5。
二、怎么确认它真的生效了
不会看日志就发结论,是压测最容易掉的坑。我们用了三个办法交叉确认。
1. 引擎启动日志
text
DSpark draft model loaded: 97 params
SpeculativeConfig(method='dspark', num_spec_tokens=5)
2. vLLM 原生 metrics 计数器(最硬的证据)
bash
curl -s http://<host>:<port>/metrics | grep spec_decode
text
vllm:spec_decode_num_drafts_total 1328
vllm:spec_decode_num_draft_tokens_total 6640
vllm:spec_decode_num_accepted_tokens_total 2704
vllm:spec_decode_num_accepted_tokens_per_pos_total{position="0"} ...
vllm:spec_decode_num_accepted_tokens_per_pos_total{position="4"} ...
per_pos 计数器从 0 到 4 全部有值,说明草稿的每个位置都在被真实验证,并非"配了但没启用"。
3. 吞吐对照
这里有个方法论要点:基线必须使用 同一份权重、相同的 TP=4 ,唯一差异是 DSpark 开关。换模型或换卡数后得出的倍率没有可比性。本文的基线与 DSpark 组严格隔离了变量。
三、草稿接受率为什么相差 5 倍:关键在文本可预测性
这是 整个实验最有价值的发现。
在本次测试中,投机解码收益与文本的可预测性高度相关。DSpark 的草稿模型每步并行预测 5 个候选 token,能否命中取决于目标模型接下来的实际输出:
| 场景 | 草稿接受率 | 吞吐(tok/s) | 相对基线 |
|---|---|---|---|
| JSON | 89.1% | 468.7 | 3.8× |
| 重复结构 | 82.8% | 448.1 | 3.6× |
| 数学推理 | 70.1% | 397.0 | 3.2× |
| 代码 | 62.7% | 365.5 | 3.0× |
| 散文 | 27.5% | 218.6 | 1.8× |
| 高熵创作 | 17.6% | 170.2 | 1.4× |
表中的 "相对基线"均为同一场景内的对照结果;不同场景之间的绝对吞吐不宜直接横向比较。
从 17.6% 到 89.1%,草稿接受率相差约 5.1 倍。
块内 5 个位置的边际接受率也印证了这一点(代码 vs 散文):
text
代码:pos0 81.1% → pos1 78.3% → pos2 78.0% → pos3 78.2% → pos4 76.7%(平缓)
散文:pos0 58.0% → pos1 57.6% → pos2 43.2% → pos3 38.9% → pos4 33.3%(陡峭)
低熵文本里,5 个草稿位置的接受率几乎是平的;高熵文本里,第 3 个位置之后开始断崖式下跌。
结论很直接:DSpark 更适合结构化、可预测性强的输出场景。如果业务以创意写作为主,仍可能获得一定收益,但不应期待接近 4 倍的加速。
四、一个反直觉的发现:温度几乎不影响草稿接受率
"采样随机性会拉低草稿命中率"是个很自然的直觉------温度越高,目标模型的输出越随机,草稿越难猜中。
但在本次散文负载与版本组合下,实测并不支持这个直觉。
| 温度 | 吞吐(tok/s) | 草稿接受率 |
|---|---|---|
| 0.0 | 211.1 | 26.9% |
| 0.3 | 210.4 | 26.9% |
| 0.7 | 213.0 | 27.2% |
| 1.0 | 224.9 | 29.5% |
温度从 0 拉到 1.0,草稿接受率只变化了 2.6 个百分点,甚至略微上升。
我们倾向于这样解释:DSpark 的草稿以块并行方式生成,一次前向产生 5 个位置;它并非简单沿贪心路径逐 token 外推,因此温度对命中率的影响可能被结构性削弱。这里是基于实验现象的推测,而不是机制层面的定论。至少在 DeepSeek-V4.1 与本次 vLLM 版本组合上,没有必要为了提高投机收益而刻意降低温度。
五、边界实测:并发、长上下文、输出长度
并发:16 并发无断崖
| 并发数 | 总吞吐(tok/s) | 单请求 P50(s) |
|---|---|---|
| 1 | 276.3 | 0.93 |
| 2 | 406.5 | 1.21 |
| 4 | 638.8 | 1.57 |
| 8 | 961.0 | 1.99 |
| 16 | 1337.6 | 2.88 |
16 并发下,总吞吐仍在持续增长,没有出现断崖式下降。对比来看,我们在 Qwen3.8 上测到过 20 并发时的性能断崖,V4.1 的 DSpark 在本次并发范围内扩展性更好。
长上下文:收益明显衰减
| 上下文长度 | DSpark(tok/s) | 基线(tok/s) | 提升 |
|---|---|---|---|
| ≈2.2K | 195.4 | 121.7 | +61% |
| ≈8.7K | 258.0 | 119.5 | +116% |
| ≈21.7K | 238.7 | 118.9 | +101% |
| ≈30K | 142.3 | 93.5 | +52% |
| ≈120K | 65.2 | 53.0 | +23% |
整体趋势是 上下文越长,DSpark 收益越小 :30K 时提升约 52% ,120K 时降至约 23%。其中 2.2K 到 8.7K 的非单调波动也说明,吞吐仍会受到请求内容、批处理状态等因素影响。
原因不复杂:长上下文下,attention 逐渐成为主导成本;投机解码主要减少 decode 阶段的前向次数,却不能消除每个 token 的 attention 开销。
输出长度:越长收益略稳
max_tokens |
DSpark(tok/s) | 基线(tok/s) | 比值 |
|---|---|---|---|
| 16 | 205.9 | 106.2 | 1.94× |
| 256 | 232.0 | 121.4 | 1.91× |
| 1024 | 245.5 | 121.9 | 2.01× |
输出越长,收益越稳定,因为草稿的摊销成本被摊薄了。
六、两个版本相关的坑:在本环境都会触发崩溃
坑 1:num_speculative_tokens=10
既然块大小是 5,那多叠一块(nst=10)不就能多赚?我们试了。
结果:4 个 TP rank 全部崩溃。
text
(Worker_TP0) torch.AcceleratorError: CUDA error: device-side assert triggered
(Worker_TP1) torch.AcceleratorError: CUDA error: device-side assert triggered
(Worker_TP2) torch.AcceleratorError: CUDA error: device-side assert triggered
(Worker_TP3) torch.AcceleratorError: CUDA error: device-side assert triggered
(EngineCore) ERROR [multiproc_executor.py:314] Worker proc VllmWorker-2 died
unexpectedly (exit code: None), shutting down executor.
实例 restart_count 持续增长:1 → 2 → 4 → 5,进入崩溃重启循环。
坑 2:enable_adaptive_verification=true
权重里有 mtp.2.confidence_head.proj.weight,看起来 adaptive 路径是准备好的,引擎也确实接受了这个参数(出现在 non-default args 里)。
结果:一样崩溃。
text
restart_count: 1 → 2 → 3 → 4 (持续)
torch.AcceleratorError: CUDA error: device-side assert triggered
怎么确认是配置问题,不是环境问题
回滚到默认 DSpark 配置(nst=5、不开 adaptive),跑了三轮复测:
text
run1: 散文 236.5 / 代码 365.1 / JSON 348.5 restart_count=0
run2: 散文 237.7 / 代码 354.7 / JSON 365.1 restart_count=0
run3: 散文 259.6 / 代码 357.4 / JSON 363.2 restart_count=0
默认 DSpark 配置的结果 可复现且零崩溃,说明崩溃由上述配置变更触发,而非机器、网络或显存波动。
我们的判定:在本次 vLLM v0.1.dev20904 + 4 × H200 + FP8 组合下,nst>5 与 adaptive verification 存在兼容性问题 。这个结论不能外推到所有 vLLM 版本与硬件;新版已将 adaptive verification 纳入官方推荐配置,升级后应重新验证。读者如果试出不同结果,欢迎在留言区交流。
七、为什么本环境建议保持 nst=5
从 nst=5 的块内数据看,越靠后的草稿位置,边际贡献越小。代码场景的计算结果如下:
text
pos0 条件接受 64.7% 边际 0.647 tok ← 收益主体
pos1 条件接受 59.1% 边际 0.382 tok
pos2 条件接受 51.0% 边际 0.195 tok
pos3 条件接受 60.3% 边际 0.118 tok
pos4 条件接受 65.9% 边际 0.078 tok ← 几乎白送
第 5 个草稿 token 每步只贡献 0.078 个 token ,而该位置的验证仍会消耗计算资源。散文场景更明显,pos4 的条件接受率降至 18%--33% 。结合 nst=10 在本环境中触发崩溃的结果,没有继续增大块长度的现实收益。
因此,在本次版本与硬件组合下,nst=5 是已经验证稳定的甜点位,建议保持默认。升级 vLLM 或更换硬件后,可以重新压测,不必把这个结论当作永久限制。
八、代价:KV 池缩水 15.2%
| 项目 | 未启用 DSpark | 启用 DSpark |
|---|---|---|
| KV 池(tokens) | 18,294,057 | 15,505,791 |
| 变化 | --- | −15.2% |
此外,每张卡约增加 1.85 GB 的草稿权重常驻显存。
这笔账要算清楚。如果场景是"高并发 + 长上下文 + 需要大 KV 池",15.2% 的 KV 损失可能抵消 DSpark 带来的收益;反过来,对以结构化输出为主的业务,这笔交换通常更划算。
九、在 GPUStack 上落地:五步
如果你的环境就是 GPUStack,上面所有操作可以归结为模型定义层的几条命令,不需要手写 Docker 命令。
第一步:建模型定义,锁死 GPU
把权重放到模型目录(本环境为 NFS 挂载点 /model/),然后在 GPUStack 里建模型定义。关键是 gpu_selector 把实验锁死在 4 张卡上:
json
{
"name": "deepseekv4.1",
"source": "local_path",
"local_path": "/model/DeepSeek-V4.1-Flash-FP8",
"backend": "vLLM",
"replicas": 1,
"enable_model_route": true,
"gpu_selector": {"gpu_ids": ["o03:cuda:4", "o03:cuda:5", "o03:cuda:6", "o03:cuda:7"]},
"backend_parameters": [
"--tensor-parallel-size=4",
"--max-model-len=1000K",
"--gpu-memory-utilization=0.96",
"--tokenizer-mode=deepseek_v41",
"--reasoning-parser=deepseek_v41",
"--tool-call-parser=deepseek_v41",
"--enable-auto-tool-choice"
]
}
在本文使用的 GPUStack v2.2.3 API 流程中,enable_model_route: true 不能漏 。漏掉后模型仍会运行,但不会出现在模型广场和 Playground 中,也不会报错。如果已经漏建,可通过 POST /v2/model-routes 补充,无需重启服务。
第二步:上 DSpark,只改一行
在 backend_parameters 里追加:
json
"--speculative-config={\"method\":\"dspark\",\"model\":\"/model/DeepSeek-V4.1-Flash-FP8\"}"
注意 JSON 嵌套转义:backend_parameters 是字符串数组,内部 JSON 的引号需要转义。追加后的末尾片段应类似:
json
"backend_parameters": [
"--enable-auto-tool-choice",
"--speculative-config={\"method\":\"dspark\",\"model\":\"/model/DeepSeek-V4.1-Flash-FP8\"}"
]
改完参数实例不会自动重建,删掉实例让它按 replicas 补一个即可:
bash
# 拿到实例 id
curl -H "Authorization: Bearer $KEY" http://127.0.0.1:3880/v2/models/7/instances
# 删实例 -> 自动按新参数重建
curl -X DELETE -H "Authorization: Bearer $KEY" http://127.0.0.1:3880/v2/model-instances/<inst_id>
第三步:腾卡做对照(本文最关键的一步)
严格对照需要 "同权重 + 同卡数 + 唯一差异"。我们只有 8 张卡,做法是创建两个独立的模型定义,共享同一份权重:基线组使用 4 卡且不启用 DSpark,实验组同样使用 4 卡并启用 DSpark。
GPUStack 里停止的正确姿势是把 replicas 置 0:模型定义、参数、GPU 绑定全部保留,实例行消失、约 30 秒释放显存,恢复改回 1:
bash
# 停(释放 GPU,保留全部定义)
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"replicas": 0}' http://127.0.0.1:3880/v2/models/7
# 恢复
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"replicas": 1}' http://127.0.0.1:3880/v2/models/7
一个小提醒:GPUStack 的实例 state 枚举里 没有 STOPPED ,停止后实例行直接被移除,replicas=0 才是判据。别去等一个不存在的状态。
第四步:读指标验证收益
GPUStack 会把 vLLM 的原生 /metrics 透出来:
bash
curl http://<worker>:<port>/metrics | grep "vllm:spec_decode"
# vllm:spec_decode_num_drafts_total (草稿轮数)
# vllm:spec_decode_num_draft_tokens_total (草稿 token 数)
# vllm:spec_decode_num_accepted_tokens_total (被接受的 token 数)
# vllm:spec_decode_num_accepted_tokens_per_pos_total (分位置, 带 position label)
这些都是累计计数器,必须通过两次采样求差。直接读取单次值计算接受率,会混入此前的历史请求。
bash
accept_rate = (accepted_tokens_2 - accepted_tokens_1) / (draft_tokens_2 - draft_tokens_1)
本文综合负载的实测接受率为 47.7% 。作为本环境中的经验参考,低于 35% 时值得检查草稿质量,高于 55% 则表现较好;这不是 vLLM 的通用告警阈值。
第五步:回滚
删掉那一行 --speculative-config,重建实例。定义与 GPU 绑定全在,一条 PUT 的事。
一个附带提醒 :本环境 不建议用 ignore_eos: true 做压测。实测中该参数曾触发引擎崩溃,实例自动重启约 4 分钟后才恢复。
一个额外的好消息 :开启 DSpark 后,模型广场依然可见、可调用 。GPUStack 的路由层不受投机解码影响,enable_model_route 生成的路由记录只与模型是否 ready 有关。我们随后也为线上主力模型 GLM-5.3-Flash 启用了其对应的投机解码配置;/v1/models 与模型广场均正常列出,网关端到端调用返回 200。不必担心启用投机解码后模型会从广场消失。
两个调用细节 :带 reasoning 的模型,输出走 reasoning 字段,content 常为 null,取值时 msg.get("content") or msg.get("reasoning") or "" 兜一下;不想看思维链,请求里传 "chat_template_kwargs": {"thinking": false}。
总结
DeepSeek-V4.1 的 DSpark 值得开,但要挑场景。
适合的场景:
- 结构化输出密集:JSON API、数据抽取、代码生成
- 短到中等上下文
- 输出长度偏长
不适合的场景:
- 创意写作、高熵文本为主
- 超长上下文占主导
- KV 池本来就吃紧
配置上记住三件事:
method是dspark,不是mtp。- 在本文的版本与硬件组合中,
num_speculative_tokens保持默认值 5。 - 在本文使用的 vLLM 版本中,不启用
enable_adaptive_verification;升级后应重新验证。
唯一需要加的一行:
bash
--speculative-config '{"method":"dspark","model":"<权重路径>"}'
在 GPUStack 上,以上操作只是向模型定义的 backend_parameters 追加一行:删除并重建实例后生效,回滚时再删除该行。配合 replicas=0 的"停而不删",可以低成本完成对照实验;生产灰度是否无中断,还取决于路由与副本的具体安排。