DeepSeek V4 Flash 本地部署验收:拿到手先验这 5 个数(附脚本)
DeepSeek V4 Flash 本地部署验收:拿到手先验这 5 个数(附脚本)
本文部分内容由 AI 辅助整理,所有配置、命令、数据均标注了出处;脚本为本人在本机真实运行并贴出原始输出。
拿到一台装好的 DeepSeek V4 Flash 本地服务,你怎么确认它是好的?
我说的是那种最磨人的场景:服务照常启动、日志一行报错都没有、/v1/models 也通,可一跑起来速度只有预期一半,或者首轮对话就串角色、把 system 内容吐出来。这种「看着正常其实不对」的部署,比跑不起来更难排查——因为没有任何东西提示你有问题。
我在这套「2 台 DGX Spark 跑 DeepSeek V4 Flash 0731」的部署上把这件事想明白了:装好不等于能用,能用是要验收的。 今天不讲怎么搭,讲拿到装好的机器,先查哪 5 个数。这 5 个数从配置一路查到功能,每条都给了「在哪查、期望值多少、不对怎么办」。文末附一个自检脚本,把这 5 个数做成能跑的检查。
一、先说清楚:哪台机器、哪份数据
模型是 deepseek-ai/DeepSeek-V4-Flash-0731,稀疏 MoE,总参 2840 亿、每次激活 130 亿,上下文 100 万 token,纯文本。权重来自官方仓,48 个 safetensors 分片、约 167 GB。
机器是两台 DGX Spark(单台 128 GB 统一内存,合计 256 GB),网线直连组成 TP=2,跑的是 vLLM 的 DSpark 镜像(ghcr.io/anemll/dspark-vllm-gx10:0.1.1),权重用官方 FP8,没有自行量化。
这篇里所有 tok/s、KV 池、配置参照,都是我们自己机器上实测的,只代表这一套环境、这一个模型版本、这一组参数,不是行业通用值。 你换镜像、换 util、换网络,期望值就要跟着变——下面的数只是参照,不是标准。
顺带补一句上一期的坑:上篇那批数的出处标注得不够准确,其实它们就是我们自己实测的。这篇统一按「我们自己机器上实测」的口径来写,已经发出去的就不回头改了。
先说两个反直觉的前提,它们决定了为什么要验收:
- 配置元数据 ≠ 真能 serve。
/v1/models里报max_model_len: 1048576,只代表配置接受了这个值,不代表它真能处理 100 万 token 的请求。官方配方自己专门跑过一个 90 万 token 的验收请求,原话是「beyond configuration metadata alone」——光看配置元数据不算数。 - 权重加载成功 ≠ 编码兼容。权重能加载、能出文字,不代表多轮对话的角色边界、工具调用行为是对的。这是两个不同层面的事。
二、5 个数速查表
| # | 查什么 | 在哪查 | 我方环境的期望值 |
|---|---|---|---|
| 1 | 配置真生效 | .env + 启动日志 + /v1/models |
max_model_len=1048576,日志没有 Unknown vLLM environment variable |
| 2 | 权重完整 | model.safetensors.index.json |
48 分片、约 167 GB,head 和 worker 各一份,含 encoding 包 |
| 3 | KV 池分配 | 启动日志 grep | 18.08 GiB / 2,493,464 tokens / 2.38x(util 0.835 时) |
| 4 | 解码基线 | 流式请求测 decode | 单并发 84.89 tok/s,八并发 284.80 |
| 5 | 边界兼容 | 多轮角色 + 工具调用 + 900K 请求 | 干净通过 |
下面逐个说。
三、第 1 个数:配置真生效了吗
这是最容易翻车的一步,因为**.env 里写了,不等于镜像真的认**。
我踩过的坑:vLLM serve 会校验进程环境里所有 VLLM_* 开头的键,遇到它不认识的,打一行
Unknown vLLM environment variable detected: VLLM_…
然后照常启动。也就是说,把一套网上教程里抄来的 VLLM_DSPARK_* 开关写进 .env,镜像不认,日志刷几条警告,服务该跑还是跑——但那些开关一个都没生效。速度掉一半、行为不对,常从这里来。
先确认两件事:
- 启动日志里有没有
Unknown vLLM environment variable:
docker compose --env-file .env.dspark -f docker-compose.dspark.yml logs vllm-dspark \
| grep -E "Unknown vLLM environment variable"
有输出,就是有键写了白写。要么删掉,要么先确认它属于当前镜像。
- 镜像到底认哪些键,直接进容器里列(官方配方提供的核对方法):
docker run --rm --entrypoint python3 ghcr.io/anemll/dspark-vllm-gx10:0.1.1 - <<'PY'
import pathlib, vllm
ns = {}
exec(compile((pathlib.Path(vllm.__file__).parent / "envs.py").read_text(), "envs.py", "exec"), ns)
for k in sorted(ns["environment_variables"]):
if any(s in k for s in ("BREAKABLE", "B12X_MOE", "FLASHINFER_SAMPLER")):
print(k)
PY
- 最后用服务自己说的话验证:
/v1/models返回的max_model_len是多少。
curl -fsS http://127.0.0.1:8888/v1/models
我方这套配置的期望值:
| 键 | 期望值 | 为什么 |
|---|---|---|
MAX_MODEL_LEN |
1048576 |
1M 上下文天花板 |
MAX_NUM_SEQS |
6 |
并发上限 |
MAX_NUM_BATCHED_TOKENS |
8192 |
单批次上限 |
GPU_MEMORY_UTILIZATION |
0.80 |
显存利用率,直接决定 KV 池大小 |
MTP_NUM_TOKENS |
5 |
DSpark 投机解码块长 |
VLLM_USE_BREAKABLE_CUDAGRAPH |
0 |
显式关掉较慢的 breakable 路径 |
HF_HUB_OFFLINE |
1 |
权重缓存补全后离线加载 |
VLLM_USE_BREAKABLE_CUDAGRAPH=0 这条单独说:镜像默认不设就会自动走较慢的 breakable 路径,单流 decode 会直接掉一截(这个对比上篇贴过,不重复)。这一项不设,第 4 个数的基线可能直接测出「只有七成」。
四、第 2 个数:权重齐不齐
DeepSeek V4 Flash 0731 的权重是 48 个 safetensors 分片(model-00001-of-00048 到 model-00048-of-00048),合计约 167 GB,索引在 model.safetensors.index.json。
两个容易漏的地方:
- head 和 worker 各要一份完整的。 TP=2 两个节点都要加载同一套权重,任何一边缺分片,另一边也会一起起不来。而且别让它在线重下——缓存不完整时在线下载,会把 worker 磁盘塞满。
encoding/encoding_dsv4.py必须包含在缓存里。 0731 这个 checkpoint 在官方仓没有 Jinjachat_template,推理靠这套编码层来组装角色和工具调用。缺它,多轮对话和工具调用会错,但权重照样加载成功——这就是第 5 个数要查的事。
怎么查:官方配方的下载脚本在下载完会做一次完整性核对。核对逻辑其实很简单,就是读 index 里的 weight_map,把声明的分片挨个查一遍在不在:
import json, os
index = json.load(open('model.safetensors.index.json', encoding='utf-8'))
shards = sorted({v for v in index['weight_map'].values()})
missing = [s for s in shards if not os.path.exists(s)]
print('声明的分片:', len(shards), ' 缺的:', len(missing))
if missing:
print('缺:', missing[:5], '… TP=2 会在这里起不来')
五、第 3 个数:KV 池给了多少
权重加载完之后,开机日志里有三行,真正决定「1M 上下文 + 几个人能并发」的就是它们:
Available KV cache memory: 18.08 GiB
GPU KV cache size: 2,493,464 tokens
Maximum concurrency for 1,048,576 tokens per request: 2.38x
Application startup complete.
(这是我方机器 util 0.835 时的实测。改 util、改 MTP,这三行都会变——官方文档的原话是「trust the live boot log」。)
怎么查:
docker compose --env-file .env.dspark -f docker-compose.dspark.yml logs vllm-dspark \
| grep -E "GPU KV cache size|Maximum concurrency"
三行分别是什么意思:
Available KV cache memory:扣除权重和 CUDA graph 之后,真正给 KV 缓存的内存。GPU KV cache size:换算成 token 数的 KV 池大小。它才是你的容量。Maximum concurrency for 1M-token request:同时跑满 1M 的请求能塞几个。2.38x 的意思是「两个多一点」——不是 bug,为什么是这样(max_model_len是天花板不是预留、多路短会话共用一个池),上篇讲过机制,这里不重复。
两个判断点:
- 池子明显小于量级(比如期望 2.5M 实际只有 1.9M):先核对
GPU_MEMORY_UTILIZATION和MTP_NUM_TOKENS。util 越高池越大,MTP 会吃掉一部分。 - 启动就 graph capture OOM:capture size =
MAX_NUM_SEQS × (MTP_NUM_TOKENS + 1)= 6 × 6 = 36,显存不够时把 util 从 0.80 降到 0.78 再试。
六、第 4 个数:解码基线正不正常
配置生效、权重齐全、池子对,才轮到测速度。这一条我特别强调口径:测的是 decode(逐字生成),不是首字延迟。首字延迟由 prefill 决定,prompt 越长越离谱,那是另一笔账(上篇有完整 sweep 表)。
怎么测:对着 OpenAI 兼容接口打流式请求,数 TTFT 之后每秒吐多少 token。官方配方的 benchmark 脚本有个关键细节——每条请求的 prompt 开头带一个唯一 nonce,让每个请求的第一个 cache block 都不一样,防止前缀缓存复用把 prefill 测假了。
我方这组参数下的实测参照(decode 阶段,官方 FP8 权重):
| 负载 | 我方实测 |
|---|---|
| 单并发 | 84.89 tok/s |
| 八并发(合计) | 284.80 tok/s |
| 同套环境、另一种测法 | 单流 82 / 峰值 95 / 3 会话 135 |
判断标准很简单:同量级算正常。 差 30% 以内通常是配置差异;差一个数量级基本是没生效或走了降级路径——回头查第 1 个数(Unknown env warning、breakable graph)和两节点之间的网(TP=2 每一层都要过 all-reduce)。
低于 60 tok/s 要警惕。60 这个数是官方配方旧 profile 里的诊断值,正常的 1M NVFP4 路径不该掉到这里。
七、第 5 个数:边界兼容过没过
前 4 个数都过了,只能说明「算得快」,还不能说明「行为对」。
权重能加载、能吐字,不代表多轮对话的角色边界是对的。0731 的编码层跟 preview 不一样,官方文档里那句话很直白:权重加载成功本身,不能证明编码兼容。 要真验,就验这几件事:
- system/user 角色边界:system 里立了身份,回复里不能把 system 内容吐出来。
- reasoning 单独返回:思考过程和最终回答分开。
- 工具调用:
deepseek_v4的 tool parser 能不能吐出合法的 OpenAI function 参数。 - 多轮:连续几轮对话,角色不串。
怎么验:对着接口发一条带 system + 两轮 user 的消息,看回复是否干净(第九节脚本里的第 5 项就是干这个的)。
再往上一个台阶是极限验收:官方配方自己跑过一次 90 万 token 的请求(899,994 token),确认 1M 档位不是「配置上写着 1M」而是「真能塞进去」。结论是能完成、角色边界干净、工具参数合法。这个测试一次要等十几分钟,不是每台机器都值得跑——值不值,我放在第十二节。
最后一句:如果直连接口是干净的,接上 agent 就开始乱码、循环、把工具 XML 漏出来,先查 agent 那层的回退和会话回放,别怀疑权重。这个坑官方文档里明写过。
八、验收不达标,多半是,怎么解决
| 验收时看到 | 多半是 | 怎么解决 |
|---|---|---|
日志刷 Unknown vLLM environment variable |
镜像不注册这个键,写了白写 | 用第三节的容器内命令确认键是否注册,不注册就删掉或换镜像 |
/v1/models 返回 max_model_len: 512000 |
本地 .env 被改过,不是默认 |
除非有意降上下文,否则改回 1048576 |
| 期望 2.5M 池子,实际只有 1.9M | GPU_MEMORY_UTILIZATION 或 MTP_NUM_TOKENS 不同 |
核对这两项,util 越高池越大 |
| 启动就 graph capture OOM | capture size=36 太大 | util 0.80 → 0.78 |
| 速度只有基线一半 | 走了 breakable 慢路径 / 键没生效 | 显式 VLLM_USE_BREAKABLE_CUDAGRAPH=0,再查 Unknown env warning |
| 权重分片缺 | 缓存没下全 | 补全 head 和 worker,再设 HF_HUB_OFFLINE=1 |
| 直连干净、agent 乱码 | agent 层静默回退/会话回放 | 清掉 fallback 列表,先验 harness 再碰权重 |
| 多轮串角色 / system 泄漏 | 编码层问题 | 先确认 encoding/encoding_dsv4.py 在位(第 2 个数) |
九、脚本:把这 5 个数做成能跑的检查
h19_verify_2spark.py,零依赖,只用标准库。前 3 个数在你机器上直接读文件/目录就能验;第 4、5 个数对着真实服务打请求(加 --base-url 即可)。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
h19_verify_2spark.py —— 拿到 DeepSeek V4 Flash 0731 的双机 DGX Spark 部署,
按「5 个数」逐项验收:配置、权重、KV 池、解码基线、边界兼容。
验收的 5 个数(对应正文三~七节):
1. 配置是否真生效 .env 期望值 + Unknown env warning + /v1/models 的 max_model_len
2. 权重是否完整 model.safetensors.index.json 全分片 + encoding 包
3. KV 池给了多少 启动日志三行(可用 KV 内存 / KV 池 token 数 / 满上下文并发倍数)
4. 解码基线速度 可选 --base-url 对真实服务打一个流式请求,测 decode tok/s
5. 边界兼容 可选 --base-url 发一条多轮角色请求,验角色边界干净
分层说明(别搞混了):
[本机] 这台机器上真算的:读你给的文件/目录、真发的请求
[期望] 我方环境的实测参照值(来源见 EXPECT_SOURCE;换镜像/换 util/换网络都会变)
[引用] 仓库公开的可复现方法与配置(MiaAI-Lab/DeepSeek-v4-Flash-DSpark-2x-DGX-Spark)
零依赖,只用标准库。
用法:
# 只做本地三项(配置/权重/启动日志)
python3 h19_verify_2spark.py --env .env.dspark \
--weights-dir /cache/huggingface/.../snapshots/<rev> \
--bootlog boot.log
# 连上服务补第 4、5 个数
python3 h19_verify_2spark.py --bootlog boot.log \
--base-url http://127.0.0.1:8888/v1 --model deepseek-v4-flash-0731
退出码:0=全部 PASS;1=有 FAIL;2=只有 WARN(打印仍完整)。
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.request
import urllib.error
# ── [期望] 我方环境的实测参照值(仅代表该环境、该模型版本与该组参数,换环境会变)
EXPECT = {
'env': {
'MAX_MODEL_LEN': '1048576',
'MAX_NUM_SEQS': '6',
'MAX_NUM_BATCHED_TOKENS': '8192',
'GPU_MEMORY_UTILIZATION': '0.80',
'MTP_NUM_TOKENS': '5',
'VLLM_USE_BREAKABLE_CUDAGRAPH': '0',
'HF_HUB_OFFLINE': '1',
},
'weights_shards': 48, # HF 权重仓 model-00001..00048-of-00048
'weights_gb': (150.0, 175.0), # 我方下载实测约 167 GB
'kv_avail_gib': (16.0, 22.0), # 我方 boot 实测 18.08 GiB
'kv_pool_tokens': (2000000, 3200000), # 我方 2,493,464;仓库历史记录 1.9M~3.2M
'max_conc': (1.8, 3.2), # 我方 2.38x
'decode_single_tps': (60.0, 130.0), # 我方 docx 口径 84.89 / 单流 82
}
EXPECT_SOURCE = ('我方 2×DGX Spark 实测(docx 发布口径 84.89 单并发 / 284.80 八并发);'
'KV 池与并发倍数区间来自该部署公开记录')
STATUS = {'PASS': 0, 'WARN': 1, 'FAIL': 2}
results = []
def report(check, status, line):
results.append((check, status))
mark = {'PASS': '✅', 'WARN': '⚠️', 'FAIL': '⛔'}[status]
print(' %s [%s] %s' % (mark, status, line))
def parse_env(path):
""".env.dspark 的宽容解析:跳过注释/空行/导出前缀。"""
env = {}
try:
with open(path, encoding='utf-8') as f:
for raw in f:
line = raw.strip()
if not line or line.startswith('#'):
continue
line = line[len('export '):] if line.startswith('export ') else line
if '=' not in line:
continue
k, v = line.split('=', 1)
env[k.strip()] = v.strip().strip('"').strip("'")
except OSError as e:
print(' ⛔ [本机] 读不到 %s:%s' % (path, e))
return None
return env
def check_config(env_path, expect):
print('\n① 配置是否真生效 [本机] 读 %s' % env_path)
env = parse_env(env_path)
if env is None:
return STATUS['FAIL']
n_miss, n_mismatch = 0, 0
for k, want in expect.items():
have = env.get(k)
if have is None:
n_miss += 1
report('config', 'WARN', '缺少 %s(服务会用它自己的默认值)' % k)
elif have != want:
n_mismatch += 1
report('config', 'WARN', '%s=%s,期望 %s' % (k, have, want))
else:
print(' ✅ [期望] %s=%s' % (k, want))
if n_miss == 0 and n_mismatch == 0:
report('config', 'PASS', '关键项全部对齐期望')
return STATUS['PASS']
report('config', 'WARN' if n_miss == 0 else 'WARN',
'%d 项缺省 / %d 项与期望不符;另请去启动日志里 grep '
'「Unknown vLLM environment variable」——镜像不注册的键写了也是空操作'
% (n_miss, n_mismatch))
return STATUS['WARN']
def check_weights(weights_dir, expect):
print('\n② 权重是否完整 [本机] 读 %s' % weights_dir)
index_path = os.path.join(weights_dir, 'model.safetensors.index.json')
if not os.path.exists(index_path):
report('weights', 'FAIL', '没有 model.safetensors.index.json,这不像完整的 HF 缓存目录')
return STATUS['FAIL']
with open(index_path, encoding='utf-8') as f:
index = json.load(f)
shards = sorted({v for v in index.get('weight_map', {}).values()})
n_expect = expect['weights_shards']
missing = [s for s in shards if not os.path.exists(os.path.join(weights_dir, s))]
print(' [本机] index 声明 %d 个分片文件,实际存在 %d 个,缺 %d 个'
% (len(shards), len(shards) - len(missing), len(missing)))
meta = index.get('metadata') or {}
if meta.get('total_size'):
print(' [引用] index 声称权重总量约 %.1f GB(我方下载实测 %d~%d GB)'
% (meta['total_size'] / 1e9, expect['weights_gb'][0], expect['weights_gb'][1]))
if missing:
report('weights', 'FAIL', '缺少 %s 等 %d 个分片 —— TP=2 会在这里起不来'
% (missing[0], len(missing)))
return STATUS['FAIL']
if len(shards) != n_expect:
report('weights', 'WARN', '分片数 %d,我方该模型是 %d(换 revision 会变,先核 revision)'
% (len(shards), n_expect))
else:
print(' [期望] 分片 %d,全齐' % n_expect)
if os.path.exists(os.path.join(weights_dir, 'encoding', 'encoding_dsv4.py')):
print(' ✅ [本机] encoding/encoding_dsv4.py 在位(0731 没有 Jinja chat_template,靠它)')
else:
report('weights', 'FAIL', '缺少 encoding/encoding_dsv4.py —— 推理角色/工具调用会错')
return STATUS['FAIL']
# head 和 worker 各一份:提示,不强判(只给一个目录时无从查第二份)
print(' ⚠️ 提醒:head 和 worker 必须各有一份完整缓存,脚本只验了手头这一个目录')
report('weights', 'PASS', '%d 个分片全齐 + encoding 包在位' % len(shards))
return STATUS['PASS']
def parse_bootlog(text):
out = {}
m = re.search(r'Available KV cache memory:\s*([\d,.]+)\s*GiB', text)
if m:
out['avail_gib'] = float(m.group(1).replace(',', ''))
m = re.search(r'GPU KV cache size:\s*([\d,]+)\s*tokens', text)
if m:
out['pool_tokens'] = int(m.group(1).replace(',', ''))
m = re.search(r'Maximum concurrency for [\d,]+ tokens per request:\s*([\d.]+)x', text)
if m:
out['max_conc'] = float(m.group(1))
return out
def check_bootlog(bootlog_path, expect):
print('\n③ KV 池给了多少 [本机] 解析 %s' % bootlog_path)
try:
with open(bootlog_path, encoding='utf-8') as f:
parsed = parse_bootlog(f.read())
except OSError as e:
report('bootlog', 'FAIL', '读不到:%s' % e)
return STATUS['FAIL']
if not parsed:
report('bootlog', 'FAIL', '没解析出三行(grep 的是 GPU KV cache size / '
'Maximum concurrency 那两行,你的日志格式可能不同)')
return STATUS['FAIL']
if 'avail_gib' in parsed:
lo, hi = expect['kv_avail_gib']
ok = lo <= parsed['avail_gib'] <= hi
report('bootlog', 'PASS' if ok else 'WARN',
'Available KV cache memory = %.2f GiB(期望 %.0f~%.0f,我方 18.08)'
% (parsed['avail_gib'], lo, hi))
if 'pool_tokens' in parsed:
lo, hi = expect['kv_pool_tokens']
ok = lo <= parsed['pool_tokens'] <= hi
report('bootlog', 'PASS' if ok else 'WARN',
'GPU KV cache size = %s tokens(期望 %s~%s,我方 2,493,464;'
'util 越高池越大)' % (format(parsed['pool_tokens'], ','),
format(lo, ','), format(hi, ',')))
if not ok and parsed['pool_tokens'] < lo:
print(' 池子明显偏小 → 核对 gpu_memory_utilization 与 MTP_NUM_TOKENS;'
'若启动就 graph capture OOM,把 util 0.80→0.78 再试')
if 'max_conc' in parsed:
lo, hi = expect['max_conc']
ok = lo <= parsed['max_conc'] <= hi
report('bootlog', 'PASS' if ok else 'WARN',
'Maximum concurrency for 1M-token request = %.2fx(期望 %.1f~%.1f,我方 2.38x)'
% (parsed['max_conc'], lo, hi))
return STATUS['PASS']
def _request(url, body, timeout=3600):
req = urllib.request.Request(
url, data=json.dumps(body).encode(), headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.load(resp)
def api_models(base_url, model):
"""查 /v1/models,确认 max_model_len 真生效(不只是 .env 里写了)。
/v1/models 是 GET 端点;POST 在真 vLLM 上会 405,必须显式走 GET。
"""
print('\n④ 服务侧验证 [本机] GET %s/models' % base_url)
try:
req = urllib.request.Request('%s/models' % base_url,
headers={'Content-Type': 'application/json'},
method='GET')
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.load(resp)
except Exception as e:
report('api', 'FAIL', '/v1/models 不通:%s' % e)
return STATUS['FAIL'], None
entries = {m['id']: m for m in data.get('data', [])}
info = entries.get(model) or entries.get('deepseek-v4-flash-0731')
if not info:
report('api', 'WARN', '服务里没有模型 %s,现有:%s' %
(model, list(entries)))
return STATUS['WARN'], None
mml = info.get('max_model_len')
if mml == 1048576:
print(' ✅ [本机] max_model_len = %s(.env 的值真生效了)' % format(mml, ','))
else:
report('api', 'WARN', 'max_model_len = %s,期望 1,048,576 —— 本地 .env 被改过?'
% format(mml or 0, ','))
return STATUS['PASS'] if mml == 1048576 else STATUS['WARN'], info
def api_decode(base_url, model, expect, output_tokens=256):
"""流式测 decode 基线:TTFT 之后的生成速度。请求带温度 0 求可复现。"""
body = {
'model': model, 'stream': True,
'messages': [{'role': 'user', 'content': 'Return exactly 64 numbered lowercase '
'English words, then stop.'}],
'temperature': 0, 'max_tokens': output_tokens,
}
req = urllib.request.Request(
'%s/chat/completions' % base_url, data=json.dumps(body).encode(),
headers={'Content-Type': 'application/json'})
started = time.perf_counter()
first = None
pieces = []
try:
with urllib.request.urlopen(req, timeout=600) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith('data: ') or line == 'data: [DONE]':
continue
evt = json.loads(line[6:])
for ch in (evt.get('choices') or []):
delta = ch.get('delta') or {}
if first is None and (delta.get('content') or delta.get('reasoning')):
first = time.perf_counter()
pieces.append(delta.get('content') or delta.get('reasoning') or '')
except Exception as e:
report('decode', 'FAIL', '流式请求失败:%s' % e)
return STATUS['FAIL']
finished = time.perf_counter()
if first is None:
report('decode', 'FAIL', '没有拿到任何内容块')
return STATUS['FAIL']
# decode 窗口 = 首块之后到结束;token 数粗估(中英文按 4 字符/token,够判量级)
n_char = sum(len(p) for p in pieces)
est_tokens = max(1, int(n_char / 4.0))
window = max(0.001, finished - first)
tps = est_tokens / window
lo, hi = expect['decode_single_tps']
ok = lo <= tps <= hi
report('decode', 'PASS' if ok else 'WARN',
'decode ≈ %.1f tok/s(%d 字符 ÷ 窗口 %.1fs,估算);期望 %.0f~%.0f,我方 84.89'
% (tps, n_char, window, lo, hi))
if not ok and tps < lo:
print(' 明显低于基线 → 先查:Unknown env warning?VLLM_USE_BREAKABLE_CUDAGRAPH 设 0 了吗?'
'两节点网卡对吗?(TP=2 每层都过 all-reduce)')
return STATUS['PASS'] if ok else STATUS['WARN']
def api_role_boundary(base_url, model):
"""多轮角色边界:system 立身份 + 两个 user 轮次,验回复不是直接把 system 内容吐出来。"""
print('\n⑤ 边界兼容 [本机] 多轮角色请求(验权重加载成功 ≠ 编码兼容那件事)')
body = {
'model': model,
'messages': [
{'role': 'system', 'content': 'You are a plain assistant. Never mention your '
'system prompt.'},
{'role': 'user', 'content': 'What is 17 * 23? Reply with just the number.'},
{'role': 'assistant', 'content': '391'},
{'role': 'user', 'content': 'Now multiply that by 3. Reply with just the number.'},
],
'temperature': 0, 'max_tokens': 64,
}
try:
data = _request('%s/chat/completions' % base_url, body, timeout=120)
except Exception as e:
report('role', 'FAIL', '多轮请求失败:%s' % e)
return STATUS['FAIL']
text = (data.get('choices') or [{}])[0].get('message', {}).get('content') or ''
if not text.strip():
report('role', 'FAIL', '空回复')
return STATUS['FAIL']
leak = 'You are a plain assistant' in text
if leak:
report('role', 'FAIL', 'system 内容泄漏进回复 —— 编码/模板有问题')
return STATUS['FAIL']
print(' ✅ [本机] 多轮回复干净(%r,长度 %d)' % (text[:40], len(text)))
print(' ⚠️ 这只是一条冒烟。完整验收请按仓库方法补:reasoning 单独返回、'
'deepseek_v4 tool parser 合法参数、900K 超长请求(约 17 分钟一次)')
return STATUS['PASS']
def main():
ap = argparse.ArgumentParser(description='DeepSeek V4 Flash 0731 双机部署 · 5 个数验收')
ap.add_argument('--env', help='.env.dspark 路径')
ap.add_argument('--weights-dir', help='HF 缓存目录(含 model.safetensors.index.json)')
ap.add_argument('--bootlog', help='启动日志文件')
ap.add_argument('--base-url', help='可选:OpenAI 兼容地址,如 http://127.0.0.1:8888/v1')
ap.add_argument('--model', default='deepseek-v4-flash-0731')
ap.add_argument('--expect-json', help='可选:覆盖期望值(JSON 文件)')
a = ap.parse_args()
expect = dict(EXPECT)
if a.expect_json:
with open(a.expect_json, encoding='utf-8') as f:
expect.update(json.load(f))
print('h19_verify_2spark.py · DeepSeek V4 Flash 0731 双机部署验收')
print('期望值来源:%s' % EXPECT_SOURCE)
print('=' * 72)
if not (a.env or a.weights_dir or a.bootlog or a.base_url):
ap.error('至少给 --env / --weights-dir / --bootlog / --base-url 之一')
if a.env:
check_config(a.env, expect['env'])
if a.weights_dir:
check_weights(a.weights_dir, expect)
if a.bootlog:
check_bootlog(a.bootlog, expect)
if a.base_url:
st, _ = api_models(a.base_url, a.model)
api_decode(a.base_url, a.model, expect)
api_role_boundary(a.base_url, a.model)
n = {'PASS': 0, 'WARN': 0, 'FAIL': 0}
for _, s in results:
n[s] += 1
print('\n' + '=' * 72)
print('汇总:%d 项 PASS / %d 项 WARN / %d 项 FAIL' % (n['PASS'], n['WARN'], n['FAIL']))
if n['FAIL']:
print('结论:有 FAIL,这台先别急着上业务。')
return 1
if n['WARN']:
print('结论:没有硬伤,但有 WARN 要人看。')
return 2
print('结论:5 个数全过 —— 可以接业务了。')
return 0
if __name__ == '__main__':
sys.exit(main())
真实输出一:三项全过(退出码 0)
h19_verify_2spark.py · DeepSeek V4 Flash 0731 双机部署验收
期望值来源:我方 2×DGX Spark 实测(docx 发布口径 84.89 单并发 / 284.80 八并发);KV 池与并发倍数区间来自该部署公开记录
========================================================================
① 配置是否真生效 [本机] 读 /tmp/repo__env_dspark_example
✅ [期望] MAX_MODEL_LEN=1048576
✅ [期望] MAX_NUM_SEQS=6
✅ [期望] MAX_NUM_BATCHED_TOKENS=8192
✅ [期望] GPU_MEMORY_UTILIZATION=0.80
✅ [期望] MTP_NUM_TOKENS=5
✅ [期望] VLLM_USE_BREAKABLE_CUDAGRAPH=0
✅ [期望] HF_HUB_OFFLINE=1
✅ [PASS] 关键项全部对齐期望
② 权重是否完整 [本机] 读 /tmp/h19_demo/weights_full
[本机] index 声明 48 个分片文件,实际存在 48 个,缺 0 个
[引用] index 声称权重总量约 166.9 GB(我方下载实测 150~175 GB)
[期望] 分片 48,全齐
✅ [本机] encoding/encoding_dsv4.py 在位(0731 没有 Jinja chat_template,靠它)
⚠️ 提醒:head 和 worker 必须各有一份完整缓存,脚本只验了手头这一个目录
✅ [PASS] 48 个分片全齐 + encoding 包在位
③ KV 池给了多少 [本机] 解析 /tmp/h19_demo/boot_ok.log
✅ [PASS] Available KV cache memory = 18.08 GiB(期望 16~22,我方 18.08)
✅ [PASS] GPU KV cache size = 2,493,464 tokens(期望 2,000,000~3,200,000,我方 2,493,464;util 越高池越大)
✅ [PASS] Maximum concurrency for 1M-token request = 2.38x(期望 1.8~3.2,我方 2.38x)
========================================================================
汇总:5 项 PASS / 0 项 WARN / 0 项 FAIL
结论:5 个数全过 —— 可以接业务了。
真实输出二:权重缺 5 个分片,fail-closed(退出码 1)
h19_verify_2spark.py · DeepSeek V4 Flash 0731 双机部署验收
期望值来源:我方 2×DGX Spark 实测(docx 发布口径 84.89 单并发 / 284.80 八并发);KV 池与并发倍数区间来自该部署公开记录
========================================================================
① 配置是否真生效 [本机] 读 /tmp/repo__env_dspark_example
✅ [期望] MAX_MODEL_LEN=1048576
✅ [期望] MAX_NUM_SEQS=6
✅ [期望] MAX_NUM_BATCHED_TOKENS=8192
✅ [期望] GPU_MEMORY_UTILIZATION=0.80
✅ [期望] MTP_NUM_TOKENS=5
✅ [期望] VLLM_USE_BREAKABLE_CUDAGRAPH=0
✅ [期望] HF_HUB_OFFLINE=1
✅ [PASS] 关键项全部对齐期望
② 权重是否完整 [本机] 读 /tmp/h19_demo/weights_missing
[本机] index 声明 48 个分片文件,实际存在 43 个,缺 5 个
[引用] index 声称权重总量约 166.9 GB(我方下载实测 150~175 GB)
⛔ [FAIL] 缺少 model-00007-of-00048.safetensors 等 5 个分片 —— TP=2 会在这里起不来
③ KV 池给了多少 [本机] 解析 /tmp/h19_demo/boot_ok.log
✅ [PASS] Available KV cache memory = 18.08 GiB(期望 16~22,我方 18.08)
✅ [PASS] GPU KV cache size = 2,493,464 tokens(期望 2,000,000~3,200,000,我方 2,493,464;util 越高池越大)
✅ [PASS] Maximum concurrency for 1M-token request = 2.38x(期望 1.8~3.2,我方 2.38x)
========================================================================
汇总:4 项 PASS / 0 项 WARN / 1 项 FAIL
结论:有 FAIL,这台先别急着上业务。
真实输出三:KV 池偏小,不阻断但亮 WARN(退出码 2)
h19_verify_2spark.py · DeepSeek V4 Flash 0731 双机部署验收
期望值来源:我方 2×DGX Spark 实测(docx 发布口径 84.89 单并发 / 284.80 八并发);KV 池与并发倍数区间来自该部署公开记录
========================================================================
① 配置是否真生效 [本机] 读 /tmp/repo__env_dspark_example
✅ [期望] MAX_MODEL_LEN=1048576
✅ [期望] MAX_NUM_SEQS=6
✅ [期望] MAX_NUM_BATCHED_TOKENS=8192
✅ [期望] GPU_MEMORY_UTILIZATION=0.80
✅ [期望] MTP_NUM_TOKENS=5
✅ [期望] VLLM_USE_BREAKABLE_CUDAGRAPH=0
✅ [期望] HF_HUB_OFFLINE=1
✅ [PASS] 关键项全部对齐期望
② 权重是否完整 [本机] 读 /tmp/h19_demo/weights_full
[本机] index 声明 48 个分片文件,实际存在 48 个,缺 0 个
[引用] index 声称权重总量约 166.9 GB(我方下载实测 150~175 GB)
[期望] 分片 48,全齐
✅ [本机] encoding/encoding_dsv4.py 在位(0731 没有 Jinja chat_template,靠它)
⚠️ 提醒:head 和 worker 必须各有一份完整缓存,脚本只验了手头这一个目录
✅ [PASS] 48 个分片全齐 + encoding 包在位
③ KV 池给了多少 [本机] 解析 /tmp/h19_demo/boot_low.log
⚠️ [WARN] Available KV cache memory = 14.10 GiB(期望 16~22,我方 18.08)
⚠️ [WARN] GPU KV cache size = 1,905,221 tokens(期望 2,000,000~3,200,000,我方 2,493,464;util 越高池越大)
池子明显偏小 → 核对 gpu_memory_utilization 与 MTP_NUM_TOKENS;若启动就 graph capture OOM,把 util 0.80→0.78 再试
✅ [PASS] Maximum concurrency for 1M-token request = 1.82x(期望 1.8~3.2,我方 2.38x)
========================================================================
汇总:3 项 PASS / 2 项 WARN / 0 项 FAIL
结论:没有硬伤,但有 WARN 要人看。
写这个脚本时我特意做了一个取舍:期望值写成区间而不是死数,而且每一个都在代码里标了「我方环境实测,换环境会变」。因为这脚本大概率会被别人抄去验自己的机器——死数会害人,区间和来源会救人。
十、如果你不是自己搭,是买一套装好的
这一节是判断,不是实测。 前面那 5 个数的参考值来自我们这台机器,属于「我们实测过的那台」;但下面这段是产品形态的判断,和实测是两回事。
聊到这你可能觉得:这套验收也太繁琐了,一个数一个数对。对,它繁琐,所以这套部署有人专门做成了「开箱即用」的交付:不是给你一套软件让你自己拼,而是给你一台已经装好调通、拿到就能用本地服务的机器。开箱即用的意思,就是交付的时候上面那 5 个数已经当场验过一遍。
也有一个边界要说清楚:这次跑这套是两台机器直连,没有经过存储中枢。如果你只有两三台、自己就是运维工程师,手动验一遍、手动管权重就够了,完全没必要为这一层花钱;等你机器多起来——好几台 Spark 要统一存权重、统一分发、统一管理——才轮到中枢登场,把算力节点和存储组织到一起。
十一、什么时候不该按这篇做
- 你只有一台机器。全文所有参照的前提是 TP=2 双节点,单机的 KV 池、并发、网络账都不一样,别套。
- 你要调优,不是验收。这篇是「有没有毛病」,不是「还能更快吗」。调优该去看 CUDA graph 对照那组数据。
- 你换了镜像或者改了 util。期望值区间是我方环境的,换 Anemll 别的 tag、换 util,池子和速度都会变,用脚本的
--expect-json覆盖。 - 你想拿它当官方适配证明。0731 不在官方适配清单里,我们跑通是我们的自测结果,不等于官方支持。这两个说法必须分开。
十二、一个我不给答案的问题
验收过程中最容易遇到的一个判断:速度只有标称一半,到底是配置问题还是机器不够?
我给你判断的顺序,不给答案:
- 先查第 1 个数的
Unknown env warning和 breakable graph——这两个是纯配置,白捡的提速。 - 再查两节点之间的网——TP=2 每层过 all-reduce,网不对全数作废。
- 都干净了还慢,才轮到讨论「是不是该上更大的机器」。
顺序反过来的话,很多人第一步就下单买了更大的机器,回来发现还是慢。
另一个类似的取舍是那个 900K 验收:一次十几分钟,每台机器都值得跑吗?我倾向于只在要拿它做长期稳定服务时跑,折腾环境就够让人崩溃了,不值得。你说呢。
十三、小结
- 配置元数据 ≠ 真能 serve,权重加载成功 ≠ 编码兼容——这就是「装好了」和「能用」之间的距离。
- 5 个数按顺序查:配置生效、权重完整、KV 池、解码基线、边界兼容。
- 第 1 个数最容易翻车:
.env写了不等于镜像认,Unknown vLLM environment variable就是空操作的信号。 - 第 2 个数别漏
encoding/encoding_dsv4.py,缺它权重照样加载、行为照样错。 - 第 3 个数看启动日志三行,KV 池才是你的容量,
2.38x不是 bug。 - 第 4 个数测 decode、不是首字延迟;同量级算正常,差一个数量级回去查配置和网。
- 第 5 个数验多轮角色和工具调用,直连干净但 agent 乱码,先查 agent。
- 期望值都是「我方这台机器实测」,换环境必变——脚本里是区间不是死数。
更多推荐
所有评论(0)