问题背景

LLM 推理是自回归的:对每一个输入 Prompt,模型一次生成一个 Token,直到输出终止符或达到最大长度。不同请求的输出长度差异巨大——有的需要 1000 字回答,有的只需一个 True/False 判断。

传统推理系统的静态批处理(Static Batching)在这里直接失效:等一批请求凑齐了,一起送进模型,等所有结果出来后,再攒下一批。一批中输出最长的请求成为短板——它不结束,整批都不能释放。这就形成了吞吐与延迟的经典对立:小 Batch 吞吐低、浪费 GPU;大 Batch 单请求延迟高、用户体验差。

根源在于:批处理的假设来自传统 DL——“一次推理 = 一次前向传播”。LLM 推理却是"一次推理 = N 次前向传播",N 的方差极大。解法不应该是两个极端的折衷,而是换一个调度粒度:为什么一定要等整批结束才能开始下一批?

核心思想的转变:从请求级调度到迭代级调度

Continuous Batching 的突破口在于把调度的粒度从"一次完整请求"变成了"一次迭代(Iteration)"。

LLM 推理的每一次迭代只生成一个 Token。在这个层面看,每次迭代的计算模式是相同的:处理当前序列,计算下一个 Token 的概率分布,采样出结果。所有进行中的请求,在每一次迭代里都在做同样形状的计算,只是序列长度不同。

于是,批处理的单位不应该是一整个请求,而应该是每一次迭代的前向计算

具体来说:一旦某个请求完成了它的全部输出,它的"座位"立即在下一次迭代就让给新排队的请求,而不是等整批结束。就像一个旋转餐厅——有人吃完离席,新客人马上入座,厨房不停。

这听起来简单,但实现起来需要解决几个关键问题:

  1. 请求何时"离开"批处理:任何一个请求输出 EOS Token 时,下一轮迭代就把它的 KV Cache 释放,新请求填充进来,不等待同批其他请求。
  2. KV Cache 内存管理:不同请求的序列长度不同,KV Cache 占用也不同。如果按最大长度预分配,内存浪费严重;如果动态分配,会产生碎片,需要高效的分配和回收策略。
  3. Prefill 和 Decode 的混合调度:新请求加入时需要进行 Prefill(对 Prompt 做一次编码),而已在进行中的请求只需要 Decode(自回归生成一个 Token),两者计算模式完全不同——Prefill 是 Compute-bound,Decode 是 Memory-bound——如果不加处理地混在一起,会互相拖累。

迭代级调度的工作流程

让我们从一个 LLM 推理系统的角度,详细过一遍 Continuous Batching 的完整周期。

Step 0:状态定义

系统维护一个"进行中请求的集合"(Running Set),每个请求至少包含:

  • 当前的完整 Token 序列(Prompt + 已生成的 Token)
  • 当前这一步迭代已经就绪,等待被喂给模型

每轮迭代就是一个循环:

Step 1:Pre-fill 新请求

如果当前有空闲的 KV Cache 槽位,就从等待队列拉入新请求,在正式进入 Decode 循环前,先做一次 Pre-fill——把整个 Prompt Token 一次性编码成 KV Cache。这一步计算量大,是 Compute-bound 的,因为需要处理多个 Token。

Step 2:组装 Batch

把 Running Set 中所有请求的当前序列(各自最后一个 Token,或者对于刚 Pre-fill 完的请求,从 Pre-fill 后状态接续)拼成一个 Batch,送入模型执行一次前向。

Step 3:采样

模型输出每个请求的下一个 Token 的 Logits,经过采样策略(Greedy、Top-p、Top-k 等)得到一个 Token。

Step 4:更新状态

  • 将新 Token 拼接到各自请求的序列后
  • 更新 KV Cache(新 Token 的 Key/Value 追加到已缓存序列)
  • 如果某个请求输出了 EOS 或达到 max_tokens,标记为完成,释放其 KV Cache 占用的内存

Step 5:驱逐已完成请求 & 接收新请求

在下一轮迭代开始前,已完成的请求离开 Running Set,释放出的 KV Cache 空间可用于接收新请求。新请求进入,又回到 Step 1。

这个循环不是"批次与批次之间"的间隙,而是连续、无缝的迭代流。GPU 始终在处理一个满的 Batch(或接近满的 Batch),请求有来有走,吞吐和延迟在这个机制下不再是天然对立。

关键实现:三个核心挑战

挑战一:KV Cache 内存管理

KV Cache 管理是 Continuous Batching 实现中最棘手的问题。在大模型推理中,KV Cache 是主要的内存占用来源——以 Llama-2-7B 为例,在 FP16 精度下,每 Token 的 KV Cache 约为 0.5MB(具体取决于层数、头数、头维度)。一个 2048 Token 的请求就需要约 1GB 显存来存 KV Cache。

静态预分配的做法是:给每个批次预留 max_sequence_length × batch_size 的 KV Cache 空间。简单,但浪费惊人——大部分请求远不需要那么长的输出,预留的空间长期空闲。

动态分配的做法是:请求来的时候按需分配,结束后立即回收。但这里有碎片化问题——不同请求的 KV Cache 生命周期不同,分配和释放交替发生,很快就会产生外部碎片(总空闲够用,但没有一块连续的足够大)。

**PagedAttention(vLLM 的方案)**提供了一个优雅的解法:借鉴操作系统的虚拟内存分页机制,把 KV Cache 切分成固定大小的 Block(例如每个 Block 16 个 Token),请求的 KV Cache 由若干不连续的 Block 组成。Block 在物理上是离散的,在逻辑上是连续的(通过一个 Block Table 映射)。当请求的序列增长时,按需分配新的 Block;请求结束,释放所有 Block 回到空闲池。

这与操作系统的页式内存管理异曲同工:

  • 消除外部碎片:所有 Block 大小一致,任何空闲 Block 都能分配给任何请求
  • 按需增长:不需要预先估计序列长度,动态扩展
  • 高效回收:释放就是将所有 Block 归还空闲链表

当然,PagedAttention 不是免费的。它需要在 Attention 计算时额外查找 Block Table、处理跨 Block 边界的 Attention 拼接,存在一定开销。但相比它节省的显存和带来的灵活性,这个开销是合算的。

挑战二:Prefill 与 Decode 的异构调度

一个新请求加入批处理时,需要先做完 Prefill,才能参与后续的 Decode。Prefill 和 Decode 的计算特性完全不同:

Prefill Decode
输入 整个 Prompt(可能几千 Token) 1 个 Token
计算模式 Compute-bound(大量矩阵乘法) Memory-bound(受限于 KV Cache 读写带宽)
FLOPs O(n² * d) 其中 n 为 Prompt 长度 O(n * d²) 其中 n 为已生成序列长度
耗时特征 一次长耗时 每次短耗时

如果不加处理地将 Prefill 和 Decode 混合在同一轮迭代,会出现严重的气泡(Bubble)——Prefill 的计算量大、耗时长,导致同一 Batch 中的 Decode 请求被迫等待,延迟暴增。

主流方案是将 Prefill 和 Decode 分阶段调度

  • vLLM:将 Prefill 拆成多个 Chunk,分多次迭代完成,每次只处理固定数量 Token(Chunked Prefill),与 Decode 请求交错执行
  • Sarathi-Serve:独立维护 Prefill 和 Decode 两个队列,Prefill 阶段使用更大的 Batch Size(Compute-bound 场景下 GPU 利用率随 Batch 增长明显),Decode 阶段控制适中的 Batch Size(Memory-bound 场景下增大 Batch 收益递减)
  • SGLang:引入 RadixAttention,在 Prefill 阶段复用已有请求的 KV Cache 前缀(多个请求共享相同的 System Prompt 时),进一步减少计算和内存开销

挑战三:调度策略

有了迭代级调度机制后,"下一轮迭代该处理哪些请求"这个问题不再是 trival 的。常见的调度策略包括:

FCFS(先到先服务):最简单的策略,但可能让一个短请求排在一个超长请求后面等很久(Head-of-Line Blocking)。

Shortest-Remaining-Time-First(最短剩余时间优先):每轮优先把计算资源分配给离完成最近的请求,降低平均延迟。但需要能够预估"剩余 Token 数"——可以在 Prefill 阶段对 Prompt 做一次快速分类来判断。

优先级队列:为不同用户/请求设置优先级,确保关键请求不被饿死。

公平共享(Fair Share):确保每个请求都有机会得到计算资源,避免某些请求长期处于饥饿状态。

实践中,多数系统采用加权公平队列(Weighted Fair Queuing)结合 FCFS作为默认策略,在公平性和效率之间取得平衡。

性能分析:吞吐与延迟的重新谈判

Continuous Batching 对吞吐和延迟的影响需要从两个维度来理解。

吞吐提升:通过维持 GPU 始终接近满负荷,Continuous Batching 相比静态批处理可以将吞吐提升 2-10 倍,具体取决于请求长度分布。请求长度方差越大,增益越显著——因为静态批处理在长尾请求上浪费的计算比例更高。在 MLPerf Inference 基准测试中,采用 Continuous Batching 的系统在相同延迟约束下可以实现数倍于静态批处理的吞吐。

延迟改善:关键不是降低单个请求的绝对最低延迟(那是模型和硬件决定的),而是消除排队延迟的级联放大。在静态批处理中,排在长请求后面的短请求,需要等待整批结束才能进入下一轮。Continuous Batching 允许短请求"插队"——当一个请求完成、释放出 GPU 资源后,排队的请求可以立即被拉入,不需要等当前批次全部结束。这显著降低了 P50 和 P95 延迟。

以下是一个模拟场景(假设每个 Token 生成耗时 10ms):

场景 静态批处理 Continuous Batching
4 个请求:[10, 50, 100, 200] Token 最长请求 (200) 决定批次耗时:P50 延迟约 2000ms 短请求完成即释放,P50 延迟约 550ms
相同吞吐下的最大延迟 第 4 个请求的延迟 = 前 3 批等待 + 自身耗时 请求随时可以流入,排队时间大幅压缩

吞吐-延迟的 Pareto 前沿被整体推向了更优的位置:你可以在相同的延迟约束下获得更高吞吐,也可以在相同的吞吐下获得更低延迟。

工程实践:三大框架的实现对比

vLLM

vLLM 是 Continuous Batching 最知名的开源实现,核心创新是 PagedAttention 和 Centralized Scheduler。

vLLM 的调度器维护一个全局的 KV Cache Block 池。每个新请求进入时,调度器为其分配一组 Block 用于存储 KV Cache。每次迭代:

  1. 调度器从等待队列中拉入尽可能多的请求(受限于可用 Block 数量)
  2. 为每个新请求执行 Chunked Prefill(如果 Prompt 较长,分多次迭代完成)
  3. 组装 Batch 执行一次前向
  4. 采样,更新状态
  5. 已完成请求释放 Block,回到 Step 1

vLLM 的 Chunked Prefill 策略解决了 Prefill 长 Prompt 拖慢 Decode 的问题:每次只处理固定数量 Token(例如 512),将长 Prompt 的 Prefill 分摊到多次迭代中。

HuggingFace TGI(Text Generation Inference)

TGI 是 HuggingFace 推出的推理服务框架,同样支持 Continuous Batching。TGI 的调度策略与 vLLM 类似,但在实现上有几个不同:

  • 使用 Flash Attention 加速 Attention 计算
  • 提供 Watermark 水位线控制:当正在处理的请求数低于一个阈值时,自动从等待队列拉入新请求;高于另一个阈值时停止拉入
  • 通过 Rust 实现的高性能 HTTP 服务器降低请求路由开销

TGI 的 Continuous Batching 实现在 vLLM 之前就已存在,但当时尚未采用分页式 KV Cache 管理(后续版本加入了类似机制),因此在极端负载下的内存效率略逊于 vLLM。

NVIDIA TensorRT-LLM(In-Flight Batching)

TensorRT-LLM 将 Continuous Batching 称为 In-Flight Batching,并深度集成了 NVIDIA GPU 的硬件特性:

  • 利用 CUDA Graph 将 Decode 迭代的 CPU-GPU 交互开销压缩到最小
  • 提供 KV Cache 的 FP8/INT8 量化,将显存占用减半
  • 支持 Multi-Node Tensor Parallelism + Pipeline Parallelism 下的调度协同

TensorRT-LLM 的 In-Flight Batching 在 NVIDIA H100/H200 等新硬件上可以达到极高的 GPU 利用率(MFU 超过 70%)。

优势与局限性

优势

  • 吞吐与延迟的解耦:不再需要在二者之间二选一
  • 对请求长度分布鲁棒:无论长短混合还是均匀分布,都能高效调度
  • GPU 利用率高:减少了静态批处理中的空闲等待
  • 自然的公平性:短请求不被迫等待长请求

局限

  • 实现复杂度高:需要精细的 KV Cache 管理、Prefill/Decode 分离调度、内存碎片控制
  • Prefill 仍然是瓶颈:大 Prompt 的 Prefill 计算量大,与 Decode 混合调度需要额外工程手段(Chunked Prefill 等)
  • 调度策略需要对负载有理解:最优的水位线、Chunk Size 等参数对工作负载敏感,缺乏通用的自适应方案
  • 分布式场景复杂度倍增:跨 GPU/跨节点的 KV Cache 管理和批处理调度引入了新的通信和一致性问题

Disaggregated Prefill:下一个演进方向

Continuous Batching 解决的是单个推理实例上的调度效率问题。下一个挑战是 Prefill 和 Decode 的解耦部署(Disaggregated Prefill)。

思路是将 Prefill 和 Decode 部署到不同的 GPU 上:

  • Prefill 节点:专门处理新请求的 Prompt 编码,完成后将 KV Cache 通过网络传输给 Decode 节点
  • Decode 节点:专做自回归生成,接收 Prefill 节点的 KV Cache,管理多请求的迭代生成

这样做的好处是让 Prefill 和 Decode 各自独立扩缩容——当系统突发大量新请求时,横向扩展 Prefill 节点;当大量请求在生成长文本时,维持稳定的 Decode 节点。Prefill 和 Decode 的计算特性不同,各自部署可以更精细地匹配硬件(例如 Prefill 需要高 BF16/FP8 算力,Decode 更需要高显存带宽)。

这是从 请求级批处理迭代级批处理(Continuous Batching)→ 分离式批处理(Disaggregated)的演进路径。而 Continuous Batching 是这个演进中的关键一步——它首先确立了"迭代"而非"请求"作为调度基本单位的思想。

总结

Continuous Batching 本质上是对 LLM 推理计算模式的一次重新认识。它不是一种优化技巧,而是一种调度范式的转变:将请求从"一次不可分割的推理任务"重新定义为"一系列同构迭代的序列",并在迭代级别上动态组批。这个转变打开了一整套新的设计空间——PagedAttention 的内存管理、Chunked Prefill 的混合调度、RadixAttention 的前缀复用——都建立在这个范式之上。

在工程意义上,Continuous Batching 使得单一 GPU 上的 LLM 推理可以在合理的延迟下接近计算饱和,这是大模型推理服务从"Demo 可用"走向"生产可用"的关键基础。今天几乎所有主流的 LLM 推理框架(vLLM、TGI、TensorRT-LLM、SGLang、LMDeploy 等)都内置了某种形式的 Continuous Batching——不是因为它是新鲜事物,而是因为它已经证明了在没有它的情况下,LLM 推理的吞吐/延迟均衡是一个不可能三角。

理解 Continuous Batching,不仅是理解一个算法,更是理解 LLM 推理系统的设计哲学:调度粒度与计算模式的匹配,是高性能系统的第一性原理


延伸阅读

  • Yu, Gyeong-In, et al. “Orca: A distributed serving system for transformer-based generative models.” OSDI 2022.
  • Kwon, Woosuk, et al. “Efficient memory management for large language model serving with PagedAttention.” SOSP 2023.
  • Agrawal, Amey, et al. “Sarathi-Serve: A scheduler for efficient LLM inference.” arXiv 2024.
  • Zheng, Lianmin, et al. “SGLang: Efficient execution of structured language model programs.” NeurIPS 2024.
  • NVIDIA TensorRT-LLM In-Flight Batching Documentation.
Logo

脑启社区是一个专注类脑智能领域的开发者社区。欢迎加入社区,共建类脑智能生态。社区为开发者提供了丰富的开源类脑工具软件、类脑算法模型及数据集、类脑知识库、类脑技术培训课程以及类脑应用案例等资源。

更多推荐