评测 LLM 推理服务时,为什么有效吞吐量比吞吐量更重要

2026-07-20 23 预计阅读时间: 1 分钟
来源: cncf.io AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

评测大语言模型服务时,最常见的数字是吞吐量:系统每秒处理多少请求,或者每秒生成多少 token。这个指标容易采集,也方便横向比较,但它可能掩盖真正影响用户体验的问题。一个服务即使吞吐量很高,如果大量请求超时、首 token 等待过久,或者在高负载下频繁失败,这些工作对用户并没有实际价值。

因此,生产环境更值得关注的是 goodput(有效吞吐量):在给定时间内,成功完成并满足服务等级目标(SLO)的请求数量。

吞吐量为什么会给出错误信号

假设两个 LLM 服务都处理了 1,000 个请求:

服务 测试时间 总吞吐量 成功且满足 SLO 有效吞吐量
A 100 秒 10 req/s 950 9.5 req/s
B 80 秒 12.5 req/s 600 7.5 req/s

只看吞吐量,B 比 A 高出 25%。但如果 SLO 要求请求成功,并且端到端延迟不超过 5 秒,那么 B 的有效吞吐量反而更低。

可以把这两个指标写成简单公式:

throughput = completed_requests / benchmark_duration

goodput = successful_requests_meeting_SLO / benchmark_duration

两者的差值不是统计噪声,而是被超时、错误和延迟违约消耗掉的计算资源。GPU 可能完成了这些推理,但调用方没有在可接受的时间内拿到结果。

LLM 推理尤其容易出现这种偏差。服务端可以通过增大批次提高 GPU 利用率和 token 吞吐量,但请求需要等待更长时间才能进入批次;并发继续上升后,队列还会迅速拉长。最终报表里的总 token 数很好看,交互式应用的首 token 延迟却已经不可接受。

SLO 必须描述用户实际等待的阶段

有效吞吐量不是一个脱离业务的固定数字。计算它之前,需要明确什么样的请求才算“有效”。对于 LLM 服务,常见条件包括:

  • HTTP 请求成功,没有限流、服务端错误或连接中断。
  • 首 token 延迟(TTFT)低于阈值。
  • token 间延迟(TPOT)或生成速度满足流式交互要求。
  • 端到端延迟(E2E latency)低于阈值。
  • 输出完整,没有因为超时或长度限制被意外截断。

例如,聊天产品可以定义:

成功状态码 = 2xx
TTFT <= 800 ms
E2E latency <= 8 s

离线摘要任务可能不关心 TTFT,而更关注任务是否在 60 秒内完成。代码补全则往往需要更严格的首 token 延迟。不同场景不能共用一个未经解释的 goodput 数字。

还要固定输入、输出长度分布。一个只生成 16 个 token 的基准,无法直接与平均生成 500 个 token 的工作负载比较。至少应同时报告请求 goodput、输出 token goodput、输入长度分位数和输出长度分位数。

可以这样实践:测量满足端到端 SLO 的请求

下面是一个可运行的最小压测脚本。它假设服务提供兼容 OpenAI Chat Completions 的 HTTP 接口,并使用非流式响应,因此示例测量的是端到端延迟,而不是 TTFT。运行前需要把 LLM_URLLLM_MODELLLM_API_KEY 改成实际环境的值。

安装依赖并执行:

python -m pip install aiohttp
export LLM_URL="http://127.0.0.1:8000/v1/chat/completions"
export LLM_MODEL="your-model"
export LLM_API_KEY="local-key"
python benchmark_goodput.py --requests 200 --concurrency 16 --slo-seconds 5

创建 benchmark_goodput.py

import argparse
import asyncio
import os
import statistics
import time

import aiohttp


async def send_request(session, semaphore, url, headers, payload):
    async with semaphore:
        started = time.perf_counter()
        try:
            async with session.post(url, headers=headers, json=payload) as response:
                body = await response.json(content_type=None)
                latency = time.perf_counter() - started
                output_tokens = body.get("usage", {}).get("completion_tokens", 0)
                return {
                    "ok": 200 <= response.status < 300,
                    "status": response.status,
                    "latency": latency,
                    "output_tokens": output_tokens,
                }
        except (aiohttp.ClientError, asyncio.TimeoutError):
            return {
                "ok": False,
                "status": 0,
                "latency": time.perf_counter() - started,
                "output_tokens": 0,
            }


def percentile(values, fraction):
    if not values:
        return 0.0
    ordered = sorted(values)
    index = min(len(ordered) - 1, int((len(ordered) - 1) * fraction))
    return ordered[index]


async def run(args):
    url = os.environ.get("LLM_URL", "http://127.0.0.1:8000/v1/chat/completions")
    model = os.environ.get("LLM_MODEL", "your-model")
    api_key = os.environ.get("LLM_API_KEY", "local-key")

    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    payload = {
        "model": model,
        "messages": [{"role": "user", "content": "Explain goodput in three sentences."}],
        "temperature": 0,
        "max_tokens": 128,
        "stream": False,
    }

    semaphore = asyncio.Semaphore(args.concurrency)
    timeout = aiohttp.ClientTimeout(total=args.timeout_seconds)

    started = time.perf_counter()
    async with aiohttp.ClientSession(timeout=timeout) as session:
        tasks = [
            send_request(session, semaphore, url, headers, payload)
            for _ in range(args.requests)
        ]
        results = await asyncio.gather(*tasks)
    duration = time.perf_counter() - started

    completed = len(results)
    successful = [result for result in results if result["ok"]]
    good = [
        result
        for result in successful
        if result["latency"] <= args.slo_seconds
    ]
    latencies = [result["latency"] for result in successful]

    print(f"duration_seconds: {duration:.2f}")
    print(f"throughput_req_s: {completed / duration:.2f}")
    print(f"successful_req_s: {len(successful) / duration:.2f}")
    print(f"goodput_req_s: {len(good) / duration:.2f}")
    print(f"slo_attainment: {len(good) / completed:.2%}")
    print(f"output_token_s: {sum(r['output_tokens'] for r in successful) / duration:.2f}")
    print(f"latency_mean_s: {statistics.mean(latencies) if latencies else 0:.2f}")
    print(f"latency_p50_s: {percentile(latencies, 0.50):.2f}")
    print(f"latency_p95_s: {percentile(latencies, 0.95):.2f}")
    print(f"latency_p99_s: {percentile(latencies, 0.99):.2f}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--requests", type=int, default=200)
    parser.add_argument("--concurrency", type=int, default=16)
    parser.add_argument("--slo-seconds", type=float, default=5.0)
    parser.add_argument("--timeout-seconds", type=float, default=30.0)
    asyncio.run(run(parser.parse_args()))

这个脚本同时输出三层结果:总吞吐量、成功请求吞吐量,以及满足延迟 SLO 的有效吞吐量。如果三者差距明显,就应该检查错误率、排队时间和尾延迟,而不是继续追求更高并发。

示例只提交同一种提示词,适合验证指标计算,不代表真实流量。正式评测时,可以这样改造:从采样文件读取不同长度的提示词;按生产比例设置 max_tokens;加入预热阶段;持续运行足够长的时间;为流式 SSE 响应记录首个数据事件到达的时间,从而计算 TTFT。

用负载曲线寻找容量边界

单个并发值无法说明系统容量。更有效的做法是逐级提高并发,并在每一级记录 throughput、goodput、P50、P95、P99、错误率和 SLO 达标率。例如:

for concurrency in 1 2 4 8 16 32 64; do
  echo "concurrency=${concurrency}"
  python benchmark_goodput.py \
    --requests 500 \
    --concurrency "${concurrency}" \
    --slo-seconds 5
done

典型结果是:吞吐量在一段时间内继续上升,但 goodput 会先达到峰值,然后因排队和超时下降。这个拐点比“GPU 跑满时的最大吞吐量”更适合作为容量规划依据。

生产配置通常不应该压在峰值点上。流量会波动,提示词长度也不是常数,还可能发生实例重启、缓存未命中和共享资源争用。应在 goodput 峰值之前保留余量,并通过队列上限、请求超时、准入控制和自动扩缩容阻止延迟无限扩散。

采用 goodput 时的检查清单

  • 先定义 SLO,再运行基准;不要测试结束后挑一个有利阈值。
  • 同时报告 throughput 和 goodput,避免隐藏硬件利用率或用户体验问题。
  • 明确统计口径:请求数、token 数、TTFT、TPOT 和端到端延迟不能互相替代。
  • 使用接近生产的输入长度、输出长度、到达模式和并发分布。
  • 单独记录失败、超时、限流和取消请求。
  • 观察尾延迟;平均延迟无法反映排队拥塞。
  • 用多档负载寻找 goodput 峰值,并为生产波动保留容量余量。

吞吐量回答的是“系统完成了多少工作”,而 goodput 回答的是“其中有多少工作按用户要求完成”。对于 LLM 服务,后一个问题通常更接近真实容量,也更适合指导批处理参数、并发限制、扩容阈值和成本优化。


相关推荐