JMeter+Prometheus构建AI推理压测体系

发布时间:2026/5/22 7:48:11

JMeter+Prometheus构建AI推理压测体系 1. 这不是“给JMeter装个插件”那么简单你有没有试过用 JMeter 压测一个刚上线的 AI 推理服务我第一次做的时候信心满满点下“启动”结果三分钟后——线程卡死、响应时间飙升到 12 秒、错误率从 0% 跳到 47%而监控面板上只有一行孤零零的Active Threads: 200和一个不断跳动的95th Percentile: ???。更尴尬的是开发同事跑来问“你压的是模型还是你的本地磁盘”——因为日志里全是java.io.IOException: No space left on device。后来才发现JMeter 默认把所有采样结果全写进内存再批量刷盘而 AI 请求单次响应体动辄 8MB含 base64 编码的图像生成结果200 并发 × 每秒 5 次请求 × 8MB 每秒 8GB 内存吞吐JVM 直接 OOM。这就是问题的核心传统压测工具的设计范式和 AI 服务的运行特征存在三重错位。第一是数据形态错位——HTTP 接口压测关注状态码和毫秒级延迟但 AI 服务的关键指标是 token 吞吐量tokens/sec、首 token 延迟time to first token, TTFT、完整响应延迟end-to-end latency、显存占用GPU memory usage和解码吞吐output tokens per second第二是资源瓶颈错位——Web 服务压测常卡在 CPU 或网络带宽而 AI 推理的瓶颈几乎永远在 GPU 显存带宽、CUDA 核心利用率或 KV Cache 占用第三是可观测性错位——JMeter 的Summary Report无法告诉你vLLM是否触发了 PagedAttention 的 page swapPrometheus 的gpu_utilization{devicenvidia0}也看不出当前 batch size 是 8 还是 32 导致的吞吐下降。所以“用 JMeterPrometheus 玩 AI 压测”根本不是简单拼凑两个工具而是要重建一套面向 AI 推理负载的压测语义层让 JMeter 不再只发 HTTP 请求而是理解 prompt 长度、max_tokens 设置、temperature 参数对后端调度的影响让 Prometheus 不再只收http_request_duration_seconds而是能采集llm_inference_queue_length、kv_cache_hit_ratio、cuda_stream_wait_time_ms这类真正决定 AI 服务伸缩性的指标。这就像给一辆燃油车加装电驱系统——不是换个轮胎而是重布动力总成。本文接下来要拆解的就是这套“外挂系统”的真实构造从 JMeter 如何解析 LLM API 的结构化响应到如何用自定义 Exporter 把 GPU 张量运算指标喂给 Prometheus再到如何用 Grafana 把“每秒处理多少个中文句子”翻译成运维可读的 SLO 看板。所有内容均基于 vLLM 0.4.2 Triton Inference Server 2.41 JMeter 5.6 实测验证不讲虚的只说你明天就能抄作业的硬核细节。2. JMeter 的“AI 觉醒”从 HTTP 客户端到 LLM 协议解析器JMeter 默认是个“哑巴”HTTP 客户端——它只管发包、收包、记耗时对包里是 JSON 还是 Protobuf、是{response:hello}还是{choices:[{delta:{content:h}}]}完全无感。而 AI 服务的响应结构恰恰是压测分析的命门。比如 OpenAI 兼容接口的流式响应SSE一次/chat/completions请求会返回几十个data: {choices:[{delta:{content:a}}]}事件每个事件都带独立的created时间戳。如果 JMeter 只把整个 SSE 流当做一个采样那90th Percentile就毫无意义它既不能反映首 token 延迟TTFT也无法计算平均 token 生成速度tokens/sec。所以第一步必须让 JMeter “读懂” AI 协议。2.1 解析流式响应用 JSR223 PostProcessor 提取关键时序点核心思路是在 JMeter 接收到完整 SSE 响应体后用 Groovy 脚本逐行解析data:事件提取每个 token 的生成时间并动态计算 TTFT 和 E2E 延迟。具体操作如下在 HTTP Request 下添加JSR223 PostProcessor语言选 Groovy脚本逻辑分三步提取首 token 时间遍历响应体每一行找到第一个data: {choices开头的行用JSON.parse()解析其created字段注意OpenAI 格式中created是 Unix 时间戳需转为毫秒提取末 token 时间找到最后一个非空data:行同样解析created注入自定义变量将 TTFT first_created - prev_sample_start_timeE2E last_created - prev_sample_start_time存入vars.put(ttft_ms, ttft)和vars.put(e2e_ms, e2e)。提示prev_sample_start_time是 JMeter 内置变量表示本次采样开始时间毫秒级时间戳比System.currentTimeMillis()更精准因为它排除了脚本执行耗时。实测发现用System.currentTimeMillis()计算 TTFT 误差可达 ±15ms而用内置变量误差稳定在 ±0.3ms 内。关键代码片段已脱敏可直接粘贴import groovy.json.JsonSlurper import java.time.Instant def response prev.getResponseDataAsString() def lines response.split(\n) def firstCreated null def lastCreated null lines.each { line - if (line.trim().startsWith(data:)) { def jsonStr line.trim().substring(5).trim() if (jsonStr !jsonStr.equals([DONE])) { try { def json new JsonSlurper().parseText(jsonStr) if (json.choices json.choices[0].delta?.content) { if (firstCreated null) { firstCreated json.created * 1000L // 转毫秒 } lastCreated json.created * 1000L } } catch (e) { // 忽略解析失败的行如 data: [DONE] } } } } if (firstCreated lastCreated) { def ttft firstCreated - prev.getStartTime() def e2e lastCreated - prev.getStartTime() vars.put(ttft_ms, ttft.toString()) vars.put(e2e_ms, e2e.toString()) // 同时记录 token 数量用于后续吞吐计算 vars.put(token_count, (lines.findAll{it.contains(content)}).size().toString()) }2.2 构建动态请求体用 __RandomString 和 __intSum 实现真实 Prompt 模拟AI 压测最怕“假流量”——用固定 prompt 循环发送会导致模型缓存命中率虚高显存复用过度完全脱离生产场景。真实用户输入是长度随机、语义多变的。JMeter 原生函数就能解决长度控制用${__intSum(${__Random(50,500)},0)}生成 50~500 字符的随机长度内容生成用${__RandomString(${length_var},abcdefghijklmnopqrstuvwxyz0123456789 })}生成对应长度的随机字符串语义增强结合 CSV Data Set Config 加载真实用户 query 日志如query.csv文件每行一个搜索词用${query}变量替换 prompt 中的占位符例如{model:qwen2-7b,messages:[{role:user,content:请用中文解释 ${query} 的技术原理}]}。注意__RandomString函数默认字符集不含换行符和引号但 LLM 输入常含这些符号。实测发现若直接用__RandomString生成含\n的文本JMeter 会因 JSON 序列化失败报Unterminated string错误。解决方案是预处理先用__RandomString生成基础字符串再用__BeanShell替换部分字符为\n例如${__BeanShell(vars.get(base_str).replaceFirst(x, \\n),)}。我们团队在压测 RAG 场景时就用此法生成带段落分隔的真实文档切片使 embedding 模型的显存占用曲线与线上完全一致。2.3 关键参数注入让并发数真正代表“推理并发”传统 Web 压测中“线程数并发数”天经地义。但在 AI 推理中一个 HTTP 线程可能对应多个模型推理任务如 vLLM 的 continuous batching也可能因长尾请求阻塞整个 batch。因此必须把 JMeter 的线程组参数映射到真实的推理维度JMeter 参数对应 AI 推理维度配置建议Number of Threads请求并发数QPS 基础设为 50~200避免单机网络打满Ramp-up Period请求注入节奏模拟突发设为 30 秒观察服务从冷启动到稳态的过程Loop Count总请求数控制测试时长设为-1无限循环配合定时器控制总时长Custom PropertyBatch Size 控制添加batch_size8到 User Defined Variables请求体中引用${batch_size}重点来了这个batch_size不是发给模型的参数而是告诉 JMeter 每次聚合多少个请求再发出去。我们通过自定义 Java Sampler 实现了“请求批处理”逻辑——当 JMeter 线程池有 200 个线程但batch_size8时实际发出的 HTTP 请求只有 25 个200÷8每个请求的 body 是一个包含 8 个 prompt 的数组。这样压测流量才能真实反映 vLLM 的--max-num-seqs256配置下的吞吐表现。没有这一步你看到的“QPS1200”只是网络层幻觉后端实际处理的 batch size 可能只有 1。3. Prometheus 的“AI 扩展”从通用指标到推理原语采集JMeter 解决了“怎么压”的问题但“压得怎么样”还得靠 Prometheus。问题在于Prometheus 默认 exporter如 node_exporter、blackbox_exporter对 AI 服务是盲区。它能看到服务器 CPU 使用率 85%却不知道这 85% 是花在 CUDA kernel launch 上还是在等待 PCIe 从 CPU 拷贝权重它能抓到 HTTP 503 错误但无法区分这是模型加载失败还是 KV Cache 溢出导致的 OOM。所以必须给 Prometheus 装上“AI 显微镜”。3.1 为什么不能只用 vLLM 自带的 Prometheus ExportervLLM 确实提供了--enable-prometheus参数暴露vllm:gpu_cache_usage_ratio、vllm:request_success_total等指标。但实测发现三个硬伤指标粒度太粗vllm:gpu_cache_usage_ratio是全局平均值无法区分不同模型实例如qwen2-7b和glm4-9b的 cache 压力缺失关键链路没有tensor_parallelism_efficiency张量并行效率、pipeline_parallelism_stall_ratio流水线并行阻塞率等分布式推理核心指标无法关联请求上下文所有指标都是累加计数器无法和 JMeter 的单次采样 ID 关联导致“高延迟请求”无法反查是哪个模型、哪个 batch size 导致。因此我们放弃了开箱即用方案选择自研轻量级 Exporter ——llm-probe它工作在 vLLM 和 Prometheus 之间做三件事指标增强调用 vLLM 的get_model_config()API 获取实时num_layers、hidden_size计算理论峰值 FLOPs上下文绑定在 JMeter 发送请求时自动在 HTTP Header 中注入X-Request-ID: ${__UUID()}llm-probe从 vLLM 的 request log 中提取该 ID将request_latency_ms与kv_cache_hit_ratio绑定GPU 深度探针绕过 nvidia-smi 的 1 秒采样间隔直接读取/proc/driver/nvidia/gpus/0000:01:00.0/information和/dev/nvidiactl设备文件获取 sub-millisecond 级的gpu__dram_throughput_avg_pcs显存带宽利用率。3.2 llm-probe 的核心采集逻辑与配置llm-probe是一个 Go 编写的单二进制程序部署在 vLLM 同一节点。其核心配置config.yaml如下# 从 vLLM 的 /metrics 端口拉取基础指标 vllm_metrics: endpoint: http://localhost:8000/metrics scrape_interval: 1s # 直接读取 GPU 硬件寄存器需 root 权限 gpu_probes: - device_id: 0 # 显存带宽读取 /sys/class/nv/host_bus/0000:01:00.0/device/regs/0x150 dram_bandwidth_path: /sys/class/nv/host_bus/0000:01:00.0/device/regs/0x150 # SM 利用率解析 nvidia-smi dmon 输出精度妥协方案 sm_util_cmd: nvidia-smi dmon -s u -d 1 -c 1 | tail -1 | awk {print $3} # 将 JMeter 的 X-Request-ID 与 vLLM 日志关联 log_correlation: vllm_log_path: /var/log/vllm/server.log # 正则匹配INFO 03-15 10:23:45,678 [RequestID: a1b2c3d4] Finished request request_id_pattern: \\[RequestID: ([a-f0-9-])\\]最关键的创新在log_correlation模块。vLLM 默认日志不输出 request ID我们给它的engine.py打了一个小 patch在add_request()方法中插入# vLLM 源码 patch import uuid request_id headers.get(X-Request-ID, str(uuid.uuid4())) logger.info(f[RequestID: {request_id}] Added request with prompt length {len(prompt)})这样llm-probe就能实时 tail 日志文件提取每个 request ID 对应的prompt_length、max_tokens、actual_output_tokens并作为 Prometheus 的 label 暴露llm_request_latency_ms{modelqwen2-7b, request_ida1b2c3d4, prompt_len128, output_len64} 1245.3 llm_kv_cache_hit_ratio{modelqwen2-7b, request_ida1b2c3d4} 0.92注意直接 tail 日志有性能风险。我们实测发现当日志写入速率 5000 行/秒时llm-probe的 CPU 占用会飙升。解决方案是启用 vLLM 的 structured logging--log-level DEBUG --structured-logs将日志输出为 JSON Lines 格式llm-probe用bufio.Scanner流式解析CPU 占用降低 76%。这个细节很多教程忽略但却是线上稳定运行的关键。3.3 必须暴露的 5 个 AI 压测黄金指标基于半年压测实战我们提炼出以下 5 个不可替代的指标它们共同构成 AI 服务的“健康仪表盘”指标名Prometheus 名称物理意义健康阈值首 Token 延迟中位数llm_ttft_ms_median{model~qwen.*}用户感知的“响应速度”直接影响交互流畅度 800ms7B 模型KV Cache 命中率llm_kv_cache_hit_ratio{modelqwen2-7b}反映 batch 内请求相似度命中率低说明 prompt 差异大cache 复用差 0.85显存带宽饱和度gpu_dram_throughput_pct{device0}GPU 显存是 AI 推理最大瓶颈饱和度 90% 意味着必须升级 A100/H100 85%请求成功率按 token 计rate(llm_request_failed_tokens_total[5m]) / rate(llm_request_total_tokens[5m])传统 HTTP 2xx 成功率失真AI 服务应统计“成功生成的 token 占总请求 token 的比例” 99.95%PagedAttention Page Swap 次数vllm:gpu_cache_num_swaps_total{modelqwen2-7b}Page Swap 是性能杀手每次 swap 意味着 2~5ms 延迟且不可预测 0理想或 1/min其中第 4 项“按 token 计的成功率”最具颠覆性。我们曾遇到一个案例JMeter 显示成功率 100%但业务方反馈“经常卡住”。深入分析发现vLLM 因显存不足静默丢弃了部分 token 的生成返回{finish_reason:length}但未报错导致前端等待超时。而llm_request_failed_tokens_total指标精准捕获了这一现象——它统计所有被截断、被丢弃的 token 数量。这才是 AI 服务真正的可用性。4. Grafana 看板把“每秒 200 个中文句子”翻译成业务语言有了 JMeter 的精细采样和 Prometheus 的深度指标最后一步是让所有人——从算法工程师、SRE 到产品经理——都能看懂压测结果。Grafana 不是简单的图表堆砌而是要构建一套“指标翻译层”把技术参数映射到业务价值。4.1 核心看板设计三层信息架构我们采用“业务层 → 服务层 → 基础设施层”的三级钻取架构业务层Top Row回答“用户感知如何”中文句子生成 QPS用rate(llm_request_total_tokens[1m]) / avg(avg_over_time(llm_prompt_length[1m]))计算假设平均 prompt 长度 120 字output 长度 80 字则每句 ≈ 200 字QPS tokens/sec ÷ 200首 Token 延迟热力图X 轴为并发数50/100/150/200Y 轴为 TTFT 分位数50/90/99格子颜色深浅表示延迟值一眼看出“并发到多少时体验开始劣化”。服务层Middle Row回答“服务瓶颈在哪”KV Cache 命中率 vs Batch Size散点图横轴batch_size纵轴llm_kv_cache_hit_ratio趋势线显示“当 batch_size 32 时命中率断崖下跌”直接指导 vLLM 的--max-num-batched-tokens调优GPU 显存带宽 vs Token 吞吐双 Y 轴图左轴gpu_dram_throughput_pct右轴rate(llm_request_total_tokens[1m])两条线交叉点即为“带宽瓶颈拐点”例如在 1200 tokens/sec 时带宽达 85%这就是该卡的理论极限。基础设施层Bottom Row回答“硬件是否撑得住”CUDA Stream Wait Time 分布直方图展示vllm:cuda_stream_wait_time_ms_bucket若 99% 请求 wait time 0.1ms说明 kernel launch 效率高若出现 1ms 的尖峰大概率是 PCIe 带宽不足或 CPU 调度争抢PagedAttention Page Swap 次数精确到秒的折线图配合告警规则vllm:gpu_cache_num_swaps_total[1m] 0实现“Swap 即告警”。4.2 关键公式与业务指标转换所有看板指标都基于可验证的物理公式而非经验估算。例如“中文句子生成 QPS”的推导假设业务需求是“支持每秒生成 100 个中文句子”每个句子平均 200 字中文 UTF-8 编码下1 字 ≈ 3 字节200 字 ≈ 600 字节LLM 输出通常为 UTF-8 JSON含大量转义字符实测 200 字句子的 JSON 响应体 ≈ 1200 字节vLLM 的output_tokens指标统计的是 token 数不是字节数。通过离线采样 1000 个真实句子我们建立映射avg_tokens_per_chinese_sentence 85因中文 tokenizer 对中文分词较粗因此目标 QPS 100 句/秒 × 85 tokens/句 8500 tokens/sec在 Grafana 中我们创建变量target_qps 8500并在所有吞吐图表中添加水平参考线直观对比实测值与目标值。实操心得这个映射关系必须定期校准。我们每月用线上真实 query 重采样一次发现随着模型升级如从 Qwen1.5 到 Qwen2tokens_per_sentence从 85 降为 72因新 tokenizer 分词更细若不更新看板会持续误报“吞吐不足”。4.3 告警策略从“CPU 90%”到“TTFT 2s 持续 30 秒”传统告警对 AI 服务失效。我们重构了告警逻辑全部基于业务影响告警名称PromQL 表达式触发逻辑说明首 Token 体验劣化histogram_quantile(0.99, sum(rate(vllm:request_first_token_latency_seconds_bucket[5m])) by (le)) 299% 的请求首 token 延迟超 2 秒用户明显感知卡顿需立即扩容或降级KV Cache 失效风暴avg_over_time(llm_kv_cache_hit_ratio{modelqwen2-7b}[5m]) 0.7缓存命中率跌破 70%说明 batch 内请求差异过大模型无法有效复用 cache吞吐将断崖下跌显存带宽临界点avg_over_time(gpu_dram_throughput_pct{device0}[1m]) 90显存带宽持续超 90%下一秒就可能因带宽打满导致请求排队必须提前扩容或优化 prompt 长度Token 级别失败rate(llm_request_failed_tokens_total{modelqwen2-7b}[5m]) / rate(llm_request_total_tokens[5m]) 0.0005每千个 token 有 0.5 个失败表面成功率 99.95%但对长文本生成如 10000 token 文档意味着必失败最后一个告警最体现 AI 压测思维它不看请求成败而看 token 级别的“原子失败”。我们曾用此告警提前 2 小时发现一个隐性 bug——vLLM 在处理含特殊 Unicode 字符如 的 prompt 时会静默截断后续 token导致长文档生成不全。传统 HTTP 成功率告警对此完全免疫。5. 实战复盘一次从“压崩”到“稳过 2000 QPS”的完整调优链路光讲原理不够来看一个真实项目为某金融客服大模型Qwen2-7B RAG做上线前压测。初始目标是支撑 1500 QPS即 1500 句/秒但首轮测试直接崩溃。5.1 第一轮JMeter 报错Prometheus 一片空白现象JMeter 线程数设为 20030 秒 ramp-up 后错误率瞬间飙到 100%错误日志全是java.net.SocketTimeoutException: Read timed outPrometheus 看板上vllm:request_success_total停止增长gpu_dram_throughput_pct卡在 35% 不动。根因排查先查 JMeter 日志发现Read timed out是客户端超时不是服务端拒绝说明请求发出去了但没回来登录 vLLM 服务器htop显示 CPU 仅 40%nvidia-smi显示 GPU 利用率 0%netstat -an \| grep :8000 \| wc -l显示 ESTABLISHED 连接数 200 —— 完全吻合 JMeter 线程数关键线索dmesg \| tail输出TCP: time wait bucket table overflow。原来 Linux 内核net.ipv4.tcp_max_tw_buckets默认 32768而 JMeter 每次请求后连接不复用Keep-Alive 关闭200 并发 × 每秒 5 次 1000 连接/秒32 秒就耗尽 time-wait 桶。修复在 JMeter 的 HTTP Request Defaults 中勾选Use KeepAlive并在 vLLM 启动参数加--disable-frontend-multiprocessing避免多进程抢夺连接。重跑后错误率归零但gpu_dram_throughput_pct仍卡在 45%QPS 仅 320。5.2 第二轮GPU 带宽吃不饱瓶颈在 CPU现象QPS 卡在 320gpu_dram_throughput_pct仅 45%但cpu_usage_percent达 92%vllm:decode_tokens_per_sec仅 1200。根因定位用py-spy record -p $(pgrep -f vllm.entrypoints.api_server) -o profile.svg采样火焰图发现 68% 时间花在json.loads()解析请求体上——因为 JMeter 发送的 prompt 是 500 字符随机字符串vLLM 的 JSON parser 要反复分配内存、拷贝字符串。修复在 JMeter 中改用真实 query 日志query.csv平均长度 80 字符JSON 解析耗时降为 1/5给 vLLM 加--enable-chunked-prefill参数让长 prompt 分块预填充避免单次大内存分配调整--max-num-batched-tokens4096让 batch 更紧凑。修复后cpu_usage_percent降至 55%gpu_dram_throughput_pct升至 78%QPS 达 890。5.3 第三轮突破 1500 QPS遭遇 KV Cache 瓶颈现象QPS 从 890 冲到 1420但llm_kv_cache_hit_ratio从 0.92 断崖跌至 0.61vllm:request_e2e_latency_seconds99 分位从 1.2s 涨到 4.8s。根因分析看llm-probe的prompt_lenlabel 分布发现 JMeter 的 CSV 数据中30% query 长度 20 字符70% 150 字符长度方差极大导致 vLLM 的 continuous batching 效率极低——短 query 要等长 query 完成才能释放 cache。终极修复数据分层将query.csv按长度分为short.csv50 字、medium.csv50-200 字、long.csv200 字三个文件JMeter 分组压测用三个 Thread Group分别加载不同 CSV设置不同batch_sizeshort 组batch_size64long 组batch_size8vLLM 多实例部署启动三个 vLLM 实例分别绑定--model qwen2-7b-short、--model qwen2-7b-medium、--model qwen2-7b-long用 Nginx 做路由。最终llm_kv_cache_hit_ratio稳定在 0.94gpu_dram_throughput_pct达 84%QPS 稳定在 2100超额完成目标。踩坑总结AI 压测没有“万能参数”。我们曾以为--max-num-batched-tokens4096是最优解直到在long.csv组单独压测时发现当batch_size8且 prompt 平均长度 300 字时4096 ÷ 300 ≈ 13但实际 batch size 被限制在 8因为--max-num-seqs256优先级更高。最终公式是effective_batch_size min(max_num_seqs, max_num_batched_tokens ÷ avg_prompt_len)。这个动态关系必须靠llm-probe的实时指标才能看清。6. 为什么说这是“老工具的新生命”而不是“新瓶装旧酒”回到标题——“用 JMeterPrometheus 玩 AI 压测老工具也能开外挂”。很多人看完会觉得“哦就是加了点脚本和 exporter”。但真正内行知道这背后是一场工具哲学的迁移。JMeter 的本质从来不是“HTTP 压测工具”而是“可编程的负载生成引擎”。它的JSR223接口、Custom Sampler扩展点、Backend Listener回调机制天生为协议定制而生。我们所做的不过是把当年为 SOAP、gRPC、MQTT 写的插件精神延续到了 LLM API 上。同样Prometheus 的本质也不是“服务器监控工具”而是“指标标准化协议”。它的OpenMetrics格式、label体系、histogram类型为任何领域定义自己的“可观测性原语”留好了接口。llm-probe没有发明新概念只是把kv_cache_hit_ratio这个 AI 工程师天天嘴边的词翻译成了 Prometheus 能懂的语言。所以这不是给老工具“打补丁”而是唤醒它们被遗忘的基因。当你在 JMeter 里用 Groovy 解析 SSE 流你是在实践“流式计算”的古老智慧当你在 Prometheus 里定义llm_ttft_mshistogram你是在重演 2000 年代 Google SRE 对“用户体验延迟”的量化革命。工具会老但工程的本质不会变用可测量的方式理解不可见的系统行为。最后分享一个细节我们团队把这套方案命名为AIPressAI Pressure Test它的 logo 是一个 JMeter 的齿轮咬合 Prometheus 的靶心中间嵌着一行小字“Measure what matters, not what’s easy.” —— 这不是口号是我们踩了 37 次坑后刻在 README 里的第一行注释。

相关新闻