推理压测和 Web 压测不是一回事。QPS 相同,输入长度从 200 token 变成 8000 token,表现能差一个数量级;并发相同,输出长度翻倍,吞吐立刻腰斩。更要命的是很多人拿"平均值"汇报,而 SLA 从来只认 P99。
这一层要产出的不是一张漂亮的曲线,是三个数字。
1. 压测必须回答的三个问题
| 问题 | 产出 | 用途 |
|---|---|---|
| 单副本能扛多少并发 | 并发上限(在 SLA 内) | 决定副本数 |
| 拐点在哪 | 排队开始恶化的负载点 | 定告警阈值 |
| 扩容粒度是多少 | 每副本多少 QPS/多少 token/s | 定副本数与成本 |
只回答"能扛住 100 并发"是不够的——没有 SLA 约束的容量数字没有意义。定 SLA 至少要写三项:TTFT P95、端到端 P95、成功率。
2. 负载模型:闭合还是开放
| 模型 | 行为 | 问题 | 适用 |
|---|---|---|---|
| 固定并发(闭合) | N 个虚拟用户不停发请求 | 协调遗漏:服务变慢时压不上去,指标偏乐观 | 快速摸底 |
| 固定到达率(开放) | 每秒固定发 R 个请求 | 更接近真实流量 | 正式容量测试 |
| 真实分布回放 | 按线上输入/输出长度分布采样 | 构造复杂 | 定 SLA 前的最终验证 |
协调遗漏是压测最常见的坑:固定并发下,一旦响应变慢,客户端来不及发新请求,于是负载自动下降,P99 看着很漂亮。真实流量不会因为服务慢而变少,只会堆积。
// k6:固定到达率,避免协调遗漏
export const options = {
scenarios: {
steady: {
executor: 'constant-arrival-rate',
rate: 20, timeUnit: '1s', // 每秒 20 个新请求,与服务快慢无关
duration: '10m',
preAllocatedVUs: 200, maxVUs: 800,
},
},
};
preAllocatedVUs 不足时 k6 会丢迭代并告警,看到 "insufficient VUs" 就说明压测本身成了瓶颈。
3. 四个指标,不能只看一个
| 指标 | 定义 | 受什么影响 | 报告方式 |
|---|---|---|---|
| TTFT | 首 token 到达时间 | prefill 计算、排队 | P50/P95/P99 |
| TPOT | 后续每 token 时间 | decode 带宽、批大小 | P50/P95 |
| 吞吐 | 总 token/s(输入+输出分开记) | 批处理、并行度 | 单副本 token/s |
| 成功率 | 非 5xx、非超时、非截断 | 超时设置、队列 | 百分比 + 错误分类 |
四个指标互相拉扯:加大批处理能提吞吐,但 TTFT 变差;缩小批处理 TTFT 好,吞吐掉。任何只报其中一项的容量结论都不可用,必须成组报。
# 从 SSE 流里算 TTFT / TPOT(示意)
t0 = time.perf_counter(); first = None; n = 0
for chunk in parse_sse(resp):
if first is None: first = time.perf_counter() - t0
n += 1
tpot = (time.perf_counter() - t0 - first) / max(n - 1, 1)
print(f"TTFT={first*1000:.0f}ms TPOT={tpot*1000:.1f}ms tokens={n}")
4. 输入输出长度分布决定一切
prefill 是算力密集,decode 是显存带宽密集,两者瓶颈不同,所以长度分布必须进实验矩阵。
| 场景 | 输入 | 输出 | 瓶颈 |
|---|---|---|---|
| 对话 | 1k~4k | 200~800 | decode 为主 |
| 长文摘要 | 8k~32k | 300~1k | prefill 为主 |
| 代码生成 | 2k | 1k~3k | 长 decode,KV cache 压力大 |
| 分类/抽取 | 500 | 10~50 | 排队与调度开销占比高 |
至少跑三档:短-短、长-短、短-长。只测短请求会系统性高估容量,因为长输入的 prefill 会挤占 decode 的显存与算力。
5. 脚本要点:别让压测工具成为瓶颈
# 先用 vLLM 自带基准摸上限,再用自研脚本验 SLA
vllm bench serve --model /models/qwen2.5-7b-instruct \
--num-prompts 500 --request-rate 20 \
--input-len 1024 --output-len 256 \
--percentile-metrics ttft,tpot,e2el --metric-percentiles 50,95,99
自研脚本必须满足:异步并发(不是线程池串行)、复用连接、真实解析 SSE、客户端超时大于最坏排队时间、记录每个请求的四个指标原始值(不是只记平均)。
客户端超时设小了,会把"排队慢"误判成"失败",成功率一栏失真;设大了,压测结束后还要等很久才能收工。按目标 SLA 的 3 倍设超时是常见折中。
6. 拐点:阶梯加压 + 恢复间隔
一段式满载压测只能得到"扛不扛得住",得不到拐点。做法是阶梯:
for rate in 5 10 15 20 25 30; do
run_load --rate "$rate/s" --duration 5m --warmup 60s
sleep 120 # 让队列排空再进入下一档
echo "done rate=$rate"
done
| 现象 | 含义 | 处理 |
|---|---|---|
| 吞吐随负载线性上升 | 未饱和 | 继续加压 |
| 吞吐走平、TTFT 上升 | 到拐点 | 这是容量上限 |
| 吞吐下降、队列爆 | 已过载 | 该负载点即告警阈值 |
| 加档后先慢再恢复 | 冷启动/缓存效应 | 加 --warmup 重测 |
拐点取吞吐走平而 TTFT 开始陡升的那一档。告警阈值设在这一档的 70% 左右,留出反应时间。
7. 从指标到副本数
单副本并发上限 C = 实测在 SLA 内的最大并发
目标 QPS = Q
单请求平均占用时间 T(秒,含排队外的处理时间)
需要副本数 = ceil(Q × T / C × 冗余系数)
冗余系数:常驻负载 1.3~1.5;有明显峰值 1.5~2.0
但还有第二个约束必须核对:KV cache 显存。并发 = 同时在跑的序列数,长上下文时显存先于算力成为瓶颈:
可用 KV 显存 = 总显存 × gpu_memory_utilization - 权重 - 激活开销
可容纳序列数 ≈ 可用 KV 显存 / (2 × layers × kv_heads × head_dim × dtype × seq_len)
两个约束取更小的那个。很多容量事故不是算力不够,是 max_num_seqs 设得比显存能撑的还大,于是频繁 preemption、重算,延迟曲线出现诡异的台阶。
8. 冷启动与预热
| 阶段 | 耗时量级 | 处理 |
|---|---|---|
| 加载权重 | 秒级到分钟级 | startupProbe 放宽容忍 |
| CUDA graph / 编译 | 几十秒 | 启动时预热,别等第一个请求 |
| 缓存冷 | 前几十个请求明显慢 | postStart 发若干预热请求 |
| 自动扩容 | 分钟级 | 提前扩容,别等崩了再扩 |
startupProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
failureThreshold: 60 # 约 10 分钟,覆盖权重加载
lifecycle:
postStart:
exec:
command: ["/bin/sh","-c","for i in 1 2 3; do curl -s -X POST localhost:8000/v1/chat/completions -d @/etc/warmup.json >/dev/null; done"]
压测必须跳过预热窗口,否则前 60 秒的冷数据会把 P99 拉高,得出"容量不够"的错误结论。
9. 长稳测试:泄漏与碎片
短时间压测看不出两类问题:显存泄漏和 KV cache 碎片。
| 现象 | 原因 | 观察方式 |
|---|---|---|
| 显存缓慢上涨不回落 | 请求对象未释放、缓存无上限 | 24h 压测,看 memory.used 斜率 |
| 同样的负载越来越慢 | KV 块碎片化,可用块变小 | 看 gpu_cache_usage 与 preemption 次数 |
| 只有长上下文才复现 | 块管理在长序列下退化 | 混合长短请求一起压 |
长稳至少跑 6~12 小时,混合长短请求,观察斜率而不是绝对值。没有长稳数据的容量结论只能算半成品。
10. 常见坑
| 坑 | 现象 | 改法 |
|---|---|---|
| 固定并发压测 | P99 好得不像话 | 改固定到达率,避免协调遗漏 |
| 只报平均延迟 | 上线后大面积超时 | 报 P95/P99 |
| 只测短请求 | 容量高估一个量级 | 三档长度组合 |
| 客户端超时过短 | 成功率虚低 | 超时 = 3 × SLA |
| 压测机成为瓶颈 | 加压后指标不动 | 看压测机 CPU/连接数 |
| 不跳过预热 | P99 被冷启动污染 | 压测前预热 60 秒 |
| 只跑 5 分钟 | 漏掉泄漏 | 至少 6 小时长稳 |
| 忽略 KV cache 约束 | 高并发下频繁 preemption | 按显存取 max_num_seqs 上限 |
11. 排错
| 现象 | 排查方向 |
|---|---|
| 加压到某档吞吐骤降 | 显存打满触发 preemption;查 gpu_cache_usage |
| 两组人压测结果差一倍 | 负载模型不同(闭合 vs 开放)或长度分布不同 |
| 服务端指标正常但客户端超时 | 客户端连接池/超时配置;查客户端侧耗时 |
| 长输入请求挤掉短请求 | 调度策略;考虑分池或分优先级 |
| 扩容后 P99 没改善 | 瓶颈在网关或排队层,不在推理副本 |
| 成功率 99% 但仍被投诉 | 截断(finish_reason=length)未计入失败 |
12. 边界
| 场景 | 更适合的做法 |
|---|---|
| 离线批处理 | 只测吞吐,不测 TTFT |
| 极低 QPS 内部工具 | 不做容量测试,靠监控兜底 |
| 流量稳定且远低于容量 | 定期复测即可,不必找拐点 |
| 模型/量化版本刚换 | 必须重测,历史容量数据作废 |
| 多租户共享同一副本 | 按租户加权压测,别用单一租户 |
13. 自测
- 固定并发压测为什么会系统性高估容量?
- TTFT 和 TPOT 分别由哪种计算阶段主导?
- 拐点用什么信号判定,而不是用"开始报错"判定?
- 容量公式算出的副本数为什么还要和 KV cache 约束取小?
- 压测客户端超时设得过短会污染哪个指标?
- 6 小时长稳能发现哪两类短测发现不了的问题?