在大语言模型(LLM)推理服务中,如何高效地将多个请求组织在一起进行批量推理,是决定系统吞吐量与延迟的核心问题。传统的静态批处理方式存在严重的资源浪费,而连续批处理(Continuous Batching)通过在每一次迭代(iteration)粒度上动态调度请求,从根本上解决了这一瓶颈。本节将从静态批处理的缺陷出发,逐步推导出连续批处理的设计思路,并深入分析其关键参数与代码实现。
静态批处理(Static Batching)是最朴素的批量推理策略:推理引擎收集若干请求,将它们组成一个固定的批次(batch),然后让这个批次中的所有请求从头到尾一起执行,直到批次内所有请求都生成完毕后,才将结果统一返回,并开始接收下一个批次。
时间轴 →
Batch 1: [Req A, Req B, Req C] 全部完成 → 返回结果
↓
Batch 2: [Req D, Req E] 全部完成 → 返回结果
LLM 的自回归生成有一个关键特点——不同请求的输出长度差异极大。一个简单的问答可能只需要生成 10 个 token,而一篇摘要可能需要 500 个 token。在静态批处理中,短请求生成完毕后,必须等待同批次中最长的请求结束,GPU 在这段等待时间内要么完全空转,要么在对已完成请求做无意义的 padding 计算。
假设 Batch 中有 4 个请求,生成长度分别为: Req A: 20 tokens ████░░░░░░░░░░░░░░░░░░░░░░ (step 1-20) Req B: 50 tokens ████████████░░░░░░░░░░░░░░ (step 1-50) Req C: 100 tokens ██████████████████████████ (step 1-100) Req D: 10 tokens ██░░░░░░░░░░░░░░░░░░░░░░░░ (step 1-10) ░ = GPU 空转 / padding 计算 整个 Batch 必须运行 100 步才能结束。 GPU 有效利用率 = (20+50+100+10) / (4×100) = 180/400 = 45%
考虑一个实际场景:
新请求 Req E 在 Batch 1 执行到第 5 步时到达
Req E 本身只需要生成 8 个 token
但它必须等到 Batch 1 全部完成(假设还需 95 步),才能被编入 Batch 2
加上自身的 8 步生成时间,Req E 的总延迟 = 95 + 8 = 103 步
静态批处理的两个根本缺陷:
资源浪费:短请求被迫等待长请求,GPU 空转或做 padding 计算,有效利用率低。
延迟膨胀:新请求无法插入正在执行的批次,必须等当前批次整体完成,排队延迟不可控。
这两个问题的根源在于同一个设计决策:批次的生命周期与批次内最长请求绑定。要打破这一限制,就需要将调度粒度从"批次级"下沉到"迭代级"。
连续批处理的核心思想来自 2022 年由 Orca 论文(Yu et al.)提出的迭代级调度(Iteration-Level Scheduling):不再以整个批次的生命周期为调度单位,而是在每一步解码迭代(即每生成一个 token)结束时重新审视批次的组成。
其核心规则极其简洁:
规则一:每一步迭代结束后,检查是否有请求已生成 EOS(结束标记)或达到最大长度。如果有,立即将其移出批次,释放其占用的资源。
规则二:每一步迭代结束后,检查等待队列中是否有新请求。如果有且资源允许,立即将其加入批次,参与下一步迭代。
时间轴 → step1 step2 step3 step4 step5 step6 step7 step8 ... Req A: ██ ██ ██ Done Req B: ██ ██ ██ ██ ██ Done Req C: ██ ██ ██ ██ ██ ██ ██ ██ ... Req D: ██ ██ Done ← step4 加入 Req E: ██ ██ ... ← step6 加入 批次大小动态变化:3 → 3 → 3 → 3 → 3 → 3 → 2 → 2 ...
与静态批处理相比,关键变化在于:
Req A 在 step3 完成后立刻释放 slot,不再空转
Req D 不必等待 Req C 完成,在 step4 就加入批次开始处理
GPU 在每个 step 都满载运行,没有 padding 浪费
"队头阻塞"是网络领域的经典概念——队列最前面的元素处理缓慢时,会阻塞后面所有元素的处理。静态批处理天然具有这种队头阻塞特性:一个超长请求会拖慢同批次内所有其他请求的返回。连续批处理通过迭代级调度,彻底消除了队头阻塞:
| 维度 | 静态批处理 | 连续批处理 |
|---|---|---|
| 短请求延迟 | 受同批次最长请求影响 | 生成完毕立刻返回 |
| 新请求等待 | 必须等当前批次结束 | 下一步迭代即可加入 |
| GPU 利用率 | 随长短差异增大而下降 | 始终接近满载 |
| 队头阻塞 | 严重 | 不存在 |
连续批处理还引入了一个重要的设计考量:当新请求加入批次时,它需要先经历 Prefill 阶段(处理整个输入 prompt),而批次中已有的请求正处于 Decode 阶段(每步只生成一个 token)。这两种操作的计算特性截然不同:
Prefill:计算密集(Compute-Bound),需要一次性处理大量 token,受算力瓶颈限制
Decode:访存密集(Memory-Bound),每步只处理一个 token,受显存带宽瓶颈限制
在连续批处理框架下,Prefill 和 Decode 可以在同一个 batch step 内混合执行。调度器需要在两者之间做权衡:如果让过多新请求同时进行 Prefill,单步延迟会被拉高,影响正在 Decode 的请求的 TPOT(Time Per Output Token);如果过度限制 Prefill,新请求的 TTFT(Time To First Token)会增大。
连续批处理的调度器有两个核心控制旋钮,它们从不同维度约束了批次的规模,且对系统性能的影响方向恰好相反。理解这两个参数的交互关系,是调优推理服务性能的关键。
max_num_seqs(在某些框架中也叫 max_batch_size)定义了同时存在于批次中的请求(序列)数量上限。
max_num_seqs = 4 时: Batch 最多同时包含 4 个序列 ┌──────────────────────┐ │ Seq 0 (decode) │ │ Seq 1 (decode) │ │ Seq 2 (prefill) │ │ Seq 3 (decode) │ └──────────────────────┘ 第 5 个请求必须在队列中等待
增大 max_num_seqs 的影响:
吞吐量提高:更多请求并行处理,GPU 的计算单元被更充分地利用
显存压力增大:每个序列都需要维护独立的 KV Cache,序列数越多,KV Cache 占用的显存越大
单步延迟略增:更大的 batch 意味着每步 matmul 的计算量更大
max_num_batched_tokens 定义了单步迭代中参与计算的 token 总数上限。这个数字是所有序列在当前步的 token 数之和:处于 Decode 阶段的序列贡献 1 个 token,处于 Prefill 阶段的序列贡献其 prompt 长度个 token。
max_num_batched_tokens = 2048 时: 假设当前批次有 3 个 decode 序列 (各贡献 1 token) token 预算剩余 = 2048 - 3 = 2045 → 可以再接入一个 prompt 长度 ≤ 2045 的新请求做 Prefill → 或接入多个短 prompt 的新请求
| 参数 | 增大时对吞吐的影响 | 增大时对延迟的影响 | 瓶颈维度 |
|---|---|---|---|
| max_num_seqs | ↑ 提高 | TPOT 略增 | 显存容量(KV Cache) |
| max_num_batched_tokens | ↑ 提高(Prefill) | TPOT 显著恶化 | 计算量 / 单步耗时 |
它们的"相反影响"体现在实际调优中的权衡:
场景一:长对话、短输出(如客服机器人) — Prompt 长、输出短,Prefill 是瓶颈。应适当增大 max_num_batched_tokens 以提高 Prefill 吞吐,max_num_seqs 可以适中,因为序列存活时间短。
场景二:短输入、长输出(如代码生成) — Prefill 很快,Decode 阶段长。应增大 max_num_seqs 以并行处理更多序列,max_num_batched_tokens 保持适中,避免拖慢 Decode 的 TPOT。
场景三:严格延迟要求(如实时交互) — 两个参数都不宜过大,需要通过压力测试找到 TPOT ≤ 目标值时的最大 batch 规模。
需要特别注意的是,这两个参数并非独立生效,而是存在隐式的联动关系:
当 max_num_seqs 很小时,即使 max_num_batched_tokens 设得很大,实际的 token 总量也不会太高(除非某个 prompt 极长)。反之,当 max_num_seqs 很大且都在 Decode 阶段时,每步 token 总量 ≈ max_num_seqs(每个序列贡献 1 token),此时 max_num_batched_tokens 几乎不起约束作用。