1. RTX3090 单卡跑 Qwen2.5-7B,为什么 TPOT 卡在 19.7ms 下不去
如果你手上有一张 RTX 3090,想本地把 Qwen2.5-7B 跑起来做推理服务,大概率会遇到一个很具体的困惑:模型能跑,速度看着也不慢,但无论怎么调--max-model-len、怎么改并发、怎么换 batch 策略,单个请求的 TPOT(Time Per Output Token,每个输出 token 的平均耗时)就是压不到 20ms 以下。我实测下来,BF16 精度下单请求 TPOT 稳定在 19.7ms 左右,对应大约 51 tok/s 的输出速度,看起来还行,但离"快"还差一口气。
这个 19.7ms 不是随便一个数字,它背后是一条硬约束。Qwen2.5-7B 用 BF16 存储,权重约 14GB;RTX 3090 的显存带宽是 936 GB/s。decode 阶段每生成一个 token,GPU 都要把全部权重从显存搬进计算单元一次,理论最快时间就是 14GB ÷ 936GB/s ≈ 15ms。实测 19.7ms 意味着显存带宽利用率(MBU)已经到 76% 左右,纯软件调参基本到顶了。想再快,只能减少每次搬运的字节数——也就是量化。
这篇是"LLM 推理优化实战"系列的第二篇,聚焦的是压测与验证这件事本身。第一篇记录了从零部署和建立基线,这一篇要解决的是:怎么用一套可复制的压测脚本,把 TPOT、吞吐、显存带宽占用这些指标测准,并且用 TaoToken 的统一 Key 通道把本地 vLLM 服务和云端模型服务放在同一套调用方式下对比。适合已经在 RTX 3090 上跑起 Qwen2.5-7B、想系统做性能对比的读者。核心检索词就三个:RTX3090、Qwen2.5-7B、TPOT,全文围绕它们展开。
先说清楚这篇要交付什么:一份能直接跑的压测脚本、TaoToken 接入配置(Base URL + Key + Model ID 三件套)、以及 TPOT 和显存带宽占用的验证步骤。你照着做,应该能复现出接近显存带宽极限的调优路径,并且知道每一步的瓶颈在哪。
2. TaoToken 统一 Key 接入:把本地 vLLM 和云端模型放进同一套压测流程
做压测最烦的一件事是:本地服务一套调用方式,云端模型另一套调用方式,脚本要写两遍,指标口径还对不齐。TaoToken 在这里的价值是提供一个统一的 OpenAI 兼容 API 通道,你用一个 Key、一个 Base URL,就能同时调用本地 vLLM 起的服务和云端模型,压测脚本只需要改base_url和model两个字段。
TaoToken 是什么、能做什么、适合谁:它是一个模型 API 聚合通道,对外暴露 OpenAI 兼容接口,支持对话、编码类模型调用,适合需要统一管理多个模型 Key、做多模型对比压测、或者把本地服务和云端服务放在同一套代码里切换的开发者。官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
接入前你需要准备三样东西,也就是常说的"三件套":
| 配置项 | 本地 vLLM | TaoToken 云端通道 |
|---|---|---|
| Base URL | http://localhost:8000/v1 | https://taotoken.net/api |
| API Key | 任意字符串(vLLM 默认不校验) | 在控制台创建的 Key |
| Model ID | qwen2.5-7b(--served-model-name 设置) | 控制台里对应的模型标识 |
这里有个容易踩的坑:本地 vLLM 的 Base URL 要带/v1,而 TaoToken 的 API 根地址是https://taotoken.net/api,具体调用路径在文档里会写清楚。我试过把两者混用,结果 404,排查了半天才发现是路径拼接问题。所以压测脚本里最好把 base_url 和 endpoint 分开配置,别硬编码。
创建 Key 的入口在控制台的 API Keys 页面,路径是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。创建完复制出来,存到环境变量里,别写死在脚本里。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有完整的接口说明和参数列表。
如果你只是想先验证模型能不能通,可以用模型对话页面直接试: https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。长期做编码类、Agent 类任务的话,Coding Plan 更划算,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
为什么压测要用统一 Key?因为你要对比的往往不只是"本地 vs 云端",还有"BF16 vs INT4"、"conc=8 vs conc=32"这些维度。如果每次对比都要换一套调用代码,指标口径很容易不一致。统一到 OpenAI 兼容接口后,压测工具(比如 vLLM 自带的bench serve)只需要改参数,不用改逻辑。
这里要强调一点:TaoToken 是 API 通道,不是替代你本地推理引擎的东西。本地 vLLM 该跑还是跑,TaoToken 解决的是"多模型、多环境统一调用"的问题。两者是配合关系,不是替代关系。
3. 可复制配置:vLLM 启动参数与压测脚本的完整 settings
这一节给的是能直接复制粘贴的配置。先看 vLLM 启动命令,这是本地服务的基础:
export OMP_NUM_THREADS=4 python -m vllm.entrypoints.openai.api_server \ --model /root/autodl-tmp/models/qwen2.5-7b-instruct \ --served-model-name qwen2.5-7b \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.85 \ --max-model-len 4096关键参数就四个:--served-model-name决定 API 调用时的 Model ID,压测脚本里必须和它一致;--gpu-memory-utilization 0.85让 vLLM 预占 85% 显存,24GB × 0.85 ≈ 20.4GB,扣掉 14GB 权重,剩约 6.4GB 给 KV cache;--max-model-len 4096限制单请求最大 token 数,直接影响 KV cache 池能容纳多少并发。
接下来是压测脚本。vLLM 自带bench serve,用 ShareGPT 数据集模拟真实对话流量。先准备数据集:
mkdir -p /root/autodl-tmp/datasets /root/autodl-tmp/results/perf_baseline wget https://hf-mirror.com/datasets/anon8231489123/ShareGPT_Vicuna_unfiltered/resolve/main/ShareGPT_V3_unfiltered_cleaned_split.json \ -O /root/autodl-tmp/datasets/sharegpt.json然后写一个批量扫描脚本,把 request-rate 和 max-concurrency 两个维度都扫一遍:
#!/bin/bash mkdir -p /root/autodl-tmp/results/sweep COMMON_ARGS=" --backend openai-chat --base-url http://localhost:8000 --endpoint /v1/chat/completions --model qwen2.5-7b --tokenizer /root/autodl-tmp/models/qwen2.5-7b-instruct --dataset-name sharegpt --dataset-path /root/autodl-tmp/datasets/sharegpt.json --num-prompts 200 --save-result " export HF_HUB_OFFLINE=1 export TRANSFORMERS_OFFLINE=1 echo "=== 实验组 A:固定 conc=32,扫描 request-rate ===" for rate in 1 2 4 8 16 inf; do vllm bench serve $COMMON_ARGS \ --request-rate $rate \ --max-concurrency 32 \ --result-dir /root/autodl-tmp/results/sweep \ --result-filename "expA_rate${rate}_conc32.json" sleep 5 done echo "=== 实验组 B:固定 rate=inf,扫描 max-concurrency ===" for conc in 1 4 8 16 32 64 128; do vllm bench serve $COMMON_ARGS \ --request-rate inf \ --max-concurrency $conc \ --result-dir /root/autodl-tmp/results/sweep \ --result-filename "expB_rateinf_conc${conc}.json" sleep 5 done这里有个必须注意的点:--base-url不能带路径。写http://localhost:8000是对的,写http://localhost:8000/v1/chat/completions会报 URL 拼接错误,因为 vLLM 内部会把 base_url 和 endpoint 拼起来。--endpoint必须显式传/v1/chat/completions,因为 openai-chat 后端默认走的是/v1/completions,不指定就会走错接口。
如果你要用 TaoToken 通道做对比压测,把--base-url换成https://taotoken.net/api,--model换成控制台里的模型标识,再在环境变量里加上 Key 就行。压测脚本本身不用改逻辑,这就是统一 Key 的好处。
再给一份 Python 版的单请求 TPOT 测量脚本,适合做精细验证:
import time import os import requests BASE_URL = os.getenv("BASE_URL", "http://localhost:8000/v1") API_KEY = os.getenv("API_KEY", "EMPTY") MODEL = os.getenv("MODEL", "qwen2.5-7b") headers = { "Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}", } payload = { "model": MODEL, "messages": [{"role": "user", "content": "用三句话解释什么是显存带宽。"}], "max_tokens": 128, "stream": True, } start = time.perf_counter() first_token_time = None token_count = 0 with requests.post(f"{BASE_URL}/chat/completions", headers=headers, json=payload, stream=True) as resp: for line in resp.iter_lines(): if not line: continue if line.startswith(b"data: ") and b"[DONE]" not in line: if first_token_time is None: first_token_time = time.perf_counter() token_count += 1 end = time.perf_counter() ttft = (first_token_time - start) * 1000 if first_token_time else 0 tpot = (end - first_token_time) * 1000 / max(token_count - 1, 1) print(f"TTFT: {ttft:.2f} ms") print(f"TPOT: {tpot:.2f} ms") print(f"输出 token 数: {token_count}")这个脚本用流式请求,能精确测出 TTFT 和 TPOT。跑的时候把BASE_URL、API_KEY、MODEL三个环境变量设好,本地和云端都能用。
4. 验证请求与成功结果:TPOT 19.7ms 和显存带宽占用的实测数据
配置跑通后,先做一次单请求验证,确认服务正常:
curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen2.5-7b", "messages": [{"role": "user", "content": "你好"}], "max_tokens": 50 }'返回里有choices[0].message.content就说明服务通了。接下来跑基线压测,rate=4, conc=16的结果大概是这样:
============ Serving Benchmark Result ============ Successful requests: 200 Failed requests: 0 Request throughput (req/s): 2.64 Output token throughput (tok/s): 558.58 Total token throughput (tok/s): 1209.61 ---------------Time to First Token---------------- Mean TTFT (ms): 127.67 Median TTFT (ms): 113.43 P99 TTFT (ms): 293.48 -----Time per Output Token (excl. 1st token)------ Mean TPOT (ms): 23.91 Median TPOT (ms): 23.87 P99 TPOT (ms): 34.38 ==================================================然后跑实验组 B,固定rate=inf,扫描并发。conc=1 时的结果最关键:
Conc=1: TTFT P50: 41 ms TPOT P50: 19.7 ms TPOT P99: 19.8 ms Out_TPS: 50 tok/s19.7ms 这个数字就是单请求 TPOT 的实测值。对照理论下限:
Qwen2.5-7B BF16 权重 = 7e9 × 2 bytes ≈ 14 GB RTX 3090 显存带宽 = 936 GB/s 理论最快 TPOT = 14 GB ÷ 936 GB/s ≈ 15 ms 实测 TPOT = 19.7 ms MBU = 15 ÷ 19.7 ≈ 76%76% 的显存带宽利用率,说明 decode 阶段 GPU 大部分时间都在等权重从显存搬过来,计算单元反而是闲的。这就是为什么纯调参没用——瓶颈在带宽,不在算力。
再看并发扫描的完整结果:
| Conc | TTFT P50 | TTFT P99 | TPOT P50 | TPOT P99 | Out_TPS |
|---|---|---|---|---|---|
| 1 | 41 ms | 196 ms | 19.7 ms | 19.8 ms | 50 |
| 4 | 92 ms | 234 ms | 20.0 ms | 25.8 ms | 190 |
| 8 | 94 ms | 355 ms | 21.4 ms | 27.2 ms | 345 |
| 16 | 83 ms | 787 ms | 23.4 ms | 35.2 ms | 595 |
| 32 | 127 ms | 1352 ms | 26.8 ms | 60.2 ms | 978 |
| 64 | 212 ms | 2018 ms | 34.4 ms | 165 ms | 1341 |
| 128 | 892 ms | 3598 ms | 51.9 ms | 387 ms | 1583 |
几个关键拐点:conc=32 时 Out_TPS 到 978,接近饱和;conc=32→64 时 TPOT P99 从 60ms 暴涨到 165ms,这是 decode 质量的实际边界;conc=128 时 TTFT P99 到 3.6 秒,用户体验已经崩了。
显存带宽占用怎么验证?用 vLLM 的 metrics 接口:
watch -n 1 'curl -s http://localhost:8000/metrics | grep -E "kv_cache_usage|num_requests_running|num_requests_waiting"'输出类似:
vllm:kv_cache_usage_perc{model_name="qwen2.5-7b"} 0.45 vllm:num_requests_running{model_name="qwen2.5-7b"} 12 vllm:num_requests_waiting{model_name="qwen2.5-7b"} 3KV cache 用了 45%,当前处理 12 个请求,还有 3 个在排队。再看 KV cache 配置:
curl -s http://localhost:8000/metrics | grep cache_config_info关键字段:num_gpu_blocks=5306、block_size=16,KV cache 总容量 = 5306 × 16 = 84,896 token。这个数字决定了系统能同时处理的最大 token 数,也是并发能力的上限。
如果你想用 TaoToken 通道验证同样的模型,把 Base URL 换成https://taotoken.net/api,Key 用控制台创建的,Model ID 用文档里对应的标识,跑同一个 Python 脚本,对比 TPOT 和 TTFT。这样你就能看到本地 RTX 3090 和云端通道在相同请求下的性能差异。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth 报错对照
压测过程中最容易撞上的几类报错,这里按真实错误信息对照排查。
401 Unauthorized:用 TaoToken 通道时最常见。原因通常是 Key 没传、传错、或者环境变量没生效。检查Authorization: Bearer <key>头有没有带上,Key 有没有多余空格。本地 vLLM 默认不校验 Key,所以本地跑通不代表云端也能通,两套 Key 要分开管理。
local proxy failed / connection refused:压测脚本连不上服务。先确认 vLLM 进程还在跑,curl http://localhost:8000/v1/models能不能返回。如果是 TaoToken 通道,确认网络能访问https://taotoken.net/api,以及 base_url 有没有多写路径。我踩过的坑是把https://taotoken.net/api写成了https://taotoken.net/api/v1,结果 404。
reading choices 报错 / KeyError: 'choices':返回体里没有choices字段,通常是接口路径错了。比如用/v1/completions去调 chat 模型,或者用/v1/chat/completions去调 completions 接口。vLLM 的bench serve里--backend openai-chat必须配--endpoint /v1/chat/completions,两者要匹配。
OAuth / token 过期类报错:TaoToken 控制台创建的 Key 如果设置了有效期,过期后会返回鉴权失败。去 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 重新创建一个,更新到环境变量里。别把 Key 硬编码在脚本里,用export API_KEY=xxx更安全。
cuDNN 版本冲突:启动 vLLM 时报AssertionError: Found 2 libcudnn.so.x,原因是同时装了 cu12 和 cu13 两个版本。排查命令:
pip list | grep cudnn pip uninstall nvidia-cudnn-cu12 -y保留 cu13 版本即可。
lm-eval 报 Loglikelihood is not supported:用openai-chat-completions后端跑选择题任务时会报这个,因为 chat 接口不返回 prompt logprobs。改用local-completions后端,走/v1/completions接口。
bench serve 的 URL 拼接错误:--base-url不能带路径,--endpoint必须显式传。这两个规则记住就不会错。
HuggingFace 数据集下载失败:设置HF_ENDPOINT=https://hf-mirror.com,或者提前下载好数据集,压测时加HF_HUB_OFFLINE=1和TRANSFORMERS_OFFLINE=1。
排查顺序建议:先确认服务活着(curl /v1/models),再确认鉴权对(Key 和 Header),再确认路径对(base_url + endpoint),最后看返回体结构。大部分报错都出在前三步。
6. 从压测数据到调优决策:下一步该往哪走
压测做完,数据摆在那,接下来怎么决策?我的经验是看三个数:TPOT P50、TPOT P99 拐点、Out_TPS 饱和点。
TPOT P50 = 19.7ms,MBU ≈ 76%,说明单请求已经逼近显存带宽极限。想再压 TPOT,只有两条路:减少权重搬运量(量化),或者减少 KV cache 搬运量(更激进的 KV 量化)。纯调--max-model-len、--gpu-memory-utilization这些参数,对单请求 TPOT 基本没影响,因为它们改的是并发能力,不是单次 decode 的带宽需求。
TPOT P99 拐点在 conc=32→64,60ms 跳到 165ms。这个拐点告诉你:生产环境并发别超过 32,否则尾部延迟会失控。如果你的业务对 P99 敏感,conc 控制在 8~16 更稳。
Out_TPS 饱和点在 978 tok/s(conc=32)。再往上加并发,吞吐涨得很少,但延迟涨得很快。这就是典型的"吞吐换延迟"trade-off,没有免费午餐。
下一步的优化实验,我建议按这个顺序做:先做 AWQ INT4 量化,预期 TPOT 减半、吞吐翻倍,这是收益最大的一步;再做 KV cache 深度分析,理解吞吐翻倍的根因;然后试 prefix caching 降 TTFT;最后考虑投机采样,但它在低并发下才有明显收益。
每次优化都要双线评测:精度用 lm-eval 跑 HellaSwag、ARC、GSM8K,性能用bench serve跑同样的参数扫描。只有精度损失可控、性能收益明确,才值得采纳。
如果你想把本地 RTX 3090 和云端通道放在一起对比,TaoToken 的统一 Key 能省不少事。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,Key 在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 创建,模型对话验证在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。长期做编码和 Agent 任务的话,Coding Plan 入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
最后留一个实用技巧:压测结果一定要存 JSON,别只看终端输出。--save-result会把每次跑的完整指标写进文件,后面做对比分析时直接读 JSON 就行,不用重新跑。我习惯把结果按expA_rate4_conc32.json这种命名存,一眼能看出参数组合。跑完一轮扫描,用几行 Python 把 JSON 读出来画个表,瓶颈在哪一目了然。