文章

Continuous Batching 生产实战:让 LLM 服务在高并发下稳定吃满 GPU

本文系统梳理大模型服务中连续批处理的核心原理、调度策略、KV Cache 约束、工程落地步骤与监控指标,帮助团队在高并发场景下提升吞吐并控制尾延迟,覆盖从原理验证到生产上线的完整实践路径。

背景问题

很多团队第一次上线大模型服务时,会沿用传统推理服务的批处理思路:把多个请求凑成一个 batch,然后一次性送进模型执行。这个方法对图像分类、文本分类、Embedding 等相对短小、无状态的模型很有效,但放到自回归 LLM 生成场景里,很快会出现三个问题。

第一,LLM 生成不是一次前向计算完成,而是 prefill + decode 两个阶段。prefill 处理完整输入提示词,decode 则按 token 逐步生成输出。第二,不同请求的输入长度和输出长度差异很大,同一个 batch 里可能有人很快结束,有人还要继续生成几百个 token。第三,每个请求都会持有不断增长的 KV Cache,GPU 显存不只被模型权重占用,还被动态请求状态持续消耗。

如果仍然按请求级别做静态 batch,就会出现典型的 head-of-line blocking:短请求必须等长请求结束,新请求也必须等当前 batch 全部完成。GPU 表面上在工作,但实际吞吐、尾延迟和显存利用率都不稳定。

Continuous Batching(连续批处理) 的核心目标,就是把批处理粒度从”请求级”下沉到”迭代级”或”token 级”。每完成一次 decode 迭代,就把已经结束的请求移出执行集合,把等待队列中的新请求补进来,让 GPU 尽量持续处理有效 token,而不是等待整个旧 batch 自然结束。

核心原理:从请求级批处理到迭代级调度

传统静态批处理更像这样:

collect requests -> build batch -> run until every request finishes -> return all results

在 LLM 里,这种方式会让短输出请求被长输出请求拖住。连续批处理改成:

while server is running:
    admit new requests if token and memory budget allow
    run one prefill or decode step for selected sequences
    stream generated tokens
    remove finished sequences
    keep unfinished sequences in the active set

这个变化看起来只是调度层面的优化,但对生产服务影响很大:

  • 新请求不必等待整个 batch 结束,只需要等下一个调度点。
  • 完成请求可以及时释放资源,减少无效占用。
  • GPU 执行更连续,吞吐通常比纯请求级批处理更稳定。
  • 队列策略可以更细,例如把短请求、长 prompt、低优先级后台任务拆开管理。

ORCA 论文把这类思路称为 iteration-level scheduling,即调度器以模型迭代为粒度调用执行引擎,而不是以完整请求为粒度调度。

Prefill 和 Decode 的资源画像不同

连续批处理不能只看”请求数”,还要区分两类 token:

类型特点瓶颈
Prefill token来自输入 prompt,并行度高,一次性消耗大量算力和显存带宽计算密集
Decode token每轮每个活跃序列通常只生成一个或少数 token内存带宽受限

因此,生产调度器通常不会只设置 max_batch_size,而会引入更贴近 LLM 的预算:

max_num_seqs: 128            # 同时活跃序列数
max_num_batched_tokens: 8192 # 单次调度允许处理的 token 总量
max_model_len: 32768         # 单请求最大上下文长度
kv_cache_blocks: 40000       # 可分配的 KV cache block 数
prefill_chunk_size: 2048     # 长 prompt 是否分块进入执行

一个常见误区是只追求更大的 batch。对 LLM 来说,batch 大并不一定更好。batch 过大可能导致 prefill 排队变长、decode 迭代被长 prompt 打断、KV Cache 接近水位线,最后吞吐上去了,但用户看到的首 token 延迟和 p99 延迟明显变差。

KV Cache 是连续批处理的真正约束

LLM 服务的显存不是只有模型权重。每个请求在生成过程中都会保存历史 token 的 key/value 状态,也就是 KV Cache。请求越多、上下文越长、输出越长,KV Cache 占用越大。

这也是为什么连续批处理经常和 PagedAttention、KV block 管理、prefix caching、KV cache quantization 一起讨论。调度器想要持续补入新请求,前提是 KV Cache 管理足够细,不会因为碎片化、预留过大或复制过多而提前触发 OOM。

PagedAttention 的关键思想,是把每个序列的 KV Cache 切成固定大小的块,逻辑上连续,物理上可以不连续。这样可以按需分配、复用和释放 KV block,降低碎片化,让更多活跃请求同时存在于 GPU 服务中。

架构流程

一个面向生产的连续批处理服务,通常可以拆成下面几层。

1. API Router

负责接收请求、校验参数、设置超时、标记租户、任务类型和优先级。它不应该只是简单转发,而要给后面的调度器提供足够的元信息。

建议至少记录:

  • 租户或业务线
  • 模型名称和版本
  • 输入 token 数
  • 最大输出 token 数
  • 是否流式输出
  • 优先级
  • SLA 类型
  • 可接受超时时间

2. Admission Control

准入控制决定请求能否进入等待队列。它需要同时看请求数、token 数、KV Cache 预算和当前队列等待时间。

def admit(request, state):
    estimated_total_tokens = request.input_tokens + request.max_output_tokens
    if state.waiting_requests > LIMIT_WAITING_REQUESTS:
        return Reject("queue is full")
    if state.active_sequences >= LIMIT_ACTIVE_SEQS:
        return Wait()
    if state.kv_blocks_free < estimate_blocks(estimated_total_tokens):
        return Wait()
    if request.deadline_ms < state.estimated_wait_ms:
        return Reject("deadline cannot be met")
    return Accept()

这里的重点不是公式多复杂,而是不要让服务”无条件收请求”。无限收请求只会把压力转移到队列里,最终表现为超长 TTFT、批量超时和雪崩式重试。

3. Prefill Queue 与 Decode Queue

prefill 和 decode 应该分开观察、分开调度。长 prompt 的 prefill 如果直接占满一次调度预算,会影响正在流式输出的 decode 请求,让用户感觉输出突然卡住。

常见策略包括:

  • Chunked prefill:把超长输入拆成多个片段进入调度,避免一次性阻塞 decode。
  • Decode-first 或 mixed policy:在交互式场景中优先保障 decode 的稳定输出。
  • Long prompt lane:把超长文档总结、代码库分析等请求放到独立队列或独立实例。
  • Background lane:批处理任务、离线生成、低优先级任务不要和在线聊天请求混跑。

4. Token Scheduler

调度器每轮选择一批”下一步要执行的 token”。它的输入不是一组完整请求,而是一组候选序列和预算约束。

while True:
    release_finished_sequences()
    update_kv_cache_watermark()
    candidates = []

    # 1. keep active decode streams moving
    candidates += pick_decode_sequences(
        max_sequences=decode_seq_budget,
        priority_policy="deadline_then_fair_share",
    )

    # 2. fill remaining token budget with prefill chunks
    candidates += pick_prefill_chunks(
        max_tokens=remaining_token_budget(),
        chunk_size=prefill_chunk_size,
    )

    # 3. execute one iteration
    outputs = model_executor.forward(candidates)

    # 4. stream tokens and update sequence state
    publish_outputs(outputs)
    update_sequence_state(outputs)

关键点是:每一轮都重新选择工作集。这让系统可以在请求完成、超时、取消、抢占、KV Cache 压力变化时快速调整,而不是被一个旧 batch 锁死。

5. KV Cache Manager

KV Cache 管理器负责分配、复用、释放和监控 KV block。它至少需要回答四个问题:

  1. 当前还能接纳多少新序列?
  2. 长上下文请求是否会挤占在线短请求?
  3. 请求取消后 KV block 是否及时释放?
  4. prefix caching、beam search、多候选生成是否会共享或复制 cache?

生产中建议把 KV Cache 水位线作为调度器的一等输入,而不是只等 OOM 后重启进程。

工程落地要点

不要只配置最大并发数

很多服务只配置 max_concurrent_requests,但连续批处理真正需要的是多维预算:请求数、序列数、prefill token、decode token、KV block、队列等待时间和 SLA deadline。

推荐初始配置方式:

serving:
  max_active_sequences: 128
  max_waiting_requests: 512
  max_batched_tokens: 8192
  max_model_len: 32768
  prefill_chunk_size: 2048
  queue_timeout_ms: 30000
  stream_idle_timeout_ms: 10000
  kv_cache_high_watermark: 0.88
  kv_cache_reject_watermark: 0.95

这些值不能直接照抄。不同模型、GPU、量化方式、上下文长度、业务流量分布都会改变最优点。上线前必须用真实 prompt 长度分布和输出长度分布压测。

给不同任务拆队列

同一模型服务通常会同时承载多类请求:聊天、RAG 问答、长文档总结、代码生成、Agent 工具调用、离线批量生成。它们的 token 分布和用户体验目标完全不同。

建议至少拆成三类:

队列类型典型场景关注重点
interactive在线聊天、客服、CopilotTTFT、流式稳定性、p95
long-context长文档总结、代码库分析prefill 成本、KV Cache 压力
batch离线生成、批量改写、数据标注吞吐、成本

不同队列可以共享模型权重,但不应该共享完全相同的调度策略。

分开监控 TTFT 和 TPOT

LLM 延迟不能只看总耗时。至少要拆成:

指标含义
TTFT(Time To First Token)用户等到第一个 token 的时间
TPOT(Time Per Output Token)生成阶段每个 token 的平均耗时
E2E latency请求从进入到完成的总耗时
queue delay请求进入队列到开始执行的等待时间
prefill time处理输入 prompt 的耗时
decode time生成输出 token 的耗时

连续批处理配置不当时,常见现象是 tokens/s 看起来很好,但 TTFT 或 p99 明显恶化。只看吞吐会掩盖用户体验问题。

处理取消、超时和客户端断开

流式输出场景中,用户关闭页面、网络断开或上游服务超时都很常见。如果服务端没有及时取消请求,KV Cache 会继续占着,decode 也会继续浪费 GPU。

建议要求:

  • API 层能感知客户端断开。
  • 调度器能把取消请求从 waiting 和 active 集合移除。
  • KV Cache block 能及时释放。
  • 取消请求计入指标,而不是静默丢弃。
  • 上游超时要和模型服务超时一致,避免”客户端早走,GPU 继续跑”。

用真实流量压测,而不是只测空 prompt

连续批处理的效果高度依赖流量形态。压测数据至少应该覆盖:

  • 输入 token 分布:短 prompt、中 prompt、长 prompt。
  • 输出 token 分布:短回答、长回答、提前 stop。
  • 并发模型:突发流量、稳定流量、租户混跑。
  • 流式比例:SSE/WebSocket 与非流式请求。
  • 取消比例:用户中断、上游超时、主动停止生成。
  • RAG 场景:上下文长度和引用数量变化。

如果只用固定长度 prompt 测吞吐,很容易得出过度乐观的结果。

适用场景

适合连续批处理的场景:

  • 在线聊天、客服、代码助手等高并发流式生成
  • RAG 问答,其中输入长度差异明显,输出长度不可预测。
  • 多租户 API 服务,需要在吞吐、隔离和 SLA 之间平衡。
  • 私有化大模型网关,需要在有限 GPU 上承载多个业务。
  • 离线批处理与在线请求共存,但可以通过队列和优先级隔离。

不太适合的场景:

  • 请求量很低,GPU 本身不饱和。
  • 所有请求长度非常一致,静态批处理已经足够稳定。
  • 极端追求单请求最低延迟,且允许牺牲吞吐。
  • 业务无法接受队列等待和动态调度带来的延迟波动。

常见误区

误区一:batch 越大越好

batch 变大通常能提高吞吐,但也可能提高排队时间和尾延迟。生产目标不是单纯最大化 tokens/s,而是在 SLA 约束下最大化有效吞吐。

误区二:连续批处理能自动解决 OOM

连续批处理只是调度策略,不能替代 KV Cache 管理。没有 KV block 预算、高水位保护和取消释放机制,系统仍然可能在长上下文流量下 OOM。

误区三:只看平均延迟

平均延迟会掩盖长尾。在线 LLM 服务更应该看 p95、p99、TTFT、TPOT 和超时率。

误区四:prefill 和 decode 可以混在一起随便调

长 prefill 会阻塞 decode,decode 抖动会影响流式体验。高质量服务通常需要 chunked prefill、decode 优先或多队列策略。

误区五:所有业务共用一个队列

长文档总结、聊天、Agent 工具调用和离线生成的资源画像不同。共用一个队列会互相污染 SLA。

上线检查清单

容量与配置

  • 已测量真实输入 token 和输出 token 分布。
  • 已设置最大活跃序列数、最大等待请求数、最大批处理 token 数。
  • 已设置 max model length,避免单请求无限占用 KV Cache。
  • 已设置 KV Cache 高水位和拒绝水位。
  • 已决定是否启用 chunked prefill。

调度与隔离

  • 已区分 interactive、long-context、batch 等业务队列。
  • 已设置优先级、deadline 或公平调度策略。
  • 已处理请求取消、客户端断开和上游超时。
  • 已验证长 prompt 不会显著阻塞在线 decode。

监控与告警

  • 已监控 TTFT、TPOT、E2E latency、queue delay。
  • 已监控 prefill tokens/s、decode tokens/s、GPU 利用率。
  • 已监控 KV Cache block 使用率、释放速度和 OOM。
  • 已记录请求取消率、超时率、拒绝率。
  • 已按队列、模型、租户、接口维度拆分指标。

灰度与回滚

  • 已支持按模型、路由或租户关闭连续批处理。
  • 已准备降级到更保守的并发和 token 预算。
  • 已准备突发流量下的限流和排队策略。
  • 已用真实流量回放验证 p95/p99。
  • 已准备故障复盘所需的调度日志和指标快照。

FAQ

是否应该自己实现连续批处理?

一般不建议从零实现。调度器、KV Cache、CUDA kernel、streaming、取消释放、分布式并行都会带来很高复杂度。多数团队更适合基于 vLLM、SGLang、TGI、TensorRT-LLM 等成熟推理引擎做配置、封装和观测。

连续批处理和 PagedAttention 是同一个东西吗?

不是。连续批处理是调度策略,PagedAttention 是 KV Cache 内存管理与 attention 执行方式。两者经常一起出现,因为连续批处理需要同时容纳更多活跃序列,而这会放大 KV Cache 管理压力。

为什么开启后吞吐提升,但用户体验变差?

常见原因是调度目标只优化吞吐,没有把 TTFT、decode 稳定性、队列等待和长 prompt 干扰纳入约束。需要重新拆队列、限制 prefill、设置高水位保护,并用 p95/p99 作为上线门槛。

参考资料

  1. ORCA: A Distributed Serving System for Transformer-Based Generative Models, USENIX OSDI 2022 — https://www.usenix.org/conference/osdi22/presentation/yu
  2. Efficient Memory Management for Large Language Model Serving with PagedAttention, SOSP 2023 — https://arxiv.org/abs/2309.06180
  3. vLLM Documentation — https://docs.vllm.ai/en/latest/
  4. Hugging Face Text Generation Inference Documentation — https://huggingface.co/docs/text-generation-inference/en/index
  5. Hugging Face TGI Architecture — https://huggingface.co/docs/text-generation-inference/en/architecture
  6. NVIDIA Triton Dynamic Batching & Concurrent Model Execution — https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/tutorials/Conceptual_Guide/Part_2-improving_resource_utilization/README.html
  7. Slice-Level Scheduling for High Throughput and Load Balanced LLM Serving — https://arxiv.org/abs/2406.13511

常见问题

Continuous Batching 和普通动态批处理有什么区别?
普通动态批处理通常在请求级合并一次执行,连续批处理会在自回归生成的每个迭代步骤后移除完成请求并补入新请求,更适合输出长度不确定的 LLM 服务。
连续批处理是否一定会降低单个请求延迟?
不一定。它主要提升 GPU 利用率和总体吞吐,但如果准入控制、prefill 切分和队列等待配置不当,可能推高 TTFT 或尾延迟。
上线连续批处理最应该先监控什么?
建议同时监控 TTFT、TPOT、p95/p99 延迟、队列等待、decode tokens/s、KV cache 使用率、OOM 次数和取消请求比例。