4. 连续批处理机制

核心原理:从“等满发车”到“随到随走”

传统深度学习推理服务(如静态批处理)的工作方式类似于“班车”:请求到达后先进入队列,必须等待攒够一批(例如 32 个请求)或达到固定时间窗口,才统一交给 GPU 执行。这种方式在请求到达率不均匀时会造成严重的 GPU 空转或请求排队延迟。vLLM 引入的 连续批处理(Continuous Batching) 则彻底改变了这一逻辑——它把“批”从物理上的同步边界,变成了逻辑上的动态调度单位。

连续批处理的核心思想是:GPU 上始终运行着多个序列,但每个序列的生命周期独立推进。当一个序列完成生成(遇到 EOS 或达到 max_tokens)时,它占用的 KV Cache 空间立即被释放,调度器立刻从等待队列中拉入一个新的请求,填充到刚空出的位置。这样,GPU 的计算资源被持续利用,不再需要等待整个批次全部结束。

从底层看,vLLM 的 PagedAttention 将 KV Cache 划分为固定大小的物理块(block),每个序列只需持有自己实际用到的块。调度器以“块”为粒度管理显存,使得不同序列可以交错占用物理块,从而支持任意数量的并发序列。连续批处理正是建立在这一显存管理机制之上的高层调度策略。

关键认知:连续批处理并不是“更频繁地创建小批次”,而是“批次内的序列动态进出”。GPU 上始终有一个正在执行的 级批次,但该批次的成员列表在每个 decode 步骤后都可能发生变化。

方法流程:连续批处理的工作循环

vLLM 的调度器在每个时间步(step)执行以下逻辑,形成连续批处理的闭环:

  1. 收集就绪序列:从等待队列中取出所有满足条件的请求(如 prefill 优先级高、显存足够分配初始块)。
  2. 执行 prefill:为新进入的序列计算完整的 prompt 表示,生成首个 ,并分配对应的 KV Cache 物理块。
  3. 执行 decode:对当前所有活跃序列(包括刚完成 prefill 的和之前就在解码的)并行执行一次前向传播,每个序列生成下一个 。
  4. 处理完成序列:检查每个序列是否满足停止条件(EOS、max_tokens、stop 字符串)。若完成,则释放其全部 KV Cache 块,将其从活跃列表移除。
  5. 返回步骤 1:调度器再次检查等待队列,将新请求补入空出的显存位置。

这一循环在 GPU 上以极高的频率重复(通常每秒钟数十次),使得“批”的构成不断变化。vLLM 还引入了 抢占(preemption) 机制:当显存不足时,调度器会暂停某些序列(swap 到 CPU 或丢弃其 KV Cache),优先保证新请求的 prefill 不被阻塞,从而避免死锁。

# 伪代码示意:连续批处理调度循环
while True:
    # 1. 补充新请求(prefill 阶段)
    while waiting_queue and gpu_memory_available():
        seq = waiting_queue.pop()
        prefill(seq)          # 计算 prompt,生成首个 
        active_seqs.append(seq)
    
    # 2. 并行 decode 所有活跃序列
    if active_seqs:
        outputs = decode(active_seqs)   # 一次前向传播,所有序列各得一个 
    
    # 3. 清理已完成序列,释放 KV Cache
    for seq in active_seqs:
        if seq.is_finished():
            free_kv_cache(seq)
            active_seqs.remove(seq)
    
    # 4. 若队列空且无活跃序列,则等待新请求
    if not waiting_queue and not active_seqs:
        wait_for_request()

案例与场景:为什么连续批处理能带来数量级提升

假设某在线服务需要处理 1000 个请求,每个请求的输入长度(prompt)为 200 tokens,输出长度(completion)为 150 tokens。GPU 单次最大批处理容量为 32 个序列。

  • 静态批处理:必须凑满 32 个请求才执行。由于每个请求的生成时间不同(有的 50 tokens 就结束,有的需要 200 tokens),批次中最早完成的请求必须等待最慢的请求结束后,整个批次才能释放。实际 GPU 利用率可能只有 60%~70%,且队列尾部的请求等待时间极长。
  • 连续批处理:第一批 32 个请求进入后,每当其中某个请求提前完成,立即释放其位置并拉入第 33 个请求。GPU 始终处理 32 个并发序列,直到所有 1000 个请求全部完成。总吞吐量可提升 2~3 倍,且尾部延迟显著降低。

以下表格对比了两种模式在典型场景下的行为差异:

对比维度 传统静态批处理 vLLM 连续批处理
批次构成 固定集合,同进同退 动态集合,序列独立进出
GPU 空闲时间 批次切换间隙明显 几乎为零,持续有 计算
短请求影响 被长请求拖累,等待批次结束 完成后立即释放资源,不阻塞他人
显存管理粒度 整批预留,浪费严重 按需分配 KV Cache 块,利用率高
调度频率 每批一次(秒级) 每个 decode 步骤(毫秒级)
适用场景 请求长度均匀、到达规律 长度差异大、到达随机、延迟敏感
静态批处理 vs 连续批处理 调度对比 静态批处理(固定批次) 批次 1(固定 4 个请求) Req A Req B Req C Req D 等待队列(必须等批次1全部完成) Req E Req F Req G Req H 时间 → 计算中 等待慢请求 计算 空闲切换 ⚠ 问题:Req A 已完成,但必须等待 Req D 结束 GPU 利用率低,尾部延迟高 批次 2(等待批次1释放后才开始) Req E Req F Req G Req H 连续批处理(动态调度) t=0:初始 4 个请求同时开始 Req A Req B Req C Req D t=1:Req A 完成,Req E 立即补入 A 完成✓ Req B Req C Req D Req E 新 t=2:Req B、D 完成,Req F、G 补入 B 完成✓ Req C D 完成✓ Req E Req F 新 Req G 新 GPU 利用率(持续满载) 持续计算,无空闲等待 ✓ 每个请求独立完成,立即释放资源 ✓ 新请求随时补入,GPU 永不空闲 ✓ 吞吐量提升 2~3 倍,延迟更低 对比

误区与复盘:连续批处理的常见误解

误区一:连续批处理 = 动态 batch size
有人误以为连续批处理只是“每次 decode 时动态调整 batch 大小”。实际上,动态 batch size 只是表象,其本质是 序列级调度——每个序列的 prefill 和 decode 阶段可以交错进行,而不是所有序列都同步处于同一阶段。vLLM 允许新请求的 prefill 与旧请求的 decode 在同一个 step 中执行(通过 chunked prefill 进一步细化),这是静态批处理无法做到的。
误区二:连续批处理会降低单请求性能
恰恰相反,由于 GPU 利用率提升,单个请求的平均排队时间大幅缩短。虽然并发度增加可能导致单次 decode 的延迟略有上升(batch 变大),但整体端到端延迟(排队 + 计算)通常显著下降。只有在极端过载场景下,才需要配合 max_num_seqs 和 max_num_batched_tokens 参数限制并发度,以保障延迟 SLO。
复盘要点:在生产环境中部署 vLLM 时,应关注 --max-num-seqs(最大并发序列数)和 --max-num-batched-tokens(单次前向传播的最大 数)两个参数。前者控制显存占用上限,后者控制计算粒度。若请求长度差异极大,建议开启 --enable-chunked-prefill,将长 prompt 拆分为多个 chunk 处理,避免单个 prefill 阻塞整个调度循环。

理解连续批处理的关键,在于跳出“批次”的思维定式,转而从 序列生命周期 的角度观察 GPU 上发生的一切。每个请求都是一个独立的、有始有终的任务,调度器的工作不是“组队”,而是“管理流水线”——让不同阶段的请求在 GPU 上无缝衔接,最大化每一毫秒的计算价值。这也是 vLLM 能在同等硬件条件下,将推理吞吐提升数倍的核心原因。