Qwen3 长上下文部署实战:一条 vLLM 命令吃下 75 万 token 的 NUMA 拓扑实测

发布时间:2026/10/17 22:17:01
Qwen3 长上下文部署实战:一条 vLLM 命令吃下 75 万 token 的 NUMA 拓扑实测
1. 单机多卡跑 Qwen3 长上下文为什么总是卡在显存和带宽上如果你最近在折腾 Qwen3 这类 27B 级别的模型想在单机 4 卡上把上下文拉到几十万 token大概率会遇到两个现象一是启动时显存看着够跑起来 KV Cache 一涨就 OOM二是单卡 benchmark 数字很漂亮一上 TP4 吞吐连 3 倍都不到GPU-Util 还长期趴在 40% 以下。这两个现象背后其实是同一件事——长上下文推理的瓶颈从来不是算力而是显存容量和多卡通信带宽。我这次实测的环境是 4 张 RTX 4090 加单路 EPYC模型用的是带 W4A16 量化、vision 和 mtp 头的 Qwen3 27B 级权重。目标很明确让这套配置稳定吃下 75 万 token 的输入同时把首 token 延迟TTFT和吞吐控制在一个能接受的范围。整个过程里真正决定成败的不是某个神奇参数而是三件事——KV Cache 容量账算得准不准、调度器保护开关关得对不对、NUMA 拓扑有没有被忽略。先说结论75 万 token 能不能跑取决于你的 GPU KV Cache 装不装得下一条满载请求。以 64 层、8 个 KV 头、head_dim 128 的典型配置估算FP8 KV Cache 下单个 token 的 KV 占用大约是 131072 字节64 层 × 2 × 8 头 × 128 维 × 1 字节。4 卡各分 10 GiB 给 KV总共约 42.9 GB折算下来大概 32.8 万 token。也就是说一条 75 万 token 的请求光靠 GPU 层是装不下的必须靠 CPU 内存和本地盘做分层卸载。这就是为什么后面那条启动命令里KV 卸载配置和调度器开关必须成对出现。这一节先把问题定义清楚你要解决的不是怎么把 max-model-len 写成 750000而是在给定显存预算下怎么让长请求不 OOM、短请求不掉质量、多卡通信不拖后腿。想明白这个后面的参数才有意义。2. TaoToken 前置准备模型权重、API Key 与接入文档在动手改 vLLM 启动参数之前有一件事值得先做——把模型调用链路的凭证和文档准备好。我这次实测用的是 TaoToken 的 API 通道来对照验证模型输出这样可以在本地 vLLM 实例和线上通道之间做交叉比对确认长上下文下模型行为是否一致。具体操作上你需要先拿到 API Key。打开 https://taotoken.net/api-keys 登录后创建一个新的 Key复制出来保存好。这个 Key 后面会用在 OpenAI 兼容的客户端里格式是sk-开头的一串字符。注意不要在代码里硬编码用环境变量传进去。拿到 Key 之后Base URL 填https://taotoken.net/api这是 OpenAI 兼容接口的根地址。Model ID 根据你要验证的模型填比如qwen3-27b这类标识。这三件套——Base URL、API Key、Model ID——是任何 OpenAI 兼容客户端接入的必备信息缺一不可。如果你对参数含义或者接入方式有疑问可以查接入文档https://taotoken.net/doc 。文档里对请求格式、流式输出、错误码都有说明遇到 401 或者 model not found 的时候先翻这里。为什么要先做这一步因为长上下文部署最容易出的问题不是启动失败而是启动成功了但输出不对。本地 vLLM 实例和线上通道同时跑同一段长 prompt对比输出质量能帮你快速判断是模型本身的问题还是部署配置的问题。比如你开了 YaRN 之后发现短文本质量下降用线上通道跑同样的短文本如果线上正常、本地掉点那基本可以确定是静态 YaRN 的缩放副作用。另外如果你打算长期跑编码或者 Agent 场景可以了解一下 Coding Planhttps://taotoken.net/coding-plan 。它适合那种需要持续调用、对稳定性和额度有要求的用法跟本地部署是互补关系——本地负责低延迟批量推理线上通道负责兜底和对照。3. 可复制的 vLLM 启动配置从 KV 卸载到 NUMA 绑核这一节给出完整的启动命令和配置文件。先看命令我把它拆成带续行符的形式方便阅读粘贴回 shell 时注意 JSON 参数外层必须用单引号包住否则 shell 会吃掉双引号。vllm serve /data/vllm_models/Qwen3-VL-27B-W4A16-mtp \ --served-model-name qwen3-27b \ --tensor-parallel-size 4 \ --max-model-len 750000 \ --gpu-memory-utilization 0.85 \ --kv-cache-dtype fp8_e4m3 \ --kv-cache-memory 10737418240 \ --enable-prefix-caching \ --enable-chunked-prefill \ --max-num-batched-tokens 8192 \ --max-num-seqs 96 \ --no-scheduler-reserve-full-isl \ --kv-transfer-config { kv_connector: OffloadingConnector, kv_role: kv_both, kv_connector_extra_config: { cpu_bytes_to_use: 214748364800, eviction_policy: arc, offload_prompt_only: true, n_read_threads: 16, n_write_threads: 4 } } \ --speculative-config { method: mtp, num_speculative_tokens: 3, use_local_argmax_reduction: true } \ --rope-scaling { rope_type: yarn, factor: 3.0, original_max_position_embeddings: 262144 } \ --limit-mm-per-prompt {image: 40} \ --mm-processor-kwargs {truncation: false} \ --mm-ipc-gpu-memory-gb 0 \ --compilation-config {cudagraph_mode: PIECEWISE} \ --disable-custom-all-reduce \ --reasoning-parser qwen3 \ --tool-call-parser qwen3_coder \ --enable-auto-tool-choice \ --enable-prompt-tokens-details这段命令里--kv-cache-memory 10737418240是每卡 10 GiB4 卡合计约 42.9 GB。cpu_bytes_to_use是 200 GiB注意这是所有 worker 加起来的总量不是每卡 200 GB。eviction_policy用 arc对周期性访问比默认 lru 更友好。offload_prompt_only默认 true只卸载 prefill 块decode 块不卸载。关于 RoPE 缩放factor 3.0和max-model-len 750000是对得上的。YaRN 的外推逻辑是max-model-len original_max_position_embeddings × factor262144 × 3.0 786432750000 留了余量。这里有个坑Qwen3-VL 用的是 Interleaved-MRoPEposition id 增长比普通 RoPE 慢所以 factor 要取得比直觉值小。同样从 256K 外推到 1M普通 RoPE 用 4.0这里只能用 2.0 到 3.0。如果你用的是 Qwen3-Next 系列权重method要改成qwen3_next_mtp。启动失败时先试另一个名字。另外 KV Cache 大小这个参数在不同版本里名字不一样看到--kv-cache-memory用前者看到--kv-cache-memory-bytes用后者语义完全相同。NUMA 绑核这块如果你的机器是双路 CPU建议用numactl把进程绑到跟 GPU 同一侧的 NUMA 节点。先跑nvidia-smi topo -m看拓扑如果矩阵里出现 SYS说明卡跨了 NUMA 边界。这时候可以考虑拆成 2 × TP2每个实例绑本地 NUMAall-reduce 路径从每轮 2 跳跨 NUMA 降到 0 跳。4. 验证请求与成功结果75 万 token 实测数据配置写好了接下来要验证它真的能跑。先启动服务观察日志里必须确认的几行。第一行是 FP8 prefill attention 是否生效日志里会打印类似Using FP8 prefill attention的确认信息。没看到这行说明--kv-cache-dtype和 attention backend 没对上。第二行是 KV Cache 容量会打印GPU KV cache size: 327,680 tokens这样的数字。第三行是 speculative acceptance rateMTP 生效后会周期性打印接受率。启动完成后用一段长文本做验证。我构造了一个约 75 万 token 的输入用 curl 发请求curl -X POST http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -d { model: qwen3-27b, messages: [{role: user, content: $(cat long_prompt.txt)}], max_tokens: 256, temperature: 0.7, top_p: 0.8, top_k: 20 }实测下来75 万 token 输入的 TTFT 大约在 40 到 60 秒之间取决于前缀缓存命中率。如果前缀完全没命中chunked prefill 要切 92 刀750000 ÷ 8192 ≈ 92每刀一次 forward累积起来就是这个量级。如果前缀命中率高TTFT 能压到 10 秒以内。输出吞吐方面decode 阶段在 MTP 接受率 70% 左右时单实例能跑到 25 到 35 token/s。这里要提醒一点--max-num-seqs 96在这套配置下几乎永远到不了。真正卡住你的是 KV Cache 容量。以平均输入长度 32K 算GPU 层只能容纳 10 条并发加上 CPU 层能到 60 条。所以评估这套配置不要问能撑多少并发要问我的平均 ISL 乘上需要的并发数有没有超过 32.8 万 token。验证模型输出质量的时候我建议同时用线上通道跑一遍同样的 prompt。打开 https://taotoken.net/models 可以看模型列表和对话入口把长文本贴进去对比输出。如果本地输出明显变差优先检查 YaRN factor 和 RoPE 配置。5. 本篇常见错误排查401、local proxy failed、reading choices、OAuth长上下文部署的报错往往不在模型本身而在接入链路和配置细节。这一节列几个我踩过的坑。第一个是 401 Unauthorized。如果你在客户端里调 TaoToken 的 API 报 401先检查 API Key 有没有带对。Base URL 是https://taotoken.net/api注意结尾不要多加/v1有些客户端会自动补。Key 放在Authorization: Bearer sk-xxx头里。如果 Key 没问题还是 401去 https://taotoken.net/console 看下额度是不是用完了。第二个是 local proxy failed。这个报错通常出现在客户端配置了本地代理但代理没起来的时候。检查你的环境变量HTTP_PROXY和HTTPS_PROXY如果不需要代理就清掉。注意这里说的是本地网络配置跟模型部署无关。第三个是 reading choices 相关报错典型信息是Error reading choices from response或者choices is null。这通常是服务端返回了非标准格式或者流式输出被中途截断。先确认 vLLM 实例的/v1/models能正常返回再用非流式请求试一次。如果非流式正常、流式报错检查客户端的 SSE 解析逻辑。第四个是 OAuth 相关错误。如果你用的是 Claude Code 或者某些需要 OAuth 的客户端报OAuth token expired或者invalid_grant需要重新走一遍授权流程。这类客户端接入时Base URL、API Key、Model ID 三件套要填全。比如 Claude Code 的配置里ANTHROPIC_BASE_URL填https://taotoken.net/apiANTHROPIC_API_KEY填你的 KeyModel ID 填对应模型标识。还有一个容易忽略的报错是启动时的CUDA error: an illegal memory access was encountered。这个在长上下文加多模态加投机解码的组合下容易出现加上--compilation-config {cudagraph_mode: PIECEWISE}可以解决。PIECEWISE 的含义是 attention 保持 eager其余算子进 cudagraph兼容性最好。最后提醒一个参数名版本问题。--kv-cache-memory和--kv-cache-memory-bytes在不同 vLLM 版本里名字不一样报unrecognized arguments的时候先确认版本。另外一旦显式指定了这个值--gpu-memory-utilization就不再参与 KV 容量推算但仍然作为进程显存总上限生效。6. 长上下文部署的取舍思路与后续接入这套配置跑下来我最大的感受是长上下文从来不是把参数拉满而是一道关于取舍的应用题。75 万 token 这个数字本身没有意义有意义的是你为了支撑它付出了什么代价以及这些代价在你的业务场景里是否划算。第一个取舍是容量和质量的平衡。开了 YaRN factor 3.0 之后你那些 2K、4K 的短请求质量也会被缩放白白损失精度。如果你的业务绝大多数请求在 32K 以内建议把 factor 降到 1.5 甚至不开 YaRNmax-model-len 跟着降到真实水位。如果长短请求混跑拆成两套实例网关按 prompt 长度路由。第二个取舍是分层卸载的收益验证。磁盘层不是便宜显存它的收益取决于 CPU 带宽、NUMA 拓扑、块大小、IO 并发、命中率分布。建议先只开 CPU 主层压一轮记录命中率和 P99再打开 FS 层对比同一压测下的吞吐和 P99只有正收益才保留。第三个取舍是 NUMA 拓扑。三层的容量账算得再准卡插在 NUMA 边界两侧收益照样腰斩。上线前务必跑一次nvidia-smi topo -m看到 SYS 就说明多卡收益正在被 NUMA 边界吃掉。这时候可以考虑去掉--disable-custom-all-reduce或者换 NCCL 算法或者拆成 2 × TP2。后续接入方面如果你需要长期跑编码或者 Agent 场景可以看看 Coding Planhttps://taotoken.net/coding-plan 。本地部署负责低延迟批量推理线上通道负责兜底和对照两者互补。模型对话入口在 https://taotoken.net/models 接入文档在 https://taotoken.net/doc API Key 管理在 https://taotoken.net/api-keys 。遇到接入问题先翻文档大部分报错都有对应说明。最后留一个自检清单启动日志里确认 FP8 prefill attention 生效、GPU KV cache size 数字、speculative acceptance rate上线前跑一次nvidia-smi topo -m压测重点看 P99 TTFT、前缀缓存命中率、MTP 接受率而不是平均吞吐。这三步做完你的长上下文服务基本就稳了。