这台 8 卡 5090 的机器,从把 vLLM 装到调到能稳定服务 65 QPS,过程、数字、脚本都在这里。所有数据均为实测,无估算、无推算;所有脚本均为线上可直接复用版本。

一、这台机器长什么样

部件 配置
GPU 8 × RTX 5090(32GB GDDR7,合计 256GB 显存)
CPU 2 × Intel Xeon Gold 6530(合计 128 线程)
内存 512GB DDR5-5600
存储 7TB NVMe(数据盘)+ 894GB SATA SSD(系统盘)
系统 Ubuntu 26.04 LTS + Kernel 6.17
驱动 / CUDA NVIDIA 580.142 + CUDA 13.1

NUMA 拓扑是典型的 4+4:GPU 0-3 在 NUMA 0,GPU 4-7 在 NUMA 1。这台机器目前只用前 4 张卡跑 LLM,后 4 张留给图像生成。

这套硬件的硬伤先说在前面

  • 消费卡 5090 之间没有 NVLink,GPU 间通信只能走 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 已默认 简单
SGLang FP8 blockwise 在 sm_120 上落后 vLLM 半年 中等
TensorRT-LLM NVIDIA 自家,性能最强 极高(每次改参数都要重编译)

模型选了 Qwen3.6-27B(2026 年 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-seqs=64,max-model-len=65536)

并发请求数 QPS 首字延迟 P99 完整响应延迟 P99
10 1.8 6.9 秒 24 秒
30 5.3 223 ms 19 秒
64 8.7 334 ms 27 秒

QPS 只有 8.7,明显被卡住了。

但 vLLM 内部日志暴露了真相:

Running: 64 reqs, Waiting: 0 reqs, GPU KV cache usage: 25%

并发被 max-num-seqs=64 死死卡住,KV cache 才用了 25%——还有 3 倍空间没动用

四、第二轮:调参后的真实能力

改了五个参数:

参数 旧值 新值 为什么改
max-num-seqs 64 256 KV 才用 25%,能撑 4 倍并发
max-model-len 65536 16384 缩单请求上下文,腾空间给更多并发
enable-chunked-prefill 长 prompt 分块处理,不阻塞 decode
max-num-batched-tokens 默认 16384 chunked-prefill 单步处理 token 上限
gpu-memory-utilization 0.90 0.92 显存再多榨一点

重启后做了两组场景化压测。

场景一:一般对话(max_tokens=150-256)

并发请求数 QPS 首字延迟 P99 完整响应延迟 P99
10 4.7 172 ms 2.9 秒
30 15.1 209 ms 3.7 秒
64 22.9 255 ms 5.0 秒
128 29.1 458 ms 8.3 秒
200 32.2 694 ms 13.5 秒

场景二:短问答(max_tokens=50,模拟客服/翻译/简单查询)

并发请求数 QPS 首字延迟 P99 完整响应延迟 P99
10 13.4 168 ms 1.5 秒
30 28.1 193 ms 1.7 秒
64 45.2 292 ms 2.3 秒
128 60.2 419 ms 3.4 秒
200 64.9 ⭐ 727 ms 5.0 秒
256 64.7 843 ms 6.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 上限 数据来源
短问答 / 客服 30 0.4 秒 65 ✅ 实测
翻译 / 简短改写 50 0.6 秒 约 50 推算
代码补全 80 1.0 秒 约 40 推算
一般对话 150 2 秒 32 ✅ 实测
长文摘要 500 6 秒 约 10 推算

只有短问答和一般对话两档是实测,其它档位是按 2500 tok/s 总吞吐推算

七、思考模式 vs 直答模式

Qwen3.6 是新一代"思考型"模型,默认开启 thinking。做了对比:

场景 直答模式耗时 思考模式耗时 思考链长度
"用一句话介绍北京" 0.44 秒 7.4 秒(被 max_token 截断) 2200+ token
"3 开关找灯泡谜题" 3.7 秒 7.4 秒(被截断) 2000+ token
"Python 斐波那契生成器" 0.85 秒 7.4 秒(被截断) 1800+ token

结论:生产环境 API 默认应该关闭思考模式,让客户端通过参数显式启用——简单对话不需要思考,复杂推理任务才需要。

八、优化空间还在哪?

当前 65 QPS / 32 QPS 是 BF16 精度下的成绩。后续还可以走的路:

优化方向 预期 QPS 提升 工作量
缩 max-model-len 到 8192 短输出场景可冲 100+ QPS 5 分钟
INT8 量化(W8A8) +80-100% 半天
AWQ-W4 量化 +50-70% 半天
N-gram speculative decoding +30-50% 1 天
拆 DP=2 + TP=2 双实例 +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。机房电力、散热、噪音都要规划。

十、总结

维度 数据
短问答场景 QPS 65(实测)
一般对话场景 QPS 32(实测)
单实例峰值 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"
PORT=8000

source /data/envs/llm/bin/activate

# Blackwell + RTX 5090 关键环境变量
export VLLM_ATTENTION_BACKEND=FLASHINFER
export NCCL_P2P_DISABLE=1
export NCCL_SHM_DISABLE=0
export NCCL_DEBUG=WARN
export NCCL_IB_DISABLE=1
export NCCL_DMABUF_ENABLE=1
export NCCL_CUMEM_ENABLE=1

# 只用 NUMA 0 的 4 张卡
export CUDA_VISIBLE_DEVICES=0,1,2,3

# vLLM 参数
export VLLM_USE_FLASHINFER_SAMPLER=0
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
export PYTHONUNBUFFERED=1

# 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 --cpunodebind=0 --membind=0 \
    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 \
        2>&1 | tee -a "$LOG_FILE"

几个关键设计点

  1. numactl --cpunodebind=0 --membind=0:把整个 vLLM 进程钉在 NUMA 0,避免跨 socket 访存。CPU 和内存都绑死。
  2. VLLM_ATTENTION_BACKEND=FLASHINFER:Blackwell 不支持 FlashAttention-3,必须切 FlashInfer。
  3. NCCL_P2P_DISABLE=1 + NCCL_DMABUF_ENABLE=1:消费卡 P2P 被禁的两个 workaround。
  4. cuDNN/cuBLAS 路径注入:因为 cuDNN 是 pip 装在 conda env 里的,PyTorch 默认找不到,需要显式指定。
  5. tool-call-parser qwen3_coder + reasoning-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-apps=pid,process_name --format=csv
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?",
    "1+1 等于几",
    "北京有什么景点",
    "怎么减肥",
    "明天会下雨吗",
]

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, json=payload, timeout=aiohttp.ClientTimeout(total=60)) 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(connector=aiohttp.TCPConnector(limit=concurrency*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 = [
    "1+1=?", "中国首都是哪里", "今天周几", "你好",
    "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, json=payload, timeout=aiohttp.ClientTimeout(total=30)) 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_tokens=50)")
    results = []
    stop_at = time.perf_counter() + duration
    async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit=concurrency*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_tokens=50, 短问题, 关闭思考模式")
    print("=" * 70)
    for c in [10, 30, 64, 128, 200, 256]:
        await run(c, DURATION)

asyncio.run(main())

完整启动到压测的流程

# 1. 启动 vLLM(前台跑,确认成功后 Ctrl+B+D 放后台,或挂 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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐