技术日报 2026-04-24
今日主题:连续批处理(Continuous Batching)与 Iteration-Level Scheduling —— LLM 推理系统的调度革命
整理时间:2026-04-24 18:00
关键词:Continuous Batching · Orca · Iteration-Level Scheduling · Chunked Prefill · Disaggregated Prefill/Decode · vLLM · TensorRT-LLM · Sarathi-Serve
目录
- 技术背景与动机
- 核心概念:LLM 推理的两阶段特性
- 静态批处理的根本缺陷
- Orca:Iteration-Level Scheduling 的开山之作
- 连续批处理的工作机制
- vLLM:PagedAttention + 连续批处理的协同
- Chunked Prefill:解决 Prefill-Decode 失衡
- 主流框架的实现对比
- 前沿演进:Disaggregated Prefill/Decode
- Speculative Decoding 与连续批处理的张力与融合
- 性能数据全景
- 代码示例:理解调度器核心逻辑
- 工程实践指南
- 参考资料
一、技术背景与动机
为什么 LLM 推理服务如此特殊?
在 ChatGPT 出现之前,深度学习推理服务的模型基本以分类、检测、翻译等任务为主。这类任务有一个关键共性:给定输入,模型只需一次前向传播(single forward pass)就能输出结果,因此可以将多个请求打包成一个批次,充分利用 GPU 的并行性。
但大语言模型(LLM)的生成任务完全不同。生成一段文本的过程是自回归(autoregressive)的——模型每次只生成一个 Token,并把该 Token 作为下一次生成的输入,如此往复直到生成结束符(EOS)。这意味着:
- 生成 100 个 Token 需要运行 100 次前向传播
- 每次前向传播是单独的一个"迭代"(iteration)
- 不同请求的输出长度完全不可预知,可能从十几个 Token 到上千个 Token 不等
正是这种"多迭代、不定长"的特性,让原有的批处理策略在 LLM 场景下严重失效,催生了 Continuous Batching 这一推理系统的核心技术。
规模化服务的严苛要求
以 2024-2026 年的主流 LLM 服务为例,一个面向企业的对话系统每秒可能收到数百甚至上千个并发请求,每个请求的提示词(prompt)长度从几十到数万 Token 不等,生成的回复长度也差异悬殊。如何在有限的 GPU 资源下同时保证:
- 高吞吐量(throughput):单位时间处理尽可能多的 Token
- 低首 Token 延迟(TTFT, Time To First Token):用户感知到的响应速度
- 低 Token 间延迟(ITL / TPOT):生成过程的流畅度
- 公平性:短请求不应被长请求无限期阻塞
这几个目标之间存在天然的权衡,而 Continuous Batching 及其演进技术正是解决这一权衡的核心工程方案。
二、核心概念:LLM 推理的两阶段特性
理解 Continuous Batching 之前,必须先理解 LLM 推理的"双阶段"本质。
2.1 Prefill 阶段(预填充)
Prefill 阶段处理用户输入的完整 Prompt。在这个阶段,模型一次性读入所有输入 Token,通过并行计算构建出 KV Cache(Key-Value Cache),并输出第一个生成 Token。
输入: "请用中文介绍注意力机制的原理" (18个Token)↓ 并行处理所有18个Token↓ 构建 18层×2×heads×dim 的 KV Cache↓ 输出第一个Token: "注"
计算特征:矩阵-矩阵乘法(Matrix-Matrix Multiplication),属于计算密集型(compute-bound)操作。GPU 的成千上万个 CUDA 核心都处于忙碌状态,计算效率高。
关键指标:TTFT(首 Token 延迟)——从发送请求到收到第一个 Token 的时间。
2.2 Decode 阶段(解码)
Decode 阶段基于已有的 KV Cache,每次只处理一个新 Token,生成下一个 Token,直到生成 EOS。
KV Cache + 当前Token "注"↓ 查询 KV Cache(向量-矩阵乘法)↓ 输出下一Token: "意"
KV Cache + "意"↓ 继续...↓ 输出: "力"
... 循环直到 EOS
计算特征:向量-矩阵乘法(Vector-Matrix Multiplication),属于内存带宽密集型(memory-bound)操作。每次前向传播只处理 1 个 Token,但需要从显存读取全部模型权重,导致 GPU 计算单元大量空转(Arithmetic Intensity 极低)。
关键指标:ITL / TPOT(Token 间延迟 / 每输出 Token 时间)
2.3 两阶段特性对比总结
| 特性 | Prefill | Decode |
|---|---|---|
| 计算类型 | 矩阵×矩阵 (GEMM) | 向量×矩阵 (GEMV) |
| 资源瓶颈 | 计算受限 (Compute-bound) | 内存带宽受限 (Memory-bound) |
| GPU 利用率 | 高(FLOPS 利用率高) | 低(算术强度仅约 1-2) |
| 批处理效益 | 有限(单次计算已饱和) | 显著(batch 越大内存读写越摊薄) |
| 吞吐优化方向 | 并行化、减少重复计算 | 增大 batch size、减少内存访问 |
| 延迟指标 | TTFT | ITL / TPOT |
理解这个对比是理解 Continuous Batching 所有设计决策的基础。
三、静态批处理的根本缺陷
3.1 静态批处理的工作方式
在 Continuous Batching 出现之前,LLM 推理普遍采用静态批处理(Static Batching):将若干请求打包成一个批次,等到批次内所有请求都完成后,才释放资源、接入新请求。
时间轴 →
Req A: [Prefill][t1][t2][t3][t4][t5][t6][t7][t8][t9][t10][EOS] <- 生成10个Token
Req B: [Prefill][t1][t2][t3][t4][EOS] _ _ _ _ _ _ _ <- 生成4个Token,等7个步骤
Req C: [Prefill][t1][t2][EOS] _ _ _ _ _ _ _ _ _ _ _ <- 生成2个Token,等9个步骤
Req D: [Prefill][t1][t2][t3][t4][t5][t6][t7][EOS] _ _ <- 生成7个Token,等3个步骤所有请求完成后,才处理 Req E、F、G...↑ GPU 空转 = 浪费
3.2 三大核心问题
问题一:GPU 利用率低下
批次中最长的序列决定了整个批次的处理时间。短序列完成后,对应的 GPU 计算槽(slot)处于空等状态——GPU 明明还能处理新的请求,却无法接入。实测中,静态批处理的 GPU 利用率仅有 30-50%。
问题二:内存预分配浪费
为了支持批处理,系统需要预先为每个请求分配 max_seq_len(最大序列长度)的 KV Cache 空间。如果最大长度设为 2048 Token,但实际生成只有 50 Token,那么 97.5% 的预分配内存都是浪费。统计显示,传统方案的 KV Cache 内存利用率仅有 20-40%。
问题三:请求延迟公平性差
一个只需要生成 10 个 Token 的短请求,如果与一个需要生成 200 个 Token 的长请求在同一批次,则必须等待整个批次(约 200 个迭代)才能完成,实际服务延迟远超其应有水平。
3.3 为什么问题在 LLM 时代更突出?
传统 NLP 任务(如机器翻译)的输出长度相对可预测,方差小;而 LLM 的生成长度方差极大——对话回复可能几十个 Token,代码生成可能数千个 Token。这使得"批次中最长序列决定总时间"的问题被严重放大。
四、Orca:Iteration-Level Scheduling 的开山之作
4.1 论文基本信息
| 项目 | 内容 |
|---|---|
| 论文标题 | ORCA: A Distributed Serving System for Transformer-Based Generative Models |
| 发表会议 | OSDI '22(第16届 USENIX 操作系统设计与实现研讨会) |
| 作者机构 | 首尔国立大学 & FriendliAI |
| 发表年份 | 2022年 |
| 核心成果 | 相同延迟约束下,GPT-3 吞吐量对比 FasterTransformer 提升 36.9 倍 |
Orca 是 LLM 推理服务领域的开山之作,FriendliAI 基于这项研究成果构建了其商业推理引擎。
4.2 核心洞察:从请求级调度到迭代级调度
Orca 的核心贡献是将调度粒度从请求(request)层面细化到迭代(iteration)层面:
传统调度(Request-Level):
调度周期 = 完成一个请求的全部迭代
新请求入队时机 = 当前批次内所有请求全部完成后
Orca 的迭代级调度(Iteration-Level):
调度周期 = 完成一次前向传播(生成一个Token)
新请求入队时机 = 每次迭代后,检查是否有序列完成,立即补入新请求
这个看似简单的粒度变化,带来了革命性的效果。
4.3 两大核心技术贡献
① 迭代级调度(Iteration-Level Scheduling)
调度器每次只让执行引擎运行模型的一次迭代,而非整个请求。迭代完成后立即决策:哪些请求退出、哪些新请求进入。这样,一旦某个请求生成了 EOS,其占用的 GPU 槽立刻可以被新的请求填充,GPU 的空转时间降至最低。
② 选择性批处理(Selective Batching)
直接对所有 Transformer 操作进行批处理并非易事:不同序列的 Attention 计算由于长度不同,无法直接在 Attention 层进行批处理(每个序列的 KV 矩阵形状不同)。
Orca 通过分析 Transformer 各层的特性,识别出哪些操作可以跨请求合并(如 FFN 层、LayerNorm),哪些需要分别处理(如不等长的 Attention),从而在支持迭代级调度的同时保证计算效率。
五、连续批处理的工作机制
5.1 动态批次管理
Continuous Batching 的核心是动态批次管理:每个迭代结束后,调度器:
- 检查当前 running 队列中是否有序列生成了 EOS
- 将已完成的序列移出批次,释放其占用的内存和计算槽
- 从 waiting 队列中选取下一个请求,加入 running 批次
- 如资源不足,触发抢占(Preemption)策略
迭代步: 1 2 3 4 5 6 7 8 9 10 11 12
Req A: █ █ █ █ █ █ █ █ █ █ █ ✓ ← Step 12 完成
Req B: █ █ █ █ █ ✓ ← Step 5 完成 → D 立即补入
Req C: █ █ ✓ ← Step 2 完成 → D 在 Step 3 入队
Req D: █ █ █ █ █ █ █ ✓ ← Step 3 加入,Step 10 完成
Req E: █ █ █ █ █ █ █ ← Step 5 加入✓ = 完成并移出 █ = 正在处理
与静态批处理相比,GPU 几乎没有空转——完成一个请求立刻补入一个新请求,流水线持续饱和。
5.2 关键调度参数
max_batch_size:同时处理的最大请求数量max_num_batched_tokens:每次迭代处理的最大 Token 总数(Prefill + Decode)waiting_served_ratio:等待队列与运行队列的比率,用于控制 Prefill 请求的接入时机- Preemption 策略:
- Swap(换出):将 KV Cache 换出到 CPU 内存,腾出 GPU 空间,恢复时换回
- Recompute(重计算):直接丢弃 KV Cache,恢复时重新执行 Prefill
5.3 Prefill 与 Decode 的调度冲突
Continuous Batching 在实现中面临一个天然矛盾:
- Prefill 请求需要一次处理整个输入 Prompt(计算密集),会占用大量 Token 预算
- Decode 请求每次只处理 1 个 Token(内存密集),需要维持高 batch size 才有效
如果 Prefill 请求与 Decode 请求在同一批次中不加控制地混合:
- 一个 2000 Token 的 Prefill 请求会占满整个 Token 预算,导致 Decode 请求被阻塞
- 正在生成的序列的 Token 间延迟(ITL)出现毛刺(spike),用户感知到明显卡顿
这个问题导致了后续 Chunked Prefill 技术的诞生(第七章详述)。
六、vLLM:PagedAttention + 连续批处理的协同
6.1 vLLM 的核心贡献
vLLM(SOSP 2023,加州大学伯克利分校)将 Continuous Batching 与 PagedAttention 深度结合,解决了 Orca 中遗留的 KV Cache 内存管理问题。
PagedAttention 的核心思想:借鉴操作系统虚拟内存的分页机制,将 KV Cache 切分成固定大小的"页"(block,默认 16 Token/block),通过 Block Table 进行逻辑地址到物理地址的映射,实现非连续物理内存的连续逻辑访问。
逻辑视图(每个请求看到的连续空间):
Request 1: [Block 0: Token 0-15] [Block 1: Token 16-31] [Block 2: Token 32-47]
Request 2: [Block 0: Token 0-15] [Block 1: Token 16-31] [Block 2: Token 32-47]│ Block Table 映射 │物理视图(GPU 显存实际布局,可以不连续):
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ Blk0 │ Blk1 │ Blk2 │ Blk3 │ Blk4 │ Blk5 │ Blk6 │ FREE │
│ R2-0 │ R1-0 │ R2-1 │ R1-1 │ R2-2 │ R1-2 │ R2-3 │ │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
PagedAttention 解决的问题:
| 问题类型 | 说明 | 解决方式 |
|---|---|---|
| 预留浪费 | 传统方案预分配 max_seq_len | 按需分配,用多少分多少 |
| 内部碎片 | 已分配未使用的内存(占 60-80%) | 固定大小分页,碎片率 < 4% |
| 外部碎片 | 剩余空间不连续 | 非连续物理块拼接,无外部碎片 |
效果:KV Cache 内存利用率从 20-40% 提升至 >95%,同等显存条件下并发请求数提升 2-4 倍。
6.2 vLLM 调度器设计
vLLM 的调度器采用迭代级调度,有几个独特设计:
① 无混合批处理(No Mixed Batching)(默认配置)
# vLLM 默认行为(未开启 Chunked Prefill 时)
Prefill 请求 → 只与其他 Prefill 请求在同一批次
Decode 请求 → 只与其他 Decode 请求在同一批次
② Prefill 优先策略
当有新请求等待 Prefill 时:
- 暂停当前所有 Decode 请求
- 先处理 Prefill 批次(为新请求构建 KV Cache)
- Prefill 完成后,恢复 Decode
这确保了新请求能快速获得首 Token(低 TTFT),但代价是已在 Decode 的请求 ITL 出现抖动。
③ Preemption 机制
class Scheduler:def schedule(self) -> SchedulerOutput:running_seqs = self.running.copy()# 尝试从 waiting 队列加入新的 Prefill 请求while self.waiting:seq = self.waiting[0]if not self.block_manager.can_allocate(seq):break # 显存不足,停止加入running_seqs.append(self.waiting.pop(0))# 显存不足时,抢占优先级低的请求while not self.block_manager.can_append_slots(running_seqs):victim = running_seqs.pop() # 从末尾选受害者self._preempt(victim) # swap 到 CPU 或 recomputereturn SchedulerOutput(running=running_seqs, ...)
6.3 vLLM 版本演进
| 版本 | 关键变化 |
|---|---|
| v0.x | 基础 Continuous Batching + PagedAttention |
| v0.3+ | 引入 Prefix Caching(相同前缀 KV 块复用) |
| v0.4+ | 引入 Chunked Prefill(可选),支持混合批处理 |
| v1.x(2025) | Chunked Prefill 默认开启,V1 调度器,Preemption 默认改为 Recompute |
七、Chunked Prefill:解决 Prefill-Decode 失衡
7.1 问题根源
传统 Continuous Batching(以及早期 vLLM)的一个核心痛点是:长 Prefill 请求会严重阻塞 Decode 请求。
考虑这样一个场景:
- 系统正在 Decode 20 个请求,每步生成 1 Token,Token 间延迟(ITL)稳定在 20ms
- 突然来了一个含有 4096 Token 的长 Prompt 请求
- 调度器执行 Prefill:花费约 200ms 处理 4096 Token(计算密集)
- 这 200ms 期间,20 个 Decode 请求完全停滞
- 用户感知到明显的延迟毛刺:原本稳定的 20ms ITL 突然跳到 200ms+
7.2 Sarathi-Serve 的解决方案
Sarathi-Serve(OSDI 2024,微软研究院 + 佐治亚理工)提出了 Chunked Prefill 技术:
核心思想:将大的 Prefill 请求拆分成固定大小的"块"(chunk),每个迭代只处理一个 chunk,并与 Decode 请求混合批处理。
传统方式(Prefill 阻塞 Decode):
Step 1: [Prefill: 所有4096个Token] ← 占用整个迭代,Decode被阻塞200ms
Step 2: [Decode: T1][Decode: T2]... ← 4096-token Prefill 完成后才能继续
Step 3: [Decode: T1][Decode: T2]...Chunked Prefill(混合批处理,chunk_size=512):
Step 1: [Prefill_Chunk1: 510 Tokens] + [Decode: 2 Tokens] ← 每步 512 Token 预算
Step 2: [Prefill_Chunk2: 510 Tokens] + [Decode: 2 Tokens]
Step 3: [Prefill_Chunk3: 510 Tokens] + [Decode: 2 Tokens]
Step 4: [Prefill_Chunk4: 510 Tokens] + [Decode: 2 Tokens]
Step 5: [Prefill_Chunk5: 506 Tokens] + [Decode: 6 Tokens] ← 4096-token Prefill 完成
Step 6: [Decode: T1][Decode: T2]... ← 新请求进入 Decode 阶段
Decode 请求在每一步都能前进,ITL 毛刺被消除,用户体验平滑。
7.3 Stall-Free Scheduling(无停顿调度)
Sarathi-Serve 的另一个创新是无停顿调度:在不暂停正在进行的 Decode 操作的前提下,向批次中添加新的 Prefill chunk。
传统方案需要在处理新请求前暂停所有 Decode(即 "stall"),Sarathi-Serve 通过精细的 Token 预算管理,使 Prefill chunk 与 Decode 共享一次前向传播,避免了任何停顿。
7.4 Chunk Size 的工程细节
默认 Chunk Size:512 Token(基于硬件特性分析)
选择 Chunk Size 时需要注意 Tile Quantization 效应:GPU 矩阵乘法以 128 Token 为 tile 单位进行分块,若 Chunk Size 略超过 tile 整数倍,会导致延迟激增:
序列长度 256 Token → 处理时间 69.8 ms ✓ 正常(= 2 × 128)
序列长度 257 Token → 处理时间 92.3 ms ✗ 激增 32%(进入下一个 tile)
最佳实践:Chunk Size 应选择 128 的倍数,如 512、768、1024。
7.5 Sarathi-Serve 性能成果
| 模型 | 硬件 | 对比基线 | 服务容量提升 |
|---|---|---|---|
| Mistral-7B | 单卡 A100 | vLLM | 2.6 倍 |
| Yi-34B | 双卡 A100 | vLLM | 最高 3.7 倍 |
| Falcon-180B | 流水线并行 | vLLM | 最高 5.6 倍 |
八、主流框架的实现对比
8.1 四大框架横向对比
| 框架 | 调度策略 | Chunked Prefill | 混合批处理 | KV Cache 管理 | 开源情况 |
|---|---|---|---|---|---|
| vLLM | 迭代级,Prefill 优先 | v1 默认开启 | 开启 Chunked Prefill 后支持 | PagedAttention | 完全开源 |
| TensorRT-LLM | In-Flight Batching(迭代级) | 支持 | 原生支持 | 预分配 + 按需 | 部分开源 |
| HuggingFace TGI | Continuous Batching | 支持 | 支持 | 块式管理 | 开源 |
| DeepSpeed-FastGen | Dynamic SplitFuse | 原生核心 | 原生支持 | 非连续 KV Cache | 开源 |
8.2 TensorRT-LLM 的 In-Flight Batching
NVIDIA TensorRT-LLM 将连续批处理称为 In-Flight Batching,并提供两种 KV Cache 分配策略:
GUARANTEED_NO_EVICT(保证无抢占):
- 请求被调度时,预分配
max_input_tokens + max_output_tokens的完整内存 - 保证 Decode 阶段不会因内存不足而被抢占
- 优点:延迟稳定,无抢占开销;缺点:内存利用率低,批大小受限
MAX_UTILIZATION(最大化利用率):
- 随 Token 生成动态分配 KV Cache
- 允许更大的批次大小,内存利用率高
- 缺点:存在抢占风险,长序列场景可能出现抖动
- 适用:长输出、变化负载场景
8.3 DeepSpeed-FastGen 的 Dynamic SplitFuse
微软 DeepSpeed 团队(2023年12月)提出了 Dynamic SplitFuse 技术,对 Chunked Prefill 进行了进一步改进:
核心创新:将 Prefill 和 Decode 操作动态融合到固定大小的 Token 批次中:
- 不是静态切分 Prefill,而是根据当前 Decode 请求的数量,动态决定每次迭代中 Prefill 的 Token 比例
- 确保每次迭代的总 Token 数量相近,使 GPU 利用率保持稳定
固定批大小 = 512 Token
当前有 10 个 Decode 请求(共 10 Token)
→ Prefill chunk 动态设为 502 Token(凑够 512)
性能:对比 vLLM,有效吞吐量提升 最高 2.3 倍,延迟降低 约 50%。
九、前沿演进:Disaggregated Prefill/Decode
9.1 为什么需要 PD 分离?
即便有了 Chunked Prefill,Prefill 和 Decode 共置在同一 GPU 上仍然存在结构性矛盾:
- Prefill 是计算密集型,适合大张量并行、高 batch size
- Decode 是内存带宽密集型,适合小并行组、低 batch size、高 HBM 带宽
- 两者的最优硬件配置截然相反,强行共置意味着两者都无法达到最优
PD 分离(Disaggregated Prefill/Decode) 的核心思想:将 Prefill 和 Decode 部署在专用的、独立的 GPU 池中,各自独立优化,通过高速网络传输 KV Cache。
传统共置架构:
┌─────────────────────────────────┐
│ 单 GPU 池 │
│ [Prefill] → [Decode] → [Output]│
│ (互相干扰,无法独立扩展) │
└─────────────────────────────────┘PD 分离架构:
┌──────────────┐ KV Cache ┌──────────────┐
│ Prefill 池 │ ──InfiniBand→│ Decode 池 │
│ TP=8, 大batch│ │ TP=4, 小batch │
│ 优化 TTFT │ │ 优化 ITL │
└──────────────┘ └──────────────┘
9.2 代表性工作
DistServe(OSDI 2024):首次系统性提出 PD 分离研究,通过分离部署实现吞吐与延迟的帕累托最优:
- 在 SLO 约束下,服务能力提升 7.4 倍
- SLO 达成率提升 12.6 倍
Mooncake(SOSP 2024,字节跳动):月之暗面(Kimi)使用的生产推理框架:
- KV Cache 集中管理,通过高速互联实现跨节点传输
- 有效请求容量提升 59% ~ 498%(取决于工作负载特性)
- 日处理超过 1000 亿 Token
LMSYS 大规模部署(2025年5月):
- 在 96 块 H100 GPU(3 节点 Prefill + 9 节点 Decode)上运行 DeepSeek-R1
- 输入吞吐量:52,300 tokens/s/节点
- 输出吞吐量:22,300 tokens/s/节点
- 对比 vanilla 张量并行:输出吞吐量提升 5 倍
- 成本:$0.20/百万输出 Token(约为 DeepSeek 官方 API 成本的 1/5)
9.3 工程挑战:KV Cache 传输
PD 分离的核心工程挑战是 KV Cache 传输延迟:
以 Llama-70B、prompt 4096 Token、batch=8 为例:
- KV Cache 大小约 4.3 GB
- 100 Gbps 以太网传输需要 344ms(不可接受)
- 必须使用 InfiniBand(400 Gbps+)或 NVLink
FlowKV 通过将 KV 传输与 Decode 计算流水线重叠,将传输延迟从 944ms 降至 53ms(降低 96%)。
9.4 何时选择 PD 分离
| 场景 | 推荐架构 |
|---|---|
| 大规模部署(50+ GPU),长 Prompt(2000+ Token),严格 SLO | PD 分离 |
| 中小规模(< 16 GPU),短 Prompt,SLO 宽松 | 共置 |
| 有 InfiniBand / NVLink 互联 | PD 分离 |
| 仅有标准以太网 | 共置 |
十、Speculative Decoding 与连续批处理的张力与融合
10.1 两种技术的本质冲突
Continuous Batching 和 Speculative Decoding 在低负载下各有其场,但在高并发生产环境中会产生资源争用:
Speculative Decoding 的工作前提:
- 小草稿模型(Draft Model)快速生成 k 个候选 Token
- 大目标模型(Target Model)一次性验证所有候选
- 核心收益来自"GPU 加载权重的空闲算力"——这依赖于 batch size 较小(内存带宽受限区间)
Continuous Batching 的工作前提:
- 通过大 batch size 摊薄内存读取开销
- 将 GPU 从"内存带宽受限"区间推向"计算受限"区间
冲突本质:Continuous Batching 正是通过填满 GPU 的"闲置算力"来提升吞吐,而 Speculative Decoding 恰恰需要这些"闲置算力"来验证草稿 Token。高负载下,两者的收益完全对立。
10.2 不规则张量问题
除资源争用外,两者结合还面临不规则张量(Ragged Tensor)问题:
- Speculative Decoding 中,不同请求接受的草稿 Token 数量不同(有的接受 3 个,有的接受 0 个)
- 批次变成锯齿状的不规则结构
- FlashAttention 等主流 CUDA 内核为规则矩形张量优化,处理不规则结构需要额外对齐开销
- 强制填充对齐会导致位置编码、注意力掩码不同步,引发输出错误;手动重对齐开销高达推理总时间的 40%
10.3 前沿解决方案
SpecFormer(AAAI 2026):融合因果注意力与草稿块内双向注意力,生成高置信度单链草稿,验证开销低,在高批次场景不崩溃。
EXSpec:滑动池调度器,将接受相同数量草稿 Token 的请求动态分组为微批次,最大化张量规则性,在 batch size=8 时实现 3 倍吞吐量提升。
TurboSpec(UC Berkeley,2025):自适应平衡"批间请求数"与"批内投机步数",自动在连续批处理吞吐模式和投机解码延迟模式间切换,适配不同负载。
工程建议:生产环境不应简单叠加两种技术,而应根据实时负载动态切换策略:低负载时启用 Speculative Decoding,高负载时回退到纯 Continuous Batching。
十一、性能数据全景
11.1 连续批处理的典型收益
| 场景 | 方案 | 吞吐量 (tokens/s) | 对比基准 |
|---|---|---|---|
| OPT-13B, 高方差序列, A100 | HuggingFace Transformers (静态) | 81 | 1× 基准 |
| 朴素连续批处理 | ~160 | 2× | |
| FasterTransformer (优化静态) | ~320 | 4× | |
| vLLM (PagedAttention + 连续批处理) | ~370 | 23× | |
| GPT-3, 175B | FasterTransformer vs Orca | 相同延迟约束 | 36.9× (Orca) |
11.2 Chunked Prefill 的收益
| 模型 | 对比 | 服务容量提升 |
|---|---|---|
| Mistral-7B, A100 × 1 | vLLM vs Sarathi-Serve | 2.6× |
| Yi-34B, A100 × 2 | vLLM vs Sarathi-Serve | 最高 3.7× |
| Falcon-180B, 流水线并行 | vLLM vs Sarathi-Serve | 最高 5.6× |
11.3 PD 分离的收益
| 部署 | 对比 | 提升 |
|---|---|---|
| DistServe (OSDI 2024) | 同等 SLO 约束下 | 服务容量 7.4×,SLO 达成率 12.6× |
| LMSYS, DeepSeek-R1, 96×H100 | 对比 vanilla TP | 输出吞吐量 5× |
| Mooncake (字节跳动) | 不同工作负载 | 有效容量提升 59%~498% |
11.4 GPU 利用率改善
| 技术 | GPU 利用率 |
|---|---|
| 静态批处理 | 30-50% |
| Continuous Batching | 60-80% |
| Continuous Batching + PagedAttention | 80-95% |
十二、代码示例:理解调度器核心逻辑
12.1 简化版连续批处理调度器
以下是一个简化的 Python 实现,帮助理解 Continuous Batching 的核心逻辑:
from dataclasses import dataclass, field
from typing import List, Optional
import time@dataclass
class Request:"""模拟一个推理请求"""request_id: strprompt_tokens: int # 输入 Token 数max_output_tokens: int # 最大生成 Token 数generated_tokens: int = 0 # 已生成 Token 数status: str = "waiting" # waiting / running / completed@propertydef is_completed(self) -> bool:"""请求是否完成(生成 EOS 或达到最大长度)"""return self.generated_tokens >= self.max_output_tokens@dataclass
class BatchStats:"""批次统计信息"""total_steps: int = 0total_tokens_generated: int = 0gpu_idle_steps: int = 0class ContinuousBatchingScheduler:"""简化版连续批处理调度器核心逻辑:每个迭代步骤后动态调整批次- 已完成的请求立即移出- 等待队列中的请求立即补入"""def __init__(self, max_batch_size: int = 8, max_tokens_per_step: int = 512):self.max_batch_size = max_batch_sizeself.max_tokens_per_step = max_tokens_per_stepself.waiting_queue: List[Request] = []self.running_batch: List[Request] = []self.completed: List[Request] = []self.stats = BatchStats()def add_request(self, request: Request):"""添加新请求到等待队列"""self.waiting_queue.append(request)print(f"[+] 请求 {request.request_id} 加入等待队列 "f"(prompt={request.prompt_tokens}T, max_output={request.max_output_tokens}T)")def _admit_new_requests(self):"""尝试从等待队列补充新请求到运行批次"""while (self.waiting_queue andlen(self.running_batch) < self.max_batch_size):new_req = self.waiting_queue.pop(0)new_req.status = "running"self.running_batch.append(new_req)print(f" >>> 请求 {new_req.request_id} 进入运行批次")def _remove_completed_requests(self):"""移除已完成的请求"""completed_this_step = [r for r in self.running_batch if r.is_completed]for req in completed_this_step:req.status = "completed"self.running_batch.remove(req)self.completed.append(req)print(f" <<< 请求 {req.request_id} 完成 "f"(生成了 {req.generated_tokens} 个 Token)")def step(self) -> int:"""执行一个迭代步骤(一次前向传播)Returns:本步骤生成的 Token 数量"""self.stats.total_steps += 1# 每步开始先尝试加入新请求(Iteration-Level 调度的核心)self._admit_new_requests()if not self.running_batch:self.stats.gpu_idle_steps += 1return 0# 模拟前向传播:每个运行中的请求生成 1 个新 Tokentokens_this_step = 0for req in self.running_batch:req.generated_tokens += 1tokens_this_step += 1self.stats.total_tokens_generated += tokens_this_step# 移除已完成的请求self._remove_completed_requests()return tokens_this_stepdef run(self):"""运行调度器直到所有请求完成"""print(f"\n{'='*60}")print(f"连续批处理调度器启动 (max_batch={self.max_batch_size})")print(f"{'='*60}")while self.waiting_queue or self.running_batch:print(f"\n--- Step {self.stats.total_steps + 1} "f"| 运行中: {len(self.running_batch)} "f"| 等待中: {len(self.waiting_queue)} ---")tokens = self.step()# 打印当前批次状态if self.running_batch:status = ', '.join(f"{r.request_id}({r.generated_tokens}/{r.max_output_tokens})"for r in self.running_batch)print(f" 批次状态: [{status}]")print(f"\n{'='*60}")print(f"所有请求完成!统计:")print(f" 总迭代步数: {self.stats.total_steps}")print(f" 总生成 Token 数: {self.stats.total_tokens_generated}")print(f" GPU 空转步数: {self.stats.gpu_idle_steps}")print(f" GPU 利用率: "f"{(1 - self.stats.gpu_idle_steps / self.stats.total_steps) * 100:.1f}%")print(f"{'='*60}\n")# 示例:模拟 6 个长度各异的请求
def demo_continuous_batching():scheduler = ContinuousBatchingScheduler(max_batch_size=3)# 请求到达(模拟不同时刻的到达)requests = [Request("A", prompt_tokens=100, max_output_tokens=10), # 短请求Request("B", prompt_tokens=50, max_output_tokens=4), # 超短请求Request("C", prompt_tokens=200, max_output_tokens=15), # 中等请求Request("D", prompt_tokens=80, max_output_tokens=6), # 短请求Request("E", prompt_tokens=300, max_output_tokens=20), # 长请求Request("F", prompt_tokens=150, max_output_tokens=8), # 中等请求]# 批量添加所有请求(实际系统中请求是持续到达的)for req in requests:scheduler.add_request(req)scheduler.run()if __name__ == "__main__":demo_continuous_batching()
运行输出示例(简化版):
连续批处理调度器启动 (max_batch=3)--- Step 1 | 运行中: 0 | 等待中: 6 --->>> 请求 A 进入运行批次>>> 请求 B 进入运行批次>>> 请求 C 进入运行批次批次状态: [A(1/10), B(1/4), C(1/15)]--- Step 4 | 运行中: 3 | 等待中: 3 ---<<< 请求 B 完成 (生成了 4 个 Token) ← B 完成,D 立即补入>>> 请求 D 进入运行批次批次状态: [A(4/10), C(4/15), D(1/6)]... (持续运行直到所有请求完成)
12.2 Chunked Prefill 调度逻辑
class ChunkedPrefillScheduler:"""支持 Chunked Prefill 的调度器"""def __init__(self,max_batch_tokens: int = 512, # 每步最大 Token 预算(Prefill + Decode)chunk_size: int = 128, # 必须是 128 的倍数(避免 Tile Quantization)):self.max_batch_tokens = max_batch_tokensself.chunk_size = chunk_sizeself.prefill_queue = [] # 等待 Prefill 的请求self.decode_queue = [] # 正在 Decode 的请求self.prefill_progress = {} # 每个请求的 Prefill 进度def schedule_step(self):"""调度一个迭代步骤优先级:Decode > Chunked Prefill(先满足 Decode,剩余预算用于 Prefill)"""token_budget = self.max_batch_tokensdecode_batch = []prefill_batch = []# 1. 优先调度 Decode 请求(对 ITL 最敏感)for req in self.decode_queue:decode_batch.append(req)token_budget -= 1 # 每个 Decode 请求消耗 1 个 Token# 2. 用剩余预算处理 Prefill chunkfor req in list(self.prefill_queue):if token_budget <= 0:breakprogress = self.prefill_progress.get(req.request_id, 0)remaining = req.prompt_tokens - progress# 本步处理的 chunk 大小this_chunk = min(remaining, self.chunk_size, token_budget)# 对齐到 128 的倍数(避免 tile quantization 效应)this_chunk = (this_chunk // 128) * 128if this_chunk == 0:this_chunk = min(remaining, token_budget)prefill_batch.append((req, this_chunk))token_budget -= this_chunk# 更新 Prefill 进度new_progress = progress + this_chunkself.prefill_progress[req.request_id] = new_progress# Prefill 完成 → 移到 Decode 队列if new_progress >= req.prompt_tokens:self.prefill_queue.remove(req)self.decode_queue.append(req)return decode_batch, prefill_batch
12.3 vLLM 实际使用
from vllm import LLM, SamplingParams# 基本用法:vLLM 默认启用 Continuous Batching + PagedAttention
llm = LLM(model="meta-llama/Llama-3.1-8B-Instruct",# V1 调度器配置(vLLM v1.x)max_num_batched_tokens=2048, # 每步最大 Token 数(较小值 → 更低 ITL)max_num_seqs=256, # 最大并发序列数gpu_memory_utilization=0.9, # GPU 显存使用率(用于 KV Cache)enable_chunked_prefill=True, # 启用 Chunked Prefill(v1 默认开启)enable_prefix_caching=True, # 启用前缀缓存
)sampling_params = SamplingParams(temperature=0.8,max_tokens=512,
)# 批量推理:vLLM 自动处理连续批处理调度
prompts = ["解释一下量子纠缠的原理","写一段冒泡排序的 Python 代码","大语言模型的主要应用场景有哪些?",# ... 更多 prompts
]outputs = llm.generate(prompts, sampling_params)for output in outputs:print(f"Prompt: {output.prompt[:50]}...")print(f"Output: {output.outputs[0].text[:100]}...")print(f"Generated tokens: {len(output.outputs[0].token_ids)}")print()# vLLM serve(在线服务):自动启用连续批处理
# python -m vllm.entrypoints.openai.api_server \
# --model meta-llama/Llama-3.1-8B-Instruct \
# --max-num-batched-tokens 2048 \
# --enable-chunked-prefill
12.4 性能基准测试
import asyncio
import time
from openai import AsyncOpenAIasync def benchmark_throughput(base_url: str = "http://localhost:8000/v1",model: str = "meta-llama/Llama-3.1-8B-Instruct",num_requests: int = 100,max_concurrency: int = 32,
):"""简单的吞吐量基准测试测量连续批处理下的实际 tokens/s 性能"""client = AsyncOpenAI(base_url=base_url, api_key="dummy")semaphore = asyncio.Semaphore(max_concurrency)results = []async def single_request(prompt: str, request_id: int):async with semaphore:start = time.time()total_tokens = 0async with client.chat.completions.stream(model=model,messages=[{"role": "user", "content": prompt}],max_tokens=256,) as stream:async for chunk in stream:if chunk.choices[0].delta.content:total_tokens += 1latency = time.time() - startreturn {"tokens": total_tokens, "latency": latency}prompts = [f"请介绍AI技术的第{i}个应用场景,详细说明" for i in range(num_requests)]print(f"发送 {num_requests} 个请求,最大并发 {max_concurrency}...")start_total = time.time()tasks = [single_request(p, i) for i, p in enumerate(prompts)]results = await asyncio.gather(*tasks)total_time = time.time() - start_totaltotal_tokens = sum(r["tokens"] for r in results)avg_latency = sum(r["latency"] for r in results) / len(results)print(f"\n=== 基准测试结果 ===")print(f"总请求数: {num_requests}")print(f"总耗时: {total_time:.2f}s")print(f"总生成 Token 数: {total_tokens}")print(f"整体吞吐量: {total_tokens / total_time:.1f} tokens/s")print(f"平均延迟: {avg_latency:.2f}s")print(f"P99 延迟: {sorted(r['latency'] for r in results)[int(0.99*len(results))]:.2f}s")
十三、工程实践指南
13.1 选型决策树
需要部署 LLM 推理服务│├── 规模 < 8 GPU,SLO 宽松?│ → 共置架构,vLLM 默认配置│├── 规模 > 50 GPU,有 InfiniBand?│ → 考虑 PD 分离(DistServe / Mooncake 模式)│├── 主要场景是长 Prompt(> 2048 Token)?│ → 开启 Chunked Prefill,调小 max_num_batched_tokens│├── 主要场景是低并发、低延迟?│ → 考虑 Speculative Decoding(低负载时有效)│└── 高并发 + 低延迟同时要求?→ Continuous Batching + Chunked Prefill + 可选 PD 分离→ 不要同时启用 Speculative Decoding(高负载下无效)
13.2 关键参数调优指南
# vLLM 调优参考# 场景1:最低 ITL(流式输出流畅度优先)
llm = LLM(model="...",max_num_batched_tokens=512, # 小值,限制每步 Prefill Token 数enable_chunked_prefill=True,
)# 场景2:最高吞吐量(离线批处理)
llm = LLM(model="...",max_num_batched_tokens=32768, # 大值,最大化每步计算量max_num_seqs=512,enable_chunked_prefill=True,enable_prefix_caching=True, # 开启前缀缓存
)# 场景3:平衡 TTFT 和 ITL(在线服务)
llm = LLM(model="...",max_num_batched_tokens=2048, # 中等值enable_chunked_prefill=True,enable_prefix_caching=True,
)
13.3 常见问题排查
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| ITL 出现毛刺(spike) | Prefill 请求未分块,阻塞 Decode | 开启 Chunked Prefill,调小 max_num_batched_tokens |
| 吞吐量低于预期 | Prefill 频率过低,批次中有大量空位 | 调大 max_num_seqs,检查 KV Cache 使用率 |
| OOM(显存溢出) | KV Cache 分配过多 | 调小 gpu_memory_utilization 或启用 Preemption |
| P99 延迟极高 | 长请求占用 GPU 时间过长,短请求堆积 | 考虑请求优先级调度,或 PD 分离 |
| 并发数增加但吞吐不线性增长 | Decode 阶段内存带宽瓶颈 | 增大 batch size(如使用 continuous batching),或使用量化减小 KV Cache |
参考资料
-
Orca(OSDI 2022)
Gyeong-In Yu et al., "ORCA: A Distributed Serving System for Transformer-Based Generative Models"
https://www.usenix.org/conference/osdi22/presentation/yu -
vLLM / PagedAttention(SOSP 2023)
Woosuk Kwon et al., "Efficient Memory Management for Large Language Model Serving with PagedAttention"
https://arxiv.org/abs/2309.06180 -
Sarathi-Serve / Chunked Prefill(OSDI 2024)
Amey Agrawal et al., "Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve"
https://www.usenix.org/conference/osdi24/presentation/agrawal -
DistServe(OSDI 2024)
Yinmin Zhong et al., "Disaggregating Prefill and Decoding for Goodput-Optimized LLM Serving"
https://arxiv.org/abs/2401.09670 -
Mooncake(SOSP 2024)
字节跳动,"Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving"
https://arxiv.org/abs/2407.00079 -
DeepSpeed-FastGen(2024)
Sheng Cao et al., "DeepSpeed-FastGen: High-throughput Text Generation for LLMs via MII and DeepSpeed-Inference"
https://arxiv.org/abs/2401.08671 -
FlowSpec(arXiv 2025)
Xing Liu et al., "FlowSpec: Continuous Pipelined Speculative Decoding for Efficient Distributed LLM Inference"
https://arxiv.org/abs/2507.02620 -
连续批处理中文科普(高质量)
幻方量化技术博客,"Continuous Batching:一种提升 LLM 部署吞吐量的利器"
https://www.high-flyer.cn/blog/continuous-batching/ -
Prefill-Decode 分离架构综述(2026)
"Prefill-Decode Disaggregation: The Architecture Shift Redefining LLM Serving at Scale"
https://groundy.com/articles/prefill-decode-disaggregation-the-architecture-shift-redefining-llm-serving-at-scale/ -
vLLM 官方文档
https://docs.vllm.ai/en/stable/configuration/optimization/ -
vLLM vs TensorRT-LLM 调度对比
"[vLLM vs TensorRT-LLM] #4. Which Scheduler Wins?"
https://blog.squeezebits.com/vllm-vs-tensorrtllm-4-which-scheduler-wins--33083
