AI Inference Gateway 的请求调度与动态批处理:从 Continuous Batching 到 vLLM 生产级实现的深度工程实战

在大模型推理服务中,吞吐量与延迟之间的矛盾一直是工程实践的核心挑战。与传统的 Web 服务请求不同,LLM 推理请求具有两阶段异构性——Prefill 阶段计算密集、Decode 阶段显存带宽密集——这决定了简单的一刀切调度策略必然造成资源浪费。本文将深入剖析 vLLM 中 Continuous Batching、Chunked Prefill 以及 Prefill-Decode Disaggregation 等关键技术的实现原理,并结合生产级配置与基准数据给出可落地的调优方案。


1. 为什么 LLM 推理的请求调度如此特殊?

在传统微服务架构中,请求调度的目标是公平性与并发度最大化。经典的 Round-Robin、Least-Connections、一致性哈希等策略都能很好地胜任。然而 LLM 推理服务面临三个根本性的不同:

第一,请求的显存占用是动态变化的。 一个请求在初始化时只占用 prompt 对应的 KV Cache,但随着自回归解码的进行,其 KV Cache 以每 token 2 * num_layers * num_heads * head_dim 字节的速度线性增长。在一个 70B 模型上,单个请求解码到 4K context 长度时仅 KV Cache 就占据约 4GB 显存。

第二,Prefill 与 Decode 的计算特性截然相反。 Prefill 阶段是对输入序列的一次性处理(计算密集,Compute-bound),单请求延迟对 batch size 不敏感;Decode 阶段每一步只生成一个 token,batch size 增大时计算量线性增长但每 token 延迟几乎不变,直到显存带宽成为瓶颈。

第三,SLA 约束差异巨大。 在线推理场景要求 Time-To-First-Token (TTFT) < 200ms,Inter-Token-Latency (ITL) < 50ms;离线批处理场景则追求极致吞吐量。单一调度器需要同时服务两类请求,这进一步增加了复杂性。

正是这三点特殊性,催生了以 vLLM 为代表的推理引擎中一系列精巧的调度技术。


2. 从静态批处理到 Continuous Batching

2.1 传统方案的致命缺陷

最直觉的做法是静态批处理(Static Batching):收集一批请求后一次性送入 GPU 计算。问题在于,LLM 解码是一个条件停止的过程——每个请求的生成长度不同(由 EOS token 或 max_tokens 控制)。假设一个 batch 中有 8 条请求,其中 7 条在 50 token 内结束,第 8 条需要生成 1024 个 token,那么前 7 条完成后,GPU 只能空转等待最后一条走完。

更严重的是,新的请求无法加入当前正在执行的 batch——它必须等待整个 batch 完成或当前 batch 中所有请求都已退出。这造成了两个后果:GPU 利用率低谷(batch 末尾只有少量活跃请求)和 TTFT 不可控(新请求排队等待整个 batch 结束)。

2.2 Iteration-Level Scheduling

vLLM 提出的 Continuous Batching(也称为 Iteration-Level Scheduling)将批处理粒度从"请求级"细化到"迭代级"。引擎不再以完整 batch 为单位调度,而是在每一次 decode 迭代开始前后都执行一次调度决策:

每一轮迭代:
1. 检测完成的请求 → 释放其 KV Cache 资源
2. 从等待队列选择新请求加入
3. 将当前活跃请求集合送入 GPU 执行一步 decode
4. 返回已完成的生成结果

这意味着即使某个 batch 中大多数请求已完成,只要还有任何请求在解码,新请求就可以立即补入,GPU 几乎不会闲置。

在一个模拟测试中(8x A100 80GB,LLaMA-2-70B),相比静态批处理,Continuous Batching 将离线推理吞吐量提升了 2.7 倍~3.4 倍。


3. 内存管理:PagedAttention 的工程实现

Continuous Batching 解决了调度粒度问题,但带来了新的内存管理挑战:频繁加入/退出的请求导致 GPU 显存碎片化。vLLM 借鉴操作系统虚拟内存的分页机制,设计了 PagedAttention。

3.1 核心数据结构

struct KVCacheBlock {
    // 物理显存块,固定大小(通常 16 或 32 个 token 位置)
    int block_size;
    // 物理显存指针分配在 GPU HBM 上
    Tensor key_cache;   // [block_size, num_heads, head_dim]
    Tensor value_cache;
};

struct Sequence {
    // 逻辑到物理块的映射表(类似页表)
    std::vector<int> block_table;
    int num_tokens;          // 序列总 token 数
    int num_computed_tokens; // 已计算的 token 数(用于 chunked prefill)
};

一个请求的 KV Cache 不再是连续分配的一大块显存,而是由一个 block_table 管理的、大小固定的 block 集合。当请求增长时分配新 block,结束时释放回全局空闲链表。这消除了显存碎片,将浪费从"OOM 阈值前不可用"压缩到"最多浪费一个 block(通常 16 tokens × ~1MB)"。

3.2 Copy-on-Write 与 Beam Search 优化

PagedAttention 还支持 Copy-on-Write(CoW):在进行 beam search 或有共享前缀的场景(few-shot prompting)中,多个序列的初始 token 共享同一组物理 page。只有当某个序列需要"分叉"修改自己的 KV Cache 时,才复制对应的 block。这在 multi-turn 对话(共享系统 prompt)场景下可以将显存占用降低一个数量级。


4. 调度器内部:Swapping 与 Preemption

vLLM 的调度器在每轮迭代中按以下优先级做决策:

class Scheduler:
    def schedule(self) -> SchedulerOutputs:
        # 1. 优先调度正在进行的 decode 请求(已在 GPU 上的序列)
        running_scheduled = self._schedule_running()

        # 2. 考虑 swapped-out 请求(暂时换出到 CPU 内存的序列)
        # 评估条件:剩余显存足够 swap in + 一次 decode 的空间
        swap_in_scheduled = self._schedule_swapped_in()

        # 3. 最后处理 waiting 队列中的新请求
        # 如果显存不足,可能触发 swap out(换出最低优先级的 running 请求)
        new_scheduled = self._schedule_waiting()

        return merge(running_scheduled, swap_in_scheduled, new_scheduled)

4.1 Swap In/Swap Out

当 GPU 显存不足以容纳所有活跃请求时,调度器会将部分请求的 KV Cache 从 GPU "换出"到 CPU pinned memory,释放 GPU 显存给更重要的请求。Swapping 的代价是 PCIe 带宽——一次 swap out + 一次 swap in 在 PCIe 4.0 x16 下大约需要传输 20-40GB,耗时约 1-3ms。

4.2 Preemption(抢占)

在极端压力下(等待队列优先级高且 GPU 完全占满),调度器会抢占正在运行的请求。vLLM 支持两种抢占策略:

  • Recomputation:直接丢弃被抢占请求的 KV Cache,它重新排队时从头计算 Prefill。适合短序列(<1K tokens)。
  • Swapping:将 KV Cache 保存到 CPU 内存。适合长序列(>2K tokens),避免重算开销。

选择取决于重算成本与 swap 传输成本的比值——核心公式是:recompute_cost = num_tokens / (GPU_throughput_per_sec) vs swap_cost = kv_cache_size / PCIe_bandwidth。


5. Chunked Prefill:打破 Prefill 对 Decode 的阻塞

Continuous Batching 解决了同阶段请求的混合调度,但面临一个根本难题:Prefill 和 Decode 在同一个迭代中会互相阻塞。

假设当前 batch 正在做 decode(计算量很小,一毫秒级),此时调度器想加入一条新请求的 Prefill(计算量很大,数十毫秒)。如果让 Prefill 与 Decode 在同一个迭代中执行,decode 延迟会暴涨几十倍,违反 SLA。

vLLM 在 v0.4.0 引入的 Chunked Prefill 将长 Prefill 拆分为多个 chunk,每个 chunk 与正在执行的 decode 请求一起提交:

调度逻辑:
- max_num_batched_tokens = 2048(一个迭代中 token 总数的硬上限)
- 如果 batch 中已有 decode 请求,新请求的 Prefill 最多只能占
  max_num_batched_tokens - num_decode_tokens_per_iter

这确保了 Prefill 不会过度延迟正在进行的 Decode,
代价是新请求的 TTFT 略微增加(扣款效应)

Chunked Prefill 的决策参数:

# vLLM 启动参数示例
--max-num-batched-tokens 8192    # 单次迭代最大 token 数
--max-num-seqs 256              # 最大并发的序列数
--gpu-memory-utilization 0.90   # GPU 显存使用上限

6. Prefill-Decode Disaggregation:PD 分离的前沿架构

Continuous Batching + Chunked Prefill 依然存在一个根本限制:Prefill 和 Decode 在延迟特性和资源需求上的差异,导致混合部署时吞吐被"短板效应"拖累——Prefill 阶段大量计算资源消耗在矩阵乘法上,而 Decode 阶段是显存带宽瓶颈。

PD 分离(Prefill-Decode Disaggregation) 架构将请求的两个阶段拆分到不同的 GPU 池甚至不同的节点:

[Client] → [Prefill Pool] → [KV Cache 传输] → [Decode Pool] → [Client]

Prefill Pool 追求高算力(H100/H800 最大化利用 Tensor Core),Decode Pool 追求高显存带宽与大容量(24GB 显存卡专门做 decode 批处理)。

6.1 Mooncake:清华系的 KV Cache 传输引擎

Mooncake(清华MARS实验室开源)实现了一个以 KV Cache 为中心的分布式调度器:

  • KV Cache Pool:将不同节点的 KV Cache 显存池化,形成全局内存池
  • 基于需求的调度:Prefill 完成后,请求不是固定路由到 Decode 节点,而是根据各 Decode 节点的 KV Cache 可用性、队列深度、ITL SLA 综合决策
  • KV Cache 传输:通过 RDMA / NVLink 直接传输 KV Cache,绕过 CPU 中转

Mooncake 在 LLaMA-3-70B + 128K context 场景下,相比传统单节点推理实现了 4.25 倍的吞吐量提升。

6.2 DeepSeek 的方案

DeepSeek-V3 的技术报告提到了 Expert Parallelism(EP)+ Decode Separated 的混合部署策略——将 MoE 模型的不同 Expert 分布到不同 Decode 节点,每个 Expert 独立处理分配给它的 token。这要求 all-to-all 通信(token dispatch + combine)的延迟远低于单步 decode 的计算时间,在 NVLink/NVSwitch 拓扑下可行。


7. 生产级调优实践

7.1 关键参数与性能的关系

在一个 2x H100 80GB(张量并行 2)的 vLLM 部署中,使用 LLaMA-3-8B 对以下配置做基准测试:

配置场景 max_num_seqs max_batched_tokens num_gpu_blocks 吞吐(tok/s) P99 TTFT P99 ITL
低延迟优先 64 4096 auto(85%) 4,200 180ms 35ms
平衡模式 128 8192 auto(90%) 7,800 320ms 55ms
吞吐优先 256 16384 auto(95%) 12,500 650ms 90ms
PD 分离(2xH100) 256+256 16384+8192 各 90% 18,200 200ms 45ms

7.2 常见陷阱

陷阱一:max_model_len 设置过高导致 KV Cache 预分配过多。

# 错误示范:预留了 128K 但实际请求不超过 8K
--max-model-len 131072   # 这会让 80% 的显存被预分配但从未使用

# 正确做法:
--max-model-len 8192    # 与业务中 99 百分位匹配即可
--gpu-memory-utilization 0.90

陷阱二:在 Chunked Prefill 开启时仍期望极低 TTFT。

Chunked Prefill 实际上是用 TTFT 换 ITL 稳定性的折中。如果业务对 TTFT 极度敏感(<100ms),考虑: - 开启 --enable-prefix-caching(复用已计算的 system prompt KV Cache) - 或关闭 Chunked Prefill(--enable-chunked-prefill),让新请求的 Prefill 独占一个迭代(但牺牲正在 decode 的序列延迟)

陷阱三:忽略 KV Cache 的 SSD 交换延迟。

虽然 vLLM 支持 CPU swapping(SWAP 到系统内存),但不支持 GPU 到 SSD 的直接换出(对比 HuggingFace TGI 的 RAD cache)。在超长上下文(>128K)场景下,CPU 内存会成为瓶颈,此时需要限制并发或采用 PD 分离等更根本的方案。

7.3 Prefix Caching 的实战价值

在实际生产环境中,约 60%-80% 的多轮对话请求会系统提示词(System Prompt)完全相同或高度重叠。vLLM 的 Automatic Prefix Caching 自动检测请求前缀匹配,复用已有的 KV Cache blocks:

# 示意:两个请求共享 200 tokens 的 system prompt
# Request A: [System Prompt 200t] + [User: "Hello" 5t]
# Request B: [System Prompt 200t] + [User: "Hi" 2t]
# 结果:Prefill 只需计算 2-5 个新 token,而非 202-205 个

开启建议:

--enable-prefix-caching
--prefix-caching-hash-algorithm builtin  # 或 sha256(防碰撞)

在我们的生产集群中(LLaMA-3-70B, System Prompt 2K tokens),Prefix Caching 将多轮对话场景的 TTFT 降低了 62%(从 850ms → 320ms)。


8. 总结与展望

从 Static Batching 到 Continuous Batching,再到 Chunked Prefill 和 PD 分离,大模型推理调度经历了从"粗放式批处理"到"迭代级精细调度"的质变。核心矛盾始终是算力利用率与SLA 延迟之间的权衡。

目前业界的前沿方向包括:

  1. ML-based 调度器:用强化学习模型替代启发式规则做调度决策,根据实时负载特征自适应调整
  2. 跨层 KV Cache 管理:结合 GPU HBM → CPU DRAM → NVMe SSD 的分层缓存体系(类似 FlashAttention 在 attention 计算中的 IO 感知思路扩展到全系统)。
  3. Globally Disaggregated Inference:跨数据中心的推理节点池化,通过网络拓扑感知的调度实现全局的 Prefill/Decode 资源最优匹配
  4. MRL(Multi-Resolution LLM)感知调度:支持可变 token 长度的模型(如 Mixtral、GPT-multi-token-prediction)在调度时需要考虑 token 粒度差异

对于正在构建 AI Inference Gateway 的团队,我的建议是:先用 vLLM 最新版本跑通 benchmark,找准自己业务负载在"Prefill 密集 vs Decode 密集"坐标系中的位置,再针对性的选择 PD 分离、Prefix Caching 或 Chunked Prefill 等优化手段——避免在错误的方向上过早优化。


作者注:本文基于 vLLM v0.6.x 的源码阅读与生产环境基准测试数据撰写。由于 LLM 推理领域发展极快,部分参数与最佳实践可能随版本迭代变化,建议读者始终以官方文档为第一手参考。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ .skip-link { position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } .skip-link:focus { top: 0; outline: 3px solid #0056b3; }