@abhibuilds: https://x.com/abhibuilds/status/2106006454046216461

X AI KOLs Timeline 新闻

摘要

从第一性原理出发,一步步讲解 LLM 推理中 prefill 与 decode 的实际工作原理,从 ChatGPT 式对话如何通过分词(tokenization)处理提示词、并以自回归方式生成 token 说起。

https://t.co/i4AjXSJ4pC
查看原文
查看缓存全文

缓存时间: 2026/10/02 20:45

Prefill 与 Decode,讲得明明白白

这两个术语非常常见。你很可能以前就听说过它们,甚至可能已经知道它们的含义。但我感觉,即使在早先我接触推理(inference)时就知道 prefill 和 decode 这两个词,我其实并没有真正理解它们。

我刚开始做推理时对它们的理解,和它们实际的含义相差甚远。

所以我认为,一个好的、正确的解释可以帮你避免很多错误的思考、错误的想象,或者仅仅是因为你从未真正理解这两个东西到底是什么,而在后续应用中套错了逻辑。

我不会去给出任何 prefill 或 decode 的定义。你可以在任何地方、借助任何 AI 都能找到定义。我只想让你明白这两件事在整个推理流程中处于什么位置。一旦这一点清楚了,我相信你自然就会明白 prefill 和 decode 到底是什么。

(免责声明——这篇文章会非常长,因为我会从第一性原理出发,一步一步、不慌不忙地讲。我希望你也做好这个预期。)

好,我们从一个很简单的例子开始:和 Grok、ChatGPT、Claude 或你选择的任何 LLM 聊天。(为了简单起见,我会用 gpt 来指代)

你打开聊天界面,点击新建对话,然后输入你的 prompt。

假设你输入:

“Hi, my name is X.”

你按下回车。

然后,在很短的时间之后——也许一秒、1.5 秒或两秒——它开始给你回复。

可能最初几个 token 类似于:

“Hello X…”

然后它继续生成回复的其余部分。过了一段时间,当它生成完完整的回复后,它就停止了。所以当我们和 LLM 聊天时,一般都会发生这样的事情:我们给它一些输入,等待一小段时间,然后它开始给我们回复。

现在让我们稍微深入地看看这个整个过程中实际发生了什么。但在深入之前,我们先了解一些基础知识,这样后面可以更好地把东西联系起来。

现代 LLM 如 GPT、Llama、Claude 等,通常都是自回归(autoregressive)语言模型。

什么是自回归?

基本上,模型基于它已有的 token 来预测下一个 token。

假设,作为一个非常简单的例子,模型当前拥有:

“Hello”

基于此,它预测下一个 token。也许这个 token 是:

“world”

现在序列变成了:

“Hello world”

模型现在用 “Hello world” 来预测接下来应该出现什么。

下一个 token 可能是 “!”。同样的过程不断重复。

所以,在我们之前提到的整个生成过程中,LLM 基本上就是在反复重复这个过程。

  • 它首先获取你的输入 prompt。
  • 然后它预测第一个新 token。
  • 现在它拥有了你的原始 prompt 加上新生成的 token。
  • 它用所有这些上下文来预测下一个 token。
  • 然后它拥有了原始 prompt + 第一个生成的 token + 第二个生成的 token。
  • 它预测另一个。
  • 然后再一个。

这个过程一直持续,直到模型生成一个序列结束 token,判断回复已经完成,或者达到了它被允许生成的最大输出 token 数。

所以这些只是一些我们应该了解的基础知识。

现在让我们回到我们的 ChatGPT 会话。你输入了:

“Hi, my name is X.”

并按下了回车。

Prompt 会到达运行模型的服务器。(你可能会有一种直觉:模型会理解这个 prompt,事实也确实如此)

但有一个问题。

你的 prompt 是一个文本(字符串)。

模型不像我们一样能直接理解文本。在内部,模型是一大堆巨大的矩阵和数字(不一定是很大的数字)。

所以我们做的第一件事是对文本进行分词(tokenize)。我们的文本会被分解成 token,每个 token 用一个 token ID 来表示。你可以去 OpenAI tokenizer 自己看看。

Tokenizer

Tokenizer

所以现在我们有了 token ID。

但我们仍然不能直接把这些 ID 传过所有的 transformer 层。

每个 token ID 首先被映射为一个 embedding,也就是一个简单的数字向量。向量基本上是一个包含许多数字(称为维度)的数学行。

Embedding

Embedding

所以假设我们的 prompt 包含 S 个 token,模型使用的 embedding 维度为 D。

每个 token 变成一个大小为 D 的向量。

如果我们把所有 S 个 token 的向量堆叠在一起,我们的输入就变成了一个矩阵:

X ∈ R^(S × D)

其中:

S = 我们输入序列中 token 的数量 D = 模型的隐藏/embedding 维度

所以我们从普通的文本开始:

“Hi, my name is X”

然后:

文本 → tokens → token IDs → embeddings

现在我们有了这个 S × D 的数字矩阵。

这就是真正的 transformer 计算可以开始的地方。这也是 prefill 开始进入画面的地方。

好,现在我们有了这个矩阵 X。

当我们把 X 传入模型时,实际会发生什么?

这个矩阵开始通过 transformer 层。

简化模型架构

简化模型架构

如果你之前看过基本的 transformer 架构,你就知道在每个 transformer 块内部,主要有两件大事在发生:

Attention 和 MLP/FFN。

我不会在这里深入讲解 attention 本身是如何工作的,否则这篇文章就会变成一篇关于 attention 的文章,而不是关于 prefill 和 decode 的文章。

但有一件事对我们来说非常重要。

假设我们的输入有 10 个 token。

你很自然地可能会想象模型会先处理 token 1,然后 token 2,然后 token 3,然后 token 4,以此类推。毕竟,这是一个自回归模型,对吧?

但这正是我最初持有错误理解的地方。

在输入 prompt 的处理过程中,情况不是这样的。

为什么?

因为所有的输入 token 已经都是已知的了。你已经把完整的 prompt 给了模型。如果你的 prompt 是:

“Hi, my name is X”

模型已经知道这个句子中的每一个 token。那它为什么要把这些输入 token 顺序处理呢?为什么不同时处理——那样会快得多,对吧?

它不需要先生成 “my” 才能知道 “name”。

它不需要先生成 “name” 才能知道 “Abhinandan”。

你已经把所有这些 token 给了它。

而且因为所有这些 token 都已经可用了,我们可以把它们放在一起并行处理。

对输入 prompt 的这整个处理过程就是我们所说的 prefill。

基本上,处理整个输入 prompt 的第一次模型前向传播,就叫做 prefill。

Prefill

Prefill

也许一个定义可以是:

Prefill 是模型处理输入 prompt 中已经可用的 token 的阶段。

现在你可能会问一个问题。

如果所有的 token 都是一起处理的,那 token 1 不是就能看到 token 10 了吗?

如果你疑惑这为什么会是个问题,让我们先把这一点讲清楚。

记住,这是一个自回归模型。它的全部工作就是仅使用它前面出现过的 token 来预测下一个 token。假设我们有:

The sky is blue

当模型处于:

The sky is

并试图预测下一个 token 时,它应该只被允许使用:

The sky is

它不应该已经能够看到:

blue

因为这正是它应该要预测的那个 token。

如果它在试图预测 “blue” 的时候已经能看到 “blue”,那我们基本上就是在让模型作弊。

这就像走进考场,正确答案已经被勾选了,然后说:

“是的,我认为这就是答案。”

当然你是这么认为的。你已经看到答案了。

所以当我们说在 prefill 期间所有 prompt token 可以一起处理时,我们不是说每个 token 都被允许去看其他每一个 token。

计算可以并行发生,但每个 token 被允许使用的信息仍然必须遵循自回归规则:

一个 token 可以看到它自己和它前面的 token,但不能看到它后面的 token。

这就是我们所说的 causal mask(因果掩码)。

所以 token 1 只能看到 token 1。

Token 2 可以看到 token 1 和 token 2。

Token 3 可以看到 token 1、token 2 和 token 3。

以此类推。

GPU 仍然可以一起计算所有这些位置。causal mask 只是确保没有 token 能窥探到未来。

或者用更简单的话说:

允许并行计算;不允许看到未来的信息。

这个区分实际上非常重要。

我认为人们可能会有的一种错误想象是:

“自回归就意味着一切都必须逐 token 逐 token 地发生。”

这在生成阶段是正确的。

但在处理 prompt 的阶段,这并不以同样的方式成立。

Prompt 已经存在了。

而输出还不存在。

这个微小的区别正是 prefill 和 decode 成为两个非常不同的阶段的根本原因。

现在让我们更深入地看看在 prefill 期间实际发生了什么。

在 attention 层内部,对于每个 token,我们计算三样东西:

Query、Key 和 Value | Q、K、V

同样,我假设你在这里已经对 attention 有一个基本的了解。

所以如果我们有 S 个 prompt token,每个这些 token 在每个 transformer 层都有自己的 K 和 V。

现在出现了一件极其重要的事情。

在计算出这些 Key 和 Value 之后,我们不会把它们扔掉。

我们会把它们存储起来。

这个存储的 Key 和 Value 的集合就是我们所说的 KV cache。

为什么我们要存储它?

此刻你可能会觉得:好吧,我们计算了一些东西并存起来了。但真正的原因在 decode 开始之后会变得非常清楚。

所以现在只需要记住:

在 prefill 期间,我们处理(基本上是 attention + ffn)所有的 prompt token,在这个过程中,我们为这些 token 创建了 K 和 V,并将它们存储在 KV cache 中。

现在 prompt 经过第 1 层、第 2 层、第 3 层,以此类推,一路穿过整个模型。

最后,我们使用一个叫做 LMhead 的东西来产生 logits。

基本上,logits 意味着在整个词表上的原始分数。

从这些分数中,我们选出下一个 token 应该是什么。

让我们回到我们的例子。

我们有:

“Hi, my name is X.”

经过 prefill 后,也许模型决定下一个 token 应该是:

“Hello”

这很重要。

第一个输出 token 出现在 prefill 阶段的结尾。

这就是为什么当你在 ChatGPT 中按下回车后,通常会有一段等待时间才能看到任何东西。

模型首先必须处理你的输入 prompt。只有在这之后,它才能产生第一个新 token。

这段等待时间通常被称为:

TTFT - Time To First Token(首 token 延迟)

你发送请求。

然后过了一段时间。

然后第一个 token 出现。

当然,TTFT 也受到很多其他因素的影响:调度、网络、批处理、服务器负载等等。

但从模型执行的角度来看,prefill 是这个等待时间中非常重要的一部分。

这也给了你一种直觉。

如果你的输入 prompt 变得非常大,那模型在开始生成回复之前就需要处理更多的 prompt。

好。

现在有些东西变了。

到目前为止,模型处理的所有东西都是你提供的。

所有那些输入 token 本来就已经存在了。

但现在模型已经生成了:

“Hello”

一个自回归模型现在会做什么?

它必须预测 “Hello” 之后应该出现什么。

也许是:

“X”(为了简化,我忽略 token 前面的空格 )

但问题来了。

与输入 prompt 不同,模型并不已经知道 “X”。

它必须先生成 “Hello”。

只有在 “Hello” 存在之后,它才能用它来预测下一个 token。

也只有在下一个 token 存在之后,它才能预测之后的那个 token。

现在我们不能并行处理整个输出,因为未来的输出 token 简直还不存在。

这就是 decode 开始的地方。

Decode 基本上就是生成阶段。

生成一个新 token。

然后这个 token 被用来生成下一个。

然后那个 token 被用来生成下一个。

这个过程一直持续到回复结束。

Decode

Decode

所以如果我要用最简单的方式概括两者的区别:

在 prefill 期间,token 已经是已知的。

在 decode 期间,token 正在被创建。

我觉得这一句话已经很好地说明了为什么它们的行为会如此不同。

但现在还有另一个问题。

假设在 prefill 之后我们有:

“Hi, my name is X.”

模型生成了:

“Hello”

为了生成下一个 token,我们要不要再次从头开始处理:

“Hi, my name is X. Hello”

然后为下一个 token,再处理一遍所有东西?

如果我们这样做,那将是巨大的浪费。

我们已经花了大量的算力来处理原始 prompt。为什么要为每个生成的 token 反复计算同样的东西呢?

这正是我们在 prefill 期间存储的 KV cache 发挥作用的地方。

还记得在 prefill 期间,对于每个 prompt token,我们计算了它的 K 和 V 并将它们存储了起来。

所以现在当 “Hello” 进入模型时,我们只需要为这个新 token 计算新的 Query Q、Key K 和 Value V。

对于所有之前的 token,它们的 K 和 V 已经在 KV cache 中了。

新 token 的 Query 可以关注到那些已缓存的 Key 和 Value。

然后我们为 “Hello” 生成的新 K 和 V 会追加到 KV cache 中。

所以现在 KV cache 中包含了这些信息:

原始 prompt + “Hello”。

当下一个 token 来时,同样的事情再次发生。

我们处理新 token。基本上就是把它传入模型。

它的 Query 关注到已经存在于缓存中的 K/V。

我们为这个 token 生成新的 K 和 V。

将它们追加到 KV cache。

计算最后一个 token 的 logits 并生成下一个 token。

重复。

这基本上就是 decode 循环。

这里有一件事需要非常清楚地说明,因为我自己也曾对此有过困惑。

有时当我们听到:

“KV cache 在 decode 期间节省了计算量”

我们可能会开始想象:在 decode 期间,也许只有 attention 层在运行,或者 transformer 的其他部分以某种方式被跳过了。

事实并非如此。

新 token 仍然会通过完整的 transformer。

它经过 attention。

然后是 MLP。

然后是下一个 transformer 块。

Attention。

MLP。

这个过程会一直持续穿过所有的层。

节省的是,我们不会把所有之前的 token 再次发送穿过那些层。它们的计算已经完成了。

对于新 token,我们仍然要做完整的前向传播。

所以,如果你的上下文当前有 2,000 个 token,在 decode 期间我们不会对所有 2,000 个旧 token 再次运行 MLP。

我们只把新 token 送过 MLP。

对于 attention,新 token 仍然需要回看之前的上下文,而 KV cache 使得这一点可以在不重新计算旧 Key 和 Value 的情况下实现。

这个区分很重要,否则很容易对 KV cache 实际节省了什么建立错误的心智模型。

现在出现了一些更有趣的事情。

Prefill 和 decode 运行的是同一个模型。

相同的 transformer。

相同的权重。

相同的 attention。

相同的 MLP。

但从 GPU 的角度来看,这两个工作负载看起来非常不同。

让我们再次假设我们的 prompt 有 2,000 个 token。

在 prefill 期间,我们有一个包含所有这些 token 表示的矩阵。

所以当我们做 attention 内部或 MLP 内部的线性投影之类的操作时,我们可以执行相当大的矩阵乘法。

有很多工作可以同时发生。

而 GPU 非常擅长这一点。

它们有大量核心(CUDA 核心和 Tensor 核心),当你给它们足够大的矩阵运算时,它们可以让很多核心保持忙碌,进行有用的计算。

所以 prefill 通常具有很高的并行度,因为 token 数量非常高,而且对于每个 token 我们都在做 attention + MLP……

相似文章