1. 先把「吞吐暴跌」这件事说清楚你看到的 82 tok/s 和体感是两笔账DeepSeek V4 Flash 在 2 台 DGX Spark 上用 vLLM 组 TP2短 prompt 下单流 decode 能跑到 75 tok/s 量级这个数没骗人。但很多人把它当成「端到端吞吐」于是上下文一长就懵了明明 decode 没怎么掉为什么体感像换了一台机器因为 decode 和 prefill 是两笔完全不同的账。decode 是逐个 token 往外吐每步只算一个 token 的前向工作量跟 prompt 长度基本无关所以它平。prefill 是把整段 prompt 一次性读进去、把每层 KV 都算出来工作量正比于 prompt 长度所以它随上下文线性膨胀。端到端 aggregate 把两笔账加在一起平均prompt 越长prefill 那一项越主导aggregate 就塌得越狠。实测数据摆在这单流、同一集群、同一模型prompt 从 256 token 涨到 131,072decode 只从 75.4 掉到 65.2剩 86%aggregate 从 69.1 掉到 5.9剩 9%TTFT 从 0.63 秒涨到 78.75 秒125 倍。你等第一个字的时间是它把整段答案吐完所需时间的 2.5 倍。这篇就是围绕这个场景写的2 台 DGX Spark、vLLM TP2、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_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。如果你是要长期跑编码或 Agent 任务本地双机这套更适合走 Coding Plan 那套配额入口 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。API Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 生成控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。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一个批次最多喂进去多少 tokenkv_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, concurrency1, output_tokens2048): # 用重复文本构造指定长度的 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, jsonpayload, timeout600) 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(fprompt{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_len1048576加max_num_seqs6很多人会默认这意味着 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_seqs6的实际含义是日常 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/4aggregate 不升反降。前面三行都很平滑就这一行断崖。把max_num_batched_tokens 8192拿出来算一下并发 44 × 2,048 8,192 恰好等于批次上限 并发 66 × 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 列表复测TP2 起不来worker 磁盘被塞满权重在线重复下载两节点各自补全 HF hub cache确认 snapshot 里有encoding/encoding_dsv4.py然后HF_HUB_OFFLINE1设了一堆VLLM_DSPARK_*日志刷 Unknown vLLM environment variableAnemll 镜像不注册 Stage-C 那套开关这些设置是空操作要么合并docker-compose.stage-c.override.yml要么干脆别设单流 decode 比预期慢三成没显式设VLLM_USE_BREAKABLE_CUDAGRAPHAnemll 会自动走较慢的 breakable 路径显式设成 0。对照数据单流 74.55 → 95.9 tok/s28.6%首字等了几十秒到十几分钟以为卡死了没卡死prefill 在算你那个长 prompt按第 4 节的表估一下 TTFT或者用第 6 节的脚本算max_num_seqs6 但请求还在排队池子被长请求占住了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.6——1.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_token4.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_mb200): 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, encodingutf-8, errorsstrict) 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, typeint) g.add_argument(--scan-dir) ap.add_argument(--ext, default) ap.add_argument(--chars-per-token, typefloat, default4.0) ap.add_argument(--concurrency, typeint, default1) ap.add_argument(--output-tokens, typeint, default2048) ap.add_argument(--kv-pool, typeint, defaultDEFAULTS[kv_pool_tokens]) ap.add_argument(--max-model-len, typeint, defaultDEFAULTS[max_model_len]) ap.add_argument(--max-batched, typeint, defaultDEFAULTS[max_num_batched_tokens]) ap.add_argument(--json, actionstore_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_asciiFalse, indent2)) 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。你只有一台机器全文所有数字的前提是 TP2 双节点单机的账完全不一样别套。你要处理图片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_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。长期跑编码或 Agent 任务走 Coding Plan入口 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。API Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 生成控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。API 基址 https://taotoken.net/api 这个不带 UTM。面对「上下文一长就塌」我看到的路有三条各有各的代价我没有标准答案砍上下文上检索只把命中的片段喂进去把 12 万压到几千代价是检索错了模型就看不见吃住延迟接受首字等一两分钟让它一次读完整个仓库适合批处理和夜间任务分层短上下文走本地超长的丢给外部的长上下文服务代价是数据得出去一部分而且要维护两套 prompt 逻辑。你们那边是哪一种为什么这么选我尤其想知道第一条的检索命中率你们做到了多少这是我目前最没底的一块。