阶段 6 · 让模型跑起来

服务化:一个人用时很快
一千个人用就崩了

《投机解码与加速》把单条请求算快了,1000 个人同时来,GPU 又闲了—— 这一章的问题是:让 GPU 一直不闲着。瓶颈往往不在算力,而在显存和调度。

1

一次推理快,不等于服务快

比什么单次推理服务化(Serving)
关心什么这一段算得快不快 单位时间能服务多少人
优化目标延迟(你等多久) 吞吐(GPU 每秒产出多少 token)
只看一个会怎样 只看延迟 → 一个人独占 GPU,快得飞起,但成本高到无法上线
只看吞吐 → 攒一大批请求一起算,吞吐漂亮,但第一个用户等到怀疑人生

服务化的全部工作,就是在延迟和吞吐之间找一个你能接受的平衡点——这两个指标互相冲突,下面那个互动会把它画出来。

🎯 类比

像餐厅。延迟是"我这桌的菜多久上", 吞吐是"整个餐厅一小时能出多少道菜"。
只服务一桌,厨师全程盯着你这一道菜,上菜飞快——但餐厅亏死。
把 20 桌的菜一起下锅,出菜总量最大——但你这桌要等很久,而且可能凉了。
这个类比管到「几桌可以一起下锅」为止:真实推理里每个字是连续吐出来的,不是一个 batch 一道菜。

这套「来人速度 λ、服务速度 μ、负载 ρ = λ/μ」的算法叫排队论。后面每一节调的 batch,就是在动 μ——同一块卡每秒能服务多少人。

互动 · 一千个人同时来:队伍是这么爆掉的

这张卡的服务能力
—
平均等待
—
队伍最长
—

2

把用户感受拆成三个指标

指标含义用户感受典型值(7B 模型)
TTFT
Time To First Token
从发请求到吐出第一个字 决定"它反应快不快" 0.1 ~ 1 秒
TPOT
Time Per Output Token
第一个字之后,每多一个字要多久 决定"它打字流不流畅" 7 ~ 70 毫秒/字
随 batch、上下文变
吞吐
Throughput
整个系统每秒产出多少 token 用户看不到,但决定你的账单 1000 ~ 5000 tok/s

为什么 TTFT 和 TPOT 要分开?因为它们的瓶颈完全不同。

TTFT 取决于"处理你输入的那几百个 token 要多久"——这一步叫预填充(prefill),是并行的,一次前向就搞定, 所以它对 batch size 不算敏感(前提是没被排在别的请求后面)。想压 TTFT,除了把输入写短,就靠第 5 节的前缀缓存让公共前缀不重算。

TPOT 取决于"每生成一个字都要把整个 KV cache 读一遍"——这一步是串行的, 而且是访存密集的(瓶颈在读显存,不在算力)。所以它才是真正被 batch size 影响的那个。

看到"用户说很卡"时,先问清是首字慢还是打字慢——这是两个完全不同的问题。

互动 · 用户看到的那条时间轴:首字 + 逐字

3

让 GPU 不空转:连续批处理

这是这一章最重要的东西。先看传统做法为什么会浪费:

互动 · 静态批处理 vs 连续批处理(真实的逐 token 调度模拟)

静态批处理(等最长的跑完才换人)

连续批处理(每步都重新组 batch)

静态:GPU 槽位利用率
—
连续:GPU 槽位利用率
—
总耗时缩短
—

互动 · 空转白格:把「浪费」画成看得见的面积(利用率按格子数真实统计)

静态批处理(整批等到最长的那条跑完)

连续批处理(每步把空槽补满)

黄 = prompt 阶段 蓝 = 生成中 红 = 这条请求的最后一格(结束) 白 = 空转(浪费)
静态:空转白格
—
连续:空转白格
—
空转格数比
—

💡 两者的唯一区别

静态批处理:凑够一批就锁死,整批一起跑到最长的那个结束才释放。 短的请求早就生成完了,但它的槽位被占着,后面的请求只能等。
连续批处理(Continuous Batching / In-flight Batching): 每生成一个 token 就重新组一次 batch——谁生成完了立刻腾出槽位, 立刻从等待队列里补一个新请求进来。
只是把组批的粒度从「一条请求」换成「一个 token」,上面那张甘特图的 GPU 槽位利用率就从 55.8% 抬到 79.6%——请求长短越是参差不齐,差距越大。 这是 vLLM、TensorRT-LLM、TGI 这些推理引擎最核心的那个优化。

4

显存才是瓶颈:PagedAttention

先算一笔真实的账。KV cache 的大小是可以精确算出来的—— 每生成一个 token,就要为它存一份所有层的 K 和 V。

把鼠标停在上面公式里的符号上——它下面的哪个控件会亮,就说明那个符号指的就是它。

每 token 的 KV cache = 2 × 层数 × KV 头数 × 头维度 × 精度字节数
7B 模型(32 层、32 个 KV 头、每头 128 维、FP16)= 2 × 32 × 32 × 128 × 2 = 524,288 字节 ≈ 0.5 MB
32 个 KV 头 × 128 维 = 4096,恰好等于隐藏维度(每个 token 内部向量的宽度),这是 MHA(每个查询头配一个 KV 头)的巧合。GQA / MQA 让多个查询头共用一个 KV 头(如 32 个查询头只留 8 个),KV cache 按 8/32 变小——下面那个「KV 头数」滑块模拟的就是它

0.5 MB 一个 token 听起来不多。但一个 4096 token 的对话就是 2 GB。 一张 40GB 的卡装下 7B 模型(14GB 权重)后只剩 26GB——只够 13 个并发长对话。 (本页的 GB / MB 都按 1024 进制算,也就是 GiB / MiB;显存厂商常用 10⁹ 口径,数字会小一点。)

互动 · 一个 token 的 KV cache 长什么样

互动 · 传统预分配 vs 分页管理(真实显存账本)

把鼠标停在上面那条公式里的符号上——它上面的哪个滑块会亮,就说明那个符号指的就是它。

预分配:可并发数
—
分页:可并发数
—
提升
—

💡 PagedAttention 的类比:操作系统的虚拟内存

传统做法是按最长可能长度预分配——你声明要 4096 个 token 的额度, 系统就一次性给你划 2GB,哪怕你实际只用了 300 个。 浪费率 60~80%,而且会产生大量无法利用的碎片。
PagedAttention 借用操作系统的思路:把 KV cache 切成固定大小的"页"(block) (比如每页放 16 个 token),用到哪页才分配哪页, 页和页之间不需要在物理上连续。
结果:浪费从"按最长算"变成"平均浪费半页",显存利用率能从 20-40% 提到 90% 以上。 vLLM 论文报告,相同延迟下吞吐提升 2~4 倍。
这个类比管到「按页分配」为止:操作系统会把一页写回磁盘,KV cache 的页只在显存里挪位置,不会换出到硬盘。

⚠️ 分页不是免费的

① 间接寻址开销。读 KV cache 时不能直接顺序读,得先查一张页表。 这让本来就已经是访存瓶颈的 attention 更慢了一点——用吞吐换了一点延迟。
② 调度更复杂。要维护页的分配、回收、共享(比如同一个 prompt 的多个采样可以共享前缀的页)。
③ 块大小是个新旋钮。块太小 → 页表太长、间接寻址开销大; 块太大 → 浪费又回来了。通常 16 是个不错的起点。

5

六个推理引擎,怎么选

方案定位强项代价
vLLM 通用服务端的默认选择 PagedAttention 的开创者;生态最活跃; 支持模型最全 极致低延迟场景略逊于专用引擎
TensorRT-LLM NVIDIA 官方,追求极致性能 编译后性能通常最好;对 NVIDIA 硬件压榨最彻底 编译流程重、调试难;换模型要重新编译;绑定 NVIDIA
SGLang 复杂结构与高并发场景 RadixAttention——自动复用多个请求的公共前缀。 在"同一个长系统提示 + 大量请求"的场景下优势巨大 社区规模比 vLLM 小
TGI
HuggingFace
和 HF 生态集成 部署简单,模型转换省事 性能通常不如 vLLM
Triton
NVIDIA
模型服务框架(不只是 LLM) 多模型、多框架统一调度;能做 A/B 测试、集成;也支持动态批处理(先把请求攒一小会儿凑一批——粒度是整条请求,和第 3 节的连续批处理不是同一件事) 本身不含 LLM 优化,要配合后端
Ollama / llama.cpp 本地、单机、低并发 一行命令跑起来;量化支持好;CPU 也能跑 为单用户优化,高并发下吞吐远不如上面几个

互动 · 同一段系统提示,为什么要存很多份

要上线服务 → vLLM(除非你有专门的性能团队去啃 TensorRT-LLM)。 有超长公共前缀 → SGLang。 自己笔记本上跑着玩 → Ollama,但别拿它做生产服务——它是为"一个人用"设计的,并发一上来就原形毕露。

M

数学 · 一个旋钮,两端

前面五节说的其实是同一句话:batch 开得越大,同一块卡服务的人越多,但每个人越慢。 这句话可以算出来——下面两张图都是真算的,拖滑块数字会跟着变。

互动 · 同一个旋钮的两端:吞吐上去,打字变慢

互动 · 每个符号管图上的哪一块

上面那行值是活的——它跟着这一节那两个滑块变;把鼠标停在公式里的符号上,图里对应的那块会亮。

🎬 自己验一遍

把「batch」从 1 拖到 64,看两个数字同时变:每个字要等的时间 一直线性变慢,而吞吐在默认的 1024 上下文下只涨到 2783 token/秒(带宽天花板 4000 的七成)就快停了—— 所以加大 batch 买到的吞吐是有限的,付出的延迟却是无限的。
这也是第 6 节那句话的来源:「同一个旋钮的两端」。 再回到第 3 节看那两张甘特图:连续批处理做的事,就是把组批的单位从「一条请求」降到「一个 token」, 于是这条曲线上你能站到更靠右的位置。

6

吞吐是拿延迟换来的

手段换来什么付出什么什么时候真的会痛 → 换什么
加大 batch 吞吐大幅上升(GPU 更饱和) 每个请求的 TPOT 变长——你被别人的请求拖慢了 实时语音这种要 30 ms/字以内的场景 → 把 batch 调小,或用第 3 节的分块 prefill 把抖动压平
KV cache 量化
FP16 → INT8
KV cache 显存砍半 → 并发数翻倍 输出质量会掉,而且掉多少必须实测。 有的任务几乎无损,有的长链推理会明显变差 任务对长链推理敏感时 → 先小流量实测质量,或只量化一部分层
权重 4bit 量化 权重显存砍到 1/4,能塞更大的模型 质量损失比 KV 量化更明显;不同量化算法的差距很大 输出质量要求高(代码、数学)→ 先试 INT8 / FP8,别一上来就 4bit
前缀缓存
Prefix Caching
相同系统提示的请求直接省掉重复计算,TTFT 大幅下降 要占显存存缓存;命中率低时反而浪费 请求前缀各不相同、命中率上不去 → 关掉它,别白占显存
投机解码 多花一个小 draft 模型的显存,就能把 TPOT 打下来 需要一个小模型当草稿;batch 很大时收益被稀释 低并发、延迟敏感时最划算;高并发稳态下 → 收益被稀释,可以不开

先量两个数,再决定动哪个旋钮

① GPU 利用率常年 < 70%
瓶颈在调度
连续批处理 · 把 batch 调大
② KV cache 占显存大头
瓶颈在显存
PagedAttention · KV 量化
③ 两个都够,单请求还是慢
瓶颈在带宽
投机解码 · 换更快的卡
⚠️ 一个必须知道的取舍

"每秒能服务多少人"和"每个人感觉多快"是同一个旋钮的两端。
你可以调 batch size 在两者之间滑动,但没有办法两边都拿到。
所以上线前必须先回答一个问题:你的用户能接受多慢? 聊天应用通常能忍 100ms/字,实时语音对话要压到 30ms 以下, 离线批处理则完全不在乎——这三种场景的最优配置完全不同。

不要一上来就换引擎、上量化。先测两个数:

① GPU 利用率——如果常年跑不到 70%,说明瓶颈不在算力, 大概率是调度(batch 组得不好)或者数据搬运。用 nvidia-smi dmon 看 GPU 利用率。
② 显存占用里 KV cache 占多少——用 vLLM 的 /metrics(vllm:kv_cache_usage_perc)看 KV cache 占用。 如果 KV cache 是大头,那提升并发数的关键就是 PagedAttention 和 KV 量化,而不是换更快的 GPU。

7

小结

它对应哪条线 不直接对应任何一条——这一章讲的是排队论,不是学习:它不改表达力,也不改泛化,只改同一份计算被多少人共享。
一句话 把批从「请求级」降到「token 级」:每空出一个格子就立刻填上,GPU 不再等最长的那条请求。
它牺牲了什么 牺牲单条请求的延迟——每个 token 都要等别人;换来同一块卡多扛几倍的人。
🎬 自己验一遍

回到第 3 节第二张图,把「输出长度差异」拉到最右边:左边静态批处理的白块成倍膨胀,右边连续批处理只有收尾时才空出格子。静态以最长的请求为单位浪费,连续以单个格子为单位浪费。

它在暗线里站在哪

这一章是系统章。下面三条联系,别章都没有。

动态 batch:一张每个 step 都被改写的表

① 请求进来
[1, Lprompt]
② 调度器拼成一批
[B, L]
③ 每个 step 各长一列
[B, L+1]
④ 谁走谁补上,仍然满格
KV cache 按页长
暗线这一章的回答
A 信息流动 这一章的 batch 维度是动态的:请求进来是 [1, Lprompt],调度器拼成 [B, L],每个 step 各长一列 → [B, L+1],谁生成完就整行抽走、新请求整行换上。 别章的 batch 是你设的超参,这一章的 batch 是调度器每一步的决策。
D 跑在什么上 两种受限分两个阶段:TTFT(prefill)是算力受限——整段输入一次矩阵乘,GPU 算力吃得饱;TPOT(decode)是带宽受限——每吐一个字都要把所有请求的 KV cache 读一遍,算术强度极低。完整对比见 《硬件与算力账本》。
F 违背了哪个直觉 「想让每个人更快,就得给更多资源」——连续批处理反着来:让每个人稍慢,让所有人快得多。另两个反直觉:GPU 利用率低不是它算得慢,是在等最长那条请求;PagedAttention 借的是 1960 年代的操作系统虚拟内存。
🎯 前后钩子

它接住了 《投机解码与加速》解决的那一半:单条请求的延迟;这一章接另一半「同时服务多少人」,把空转的算力切成尽量多的并发请求。它给后面留的问题是:服务化只保证「快」,不保证「对」——那是 《评测、基准与幻觉》 的事。

一句话带走服务化

服务化的核心是延迟 vs 吞吐这一对矛盾:加大 batch 提吞吐,就一定让单请求变慢;连续批处理把组批粒度从「一条请求」降到「一个 token」,PagedAttention 把 KV cache 按页(block)分配,两招分别把 GPU 利用率和显存里的并发数抬上去。 所以推理慢的时候先看显存和调度,不要先想着换卡。

8

拓展阅读

上面讲的都是「够用」的版本。想往下挖,这里有三个入口—— 它们不是必修内容,是给想再往前走一步的读者准备的。

📄 这一章的说法从哪来

💻 工业界怎么写

∑ 更严格的形式