文章

Continuous Batching 实战:为什么 LLM 服务不能用传统固定批处理

从固定批处理、迭代级调度到 PagedAttention,系统梳理 Continuous Batching 在大模型推理服务中的原理、收益、边界与上线检查方法。

背景:为什么传统固定批处理不适合 LLM 在线服务

在传统深度学习推理中,batching 的直觉很简单:把多个请求凑成一个批次,一次送进 GPU,提高并行度。但大模型文本生成服务并不是一次前向计算就结束的事。一个请求通常包含两个阶段:

  1. Prefill 阶段:处理完整 prompt,计算第一批 KV Cache。
  2. Decode 阶段:每轮生成一个或少量 token,并持续读取、追加 KV Cache。

问题在于,不同用户请求的 prompt 长度、输出长度、到达时间差异很大。如果使用传统固定批处理,系统通常会遇到三个核心问题:

  • 短请求被长请求拖住:同一批请求需要近似同步执行,短请求即使已经完成,也可能等到整批结束才能释放位置。
  • 新请求等待批次结束:批次运行期间,新到请求不能马上进入 GPU,只能在队列中等待。
  • padding 和 KV Cache 浪费严重:为了对齐不同长度的序列,系统会产生大量无效计算或显存占用。

ORCA 论文把这个问题描述得很直接:生成式 Transformer 推理具有多轮迭代特征,传统调度不能在当前批次运行时改变请求集合,导致已完成请求不能及时返回、新请求必须等待当前批次完全结束。ORCA 的核心改进是 iteration-level scheduling,也就是以每一轮生成迭代为单位重新组织批次。

这正是 Continuous Batching 的出发点。

Continuous Batching 的核心思想

Continuous Batching,也常被称为 in-flight batchingiteration-level batching。它不是简单把 batch size 调大,而是从根本上改变了调度粒度。

传统 batching 的粒度是**「请求级」**:

batch = [request_a, request_b, request_c]
run until all requests finish
return results
start next batch

Continuous Batching 的粒度是**「生成迭代级」**:

while server_running:
    remove_finished_requests()
    add_new_requests_if_capacity_available()
    schedule_prefill_and_decode_tokens()
    run_one_or_more_model_steps()
    stream_completed_tokens()

这意味着系统可以在 token generation loop 的每一步动态调整活跃请求集合:完成的请求马上退出,新来的请求只要有容量就能进入。TensorRT-LLM 的 Batch Manager 文档也把 in-flight batching 描述为一种减少队列等待、消除 padding 请求、提高 GPU 利用率的机制,并且允许在每次 token generation loop 迭代中纳入新请求、返回已完成请求。

从工程角度看,Continuous Batching 不是一个单点优化,而是一组能力的组合:

能力说明
请求池管理维护 waiting、running、finished、aborted 等状态
迭代级调度每轮决定哪些请求执行 prefill、哪些请求执行 decode
token budget 控制限制每轮可处理的 token 总量,避免长 prompt 抢占所有资源
KV Cache 分配与回收请求进入、增长、完成、取消时都要动态管理 KV Cache
流式返回decode 阶段生成 token 后尽快推给客户端,而不是等待整批结束

为什么 PagedAttention 经常和 Continuous Batching 一起出现

Continuous Batching 解决的是「请求如何进入和离开批次」的问题,但它会进一步放大另一个问题:KV Cache 是动态增长的,且生命周期高度不规则

每个请求都会维护自己的 KV Cache。请求越长,KV Cache 越大;请求完成、取消、超时后,KV Cache 又要释放。如果直接使用连续大块显存管理,系统很容易产生碎片。碎片一多,可用显存看似还有,但无法容纳新的请求,最终限制 batch size 和吞吐。

PagedAttention 的思路是借鉴操作系统里的虚拟内存分页,把 KV Cache 切成块来管理。PagedAttention 论文指出,LLM serving 的 KV Cache 很大,并且会动态增长和收缩;低效管理会因为碎片和重复复制浪费大量内存,从而限制 batch size。PagedAttention 通过块级管理让 KV Cache 接近零浪费,并支持请求内和请求间共享 KV Cache。

两者的关系可以这样理解:

  • Continuous Batching 让请求集合在运行中动态变化。
  • PagedAttention 让动态变化的 KV Cache 更容易被分配、复用和释放。
  • 两者结合,才能在高并发、长短请求混合的真实服务中稳定提升吞吐。

这也是为什么 vLLM 会同时强调 PagedAttention、continuous batching、chunked prefill、prefix caching 等能力。单独看某一项优化容易误判,真正上线时要把它们放在同一个调度系统里评估。

一条请求在 Continuous Batching 系统中的生命周期

1. Admission:进入等待队列

请求到达后,系统不会立即执行,而是先进入等待队列。调度器需要检查:

  • 当前 GPU 是否还有可用 KV Cache block。
  • 当前 active request 数是否达到上限。
  • 当前 token budget 是否允许新增 prompt token。
  • 请求是否带有优先级、超时、租户或 SLA 约束。

如果只用 max_batch_size 控制容量,很容易出问题。LLM 服务更应该同时控制请求数token 数。一个 32K prompt 请求对系统的压力,远大于几十个短 prompt 请求。

2. Prefill:计算上下文

Prefill 阶段通常是 compute-heavy。长 prompt 会消耗大量算力,并推高 TTFT(Time To First Token)。如果连续批处理把大量长 prompt 一次性塞进 GPU,decode 请求可能被挤压,导致正在流式输出的用户感觉「卡顿」。

因此,生产环境常把 Continuous Batching 和 chunked prefill 结合:长 prompt 不一次性处理完,而是拆成多个 chunk,与 decode 请求穿插执行。这样可以减少 prefill 对 decode 的阻塞。

3. Decode:逐 token 生成

Decode 阶段通常更接近 memory-bound,因为每步都要读取已有 KV Cache。Continuous Batching 的主要收益之一,就是让 decode 阶段保持较高并发:当某些请求结束后,新请求可以补进来,GPU 不会因为批次尾部几个长输出请求而空转。

但 decode 并不是越塞越多越好。活跃序列太多时,KV Cache 读取压力会上升,ITL(Inter-Token Latency)和尾延迟可能恶化。

4. Completion / Abort:释放资源

请求完成、客户端断开、超时或被取消后,系统必须及时释放:

  • KV Cache block。
  • 调度器中的 running slot。
  • 流式连接资源。
  • trace、metrics、日志上下文。

这一步经常被低估。取消请求如果不能及时释放 KV Cache,会让线上显存使用率异常升高,进而影响后续请求 admission。

生产落地时最容易犯的误区

误区一:只看吞吐,不看 TTFT 和 ITL

Continuous Batching 很容易提升总吞吐,但吞吐不是唯一指标。在线服务至少要拆开看:

指标含义
TTFT用户多久看到第一个 token
ITL后续 token 间隔是否稳定
TPOT每个输出 token 的平均耗时
P95/P99 latency尾部请求是否被拖慢
Queue waiting time延迟来自排队还是模型执行

如果只看 tokens/s,系统可能在压测中很好看,但真实用户会感觉首字慢或流式输出抖动。

误区二:把 max batch size 当成唯一旋钮

传统推理常调 batch size,但 LLM serving 要调的是一组约束:

scheduler:
  max_num_seqs: 128
  max_num_batched_tokens: 8192
  max_model_len: 32768
  prefill_chunk_size: 2048
  kv_cache_utilization_limit: 0.85
  waiting_timeout_ms: 2000

这些参数之间互相制约:

  • max_num_seqs 太大 → decode 并发高,但 KV Cache 压力更大。
  • max_num_batched_tokens 太大 → prefill 吞吐高,但可能压制 decode。
  • max_model_len 太大 → 单请求上限高,但 admission 更保守。
  • kv_cache_utilization_limit 太激进 → 容易在尾部负载下触发 OOM 或频繁拒绝。

误区三:把 benchmark 结果直接搬到生产

压测脚本常用固定输入长度、固定输出长度、固定到达率。但真实业务里,请求分布通常是长尾的:

  • 有些用户只问一句话。
  • 有些 Agent 会塞入几十 KB 工具上下文。
  • 有些任务输出十几个 token。
  • 有些任务输出上千 token。
  • 有些客户端会中途断开。

因此,生产压测不能只跑「平均长度」。至少要构造短 prompt、长 prompt、短输出、长输出、混合到达、突刺流量、取消请求几类场景。

误区四:忽略多租户公平性

Continuous Batching 会让系统更积极地填满 GPU,但如果没有公平调度,大租户或长 prompt 任务可能挤压小租户。常见做法包括:

  • 按租户设置并发上限。
  • 按请求类型设置 token budget。
  • 对交互式请求和离线任务分队列。
  • 对长上下文请求设置更严格的 admission 策略。
  • 使用 deadline-aware 或 SLA-aware 调度策略。

上线前的检查清单

调度参数

  • 是否同时设置 active request 上限和 batched token 上限。
  • 是否限制单请求最大输入长度和最大输出长度。
  • 是否区分 prefill token budget 与 decode token budget。
  • 是否启用或评估 chunked prefill。
  • 是否对请求取消、超时、客户端断连做了资源释放测试。

显存与 KV Cache

  • 是否监控 KV Cache block 使用率、空闲数和回收速度。
  • 是否监控 admission failure 的原因:显存不足、token budget 不足、并发满、超时。
  • 是否压测长上下文混合流量。
  • 是否验证 prefix caching、LoRA、speculative decoding 等功能与调度器的组合影响。

延迟指标

  • 是否拆分 TTFT、ITL、TPOT、queue time、model execution time。
  • 是否看 P50、P95、P99,而不是只看平均值。
  • 是否单独观察长 prompt 对短请求的影响。
  • 是否观察高负载下 streaming token 的抖动。

容错与降级

  • GPU 显存接近阈值时,是否拒绝新请求而不是直接 OOM。
  • 是否支持按租户、模型、请求类型限流。
  • 是否支持快速下调 max_num_seqs 或 token budget。
  • 是否在异常时保留 trace id,便于定位具体请求。

一个更稳妥的调参顺序

生产调优不要一开始就追求最大吞吐。建议按下面顺序推进:

第一步:建立基线

先用保守配置跑通服务:

max_num_seqs: 32
max_num_batched_tokens: 4096
max_model_len: 8192
kv_cache_utilization_limit: 0.75

记录不同场景下的 TTFT、ITL、吞吐、显存使用率。

第二步:提高 decode 并发

逐步增加 max_num_seqs,观察 ITL 是否恶化。如果吞吐提升明显、ITL 稳定,可以继续提高;如果 ITL 波动变大,说明 decode 阶段已经接近瓶颈。

第三步:控制 prefill 冲击

逐步调整 max_num_batched_tokens 和 chunked prefill 参数。目标不是让 prefill 一次跑得最大,而是让 prefill 不阻塞 decode。

第四步:加入真实分布

使用真实日志抽样构造压测数据,至少包含:

  • 输入长度分布。
  • 输出长度分布。
  • 到达率分布。
  • 请求取消比例。
  • 租户分布。
  • 工具调用或 Agent 上下文分布。

第五步:设置保护线

为线上服务设置明确保护线:

slo:
  ttft_p95_ms: 1500
  itl_p95_ms: 120
  queue_wait_p95_ms: 500
  kv_cache_utilization_max: 0.88
  admission_reject_rate_max: 0.01

当指标触发保护线时,优先降级 admission 或长上下文请求,而不是继续扩大 batch。

适用场景与边界

适合的场景

  • 在线聊天、代码补全、Agent 服务等请求持续到达的场景。
  • 输入输出长度差异较大的混合流量。
  • 流式输出服务。
  • GPU 利用率不足、队列等待明显的服务。
  • 需要在吞吐和延迟之间动态平衡的服务。

需要谨慎的场景

  • 极低延迟、低并发的小模型服务 — 调度开销可能抵消收益。
  • 超长上下文占比很高,且没有 chunked prefill 或 prefix caching。
  • 多租户强隔离场景,但缺乏公平调度。
  • 显存已经非常紧张,KV Cache 管理不完善。
  • 使用复杂组合优化(Multi-LoRA、speculative decoding、guided decoding),同时缺乏独立压测。

结论

Continuous Batching 的价值不在于「把 batch size 调大」,而在于把 LLM 生成服务从请求级固定批处理改造成迭代级动态调度。它让完成的请求及时退出,让新请求尽快进入,并配合 KV Cache 管理减少显存浪费。

但它也会把调度、显存、SLO、公平性问题暴露得更明显。真正可上线的 Continuous Batching,需要同时管理 prefill、decode、KV Cache、token budget、请求取消和多租户策略。对于长上下文和 Agent 服务,建议把它与 PagedAttention、chunked prefill、prefix caching 和 SLA-aware admission 一起设计,而不是作为孤立优化项。

主要参考资料

常见问题

Continuous Batching 和普通 batching 的核心区别是什么?
普通 batching 通常要求同一批请求一起开始、一起结束;Continuous Batching 会在每个生成迭代重新调度,把已完成请求移出,把新请求加入,从而减少等待和 padding 浪费。
Continuous Batching 一定能降低延迟吗?
不一定。它通常提升吞吐并改善排队等待,但在长 prompt、长输出、严格 TTFT 或显存紧张场景下,需要结合 chunked prefill、KV Cache 管理和 SLA-aware 调度。
生产环境应该优先调 batch size 还是调调度策略?
不要只调固定 batch size。应同时观察 TTFT、ITL、队列等待、KV Cache 使用率、显存碎片、请求取消率和尾延迟,再决定 max_num_seqs、token budget、prefill/decode 策略。