☰
预填充与解码(Prefill vs Decode):LLM 推理优化全解析
2026/10/22 17:45:06 网站建设 项目流程

预填充(prefill)与解码(decode)是 LLM 推理的两个阶段。预填充一次性读完整个 prompt 并产出第一个 token,而解码则逐次生成剩下的 token,复用预填充准备好的 KV 缓存。

本文我们讲清楚预填充与解码——LLM 推理的这两个阶段,以及理解它们为什么能帮我们优化 LLM 的速度。我们还会看到两个阶段各自怎么工作、KV 缓存如何把它们连接起来、它们有什么不同、什么时候该用哪一个(取决于你的场景),以及如何分别优化这两个阶段让 LLM 更快。

本文涵盖以下内容:

  • 什么是 LLM 推理?
  • 两个阶段:预填充与解码
  • 用大白话讲预填充
  • 用大白话讲解码
  • 两个阶段与 KV 缓存流转示意图
  • KV 缓存:连接两个阶段的桥梁
  • 走一遍若干个解码步骤
  • 预填充与解码对比表
  • 这个划分为什么重要:计算受限 vs 显存带宽受限
  • 关键指标:TTFT、TPOT、吞吐与端到端延迟
  • 各项优化技术分别对应哪个阶段
  • 结论

什么是 LLM 推理?

在学习预填充与解码之前,我们得先理解 LLM 推理是什么意思。

LLM 是大语言模型(Large Language Model,LLM)。它就是我们日常使用的那些聊天助手背后的模型。

推理(Inference)是使用一个训练好的模型来产生答案的行为。用大白话说,推理就是模型真正开始干活——读懂你的问题,然后写回一个回答。

这里最重要的一点是:LLM 不是一次性写出整段答案的。它一小块一小块地写,每一小块叫作一个 token。token 是一小段文本,可能是一个词,也可能是一个词的一部分。

所以,模型先产出一个 token,再产出下一个,再下一个,直到整个答案写完。这就是为什么你用聊天助手时,能看到文字像有人在打字一样一个个冒出来。这种实时逐 token 出现的效果叫流式输出(Streaming)。

为了对规模有点感觉:一个短句大约是 15 到 20 个 token。所以 1000 个 token 的 prompt 差不多是一页文字,而 200 个 token 的回答就是几句话。

一个请求(Request)就是某个用户发给模型的一个问题。当我们说服务器要处理很多请求,意思就是它要回答很多用户的问题。

到这里我们就懂了什么是 LLM 推理:模型逐 token 生成文本。

两个阶段:预填充与解码

LLM 每回答一次请求,都要经过两个截然不同的阶段。

预填充是第一个阶段。它读取并处理你发来的整个输入 prompt。

解码是第二个阶段。它一次一个地生成输出 token。

用大白话说:预填充是模型在读你的问题,解码是模型在写答案。

两个阶段用的是完全同一个模型。它们不用不同的模型,也不用不同的权重。权重是模型在训练中学到的数以百万计的数字,是模型「知道」的东西,数量大到要占掉 GPU 显存的一大块。这个事实我们待会儿会用到。不同的只是两个阶段的工作方式,而这点小小的差别会改变一切——后面你就会看到。

我们来把每个阶段都吃透。

用大白话讲预填充

预填充是这样一个阶段:模型一次性读完并处理你的整个输入 prompt,然后产出第一个输出 token。

理解它最好的方式是举个例子。

假设你粘贴了一个 1000 token 长的问题。在预填充阶段,模型并不会一个接一个地读这 1000 个 token,它是把 1000 个 token 同时一起读。

想象一个学生走进考场。动笔之前,他先把整张试卷默默读一遍,一眼扫过每道题,快速记下要点。这个「读完整张试卷」的部分就是预填充。

因为整个 prompt 一开始就全都在手上了,所以不需要等。模型可以同时处理每一个输入 token。这叫并行处理(parallel processing),意思就是很多事情一起发生,而不是一个接一个。

在读 prompt 的同时,模型还会为后面准备一些笔记:对每个输入 token,它计算并保存两份特殊数据,叫作 Key(K)和 Value(V)。所有存下来的 Key 和 Value 合在一起,就构成了 KV 缓存。现在先不用纠结 Key 和 Value 到底是什么意思,你只要知道 KV 缓存是模型对你这段 prompt 的记忆就够了,细节我们马上会讲。

所以,预填充用「模型对每个输入 token 的笔记」把这个 KV 缓存填满。而在预填充结束时,模型产出第一个输出 token。

这里有一点非常重要:预填充只产出第一个输出 token,不是整个答案。剩下的所有 token 都留到后面、由解码阶段产出。

预填充是计算受限的(compute-bound)。计算受限的意思是,速度被数学运算卡住,别的东西都不是瓶颈。GPU 是跑模型的强力芯片,GPU 内部有数学单元,真正做乘法的就是它们。在预填充期间,这些数学单元被安排得很满,干着很重的数学活。在高端 GPU 上,处理大 prompt 时数学单元的利用率常常能接近 90% 甚至更高,GPU 被用得很充分。

为什么预填充的数学这么重?因为很多 token 一起处理时,工作量就变成了大数字表之间的大规模乘法。这种大数字表叫作矩阵(matrix),而把两个大表相乘正好是 GPU 天生擅长的事,所以 GPU 能跑到接近满功率。

还有一点要知道:prompt 越长,预填充越久。简短的问题几乎感觉是瞬间的。但如果你把一份巨大文档塞到模型面前,预填充就会变长,第一个字出现之前你得等更久。

实际上,预填充里有一部分工作量增长得比 prompt 长度还快。模型内部有一个步骤叫注意力(attention),每个 token 都要看其他所有 token 来理解上下文,所以把 prompt 长度翻倍,这部分工作量大约要翻四倍。给个具体数字:prompt 从 100 个 token 翻到 200 个 token,这部分工作量大约变成 4 倍,而不是 2 倍。也就是说,超长 prompt 在第一个字出现之前会让人等得难受。打个比方:10 个人的房间里大家互相握手还应付得来,但 100 个人的房间里握手次数就爆炸了。房间越大(prompt 越长),工作量堆得越快。

这「第一个字出现之前的等待」重要到有自己的名字,我们在讲指标时会学到。现在只要记住一句话:

预填充越快,你越早看到第一个 token。

到这里预填充讲完了,我们进入解码。

用大白话讲解码

解码是这样一个阶段:模型一次一个地生成输出 token,并复用预填充准备好的 KV 缓存。

接着用考试那个例子:读试卷是预填充,现在学生开始一个字一个字地写答案。写一个词,想一想,再写下一个词,如此继续。这个慢吞吞、一个一个词写的过程就是解码。

在解码中,模型每一步只生成一个新的 token,然后把这个 token 再喂回自己,帮助产出下一个 token。因为每个新 token 都依赖刚产出的那个 token,所以 token 必须严格串行地一个接一个。这叫自回归生成(autoregressive generation)。用大白话说:模型不断把自己的输出当作新输入喂回去。

所以解码是串行的。它不能像预填充一次性处理所有输入 token 那样,一次性处理所有输出 token——每个 token 都得等前一个。

接下来是最巧妙的地方:在每一个解码步骤里,模型都不会重新读一遍、重新处理一遍你的整个 prompt,那样太慢也太浪费。它读的是 KV 缓存——预填充已经准备好的那份「记忆」。它只为这一个新 token 计算 Key 和 Value,从缓存里读出所有更早的 Key 和 Value,产出下一个 token,然后把新 token 的 Key 和 Value 追加进缓存。

所以,每一个解码步骤,缓存正好增长一条记录。

解码是显存带宽受限的(memory-bandwidth-bound),这一点正好和预填充相反。解码的速度被「数据从 GPU 显存搬到数学单元有多快」卡住,而不是被数学运算本身卡住。

我们一步一步看为什么。

解码中每步只处理一个 token。所以它不是像预填充那样两个大表相乘,而是用大表去乘一行很细的数字——也就是那一个新 token。这样一行细数字叫作向量(vector)。因此,一个 token 的数学量很小。

但即便只产出这一个 token,GPU 仍然必须把整个模型的权重和整个还在增长的 KV 缓存从显存里读出来。别忘了权重非常巨大,这是非常大的一批数据要搬。显存带宽是数据搬出 GPU 显存的速度。这里有个关键事实要记住:在 GPU 内部,做数学非常快,但从显存里搬大量数据相对很慢。

于是,GPU 大部分时间都在等数据到位,它的数学单元大多数时候闲着。在解码期间,处理单个请求时高端 GPU 的数学单元利用率大概只有 20% 到 40%。

再打个比方:想象一个厨师必须走进巨大的储藏室,只拿一样食材,走回来,切好,再走一趟拿一样。来回走路(从显存搬数据)花的时间远多于切菜(那点小小的数学运算)。刀(GPU 的数学单元)大部分时间都闲在那。

所以,预填充让 GPU 忙着算数学,解码让 GPU 忙着等显存。

还有一个有意思的点:对一次典型请求来说,预填充往往几百毫秒就结束了,而解码可能跑好几秒,因为它要一个接一个地生成很多 token。所以解码通常占总时间的大头,尽管它只用了 GPU 数学算力的一小部分。这正是为什么大量优化都聚焦在解码阶段。

记住这句话:

解码越快,你越快看到答案剩下的部分。

现在两个阶段都讲完了,我们把它们放在一起看。

两个阶段与 KV 缓存流转示意图

我们来看一张清晰的图:一次请求如何流过预填充、再进入解码。

INPUT PROMPT(输入 prompt,例如 1000 个 token) | v +-----------------------------+ | PREFILL 预填充(一个大步骤) | | - 并行读取全部输入 token | | - 写入 KV 缓存 | | - GPU 计算单元非常繁忙 | +-----------------------------+ | | 产出第一个输出 token v +-----------------------------+ +------------------+ | DECODE 解码(很多小步骤) | <--> | KV 缓存 | | - 每步一个 token | 读/写 | (到目前为止所有 | | - 读取 KV 缓存 | 增长 | token 的记忆) | | - 追加一条新记录 | +------------------+ | - GPU 主要在等显存 | +-----------------------------+ | | token 2、token 3、token 4 …… token N v 完整答案

这里可以清楚看到时间线:有且只有一次预填充,然后是很多个解码步骤。如果你的答案是 200 个 token,那就是 1 次预填充加 200 个串行解码步骤。KV 缓存由预填充写一次,之后由每一个解码步骤读取并扩展。

两个阶段就是这样协作的。

KV 缓存:连接两个阶段的桥梁

KV 缓存是连接预填充与解码的桥梁,所以我们必须把它搞明白。

先看它解决的问题。

在模型内部,要产出下一个 token,模型必须回看之前每一个 token。对每个 token,模型会计算三样东西:Query(Q)、Key(K)和 Value(V)。新 token 把自己的 Query 与所有历史 token 的 Key 做比较,然后混合它们的 Value 来决定接下来该说什么。

关键的观察是:一个已经过去的 token,它的 Key 和 Value 一旦算出来就永远不变,不依赖后面出现的 token。所以与其每一步都重算,不如算一次就存起来。这个存放 Key 和 Value 的仓库,就是 KV 缓存。

KV 缓存只存历史 token 的 Key 和 Value,不存 Query。Query 是每一步为当前 token 新算的,没必要存。

现在用一个小规模的数字推演看看为什么这件事如此重要。为了便于理解,把一个缓存记录看作一个 token 存下来的 Key 和 Value。

假设预填充已经处理完一个 3 个 token 的 prompt,所以 KV 缓存里已经有 3 条记录。现在解码开始。

第 1 步:模型取第一个输出 token 的输入,算出它的 Key 和 Value,并读取已缓存的 3 条记录做回看。它产出第 4 个 token,然后把第 4 个 token 的 Key 和 Value 追加进缓存。缓存现在有 4 条记录。

第 2 步:模型取第 4 个 token,算出它的 Key 和 Value,读取已缓存的 4 条记录做回看。它产出第 5 个 token,然后把第 5 个 token 的 Key 和 Value 追加进缓存。缓存现在有 5 条记录。

第 3 步:模型取第 5 个 token,算出它的 Key 和 Value,读取已缓存的 5 条记录做回看。它产出第 6 个 token,然后把第 6 个 token 的 Key 和 Value 追加进缓存。缓存现在有 6 条记录。

我们把这些步骤里缓存的增长画出来。

预填充之后 |P1|P2|P3| -> 缓存 3 条记录 解码第 1 步 |P1|P2|P3|T4| -> 追加 T4,现在 4 条 解码第 2 步 |P1|P2|P3|T4|T5| -> 追加 T5,现在 5 条 解码第 3 步 |P1|P2|P3|T4|T5|T6| -> 追加 T6,现在 6 条

这里 P1、P2、P3 是预填充写下的 prompt token,T4、T5、T6 是解码每步追加一个格子加进来的。

规律很明显:每一步模型只为一个新 token 计算 Key 和 Value,其余更早的 Key 和 Value 都从缓存里读,不再重算;而缓存每步正好增长一条记录。

注意:没有 KV 缓存的话,每个解码步骤都要从零重算所有历史 token 的 Key 和 Value。由于第 N 步要把前面 N 个 token 的活全重做一遍,整段答案累计的工作量大致按长度的平方增长,会慢得离谱。KV 缓存把这件事压到「每个 token 的 Key 和 Value 只算一次」。这就是为什么 KV 缓存不只是一个小优化——没有它,解码会慢得多。

但这里有个代价:KV 缓存会一直增长。预填充之后它从 prompt 的大小开始,然后每生成一个 token 就增长一条记录。它也会随着同时服务的用户数增长,因为每个用户都需要自己的缓存。极端情况下,比如上下文极长且并发用户很多,KV 缓存总量甚至可能超过模型权重本身的大小,把 GPU 显存塞满。这个不断增长的缓存,正是我们后面会看到的那一大堆优化存在的原因,也是为什么会有「KV 缓存压缩(KV Cache Compression)」这一整类技术专门用来把缓存本身压小。

KV 缓存就是这样充当预填充与解码之间的桥梁。

预填充与解码对比表

我把预填充与解码的差异列成表格,方便你对照理解。

维度预填充(Prefill)解码(Decode)
做什么处理整个输入 Prompt逐个生成输出 Token
并行性并行(所有 Prompt Token 一起来)串行(一次一个 Token)
步骤数一个大的步骤很多小的步骤(N 个)
数学形态大表 × 大表大表 × 一个细向量
瓶颈计算受限(受数学速度限制)显存带宽受限(受数据搬运限制)
GPU 利用率高(数学单元繁忙,可接近 90% 或更高)低(数学单元大多空闲,约 20%~40%)
KV 缓存动作写入缓存读取并扩展缓存
延迟指标首 Token 延迟(TTFT)单 Token 输出时间(TPOT)
成本驱动随 Prompt 长度增长每一步都要流式读取全部权重加 KV 缓存

这张表概括了整个故事:预填充与解码互为镜像。

这个划分为什么重要:计算受限 vs 显存带宽受限

那么问题来了:我们为什么这么在意这两个阶段的划分?

答案是:这两个阶段有相反的瓶颈,而几乎所有优化都来自这一个事实。瓶颈(bottleneck)就是拖住整个流程的那一个慢环节,就像瓶子的细颈限制了水的流出速度。

预填充是计算受限的。它一次对很多 token 做重数学运算,所以把 GPU 的数学单元用得很满,工作量变成大表乘大表,这正是 GPU 喜欢的。

解码是显存带宽受限的。它只对一个 token 做很小的数学,但每一步都必须把整个模型加上还在增长的 KV 缓存从显存里流式读出来。于是 GPU 大部分时间在等显存,数学单元闲着。

记住两者差别有个简单说法:预填充像一个厨师一口气把一堆菜全切了,手一直没停;解码像一个厨师一道一道摆盘,而且每摆一道都要跑一趟冰箱,慢的是跑冰箱,不是摆盘。

把两个阶段并排放。

PREFILL 预填充(计算受限) DECODE 解码(显存带宽受限) 一次处理很多 token 一次处理一个 token GPU 数学单元: [#########-] GPU 数学单元: [##--------] 约 90% 忙 约 20% ~ 40% 忙 受限于:做数学运算 受限于:搬数据 (模型权重 + KV 缓存)

这里能清楚看到镜像关系:预填充几乎把数学单元填满,而解码让它们大部分空闲、转而去等显存。

这一个差别解释了两件大事。

第一,它解释了为什么长 prompt 会让答案开始前等得更久——长 prompt 意味着更多预填充数学。

第二,它解释了为什么在解码阶段可以同时服务很多用户而几乎不额外增加成本——既然一个解码步骤几乎不用 GPU 的数学算力,我们就可以把很多用户的解码步骤一起跑。这个思路叫批处理(batching),我们会在优化那一节看到。

正因为两个阶段对硬件的要求不同,有些服务系统甚至把预填充和解码放到不同的 GPU 上跑,这个我们也会讲到。

关键指标:TTFT、TPOT、吞吐与端到端延迟

要优化任何东西,都得先能量它。有几个关键指标,每一个都干净地对应到一个阶段,所以这一节把整件事串起来。

TTFT(Time To First Token,首 token 延迟)是从你发出请求,到第一个输出 token 出现的时间。它主要由预填充决定,因为第一个 token 必须在预填充完成后才能产出。粗略地说,TTFT 等于预填充时间加一个解码步骤。所以 prompt 越长,TTFT 越大。用大白话说,TTFT 是答案开始出现得有多快。

TPOT(Time Per Output Token,单 token 输出时间)是产出第一个 token 之后,平均每个 token 所需的时间。它也叫 ITL(Inter-Token Latency,相邻 token 间隔),意思是两个 token 之间的时间差。TPOT 由解码决定,因为这些 token 是解码一个个产出的。用大白话说,TPOT 是答案开始之后「打字的速度」。给你个直觉:TPOT 为 50 毫秒意味着每秒 20 个 token,TPOT 为 25 毫秒意味着每秒 40 个 token。所以 TPOT 减半,用户感受到的流式速度就翻倍。

注意:对单个请求来说,你可以把 TPOT 和 ITL 当成同一件事,即每个 token 的间隔。跨多个请求时它们的测法略有不同,但理解上是一回事。

吞吐(throughput)是同时服务的所有用户加起来、每秒生成的 token 总数。这是系统级指标,不是单用户指标。用大白话说,吞吐是这个服务器能同时服务多少用户。批处理——也就是把很多请求一起送进 GPU 跑——能提高吞吐。一起跑的这一组请求叫一个批次(batch),里面的请求数叫批大小(batch size)。

端到端延迟(end-to-end latency)是从发出请求到收到最后一个 token 的总时间。我们可以用一个简单公式建立直觉:

端到端延迟 = TTFT + (输出 token 数 - 1) × TPOT

这里第一个 token 已经算在 TTFT 里了,所以用输出 token 数减一。先是 prompt 被处理(TTFT),然后剩下每个输出 token 各加一个 TPOT。

做个小算例:假设 TTFT 是 400 毫秒,答案是 200 个 token,TPOT 是每 token 25 毫秒。答案是 200 个 token,但第一个已经算进 TTFT 了,所以还剩 199 个,每个花 25 毫秒。那么端到端延迟大约是 400 毫秒加上 199 乘以 25 毫秒,约 5.4 秒。

把这条时间线画出来。

请求 发出 | v |<------ TTFT ----->| TPOT | TPOT | TPOT | ... | TPOT | | 预填充 + 第1个token| 第2个| 第3个| 第4个| ... | 第N个| 端到端延迟 = TTFT + (N - 1) × TPOT

可以看到 TTFT 覆盖了到第一个 token 为止的一切,之后每个 token 相隔一个 TPOT 到达。所以答案是「先等 TTFT,再以 TPOT 的速度流出来」。

还有一组核心权衡必须理解:TTFT 和 TPOT 是单用户延迟,越小越好;吞吐是系统指标,越大越好。它们互相拉扯。更大的批次让 GPU 每秒吐出更多 token(吞吐更高),但让每个用户等得更久(TTFT 和 TPOT 变差)。更小的批次感觉更跟手,但浪费 GPU 能力。我们按场景来调批大小:聊天机器人想要低 TTFT 和低 TPOT,而批量文档摘要任务想要高吞吐。

现在我们知道了每个阶段各自拥有哪个指标:预填充拥有 TTFT,解码拥有 TPOT 和吞吐。所以做优化时,我们永远先问:我要改善的是哪个指标。

各项优化技术分别对应哪个阶段

现在来讲主要的优化技术。妙处在于每一种技术都针对特定阶段。所以我们会用平实的话说清每种技术做什么、帮的是哪个阶段或哪个指标。

KV 缓存(基础,针对解码)

这个我们已经学过了。KV 缓存保存所有历史 token 的 Key 和 Value,让每个解码步骤只为一个新 token 干活。这是解码能快起来的根本原因。但缓存会随每个 token 增长并塞满显存,这正是下面所有其他技术存在的理由——都是为了管理它。

连续批处理(针对吞吐)

先看老办法和它的问题。

老办法叫静态批处理(static batching):服务器把若干请求分成一组,然后等这一组里最慢的那个跑完,才开始下一组。问题在于短请求很早就结束了,它们占的槽位空着,GPU 被浪费。看看新办法怎么解决。

连续批处理(continuous batching)把「怎么组批」的决定从每组一次改成每个解码步骤都做一次。某个请求一结束,它的槽位立刻释放,正在等待的请求在下一步就加入批次。于是批次一直是满的,GPU 一直忙。

我们把差别画出来。

静态批处理(结束的槽位空着 -> GPU 被浪费) 槽位 1: R1===== (空闲,浪费) ......... 槽位 2: R2========================== 槽位 3: R3========== (空闲,浪费) ............... |------- 整批都在等最慢的那个 -------| 连续批处理(结束的槽位立刻被填上 -> GPU 保持繁忙) 槽位 1: R1=====R4==================== 槽位 2: R2========================== 槽位 3: R3==========R5================ (R1 结束的瞬间 R4 进来,R3 结束时 R5 进来)

可以看到静态批处理在短请求提前结束时留下空槽,而连续批处理立刻把这些槽补满。

打个比方:像一辆拼车,一个乘客刚下车,马上就把路边等着的下一位接上,而不是空着座位绕到所有人行程都结束。这辆车从不空座跑。

相比老的静态做法,连续批处理通常能带来吞吐的大幅跃升。

分块预填充(针对其他用户的流畅输出)

先看问题:一个很长 prompt 的预填充是作为一个巨大步骤跑的。这个巨大步骤运行期间独占 GPU,会把已经处于解码阶段的其他用户的 token 流冻住,他们的回答就一顿一顿的。

分块预填充(chunked prefill)把一个长 prompt 的预填充切成好几块,而不是一个巨大步骤,然后在块与块之间插进小的解码步骤。这样任何单个长预填充都不会阻塞其他人。它保护了其他用户的 token 流,让他们的回答不卡顿。额外好处是:把计算密集的预填充和显存密集的解码混在一个批次里,硬件利用率更高。

画出来看。

没有分块预填充: 长 prompt: [============= 一个巨大的预填充 =============] 其他用户: tok ....... (被冻住,卡顿) ....... tok 有分块预填充: 长 prompt: [块1][块2][块3][块4][块5] 其他用户: tok tok tok tok tok tok ^ 块与块之间插进了一个解码步骤

可以看到一个巨大预填充会冻住其他用户,而分块预填充让他们的 token 在块之间继续流动。

打个比方:像一个只有一个灶的厨师。如果一位客人点了巨型宴席,一口气全做完会把其他每桌的菜都冻住。于是厨师把宴席分成小份来做,中间插进那些能快速出的小菜,谁的盘子都不会凉。

注意:分块预填充主要是保护正在进行的回答不被打断,它不改变数学总量,所以它本身并不会让预填充变快。

前缀缓存(也叫 prompt 缓存,针对预填充与 TTFT)

很多请求都以同一段很长的开头开始。比如同一段固定的系统指令、同一个用来提问的文档,或者同一段更早的聊天轮次。这段共享的开头叫前缀(prefix)。

前缀缓存(prefix caching)把这段共享前缀的 KV 缓存跨请求复用:共享部分的预填充只算一次,之后直接复用,而不是每个请求都重算。这样跳过了冗余工作,缩短了 TTFT。

打个比方:像一位老师每节课都在黑板上写同样一段长长的说明。与其每次重写,他给黑板拍张照片,下次直接复用,省下来的时间只花在每个学生的新问题上。

在生产环境里,这能在重复 prompt 上带来非常高的缓存命中率和很大的成本节省。缓存命中率高意味着大部分请求发现自己的共享开头已经存好待用,那部分工作就被跳过了。注意:前缀缓存只在这些请求确实共享同一段开头时才有用;如果没有共享前缀,就会退回完整的预填充。

分离式服务(也叫预填充-解码分离,同时针对 TTFT 与 TPOT)

我们学过:预填充计算受限,解码显存带宽受限。当一个 GPU 两者都干时,一阵重的预填充会卡住其他用户正在进行的解码,而且我们没法分别调优两个阶段。

分离式服务(Disaggregated Serving)把预填充放到一组 GPU 上跑,解码放到另一组 GPU 上跑。这里「一组」(Pool)就是协同工作的一群 GPU。预填充 GPU 产出的 KV Cache 通过高速链路传给解码 GPU。这样每组都按自己的瓶颈配置,各自独立达成自己的延迟目标。

画出这两组。

预填充组(PREFILL POOL) 解码组(DECODE POOL) +-------------------------+ +-------------------------+ | 为重型数学调优的 GPU | | 为快速访存调优的 GPU | | (计算受限) | KV 缓存 | (显存带宽受限) | | | =======> | | | 读 prompt,写 KV 缓存, | 高速链路 | 读缓存,逐 token 流出答案 | | 产出第一个 token | | | +-------------------------+ +-------------------------+

可以看到预填充组建好 KV Cache 并通过高速链路交给解码组,每组都针对自己的瓶颈调优。

打个比方:像一家餐厅把重型备菜厨房放一个房间,把稳定的摆盘出餐线放另一个房间。每个房间的人手和设备都按自己那类工作配置,备好的食材(KV Cache)在两边之间传递,谁也不拖慢谁。

注意:分离不是免费的。它增加了把 KV 缓存通过网络搬运的成本,也把模型在两个组里各复制了一份。它主要在大规模场景下划算——当你必须同时满足严格的 TTFT 和 TPOT 目标时。规模小一些的部署,用单 GPU 上的连续批处理加分块预填充通常就够了。

SGLang 服务系统支持这种预填充-解码分离,也支持分块预填充和前缀缓存。TensorRT-LLM 同样支持分块预填充,把长 prompt 切片,使它永远不阻塞其他用户的解码步骤。(SGLang 与 TensorRT-LLM 的工作原理各有专文详解。)

投机解码(针对解码与 TPOT)

这一招很聪明,它之所以有效,是因为解码是显存受限的。

投机解码(speculative decoding)用一个小而快的模型——叫草稿模型(draft model)——低成本地猜出接下来的若干个 token。然后大模型——叫目标模型(target model)——用一次并行的前向把所有这些猜测一次性校验。猜对的保留;第一个猜错的以及它之后的全丢掉,由大模型纠正。

为什么有用?因为解码是显存受限的,大模型的权重反正都得从显存读出来。一次校验 5 个猜出来的 token,从显存搬的数据量和生成 1 个 token 几乎一样。所以每一个猜对的 token 几乎都是白送的。这就把「一次一个」的慢过程变成了更快的并行过程。

画一轮投机解码。

草稿模型(小、快)用一次低成本前向提出 5 个 token: g1 g2 g3 g4 g5 目标模型(大)用一次并行前向校验全部: ok ok ok X - ^ 第一个猜错的:由大模型纠正,g5 丢弃 结果:接受 3 个猜测 + 1 处修正,成本约等于 1 个正常步骤

可以看到大模型一次前向里接受了正确的猜测并修正第一个错误,于是用大约一个 token 的成本产出了好几个 token。

打个比方:一个反应快的初级助手猜老板接下来要说哪几个词,老板扫一眼整段猜测,对的点头、错的改第一处。校验一段猜测远比从零构思快。

非常重要:投机解码的设计目标是保持模型的输出质量,它只改变速度。当模型每次都取最可能的那个 token 时,产出与只用大模型完全一致。当模型带随机性取 token 时,输出的采样分布与大模型用的完全相同的概率集合,所以即使每次具体措辞可能不同,质量依然保持不变。典型的解码加速大约是 2 到 3 倍,取决于小模型的猜测被接受的比例。

草稿模型不是唯一的猜测方式:也可以在大模型自身加额外的头来猜接下来几个 token;也可以在大模型内部特征层面起草以提高猜测接受率;还有 N-gram 投机——完全不需要起草模型,直接从 prompt 里复制现成的猜测。(Medusa、EAGLE、N-gram Speculation 各有专文详解。)

PagedAttention(针对 KV 缓存的显存效率)

先看问题:在朴素的系统里,每个请求的 KV 缓存被存成一大块连续显存,大小按「可能的最长答案」预留。问题是这样预留的空间大部分一直空着,被浪费掉,浪费量可能非常大。

PagedAttention 把 KV 缓存切成固定大小的小块(通常是每块 16 个 token),这些块可以散落在显存任意位置,而不是每个请求一大块连续内存。一张小表叫块表(block table),记住哪些散块属于哪个请求。只有当序列真的变长时才新增一个块。

画出差别。

没有分页(一开始就预留一大块连续空间): 请求 A: [已用][已用][已用][------ 已预留但为空 ------] 请求 B: [已用][已用][--------- 已预留但为空 ---------] 每块内部都空着未使用 有分页(固定大小的小块,按需分配): 请求 A -> 块 7、块 2、块 9(散落在任何位置) 请求 B -> 块 4、块 1 块表记住哪些块属于哪个请求 有共享前缀?两个请求可以指向同一个块

可以看到连续分配预留一大块并浪费空白部分,而分页只在序列增长时才发小块。

这是借用了计算机里一个久经考验的老办法——分页(paging):内存按小的等份发放,只在实际需要时才给。

打个比方:不再为每位客人的行李要一整个大空停车场,服务生改用散布在楼里的一堆带编号的小储物柜,并用一张纸条记下哪些柜子属于谁。没有空白预留场地被浪费。而如果两个人行李完全一样(共享前缀),他们直接共用同一个柜子就行。

PagedAttention 几乎消除了这部分浪费,让我们能容纳更大的批次、提高吞吐。它也是让前缀缓存成为可能的基础——共享前缀的块可以直接指向同一个物理块。

注意:PagedAttention 是避免浪费的显存布局,而前缀缓存是跨请求复用已算好的 KV。两者是不同的概念,但正是 PagedAttention 让前缀缓存的块共享成为可能。

PagedAttention 由 vLLM 服务系统提出(vLLM 的工作原理有专文详解)。

一张速查表:哪种技术帮哪个阶段

我把这些技术以及它们针对的阶段或指标列成表,方便你对照。

技术主要针对改善了什么
KV 缓存解码避免重算,让解码变快
连续批处理两个阶段吞吐(同时服务更多用户)
分块预填充对其他用户的服务流畅度防止长预填充卡住正在进行的 token 流(保护别人的 ITL)
前缀缓存预填充TTFT(跳过冗余的 prompt 处理)
分离式服务两个阶段大规模下独立优化 TTFT 与 TPOT
投机解码解码TPOT(更快地产出 token)
PagedAttentionKV 缓存显存吞吐(更大批次、更少浪费)

这张表是张好用的心智地图:想要第一个字更快,看预填充这一侧;想要流得更快或同时服务更多用户,看解码这一侧。

结论

我们用一条简单的时间线把一切回收一遍。

LLM 分两个阶段回答。预填充用一次并行前向读完你的整个 prompt,填满 KV 缓存,产出第一个 token。它是计算受限的,它决定 TTFT——也就是答案开始得有多快。随后解码一次一个 token 生成答案剩下的部分,每一步都读取并扩展 KV 缓存。它是显存带宽受限的,它决定 TPOT——也就是答案流出的速度。

KV 缓存是连接两个阶段的桥梁:预填充写它,解码读它并让它增长。它让我们不必每一步都重算过去,但它会随每个 token 增长、塞满显存。

因为两个阶段瓶颈相反,每项优化都针对其中之一:连续批处理和 PagedAttention 提高吞吐;前缀缓存和分块预填充改善预填充与输出流畅度;投机解码加速解码;分离式服务把两个阶段拆开,各自达成自己的目标。

常见问题

预填充和解码用的是不同模型吗?

不是。两个阶段用的是完全同一个模型、同一份权重,不同的只是工作方式。预填充一次并行前向读完整个 prompt,解码一次一个 token 写答案,而这点小小的差别决定了它们在瓶颈上的一切不同。

为什么解码占了总响应时间的大头?

解码占大头是因为它要一个接一个地产出很多 token。对一次典型请求,预填充往往几百毫秒就结束,而解码可能跑好几秒。这正是大量优化聚焦解码阶段的原因,尽管解码只用到 GPU 数学算力的一小部分。

KV 缓存也存 Query 吗?

不存。KV 缓存只存历史 token 的 Key 和 Value。Query 是每一步为当前 token 新算的,没必要存。历史 token 的 Key 和 Value 一旦算出就不变,所以可以存下来复用。

投机解码会降低输出质量吗?

不会。投机解码的设计目标是保持输出质量,只改变速度。当模型每次都取最可能的 token 时,产出的 token 与只用大模型完全一致;在有随机性时,输出也从同一组概率中采样,所以即使措辞有差异,质量依然保持。

分块预填充会让预填充变快吗?

不会。分块预填充不改变数学总量,所以它本身不会让预填充更快。它主要是保护正在进行的回答不被打断:把长预填充切成小块,中间插进解码步骤,这样其他用户的 token 流就不会卡顿。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询