2.2 连续批处理与推测解码 本节摘要:PagedAttention 解决了显存碎片,但要榨干 GPU 利用率还差两个关键优化。连续批处理(continuous batching)打破了传统静态批处理"一个请求慢、整个 batch 等"的困局——请求在 token 级别动态加入和退出,GPU 始终保持满载,吞吐提升两到四倍。推测解码(speculative decoding)走另一条路降延迟:用一个小而快的草稿模型先猜几个候选 token,大模型一次性并行验证这些猜测,猜对的直接采纳,把 decode 的串行过程变成部分并行。两者一个提吞吐、一个降延迟,可以叠加使用。
本节摘要:PagedAttention 解决了显存碎片,但要榨干 GPU 利用率还差两个关键优化。连续批处理(continuous batching)打破了传统静态批处理"一个请求慢、整个 batch 等"的困局——请求在 token 级别动态加入和退出,GPU 始终保持满载,吞吐提升两到四倍。推测解码(speculative decoding)走另一条路降延迟:用一个小而快的草稿模型先猜几个候选 token,大模型一次性并行验证这些猜测,猜对的直接采纳,把 decode 的串行过程变成部分并行。两者一个提吞吐、一个降延迟,可以叠加使用。
阅读完本节,你应当能够:
假设你用一个 batch=8 的静态批处理服务。8 个请求一起开始生成,但它们的长短天差地别——请求 A 只生成 20 token 就结束了,请求 H 要生成 800 token。问题来了:A 结束后,它的 GPU 算力和显存空着,但因为 batch 是静态的,要等 H 也结束才能开下一批。结果就是 GPU 在大部分时间里只跑了 1–2 个请求,利用率可能不到 20%。
这就是静态批处理(static batching)的核心痛点:木桶效应——最长的请求决定整个 batch 的时长,短的请求结束后资源闲置。batch 越大,浪费越严重;batch 小了又吃不饱。
连续批处理(continuous batching,也叫 iteration-level scheduling 或 dynamic batching)打破了这个困局。它的核心思想是:不在请求级别批处理,而在 token 级别。每当某个请求生成完一个 token,调度器检查有没有新请求在等,有就立刻把它加进 batch。某个请求生成结束,立刻移出 batch,资源给新请求。这样 batch 永远是满的,GPU 利用率拉满。
而推测解码解决的是另一个问题。即使 GPU 利用率满了,单个请求的 decode 还是串行的——一次只能生成一个 token,因为下一个 token 依赖上一个的输出。推测解码用一个"作弊"办法打破这个串行:让小模型先猜接下来 4 个 token,大模型一次性验证这 4 个猜测(因为是并行验证,只花一次前向传播的时间),猜对的直接采纳。如果小模型猜得准,相当于一次decode 生成了多个 token。
先看两者的时序对比,差异一目了然:
静态批处理的生命周期是"组 batch → 全部跑完 → 组下一批",中间不接新请求。连续批处理的生命周期是"持续运行,每个 token 迭代后检查并调整 batch 成员"。后者能保持高利用率的关键是:得益于 PagedAttention 的分块显存管理,加一个新请求只是从块池领几块、改一下页表,不需要预留连续大空间,也不影响正在跑的其他请求。
连续批处理的调度器每个迭代步做三件事:
这里有个细节:新加入的请求要先做 prefill(处理它的整个 prompt),这和正在 decode 的请求的计算模式不同。早期的连续批处理实现会让 prefill 和 decode 分开做(要么先处理完所有 prefill 再 decode,要么轮流),但这会造成卡顿。后来的改进(比如 vLLM 的 chunked prefill)把长 prompt 的 prefill 切成小块,和 decode 混在一起迭代,让延迟更平滑。
推测解码(也叫 speculative sampling 或 draft-verify)的核心是利用一个事实:大模型一次前向传播算多个 token 的概率分布,和算一个的成本差不多(只要它们在一次 attention 里)。所以与其让大模型一个个串行生成,不如让它一次验证多个候选。
加速来自哪里?假设草稿模型猜 4 个 token,平均接受 2.5 个。那么大模型每做一次前向传播,平均推进了 2.5 个 token(而不是 1 个)。如果草稿模型足够快(它的前向成本远低于大模型),整体 decode 速度能提升 1.5 到 2.5 倍。
推测解码的收益高度依赖"接受率"——草稿模型猜对的概率。这取决于草稿模型和目标模型的分布有多接近。理想情况是草稿模型是目标模型的蒸馏版,二者分布接近,接受率高。如果草稿模型太弱(和目标分布差很远),接受率低,加上草稿模型本身的前向开销,可能反而变慢。
| 草稿模型选择 | 接受率 | 草稿开销 | 净收益 |
|---|---|---|---|
| 目标模型的小型蒸馏版 | 高(70%+) | 中 | 明显加速 |
| 同家族更小参数模型 | 中(50%) | 低 | 有收益 |
| 任意小模型 | 低(20%) | 低 | 收益微薄甚至倒退 |
| n-gram 统计模型 | 不稳定 | 极低 | 简单文本有效 |
⚠️ 常见坑:别拿一个和目标模型完全不同家族的小模型当草稿。比如目标是某主流大模型,草稿用一个不相干的小模型,二者分布差异巨大,接受率可能只有 10–20%,草稿的前向开销都赚不回来。草稿模型最好和目标同源,或者直接用目标模型前面几层的浅版本。
落地连续批处理,几个参数要调好:
最大 batch 大小:决定同时能服务多少请求。受显存池大小约束(每个请求占若干 KV Cache 块)。设太大显存溢出,设太小吞吐上不去。建议从显存允许的最大值开始,压测观察延迟,找到吞吐和延迟的平衡点。
最大等待 token 数(max num batched tokens):限制单次迭代处理的 token 总数(包括新请求的 prefill)。这是为了防止单个超长 prompt 的 prefill 把整个 batch 卡住。常见值几千到一万。
prefill 调度策略:新请求来时是立刻 prefill 还是攒一批。立刻 prefill 延迟低但可能打断正在 decode 的请求;攒一批吞吐高但首字延迟高。多数场景用混合策略——短 prompt 立刻处理,长 prompt 切块混入。
# 概念性调度器骨架 class ContinuousBatcher: def __init__(self, max_batch, max_tokens): self.max_batch = max_batch self.max_tokens = max_tokens self.running = [] # 正在decode的请求 self.waiting = [] # 待prefill的请求 self.engine = InferenceEngine() def step(self): # 1. 移除已完成的请求 self.running = [r for r in self.running if not r.done] # 2. 尽量加入新请求 while (len(self.running) < self.max_batch and self.waiting and self._token_budget_ok()): new_req = self.waiting.pop(0) self.engine.add_request(new_req) # prefill + 接入页表 self.running.append(new_req) # 3. 一次前向,每个running请求各decode一个token self.engine.decode_step(self.running) def _token_budget_ok(self): # 估算加入下一个请求后的token总数是否超限 return True
用推测解码要决定两件事:草稿模型选什么、猜几个 token。
草稿模型:最优是目标模型的蒸馏版(同架构小参数)。如果没有蒸馏版,选同家族最小的模型。极端情况可以用 n-gram 模型(基于统计预测下一个词),它几乎零开销,对重复性高的文本(代码、模板文档)接受率不错,但对开放生成效果差。
猜测长度:猜太少(1–2 个)收益不明显;猜太多(8+)一旦中间猜错后面全废,浪费草稿算力。实践中 4–6 是常见区间,要根据实际接受率调。
推测解码和连续批处理可以叠加:batch 里每个请求都可以各自做推测解码,草稿模型对整个 batch 一次性猜。但实现复杂度高,且接受率不稳时收益波动大,建议先把连续批处理做扎实,再考虑叠加推测。
| 优化技术 | 主要提升 | 适用场景 | 不适用场景 |
|---|---|---|---|
| 连续批处理 | 吞吐(2–4倍) | 多并发、请求长短不一 | 单请求、请求长度整齐 |
| 推测解码 | 单请求延迟(1.5–2.5倍) | 对单请求 ITL 敏感、有合适草稿模型 | 无合适草稿模型、开放创意生成 |
| 前缀缓存 | prefill 重复利用 | 共享系统提示的多对话 | 每个请求 prompt 都不同 |
| Chunked prefill | TTFT 平滑 | 混合 prefill+decode 负载 | 纯 decode 场景 |
💡 关键直觉:别迷信单一技术。真实生产系统通常是"连续批处理打底(保吞吐)+ 前缀缓存(省重复计算)+ 必要时上推测解码(救敏感场景的延迟)"的组合拳。先用最基础连续批处理把基线拉起来,再针对性补强。
下一节我们把前面讲的优化原理落到具体工具上,对比 vLLM 和 TensorRT-LLM 两大主流引擎的设计差异、性能特点和选型建议。
连续批处理进入主流视野的方式颇为偶然。2022 年斯坦福团队做 flexflow serving 实验时发现,把调度粒度从请求级降到迭代级只需要几百行调度器改动,吞吐却翻了几倍,这个"性价比"让它迅速被所有引擎吸收。但工程界很快发现它的隐性前提:动态进出要求 KV Cache 能按块灵活分配,传统连续预留的显存布局根本腾不出位置,所以连续批处理和 PagedAttention 实际上是绑在一起落地的——这提醒我们,读优化清单时别把互相依赖的项当成可独立摘的果子。
推测解码的历史更有戏剧性。它的理论根基是 2023 年初几乎同时出现的两篇工作:一篇给出了"并行验证 + 按分布修正"的采样等价性证明,保证输出分布与直接采样完全一致;另一篇(Medusa)干脆放弃外部草稿模型,在大模型头上加几个轻量解码头自己猜自己验。前者确立了"不掉精度"的口碑,后者解决了"上哪找一个分布相近的小模型"的麻烦——维护两套模型版本对齐是真实的运维负担。目前的实践共识:同源蒸馏出的草稿模型接受率最高,跨家族借用小模型要谨慎;草稿长度别贪,4 到 8 个 token 之后的接受率通常断崖下跌,验证成本反而超过节省。
边界条件也值得记两条。第一,吞吐受限的系统上推测解码可能负优化:验证占用的是本可以服务其他请求的算力,GPU 已经满载时,加速单请求等于挤压别人,系统吞吐反降。它真正的主场是延迟敏感、算力有余的场景(在线对话的低峰期、私有化部署)。第二,连续批处理对请求长度分布敏感:如果流量几乎全是等长短请求,静态批处理的浪费本来就小,动态调度的收益会被调度开销吃掉一部分。上线前拿真实流量分布做回放压测,永远比抄默认参数靠谱。