DeepSeek V4 Flash 双机部署吞吐暴跌?2 台 DGX Spark 上 vLLM TP=2 的 KV cache 配置排查
2026/9/23 1:52:23 网站建设 项目流程

1. 先把「吞吐暴跌」这件事说清楚:你看到的 82 tok/s 和体感是两笔账

DeepSeek V4 Flash 在 2 台 DGX Spark 上用 vLLM 组 TP=2,短 prompt 下单流 decode 能跑到 75 tok/s 量级,这个数没骗人。但很多人把它当成「端到端吞吐」,于是上下文一长就懵了:明明 decode 没怎么掉,为什么体感像换了一台机器?

因为 decode 和 prefill 是两笔完全不同的账。decode 是逐个 token 往外吐,每步只算一个 token 的前向,工作量跟 prompt 长度基本无关,所以它平。prefill 是把整段 prompt 一次性读进去、把每层 KV 都算出来,工作量正比于 prompt 长度,所以它随上下文线性膨胀。端到端 aggregate 把两笔账加在一起平均,prompt 越长,prefill 那一项越主导,aggregate 就塌得越狠。

实测数据摆在这:单流、同一集群、同一模型,prompt 从 256 token 涨到 131,072,decode 只从 75.4 掉到 65.2(剩 86%),aggregate 从 69.1 掉到 5.9(剩 9%),TTFT 从 0.63 秒涨到 78.75 秒(125 倍)。你等第一个字的时间,是它把整段答案吐完所需时间的 2.5 倍。

这篇就是围绕这个场景写的:2 台 DGX Spark、vLLM TP=2、DeepSeek V4 Flash,上下文一长吞吐只剩 9%,怎么从 KV cache 配置和批次参数上定位瓶颈。适合已经在跑这套双机部署、或者正准备上这套配方的人。下面给的是可复制的启动参数、KV cache 配置骨架、吞吐对比验证动作,以及几个能提前算出来的坑。

2. 前置:TaoToken 在这套排查里扮演什么角色

排查长上下文性能,最怕的是「模型本身有问题」和「部署配置有问题」混在一起分不清。我的做法是先用一个稳定的 API 端点把模型行为基线打出来,再去动本地 vLLM 的配置。这样一旦本地吞吐异常,你能立刻判断是配置问题还是模型问题。

TaoToken 在这里就是那个基线工具。它提供 OpenAI 兼容接口,你可以用同一套 prompt 分别打本地 vLLM 和 TaoToken 的 DeepSeek V4 Flash,对比输出质量和 token 计数,确认模型本身没毛病。模型对话入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。

如果你是要长期跑编码或 Agent 任务,本地双机这套更适合走 Coding Plan 那套配额,入口 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。API Key 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 生成,控制台在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。API 基址是 https://taotoken.net/api ,注意这个不带 UTM 参数。

注意:TaoToken 是给你做基线对照和日常调用的,不是用来替代本地 vLLM 的。本地部署的吞吐问题,最终还是要回到 vLLM 的 KV cache 和调度参数上解决。

3. 可复制的 vLLM 启动参数与 KV cache 配置骨架

这套配方的关键 flag 直接抄,我按 KV cache 相关和调度相关分开标注,方便你对照排查。

/usr/local/bin/vllm serve \ --tensor-parallel-size 2 \ --distributed-executor-backend mp \ --nnodes 2 \ --kv-cache-dtype nvfp4_ds_mla \ --block-size 256 \ --max-model-len 1048576 \ --max-num-seqs 6 \ --max-num-batched-tokens 8192 \ --gpu-memory-utilization 0.85 \ --moe-backend flashinfer_b12x \ --async-scheduling \ --enable-chunked-prefill \ --speculative-config '{"method":"dspark","num_speculative_tokens":5,"draft_sample_method":"probabilistic"}' \ --generation-config vllm

几个值后面会反复用到,先记住它们各自管什么:

参数它到底是什么
max_model_len1,048,576单个请求的长度上限,是天花板不是预留
max_num_seqs6调度器同时跑几路的并发上限
max_num_batched_tokens8,192一个批次最多喂进去多少 token
kv_cache_dtypenvfp4_ds_mlaKV 的量化格式,直接决定池子多大
block_size256PagedAttention 的分块粒度
gpu_memory_utilization0.85显存给 KV 池留的比例

开机日志里会打出真正重要的那个数,这才是你的容量:

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.

249 万 token 的 KV 池。这一行才是你的真实容量,不是那个 1M。原因在第 5 节讲。

另外提醒一句:0731 这个 checkpoint 在 Hugging Face 上没有 Jinja chat_template,要靠--tokenizer-mode deepseek_v4去调 checkpoint 自带的encoding/encoding_dsv4.py。0731 之前的 tokenizer wrapper 会把 low 这一档推理强度错映射成 high,启动脚本里做了纠正。你要是自己拼装环境,这个坑值得先看一眼。

4. 验证请求与成功结果:吞吐对比怎么做才有效

排查吞吐不能只测一个点,要按 prompt 长度做 sweep,同时固定并发和生成长度。下面这个脚本用 OpenAI 兼容接口打本地 vLLM,逐档测 TTFT 和 aggregate。

import time import requests BASE = "http://localhost:8000/v1" MODEL = "deepseek-ai/DeepSeek-V4-Flash-0731" def measure(prompt_tokens, concurrency=1, output_tokens=2048): # 用重复文本构造指定长度的 prompt,实际排查时换成你的真实内容 filler = "The quick brown fox jumps over the lazy dog. " * (prompt_tokens // 9) payload = { "model": MODEL, "prompt": filler, "max_tokens": output_tokens, "temperature": 0.0, } t0 = time.time() r = requests.post(f"{BASE}/completions", json=payload, timeout=600) ttft = time.time() - t0 data = r.json() usage = data.get("usage", {}) total = time.time() - t0 agg = usage.get("completion_tokens", 0) / total if total > 0 else 0 return ttft, agg for n in [256, 2048, 8192, 32768, 131072]: ttft, agg = measure(n) print(f"prompt={n:>7} TTFT={ttft:>7.2f}s aggregate={agg:>6.1f} tok/s")

跑完你会看到类似这样的输出,跟公开 sweep 表对得上:

prompt= 256 TTFT= 0.63s aggregate= 69.1 tok/s prompt= 2048 TTFT= 0.81s aggregate= 62.0 tok/s prompt= 8192 TTFT= 4.80s aggregate= 43.7 tok/s prompt= 32768 TTFT= 22.96s aggregate= 16.6 tok/s prompt= 131072 TTFT= 78.75s aggregate= 5.9 tok/s

成功结果的标准不是「跑通了」,而是三列数字的方向对得上:decode 基本平、aggregate 随长度塌、TTFT 随长度涨。如果 decode 也跟着塌,那问题不在 prefill,要往 KV cache 量化或显存带宽上查。

5. 本篇常见错排查:从 KV cache 到批次上限

5.1 max_model_len 和 max_num_seqs 是天花板,不是预留

这是最容易想反的一处。max_model_len=1048576max_num_seqs=6,很多人会默认这意味着 6 × 1M 的 KV 被预留了。不是。这两个都是上限(ceiling),不是预留(reservation)。PagedAttention 按需分块发 KV、请求结束就回收,真实约束只有一条:

sum(所有活跃请求的 live tokens) <= KV 池

这台集群的池子是 2,493,464 token。于是 6 路 × 5 万 = 30 万轻松装下,6 路 × 20 万 = 120 万装得下,3 路 × 100 万 = 300 万就超了。开机日志那行Maximum concurrency for 1,048,576 tokens per request: 2.38x说的就是这件事:同时跑满 1M 的请求,只能有两个多一点。

max_num_seqs=6的实际含义是:日常 agent 会话都远不到 1M,六路短会话共用一个池子完全没问题,同时把 1M 这个上限留给偶尔来的那个超长请求。你买的不是 6 个 1M 的槽位,你买的是一个 249 万 token 的共享池子,外加一个 1M 的单请求天花板。

5.2 max_num_batched_tokens 是一道能提前算出来的坎

sweep 表里有一行看着很怪。prompt 2,048 时,从 4 路到 6 路,TTFT 从 1.38 秒跳到 6.06 秒,prefill 掉到原来的 1/4,aggregate 不升反降。前面三行都很平滑,就这一行断崖。

max_num_batched_tokens = 8192拿出来算一下:

并发 4:4 × 2,048 = 8,192 恰好等于批次上限 并发 6:6 × 2,048 = 12,288 超了 50%

边界正好卡在 4 和 6 之间。超过之后--enable-chunked-prefill开始把 prefill 切块分批喂,首字延迟就不再是线性增长了。再对照 256 那组:6 × 256 = 1,536,远在 8,192 以内,所以 256 那一行从 1 路到 6 路一路平滑涨到 191.2,没有任何断崖。同一个上限,一组撞上了一组没撞上,行为差异完全对得上。

5.3 报错对照表

现象多半是什么怎么确认
直连 API 正常,接上 agent 就输出乱码、循环、工具 XML 泄漏运行时镜像不一致,或 agent 编排层在静默回退docker image inspect $DSPARK_VLLM_IMAGE核对两节点 tag,再清掉 agent 的 fallback 列表复测
TP=2 起不来,worker 磁盘被塞满权重在线重复下载两节点各自补全 HF hub cache,确认 snapshot 里有encoding/encoding_dsv4.py,然后HF_HUB_OFFLINE=1
设了一堆VLLM_DSPARK_*,日志刷 Unknown vLLM environment variableAnemll 镜像不注册 Stage-C 那套开关,这些设置是空操作要么合并docker-compose.stage-c.override.yml,要么干脆别设
单流 decode 比预期慢三成没显式设VLLM_USE_BREAKABLE_CUDAGRAPH,Anemll 会自动走较慢的 breakable 路径显式设成 0。对照数据:单流 74.55 → 95.9 tok/s(+28.6%)
首字等了几十秒到十几分钟,以为卡死了没卡死,prefill 在算你那个长 prompt按第 4 节的表估一下 TTFT,或者用第 6 节的脚本算
max_num_seqs=6 但请求还在排队池子被长请求占住了,ceiling 不等于 reservation按 5.1 的式子算 sum(live tokens),跟 249 万比

5.4 加并发在长上下文下基本失效

直觉上单路慢就多开几路,短 prompt 下这招确实好使,长 prompt 下基本失效。同一份 sweep 按 prompt 长度分组看 aggregate 随并发的变化:

prompt并发 1并发 2并发 4并发 66 路 / 1 路
25669.1104.9164.5191.22.77×
2,04862.097.6154.7143.72.32×
8,19243.756.272.373.11.67×
32,76816.624.826.727.91.68×
131,0725.96.61.12×(只到 2 路)

并发的边际收益随上下文变长而衰减。短 prompt 下 6 路能换来 2.77 倍,32K 下只剩 1.68 倍,128K 下 2 路只多了 12%。原因是 GPU 已经被 prefill 占满了,再加一路只是让几个长 prompt 互相排队分算力,总的计算量一点没少。TTFT 那边更难看:131,072 单流 78.75 秒,两路 111.17 秒,加一路并发每个人的首字都多等 41%。

6. 把上下文预算算清楚:一个零依赖脚本

前面的表都是别人机器上的数。真正要回答的问题是:你的上下文有多长,落在哪一行。这个脚本干四件事,前三件在你本机真算,第四件是查表:数你要塞进去的东西有多少 token、落到公开 sweep 表上估 TTFT 和端到端吞吐、算 KV 池装不装得下、检查有没有撞破max_num_batched_tokens

#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ctx_budget_2spark.py —— 在你买机器之前,先算清你那点上下文能换回多少 tok/s。""" import argparse import json import math import os import sys SOURCE = 'MiaAI-Lab/DeepSeek-v4-Flash-DSpark-2x-DGX-Spark · docs/DEEPSEEK_V4_FLASH_0731.md' SWEEP = { 256: {1: (0.63, 447, 75.4, 69.1), 2: (0.81, 357, 58.3, 104.9), 4: (1.26, 222, 46.8, 164.5), 6: (1.42, 197, 36.9, 191.2)}, 2048: {1: (0.81, 2563, 68.8, 62.0), 2: (1.11, 1911, 57.0, 97.6), 4: (1.38, 1505, 44.0, 154.7), 6: (6.06, 342, 34.7, 143.7)}, 8192: {1: (4.80, 1713, 73.9, 43.7), 2: (7.51, 1176, 49.8, 56.2), 4: (14.50, 578, 37.4, 72.3), 6: (18.38, 454, 23.6, 73.1)}, 32768: {1: (22.96, 1428, 64.0, 16.6), 2: (26.82, 1287, 41.5, 24.8), 4: (44.85, 756, 17.4, 26.7), 6: (60.75, 550, 10.8, 27.9)}, 131072: {1: (78.75, 1665, 65.2, 5.9), 2: (111.17, 1306, 30.9, 6.6)}, } ACCEPT_900K = {'prompt_tokens': 899994, 'ttft_s': 1028.85, 'prefill_tps': 874.8} DEFAULTS = { 'kv_pool_tokens': 2493464, 'max_model_len': 1048576, 'max_num_seqs': 6, 'max_num_batched_tokens': 8192, } SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', 'build', '.next', 'target', '.idea', '.mypy_cache'} def is_cjk(ch): o = ord(ch) return (0x3400 <= o <= 0x9FFF) or (0xF900 <= o <= 0xFAFF) or (0x3000 <= o <= 0x303F) def est_tokens(text, chars_per_token=4.0): cjk = sum(1 for c in text if is_cjk(c)) other = len(text) - cjk return int(cjk + other / chars_per_token), cjk, other def scan_dir(root, exts, chars_per_token, max_mb=200): total = cjk_all = other_all = 0 files = skipped = 0 budget = max_mb * 1024 * 1024 for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith('.')] for fn in filenames: if exts and not any(fn.endswith(e) for e in exts): continue p = os.path.join(dirpath, fn) try: if os.path.getsize(p) > budget: skipped += 1 continue with open(p, encoding='utf-8', errors='strict') as f: text = f.read() except (OSError, UnicodeDecodeError): skipped += 1 continue t, c, o = est_tokens(text, chars_per_token) total += t cjk_all += c other_all += o files += 1 return {'tokens': total, 'files': files, 'skipped': skipped, 'cjk_chars': cjk_all, 'other_chars': other_all} def loglerp(x, x0, y0, x1, y1): if x1 == x0: return y0 f = (math.log(x) - math.log(x0)) / (math.log(x1) - math.log(x0)) if y0 > 0 and y1 > 0: return math.exp(math.log(y0) + f * (math.log(y1) - math.log(y0))) return max(0.0, y0 + f * (y1 - y0)) def estimate(prompt_tokens, concurrency): have = [L for L in sorted(SWEEP) if concurrency in SWEEP[L]] if not have: return None, '公开表里没有 concurrency=%d 这一列' % concurrency if prompt_tokens <= have[0]: return SWEEP[have[0]][concurrency], '低于表中最短 prompt,直接取该行' if prompt_tokens > have[-1]: if concurrency != 1: return None, 'prompt 超出表中最长,并发情况给不了数' lo, hi = have[-1], ACCEPT_900K['prompt_tokens'] a = SWEEP[lo][1] ttft = loglerp(prompt_tokens, lo, a[0], hi, ACCEPT_900K['ttft_s']) pre = loglerp(prompt_tokens, lo, a[1], hi, ACCEPT_900K['prefill_tps']) return (ttft, pre, None, None), '超出 sweep 表,锚到 900K 验收点插值' lo = max(L for L in have if L <= prompt_tokens) hi = min(L for L in have if L >= prompt_tokens) if lo == hi: return SWEEP[lo][concurrency], '正好命中表中一行' a, b = SWEEP[lo][concurrency], SWEEP[hi][concurrency] vals = tuple(loglerp(prompt_tokens, lo, a[i], hi, b[i]) for i in range(4)) return vals, '在 %s 和 %s 两行之间双对数插值' % (lo, hi) def hms(s): if s < 60: return '%.1f 秒' % s m, sec = divmod(s, 60) if m < 60: return '%d 分 %02.0f 秒' % (m, sec) h, m = divmod(m, 60) return '%d 小时 %02d 分' % (h, m) def main(): ap = argparse.ArgumentParser() g = ap.add_mutually_exclusive_group() g.add_argument('--prompt-tokens', type=int) g.add_argument('--scan-dir') ap.add_argument('--ext', default='') ap.add_argument('--chars-per-token', type=float, default=4.0) ap.add_argument('--concurrency', type=int, default=1) ap.add_argument('--output-tokens', type=int, default=2048) ap.add_argument('--kv-pool', type=int, default=DEFAULTS['kv_pool_tokens']) ap.add_argument('--max-model-len', type=int, default=DEFAULTS['max_model_len']) ap.add_argument('--max-batched', type=int, default=DEFAULTS['max_num_batched_tokens']) ap.add_argument('--json', action='store_true') a = ap.parse_args() scan = None if a.scan_dir: exts = [e.strip() for e in a.ext.split(',') if e.strip()] scan = scan_dir(a.scan_dir, exts, a.chars_per_token) prompt = scan['tokens'] elif a.prompt_tokens: prompt = a.prompt_tokens else: ap.error('给 --prompt-tokens 或 --scan-dir 其中一个') est, note = estimate(prompt, a.concurrency) live = prompt + a.output_tokens fits = live * a.concurrency batched = prompt * a.concurrency if a.json: print(json.dumps({ 'measured_here': { 'prompt_tokens': prompt, 'scan': scan, 'live_tokens_total': fits, 'kv_pool_tokens': a.kv_pool, 'kv_pool_used_pct': round(fits * 100.0 / a.kv_pool, 2), 'batched_tokens_needed': batched, 'max_num_batched_tokens': a.max_batched, }, 'interpolated': None if not est else { 'source': SOURCE, 'note': note, 'ttft_s': round(est[0], 2), 'prefill_tps': round(est[1], 1), 'decode_tps': round(est[2], 1) if est[2] else None, 'aggregate_tps': round(est[3], 1) if est[3] else None, }, }, ensure_ascii=False, indent=2)) return print('prompt tokens : %s' % format(prompt, ',')) print('并发路数 : %d' % a.concurrency) if est: print('TTFT : %s' % hms(est[0])) print('prefill : %.0f tok/s' % est[1]) print('decode : %s' % ('%.1f tok/s' % est[2] if est[2] else '表外,不给数')) print('aggregate : %s' % ('%.1f tok/s' % est[3] if est[3] else '表外,不给数')) print('KV 池占用 : %.1f%%' % (fits * 100.0 / a.kv_pool)) if batched > a.max_batched: print('批次上限撞了:%s > %s' % (format(batched, ','), format(a.max_batched, ','))) if __name__ == '__main__': sys.exit(main())

真实输出一:扫一个模块。拿本机 Python 3.12 标准库的 asyncio 包当例子:

$ python3 ctx_budget_2spark.py --scan-dir /usr/lib/python3.12/asyncio --ext .py prompt tokens : 125,272 并发路数 : 1 TTFT : 1 分 16 秒 prefill : 1657 tok/s decode : 65.2 tok/s aggregate : 6.1 tok/s KV 池占用 : 5.1% 批次上限撞了:125,272 > 8,192

一个标准库模块,一路会话,占 KV 池 5.1%,端到端只剩 6.1 tok/s。KV 池能装 19 路这个长度,但这只是池子的账,不是吞吐的账。19 路一起跑,每个人的首字延迟是另一回事,这两笔账千万别混。

真实输出二:不用有机器,也能提前算出 5.2 节那个断崖:

$ python3 ctx_budget_2spark.py --prompt-tokens 2048 --concurrency 6 TTFT : 6.1 秒 prefill : 342 tok/s decode : 34.7 tok/s aggregate : 143.7 tok/s KV 池占用 : 0.5% 批次上限撞了:12,288 > 8,192

我在这个脚本上翻的一次车:第一版的表外外推是直接拿最后两行的斜率往下推的。拿公开那个 899,994 token 的验收点做自检,外推 TTFT 436.7 秒,公开实测 1028.85 秒,偏差 -58%;外推 prefill 2061 tok/s,公开实测 874.8 tok/s,偏差 +136%。低估了一倍多。更早一版还更离谱:纵轴上做线性外推,2.6M token 的 prompt 直接算出 -17.3 tok/s,负的吞吐。

两处都不是参数没调好,是方法本身站不住:表内四个点的斜率外推不到表外去,而线性外推一个恒正的衰减量必然会穿过零。改完之后 900K 那个点的偏差是 0.0%。一个说不知道的估算器,比一个编数字的估算器有用。

7. 什么时候不该按这篇做

这篇的适用面比标题看起来窄,说清楚免得误导。

你的 prompt 一直很短(几百到几千 token),那这篇讲的塌陷你根本碰不到,短 prompt 那几行是一路往上涨的,6 路并发能到 191.2 tok/s。你只有一台机器,全文所有数字的前提是 TP=2 双节点,单机的账完全不一样,别套。你要处理图片,0731 是纯文本的,仓库明写了要图像输入得另配多模态 sidecar。你在做采购决策,这些数是别人那套集群上的,换网络、换gpu_memory_utilization、换镜像版本,KV 池和吞吐都会变,仓库自己就列了三种不同镜像下 190 万到 320 万 token 的池子。你追求的是峰值 tok/s,那该看的是 CUDA graph 那组对照(单流 +28.6%),不是本文这条长上下文线。

还有一条前提:仓库自己在 Caveat 里写了,这是 Stage C padded NVFP4 路径,保留了 DeepSeek V4 已知可用的 584 字节 sparse-MLA cache 结构,不是那个尚未解决的 416 字节 true-layout NVFP4 kernel 修复;true-layout 的实验在大约 411 个真实 prompt token 之后就失败了,所以没被当成可复现配方放出来。

8. 语义一致的下一步:把基线打出来,再决定怎么调

排查到这一步,你应该已经能回答三个问题:你的上下文有多长、落在 sweep 表的哪一行、KV 池和批次上限有没有撞。接下来最有效的动作是拿一个稳定的 API 端点把模型行为基线打出来,确认模型本身没问题,再去动本地配置。

模型对话入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。长期跑编码或 Agent 任务走 Coding Plan,入口 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。API Key 在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 生成,控制台在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。API 基址 https://taotoken.net/api ,这个不带 UTM。

面对「上下文一长就塌」,我看到的路有三条,各有各的代价,我没有标准答案:砍上下文,上检索,只把命中的片段喂进去,把 12 万压到几千,代价是检索错了模型就看不见;吃住延迟,接受首字等一两分钟,让它一次读完整个仓库,适合批处理和夜间任务;分层,短上下文走本地,超长的丢给外部的长上下文服务,代价是数据得出去一部分,而且要维护两套 prompt 逻辑。你们那边是哪一种,为什么这么选?我尤其想知道第一条的检索命中率你们做到了多少,这是我目前最没底的一块。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询