8 张 RTX 5090 跑 Qwen3.6-27B:从装 vLLM 到压测调优的真实数据(含完整脚本)
这台 8 卡 5090 的机器从把 vLLM 装到调到能稳定服务 65 QPS过程、数字、脚本都在这里。所有数据均为实测无估算、无推算所有脚本均为线上可直接复用版本。一、这台机器长什么样部件配置GPU8 × RTX 509032GB GDDR7合计 256GB 显存CPU2 × Intel Xeon Gold 6530合计 128 线程内存512GB DDR5-5600存储7TB NVMe数据盘 894GB SATA SSD系统盘系统Ubuntu 26.04 LTS Kernel 6.17驱动 / CUDANVIDIA 580.142 CUDA 13.1NUMA 拓扑是典型的 44GPU 0-3 在 NUMA 0GPU 4-7 在 NUMA 1。这台机器目前只用前 4 张卡跑 LLM后 4 张留给图像生成。这套硬件的硬伤先说在前面消费卡 5090 之间没有 NVLinkGPU 间通信只能走 PCIe比专业卡慢 30-50%nvidia-smi 看 P2P 矩阵全是 Chipset not supported——驱动层面禁用了 GeForce 卡的 P2P这会让张量并行TP的 AllReduce 走主机内存中转是吞吐天花板的主要来源二、软件栈选 vLLM 不选 SGLang为什么对比过 vLLM、SGLang、TensorRT-LLM 三家。最终选 vLLM 的理由框架sm_120 (Blackwell) 支持模型加载难度选择vLLM 0.20.2主线支持CUDA 13 PyTorch 2.11 已默认简单✅SGLangFP8 blockwise 在 sm_120 上落后 vLLM 半年中等❌TensorRT-LLMNVIDIA 自家性能最强极高每次改参数都要重编译❌模型选了Qwen3.6-27B2026 年 4 月开源密集 27B原生 262K 上下文SWE-bench 77.2%。关键细节Qwen3.6-27B 有个已知问题——在 CUDA 13.2 上会输出乱码必须用 CUDA 13.1 或 12.x。这台机器的 13.1 是安全区。三、第一轮测试默认参数下能跑多少启动成功后先用混合业务负载压测80% 直答短输出 20% 深度思考长输出最大输出 1024 token。第一轮结果max-num-seqs64max-model-len65536并发请求数QPS首字延迟 P99完整响应延迟 P99101.86.9 秒24 秒305.3223 ms19 秒648.7334 ms27 秒QPS 只有 8.7明显被卡住了。但 vLLM 内部日志暴露了真相Running: 64 reqs, Waiting: 0 reqs, GPU KV cache usage: 25%并发被max-num-seqs64死死卡住KV cache 才用了 25%——还有 3 倍空间没动用。四、第二轮调参后的真实能力改了五个参数参数旧值新值为什么改max-num-seqs64256KV 才用 25%能撑 4 倍并发max-model-len6553616384缩单请求上下文腾空间给更多并发enable-chunked-prefill关开长 prompt 分块处理不阻塞 decodemax-num-batched-tokens默认16384chunked-prefill 单步处理 token 上限gpu-memory-utilization0.900.92显存再多榨一点重启后做了两组场景化压测。场景一一般对话max_tokens150-256并发请求数QPS首字延迟 P99完整响应延迟 P99104.7172 ms2.9 秒3015.1209 ms3.7 秒6422.9255 ms5.0 秒12829.1458 ms8.3 秒20032.2694 ms13.5 秒场景二短问答max_tokens50模拟客服/翻译/简单查询并发请求数QPS首字延迟 P99完整响应延迟 P991013.4168 ms1.5 秒3028.1193 ms1.7 秒6445.2292 ms2.3 秒12860.2419 ms3.4 秒20064.9⭐727 ms5.0 秒25664.7843 ms6.3 秒两个相邻档位 QPS 完全相同64.9 vs 64.7——这是教科书般的算力到顶信号。再加并发也只是让请求排队等。真实生成速度指标数字单实例总 token 吞吐约 2500 tokens/秒单请求生成速度流式给单用户约 80 tokens/秒单 token 延迟约 18ms用户体感非常流畅4 卡平摊单卡吞吐约 625 tokens/秒/卡五、QPS 上限究竟在哪短输出场景跑出 65 QPS 时vLLM 日志显示Running: 241 reqs, KV cache 96%, Avg gen throughput: 2000 tok/s这次卡在了 KV cache 96%。同时 256 并发档位 QPS 反而比 200 档下降一点点64.7 vs 64.9说明 KV cache 已经满到开始抢占。一般场景峰值 32 QPS 时KV cache 用到 81%Running 195。两组数据合起来说明4 卡 5090 无 P2P 的物理上限是 2000-2500 tokens/秒。同样的硬件业务输出长度决定 QPS 天花板。六、不同业务场景下的真实 QPS 上限业务场景平均输出 token单请求耗时单实例 QPS 上限数据来源短问答 / 客服300.4 秒65✅ 实测翻译 / 简短改写500.6 秒约 50推算代码补全801.0 秒约 40推算一般对话1502 秒32✅ 实测长文摘要5006 秒约 10推算只有短问答和一般对话两档是实测其它档位是按 2500 tok/s 总吞吐推算。七、思考模式 vs 直答模式Qwen3.6 是新一代思考型模型默认开启 thinking。做了对比场景直答模式耗时思考模式耗时思考链长度用一句话介绍北京0.44 秒7.4 秒被 max_token 截断2200 token3 开关找灯泡谜题3.7 秒7.4 秒被截断2000 tokenPython 斐波那契生成器0.85 秒7.4 秒被截断1800 token结论生产环境 API 默认应该关闭思考模式让客户端通过参数显式启用——简单对话不需要思考复杂推理任务才需要。八、优化空间还在哪当前 65 QPS / 32 QPS 是 BF16 精度下的成绩。后续还可以走的路优化方向预期 QPS 提升工作量缩 max-model-len 到 8192短输出场景可冲 100 QPS5 分钟INT8 量化W8A880-100%半天AWQ-W4 量化50-70%半天N-gram speculative decoding30-50%1 天拆 DP2 TP2 双实例30-50%数天65 QPS 短问答 / 32 QPS 一般对话已经够用先稳定运行量化等系统跑顺再做。九、几个软细节第一个消费卡跑生产 API技术上没问题法务上有灰色。NVIDIA GeForce 驱动 EULA 禁止在数据中心环境用 GeForce。国内执行松但你应该知道。第二个Ubuntu 26.04 Kernel 6.17 是 2026 年 4 月的新版本几乎所有 ML 框架的官方测试目标都是 24.04。用新系统的代价是踩了好几天的 DKMS 编译坑。如果你还在做选择强烈建议用 Ubuntu 24.04。第三个消费卡 8 张挤在一台机器里整机峰值功耗 5.5kW。机房电力、散热、噪音都要规划。十、总结维度数据短问答场景 QPS65实测一般对话场景 QPS32实测单实例峰值 token 吞吐2500 tokens/秒单字延迟用户体感18ms流畅首字延迟 P99短输出 200 并发727 ms适合业务类型中等量级 API 服务、企业内部 AI 工具、对延迟敏感的实时应用不适合极高吞吐千 QPS 的 C 端应用需要集群8 张 RTX 5090 用 4 张跑 Qwen3.6-27B BF16单台机器在短问答场景能稳定服务 65 QPS在一般对话场景能扛 32 QPS。如果是更小的模型如 Qwen3.6-7B同样硬件 QPS 还能再翻一倍。剩下的 4 张卡正在跑图像生成服务下一篇会讲那部分。数据就这些。值不值每个团队的业务量级不同自己算。附录完整可复现脚本下面是这套服务实际在用的脚本复制即可使用路径按需替换。启动脚本/data/services/start-vllm.sh#!/bin/bash set -e MODEL_PATH/data/models/llm/Qwen3.6-27B-source LOG_FILE/data/logs/vllm.log PORT8000 source /data/envs/llm/bin/activate # Blackwell RTX 5090 关键环境变量 export VLLM_ATTENTION_BACKENDFLASHINFER export NCCL_P2P_DISABLE1 export NCCL_SHM_DISABLE0 export NCCL_DEBUGWARN export NCCL_IB_DISABLE1 export NCCL_DMABUF_ENABLE1 export NCCL_CUMEM_ENABLE1 # 只用 NUMA 0 的 4 张卡 export CUDA_VISIBLE_DEVICES0,1,2,3 # vLLM 参数 export VLLM_USE_FLASHINFER_SAMPLER0 export VLLM_ALLOW_LONG_MAX_MODEL_LEN1 export PYTHONUNBUFFERED1 # cuDNN 路径(让 PyTorch 找到 pip 装的 cuDNN) SITE_PACKAGES$(/data/envs/llm/bin/python -c import site; print(site.getsitepackages()[0])) export LD_LIBRARY_PATH${SITE_PACKAGES}/nvidia/cudnn/lib:${SITE_PACKAGES}/nvidia/cublas/lib:${LD_LIBRARY_PATH} # 绑定到 NUMA 0 exec numactl --cpunodebind0 --membind0 \ vllm serve $MODEL_PATH \ --served-model-name qwen3.6-27b \ --host 0.0.0.0 \ --port $PORT \ --tensor-parallel-size 4 \ --max-model-len 16384 \ --gpu-memory-utilization 0.92 \ --max-num-seqs 256 \ --enable-chunked-prefill \ --max-num-batched-tokens 16384 \ --enable-prefix-caching \ --enable-auto-tool-choice \ --tool-call-parser qwen3_coder \ --reasoning-parser qwen3 \ --disable-custom-all-reduce \ --trust-remote-code \ 21 | tee -a $LOG_FILE几个关键设计点numactl --cpunodebind0 --membind0把整个 vLLM 进程钉在 NUMA 0避免跨 socket 访存。CPU 和内存都绑死。VLLM_ATTENTION_BACKENDFLASHINFERBlackwell 不支持 FlashAttention-3必须切 FlashInfer。NCCL_P2P_DISABLE1NCCL_DMABUF_ENABLE1消费卡 P2P 被禁的两个 workaround。cuDNN/cuBLAS 路径注入因为 cuDNN 是 pip 装在 conda env 里的PyTorch 默认找不到需要显式指定。tool-call-parser qwen3_coderreasoning-parser qwen3让 vLLM 正确解析 Qwen3.6 的工具调用和思考链输出。停止脚本/data/services/stop-vllm.sh#!/bin/bash pkill -SIGTERM -f VLLM:: 2/dev/null pkill -SIGTERM -f vllm serve 2/dev/null sleep 3 pkill -9 -f VLLM:: 2/dev/null pkill -9 -f vllm serve 2/dev/null sleep 2 echo 残余进程 ps aux | grep -E VLLM|vllm | grep -v grep || echo 无残余 echo GPU 占用 nvidia-smi --query-compute-appspid,process_name --formatcsv echo 端口 8000 sudo ss -lntp 2/dev/null | grep :8000 || echo 端口空闲为什么要这样写vLLM 启动后会派生若干个VLLM::Worker_TPx子进程名字不含 vllm。一开始用pkill -f vllm杀不干净每次重启都 OOM。这个脚本先发 SIGTERM 优雅退出3 秒后再 SIGKILL 强杀最后报告残余状态可以一眼看清是否清干净。一般场景压测脚本/tmp/realistic_test.py跑出 32 QPS 那组数据的脚本import asyncio, aiohttp, time, statistics, json, random URL http://localhost:8000/v1/chat/completions DURATION 30 PROMPTS [ 今天天气怎么样?, 推荐一首歌, 你好, 帮我翻译: hello world, 讲个笑话, 什么是 Python?, 11 等于几, 北京有什么景点, 怎么减肥, 明天会下雨吗, ] async def one_request(session, results): payload { model: qwen3.6-27b, messages: [{role: user, content: random.choice(PROMPTS)}], max_tokens: 150, temperature: 0.7, stream: True, chat_template_kwargs: {enable_thinking: False}, } t0 time.perf_counter() first None n 0 try: async with session.post(URL, jsonpayload, timeoutaiohttp.ClientTimeout(total60)) as r: async for line in r.content: line line.decode().strip() if not line.startswith(data: ) or line data: [DONE]: continue try: chunk json.loads(line[6:]) delta chunk[choices][0].get(delta, {}) if delta.get(content) or delta.get(reasoning): if first is None: first time.perf_counter() n 1 except Exception: pass t1 time.perf_counter() if first and n 0: results.append({ ttft_ms: (first - t0) * 1000, total_ms: (t1 - t0) * 1000, tokens: n, }) except Exception as e: results.append({error: str(e)[:80]}) async def worker(session, results, stop_at): while time.perf_counter() stop_at: await one_request(session, results) async def run(concurrency, duration): print(f\n并发 {concurrency} × {duration}s) results [] stop_at time.perf_counter() duration async with aiohttp.ClientSession(connectoraiohttp.TCPConnector(limitconcurrency*2)) as session: tasks [worker(session, results, stop_at) for _ in range(concurrency)] await asyncio.gather(*tasks) ok [r for r in results if error not in r] err [r for r in results if error in r] if not ok: print(f 全部失败: {err[:1]}) return ttfts sorted([r[ttft_ms] for r in ok]) totals sorted([r[total_ms] for r in ok]) total_tokens sum(r[tokens] for r in ok) actual_qps len(ok) / duration def pct(arr, p): i max(0, min(len(arr)-1, int(len(arr)*p/100))) return arr[i] print(f 成功 {len(ok)} 失败 {len(err)} | QPS {actual_qps:.1f} | gen ~{total_tokens/duration:.0f} chunk/s) print(f TTFT P50/P99: {statistics.median(ttfts):.0f} / {pct(ttfts,99):.0f} ms) print(f 时延 P50/P99: {statistics.median(totals):.0f} / {pct(totals,99):.0f} ms) async def main(): for c in [10, 30, 64, 128, 200]: await run(c, DURATION) asyncio.run(main())短输出场景压测脚本/tmp/short_output_test.py跑出 65 QPS 那组数据的脚本import asyncio, aiohttp, time, statistics, json, random URL http://localhost:8000/v1/chat/completions MODEL qwen3.6-27b DURATION 30 SHORT_PROMPTS [ 11?, 中国首都是哪里, 今天周几, 你好, Python 怎么读取文件, 翻译: cat, 什么是 GPU, JavaScript 缩写, 圆周率前 5 位, 推荐一本书, 周末快乐怎么说, Hello, 1024 是 2 的几次方, HTTP 默认端口, 微信英文是, ] async def one_request(session, results): payload { model: MODEL, messages: [{role: user, content: random.choice(SHORT_PROMPTS)}], max_tokens: 50, temperature: 0.7, stream: True, chat_template_kwargs: {enable_thinking: False}, } t0 time.perf_counter() first None n 0 try: async with session.post(URL, jsonpayload, timeoutaiohttp.ClientTimeout(total30)) as r: async for line in r.content: line line.decode().strip() if not line.startswith(data: ) or line data: [DONE]: continue try: chunk json.loads(line[6:]) delta chunk[choices][0].get(delta, {}) if delta.get(content) or delta.get(reasoning): if first is None: first time.perf_counter() n 1 except Exception: pass t1 time.perf_counter() if first and n 0: results.append({ ttft_ms: (first - t0) * 1000, total_ms: (t1 - t0) * 1000, tokens: n, }) except Exception as e: results.append({error: str(e)[:80]}) async def worker(session, results, stop_at): while time.perf_counter() stop_at: await one_request(session, results) async def run(concurrency, duration): print(f\n并发 {concurrency} × {duration}s (短输出: max_tokens50)) results [] stop_at time.perf_counter() duration async with aiohttp.ClientSession(connectoraiohttp.TCPConnector(limitconcurrency*2)) as session: tasks [worker(session, results, stop_at) for _ in range(concurrency)] await asyncio.gather(*tasks) ok [r for r in results if error not in r] err [r for r in results if error in r] if not ok: print(f 全部失败: {err[:1]}) return ttfts sorted([r[ttft_ms] for r in ok]) totals sorted([r[total_ms] for r in ok]) token_counts [r[tokens] for r in ok] avg_tokens sum(token_counts) / len(token_counts) actual_qps len(ok) / duration def pct(arr, p): i max(0, min(len(arr)-1, int(len(arr)*p/100))) return arr[i] print(f 成功 {len(ok)} 失败 {len(err)} | QPS {actual_qps:.1f} | 平均输出 {avg_tokens:.0f} chunks) print(f TTFT P50/P99: {statistics.median(ttfts):.0f} / {pct(ttfts,99):.0f} ms) print(f 总时延 P50/P99: {statistics.median(totals):.0f} / {pct(totals,99):.0f} ms) async def main(): print( * 70) print(短输出场景压测: max_tokens50, 短问题, 关闭思考模式) print( * 70) for c in [10, 30, 64, 128, 200, 256]: await run(c, DURATION) asyncio.run(main())完整启动到压测的流程# 1. 启动 vLLM(前台跑,确认成功后 CtrlBD 放后台,或挂 systemd) bash /data/services/start-vllm.sh # 等到日志里出现: # Uvicorn running on http://0.0.0.0:8000 # 2. 验证服务在线 curl -sf http://localhost:8000/v1/models /dev/null echo ✅ 服务在线 # 3. 预热(消除冷启动延迟) seq 1 30 | xargs -P 30 -I {} curl -s http://localhost:8000/v1/chat/completions \ -H Content-Type: application/json \ -d {model:qwen3.6-27b,messages:[{role:user,content:hi}], max_tokens:30,chat_template_kwargs:{enable_thinking:false}} /dev/null # 4. 跑短输出压测(冲峰值 QPS) python3 /tmp/short_output_test.py # 5. 跑一般场景压测(中位 QPS) python3 /tmp/realistic_test.py # 6. 停止服务(测完释放显存) bash /data/services/stop-vllm.sh