评测大语言模型服务时,最常见的数字是吞吐量:系统每秒处理多少请求,或者每秒生成多少 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_URL、LLM_MODEL 和 LLM_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 服务,后一个问题通常更接近真实容量,也更适合指导批处理参数、并发限制、扩容阈值和成本优化。