大模型推理服务性能压测教程

本教程面向内部技术人员,指导如何使用 llm_benchmark.py 对 Chat 类大模型进行系统性性能压测,并基于测试结果填写评估报告模板。


1. 环境准备

1.1 压测客户端

  • 操作系统:Linux(推荐 openEuler / Ubuntu)

  • Python 版本:≥ 3.8

  • 依赖库

    bash

    pip install aiohttp --break-system-packages   # 若系统保护,可加 --user
  • 网络:与目标模型服务内网互通,避免公网延迟干扰。

1.2 获取脚本

将 llm_benchmark.py 拷贝到工作目录,赋予执行权限(可选):

bash

chmod +x llm_benchmark.py

1.3 确认服务地址

记录待测模型的 URL 和 MODEL_NAME,例如:

  • URL:http://1xx.xx.xx.6x:233xx/v1/chat/completions

  • Model:QwQ-32B

注意:接口必须兼容 OpenAI Chat Completion 流式格式(支持 stream 和 stream_options)。


2. 脚本参数速查

bash

python llm_benchmark.py --help
参数必填说明示例
--url服务端 API 地址http://127.0.0.1:8000/v1/chat/completions
--model模型名称(须服务端识别)Qwen2.5-14B
--prompt测试用提示词(默认见脚本)"你好"
--max-tokens最大输出 token 数(默认 512)2048
--timeout单请求超时秒数(默认 120)180
--num-requests每个并发档位的请求总数(默认 20,建议 ≥50 以保证统计稳定性)50
--concurrency固定单一并发数(与 --concurrency-sweep 二选一)10
--concurrency-sweep并发扫描档位,逗号分隔(推荐 1,5,10,20,501,5,10,20,50
--api-key若服务需要认证则填写Bearer xxx

3. 测试场景设计(T1~T4)

根据业务需求,通常执行 4 组场景,从极短输出到长文生成,全面覆盖。

场景编号场景名称--max-tokens推荐 --prompt核心目的
T1TTFT 基线16"你好"排除生成耗时,获取纯调度/prefill 延迟基线
T2实时对话64"用一句话总结人工智能在建筑领域最核心的应用。"模拟实时交互,体验响应速度
T3基准场景 ⭐512"请详细介绍一下人工智能在建筑行业的应用场景,包括但不限于智能监控、人脸识别等方面,尽量展开说明。"横向对比主场景,最贴近业务
T4文书生成2048与 T3 相同 prompt隔离输出长度变量,测试长输出稳定性

关键:T3 和 T4 使用相同 prompt,仅 --max-tokens 不同,才能对比输出长度对 TPS 的影响。


4. 执行压测(完整步骤)

4.1 单模型单场景测试

以 T3 基准场景 为例,执行一次并发扫描:

bash

python llm_benchmark.py \
  --url http://1xx.xx.xx.6x:233xx/v1/chat/completions \
  --model QwQ-32B \
  --prompt "请详细介绍一下人工智能在建筑行业的应用场景,包括但不限于智能监控、人脸识别等方面,尽量展开说明。" \
  --max-tokens 512 \
  --timeout 120 \
  --num-requests 50 \
  --concurrency-sweep 1,5,10,20,50

4.2 完整四场景自动化脚本(推荐)

为每个模型建立 Shell 脚本,顺序执行 T1~T4,并在每次测试间 sleep 5 秒让服务恢复。

bash

#!/bin/bash
MODEL_URL="http://1xx.xx.xx.6x:233xx/v1/chat/completions"
MODEL_NAME="QwQ-32B"

# T1
python llm_benchmark.py --url $MODEL_URL --model $MODEL_NAME \
  --prompt "你好" --max-tokens 16 --timeout 120 --num-requests 50 \
  --concurrency-sweep 1,5,10,20,50
sleep 5

# T2
python llm_benchmark.py --url $MODEL_URL --model $MODEL_NAME \
  --prompt "用一句话总结人工智能在建筑领域最核心的应用。" --max-tokens 64 \
  --timeout 120 --num-requests 50 --concurrency-sweep 1,5,10,20,50
sleep 5

# T3
python llm_benchmark.py --url $MODEL_URL --model $MODEL_NAME \
  --prompt "请详细介绍一下人工智能在建筑行业的应用场景,包括但不限于智能监控、人脸识别等方面,尽量展开说明。" \
  --max-tokens 512 --timeout 120 --num-requests 50 --concurrency-sweep 1,5,10,20,50
sleep 5

# T4
python llm_benchmark.py --url $MODEL_URL --model $MODEL_NAME \
  --prompt "请详细介绍一下人工智能在建筑行业的应用场景,包括但不限于智能监控、人脸识别等方面,尽量展开说明。" \
  --max-tokens 2048 --timeout 120 --num-requests 50 --concurrency-sweep 1,5,10,20,50

注意:若 T4 高并发下大量超时,脚本可能卡住,可提前终止该档位(Ctrl+C)后继续下一档。

4.3 输出保存

将终端输出重定向至日志文件,方便后续提取数据:

bash

bash run_test.sh 2>&1 | tee model_name_T3.log

5. 结果解读与关键指标

5.1 脚本输出示例

text

并发数: 50  |  请求数: 50 (成功 50 / 失败 0)
墙钟总耗时: 52.34s  |  QPS: 0.96
系统总Token吞吐 (system TPS): 1003.44 tokens/s   <-- 核心并发能力指标
单请求平均生成速率: 39.87 tokens/s
TTFT  avg/p50/p90/p99: 0.083s / 0.083s / 0.091s / 0.097s
总延迟 avg/p50/p90/p99: 12.84s / 12.58s / 13.51s / 14.05s

5.2 指标定义与用途

指标含义业务意义
TTFT P50/P90/P99首 Token 延迟分布用户感知的“响应速度”,P90 反映绝大多数体验
system_tps所有请求累计 token 数 ÷ 墙钟总耗时服务端总吞吐能力,横向对比核心
单请求平均生成速率token数 ÷ 该请求总耗时(纯生成阶段)模型本身生成速度,不含排队
总延迟 P50/P90/P99端到端完整响应耗时用户等待完整结果的时间
吞吐拐点system_tps 不再随并发增加而增长的点容量规划上限,超过则排队加剧

5.3 关键判断规则

  • TTFT 劣化拐点:观察 TTFT P90 随并发变化,若某并发下突增(如从 0.5s 升至 3s),则说明服务开始严重排队。

  • 吞吐拐点:在汇总对比表中,当 system_tps 增幅显著减小(< 5%)或开始下降时,当前并发即为饱和点。

  • 稳定性:失败率 > 0% 说明服务不堪重负,需降低并发或优化。


6. 填写报告模板

6.1 数据提取(从脚本输出填入表格)

每个模型的每一场景(T1~T4)均需填写类似下表的行,数据直接取自脚本输出的对应并发行。

并发数成功/总请求失败率系统TPSQPSTTFT AVG (s)TTFT P50 (s)TTFT P90 (s)TTFT P99 (s)延迟 AVG (s)延迟 P50 (s)延迟 P90 (s)延迟 P99 (s)
150/500%39.870.080.0830.0830.0910.09712.8412.5813.5114.05
.......................................

注意:失败率 = (总请求 - 成功数) / 总请求 × 100%。

6.2 填写单请求基准(T3 并发=1)

从 T3 并发=1 的行中提取:

  • 平均 TTFT

  • 单请求 TPS(脚本输出中有 单请求平均生成速率

  • 平均总延迟

  • 平均输出 Token 数/请求 = 单请求 TPS × 平均总延迟(可近似估算)

  • finish_reason:需查看服务端返回,若输出 token 数远小于 max-tokens 且 finish_reason 为 stop,说明模型提前结束,TPS 可能虚高。

6.3 填写性能曲线描述(基于 T3 数据)

观察各并发下的 system_tps 和 TTFT P90,给出:

  • 吞吐拐点:例如 并发数达到 50 时,system_tps=1093,继续增加可能持平,未饱和

  • TTFT 拐点:例如 并发数超过 20 后,TTFT P90 由 0.21s 增至 0.46s,但仍低于 0.5s,可接受

  • 输出长度影响:对比 T3 和 T4 并发=1 的单请求 TPS,计算变化百分比(公式:(T4 - T3) / T3 * 100%)。

  • 稳定性:指出开始出现失败的并发阈值。

6.4 横向对比与问题汇总

在报告第四章(横向对比分析)中,填入各模型在 T3 下的峰值 system_tps、TTFT 基线等。问题清单按实际观察填写,严重程度自行判定。


7. 常见问题 FAQ

Q1:脚本报错 ModuleNotFoundError: No module named 'aiohttp'

A:安装依赖 pip install aiohttp,若系统限制可加 --user 或 --break-system-packages(Python 3.11+)。

Q2:为什么 system_tps 很高但 单请求平均生成速率 很低?

A:高并发下多个请求并行,system_tps 是累计值,单请求生成速率受 GPU 时间片影响会下降。正常现象,只要 system_tps 增长,说明并发扩展有效。

Q3:输出 token 数远小于 --max-tokens,怎么回事?

A:模型主动停止(finish_reason=stop),可能因为 prompt 较短或模型认为已回答完整。这不是错误,但 TPS 会偏高(因为生成长度短)。若要测试最大长度能力,可调整 prompt 或增加 --max-tokens,但需注意服务超时。

Q4:高并发下大量超时,脚本卡住怎么办?

A:可提前设置 --timeout 较小(如 60s)让失败快速返回;或直接 Ctrl+C 终止当前档位,记录失败率后继续下一档。在报告中注明“并发=50 时因超时主动终止”。

Q5:如何确保分位数 P90/P99 稳定?

A--num-requests 至少 30,建议 50 以上。脚本会给出警告若样本数 < 10,此时分位数不可靠,需增加请求数重测。

Q6:不同模型的 --concurrency-sweep 需要一致吗?

A:必须一致!否则横向对比失去意义。若某模型在 50 并发下全失败,仍执行 50 档并记录失败率,但可不填性能数据。

Q7:能否测试 Embedding 或 Reranker 服务?

A:本脚本仅针对 Chat 类(流式输出)。Embedding/Reranker 有独立的压测脚本(embedding_benchmark_v2.pyreranker_benchmark_v2.py),用法类似,但指标为 sentences/s 或 pairs/s。


📎 附录:llm_benchmark.py源码

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型接口压测脚本
测试指标: TTFT(首token延迟)、单请求TPS、系统总TPS、并发能力、P50/P90/P99延迟

依赖: pip install aiohttp --break-system-packages

使用示例:
  单次测试:
    python llm_benchmark.py --url http://localhost:8000/v1/chat/completions \
        --model qwen2.5-72b --concurrency 1 --num-requests 5

  并发扫描测试(自动测试多个并发档位):
    python llm_benchmark.py --url http://localhost:8000/v1/chat/completions \
        --model qwen2.5-72b --concurrency-sweep 1,5,10,20,50 --num-requests 50
"""

import argparse
import asyncio
import json
import math
import statistics
import time

import aiohttp


DEFAULT_PROMPT = "请详细介绍一下人工智能在建筑行业的应用场景,包括但不限于智能监控、人脸识别等方面,尽量展开说明。"


class RequestResult:
    def __init__(self):
        self.success = False
        self.error = None
        self.ttft = None
        self.total_time = None
        self.token_count = 0
        self.start_time = None
        self.end_time = None


def percentile(sorted_data: list, p: float):
    """
    计算百分位数。
    使用 ceil 插值:index = ceil(n * p) - 1,与 numpy percentile method='lower' 行为一致。
    要求 sorted_data 已排序,p 取值范围 (0, 1]。
    """
    n = len(sorted_data)
    if n == 0:
        return None
    idx = int(math.ceil(n * p)) - 1
    idx = max(0, min(idx, n - 1))   # 边界保护,防止越界
    return sorted_data[idx]


async def send_streaming_request(session, url, model, prompt, max_tokens, headers, timeout):
    """发送一条流式请求,统计 TTFT 和 token 生成速率"""
    result = RequestResult()

    # Fix: 加入 stream_options 请求服务端返回精确的 usage token 数
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "stream": True,
        "max_tokens": max_tokens,
        "temperature": 0.7,
        "stream_options": {"include_usage": True},
    }

    result.start_time = time.perf_counter()
    first_token_time = None
    token_count = 0
    usage_completion_tokens = None

    try:
        async with session.post(
            url, json=payload, headers=headers,
            timeout=aiohttp.ClientTimeout(total=timeout)
        ) as resp:
            if resp.status != 200:
                result.error = f"HTTP {resp.status}: {await resp.text()}"
                return result

            async for line in resp.content:
                line = line.decode("utf-8", errors="ignore").strip()
                if not line or not line.startswith("data:"):
                    continue
                data_str = line[len("data:"):].strip()
                if data_str == "[DONE]":
                    break
                try:
                    chunk = json.loads(data_str)
                except json.JSONDecodeError:
                    continue

                choices = chunk.get("choices", [])
                has_content = False
                if choices:
                    delta = choices[0].get("delta", {})
                    if delta.get("content"):
                        has_content = True

                if has_content:
                    if first_token_time is None:
                        first_token_time = time.perf_counter()
                    token_count += 1   # chunk 数近似,有 usage 时会被覆盖

                if chunk.get("usage"):
                    usage_completion_tokens = chunk["usage"].get("completion_tokens")

        result.end_time = time.perf_counter()
        result.total_time = result.end_time - result.start_time
        result.ttft = (first_token_time - result.start_time) if first_token_time else None
        # 优先使用服务端返回的精确 token 数,fallback 到 chunk 计数近似值
        result.token_count = usage_completion_tokens if usage_completion_tokens else token_count
        result.success = True

    except asyncio.TimeoutError:
        result.error = "Timeout"
        result.end_time = time.perf_counter()
    except Exception as e:
        result.error = str(e)
        result.end_time = time.perf_counter()

    return result


async def run_concurrency_level(url, model, prompt, max_tokens, timeout,
                                 concurrency, num_requests, api_key=None):
    """在给定并发数下跑 num_requests 个请求,返回汇总统计"""
    # Fix: headers 在函数内部构建,彻底避免多档位间共享同一对象的隐患
    headers = {"Content-Type": "application/json"}
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"

    connector = aiohttp.TCPConnector(limit=0)
    sem = asyncio.Semaphore(concurrency)

    async def bound_request(session):
        async with sem:
            return await send_streaming_request(session, url, model, prompt, max_tokens, headers, timeout)

    async with aiohttp.ClientSession(connector=connector) as session:
        wall_start = time.perf_counter()
        tasks = [bound_request(session) for _ in range(num_requests)]
        results = await asyncio.gather(*tasks)
        wall_end = time.perf_counter()

    wall_time = wall_end - wall_start
    success_results = [r for r in results if r.success]
    failed = [r for r in results if not r.success]

    if not success_results:
        print(f"  [并发={concurrency}] 全部失败!示例错误: {failed[0].error if failed else 'unknown'}")
        return None

    ttfts = sorted([r.ttft for r in success_results if r.ttft is not None])
    total_times = sorted([r.total_time for r in success_results])
    token_counts = [r.token_count for r in success_results]
    total_tokens = sum(token_counts)

    # Fix: 样本数 < 10 时给出警告,P90/P99 数值仅供参考
    if len(ttfts) < 10:
        print(f"  ⚠ 警告: 成功样本数={len(ttfts)} < 10,P90/P99 分位数统计意义有限,建议增大 --num-requests 到 20 以上")

    # token 计数来源提示
    has_usage = any(r.token_count > 0 for r in success_results)
    if not has_usage:
        print("  ⚠ 警告: 服务端未返回 usage.completion_tokens,TPS 使用 chunk 数近似,数值偏低,仅供横向对比参考")

    per_req_tps = [
        r.token_count / r.total_time for r in success_results
        if r.total_time and r.total_time > 0 and r.token_count > 0
    ]

    summary = {
        "concurrency": concurrency,
        "num_requests": num_requests,
        "num_success": len(success_results),
        "num_failed": len(failed),
        "wall_time_s": wall_time,
        "qps": len(success_results) / wall_time if wall_time > 0 else 0,
        "total_tokens": total_tokens,
        "system_tps": total_tokens / wall_time if wall_time > 0 else 0,
        "avg_per_request_tps": statistics.mean(per_req_tps) if per_req_tps else None,
        # TTFT 分位数 —— Fix: 使用 ceil 插值,不再偏移到最大值
        "ttft_avg": statistics.mean(ttfts) if ttfts else None,
        "ttft_p50": percentile(ttfts, 0.50),
        "ttft_p90": percentile(ttfts, 0.90),
        "ttft_p99": percentile(ttfts, 0.99),
        # 端到端延迟分位数 —— Fix: 同上
        "latency_avg": statistics.mean(total_times),
        "latency_p50": percentile(total_times, 0.50),
        "latency_p90": percentile(total_times, 0.90),
        "latency_p99": percentile(total_times, 0.99),
    }
    return summary


def print_summary(summary):
    if summary is None:
        return
    print(f"\n  并发数: {summary['concurrency']}  |  请求数: {summary['num_requests']} "
          f"(成功 {summary['num_success']} / 失败 {summary['num_failed']})")
    print(f"  墙钟总耗时: {summary['wall_time_s']:.2f}s  |  QPS: {summary['qps']:.2f}")
    print(f"  系统总Token吞吐 (system TPS): {summary['system_tps']:.2f} tokens/s   <-- 核心并发能力指标")
    if summary['avg_per_request_tps']:
        print(f"  单请求平均生成速率: {summary['avg_per_request_tps']:.2f} tokens/s")
    if summary['ttft_avg'] is not None:
        p99_str = f"{summary['ttft_p99']:.3f}s" if summary['ttft_p99'] is not None else "N/A"
        print(f"  TTFT  avg/p50/p90/p99: {summary['ttft_avg']:.3f}s / {summary['ttft_p50']:.3f}s"
              f" / {summary['ttft_p90']:.3f}s / {p99_str}")
    p99_lat = f"{summary['latency_p99']:.2f}s" if summary['latency_p99'] is not None else "N/A"
    print(f"  总延迟 avg/p50/p90/p99: {summary['latency_avg']:.2f}s / {summary['latency_p50']:.2f}s"
          f" / {summary['latency_p90']:.2f}s / {p99_lat}")


async def main():
    parser = argparse.ArgumentParser(description="LLM 接口压测脚本")
    parser.add_argument("--url", required=True, help="接口地址,如 http://localhost:8000/v1/chat/completions")
    parser.add_argument("--model", required=True, help="模型名称")
    parser.add_argument("--api-key", default=None, help="API Key(如需要)")
    parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="测试用的prompt")
    parser.add_argument("--max-tokens", type=int, default=512, help="单次生成的最大token数")
    parser.add_argument("--timeout", type=int, default=120, help="单请求超时时间(秒)")
    parser.add_argument("--num-requests", type=int, default=20, help="每个并发档位测试的总请求数")
    parser.add_argument("--concurrency", type=int, default=None, help="单一并发数测试")
    parser.add_argument("--concurrency-sweep", type=str, default=None,
                         help="并发扫描,逗号分隔,如 1,5,10,20,50")
    args = parser.parse_args()

    if args.concurrency_sweep:
        levels = [int(x.strip()) for x in args.concurrency_sweep.split(",")]
    elif args.concurrency:
        levels = [args.concurrency]
    else:
        levels = [1, 5, 10, 20]

    print(f"目标接口: {args.url}")
    print(f"模型: {args.model}")
    print(f"测试并发档位: {levels}")
    print(f"每档位请求数: {args.num_requests}")
    print("=" * 70)

    all_summaries = []
    for level in levels:
        print(f"\n>>> 开始测试并发数 = {level} ...")
        summary = await run_concurrency_level(
            args.url, args.model, args.prompt, args.max_tokens,
            args.timeout, level, args.num_requests, args.api_key
        )
        print_summary(summary)
        if summary:
            all_summaries.append(summary)
        await asyncio.sleep(1)

    if len(all_summaries) > 1:
        print("\n" + "=" * 70)
        print("汇总对比表 (寻找系统吞吐的拐点/饱和点):")
        print(f"{'并发':>6} {'成功率':>8} {'系统TPS':>10} {'QPS':>8} {'TTFT_p50(s)':>12} {'TTFT_p90(s)':>12} {'延迟_p50(s)':>12} {'延迟_p90(s)':>12}")
        for s in all_summaries:
            success_rate = f"{s['num_success']}/{s['num_requests']}"
            ttft_p50 = f"{s['ttft_p50']:.3f}" if s['ttft_p50'] is not None else "N/A"
            ttft_p90 = f"{s['ttft_p90']:.3f}" if s['ttft_p90'] is not None else "N/A"
            lat_p90  = f"{s['latency_p90']:.2f}" if s['latency_p90'] is not None else "N/A"
            print(f"{s['concurrency']:>6} {success_rate:>8} {s['system_tps']:>10.2f} "
                  f"{s['qps']:>8.2f} {ttft_p50:>12} {ttft_p90:>12} {s['latency_p50']:>12.2f} {lat_p90:>12}")
        print("\n判断依据: 当并发数上升但 system_tps 不再增长甚至下降时,说明已达到服务端处理能力上限(吞吐拐点)。")


if __name__ == "__main__":
    asyncio.run(main())

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值