批量推理与 Continuous Batching:吞吐提升的核心原理
批量推理(Batch Inference)是推理服务降本提效的核心手段,但传统静态批处理在 LLM 场景严重浪费 GPU——长短请求混在一批里,短请求做完只能等长请求。Continuous Batching 解决了这个问题,它让 GPU 几乎没有等待间隙。
为什么需要批处理?
GPU 的强项是并行矩阵运算。单个推理请求只用到 GPU 算力的一小部分,大量算力在等待。把多个请求合并成一批送入 GPU,矩阵运算的规模扩大,GPU 利用率从 10–20% 提升到 60–80%。
代价是:每个请求的延迟会略微上升(等凑批的时间)。批大小和延迟之间存在天然权衡。
拿具体数字过一遍会更有感觉。假设你在一张 A100(40GB)上跑 7B 模型,单请求推理时,GPU 计算单元大部分时间在等显存搬运数据(访存瓶颈,也就是常说的 memory-bound),矩阵乘法的算力根本吃不满。把 32 个请求打包成一批,矩阵从 [1, hidden_dim] 变成 [32, hidden_dim],每次显存搬运带来的计算量翻了 32 倍,但搬运耗时几乎不变——这就是批处理能把利用率从两位数拉到七八十的根本原因:不是 GPU 变快了,是同样的访存开销分摊到了更多计算上。这也解释了为什么小模型(1B 以下)批处理增益不如大模型明显:小模型本来访存占比就低,天花板矮。
静态批处理的问题
传统静态批处理(Static Batching)的逻辑是:
- 等待足够多的请求积累到一批(batch size = N)
- 将整批送入 GPU 前向计算
- 等所有请求都生成完成,才释放这批请求,开始下一批
问题:LLM 的请求输出长度差异极大。一批请求里,短请求(输出 10 token)很快完成,但要等最长请求(输出 500 token)完成后才能接受新请求。这段等待时间 GPU 在做无谓计算(对短请求的 padding 填充)。
| 情况 | 静态批处理 GPU 利用率 |
|---|---|
| 批内请求长度均匀 | 较好(60–70%) |
| 批内请求长度差异大 | 差(20–40%) |
| 长短请求随机混合(真实场景) | 通常 30–50% |
我早期用某个自己攒的 HF Transformers 推理服务扛过一波压测流量,观察到的现象是:只要有一条请求要求生成长文本(比如让模型写一篇 800 字总结),同批次里其他本该秒回的短请求(比如”你好”)也会被拖到几秒后才返回。用户侧的直观反馈是”响应时快时慢,没有规律”——排查下来根因就是这张表里的第三行:批内混着长短请求,短请求做完了也只能干等,GPU 在对已完成序列做无意义的 padding 计算,白白耗电。这不是代码写错了,是静态批处理这套调度逻辑天生带的毛病,换框架比改代码更实际。
Continuous Batching:迭代级调度
Continuous Batching(也称为 In-flight Batching 或 Iteration-level Scheduling)的核心思路:
不在请求级别调度,而在每一个 decoding step(迭代)级别调度。
每完成一个 decoding step:
- 检查哪些序列已经生成完毕(输出了 EOS token)
- 立刻将这些序列从批次中移除
- 立刻从等待队列中拉入新请求,填满空出的批次槽位
- 继续下一个 decoding step
结果:GPU 的每一步计算都是”满载”的,没有等待间隙,没有 padding 浪费。
这里有个容易被忽略的细节:LLM 推理其实分两个阶段——prefill(把输入 prompt 一次性算完,生成第一个 token,计算密集)和 decode(逐 token 生成,每步只算一个 token,访存密集)。这两个阶段的计算特征完全不同,如果调度器把新请求的 prefill 硬塞进正在批量 decode 的迭代里,会让这一步迭代耗时突然变长,正在 decode 的其他请求跟着卡顿——这就是所谓的”生成阻塞”(generation stall)。vLLM 后来引入的 chunked prefill(分块预填充)就是专门治这个病:把长 prompt 的 prefill 拆成几个小块,分散插到多个 decode 迭代里一起算,让每一步迭代的耗时保持平稳。如果你在压测时发现 P99 延迟有毛刺,且发生时间点和长 prompt 请求到达时间对得上,基本可以判定是这个问题,去查你用的框架版本是否已经支持并开启了 chunked prefill(vLLM 0.4.2+ 默认支持,可用 --enable-chunked-prefill 显式开启,新版本部分场景已默认打开)。
性能对比
| 指标 | 静态批处理 | Continuous Batching |
|---|---|---|
| GPU 利用率 | 30–50% | 70–85% |
| 吞吐(tok/s) | 基准 | 2–4× 提升 |
| 首 token 延迟(TTFT) | 受批等待影响 | 更低(新请求快速入队) |
| 实现复杂度 | 简单 | 需要框架层支持 |
数据来自 vLLM 团队论文(Kwon et al., 2023)及社区压测,具体提升幅度依请求长度分布而异。
这些数字别照单全收,最好在自己的机器上跑一遍验证。vLLM 启动后会暴露一个 /metrics 端点(Prometheus 格式),重点盯三个指标:vllm:num_requests_running(当前批次里正在跑的请求数)、vllm:num_requests_waiting(排队等入批的请求数)、vllm:gpu_cache_usage_perc(KV Cache 显存占用比例)。如果 num_requests_waiting 长期不为零而 gpu_cache_usage_perc 早就顶到 90%+,说明不是调度器的问题,是显存不够用,加显存或者调小 --max-model-len 才是对症的办法;反过来如果显存占用一直很低但吞吐上不去,多半是 --max-num-seqs 设得太保守,批次根本没喂饱。配合 nvidia-smi dmon -s u 看 GPU 利用率曲线,和 num_requests_running 的曲线对照着看,比单看一个”吞吐提升了几倍”的结论靠谱得多。
主流框架支持情况
| 框架 | 是否支持 Continuous Batching | 备注 |
|---|---|---|
| vLLM | 是(核心特性) | 生产推荐首选 |
| SGLang | 是(Radix Attention 增强版) | Agent 场景延迟更优 |
| TGI(Hugging Face) | 是 | HF 生态集成好 |
| TensorRT-LLM | 是 | NVIDIA 生态,需额外配置 |
| Ollama | 否(主要面向单用户本地使用) | 不适合多并发生产 |
| llama.cpp server | 部分支持 | 并发能力有限 |
选型别只看”支不支持”这一列,实际差异在细节上。vLLM 的调度器和内存管理(PagedAttention)打磨得最久,中文社区踩坑资料也最多,出问题好查,这是我优先推它的原因;SGLang 的 Radix Attention 对”多个请求共享同一段前缀”(比如同一个 system prompt 打底的多轮对话、Agent 工具调用链路)做了额外的 KV Cache 复用优化,前缀重复度越高,它比 vLLM 省的显存和算力越明显,如果你的业务是 Agent 类、System Prompt 又长又固定,值得单独压测对比一下;TGI 胜在和 Hugging Face 的模型仓库、transformers 生态无缝衔接,团队已经深度绑定 HF 工具链的话切换成本低。至于 Ollama,它的定位本来就是本地单机跑模型给一个人用,没有针对多请求并发做调度优化,拿它扛线上多用户流量本身就是用错了场景,不是它”性能差”的问题。
使用 vLLM 的最佳实践
vLLM 默认开启 Continuous Batching,但以下参数影响实际效果:
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct \
--max-num-seqs 256 \ # 最大并发序列数
--max-num-batched-tokens 8192 \ # 每次迭代最大 token 数
--gpu-memory-utilization 0.90 # KV Cache 显存比例
--max-num-seqs 和 --max-num-batched-tokens 共同决定了批次的”上限”,应根据显存容量调大而不是用默认值。
三个参数各管一摊,别混着调,我把踩过的坑列一下:
--gpu-memory-utilization:这是显存预留比例,不是”用多少显存”。0.90 意味着 vLLM 会把 90% 的显卡显存都预留给模型权重和 KV Cache(剩下 10% 留给 CUDA 上下文等杂项)。启动时如果报torch.cuda.OutOfMemoryError或者日志里出现CUDA out of memory. Tried to allocate ...,第一反应不是加显卡,是先把这个值调低到 0.80 甚至 0.75 试试——很多时候是同一张卡上还跑着别的进程(比如你自己开了个 Jupyter 占了显存),vLLM 按 0.90 预留时和别的进程抢显存,直接崩了。--max-num-seqs:设得太大不会立刻报错,但会看到吞吐”不增反降”——因为批次里塞的序列越多,每一步要维护的 KV Cache 越多,显存被 Cache 挤占后能同时跑的批次反而变小,调度器忙着抢占(preemption,也就是把跑到一半的请求踢出去重新排队)。vLLM 日志里如果频繁出现Sequence group ... is preempted by PagedAttentionScheduler,就是这个信号,说明并发设置超过了显存能稳定承载的水平,往下调,别死磕默认值。--max-num-batched-tokens:这个值太小会限制单次迭代能塞进去的 prefill token 数量,长 prompt 请求进来时会被拆得很碎,处理变慢;太大又会让某次迭代耗时突然拉长(回到前面说的”生成阻塞”问题)。经验值是设成和你的平均 prompt 长度乘以期望并发数同一量级,具体数字建议先按官方文档给的默认值跑一版压测基线,再逐步调整,不要凭感觉一步到位。
批量推理 vs Batch API
两者容易混淆,但含义不同:
| 概念 | 含义 | 适用场景 |
|---|---|---|
| Continuous Batching | 推理服务内部的调度优化,对用户透明 | 所有在线推理服务 |
| Batch API | 用户显式提交”离线批量任务”,通常有 24h 延迟,价格更低 | 非实时的大批量处理(标注、评估等) |
如果你是服务提供者,关注 Continuous Batching;如果你是调用方想降低成本,可以评估 Batch API 的价格优势。
两者也不是非此即彼,判断标准就一条:这批任务对”结果多久拿到”有没有硬性要求。用户在等对话框里的回复、Agent 在等工具调用结果返回继续下一步——这类场景实时性是刚需,只能走在线接口,享受 Continuous Batching 带来的高吞吐低延迟。而离线打标签、跑评测集、批量生成训练数据这类任务,反正要等,用 Batch API 换更低的单价划算;具体折扣幅度各家不一样,且会调整,下单前去对应服务商的定价页面核实实时价格,别拿这里的经验数字直接去做预算。判断的时候多问自己一句”如果这批任务晚 12 小时出结果,业务会不会受影响”,答案是”不会”就大胆用 Batch API。
常见问题
Continuous Batching 会让单个请求的延迟变高吗?
对于首 token 延迟(TTFT),实际上比静态批处理更好——不需要等一批请求全部到齐。对于总生成时间(TPOT × 输出长度),与静态批处理相近,且 GPU 更满,整体吞吐更高。
我的服务并发很低(< 5),还需要 Continuous Batching 吗?
极低并发时收益有限。此时瓶颈不在批处理效率,而在于单个请求的延迟优化(如更快的解码、更小的模型)。
能手动控制批大小吗?
vLLM 的 Continuous Batching 是动态的,不能直接设置”固定批大小”。可以通过 --max-num-seqs 和 --max-num-batched-tokens 间接限制批次上限,但具体批大小由调度器决定。
服务日志里频繁出现”抢占”(preempted),要紧吗?
偶尔出现不用紧张,说明调度器在动态平衡负载。但如果压测时几乎每个请求都被抢占过一次,说明当前并发设置已经超过显存能稳定支撑的水平,被抢占的请求要么等显存腾出来再重新计算(recompute,代价是这部分 token 白算了一遍),要么被换出到 CPU 内存(swap,代价是多一次数据搬运)。两种代价都会拉高尾延迟。这时候不是加机器,是先把 --max-num-seqs 往下调一档,重新压测看抢占率是否降下来,这个成本比盲目扩容低得多。
换了 Continuous Batching 框架后,为什么感觉不到吞吐提升?
先排除一个最常见的假问题:如果你的请求量本来就很低(同时在跑的请求长期只有一两个),批处理这套机制根本没有发挥空间,因为压根凑不成”批”,这时候看到的性能和静态批处理几乎没差别是正常的,不是配置错了。真正要验证收益,得用压测工具(比如 vLLM 官方自带的 benchmark_serving.py,或者自己写脚本模拟真实并发)打一批有长有短、到达时间随机的请求,观察前面提到的 num_requests_running 指标是不是稳定维持在一个较高水平——如果这个数字长期是 1 或 2,说明真实并发不够,问题不在框架。
延伸阅读:
- 吞吐与并发优化实践:推理吞吐与并发优化:让你的 GPU 不再空转
- KV Cache 显存管理:KV Cache 与显存占用详解
- 算力专题 Hub:算力专题
- 自建推理成本核算:自建推理成本测算指南