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、换网络,期望值就要跟着变——下面的数只是参照,不是标准。

顺带补一句上一期的坑:上篇那批数的出处标注得不够准确,其实它们就是我们自己实测的。这篇统一按「我们自己机器上实测」的口径来写,已经发出去的就不回头改了。

先说两个反直觉的前提,它们决定了为什么要验收:

  1. 配置元数据 ≠ 真能 serve。/v1/models 里报 max_model_len: 1048576,只代表配置接受了这个值,不代表它真能处理 100 万 token 的请求。官方配方自己专门跑过一个 90 万 token 的验收请求,原话是「beyond configuration metadata alone」——光看配置元数据不算数。
  2. 权重加载成功 ≠ 编码兼容。权重能加载、能出文字,不代表多轮对话的角色边界、工具调用行为是对的。这是两个不同层面的事。

二、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,镜像不认,日志刷几条警告,服务该跑还是跑——但那些开关一个都没生效。速度掉一半、行为不对,常从这里来。

先确认两件事:

  1. 启动日志里有没有 Unknown vLLM environment variable
docker compose --env-file .env.dspark -f docker-compose.dspark.yml logs vllm-dspark \
  | grep -E "Unknown vLLM environment variable"

有输出,就是有键写了白写。要么删掉,要么先确认它属于当前镜像。

  1. 镜像到底认哪些键,直接进容器里列(官方配方提供的核对方法):
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
  1. 最后用服务自己说的话验证:/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-00048model-00048-of-00048),合计约 167 GB,索引在 model.safetensors.index.json

两个容易漏的地方:

  1. head 和 worker 各要一份完整的。 TP=2 两个节点都要加载同一套权重,任何一边缺分片,另一边也会一起起不来。而且别让它在线重下——缓存不完整时在线下载,会把 worker 磁盘塞满。
  2. encoding/encoding_dsv4.py 必须包含在缓存里。 0731 这个 checkpoint 在官方仓没有 Jinja chat_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 是天花板不是预留、多路短会话共用一个池),上篇讲过机制,这里不重复。

两个判断点:

  1. 池子明显小于量级(比如期望 2.5M 实际只有 1.9M):先核对 GPU_MEMORY_UTILIZATIONMTP_NUM_TOKENS。util 越高池越大,MTP 会吃掉一部分。
  2. 启动就 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 不一样,官方文档里那句话很直白:权重加载成功本身,不能证明编码兼容。 要真验,就验这几件事:

  1. system/user 角色边界:system 里立了身份,回复里不能把 system 内容吐出来。
  2. reasoning 单独返回:思考过程和最终回答分开。
  3. 工具调用:deepseek_v4 的 tool parser 能不能吐出合法的 OpenAI function 参数。
  4. 多轮:连续几轮对话,角色不串。

怎么验:对着接口发一条带 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_UTILIZATIONMTP_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 要统一存权重、统一分发、统一管理——才轮到中枢登场,把算力节点和存储组织到一起。

十一、什么时候不该按这篇做

  1. 你只有一台机器。全文所有参照的前提是 TP=2 双节点,单机的 KV 池、并发、网络账都不一样,别套。
  2. 你要调优,不是验收。这篇是「有没有毛病」,不是「还能更快吗」。调优该去看 CUDA graph 对照那组数据。
  3. 你换了镜像或者改了 util。期望值区间是我方环境的,换 Anemll 别的 tag、换 util,池子和速度都会变,用脚本的 --expect-json 覆盖。
  4. 你想拿它当官方适配证明。0731 不在官方适配清单里,我们跑通是我们的自测结果,不等于官方支持。这两个说法必须分开。

十二、一个我不给答案的问题

验收过程中最容易遇到的一个判断:速度只有标称一半,到底是配置问题还是机器不够?

我给你判断的顺序,不给答案:

  1. 先查第 1 个数的 Unknown env warning 和 breakable graph——这两个是纯配置,白捡的提速。
  2. 再查两节点之间的网——TP=2 每层过 all-reduce,网不对全数作废。
  3. 都干净了还慢,才轮到讨论「是不是该上更大的机器」。

顺序反过来的话,很多人第一步就下单买了更大的机器,回来发现还是慢。

另一个类似的取舍是那个 900K 验收:一次十几分钟,每台机器都值得跑吗?我倾向于只在要拿它做长期稳定服务时跑,折腾环境就够让人崩溃了,不值得。你说呢。

十三、小结

  1. 配置元数据 ≠ 真能 serve,权重加载成功 ≠ 编码兼容——这就是「装好了」和「能用」之间的距离。
  2. 5 个数按顺序查:配置生效、权重完整、KV 池、解码基线、边界兼容。
  3. 第 1 个数最容易翻车:.env 写了不等于镜像认,Unknown vLLM environment variable 就是空操作的信号。
  4. 第 2 个数别漏 encoding/encoding_dsv4.py,缺它权重照样加载、行为照样错。
  5. 第 3 个数看启动日志三行,KV 池才是你的容量,2.38x 不是 bug。
  6. 第 4 个数测 decode、不是首字延迟;同量级算正常,差一个数量级回去查配置和网。
  7. 第 5 个数验多轮角色和工具调用,直连干净但 agent 乱码,先查 agent。
  8. 期望值都是「我方这台机器实测」,换环境必变——脚本里是区间不是死数。
Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐