在 DGX Spark(GB10,单卡 128G 统一内存)上用 vLLM 跑 Qwen3.8-Flash-Next(125B MoE / 6B 激活 / NVFP4 量化)给审核服务做推理,是可以靠调显存和调度参数把吞吐拉起来的。然而在这台机器上,遇到过一个很别扭的现象:明明每个请求的提示词都一样,缓存命中率却接近零;后来把能调的调度参数都试了一遍,吞吐不升反降。
本文记录一下排查过程,以及最后真正有效的两处配置。
主要参考: DGX Spark 上 Qwen3.8-Flash-Next 的实测记录、GB10 上 GDN kernel 选型与换引擎的分析
一、起点
服务端的实际配置是 vLLM 加社区的 b12x 镜像(针对 GB10 做了定制 kernel 的那个 fork)。审核服务的请求结构很简单:
messages[0] role=system 纯文本 666 字符(所有请求完全一致)
messages[1] role=user [ image_url, text「请审核这张视频关键帧截图。」]
折算成 token 就是 416 token 共享提示词 + 660 token 图片 = 1,076 token。业务侧要的是每分钟 45 个请求以上。
初始状态(2026-11-10 上午):
| 指标 | 起点 |
|---|---|
| GPU 显存利用率 | 0.72 |
| KV cache 显存 | 7.81 GiB |
| KV cache 可容纳 token | 318,246 |
| 131k 上下文最大并发 | 2.43x |
| 平均 TTFT | 1.69 s |
| 端到端请求耗时 | 12.93 s |
| 生成吞吐 | 65–80 tok/s |
二、第一步:把显存要回来
这台机器是统一内存,0.72 的利用率相当于白扔了 27 GiB。但直接调大会起不来 —— PyTorch 的分配器上限还卡在 0.72,两处必须一起改:
# .env
GPU_MEM_UTIL=0.72 -> 0.822
PYTORCH_ALLOC_CONF=per_process_memory_fraction:0.72 -> 0.822
改完重启,KV cache 从 7.81 GiB 涨到 23.83 GiB,可容纳 token 从 318,246 涨到 1,205,134,131k 上下文的最大并发从 2.43x 涨到 9.19x。这个数字比 NVIDIA 模型卡上标的「1M tokens of FP8 KV Cache」还多一点。
注意:只改 GPU_MEM_UTIL 不改分配器上限,KV cache 申请时会直接 OOM,容器起不来。
三、第二步:关掉 MTP 投机解码
这一条和直觉相反。MTP 是模型自带的 4B 投机头,本来是加速用的,但单卡上带宽已经吃满,draft + verify 等于多跑一趟,只添延迟。
# docker-compose.yml,去掉这两行
- --speculative-config
- '{"method":"mtp","num_speculative_tokens":4}'
实测(接受率 39.9%,每步验证 4 个位置只接受 1.6 个):
生成吞吐 65–80 -> 100–104 tok/s
平均 TTFT 1.27 s -> 0.54 s
同一个模型,同一块卡,少一个投机头反而更快。
四、查缓存:为什么一个 token 都没命中
1. 先把逐请求的命中数打开
默认情况下 vLLM 的响应里是看不到缓存命中数的,只有一个聚合的 Prefix cache hit rate。加上这个开关就能逐请求看:
--enable-prompt-tokens-details
usage.prompt_tokens_details = {"cached_tokens": 0, "created_cache_tokens": 0,
"multimodal_tokens": {"image": 660}}
11 个真实请求采样,cached_tokens 全是 0,而提示词是逐字相同的。
2. 根因是块粒度
混合模型(线性注意力 + full attention)解析出的 KV 块粒度是 2,960 token。整个请求只有 1,076 token,只占一个不完整块,而块哈希覆盖的是块内全部 token —— 图一换,块的哈希就换,连前面那 416 token 的共享提示词也一起废掉。
所谓「前缀」,在这里从来没有被单独看见过。
反证很好做:重复发同一个 30 token 的纯文本请求,cached_tokens 是 0,然后 30,再 30。整段完全相同才命中,差一点都不行。
3. 两个开关都改不动
--mamba-block-size 64
-> 被平台按 kernel 对齐重算回 2960
--mamba-cache-mode none
-> 被 compose 命令行里原有的一条同名参数覆盖(同名参数取最后一个)
4. 一个容易看错的指标
Engine 日志里的 Prefix cache hit rate 是容器启动以来的累计值,不是当前值。我用重复请求的探针把它拉到过 77.8%,之后被真实流量稀释下来,一度让我以为缓存是好的。
判断缓存有没有生效,看逐请求的 cached_tokens 或者 vllm:prefix_cache_hits_total 的增量:
$ curl -s localhost:8000/metrics | grep -E '^vllm:prefix_cache_(queries|hits)_total'
vllm:prefix_cache_queries_total{...} 95764
vllm:prefix_cache_hits_total{...} 0
60 秒内查询 20,444 token,命中 0。
五、算一笔账:瓶颈在 prefill,不在 decode
每个请求 1,072 token 的 prefill,对 95 token 的输出,比例 11:1。
之前一直盯着生成吞吐和 decode 的每步耗时优化,方向是反的 —— 真正的大头是把整段输入铺进显存、算出第一份 KV。
六、调度旋钮实测三轮,全是负收益
这个 fork 自己写了一套 prefill/decode 配平的东西(上游 vLLM 里连 vllm/v1/core/sched/prefill_interleave.py 这个文件都没有):
--prefill-compute-share 争用时给 prefill 的算力占比,auto 会自动配平
--prefill-compute-half-life auto 的自适应半衰期
--max-num-prefill-tokens-per-step 争用步内 prefill 的 token 上限
--max-parallel-prefills 同时 prefill 的车道数
--prefill-policy round-robin / decode-aware
--decode-refill-target decode 感知的 refill 阈值
三轮实测(每轮都是重启后 12 分钟的同负载窗口):
| 配置 | 请求速率 | TTFT | 生成吞吐 |
|---|---|---|---|
| 不调(基线) | 46.7 /分 | 0.468 s | 72.9–86.1 tok/s |
| share 0.2 + step 256 | 24.3 /分 | 5.93 s | 36.3–59.0 tok/s |
| auto + responsive | 34.7 /分 | 1.449 s | 49.3–63.5 tok/s |
| decode-aware + np2 | 30.7 /分 | 0.792 s | ~65 tok/s |
不是这轮压得更狠 —— auto 和 decode-aware 那两轮的请求速率更低(34.7 和 30.7,负载比基线还轻),吞吐反而更低。
原因也很直接。这套旋钮默认是 decode 优先、TTFT 不敏感,首 token 晚一点无所谓。我们的负载前提正好掉过来:输入长、输出短、首 token 就是全部体验。前提反了,调得越勤亏得越多。
七、最终配置
GPU_MEM_UTIL=0.822
PYTORCH_ALLOC_CONF=per_process_memory_fraction:0.822
max-num-seqs 8 / max-num-batched-tokens 4096 / max-model-len 131072
无 --speculative-config
无任何 prefill 调参
效果:
| 指标 | 起点 | 终点 |
|---|---|---|
| KV cache 显存 | 7.81 GiB | 23.83 GiB |
| KV cache 可容纳 token | 318,246 | 1,205,134 |
| 131k 上下文最大并发 | 2.43x | 9.19x |
| 平均 TTFT | 1.69 s | 0.468 s |
| 端到端请求耗时 | 12.93 s | 8.56 s |
| 生成吞吐 | 65–80 tok/s | 72.9–86.1 tok/s |
八、踩过的坑
重启脚本会把服务停掉。 直接 docker compose up -d --force-recreate 时,旧容器里有在途请求,FastAPI 会一直等连接关闭,而推理引擎已经停了,请求永远等不到,容器就退不出来;compose 因为名字冲突把新容器改名并卡在 Created,最后 8000 端口不再监听,服务实际是停的。改成先强杀再起就好:
docker compose down --timeout 10 --remove-orphans
docker compose up -d
改 compose 的时候:
- 64 会被 YAML 解析成整数,compose 报 command.N must be a stringdocker compose config -q 校验,不然重启脚本会在校验阶段就中止判断缓存命中率别看日志那一行,看逐请求的 cached_tokens 或 hits_total 增量。
九、还没动的
每请求输出 95 个 token,其中大部分是模型的思考链。关掉思考能把输出压到个位数,延迟还有很大空间,但审核质量会掉,需要先拿真实样本比过一致率再决定。
另外还有一条没走通的:换 llama.cpp。llama.cpp 在同一个 batch 里混算 prefill 和 decode,不存在这种争抢,而且前缀缓存是 token 级的连续匹配,没有块粒度问题。但它要换 GGUF 权重(91G,和 vLLM 不能并存)、丢掉 MTP 和定制 kernel,审核质量也要重测。
最后留一条判据给自己:以后再碰上「自动配平」这类参数,先问一句它是默认 decode 优先还是 prefill 优先,答不上来就别拧。

Comments NOTHING