把 Qwen3.8 Flash-Next 装进 Jetson AGX Thor(续)
上一篇记录了 Qwen3.8 Flash-Next 在 Jetson AGX Thor 上的几条部署路线,并最终选择 SGLang + SSD-stream:主模型与计算热路径驻留统一内存,约 48 GB 的 PLE 通过 NVMe 流式读取,MTP 则配合 ReplaySSM 降低多路并发所需的递归状态容量。
服务接入持续编程任务后,测试目标发生了变化。短请求能完成、基准速度足够,并不等于二十多万 token 的会话能够长期运行。后续排查依次处理了整机 OOM、KV cache 账目、运行期内存增长、FP8 KV、长上下文正确性、自动截断死锁、并发容量,以及最后出现的一次思维输出碎片化。本文按实际发生顺序记录这些问题和已验证的结论。
先给结论:在一台新 Thor 上直接部署
最终性能,代码能力在不太严谨的评判下略弱于之前的GPT5.5(基本符合https://artificialanalysis.ai/leaderboards/models 的排名,qwen3.8-next大约和gpt5.6terra相当,5.6terra约等于5.5,我们实际运行的是一个qwen3.8 next的nvfp4版本),在vibe coding任务下,长开发任务的生成速度大约是31tokens/s。
下面是本文最终配置的复现路径,适合先把服务运行起来,再阅读后面的排障记录。它对应的硬件与系统是 Jetson AGX Thor 128 GB、JetPack 7.1 / Jetson Linux R38.4、SM110、本地 NVMe。模型文件、PLE sidecar、运行时和编译缓存合计需要较大空间,建议开始前在 NVMe 上至少保留 180 GiB。
最终服务由三部分组成:
| 部分 | 来源 | 用途 |
|---|---|---|
| 主模型 | hn7305/Qwen3.8-Flash-Next-NVFP4-Spark 的配置与 7 个 dense shards | attention、GDN 投影和 shared expert 等 dense path 也使用 NVFP4 |
| routed experts、MTP、PLE sidecar | garnermccloud/Qwen3.8-Flash-Next-NVFP4-SSD-Stream | 复用与 HN checkpoint 字节相同的 expert 权重;MTP 作为 draft model;51.2 GB PLE 从 NVMe 流式读取 |
| Thor 运行时与补丁 | deletoe/sglang-ssd-stream 的 Thor 分支 | SM110、FP8 target/draft KV、QSA 工作区限额、页安全截断和 Mamba/NEXTN 边界修复 |
HN 仓库目前也提供完整约 122 GB checkpoint,但本文实际运行的 SSD-stream 方案只需要其中约 11 GB 的 7 个 dense shards 和小型配置文件。其余 192 个 expert shards 与基础 checkpoint 字节相同;PLE 则必须使用 SSD-stream 准备好的单一 sidecar,而不是 HN 仓库中的十个普通 safetensors shards。以下步骤据此构建一个不重复保存相同大权重的模型视图。
1. 检查设备与磁盘
uname -m cat /etc/nv_tegra_release nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader df -h /home free -h
预期架构为 aarch64,GPU 为 Thor,compute capability 为 11.0。本文配置按 128 GB 统一内存计算,不适用于容量更小的 Jetson。系统最好准备约 16 GB 总 swap;它是瞬时压力缓冲,不是模型权重或 KV 的常规存放区。生产运行还应启用持久化 journal,否则整机被 watchdog 重启后无法查看上一轮日志。
如果 swapon --show 只显示 JetPack 默认的约 2 GB swap,可以额外建立 14 GiB swapfile。以下命令只应在 /swapfile.thor 尚不存在时执行一次:
sudo fallocate -l 14G /swapfile.thor sudo chmod 600 /swapfile.thor sudo mkswap /swapfile.thor sudo swapon /swapfile.thor echo '/swapfile.thor none swap sw 0 0' | sudo tee -a /etc/fstab echo 'vm.swappiness=10' | sudo tee /etc/sysctl.d/90-thor-llm.conf sudo sysctl --system
持久化 journal 使用 1 GiB 上限,并为文件系统保留 2 GiB:
sudo install -d -m 2755 /var/log/journal sudo install -d -m 0755 /etc/systemd/journald.conf.d sudo tee /etc/systemd/journald.conf.d/90-thor-llm.conf >/dev/null <<'EOF' [Journal] Storage=persistent SystemMaxUse=1G SystemKeepFree=2G EOF sudo systemctl restart systemd-journald
2. 安装固定版本运行时
不要使用上游仓库当前 main 的一键命令代替下面的 Thor 分支;本文依赖的补丁仍在 PR #2 中。安装脚本会创建隔离的 Python 3.12 环境,并固定 SGLang、FlashInfer、ModelOpt 与 CUDA 用户态组件,不会替换 JetPack 自带驱动。
sudo apt-get update sudo apt-get install -y git curl jq build-essential patch cd /home/nvidia git clone --branch thor/page-safe-fp8-long-context --single-branch \ https://github.com/deletoe/sglang-ssd-stream.git cd /home/nvidia/sglang-ssd-stream git checkout a2c253d ./install.sh
以下示例把模型缓存固定在 NVMe 上的 /home/nvidia/models/sglang-ssd-stream-runtime。如果 /home 不在 NVMe,请把这个目录改到实际的本地 NVMe 挂载点;PLE 不应放在网络文件系统上。
export SGLANG_SSD_STREAM_HOME=/home/nvidia/models/sglang-ssd-stream-runtime export HF_ENDPOINT=https://hf-mirror.com # 直连 Hugging Face 正常时可删除 /home/nvidia/.local/bin/sglang-ssd-stream update-model \ --data-dir "$SGLANG_SSD_STREAM_HOME"
这一步下载并校验 prepared SSD-stream snapshot,包含 routed experts、MTP 和 PLE sidecar。下载约 140 GiB,不要在完成后删除 Hugging Face cache:下一步的组合模型会通过符号链接复用其中的大文件。
3. 构建 dense-NVFP4 + SSD-stream 模型视图
先定位刚才固定的基础快照,再只下载 HN checkpoint 中真正不同的 dense shards 与配置文件:
export RUNTIME_ROOT=/home/nvidia/.local/share/sglang-ssd-stream
export PYTHON="$RUNTIME_ROOT/venv/bin/python"
export HF_HOME="$SGLANG_SSD_STREAM_HOME/huggingface"
export MODEL=/home/nvidia/models/Qwen3.8-Flash-Next-NVFP4-Spark
export BASE_SNAPSHOT="$($PYTHON - <<'PY'
from huggingface_hub import snapshot_download
print(snapshot_download(
repo_id="garnermccloud/Qwen3.8-Flash-Next-NVFP4-SSD-Stream",
local_files_only=True,
))
PY
)"
ln -sfn "$BASE_SNAPSHOT" /home/nvidia/models/sglang-ssd-stream-base
mkdir -p "$MODEL"
"$RUNTIME_ROOT/venv/bin/hf" download \
hn7305/Qwen3.8-Flash-Next-NVFP4-Spark \
--local-dir "$MODEL" \
--include '*.json' '*.txt' '*.model' '*.jinja' '*.py' 'README.md' \
'model-bf16-*.safetensors'
随后把相同的 expert 文件和 SSD sidecar 链接进组合目录:
find -L "$BASE_SNAPSHOT" -maxdepth 1 -type f \ -name 'layer-*-experts-*.safetensors' -print0 | while IFS= read -r -d '' file; do ln -sfn "$file" "$MODEL/$(basename "$file")" done ln -sfn "$BASE_SNAPSHOT/model-plefp8-scale.safetensors" \ "$MODEL/model-plefp8-scale.safetensors" mkdir -p "$MODEL/ple" ln -sfn "$BASE_SNAPSHOT/ple/qwen3.8-flash-next-ple-fp8.bin" \ "$MODEL/ple/qwen3.8-flash-next-ple-fp8.bin" cp "$BASE_SNAPSHOT/ssd-stream.json" "$MODEL/ssd-stream.json"
HN 原始 index 仍包含十个普通 PLE shard。把它留作备份,并从服务使用的 index 中删除这 128 条 PLE tensor 映射,让插件改从 sidecar 读取;scale tensor 保留:
MODEL="$MODEL" "$PYTHON" - <<'PY'
import json
import os
from pathlib import Path
root = Path(os.environ["MODEL"])
index = root / "model.safetensors.index.json"
backup = root / "model.safetensors.index.with-ple.json"
data = json.loads(index.read_text(encoding="utf-8"))
if not backup.exists():
backup.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
data["weight_map"] = {
name: shard
for name, shard in data["weight_map"].items()
if not (
shard.startswith("model-plefp8-")
and shard != "model-plefp8-scale.safetensors"
)
}
index.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
print("weights:", len(data["weight_map"]), "shards:", len(set(data["weight_map"].values())))
PY
本文实测目录在转换后应有 297,289 条 weight mappings、200 个 safetensors shards。在启动前再检查所有链接和索引引用:
find -L "$MODEL" -type l -print
MODEL="$MODEL" "$PYTHON" - <<'PY'
import json
import os
from pathlib import Path
root = Path(os.environ["MODEL"])
data = json.loads((root / "model.safetensors.index.json").read_text())
missing = sorted({name for name in data["weight_map"].values() if not (root / name).is_file()})
if missing:
raise SystemExit("missing model files:\n" + "\n".join(missing))
for name in ("config.json", "ssd-stream.json", "ple/qwen3.8-flash-next-ple-fp8.bin"):
if not (root / name).is_file():
raise SystemExit(f"missing: {name}")
print("model view is complete")
PY
第一条命令在一切正常时没有输出;它用于找出目标已经失效的符号链接。
4. 安装 Thor 补丁并以前台方式验收
cd /home/nvidia/sglang-ssd-stream export SGLANG_SSD_STREAM_RUNTIME_ROOT=/home/nvidia/.local/share/sglang-ssd-stream ./scripts/install-thor-qsa-fp8.sh MODEL=/home/nvidia/models/Qwen3.8-Flash-Next-NVFP4-Spark \ DRAFT="$BASE_SNAPSHOT/mtp" \ ./scripts/run-thor-hn-fp8.sh
补丁安装器会核对目标源码的修改前或修改后 SHA-256,并为首次修改建立备份。如果它报告 Refusing to patch an unknown ... source state,说明安装到的 SGLang 已经不是本文验证的版本;不要使用 --force 或手工跳过检查,应切回该分支固定的运行时版本。
首次启动需要编译 SM110 相关 kernel,并加载约 86 GB 的主模型与 MTP 权重,达到 API 就绪通常需要数分钟。看到 8000 端口监听后,在另一个终端验证:
curl -fsS http://127.0.0.1:8000/v1/models | jq
curl -fsS http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "Qwen/Qwen3.6-27B-NVFP4",
"messages": [{"role": "user", "content": "只回复:Thor 服务正常"}],
"max_tokens": 32,
"temperature": 0
}' | jq
对外模型名保留为 Qwen/Qwen3.6-27B-NVFP4,这是既有客户端使用的兼容别名;底层实际运行的是 Qwen3.8 Flash-Next。
5. 交给 systemd 常驻
前台验收通过后,可以建立 /etc/systemd/system/qwen38-ssd-stream.service:
[Unit] Description=Qwen3.8 Flash-Next NVFP4 SSD-stream SGLang service Wants=network-online.target After=network-online.target [Service] Type=simple User=nvidia Group=nvidia WorkingDirectory=/home/nvidia/sglang-ssd-stream Environment=HOME=/home/nvidia Environment=SGLANG_SSD_STREAM_RUNTIME_ROOT=/home/nvidia/.local/share/sglang-ssd-stream Environment=MODEL=/home/nvidia/models/Qwen3.8-Flash-Next-NVFP4-Spark Environment=DRAFT=/home/nvidia/models/sglang-ssd-stream-base/mtp ExecStart=/home/nvidia/sglang-ssd-stream/scripts/run-thor-hn-fp8.sh Restart=on-failure RestartSec=10 TimeoutStopSec=90 KillMode=control-group LimitMEMLOCK=infinity StandardOutput=journal StandardError=journal SyslogIdentifier=qwen38-ssd-stream [Install] WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable --now qwen38-ssd-stream.service systemctl status qwen38-ssd-stream.service journalctl -u qwen38-ssd-stream.service -f
启动脚本已经包含本文验证的全部关键参数:262,144-token 单请求窗口、557,056-token 共享 KV 池、四个请求槽、16 个 Mamba state slots、target/draft FP8 KV、2,048-token chunked prefill、NEXTN 3/1/4、ReplaySSM、32 MiB/层 QSA prefill 预算,以及页安全自动截断。四路共享池并不容纳四条完整 262K 会话;它可以容纳两条接近 250K 的会话,或四条总量不超过 557,056 tokens 的较短会话。
以上是可直接部署的结果。下面从最初的生产负载开始,按时间顺序说明这些参数和补丁分别由什么问题产生。
| 阶段 | 现场现象 | 查明或完成的事项 |
|---|---|---|
| 生产负载接入 | 速度正常,但整机突然重启 | 确认统一内存压力与硬件 watchdog 链路,补齐持久化日志与 swap |
| 单路 262K 稳定化 | 长请求再次触发重启 | 重新核算 KV 已预分配;运行期仍需给激活、QSA 和工作区留余量 |
| 内存压缩 | BF16 KV 占用约 6.5 GiB | 移植 QSA FP8 KV 支持,目标与草稿 KV 降至约 3.26 GiB |
| 长上下文验证 | 担心量化、QSA 或递归状态出现 NaN | 逐级测试至 261,888-token prompt,未复现 NaN,三处信息均可正确召回 |
| 内存增长定位 | 真实会话越长,reserved memory 持续上升 | 定位到 QSA prefill 临时 logits 与 PyTorch allocator 高水位,并收紧单层预算 |
| 边界请求 | 超长输入被截断后一直不返回 | 修复页对齐后挤掉输出预算、MTP 请求无法结束的死锁 |
| 并发扩容 | 需要四个并发槽和更大的共享上下文池 | 建成 557,056-token 共享池,通过双路、三轮、40 分钟耐久测试 |
| 思维输出异常 | 一次长会话后段出现合法但无语义的 token 片段 | 分离取消请求问题,补入缺失的 Mamba/NEXTN 边界修复;原异常根因尚未被唯一锁定 |
一、真实编程会话中的速度并没有先恶化
正式接入连续编程任务后,先观察到的是一段正常运行期。30 分钟内记录了 501 个 decode 样本,平均速度为 32.89 tok/s,中位数 33.69 tok/s,最后 5 分钟平均 35.78 tok/s。同期上下文从约 26K 增长到 87.5K,没有出现随长度单调下降的解码速度。
MTP 的平均接受率为 0.61,平均接受长度为 2.84。按接受率分组后,速度关系很清楚:
| MTP 接受率 | 平均解码速度 |
|---|---|
< 0.4 | 22.03 tok/s |
0.4—0.8 | 32.81 tok/s |
≥ 0.8 | 43.00 tok/s |
这些数字只说明该时段的推测解码运行正常,以及速度主要跟随接受率变化;它们没有提供内存稳定性的证据。之后发生的故障也不是解码逐渐变慢,而是设备整体失去响应并重启。
二、两次整机重启暴露的是统一内存余量不足
第一次异常发生在约 15:30:50。systemd 记录中的服务重启计数仍为零,而 last -x 显示系统经历了 crash/reboot,不是单个推理进程按正常路径退出。设备配置了两分钟硬件 watchdog,系统失去调度能力后会被自动拉起。
当时 journald 只保存在内存中,重启后上一轮内核日志已经丢失;pstore 中也没有留下 panic 记录。因此,可以确认的是整机异常重启,不能从现有证据进一步声称某一条 CUDA 分配调用就是根因。
客户端恢复任务后,约 15:45 又出现一次相同级别的异常。现场同时观察到仅有的 2 GB swap 已满。原配置在模型、KV、Mamba cache 和 CUDA Graph 完成静态分配后,MemAvailable 只剩约 10.10 GB。对于 CPU、GPU 共用同一套物理内存的 Thor,这部分余量还要承受长 prefill 激活、CUDA/Triton 工作区、SSD 页缓存、Python 运行时和其他系统进程,空间过窄。
为了让下一次故障可观测,系统层先做了两项修改:
- 在原有 2 GB swap 之外增加 14 GB,总计 16 GB,并把
vm.swappiness设为 10; - 开启持久化 journal,日志目录为
/var/log/journal,上限 1 GB,并给文件系统保留 2 GB 空间。
服务配置则暂时缩到单路:max-total-tokens=262144、max-running-requests=1、max-mamba-cache-size=5、cuda-graph-max-bs-decode=1、mem-fraction-static=0.92。CUDA Graph 完成后,MemAvailable 从原来的约 10.10 GB 增加到 16.96 GB。
这次收缩没有彻底解决问题。一个真实请求推进到约 180K 时,可用内存已经降至约 2 GB;到约 204K 时设备再次异常重启。持久化日志显示记录在 16:11:43 突然中断,系统约两分钟后重新启动,符合 watchdog 行为。日志中没有 OOM killer 记录,也没有正常 shutdown 过程。这证明“单路 + 262K KV 容量”仍不足以保证最坏时刻的整机余量,但依然不能凭日志缺失把故障精确归到某一个分配栈。
三、KV cache 的 6 GB 账目其实是对的
服务启动日志给出了如下信息:
KV Cache is allocated. dtype: torch.bfloat16 #tokens: 262144 K size: 3.00 GB V size: 3.00 GB
Qwen3.8 Flash-Next 共有 48 层,其中每 4 层设置一个 full-attention 层,共 12 层。每层有 2 个 KV heads,head_dim=256,BF16 每元素 2 字节。于是主模型每个 token 的 K、V 总量为:
12 layers × 2 KV heads × 256 × 2 bytes × (K + V) = 24 KiB/token
乘以 262,144 tokens,正好得到 6 GiB。此外还有:
- 一个 MTP full-attention 层的 KV:约 0.5 GiB;
- QSA compressed-index K:约 192 MiB。
合计约 6.69 GiB。启动过程中的可用内存变化也与此相符:MTP 权重加载后约 24.45 GB,主 KV 与 QSA 分配后约 18.33 GB,草稿 KV 分配后约 17.32 GB,CUDA Graph 完成后约 16.96 GB。
因此,KV cache 并不是“只记录了虚拟地址、等请求变长才逐步实际占用”。它在启动阶段已经分配。运行期继续增长的是另一组对象:prefill 激活、QSA 选择过程的临时张量、MTP verify 中间量、Triton/CUTLASS 工作区,以及 PyTorch caching allocator 保留但尚未归还给系统的块。一次冷启动空闲测试中,empty_cache() 只释放了 48 MiB;这个结果只描述当时的进程状态,不能外推到已经运行过 200K prompt 的高水位进程。
四、vLLM 仍然不能替代 PLE 的 SSD streaming
排查中短暂回到 provsalt/Qwen3.8-Flash-Next-NVFP4-PLE-NVFP4 的 vLLM 路线,目的是确认能否用更成熟的服务框架换取稳定性。
该插件能够把 PLE 完整放入统一内存,实测 PLE 常驻约 26.822 GiB;主模型加载后总量约 73.6 GiB。剩余空间只建立了 2.88 GiB KV cache,对应约 41,506 tokens。32K 上下文可用,64K 已超出,按相同比例估算 262K 约需 18.2 GiB KV,缺口约 15.3 GiB。
vLLM 的 filesystem offload 针对的是 KV cache,不是把 PLE 做成 SGLang 这条路线中的按需 SSD streaming。性能方面,Thor 上可稳定运行的 Marlin 路径约为 14—15 tok/s;CUTLASS MoE 初始化返回 status 7,MTP GDN 又缺少 SM110 实现。于是这条路线在当前环境里既没有得到 262K 容量,也没有获得足以抵消容量损失的速度优势,服务随后切回 SGLang + SSD-stream。
五、把目标与草稿 KV 都改为 FP8
SGLang 上游 PR #36644 增加了 QSA 路径对 FP8 KV 的支持,但当时仍未合入使用中的 Qwen 分支。相关修改被移植到 Thor/SM110 运行时,并同时覆盖 target model 和 draft model 的 E4M3 KV。
在 262K 配置下,KV 总量由 BF16 的约 6.5 GiB 降到约 3.26 GiB,节省约 3.24 GiB。后续生产配置同时换用了前文测试过的 dense-path NVFP4 主模型。一次冷启动的主要账目如下:
| 项目 | 观察值 |
|---|---|
| 主模型 NVFP4 权重 | 77.97 GB |
| MTP NVFP4 权重 | 8.08 GB |
| 主模型 FP8 K + V | 1.5 + 1.5 GB |
| 草稿模型 FP8 K + V | 0.13 + 0.13 GB |
| Mamba FP32 状态 | 约 0.65 GB |
启动完成后 MemAvailable | 约 25.85 GB |
主模型和 MTP checkpoint 都没有提供 KV scale,SGLang 因而使用 1.0,并明确打印可能影响精度的警告。当前运行时没有一个可直接启用的在线校准开关。这里保留 FP8 KV,并把 scale=1.0 记录为已知限制;没有证据把它写成后来文本异常的既定原因。
六、用可检验的信息把上下文推到 261,888 tokens
KV 量化之后,需要分别检查容量、数值稳定性和长距离召回。测试文本选用《基督山伯爵》,在约 10%、50% 和 90% 位置嵌入三条不同信息。目标不是让模型自由续写一大段文字,而是让它在不同长度下回读三处针点,便于判断注意力结果是否仍然有效。
同时在 48 个 decoder 层和 1 个 MTP 层安装检测 hook,并开启 logits sanitizer。逐级结果如下:
| 实际 prompt tokens | 处理时间 | 三处针点 | 数值异常 |
|---|---|---|---|
| 32,912 | 30.16 s | 全部召回 | 未发现 |
| 65,679 | 59.39 s | 全部召回 | 未发现 |
| 131,071 | 108.07 s | 全部召回 | 未发现 |
| 196,608 | 157.97 s | 全部召回 | 未发现 |
| 245,760 | 198.91 s | 全部召回 | 未发现 |
| 260,800 | 209.83 s | 全部召回 | 未发现 |
| 261,888 | 209.87 s | 全部召回 | 未发现 |
最大一次请求的 token 总量为 261,939。测试期间没有记录到 NaN/Inf、scheduler error、OOM 或 sanitizer 告警。PyTorch allocated memory 约为 84.78 GB,基本保持不变;reserved memory 则随长度从约 90.49 GB 上升到 98.04 GB,MemAvailable 最低约 20.7 GiB,swap 只变化约 1—1.5 MiB。
这组结果的准确表述是:在这套确定性测试里,FP8 KV 能完成接近 262K 的 prefill 与召回,先前偶发的 NaN 没有复现。它并不等于所有提示、采样参数和长时间工作负载都已被证明没有数值问题。
七、继续增长的内存主要来自 QSA prefill 与分配器高水位
回到真实编程任务后,上下文从约 239K 增长到 254.6K 时,MemAvailable 又减少约 1.6 GB。同期文件缓存被回收约 2.1 GB,匿名内存没有出现同量级持续增长;在约 245K 到 260K 的区间,PyTorch reserved memory 跳升约 1.47 GB。到约 256.4K 后,MemAvailable 趋于平台,没有继续以同样速度下降。
源码检查定位到 QSA prefill 的一项主要临时分配:每个 full-attention 层需要构造 FP32 的 [query_rows, compressed_keys] logits,用来选择 top-k compressed KV。默认预算约为每层 128 MiB,12 个 full-attention 层的高水位总量约 1.5 GiB,与现场 reserved memory 的跃升相符。
这部分预算被改成可配置项,Thor 的生产值收紧到每层 32 MiB,合计约 384 MiB。修改只让同一工作分成更多 tiles,不改变 top-k 语义,也不改变 decode 阶段的张量形状。
上下文清空后,可用内存不会立刻完全回到冷启动值,因为 PyTorch allocator 会缓存达到过的工作区。服务因此增加:
export SGLANG_EMPTY_CACHE_INTERVAL=60
它与 --sleep-on-idle 配合,只在 scheduler 完全空闲时调用 torch.cuda.empty_cache();不会清空 radix cache 或仍在使用的 KV。其作用是归还空闲的 allocator block,不能代替为活跃请求预留运行期空间。
八、自动截断在页边界上制造了一个永不完成的请求
另一个故障出现在客户端提交超过窗口的会话时。界面反复停留在:
The model hit its output limit, so this response may be incomplete.
服务端原有逻辑先把输入截到 262,144 tokens,于是 max_new_tokens 被压成 0;scheduler 的最大输入限制实际是 262,138。随后 KV 以 64-token page 对齐,又把占用向上取整到 262,144,并且 admission 还需要额外页。普通 prefill-only 请求有结束路径,但带 MTP、max_new_tokens=0 的这个组合没有进入它,HTTP future 因而一直等待。
修复分成三层:
- tokenizer 截断时先保留客户端请求的输出预算;
- 输入上限按 KV page 做安全对齐,不让页取整重新顶满窗口;
- scheduler 增加边界保护,确保即使预算在后续阶段发生变化,请求也能结束。
离线小例子中,120-token 输入、8-token 输出、100-token 总上限和 90-token scheduler 上限,修复前会得到 90 + 0,修复后为 82 + 8。
真实回归中,一个原始 281,896 + 8 的请求被安全截为约 262,016-token prompt,168.18 秒后返回,随后短请求仍能正常处理。后续边界回归使用原始 262,871-token 输入,最终有效输入 261,110、输出预算 1,024、总量 262,134;请求用时 208.261 秒,以 finish_reason=length 正常结束,完成后 KV 占用归零。
服务没有设置一个全局固定的 max_new_tokens。每次实际输出上限是客户端 max_tokens 与“总窗口减去页安全输入和保留量”两者的较小值。finish_reason=length 表示本次 HTTP 响应已经结束;如果客户端仍显示为持续处理中,需要在客户端状态机里正确处理该结束原因。
九、四个并发槽,共享 557,056-token KV 池
完成单路边界修复后,容量目标调整为:保留原生 262,144-token 单请求窗口,同时提供四个并发槽,并让共享 KV 池覆盖两条接近 250K 的长会话。最终主要参数是:
--context 262144 --max-total-tokens 557056 --max-running-requests 4 --max-mamba-cache-size 16 --cuda-graph-max-bs-decode 4 --chunked-prefill-size 2048 --mem-fraction-static 0.95
target 与 draft KV 均使用 FP8 E4M3,KV page size 为 64;推测解码采用 NEXTN 3/1/4 与 ReplaySSM。
最初 max-mamba-cache-size 仍设为 5,虽然命令行写了四路并发,运行时实际上只能进入一路。原因是每个请求需要 target 状态加 3 个 draft 状态,共 4 个 Mamba state slots;四路并发需要 16 个槽。调整为 16 后,调度器才真正允许四个活跃请求。
557,056 是共享池,不是每路都有 544K。四路均分时平均约 139,264 tokens/路;短请求可以共享剩余空间,而两路长请求则可以同时接近 250K。
双路长会话耐久测试
测试使用两条并行线程和《基督山伯爵》长文本,每条线程执行三轮循环:
- 把上下文填充到接近 250K;
- 续写 1,024 tokens;
- 要求模型把上下文压缩到约一半;
- 再次填充并继续下一轮。
实测 continuation prompt 约为 249,933 tokens,compression prompt 约为 249,949 tokens;压缩后的上下文为 126,994—128,084 tokens,即原长度的 50.81%—51.24%。
整轮测试持续 2,400.94 秒,约 40 分钟,12 个阶段全部完成:
| 指标 | 结果 |
|---|---|
| 完成阶段 | 12/12 |
| 峰值同时 decode | 2 |
| 峰值 KV 占用 | 503,232 / 557,056 tokens(90.34%) |
最低 MemAvailable | 15.06 GiB |
| swap 增长 | 约 21 MiB |
| 服务或设备重启 | 0 |
| OOM / NaN / CUDA error / retraction | 0 |
| 测试结束后的 KV | 0 |
空闲 40 秒后的 MemAvailable | 16.75 GiB |
这轮测试验证了两条接近 250K 的会话能够并行经历“续写—压缩—再填充”循环,内存会增长到高水位后趋于稳定,并在请求结束后释放 KV。测试时长约 40 分钟,因此它不能替代 8—12 小时的持续负载观测。
十、最后出现的思维输出碎片化
容量与边界测试完成后,一次真实长会话的 reasoning_content 在前段正常,后段出现了类似下面的片段:
*4 For these items... *2-* *3 4.*
这不是 UTF-8 字节被错误解释后的字符乱码。返回内容由合法 token 组成,但语义与格式已经碎裂。现场请求约 237K tokens;当时 MTP 平均接受长度约 2.7—3.65,接受率约 0.57—0.88,解码速度约 39—51 tok/s。日志没有显示接受率跌到零,所以不能仅凭这次现象认定 MTP 失效。
同时确认了一个独立的生命周期问题:客户端已经断开并删除本地请求状态后,scheduler 仍继续生成同一个 request ID,KV 占用从约 237K 增长到 244K;手工调用 /abort_request 后才释放。这会浪费共享池和运行时间,但现有证据不足以把它与文本碎片化视为同一个根因。
可重复回归没有复现原始碎片
重启前执行了以下矩阵:
- thinking 开/关 × tools 有/无;
- 历史 reasoning 保留与不保留;
- 连续八轮工具调用,并把工具结果重新写入历史。
所有组合都能返回结构化 tool_calls,工具结果能被模型正确读取。八轮对话的 prompt 从 331 增长到 1,065 tokens;保存的 token 中没有 token ID 0,最长相同 token 连续次数为 1。这个回归覆盖了“思考 + 工具 + 历史回灌”,但长度远小于发生异常时的二十多万 tokens,所以只能说明短链路没有复现。
SGLang issue #36537 描述的是 thinking、tools 与 Qwen3-Coder 组合后进入 token ID 0 循环;本机测试没有出现 token 0。issue #37326 描述的是接受率逐步降到零、速度从 47.1 tok/s 降到 16.4 tok/s;现场接受率仍然较高。两者都与本次现象有部分相似处,但症状不一致。
运行时确实缺少三处 Mamba/NEXTN 修复
源码对照发现,Thor 分支尚未包含上游 PR #35821 中的三类边界保护:
- 拒绝
cache_len为None或 0 的 ghost cache nodes; - eager/KDA 路径的状态跟踪索引需要限制在
accept_lens - 1; - fused Triton 路径需要使用相同的索引上限。
这些修改已移植为带源文件哈希检查的持久化补丁,安装脚本重复执行不会重复修改未知版本;修改后通过 py_compile,重启后的短 thinking/tool 请求能正常返回结构化工具调用,没有 token 0。
这里可以确认的是:运行时缺失的三处 Mamba/NEXTN 边界保护已补齐。由于原始碎片在补丁前没有稳定复现,也没有获得同一输入的补丁前后 A/B,现阶段不能证明它们就是那次思维输出异常的唯一根因。如果异常再次出现,需要同步保存完整请求体、采样参数、逐 token ID、request ID、MTP 接受序列和客户端断开时间,才能继续缩小范围。
相关持久化修复已经提交到 garnermccloud/sglang-ssd-stream PR #2。对外服务名按原配置保留为:
Qwen/Qwen3.6-27B-NVFP4
当前结论
截至本轮测试,Thor 上这套 Qwen3.8 Flash-Next 服务的已验证状态如下:
- 主模型与 MTP 采用 dense-path NVFP4,PLE 继续走 SSD streaming;
- target 与 draft KV 使用 FP8 E4M3,262K 单请求上下文通过逐级召回测试;
- QSA prefill 临时内存预算已从约 1.5 GiB 收紧到约 384 MiB;
- 超长输入会为输出保留预算,并按 64-token KV page 安全截断,不再形成零输出预算死锁;
- 共享 KV 池为 557,056 tokens,提供四个调度槽,已验证两路接近 250K 的 40 分钟循环负载;
- 已增加 16 GB swap、持久化 journal 和空闲期 allocator cache 回收;
- 缺失的 Mamba/NEXTN 边界补丁已经持久化;
- FP8 KV scale 固定为 1.0 仍是已知精度限制;一次思维输出碎片化的唯一根因仍未被证明。
与上一篇的“成功启动”相比,这一轮工作的变化主要体现在可观测性和边界行为:内存由哪些部分构成、增长到哪里、何时归还;超长请求如何截断并结束;四个槽如何共享总 token 池;以及哪些故障已经复现并修复,哪些仍只有一次现场样本。当前配置足以继续承载实际编程任务,同时应保留长期内存监控和失败请求采样,以覆盖 40 分钟耐久测试之外的运行时间。
相关资料
- SGLang PR #36644:QSA FP8 KV cache
- SGLang PR #35821:Qwen Mamba cache 修复
- SGLang issue #36537
- SGLang issue #37326
- PyTorch CUDA memory management
- sglang-ssd-stream PR #2
