一次推理请求的解剖 · prefill/decode 与 KV cache
把一次 LLM 推理拆开:prefill(并行处理 prompt)与 decode(逐 token 自回归)的资源画像截然不同;KV cache 是什么系统对象、为什么它主宰显存、为什么 decode 是 memory-bound;TTFT 与 TBT 两个延迟指标。前置:Ch2。
这是系统设计路书的第 4 章,约 2 小时,纯 Mode A,是第三站「推理服务」的第一章,也是这条路书真正主脊的起点。
前两章你学的是训练和推理共享的底层:怎么把模型切到多机、切开后多机怎么协同。从这一章起,焦点正式转向服务 —— 一个已经训好、铺在整个集群上的模型,到底怎么把亿万个请求一次次接住。要回答这个问题,你得先会一件最基本的事:把一次推理请求拆开,看清里面到底发生了什么。读完你会发现,你以为模型生成文字是一件均匀的事,其实一次推理内部是两段性格截然相反的计算,中间还夹着一块会不断变大、并且主宰显存的东西。理解了这三件事,后面三章讲的所有花招(批处理、分离、分页),你都会知道它们到底在对付什么。
本章四节:
- 一次请求其实是两个阶段(25 min)
- KV cache:为什么 decode 不重算历史(25 min)
- KV cache 为什么主宰显存:算一笔真实的账(35 min)
- 为什么 decode 被显存带宽卡住 · 两个延迟指标(30 min)
0. 从你最熟悉的那个停顿说起
你几乎每天都在体验这一章要解剖的东西,只是没留意。你给 ChatGPT 发一段长长的 prompt,按下回车,它会先顿一下,过了零点几秒甚至更久才开始往外吐字;而一旦开始吐,字就一个接一个地蹦出来,像有人在飞快打字。这个先停顿、再逐字蹦出的体感不是界面动画,它精确地对应着一次推理内部真实发生的两个阶段:那个停顿,是模型在读你的整段 prompt;后面逐字蹦,是它在写回答。这一章要做的,就是把这两段拆开,看清它们为什么脾气如此不同,以及夹在它们中间那块决定一切的显存。
为什么要从解剖开始,而不是直接讲怎么优化?因为推理服务这门工程的所有招式 —— 你后面会学的连续批处理、prefill/decode 分离、PagedAttention —— 全都是冲着这次解剖暴露出来的某个具体特征去的。不先看清一次请求的内部构造,那些招式对你就只是一堆名词;看清了,你会发现它们每一个都在对付一个你已经亲眼见过的问题。所以这一章不教任何优化,只做一件事:把一次推理彻底拆开,给你一张推理服务的解剖图。带着这张图,主脊后面三章你才走得动。
1. 一次请求其实是两个阶段
先把那个停顿和逐字蹦,换成两个你以后天天会用到的词:prefill 和 decode。一次推理请求进来,模型不是一上来就开始生成回答的,它必须分两步走。第一步,把你发来的整段 prompt 读进去、理解透,这一步叫 prefill(预填充)。第二步,在读懂的基础上,一个 token 一个 token 地把回答生成出来,这一步叫 decode(解码)。你看到的那个停顿就是 prefill 在干活,后面逐字蹦就是 decode 在干活。这两步听起来只是先读后写的自然顺序,但它们在 GPU 上的计算方式,几乎是两种完全不同的负载。
先看 prefill。你的 prompt 是一整段已经摆在那里的 token,比如一千个。prefill 做的事,是把这一千个 token 一次性、并行地全喂进模型,跑一遍前向计算,算出每个位置该有的内部表示。关键就在一次性和并行这两个词:因为整段 prompt 在 prefill 开始时就全都知道了,模型可以把它们铺开,用一次巨大的矩阵乘法同时处理掉,不必一个一个来。这就像老师拿到一张已经写满的卷子,可以一眼扫过全部题目;prompt 是一千个 token 还是两千个,对 prefill 来说只是矩阵大一点,本质上都是读一遍。
再看 decode,它的脾气完全相反。回答还不存在,模型必须一个字一个字地把它造出来:先根据读懂的 prompt 生成第一个 token,把它接到序列末尾;再根据 prompt 加上第一个 token 生成第二个;再根据 prompt 加上前两个生成第三个,如此逐字递推,直到生成结束。这种拿自己刚生成的输出当下一步输入的逐字推进,叫自回归(autoregressive)。它有一个绕不开的特性:每一步都必须等上一步的结果出来才能开始,所以天然是串行的,没法像 prefill 那样把所有 token 并行铺开 —— 因为第十个字还没生成出来,你根本无从去算第十一个字。
把这两步并排一看,一次推理的真相就清楚了:它不是一段均匀的生成,而是两段脾气相反的计算拼在一起。prefill 像读题,一次性、并行、面对的是一整段已知的输入;decode 像答题,逐字、串行、每一步都依赖前一步的产出。下面这张图把这两个阶段画在一起,你对照着看会更直观。
这个两段脾气相反的事实,不是个无关紧要的细节,它是后面好几章的根。因为脾气相反,它们想要的硬件也不一样,塞在同一台机器上会互相拖累 —— 这正是 2024 年 Splitwise 和 DistServe(你在第一章技术弧见过这两个名字)提出把 prefill 和 decode 拆到不同机器上各自优化的根本原因,也是这条路书 Ch6 整章的主题。所以现在请把这两个词牢牢记住:接下来每一章,你都会不断回到 prefill 和 decode 这对孪生兄弟身上。但在拆开它们之前,我们得先看清夹在它们中间那块东西,因为 decode 那种逐字推进的方式,会立刻逼出一个问题。
2. KV cache:为什么 decode 不重算历史
上一节留了个尾巴:decode 每生成一个新 token,都要根据前面所有 token 来算。这句话藏着一个吓人的代价。注意力机制的核心,是当前这个 token 要去看前面每一个 token,才能决定下一个字是什么。那么最朴素的做法是:每生成一个新 token,就把整个序列从头到尾重新算一遍注意力。我们来算算这个代价有多大 —— 生成第 1 个字要处理 1 个位置,第 2 个字要处理 2 个,第 100 个字要处理 100 个,生成一段 N 个字的回答,总计算量大约是 1 加 2 一直加到 N,约等于 N 的平方的一半。这是一条二次增长的曲线:回答长一倍,重算的代价涨四倍。对一个要服务亿万次的系统来说,这是不可接受的浪费。
但停一下,这里有一个关键的观察能救场。当模型处理到第 100 个字时,它要回看前面 99 个字,可那 99 个字的内部表示,在之前的步里不是已经算过了吗?更妙的是,注意力机制里每个 token 贡献给被回看的那两样东西(分别叫 Key 和 Value,你可以理解成它对外提供的索引和内容),一旦这个 token 确定了,它的 Key 和 Value 就再也不变了。既然不变,何必每一步都重算?把它们第一次算出来时就存下来,后面每一步直接取用就行。这块被存下来、反复复用的 Key 和 Value,就叫 KV cache。
有了 KV cache,decode 每一步要做的事就被大大简化了:它只需要为当前这一个新 token 计算 Key 和 Value,追加进 cache,然后读取 cache 里全部历史的 Key 和 Value 来做这一步的注意力。换句话说,每一步真正新算的只有一个 token 的量,而不是整个历史。于是那条吓人的二次曲线被压平了,生成 N 个字的总新计算量,从 N 的平方那个量级降回到和 N 成正比。代价呢?你得有地方把这块越来越长的 KV cache 一直存着,而那个地方,是寸土寸金的 GPU 显存。
所以 KV cache 的本质,是一笔非常典型的工程交易:用显存,换计算。它把 decode 从每步重算整个历史的二次灾难,拉回到每步只算一个新字的线性开销,代价是一块随着生成不断变大、而且必须常驻显存的缓存。
KV cache 是「decode 不想每步重算历史」这堵墙的产物 —— 用一块越来越大的显存,换回每一步的计算量。
记住这笔交易的两头,因为这一章后半段全在算它的代价那一头。KV cache 是推理服务的头号资源对象,serving 工程里几乎所有跟显存有关的招式都是围着它转的。你在第一章见过的 PagedAttention,正是 2023 年 vLLM 为了高效管理这块缓存而发明的(它把操作系统的虚拟内存分页搬来管 KV cache),你会在 Ch7 看到它的真实实现。但要理解为什么需要那么精巧地管理它,你得先亲手算一算:这块 cache 到底能大到什么程度。
3. KV cache 为什么主宰显存:算一笔真实的账
KV cache 占显存这句话,只有当你亲手算过一次,才会真正理解它有多吓人。这笔账你必须会算,因为它直接决定一件运营上最现实的事:你的一张 GPU,到底能同时接住多少个请求。我们拿一个真实的、配置公开的模型来算 —— Llama 3.1 8B,一个八十亿参数的开源模型。
KV cache 的大小有个干净的公式。每存一个 token,要存的字节数等于:2,乘以层数,乘以每层的 KV 头数,乘以每个头的维度,再乘以每个数字占的字节数。开头那个 2,是因为要分别存 Key 和 Value 两份。把 Llama 3.1 8B 的真实配置代进去,它有 32 层,每层 8 个 KV 头,每个头 128 维,用 BF16 精度也就是每个数字 2 字节。算出来是 2 乘 32 乘 8 乘 128 乘 2,等于 131072 字节,也就是每个 token 128 KiB。一个 token 128 KiB 听起来不多,但别忘了它要乘上序列里的每一个 token。
那就乘起来看。一个比较常规的请求,prompt 加回答一共 8 千个 token,它的 KV cache 就是 128 KiB 乘以 8192,正好 1 GiB 显存,一个 GiB 只为了一个请求。如果是个长上下文请求,比如 12.8 万个 token(现在的长文档、长对话很容易到这个量级),它的 KV cache 是 16 GiB,比 Llama 3.1 8B 这个模型自己的权重(BF16 下约 16 GB)还要大。读到这你该停一下:一个请求的临时缓存,居然能超过整个模型本身的大小。而这还只是一个请求,真实的服务要同时接很多个,每一个请求都各自拥有一块自己的 KV cache,并发数翻倍,KV cache 的总占用就翻倍。
你可能注意到一个细节:这个模型有 32 个注意力头,但 KV 头只有 8 个。这不是笔误,而是一个专门为了压 KV cache 而生的设计,叫分组查询注意力(GQA)—— 让多个查询头共享同一组 Key 和 Value,KV 头数因此只有查询头的四分之一,KV cache 也就直接小了四倍。如果用最原始的、每个头都有自己 KV 的多头注意力,这个模型每 token 的 KV cache 会是 512 KiB,而不是 128 KiB。GQA 这类注意力变体本身是模型设计的题目(为什么这样设计、对质量有什么影响,见 #4 模型设计路书),这里你只需要知道,它存在的一大动机就是这一节正在算的这笔显存账。
把这笔账的结论收一下,它会改变你看 serving 系统的方式。一个模型的权重是固定的一次性成本,装一次占掉那么多,不再变;而 KV cache 是随并发请求数乘以每个请求上下文长度而线性增长的可变成本。这意味着,决定你的 GPU 还能不能再接一个新请求的,往往不是权重(它早就装好了),而是 KV cache 还剩多少空间。
serving 的容量约束,本质上是 KV cache 的显存约束 —— 你能同时服务多少人,取决于显存里还能塞下多少块 KV cache。
现在你应该能体会,为什么管理这块缓存值得专门发明一套机制了。如果像最朴素的做法那样,给每个请求预先划一大块连续显存,装它可能用到的最长 KV cache,那么绝大部分时候请求没那么长,这些预留的显存就白白浪费了 —— vLLM 的论文实测,这种浪费能高达六到八成。这正是 2023 年 PagedAttention 要解决的问题:把显存切成小页、按需分配,像操作系统管内存那样管 KV cache,把浪费压到几乎为零。这笔账你算明白了,Ch7 读 vLLM 源码时,你就知道它那些精巧设计到底在抢救什么。但在那之前,还有最后一个问题没解释:为什么 decode 会慢,而且慢得和算力没什么关系。
4. 为什么 decode 被显存带宽卡住 · 两个延迟指标
回到第一节那个一直没说透的对比:prefill 和 decode 脾气相反,到底相反在哪?现在你有了 KV cache 这块拼图,可以把它讲清了。答案藏在一个反直觉的事实里:decode 慢,但不是因为 GPU 算得不够快,而是因为数据从显存搬不过来。
我们看看 decode 的每一步,GPU 到底在干什么。为了生成下一个 token,GPU 必须把整个模型的权重(几十 GB),加上这个请求的整块 KV cache,从显存(也就是 HBM)统统搬进计算单元,然后只为了算出一个 token。你品一下这个比例:搬运量巨大(全部权重加全部历史缓存),计算量却小得可怜(就一个 token 的事)。结果就是,GPU 那些昂贵的计算单元大部分时间在干等数据,真正卡住速度的,是显存到计算单元之间那条数据通道有多宽。这种瓶颈在搬数据、不在算的状态,有个专门的名字,叫 memory-bound(被显存带宽卡住)。
再对照 prefill,你立刻就懂它为什么相反了。prefill 一次并行处理成百上千个 token,而搬一次权重的成本被摊到了这一大批 token 身上,每个 token 摊到的搬运很少、计算却实打实地多,于是 GPU 的计算单元被喂得满满当当,瓶颈回到了算得多快上,这种状态叫 compute-bound(被算力卡住)。有个真实的对照能让你记住这个反差:同一个七百亿参数模型跑在 H100 上,prefill 阶段能把算力利用率吃到约 92%,到了 decode 阶段直接掉到约 30%。同一张卡、同一个模型,两个阶段的忙碌程度差了三倍,这就是脾气相反最硬的证据。
你可能马上会想到一个聪明的主意:既然 decode 时 GPU 闲着,那多塞几个请求进去、拼成一批一起算,不就把它喂忙了吗?这个想法对了一半,也正好是下一章的主题。拼批确实能摊薄权重的搬运成本(搬一次权重,给好几个请求共用),但有个东西摊不薄:每个请求的 KV cache 是各自独立的,请求越多,要搬的 KV cache 总量就越大、不会被分摊。所以拼批能缓解、但并不能真正让 decode 变成 compute-bound,大批量的 decode 在实测里依然主要是 memory-bound。这个张力(拼批的好处和它的天花板)是 Ch5 要展开的,这里你先记住一点:decode 的 memory-bound 是个相当顽固的事实。
既然一次请求是两段脾气相反的计算,衡量它快不快,自然也就有两个不同的指标,分别盯着两个阶段。第一个叫 TTFT(time to first token,首字延迟):从你发出请求,到第一个字蹦出来,中间这段时间。它主要由 prefill 决定,prompt 越长、prefill 算得越久,首字就来得越晚;此外请求在系统里排队、被调度的时间也都算在里头。第二个叫 TBT(time between tokens,字间延迟,你也会看到 TPOT 或 ITL 这些近义的叫法):第一个字出来之后,后面每个字之间的间隔。它主要由 decode 决定,也就是被 GPU 显存带宽卡着。两个指标盯着两个阶段,对应两套完全不同的优化手段。
这两个指标不是纸上的定义,它们是真实 serving 团队每天盯着的数字。Anthropic 的 Inference Systems 团队就分享过一个很说明问题的项目:他们在一个新的加速器平台上,发现有大约 350 毫秒的延迟差距说不清来源,一路追查下去,最后定位到的不是模型算得慢,而是 server 的调度开销。这件事点破了一个新手常忽略的真相 —— 首字延迟里堆着的不只是模型算 prompt 有多快,请求在系统里怎么排队、怎么被调度、怎么被分配到机器上,这些系统开销同样实打实地累加在 TTFT 上。所以延迟从来不是一个纯粹的模型问题,它是一个系统问题。
走到这,你手里这张解剖图已经完整了。你能把一次推理拆成 prefill 和 decode,知道前者 compute-bound、后者 memory-bound,知道它们之间那块 KV cache 为什么主宰显存,也知道用 TTFT 和 TBT 分别去量两个阶段。这套词汇和直觉,就是后面三章全部要操作的对象:Ch5 想方设法把 decode 时闲着的 GPU 喂忙(连续批处理、调度、路由),Ch6 干脆把脾气相反的 prefill 和 decode 拆成两套机器,Ch7 则带你在真实的 vLLM 源码里,看这些抽象怎么变成一行行代码。
综合 · 你现在握着一张 serving 的解剖图
回头看这一章给了你什么。你先把模型生成文字这件看似均匀的事,拆成了两段脾气相反的计算:prefill 一次性地、并行地读完整段 prompt,是 compute-bound 的读题;decode 逐字地、串行地生成回答,是 memory-bound 的答题。你接着搞懂了夹在中间的 KV cache,它是 decode 不想每步重算历史的产物,用一块随生成不断变大的显存,换回每步只算一个新 token 的线性开销。然后你亲手算了那笔账:一个 8K 请求要 1 GiB、一个 128K 请求能吃掉 16 GiB(比模型权重还大),而每个并发请求都各有一份,于是 KV cache 成了 serving 真正的容量约束。最后你想通了 decode 为什么慢:每步要把全部权重和 KV cache 从显存搬过来只为算一个 token,卡住的是显存带宽而非算力,所以才有 TTFT 和 TBT 这两个分别盯着两个阶段的指标。
这一整套,合起来就是一次推理请求的解剖学。它最值钱的地方,是把后面所有的优化都变得有的放矢:当你听到连续批处理,你知道它在抢救 decode 闲置的算力;当你听到 prefill/decode 分离,你知道它在拆解两段脾气相反的负载;当你听到 PagedAttention,你知道它在抢救被浪费的 KV cache 显存。没有这张解剖图,这些都是名词;有了它,它们都是答案。
学完这一章,你已经能解剖任何一次 LLM 推理、能估算一个模型的 KV cache 显存账、能判断一个 serving 系统的瓶颈大概在 prefill 还是 decode、也能读懂 TTFT 和 TBT 这类延迟指标背后指的是什么。这就是你该合上书、准备走进主脊下一段的地方。下一章我们带着这张图去做工程:GPU 一闲着就是在烧钱,到底怎么把它持续喂饱。
本章关键术语
本章引入的 canonical 术语,点进术语表看更完整的解释:
- prefill / decode 一次推理的两个阶段:prefill 并行处理整段 prompt(compute-bound · 决定 TTFT),decode 逐 token 自回归生成(memory-bound · 决定 TBT);二者资源脾气相反,是 Ch6 分离的根因
- KV cache decode 时把每个 token 算过的 Key 和 Value 存下来反复复用,把每步计算从重算整个历史降到只算一个新 token;代价是一块随上下文线性增长、主宰显存的缓存
- TTFT 首字延迟 time to first token:请求到达到第一个输出 token 的时间;主要由 prefill 计算加排队/调度开销决定
- TBT 字间延迟 time between tokens(亦称 TPOT / ITL):decode 阶段每个输出 token 之间的间隔;由 GPU 显存带宽主导
- memory-bound 瓶颈在数据从显存搬到计算单元的带宽、而非算力;decode 是典型(每步搬全部权重加 KV 只算一个 token),对照 compute-bound 的 prefill
参考文献
Mode A · 引用出处(正文 inline,集中列在此)
- Mastering LLM Techniques: Inference Optimization(NVIDIA, 2023)· KV cache 公式、prefill compute-bound / decode memory-bound 的权威表述
- Anatomy of a vLLM Inference(vLLM team, 2025)· prefill 与 decode 性能画像截然不同(compute-bound vs memory-bandwidth-bound)的一手框定
- Efficient Memory Management for LLM Serving with PagedAttention(vLLM)(Kwon et al., UC Berkeley, SOSP 2023)· KV cache 朴素分配浪费六到八成显存(论文 Fig. 2 最低 20.4% 有效)· 分页管理的源头
- Llama 3.1 8B · model card / config(Meta, 2024)· 本章算账用的真实配置(32 层 / 8 KV 头 / head_dim 128 / BF16)
- DistServe(Zhong et al., OSDI 2024)· prefill 计算密集 / decode 显存密集,所以把两阶段拆开(Ch6 的源头之一)
- Anthropic · Performance Engineer, Inference Systems(JD · 岗位帖会轮换,内容收录于
research/system-design/job-postings/anthropic.md)· 把新加速器平台约 350ms 延迟差距追到 server 调度开销的真实案例 · job-boards.greenhouse.io/anthropic
深 dive 资源(可选 · 想往下走再看)
- LLM Inference Performance Metrics(BentoML)· TTFT / TPOT / ITL 的精确定义与口径差异,以及为什么 decode 是 memory-bound
- LLM Benchmarking: Fundamental Concepts(NVIDIA, 2025)· 不同 benchmark 工具对延迟指标的口径差异(GenAI-Perf 与 LLMPerf 算法不同)
- Prefill is Compute-bound, Decode is Memory-bound(Towards Data Science)· 用真实利用率数字把两阶段的反差讲透
说明:本章同样没有 Mode B 视频站 —— 推理服务这类硬核系统主题,目前缺少合格的、人讲解的系统化教学长视频;深入的会议论文(SOSP / OSDI)与厂商工程文档在上方按需列出,由 Mode A 原创讲解扛主线。
下一章
下一章 Ch5 继续走在第三站的主脊上,把这一章的解剖图拿去做第一件工程。你已经看清 decode 阶段 GPU 大量算力在闲置,而 GPU 一闲着就是在烧钱。Ch5 就讲怎么把它喂饱:连续批处理(动态地把陆续到达的请求拼成一批,不必等一整批结束)、请求怎么调度、以及在多个副本之间怎么路由和均衡负载。你还会撞上 serving 工程里那个永恒的张力 —— 把吞吐做上去和把延迟压下来,常常是一对要互相妥协的冤家。