使用wrk对vLLM OpenAI API进行压力测试与性能调优实战
1. 项目概述:为什么我们需要对 vLLM OpenAI API 进行压力测试?
在部署基于 vLLM 的大模型推理服务时,很多开发者会直接使用其内置的 OpenAI API 兼容接口。这个接口用起来确实方便,一行curl命令就能调用,感觉和调用官方的 OpenAI 服务没什么两样。但当你把服务部署上线,准备迎接真实流量时,心里总会有点没底:我这个服务到底能扛住多少并发?响应延迟在压力下会变成什么样?内存会不会因为请求堆积而爆掉?这些问题,单靠功能测试是回答不了的,必须得上压力测试。
我最近就在调优一个部署在内部集群的 Qwen2-72B 模型服务,用的就是 vLLM 的 OpenAI API 接口。在开发环境跑得好好的,一到预发布环境,模拟几个用户同时提问,服务响应就开始不稳定,甚至偶尔超时。这时候,一个轻量级但威力强大的命令行工具wrk就派上了用场。它不像 JMeter 那样需要复杂的 GUI 配置,写个简单的 Lua 脚本,就能模拟出高并发的 HTTP POST 请求,直击我们服务的性能瓶颈。通过分析wrk输出的详细报告,我们不仅能得到每秒处理请求数(QPS)、平均延迟这些宏观指标,更能结合 vLLM 自身的监控指标(比如vllm:request_latency),深入分析从请求进入、模型推理到结果返回的全链路性能,找到到底是网络层、推理引擎还是 GPU 算力成了拖后腿的环节。这次,我就把整个压力测试的设计思路、具体操作步骤、结果分析方法以及踩过的坑,完整地分享出来。
2. 测试环境与工具选型解析
2.1 为什么选择wrk而不是 JMeter 或 Locust?
压力测试工具有很多,JMeter 功能全面,Locust 可以用 Python 写脚本,都很强大。但我最终选择wrk,主要是基于以下几个实际考量:
- 极致轻量与高性能:
wrk是用 C 语言开发的,它本身对系统资源的消耗极低。这意味着在施压过程中,测试工具本身不会成为性能瓶颈,也不会因为占用过多 CPU 或内存而干扰我们对被测服务(vLLM)的观测。相比之下,基于 JVM 的 JMeter 在发起高并发时,自身开销就不容忽视。 - 学习成本低,上手快:
wrk的核心命令非常简单,通常就是wrk -t 线程数 -c 连接数 -d 持续时间 -s 脚本路径 URL。复杂的逻辑,比如构造带有不同参数的 POST 请求体,可以通过 Lua 脚本实现。对于我们测试固定的 API 接口,写一个脚本后就可以反复使用。 - 结果清晰直观:
wrk的输出报告直接给出了 Latency(延迟)分布和 Requests/sec(每秒请求数),这些正是评估 API 性能最关键的指标。它没有花哨的图表,但数据非常直接有用。
当然,wrk也有局限,比如它主要擅长 HTTP 测试,且 Lua 脚本对于需要复杂业务逻辑(如依赖上一个请求的响应)的场景支持不够友好。但对于我们“向固定 API 发送 POST 请求”这个目标,它是最合适的“手术刀”。
2.2 被测服务:vLLM OpenAI API 接口部署要点
我们的压力测试对象是一个已经启动的 vLLM 服务。这里有几个关键配置直接影响测试结果和结论:
- 启动命令与参数:我们使用类似以下的命令启动服务:
关键参数解析:python -m vllm.entrypoints.openai.api_server \ --model /path/to/your/model \ # 例如 Qwen2-7B-Instruct --served-model-name qwen2-7b \ --host 0.0.0.0 \ --port 8000 \ --tensor-parallel-size 1 \ # 根据 GPU 数量调整 --max-model-len 4096 \ # 模型最大上下文长度 --gpu-memory-utilization 0.9 \ # GPU 内存利用率目标 --max-num-batched-tokens 2048 \ # 批处理最大 token 数,影响吞吐 --disable-log-requests # 压力测试时建议关闭请求日志以减少干扰--max-num-batched-tokens:这是 vLLM 吞吐性能的“油门”。它决定了单次前向传播能处理的最大 token 总数。设置得太低,GPU 算力无法饱和;设置得太高,可能导致 OOM 或排队延迟激增。压力测试的一个重要目的就是找到当前硬件下的最优值。--disable-log-requests:在压力测试期间,每个请求都打印日志会带来巨大的 I/O 开销,严重影响性能表现,导致测试结果失真。务必关闭。
- API 端点:vLLM 的 OpenAI 兼容接口默认在
/v1/chat/completions提供聊天补全服务。这就是我们wrk将要攻击的“靶心”。 - 监控准备:在运行
wrk的同时,我们需要另开终端观察 vLLM 服务本身的状态。除了基础的nvidia-smi查看 GPU 利用率,更关键的是 vLLM 的 metrics。默认情况下,vLLM 会在http://localhost:8000/metrics提供 Prometheus 格式的指标。我们需要关注vllm:request_latency(请求延迟)、vllm:num_requests_running(正在处理的请求数)等。
3. 构造wrk测试脚本与执行策略
3.1 编写 Lua 脚本:模拟真实的聊天请求
wrk的强大之处在于可以用 Lua 脚本自定义请求。我们的目标是模拟用户向/v1/chat/completions发送一个典型的聊天请求。
创建一个文件,命名为vllm_stress.lua:
-- vllm_stress.lua -- 初始化阶段,设置全局变量 wrk.method = "POST" wrk.headers["Content-Type"] = "application/json" wrk.headers["Authorization"] = "Bearer dummy-key" -- vLLM 若未启用鉴权可留空,但建议保留头部结构 -- 定义请求体模板 -- 注意:为了测试公平性,每次请求的输入长度应保持一致,或在一个可控范围内随机。 local prompt_template = [[ { "model": "qwen2-7b", "messages": [ {"role": "user", "content": "%s"} ], "max_tokens": 128, -- 限制生成长度,避免响应过长影响测试 "temperature": 0.7, "stream": false -- 压力测试建议关闭流式输出,简化处理 } ]] -- 准备一个请求内容池,避免所有请求完全一致(可选,但更真实) local prompts = { "请用中文解释一下牛顿第一定律。", "写一首关于春天的五言绝句。", "计算一下 15的平方加上28的三次方等于多少?", "简述人工智能发展的三个主要阶段。", "如何快速学习一门新的编程语言?给出三个建议。" } -- 每个线程初始化时调用 function setup(thread) thread.addr = "http://your-server-ip:8000/v1/chat/completions" -- 替换为你的 vLLM 服务地址 end -- 每次请求前调用,用于生成动态请求体 function request() -- 随机选择一个提示词,模拟不同用户的输入 local random_prompt = prompts[math.random(#prompts)] local body = string.format(prompt_template, random_prompt) wrk.body = body return wrk.format() end -- 响应处理函数(可选),可用于校验响应或记录特定错误 function response(status, headers, body) -- 如果只想记录错误,可以这样: -- if status ~= 200 then -- io.write(string.format("Error: Status %d\n", status)) -- io.write(body .. "\n") -- end -- 注意:在高压下频繁 io.write 会影响性能,仅用于调试。 end脚本要点解析:
- 动态内容:使用一个提示词池并随机选择,这比所有请求发送完全相同的内容更能模拟真实场景,也能避免服务端可能存在的缓存优化带来的测试偏差。
- 固定生成长度:
”max_tokens”: 128确保了每次请求的“工作量”(生成token数)大致可控,使延迟数据更具可比性。 - 关闭流式:
”stream”: false对于压力测试至关重要。流式输出会保持长连接,极大地增加服务端的并发连接管理和内存占用复杂度,不利于我们聚焦在核心的推理吞吐和延迟上。 - 鉴权头:即使 vLLM 服务未启用鉴权,也保留
Authorization头,因为这是 OpenAI API 的标准格式,避免因头部差异导致意外问题。
3.2 设计科学的压力测试执行策略
直接用一个高并发参数去“猛打”服务是不科学的,这很可能瞬间把服务打挂,得不到渐进式的性能曲线。我推荐采用阶梯增压(Step-load)策略。
我们可以写一个简单的 Shell 脚本来自动化这个过程:
#!/bin/bash # stress_test_step.sh SERVER_URL="http://10.0.1.100:8000" # 替换为你的 vLLM 服务地址 DURATION=30 # 每个压力阶梯持续30秒 THREADS=4 # 根据测试机CPU核心数调整,通常等于核心数 for CONNECTIONS in 10 30 50 80 120 200; do echo "==============================================" echo "开始测试: 连接数=$CONNECTIONS, 线程数=$THREADS, 持续时间=${DURATION}s" echo "==============================================" # 运行 wrk wrk -t $THREADS -c $CONNECTIONS -d $DURATION -s ./vllm_stress.lua $SERVER_URL echo "" echo "等待10秒,让服务恢复稳定..." sleep 10 done执行策略解析:
- 渐进增加连接数(-c):从 10 个并发连接开始,逐步增加到 200。这允许我们观察系统在不同负载下的表现:何时响应延迟开始线性增长?何时吞吐量达到平台期?何时开始出现错误或超时?
- 固定线程数(-t):
wrk的线程数用于管理网络连接,通常设置为测试机器可用的 CPU 逻辑核心数即可,避免线程切换开销。 - 持续时长(-d):每个阶梯持续 30 秒到 60 秒是比较合适的。时间太短,系统可能未达到稳定状态;时间太长,整体测试周期会拉得很长。
- 间隔休息(sleep):在每个压力阶梯之间插入 10 秒左右的等待时间,让 vLLM 服务处理完队列中的请求,GPU 内存和算力恢复到空闲状态,确保下一个阶梯的测试是独立的。
注意:运行压力测试的机器(施压机)最好与被测的 vLLM 服务器在同一个内网,并确保网络带宽(例如万兆)不是瓶颈。否则,测试结果反映的可能是网络限制,而非 vLLM 服务的真实性能。
4. 测试结果深度解读与性能瓶颈分析
假设我们运行了上述阶梯测试,并得到了类似下面的一组输出(以连接数 80 为例的简化报告):
Running 30s test @ http://10.0.1.100:8000/v1/chat/completions 4 threads and 80 connections Thread Stats Avg Stdev Max +/- Stdev Latency 1.23s 385.62ms 2.11s 85.12% Req/Sec 16.22 4.67 30.00 78.33% Latency Distribution 50% 1.19s 75% 1.45s 90% 1.78s 99% 2.05s 1942 requests in 30.09s, 12.34MB read Requests/sec: 64.56 Transfer/sec: 420.11KB同时,我们通过curl http://localhost:8000/metrics(在 vLLM 服务器上)持续抓取了一些关键指标。
4.1 核心指标拆解:Latency 和 Requests/sec
- 平均延迟(Avg Latency):1.23秒。这个时间是从
wrk发送请求到收到完整响应所经历的时间。它包含了网络传输、vLLM 请求排队、模型推理和结果返回的全过程。1秒多的延迟对于交互式应用来说已经偏高了。 - 延迟分布:这是比平均延迟更有价值的指标。
50%(中位数):1.19秒。一半的请求在 1.19 秒内完成。90%:1.78秒。90% 的请求在 1.78 秒内完成。这意味着有 10% 的请求延迟超过了 1.78 秒。99%:2.05秒。尾部延迟较高。在并发 80 时,最慢的 1% 请求需要超过 2 秒。高尾部延迟是影响用户体验的“杀手”,用户会感觉服务“卡顿”。
- 每秒请求数(Requests/sec):64.56。这是系统在当前负载下的吞吐量。我们需要结合不同并发下的吞吐量来看趋势。
4.2 结合 vLLM Metrics 进行瓶颈定位
仅看wrk的输出是不够的,我们需要知道时间花在了哪里。此时,vLLM 的监控指标就至关重要。我们重点关注以下两个:
vllm:request_latency:vLLM 自身统计的请求处理延迟。这个延迟通常从请求被 vLLM 引擎接收开始,到推理完成结束。比较wrk报告的总延迟和vllm:request_latency,如果两者差距很大(比如总延迟2秒,vLLM延迟仅0.5秒),那么瓶颈很可能不在模型推理,而在网络、API 网关或者wrk测试机本身。vllm:num_requests_running与vllm:num_requests_waiting:running表示正在被调度和推理的请求数。它受--max-num-batched-tokens和 GPU 算力限制。waiting表示在队列中等待的请求数。如果waiting数持续大于 0,甚至在增加,说明请求到达速率超过了服务处理速率,队列在堆积。这是系统过载的明确信号,也是导致高延迟(尤其是尾部延迟)的直接原因。
性能瓶颈分析流程:
- 绘制性能曲线:将不同并发数(
-c)下的Requests/sec(吞吐)和Latency(延迟)分别制成图表。- 理想情况:随着并发增加,吞吐线性上升,延迟缓慢增长。
- 瓶颈出现:当并发增加到某个点后,吞吐增长停滞甚至下降,而延迟开始急剧上升。这个拐点就是系统的最佳并发点。超过这个点,增加并发只会增加排队时间,不会提升吞吐。
- 对照资源监控:在出现拐点时,查看 GPU 利用率(
nvidia-smi)是否已达到 90% 以上?vllm:num_requests_waiting是否开始显著增长?- 如果 GPU 未饱和,但延迟已飙升:瓶颈可能在于
--max-num-batched-tokens设置过小,导致 GPU 无法充分并行处理请求;或者 CPU 预处理/后处理成为瓶颈;亦或是测试机网络或 vLLM 所在服务器的网络中断处理能力不足。 - 如果 GPU 已饱和,且队列堆积:这就是纯粹的算力瓶颈。需要更强大的 GPU,或者考虑模型量化、使用更小模型、增加
tensor-parallel-size(多卡并行)来提升算力。
- 如果 GPU 未饱和,但延迟已飙升:瓶颈可能在于
4.3 一个实战排查案例:调整--max-num-batched-tokens
在我的测试中,初期使用默认设置,在并发 50 时,吞吐就上不去了,延迟飙升。观察nvidia-smi,GPU 利用率仅在 60%-70% 徘徊,而vllm:num_requests_waiting却有 10 多个。
问题分析:GPU 没喂饱,但请求在排队。这强烈暗示了--max-num-batched-tokens(批处理最大 token 数)是瓶颈。vLLM 的 PagedAttention 引擎会尝试将多个请求的 KV Cache 拼接在一起进行批处理,以提高计算效率。如果这个上限设得太低,即使有很多请求在等待,引擎也无法将它们放入同一个批处理中,导致 GPU 利用率低,吞吐上不去。
解决方案:逐步增加--max-num-batched-tokens的值(例如从 2048 到 4096,再到 8192),并重复压力测试。同时密切监控 GPU 内存使用情况(nvidia-smi),避免因批处理过大导致 OOM。
调整后的效果:将--max-num-batched-tokens从 2048 调整为 8192 后,在并发 80 时,GPU 利用率稳定在 95% 以上,吞吐从 64.56 提升到了 89.23,平均延迟从 1.23秒 降低到 980毫秒。这就是通过压力测试找到并优化关键参数带来的直接收益。
5. 高级测试场景与常见问题排查
5.1 模拟更复杂的请求模式
上面的脚本模拟了请求内容长度基本固定的场景。但真实场景可能更复杂:
- 变长输入测试:用户的问题有长有短。我们需要测试服务对变长输入的鲁棒性。可以在 Lua 脚本的
prompts池中加入长度差异很大的提示词,比如从 10 个 token 到 500 个 token。观察长文本输入是否会对延迟和吞吐产生不成比例的影响。 - 混合流量测试:同时模拟“短平快”的问答(
max_tokens: 50)和“长思考”的创作(max_tokens: 512)。这可以测试 vLLM 调度器在处理异质工作负载时的公平性和效率。这需要更复杂的 Lua 脚本,为不同比例的请求分配不同的请求体模板。
5.2wrk测试中常见的坑与解决之道
错误
socket: Too many open files:- 现象:
wrk报错,无法建立更多连接。 - 原因:Linux 系统对单个进程可打开的文件描述符数量有限制。
wrk每个连接都会占用一个。 - 解决:临时提高限制:
ulimit -n 65536。在运行wrk的终端中执行此命令。
- 现象:
测试结果波动巨大:
- 现象:两次相同的测试,QPS 和延迟差异很大。
- 排查:
- 系统干扰:确保测试期间没有其他重型进程在运行(如系统更新、备份)。
- GPU 状态:确保每次测试前 GPU 是“冷启动”的,或者至少处于相同的初始状态(可通过重启 vLLM 服务实现)。GPU Boost 频率、显存碎片都可能影响结果。
- 预热:在正式记录数据的测试前,先运行一个 30 秒的低压力测试(如
-c 10),让 vLLM 的 CUDA 内核、内存分配等完成初始化,进入稳定状态。
服务端直接崩溃或无响应:
- 现象:
wrk报告大量连接失败或超时,vLLM 服务进程可能挂掉。 - 排查:
- 检查日志:查看 vLLM 服务的输出日志,是否有 OOM(Out of Memory)错误。这可能是
--max-num-batched-tokens设置过高,或并发请求导致显存耗尽。 - 检查系统内存:使用
free -h查看系统内存是否耗尽。vLLM 除了显存,也会使用部分主机内存。 - 降低负载:这是最直接的验证方法。如果降低并发后服务恢复稳定,说明当前硬件配置无法支撑预设的负载。
- 检查日志:查看 vLLM 服务的输出日志,是否有 OOM(Out of Memory)错误。这可能是
- 现象:
wrk脚本性能问题:- 现象:
wrk本身 CPU 占用率很高,甚至达到 100%。 - 排查:检查 Lua 脚本中的
response()函数。如果在这个函数里执行了复杂的逻辑(如字符串解析、文件写入),会严重拖慢wrk的发包速度。压力测试时,response()函数应尽可能轻量,或直接注释掉。
- 现象:
5.3 从压力测试到性能调优清单
根据测试结果,你可以形成一个有针对性的调优清单:
| 现象 | 可能原因 | 调优方向 |
|---|---|---|
| 高延迟,低吞吐,GPU利用率低 | 批处理大小限制 | 增加--max-num-batched-tokens |
| 高延迟,吞吐尚可,GPU利用率高 | 算力瓶颈 | 模型量化、启用更多GPU(增加--tensor-parallel-size)、升级硬件 |
| 延迟不稳定,尾部延迟极高 | 请求队列堆积 | 优化调度策略(如vLLM的优先级调度)、实施请求速率限制、增加服务实例(水平扩展) |
wrk总延迟远大于vllm:request_latency | 网络或前端代理瓶颈 | 检查网络带宽和延迟、优化API网关(如Nginx)配置、确保wrk测试机性能足够 |
| 服务内存/显存溢出崩溃 | 内存资源不足 | 降低--max-num-batched-tokens、降低--gpu-memory-utilization、使用量化模型、减少单请求max_tokens |
压力测试不是一次性的任务,而是一个迭代的过程。每次对服务配置、模型参数或基础设施进行调整后,都应该重新运行压力测试,用数据来验证优化是否有效,从而持续推动服务性能向更高的水平迈进。通过wrk这把精准的尺子,我们能清晰地度量出 vLLM OpenAI API 接口的性能边界,为生产环境的稳定、高效运行打下坚实的基础。