把 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 shardsattention、GDN 投影和 shared expert 等 dense path 也使用 NVFP4
routed experts、MTP、PLE sidecargarnermccloud/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.422.03 tok/s
0.4—0.832.81 tok/s
≥ 0.843.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 + V1.5 + 1.5 GB
草稿模型 FP8 K + V0.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,91230.16 s全部召回未发现
65,67959.39 s全部召回未发现
131,071108.07 s全部召回未发现
196,608157.97 s全部召回未发现
245,760198.91 s全部召回未发现
260,800209.83 s全部召回未发现
261,888209.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 因而一直等待。

修复分成三层:

  1. tokenizer 截断时先保留客户端请求的输出预算;
  2. 输入上限按 KV page 做安全对齐,不让页取整重新顶满窗口;
  3. 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。

双路长会话耐久测试

测试使用两条并行线程和《基督山伯爵》长文本,每条线程执行三轮循环:

  1. 把上下文填充到接近 250K;
  2. 续写 1,024 tokens;
  3. 要求模型把上下文压缩到约一半;
  4. 再次填充并继续下一轮。

实测 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
峰值同时 decode2
峰值 KV 占用503,232 / 557,056 tokens(90.34%)
最低 MemAvailable15.06 GiB
swap 增长约 21 MiB
服务或设备重启0
OOM / NaN / CUDA error / retraction0
测试结束后的 KV0
空闲 40 秒后的 MemAvailable16.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 中的三类边界保护:

  1. 拒绝 cache_len 为 None 或 0 的 ghost cache nodes;
  2. eager/KDA 路径的状态跟踪索引需要限制在 accept_lens - 1;
  3. 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 分钟耐久测试之外的运行时间。


相关资料

类似文章

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理。