☰
Qwen3 长上下文部署实战:一条 vLLM 命令吃下 75 万 token 的 NUMA 拓扑实测
2026/10/17 22:16:48 网站建设 项目流程

1. 单机多卡跑 Qwen3 长上下文,为什么总是卡在显存和带宽上

如果你最近在折腾 Qwen3 这类 27B 级别的模型,想在单机 4 卡上把上下文拉到几十万 token,大概率会遇到两个现象:一是启动时显存看着够,跑起来 KV Cache 一涨就 OOM;二是单卡 benchmark 数字很漂亮,一上 TP=4 吞吐连 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 Plan:https://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 GiB,4 卡合计约 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 × factor,262144 × 3.0 = 786432,750000 留了余量。这里有个坑:Qwen3-VL 用的是 Interleaved-MRoPE,position 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 × TP=2,每个实例绑本地 NUMA,all-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 rate,MTP 生效后会周期性打印接受率。

启动完成后,用一段长文本做验证。我构造了一个约 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/api,ANTHROPIC_API_KEY填你的 Key,Model 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 甚至不开 YaRN,max-model-len 跟着降到真实水位。如果长短请求混跑,拆成两套实例,网关按 prompt 长度路由。

第二个取舍是分层卸载的收益验证。磁盘层不是"便宜显存",它的收益取决于 CPU 带宽、NUMA 拓扑、块大小、IO 并发、命中率分布。建议先只开 CPU 主层压一轮,记录命中率和 P99,再打开 FS 层对比同一压测下的吞吐和 P99,只有正收益才保留。

第三个取舍是 NUMA 拓扑。三层的容量账算得再准,卡插在 NUMA 边界两侧,收益照样腰斩。上线前务必跑一次nvidia-smi topo -m,看到 SYS 就说明多卡收益正在被 NUMA 边界吃掉。这时候可以考虑去掉--disable-custom-all-reduce,或者换 NCCL 算法,或者拆成 2 × TP=2。

后续接入方面,如果你需要长期跑编码或者 Agent 场景,可以看看 Coding Plan:https://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 接受率,而不是平均吞吐。这三步做完,你的长上下文服务基本就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询