推理服务里最常被问的两个数,是"能扛多少"和"多快返回"。这两个数在物理上是对立的:想扛得多,就得把更多请求塞进同一批一起算;塞得越多,单个请求等得越久。
入门阶段最容易犯的错,是把这两件事当成两个独立旋钮——调大并发提吞吐、调小批降延迟,看起来很合理。但真实系统里它们通过同一份显存、同一条带宽、同一个调度器连在一起,动一个必然带另一个。这篇把这条耦合关系拆开,并给出找拐点的方法。
1. 批处理的收益来源与代价
收益来自硬件特性:decode 每一步都要把权重读一遍,只产出 1 个 token。如果只服务 1 条请求,读一次权重换 1 个 token;如果同时服务 32 条请求,同一次权重读取换 32 个 token。读的量不变,产出翻了 32 倍,这就是批处理的全部收益来源。
代价来自"批"的定义:一批要等齐、要一起算完、要一起出结果。
| 项 | 收益 / 代价 | 具体表现 |
|---|---|---|
| 权重读取 | 收益 | 摊薄到多条请求,吞吐近似线性上升(未饱和前) |
| 算力利用率 | 收益 | 从"内存等待"变成"算力忙碌" |
| 完成时间 | 代价 | 同批内最慢的请求决定整批完成时间 |
| 显存 | 代价 | 每条请求都要占 KV,批越大占越多 |
| 准入 | 代价 | 批满时新请求只能排队 |
批处理的净收益 ≈ (批内 token 产出总量) / (该批的墙钟时间)
≠ 单请求速度 × 批大小 # 忽略了等待与尾部延迟
2. 静态批与连续批处理
| 维度 | 静态批(static batching) | 连续批(continuous batching) |
|---|---|---|
| 组批时机 | 凑齐一批才开始,跑完才换批 | 每一步都重新组批,完成的立即退出 |
| 等待行为 | 后到的请求等整批跑完 | 有空位就补进来 |
| 木桶效应 | 严重:等最长的那条 | 大幅缓解:短请求先走 |
| 实现复杂度 | 低 | 需要引擎支持 |
| 适用 | 离线批处理、长度整齐的任务 | 在线服务,长度分布不可控 |
静态批的问题可以用一条时间线看清:
静态批(两条短请求陪一条长请求跑完全程):
req A(短) ────┐
req B(短) ────┤ 全部等到最长的那条结束才返回
req C(长) ────────────────┘
连续批(每条完成即退出,空位立刻补新请求):
req A(短) ────┘
req B(短) ────┐
req C(长) ────────────────┘
req D(新) └── 补进 A 让出的位置
所以连续批处理解决的不是"算得更快",而是不让短请求陪跑。它对吞吐的提升来自减少空转(padding 与空位),对延迟的提升来自消除无谓等待。
3. batch size 与两个延迟指标的拉扯
| 批大小 | 吞吐 | TTFT | TPOT | 显存占用 |
|---|---|---|---|---|
| 很小 | 低(读一次权重换 token 少) | 好 | 好 | 低 |
| 适中 | 接近线性上升 | 可接受 | 可接受 | 中 |
| 很大 | 趋于平缓(进入算力/带宽饱和) | 变差(排队 + prefill 更重) | 明显变差(每步要算更多) | 高,逼近 OOM |
机制要说清:批变大时,单步 decode 要处理的隐藏状态变多,单步耗时上升,于是 TPOT 变差;同时批满导致新请求排队,于是 TTFT 变差。吞吐的上升却有天花板,因为带宽和算力总有一个先饱和。
单步 decode 时间 ≈ 权重读取时间 + 批内注意力与矩阵乘时间 + 固定开销
# 批很小时第一项主导,批很大时后两项主导
这就是"批大小有最优值"的物理原因:小批时瓶颈是权重读取(加批几乎免费);大批时瓶颈变成计算与显存(加批开始收费)。最优值就在两条曲线的交点附近,不是越大越好。
4. 并发数与吞吐不是线性关系
| 并发(同时处理的请求) | 实际吞吐 | 单请求延迟 | 说明 |
|---|---|---|---|
| 低于批上限 | 随并发近似线性上升 | 基本不变 | 还有空位,加进来几乎免费 |
| 接近饱和 | 上升明显变慢 | 开始变差 | 带宽/算力开始抢 |
| 超过饱和 | 几乎不涨 | 快速恶化 | 排队变长,吞吐不再增加 |
| 远超饱和 | 可能下降(调度与换出开销) | 不可用 | 无谓的上下文切换与重算 |
饱和之后的加并发不叫"提升吞吐",叫"加深队列"。区分方法:看吞吐曲线是否还在上升——平了就说明加的东西只是排队。
一个常用的换算关系(Little 定律的直觉版):
平均在系统内的请求数 ≈ 到达率 × 平均停留时间
# 想同时要"高到达率"和"短停留时间",只能提高服务能力,没有第三条路
推论很直接:如果服务率固定,到达率一超过它,队列长度就随时间线性增长,然后超时、然后失败。排队论里这叫不稳定状态,工程上叫"雪崩前的最后十分钟"。
5. 三个可调参数与它们的相互约束
| 参数 | 管什么 | 调大之后 | 与谁冲突 |
|---|---|---|---|
最大批大小(max_batch_size) |
一步最多算多少条请求 | 吞吐升、TPOT 差 | 显存(KV 与激活) |
最大批 token 数(max_num_batched_tokens) |
一步最多算多少 token | 吞吐升、prefill 更重 | 显存(激活峰值)、TTFT |
最大并发序列数(max_num_seqs) |
同时在系统里的请求上限 | 吞吐升到饱和、排队变长 | KV 显存、延迟目标 |
# vLLM 侧三个上限(名字与默认值按引擎版本核对,不同版本有差异)
llm = LLM(
model="MODEL_PATH",
max_model_len=MAX_LEN, # 决定单请求 KV 上限
max_num_seqs=MAX_SEQS, # 同时处理的序列数
max_num_batched_tokens=MAX_TOKENS, # 单步 token 预算,含 prefill
gpu_memory_utilization=0.90, # KV 可用的显存比例
)
三者的关系是互相封顶:批大小受 token 预算限,token 预算受激活显存限,并发数受 KV 显存限。调其中一个而不动另两个,往往只是把瓶颈挪到另一处。常见组合错误是:把 max_num_seqs 调得很高,但 max_num_batched_tokens 很小,结果请求全在排队,吞吐一点没涨。
6. 延迟目标的两种表述
| 口径 | 含义 | 用途 | 陷阱 |
|---|---|---|---|
| P50 | 一半请求快于这个值 | 看"典型体验" | 会掩盖长尾,通常显著乐观 |
| P95 / P99 | 95%/99% 的请求快于这个值 | 容量规划、SLO | 采样不足时不稳定 |
P99 才决定体验,原因是用户不会平均地感受延迟:一个用户只要遇到一次 30 秒的等待,就会认为服务坏了;而 P50 好看完全不影响这次遭遇。所以延迟 SLO 应该写成"P99 TTFT < X 毫秒",而不是"平均 TTFT < X/2 毫秒"。
同时要注意长尾的来源不止排队:超长 prompt、KV 换出重算、GC 式的显存整理、上游限流重试,都会制造尖峰。只看均值这些全都看不见。
7. 怎么找自己服务的拐点
方法固定四步,不要靠猜:
- 固定负载模型:输入长度分布、输出长度分布、到达方式(闭合/开放)先定死。
- 阶梯加压:并发从低到高按固定步长往上推(例如 1→2→4→8→16→32),每级跑够时间,级间留恢复间隔。
- 每级记录三个数:吞吐、P50 延迟、P99 延迟。
- 找交叉点:吞吐曲线开始变平、同时 P99 开始陡升的那一级,就是拐点。拐点之前的并发是安全区。
# 示意:只看形状不看具体数值,具体数字必须自己压出来
并发 吞吐 P99 延迟
1 低 稳定
4 明显上升 稳定
8 上升变慢 开始抬头 ← 接近拐点
16 基本不涨 陡升 ← 已过拐点,加的是队列
32 可能下降 不可用
一个必须提醒的坑:闭合压测会低估排队。闭合模型里客户端只有固定线程,服务慢下来客户端也慢,压力自动变小;开放模型(按固定速率打)才暴露真实的排队增长。生产容量按开放模型的结果规划更安全。
8. 四个常见误解
| 误解 | 实际 |
|---|---|
| 加并发就加吞吐 | 饱和点之后只增加排队与延迟,吞吐不变 |
| 只报平均延迟 | 均值掩盖长尾;体验由 P99 决定 |
| 用一个 batch size 应付所有场景 | 交互式与批处理的最优值不同,甚至同一服务内按流量分时段也不同 |
| 忽略 prefill 与 decode 的混合调度 | 长 prompt 的 prefill 会挤占 decode 的步进节奏,造成 TPOT 抖动 |
9. 排错:五种吞吐/延迟异常
| 现象 | 原因 | 处理 |
|---|---|---|
| 吞吐低但 GPU 利用率不高 | 批太小、请求串行 | 开连续批处理,提高批上限 |
| 吞吐上不去,GPU 利用率很高 | 已饱和,算力或带宽打满 | 这是上限;要更好只能加卡或量化 |
| TPOT 抖动大,平均值还行 | prefill 插入抢占步进 | 限制单步 token 预算,或分离 prefill 与 decode |
| 并发加了一倍,吞吐只涨一点 | 已过拐点 | 回退到拐点前的配置 |
| 压测很好,线上很差 | 闭合压测 + 长度分布失真 | 用真实流量重放,改开放模型压测 |
10. 边界:这些结论什么时候不适用
| 场景 | 说明 |
|---|---|
| 离线批处理 | 没有人在等,可以牺牲全部延迟换吞吐,拐点概念不重要 |
| 单请求、低 QPS | 永远到不了饱和,批处理收益接近于零,别过度配置 |
| 输出极短(如分类) | 主要成本在 prefill,decode 批处理的结论要重新推 |
| 多副本部署 | 单副本拐点不等于全局上限,要按总吞吐与负载均衡一起算 |
11. 自测
- 批处理的收益到底来自什么硬件特性?为什么批很小时加批"几乎免费"?
- 连续批处理解决的是"算得更快"还是"不让短请求陪跑"?两者对指标的影响有何不同?
- 为什么批大小有最优值,而不是越大越好?两条曲线的交点代表什么?
- 吞吐曲线变平之后再提高并发,系统处于什么状态?用户会看到什么?
- 为什么延迟 SLO 要写 P99 而不是平均值?长尾通常由哪些原因造成?
- 阶梯加压找拐点,为什么闭合模型的结果会低估真实排队?