一个推理服务设了"攒批窗口 20ms":请求到达后等 20ms,把这段时间内的请求凑成一批一起算。上线后吞吐涨了 40%,而 P99 从 300ms 涨到 900ms——因为高峰期排队叠加:每批都等 20ms,而队列在增长,于是等待被放大成"批次 × 20ms"。
攒批(batching window)是用延迟换吞吐的经典手段,而它的收益与代价都不是线性的:而"拥塞时爆炸"这一点很少被写进配置说明:同一个 20ms 在低负载下是 20ms,在高负载下可能是 400ms,因为等待被"排队"放大了。
窗口过短没效果,过长会让延迟在拥塞时爆炸。
1. 三个参数的关系
| 参数 | 作用 |
|---|---|
| 窗口时长(window) | 最多等多久 |
| 最大批大小(max_batch) | 一批最多几个请求 |
| 并发批数(in-flight) | 同时能算几批 |
三者的组合决定行为:窗口决定"最小延迟",批大小决定"吞吐上限",并发批数决定"能否重叠"。
关键机制是"先到者与后到者互相牵制":一个请求最多等窗口时长,但如果它到的时候队列已满,它要等前面的批次算完。这个牵制有个"正反馈":队列越长,新请求等待越久,而等待越久又有更多请求进入队列。所以攒批系统在过载时的退化是"非线性"的,而不是缓慢变差。
2. 为什么"再等一点"可能完全没收益
| 情形 | 结果 |
|---|---|
| 请求本来就密集(>100/s) | 窗口内自然凑满,再加窗口无收益 |
| 请求稀疏(<10/s) | 窗口内只有 1–2 个,攒批无意义 |
| 长请求混入 | 短请求被强绑长请求 |
| 批次内长度差异大 | 按最长者对齐,浪费算力 |
第一项是最常见的误判:"加窗口 → 吞吐上升"其实是因为"批次更满",而当批次已满时,再加窗口只会增加延迟。
第四项还有"显存"层面的后果:一批的显存需求按"批次内 token 总和 + 最长序列的 KV"算,所以混入长请求会同时抬高"峰值显存"与"计算时间"。
第四项是"批次内长度不齐"的代价:一批里 9 个短请求 + 1 个超长请求,整批按最长者算,于是"9 个短请求等 1 个长的"。
3. 延迟的三段构成
| 段 | 来源 |
|---|---|
| 窗口等待 | 0 到 window |
| 排队等待 | 取决于队列长度与批数 |
| 计算 | 批次大小 × 每 token 时间 |
第二段是"被忽略的那一段":它不在配置里,而在负载里。第二段的估算可用一个粗公式:排队时长 ≈ 队列长度 × 单批耗时 / 批数。它解释了"为什么批数翻倍能减半排队"——但代价是显存与调度开销。
队列长度 = 到达率 × 服务时间,而当它超过"批数 × 批大小"时,等待就开始累积。
所以"窗口 20ms"在低负载下意味着"最多多等 20ms",在高负载下意味着"多等到能挤进下一批"——同一配置在两种负载下体验完全不同。
// 同一窗口在两种负载下的延迟
低负载(20 QPS,批数 4,批大小 32):
窗口 0–20ms + 排队 ≈ 0 + 计算 50ms ≈ 70ms
高负载(400 QPS,队列满):
窗口 0–20ms + 排队(队列 30 × 50ms / 4 批)≈ 375ms + 计算 ≈ 425ms
// 结论:延迟爆炸来自"排队",不是"窗口"
4. 三种缓解手段
| 手段 | 效果 |
|---|---|
| 窗口自适应(按队列长度调整) | 拥塞时缩短窗口 |
| 短请求快速通道 | 不受长请求牵制 |
| 队列长度上限 + 拒绝 | 把排队变成拒绝 |
第二项最有效但改造大:把"短请求"单独成池,按预估 token 数分流。代价是两池不能互相借用容量。
第三项的前提是"客户端会尊重 Retry-After"。若客户端是"立即重试"的,那拒绝会变成"重试风暴"——所以拒绝策略要与客户端的重试行为配套设计。
第三项是"诚实"的做法:当排队超过阈值,直接返回 429 + Retry-After,而不是让用户等 2 秒。它把"不确定的慢"变成"确定的快失败"。
// 自适应窗口(按队列长度分档)
batching:
window_ms: { low: 20, normal: 10, high: 4 }
queue_thresholds: { normal: 16, high: 64 }
max_batch_tokens: 16384
reject_when_queue_gt: 128 # 超过即拒绝,不做无限排队
// 关键:把"拥塞时的窗口"缩短,把"排队"变成"拒绝"
"攒批是否值得"可以先用一个算式粗判,再决定要不要上线:
// 收益比 = 批大小带来的吞吐提升 / 窗口带来的延迟增加
无攒批:T 次/秒,每次延迟 L
攒批后:T × k 次/秒(k = 平均批大小),每次延迟 L + w/2
// 判据:k 的提升是否值得 (w/2) 的延迟
// 例:k=4(吞吐 4 倍)、w=20ms(延迟 +10ms)→ 值得
// k=1.2、w=20ms → 不值得(只多 20% 吞吐,多 10ms 延迟)
5. 观测:三个必须能分开的数
| 指标 | 说明 |
|---|---|
| 窗口等待 | 应稳定在 window/2 左右 |
| 排队时长(P99) | 拥塞信号 |
| 批大小分布 | 判断"攒批是否有效" |
第三项最容易被忽略:如果批大小长期在 1–2,攒批完全没生效第一项(窗口等待)的期望值是"窗口的一半"(均匀到达时)。如果它显著高于一半,说明到达不均匀或系统在拖,而不是"配置问题"。
(说明请求太稀疏或窗口太短)。而这个事实只看"延迟"看不出来。
第二项是"延迟爆炸"的直接来源:它的 P99 应当被单独监控并告警,而不是混在"总延迟"里。
运维上还有一个"灰度"问题:窗口参数变更的影响是"延迟分布",而不是"错误率"。所以灰度时不能只看"有没有报错",要看"P99 与排队时长"——否则"改坏了"不会触发任何告警。
// 参数变更的验证三件套(灰度 10% 流量)
1 P99 延迟是否在预算内(如 +50ms 容忍)
2 排队时长 P99 是否上升(拥塞信号)
3 批大小均值是否上升(收益信号)
// 只看 1 → 可能"延迟没超但收益也没"; 只看 3 → 可能"收益高但延迟爆"
6. 排错:五种"攒批没效果或反效果"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 吞吐没涨、延迟涨了 | 批大小没变 | 检查窗口是否太短 |
| 高峰期延迟爆炸 | 排队叠加 | 自适应窗口 + 拒绝 |
| 短请求被拖长 | 混批 | 长短分池 |
| 批大小长期为 1 | 请求稀疏 | 关掉攒批 |
| 长尾特别差 | 长度不齐 | 按 token 分批 |
第四项还有个"时长"判据:若 95% 的请求都在窗口内独自成批,那这个窗口就是纯延迟税。所以判断应当基于"批大小分布",而不是"吞吐有没有涨"。
第四项是"该关掉"的信号:请求稀疏时攒批只会增加延迟,而"关掉"这个动作很少有人做——于是它成了一个"永久的 20ms 税"。
最后一个"实现"边界:攒批逻辑放在"请求入口"还是"推理引擎"里,行为不同。放在入口(应用侧)时,你可以控制"哪些请求能合批";放在引擎里(如 vLLM 的 continuous batching)时,引擎会持续动态合批,而"窗口"概念被替换成"调度周期"。
// 两种实现的差别
应用侧攒批:显式窗口 + 显式批,可控但需自己管队列
引擎侧合批:continuous batching,吞吐更高,但"延迟"由调度决定,不可直接按窗口调
// 结论:已在用 continuous batching 时,不要再叠一层窗口
7. 边界:什么情况不该攒批
| 场景 | 为什么 | 做法 |
|---|---|---|
| 交互式单请求(对话) | 用户等不起 | 窗口 0 或极小 |
| 请求稀疏(<10/s) | 攒不起来 | 关闭 |
| 强实时(<100ms SLO) | 窗口已占满预算 | 关闭 |
| 单请求超长(>4k token) | 批次装不下 | 关掉或单列 |
判断口径一句话:"每秒请求数 × 窗口时长"是否远大于 1。最后一条与"产品"有关的边界:攒批的延迟预算应当与"用户可感知的等待"对齐。对话类产品里 20ms 的额外等待无感,但 200ms 就明显;而批量任务里 2 秒都无所谓——所以同一个参数不该跨场景复用。
远大于(如 200 QPS × 20ms = 4),攒批有意义;接近 1(如 10 QPS × 10ms = 0.1),它只是延迟税。
8. 自测
- 三个参数分别决定什么?为什么"先到者与后到者互相牵制"是关键机制?
- 为什么"请求本来就密集时,再加窗口无收益"?第四项"长度不齐"浪费了什么?
- 延迟三段里哪一段"不在配置里,而在负载里"?同配置在两种负载下为什么体验不同?
- 三种缓解手段里,哪一种最有效但改造大?"把排队变成拒绝"为什么更诚实?
- 三个观测数里,哪一个能揭示"攒批完全没生效"?只看延迟为什么看不出来?
- 判断"该不该攒批"的那个问题是什么?