AI AI infra · 推理侧
书库 第 1 章
AI infra · 推理侧

第 1 章 一次请求的一生

先修:无 · 深度:L1 · 预计阅读:30 分钟
这一章只回答一个问题:同一次推理,为什么有时卡在算,有时卡在搬,有时直接装不下。

先看一个翻车现场

你在 notebook 里跑一个 7B 模型,输入一句"用三句话解释 KV cache",两秒出答案。你把它包成接口,发给二十个同事试用。

第二天,有人上传了 8K token 的合同,有人一口气连发二十个请求。三件事一起发生:有人等了半分钟才看见第一个字,有人直接失败,显存监控拉满。

你回去查代码。模型没换,推理逻辑没改,prompt 也没变。

差别在测试条件。notebook 里是一次请求、短输入、batch=1;线上是二十个人、长输入、随时到达。同一份代码,工作负载的形状变了,先撞的墙就变了。

这本书只讲推理这一侧:模型训好之后,怎么把它变成一个回答得过来的服务。训练侧的麻烦是另一套,激活值、优化器状态、梯度同步,不在这本书里。

下面出现的名词,第一遍看不懂就跳过。先记住它们在哪个位置,比记住名字有用。

慢有三种,先分开

不分开,后面会一直吵。

  • 首字慢:按下回车,半天看不到第一个字。
  • 出字慢:第一个字出来了,后面一个字一个字往外挤。
  • 直接失败:OOM、超时、请求被丢弃。

三种慢对应三个不同的地方。首字慢,多半卡在预填充或排队;出字慢,多半卡在等显存搬数据;直接失败,多半是历史缓存把显存吃满了。

你可能会想,二十个请求而已,加张卡。加卡补的是算力,不一定补容量。反过来,换一个更省显存的引擎,也不一定让首字更快。

先定位墙,再选工具。这句话后面还会出现,因为换框架是最容易的错觉。

一次请求经过哪两段

1 请求到达 进队列
2 排队 计入 TTFT
3 Prefill T 个 token 并行 · 吃算力
4 Decode 每步 1 个 token · 吃带宽
5 流式返回 边生成边发

第 3 步末尾产出首 token,决定 TTFT;第 4 步每一步都要重读历史 KV,决定 TPOT。

一次请求的五段。真正决定快慢的是 Prefill 和 Decode 两段,它们撞的不是同一堵墙。

请求到达以后,先排队,再切成 token,然后进模型。模型这一段分两截:预填充(prefill)和逐字生成(decode)。后面所有章节都绕不开这两截。

预填充处理整段输入:8K 输入就是 8192 个 token 一起过每一层,算完取最后一个位置的输出,得到第一个字。这一截的矩阵很大,计算量随输入长度涨,GPU 的算力容易被吃满。

逐字生成从第二个字开始,一次只喂一个新 token。可要算出这个字,模型得回头用到前面所有 token 的 Key 和 Value,于是每一步都把它们存下来,下一步直接读。这一截每一步的计算量小得喂不饱 GPU,时间大头花在把权重和这份历史缓存从显存搬进计算单元。

预填充拼算力,逐字生成拼显存带宽。两种活,两堵墙。

中间还有两处容易漏掉的等待。排队不算模型计算,却算进用户等待;切词决定了后面所有的账,切成多少 token,预填充就算多少,缓存就占多少。

用两个数字看体验:

  • 首字延迟(TTFT):从发出请求到看见第一个字,包含排队、预填充和第一个字的采样。
  • 出字间隔(TPOT):第一个字之后,平均每个字花的时间。
TPOT ≈ (总耗时 − TTFT) / (输出字数 − 1)

吞吐是另一回事:单位时间总共服务多少请求、生成多少 token。它衡量产能,不衡量体验。一个服务可以吞吐很高,同时每个用户都等到骂人。加了"满足延迟要求"这个前提的吞吐叫 goodput,第 9 章再说。

读到这儿停一下。同样 8K 输入,A 是一个 8K 请求,B 是八个 1K 请求,谁的第一个字更慢?先写下判断,第 6 节算完再回来对。

KV cache 省的是计算,不是显存

这句话最容易被说反,得单独说一节。

没有缓存会怎样?生成第 100 个字时,模型把前 99 个字的 Key 和 Value 重算一遍;第 101 个字,再重算前 100 个。越往后越亏,一条 8K 的回答,开头那些 token 被重算了几千次。

有了缓存,第一次算完就存着,后面直接读。省下的是重复计算,付出的是显存——这份东西要一直占着,直到这条请求结束。

它减少计算,增加显存。所以"KV cache 减少了显存占用,长上下文更省显存"这句话正好说反了。后面关于容量、batch、并发的麻烦,根子都在这里。

三堵墙:算力、带宽、容量

墙它管什么撞了会怎样看哪个数
算力单位时间能做多少矩阵乘法首字等很久,GPU 打满TTFT
带宽单位时间能从显存搬多少字节出字慢,GPU 利用率不高TPOT
容量显存装得下多少权重和缓存OOM、batch 上不去、请求被抢占最大并发、P99

三堵墙不是并列的。一次请求会依次撞上:预填充先撞算力,逐字生成再撞带宽,缓存越堆越多,最后撞容量。

后厨这个类比可以用一次。预填充像一次读完整张订单、把料全备好,订单越长读得越久,但很多人可以同时动手;逐字生成像一道一道出菜,每出一盘都要回台面取一次料;缓存是台面上备好的料,省了切菜时间,也占住台面。

这个类比有三处会骗你。台面能加,显存焊在卡上;加人手能让每桌更快,加 batch 主要提高总出菜量;餐厅没料可以暂停,显存放不下时调度器会抢占请求、丢掉缓存、之后再重算,用户看到的是卡顿。

算一笔粗账:7B 能不能扛住 8K

现在把二十个同事这件事算成数字。模型固定为 7B 参数、FP16、8K 输入,后面就认这一个例子。

权重:7B × 2 字节 = 14 GB,约 13 GiB。

缓存:这个规模的 7B 是 32 层、32 个 KV 头、每头 128 维。先算一个 token:

2(K 和 V)× 32 层 × 32 头 × 128 维 × 2 字节 = 512 KiB

8K 输入是 8192 个 token,一条请求约 4 GiB。

一条请求:13 + 4 = 17 GiB。24GB 卡实际可用约 22 GiB,放得下,但激活值和框架开销还没算,很紧。

八条并发:4 × 8 = 32 GiB 缓存,加权重 45 GiB。24GB 装不下,48GB 很紧,80GB 才舒服。

翻车现场到这里能对上。二十个人一起进来,实际拼上的 batch 可能只有 8,剩下十二个在队列里等。你看到的首字变慢,一半是排队,一半是预填充。

回到第 3 节那个判断:B 的八条短请求拼成一个 batch,权重只读一遍,总吞吐更高;A 是一条长序列,缓存和计算都堆在一条上,第一个字更慢。

这条式子还有几个变量没展开。不同模型的层数和 KV 头数不一样,GQA 能把缓存压掉几倍;70B 的瓶颈可能根本不在缓存,而在权重和跨卡通信。第 2 章把这张账算全。

为什么 decode 一步要几十毫秒

14 GB 权重,每生成一个字都要完整读一遍。

80GB A100 的显存带宽是 2039 GB/s,按带宽算,一个字的下限是 14 / 2039 ≈ 6.9 毫秒。H100 是 3350 GB/s,约 4.2 毫秒。实际还要读缓存、算注意力,达不到这个上界。

单条请求的出字速度就是这么被卡住的:不是 GPU 算不动,是数据搬不过来。

batching 也是从这里来的。八条请求一起生成,权重只读一遍,八条平摊这 6.9 毫秒,总吞吐涨上去,单条的出字速度基本不变。代价是缓存也乘八,又回到那堵容量墙。

什么时候这套说法不够用

说四类,你对号入座。

超长 prompt、batch=1。32K 输入,预填充的注意力计算随长度涨得很快,首字可能是秒级;缓存也跟着涨到 16 GiB,24GB 卡加权重就放不下。

高并发长上下文。二十个 8K 请求,缓存约 80 GiB,超过单张 80GB 卡。症状是排队、被抢占、重算,尾延迟飙升,平均值反而还行。

短请求混长请求。一个 32K 的预填充排在队头,后面的短请求首字一起变慢。解法是 chunked prefill 和优先级调度,不是加卡。

多卡 70B。张量并行把每层切到多张卡上,每生成一个字都要跨卡同步一次。这时通信可能比单卡带宽更先成为瓶颈,症状是加卡之后吞吐没有线性提升。

上游还有 tokenizer。同一段中文,不同的 tokenizer 切出的 token 数不一样,差多少要自己数一遍,预填充的计算和缓存也跟着差。下游是调度器,它能拼多大的 batch,直接决定吞吐和尾延迟。

这套模型是基线,不是铁律。量化、MoE、投机解码都会改变算术强度,第 6、8 章再补。

经验:先问瓶颈,再换框架

看到慢,很多人的第一反应是换引擎。vLLM 换 SGLang,SGLang 换 TensorRT-LLM。

换之前先回答三个问题:是首字慢、出字慢,还是直接失败?账本里权重、缓存、余量各占多少?当前 batch 真的拼上了,还是请求都堆在队列里?

答不上来,换框架大概率只是把同一个瓶颈搬进另一个仓库。

练习

六道题,先闭卷,再翻 ANSWERS.md。

  1. 不看资料,用三句话说明:预填充在做什么、逐字生成在做什么、KV cache 在省什么。
  2. 7B 模型 FP16 权重多少 GB?8K、batch=1 的缓存多少?相加多少?能不能放进 24GB 卡?
  3. 同样 8K 总输入:A 是一个 8K 请求,B 是八个 1K 请求。谁的 TTFT 更差?谁的总吞吐更高?为什么?
  4. 找错:"KV cache 减少了显存占用,所以长上下文更省显存。"错在哪?
  5. 只把显存带宽翻倍、算力不变,预填充和逐字生成哪个受益更大?反过来呢?
  6. 用 llama.cpp 或 vLLM 起个小模型,batch=1 和 batch=4 各压一次。先写预测,再测。

这一章就记一句

一次请求分两段。预填充并行读入 prompt、写缓存,计算密集,决定首字快慢;逐字生成每步只算一个 token,却要读全部权重和缓存,带宽密集,决定出字快慢。缓存用空间换时间,省的是重复计算,花的是显存,直接限制 batch。

真只记一句:先看工作负载的形状(输入多长、几个人同时来),再判断它先撞算力、带宽还是容量,最后才谈引擎和参数。

下一章把账算细:权重、缓存、激活、框架预留分别怎么估,为什么模型能加载不等于服务能跑,以及怎么在选型前就判断一张卡扛不扛得住。