第 05 章

把吞吐做上去 · 连续批处理 · 调度 · 路由

GPU 闲着就是烧钱,怎么把它喂饱:连续批处理(动态拼批,不等整批结束)、请求调度、跨 replica 路由与负载均衡,以及吞吐与延迟之间那个根本张力。前置:Ch4。

约 2 小时
GPU 一闲着就是在烧钱:这一章讲怎么把它持续喂饱,以及喂饱过程中那个绕不开的张力

这是系统设计路书的第 5 章,约 2 小时,纯 Mode A,是第三站「推理服务」主脊的第二章。

上一章你把一次请求解剖开了,还看到一个扎眼的数字:decode 阶段 GPU 的算力利用率只有约三成。GPU 是这门生意里最贵的东西,七成闲置就是七成的钱在白烧。这一章讲怎么把那七成抢回来。但把 GPU 喂饱不是一句口号,它会立刻撞上三个具体的工程问题:一批请求怎么拼才不浪费、一台机器内部 prefill 和 decode 谁先算、请求该发给成百上千台机器里的哪一台。我们就沿着这三个问题走一遍,你会发现它们最后都收敛到同一个张力 —— 吞吐和延迟,常常是按下葫芦浮起瓢。

本章四节:

  • 静态批处理的墙:为什么 GPU 喂不饱(20 min)
  • 连续批处理:把批的粒度降到每一步(30 min)
  • 调度:prefill 和 decode 抢同一个 GPU(30 min)
  • 路由:从一台机器到一个 fleet(30 min)

0. 把那闲置的七成抢回来

先把上一章的结论摆到台面上:decode 阶段 GPU 算力利用率只有约三成,因为它是 memory-bound 的,每一步搬一大堆数据只算一个 token,昂贵的计算单元大部分时间在干等。而 GPU 是 serving 成本里最大的一块,这意味着你每跑一秒 decode,就有大约七成的算力在空转烧钱,整个推理服务工程里最持久的一条主线,就是想方设法把这闲置的算力填满,让每一个 GPU 周期都在干有用的活。那直觉上的解法很简单:既然一个请求喂不饱 GPU,那就同时塞好几个请求,让它们的计算拼在一起,把算力占满。这个想法本身是对的,它叫批处理(batching),是几乎所有高吞吐系统的看家本领;但 LLM 推理有它自己的脾气,最朴素的拼法会让 GPU 还是大量空转。这一章就从最朴素的批处理为什么不够好讲起,一步步走到现代 serving 系统真正在用的那套,顺带认识它内部那台一刻不停做权衡的调度器。

1. 静态批处理的墙:为什么 GPU 喂不饱

最朴素的拼法是这样:攒够一批请求,把它们打包成一个 batch 一起送进 GPU,等这一批全部生成完,再攒下一批。这叫静态批处理(static batching),批的大小从开始到结束固定不变。它在很多传统场景里工作得很好,但放到 LLM 上,有一个致命的水土不服:一批请求里,每个请求要生成多长,事先根本不知道,而且差得很远。有的用户问一句话,模型回十个字就结束;有的让它写一篇长文,要生成五百个 token。问题就出在这里:静态批处理必须等整批里生成最长的那个请求结束,才能开始下一批。于是那些早早就生成完的请求,占着的 GPU 槽位只能空在那儿干等,等那个最长的慢慢吐完。这段空等就是纯粹被浪费掉的 GPU 算力,工程上形象地叫它气泡(bubble)。更糟的是,在整批结束之前,新到达的请求一个都上不了车,只能在队列里干等,排队延迟被那个最长的请求一并拖高。算力在浪费,延迟在变差,两头不讨好。

这里要诚实地补一句,免得你把这堵墙想得比实际更普遍:静态批处理的浪费,严格说取决于生成长度的方差。如果凑巧一批请求生成的长度都差不多,那大家几乎同时结束,气泡很小,静态批处理其实没那么差。但真实流量恰恰是高方差的,有人问一句,有人写长文,长度天差地别,所以气泡在生产里总是又大又普遍。换句话说,LLM 流量那种长短极不齐的特性,正是静态批处理这堵墙之所以高的原因。

把这堵墙的根源点破,你就看清了出路。问题不在于拼批这个思路错了,而在于批的粒度太粗:它以整批请求为单位,一锁就是从头到尾,中途不能动。如果能把粒度变细,细到每生成一步就能重新审视一次这个批、把空出来的位置立刻补上人,气泡就没有了。下一节讲的连续批处理,做的正是这件事。

2. 连续批处理:把批的粒度降到每一步

现代 serving 系统真正在用的,叫连续批处理(continuous batching),它还有一个更准确的名字,叫迭代级调度(iteration-level scheduling)。核心想法只有一句话:不以整批请求为单位,而以每一步 decode 为单位重新组批。每生成完一步,调度器就检查一遍当前这个批:哪些请求已经生成结束了,把它腾出来的槽位空出来;队列里有没有新请求在等,有就立刻把它填进这个空槽,下一步一起算。于是这个批不再是锁死的,而是一个每一步都在吐故纳新的动态批。

这么一改,上一节那两个毛病同时消失了。早结束的请求不再留下气泡,因为它一走,槽位下一步就被新请求占上,GPU 持续满载;新请求也不必等整批结束才能上车,任何一步都可以加入。下面这张图把静态和连续两种批处理摆在一起,你扫一眼就能看到那个区别 —— 静态那边一片灰色的空等,连续这边被新请求(图中暖色)填得满满当当。

静态批处理 · 等最长的那个结束,先完成的干等请求 A请求 B请求 C请求 DGPU 空转(浪费)↑ 整批结束,下一批才能上连续批处理 · 槽位一空,下一步立刻补新请求请求 A请求 B请求 C请求 Damber = 补进来的新请求正在跑的请求补进来的新请求GPU 空转 · 时间(迭代)→
静态批处理 vs 连续批处理。静态:整批要等生成最长的那个结束,先完成的请求把 GPU 槽位空着干等(灰色气泡 = 烧钱),新请求还得等整批结束才能上车。连续批处理(Orca 首创·迭代级调度,vLLM 普及):哪个槽位一空,下一步立刻填进排队的新请求,GPU 持续满载 · 对应正文 第 1 节–第 2 节

这个技术不是凭空冒出来的,它是 GPU 利用率这堵墙逼出来的产物。它的首创是 2022 年的 Orca(发表在系统顶会 OSDI),论文里管它叫迭代级调度;而真正让它成为全行业标配的,是你在第一章见过的 vLLM:它把连续批处理和自己的 PagedAttention(高效管理 KV cache 显存)组合在一起,成了开源推理引擎的事实标准。今天你用到的几乎每一个 serving 框架,底层都在跑某种形式的连续批处理。

它到底能带来多大的提升?这里要给你一个诚实的答案,因为这个数字在网上经常被夸大。你会看到提升 23 倍甚至 36 倍这样的说法,但那些惊人的数字往往把别的因素一起算进去了,比如跟一个完全不拼批的基线比,或者把 PagedAttention 的显存收益也算进来。如果只看连续批处理这一项本身、跟静态批处理比,在前面说的高方差流量上,提升通常是数倍这个量级,而且方差越大、提升越明显。把这个数量级记准就够了:它是一次实打实的数倍提升,但不是魔法。

不过,连续批处理也不是想拼多大就拼多大,这里要把上一章埋的那个伏笔接上。你或许记得,拼批能摊薄权重的搬运成本,却摊不薄每个请求各自的 KV cache:批里每多一个请求,就多一整块 KV cache 占显存。所以你能同时拼进一个批的请求数,被显存里还能塞下多少块 KV cache 死死卡住。这就把这一章的吞吐和上一章的显存账连成了一根线:连续批处理负责把空槽填满,但显存决定了一共有多少个槽。吞吐的上限,最终还是写在 KV cache 的显存账上。

3. 调度:prefill 和 decode 抢同一个 GPU

连续批处理需要一个调度器,在每一步决定这个 GPU 接下来到底算什么。听起来简单,但这里藏着一个上一章就埋下的冲突。回想 prefill 和 decode 的脾气:新来的请求要先做 prefill,这是算力密集的;而正在进行中的那些请求要做 decode,这是显存带宽密集的。它们都想用同一个 GPU,调度器必须决定先伺候谁。

这个决定没有免费的午餐。如果调度器优先插入新请求的 prefill,新用户的首字延迟(TTFT)是低了,但正在跟模型对话的那些用户,他们的 decode 被这个大 prefill 打断,字间延迟(TBT)会突然抖一下 —— 你可能见过这种现象:跟 AI 聊着聊着,它吐字突然卡顿一下,很可能就是后台有个带着长 prompt 的新请求插了进来。反过来,如果调度器优先把正在进行的 decode 推下去、让新请求等,那正在对话的人很流畅,但新来的人要等更久才看到第一个字。这正是上一章那两个指标 TTFT 和 TBT 在这里正面相撞:你压低一个,往往就抬高另一个。

化解这个冲突的一个漂亮办法,叫 chunked prefill(分块预填充)。它的想法是:既然一整块大 prefill 会长时间霸占 GPU、把所有人的 decode 都憋住,那就把这块大 prefill 切成若干小块,一块一块地插进正常的 decode 步骤之间去算。这样新请求的 prefill 在稳步推进,正在对话的人也因为 decode 没被长时间打断而保持着平稳的吐字。下面这张图对比了切与不切的差别。

大 prefill 会卡住所有人的 decode —— 切块交错来缓解不切块新请求 prefill(整块占住 GPU)这段时间 decode 全停 → 大家的 TBT 飙升切块prefill 切成小块,插在 decode 之间 → decode 不断流、TBT 平稳decodeprefill
调度的核心冲突:新请求要 prefill(算力密集),正在跑的请求要 decode(显存带宽密集),抢同一个 GPU。上:一整块大 prefill 占住 GPU,期间所有请求的 decode 都停,大家的字间延迟(TBT)飙升。下:把 prefill 切成小块、插在 decode 之间(chunked prefill),token 持续往外吐、TBT 平稳 · 对应正文 第 3 节

这个办法同样有出处:它来自 2024 年的 Sarathi-Serve(也发表在 OSDI),论文里把这种调度叫"无停顿调度",报告的服务容量提升从 2.6 倍到 5.6 倍不等。到今天,chunked prefill 已经成了主流引擎的默认行为 —— 比如 vLLM 在 2025 年的新一代架构里,把它设成了默认开启。你不需要记住这些版本细节,要带走的是这个调度器的本质:它在每一步都在 prefill 和 decode、在 TTFT 和 TBT 之间做实时权衡,而 chunked prefill 是让这个权衡不那么剑拔弩张的一把好刀。

4. 路由:从一台机器到一个 fleet

到这里,前面讲的都还发生在一台机器内部,在一个 GPU(或一组对等的 GPU)上,怎么拼批、怎么调度。但生产规模从来不是一台机器。服务亿级用户,你手里是成百上千个对等的副本(replica),每个都跑着一份模型。于是一个新问题冒出来:一个请求进来,该把它发给哪一个副本?这一层,叫路由(routing)。

最朴素的路由是轮询:来一个请求就轮流发给下一个副本,雨露均沾。但这么做会浪费掉一个巨大的便宜。还记得 KV cache 吗?如果两个请求共享同一段前缀,比如它们用的是同一个很长的 system prompt,那么第二个请求本可以直接复用第一个已经算好的那段 KV cache,省掉重复的 prefill。可前提是,这两个请求得落在同一个副本上,因为 KV cache 是某一台机器显存里的东西,发到别的副本就用不上了;轮询恰恰会把这种能复用缓存的请求随机打散到不同副本,逼着每一台都从头重算,白白浪费。所以生产级的路由是 cache-aware(缓存感知)的:它在均衡负载的同时,尽量把共享前缀的请求送到同一个副本,好让缓存命中。下面这张图画的就是这件事:带同一段前缀的请求被送到同一台暖着对应 KV cache 的副本上。

共享前缀 → 同一副本 → 命中 KV cache请求1 · 前缀A请求2 · 前缀A请求3 · 前缀BRouter看前缀选副本副本1KV cache: 前缀B 暖副本2KV cache: 前缀A 暖副本3KV cache: 空闲↑ 请求2 命中,跳过 prefill
路由:把共享前缀(比如同一段长 system prompt)的请求送到同一个副本,第二个就能命中已经算好的 KV cache、跳过重复 prefill。所以生产里的路由是 cache-aware 的负载均衡 —— 既要均衡负载,又要让能复用缓存的请求落到同一台(随便轮询会把它们打散、各自重算)· 对应正文 第 4 节

这不是纸上谈兵。开源引擎 SGLang(你在第一章见过它,DeepSeek 官方推荐用它部署)有一个叫 RadixAttention 的机制,用一棵基数树自动管理和复用前缀缓存;它在 2024 年底的版本里还加了一个缓存感知的负载均衡器,在多机部署上把吞吐提了约 1.9 倍、缓存命中率提了约 3.8 倍。而这层路由的重要性,从大实验室设的岗位就能看出来:Anthropic 专门有一个 Inference Routing 团队,他们描述自己的系统"坐在 API 接口和推理引擎之间,实时做 fleet 级的效率决策",具体就是路由决策、缓存的放置与淘汰、跨副本的协调。把请求发给哪台机器,本身就是一门需要专门团队去做的工程。

讲到这,你也就站到了这一章的边界上。前面这些(连续批处理、chunked prefill、缓存感知路由)解决的都是怎么在一台或一组对等机器内部把效率榨到最高。但从 2025 年起,真正的大规模 serving 之上又长出了一层:既然 prefill 和 decode 的脾气这么相反,与其在同一台机器里反复调度它们抢资源,不如干脆把它们拆到两组专门的机器上各干各的。这叫 prefill/decode 分离,是下一章 Ch6 的主题。一个清楚的分层会帮你不迷路:这一章讲的是机器内部那层调度,Ch6 讲的是机器之间那层拆分,两层叠起来,才是 2026 年一个真实 serving 集群的全貌。

综合 · 你现在懂了「怎么把 GPU 喂饱」

回头看这一章。你从一个扎眼的事实出发:decode 让 GPU 七成算力闲置,而 GPU 是这门生意最贵的东西。最朴素的静态批处理喂不饱它,因为一批请求要等最长的那个结束,先完成的留下大片气泡,而 LLM 流量长短极不齐,这堵墙总是又高又普遍。连续批处理把批的粒度从整批请求降到每一步生成,空槽立刻补人,气泡消失,GPU 持续满载,但它能拼多大被 KV cache 的显存账卡着。一台机器内部,调度器在 prefill 和 decode 之间实时权衡,chunked prefill 把大 prefill 切碎插进 decode,让 TTFT 和 TBT 不至于互相踩踏。再往上,路由这层决定请求发给哪个副本,缓存感知的路由把共享前缀的请求聚到一起命中 KV cache,而不是轮询着打散重算。

把这四件事串起来,你会发现它们背后是同一个张力:吞吐和延迟这对冤家。拼更大的批、把 GPU 喂得更满,吞吐上去了,但单个请求的延迟往往被拖高;反过来死守低延迟,又喂不满 GPU、吞吐下来。连续批处理、chunked prefill、缓存路由,本质上都是在这对冤家之间找一个更划算的平衡点,而不是消灭这个张力 —— 它消灭不了。这也是为什么真实系统会按服务等级(SLO)把流量分开:要交互式低延迟的走一套配置,跑离线批量、只要高吞吐的走另一套。理解了这个张力,你以后看任何一个 serving 优化,都能一眼问出那句关键的话:它在拿什么换什么?

学完这一章,你已经能解释一个 LLM serving 系统是怎么把 GPU 喂饱的、能说清连续批处理和 chunked prefill 各自治什么病、能判断一个路由策略聪不聪明,也理解了吞吐与延迟那个消灭不掉的张力。这就是你该合上书的地方。下一章我们走到机器之间那一层:当 prefill 和 decode 被拆成两套独立的机群,集群该怎么自动扩缩、怎么做容量规划,以及 DeepSeek 那套把上百张卡组成一个 decode 机群的真实顶点部署长什么样。

本章关键术语

本章引入的 canonical 术语,点进术语表看更完整的解释:

  • 连续批处理 continuous batching 也叫迭代级调度:以每一步 decode 为单位重新组批,请求一结束就把空槽补上排队的新请求,GPU 不留气泡;首创 Orca(OSDI'22),vLLM 普及成事实标准
  • chunked prefill 分块预填充 把一整块大 prefill 切成小块、插进正常 decode 步骤之间算,避免长 prefill 长时间霸占 GPU 把所有人的 decode 憋住;来自 Sarathi-Serve(OSDI'24),vLLM 新架构默认开启
  • 缓存感知路由 prefix-aware routing 把共享前缀的请求路由到同一副本,让第二个请求命中已算好的 KV cache、跳过重复 prefill;对照轮询会把它们打散重算。SGLang 的 RadixAttention + 缓存感知负载均衡是代表实现

参考文献

Mode A · 引用出处(正文 inline,集中列在此)

深 dive 资源(可选 · 想往下走再看)

说明:本章同样没有 Mode B 视频站 —— 推理服务调度这类硬核系统主题缺少合格的人讲解长视频;深入的会议论文(OSDI · 上方)与引擎官方文档由 Mode A 原创讲解串起。

下一章

下一章 Ch6 把镜头从一台机器拉到整个集群,走到刚才那条边界的另一侧:机器之间那一层。你会看到为什么大规模 serving 要把 prefill 和 decode 拆成两套独立的机群(它们的资源脾气太冲),集群怎么根据流量自动扩缩、怎么做容量规划,以及一个真实的顶点案例 —— DeepSeek 怎么把上百张卡组织成一个专门的 decode 机群。这一章的连续批处理和调度是地基,Ch6 在它上面盖起整个集群。