↓ 跳过正文
  1. Posts/

从一次 Forward 到持续生成:LLM Inference 为什么不一样?

·5127 字·11 分钟

最近在从传统模型 Serving 转向 LLM Inference,一个让我停下来想了很久的问题是:为什么 LLM 的推理系统看起来和我之前熟悉的那套完全不一样?

我之前做推荐模型和 CV 模型的部署,排序模型的 Serving 架构虽然在工程上也有不少复杂性(远程 embedding 读取、稀疏参数缓存等),但核心计算模式不难概括:一个请求进来,跑一次 forward,返回结果。LLM Serving 里冒出来一大堆新概念——推理要分成 Prefill 和 Decode 两个阶段,中间要维护 KV Cache,请求调度要做 Continuous Batching,显存管理要用 PagedAttention——我花了些时间才把它们背后的因果链串起来。

这篇是我理清这条链路之后的记录。

传统推理:一个熟悉的起点
#

先回到传统模型推理的执行模型。一个典型的分类或回归模型,推理过程可以写成:

$$ y = f_\theta(x) $$

输入 \(x\) 进来,模型权重 \(\theta\) 是共享的,一次 forward pass 产出结果 \(y\),请求结束,临时的中间 activation 可以释放。多个请求在 batch 维度上拼起来一起算,TensorRT 那篇聊过这种"来什么 shape 都要能跑"的动态推理演进。

对于大多数分类、回归、推荐模型,这个执行模型有几个对系统设计友好的特点:

  • 单个请求通常一次 forward 完成。 请求的计算量在到达时就基本确定了。
  • 计算模式相对固定。 给定模型结构和输入 shape,forward 的算子序列、中间 tensor shape 都是已知的。
  • 中间 activation 不需要跨请求的多个执行轮次保留。 一次 forward 结束后,中间结果可以丢弃。
  • Batch 通常在请求维度上直接组织。 多个请求的输入拼成一个大 tensor,一起过模型。

这里需要注意,这不是所有模型的情况。RNN 和流式模型本身就有跨时间步的状态,语音和视频处理可能涉及多次 forward。但对于我之前负责的分类、推荐、检测模型,上面的描述基本成立。

所以我刚接触 LLM Serving 的时候,第一反应是:不就是把 Transformer 模型部署上去跑 forward 吗,能有多大区别?

自回归生成:一次请求不再等于一次 Forward
#

考虑一个简单的 prompt:

1The capital of France is

模型需要生成后续的 token。但它不是一次把整个回答都算出来的——第一步预测出 Paris,拿到 Paris 之后才能预测下一个 token(比如 .),再拿到 . 才能继续生成。

(这里的 token 切分只是示意,实际 tokenizer 的切分结果可能不同。)

这就是自回归生成。从概率的角度看(之前写过的 next-token prediction那篇有更详细的推导),生成一个 token 序列等价于:

$$ P(y_1, \ldots, y_T \mid x) = \prod_{t=1}^{T} P(y_t \mid x, y_{\lt t}) $$

每一步只预测一个 token,且依赖前面已经生成的所有 token。在标准自回归解码中,模型无法跳过 \(y_t\) 直接去算 \(y_{t+1}\)。

这带来了一个根本性的变化:一次完整请求不再等于一次 forward。 如果要生成 200 个 token,模型需要执行多次 forward(具体次数取决于实现方式,后面会讲)。请求的生命周期不再是"进来 → 算一次 → 出去",而是"进来 → 计算 → 等待采样 → 继续计算 → … → 直到生成结束标记"。

传统推理与 LLM 推理的执行模式对比

Forward 的执行次数与请求的生命周期解耦了。这是后面所有复杂性的源头。

Prefill 与 Decode:已知的和未知的
#

理解了自回归之后,我紧接着想的一个问题是:prompt 的 token 和生成的 token,处理方式一样吗?

假设输入 prompt 有 1000 个 token,目标生成 200 个 token。

Prompt 的 1000 个 token 在请求到达时已经全部确定。虽然 Causal Attention 要求位置 \(i\) 只能看到 \(j \le i\) 的信息,但这个约束限制的是信息访问方向,不是执行方式。所有 token 都已知的情况下,Transformer 的每一层可以同时处理全部 1000 个位置——Causal Mask 在 Softmax 之前把未来位置的 score 遮掉就行,不需要一个个串行计算。

这就是 Prefill:一次 forward 并行处理全部已知的 prompt token。Prefill 做两件事——为每层 Transformer 产生所有 prompt 位置的 K 和 V(后面会讲这些 K/V 有什么用),并利用最后一个位置的输出预测第一个生成 token。

接下来就没这么简单了。第一个生成 token 被采样出来之后,模型需要拿它作为输入去预测第二个。第二个出来之后,再去预测第三个。每一步的输入依赖前一步的采样结果,无法预知——这部分必须逐步推进。

这就是 Decode:逐 token 增量生成。每一步只处理一个新的 token 位置。

Prefill 与 Decode 的时间线

一个容易混淆的边界问题:Prefill 结束时已经预测出了第一个生成 token,所以 200 个生成 token 并不意味着需要 200 次 Decode forward。Prefill 的最后一个位置就产出了第一个预测,后续的 Decode 负责剩下的 token。

Prefill 和 Decode 的性能特性也很不一样:

  • Prefill 一次处理大量 token,矩阵乘法的维度较大,GPU 的计算单元通常能被较好地利用,整体更偏 compute-bound。
  • Decode 在小 batch 下每步只处理一个 token 位置,矩阵乘法退化为矩阵-向量乘,GPU 计算单元利用率低,瓶颈容易落在模型权重和缓存数据的读取带宽上。

需要强调,这是典型倾向,不是绝对结论。大 batch 的 Decode、短 prompt 的 Prefill、不同的模型架构和硬件配置都会影响实际瓶颈。

为什么历史 Token 不必重复计算
#

到目前为止,Decode 阶段的执行方式还有一个问题我没有回答。

假设 Prefill 处理完 1000 个 prompt token,模型生成了 token 1001。接下来预测 token 1002 时,最直接的做法是什么?

我最开始想的是:把 1001 个 token 全部输入 Transformer,重新跑一遍完整的 forward——对 token 1 到 1001 的每一层都重新计算 Q、K、V 和 Attention。

这在数学上完全可行。但代价很大:每生成一个 token,计算量都和整个序列长度成正比。生成第 1200 个 token 时,要对 1200 个 position 全部重算一遍,其中前 1199 个位置的计算结果和上一步相比根本没变。

为什么没变?因为 Causal Attention 保证了:位置 \(i\) 的表示只依赖 \(x_1, \ldots, x_i\),不受任何 \(i\) 之后的 token 影响。

之前写 Causal Mask 的时候推导过这一点。在相同的上下文和模型参数下,位置 \(i\) 的 hidden state 可以写成:

$$ h_i = f(x_1, \ldots, x_i) $$

新 token \(x_{1001}\) 的加入不会改变 \(h_1, h_2, \ldots, h_{1000}\)。既然不变,就没有必要重算。

具体到 Attention 的计算。回忆标准公式:

$$ \text{Attention}(Q, K, V) = \text{Softmax}\!\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$

Decode 阶段预测 token 1002 时,新 token 只需要:

  1. 计算自己在本层的 \(Q_{1002}\)、\(K_{1002}\)、\(V_{1002}\)。
  2. 用 \(Q_{1002}\) 和所有历史位置的 K(\(K_1, K_2, \ldots, K_{1001}\))做 dot product,得到 Attention Score。
  3. 对 Score 做 Softmax,再用 Softmax 权重对所有历史 V 做加权求和,得到输出。
  4. 把新计算的 \(K_{1002}\) 和 \(V_{1002}\) 追加到缓存中。

历史位置的 K 和 V 需要保留——新 token 的 Attention 计算要用。但历史位置的 Q 不需要保留——Q 表达的是"当前 token 想要查询什么",历史 token 的 Q 在它们自己的 Attention 计算中已经用过了,后续 token 不需要引用历史 Q。

这就是 KV Cache 的由来。

KV Cache 在一层 Transformer 中的工作机制

有几个需要特别澄清的地方:

KV Cache 的 K 和 V 指的是 Attention 计算中的 Key Tensor 和 Value Tensor,不是 Redis 或 HashMap 里的 Key-Value 关系。 之所以叫 KV Cache,是因为缓存的对象恰好就是 Attention 公式中的 \(K\) 和 \(V\)。

每一层 Transformer 有自己独立的 KV Cache。 一个 32 层的模型就有 32 份独立的 KV 缓存。不同层的 K/V 投影权重不同,同一个 token 在不同层产生的 K/V 也不同。

Token ID 和 Token Position 不能混淆。 Token ID 是词表中的编号(比如 Paris 对应的 ID),Token Position 是这个 token 在当前序列中出现的位置。同一个 Token ID 出现在不同位置时,由于位置编码的影响,它在各层的 K/V 是不同的。KV Cache 按位置组织,不是按 Token ID 索引。

用显存换计算,换了多少
#

KV Cache 避免了对历史 token 的重复计算,代价是把历史每一层的 K 和 V 都保存在 GPU 显存中。

KV Cache 的逻辑存储量:

$$ M_{KV} = 2 \times L \times B \times S \times H_{kv} \times D_{head} \times P $$

各项含义:\(L\) 是 Transformer 层数,\(B\) 是并发请求数(假设长度相同),\(S\) 是每个请求已缓存的 token 数量,\(H_{kv}\) 是 KV Heads 数量,\(D_{head}\) 是每个 Head 的维度,\(P\) 是每个元素的字节数(BF16 为 2),系数 2 是因为 K 和 V 各一份。

我第一次认真算这个数字的时候有点吃惊。代入一个具体配置:32 层,32 个 KV Heads,Head Dimension 128,序列长度 4096,BF16:

$$ M_{KV} = 2 \times 32 \times 1 \times 4096 \times 32 \times 128 \times 2 = 2{,}147{,}483{,}648 \text{ bytes} = 2 \text{ GiB} $$

单个请求的 KV Cache 就要 2 GiB。如果同时服务 8 个请求,KV Cache 就是 16 GiB——可能已经和模型权重本身差不多大了。

这个公式给出的是 KV 数据的理论存储量。实际的物理显存占用还包括内存分配器的开销、Block 内部碎片、元数据等。不同请求的缓存长度通常也不一样,上面公式用统一的 \(B \times S\) 只是为了说明数量级。

KV Cache 带来两个资源约束:

显存容量。 模型权重由所有请求共享,但 KV Cache 是每个请求独有的。活跃请求越多、上下文越长,KV Cache 占用的显存就越大。同一块 GPU 能同时服务多少请求,往往取决于 KV Cache 能不能放得下。

访存带宽。 Decode 阶段的 Attention 需要读取历史所有位置的 K 和 V。上下文越长,每一步需要从 HBM 搬运的数据量越大。KV Cache 虽然避免了重复计算,但没有免除长 context 下 Attention 的 IO 成本。

KV Heads 的数量直接影响缓存大小和读取带宽。现代 LLM 通常使用 GQA 或 MQA 来减少 KV Heads,用表示多样性换取推理效率——这条线在 MHA 到 GQA 那篇里已经详细写过了,这里不再展开。

从单请求到多请求 Serving
#

上面的分析都集中在单个请求。但我以前做 Serving 就知道,真正让系统变复杂的从来不是单个请求的计算,而是多个并发请求之间的资源管理。LLM 的情况更棘手——每个请求有自己的 prompt、自己的生成进度、自己不断增长的 KV Cache。问题的性质从"怎么高效执行一次推理"变成了"怎么在多个并发的有状态任务之间管理 GPU 资源"。

可以把 Decode 阶段的增量执行简化成这样一个抽象:

$$ (y_{t+1}, C_{t+1}) = F_\theta(y_t, C_t) $$

其中 \(C_t\) 是到第 \(t\) 步为止积累的 KV Cache。这不是一个无状态的函数调用——每一步的执行依赖上一步的状态,而且这个状态(KV Cache)是 per-request 的、持续增长的。Prefill 负责建立初始状态 \(C_0\)。

这个有状态、持续多轮、资源动态变化的计算模式,给 Serving 系统带来了一系列传统推理系统没有遇到过的工程问题。

动态显存管理。 每个请求的 KV Cache 随生成逐步增长,请求结束后需要释放。不同请求的生命周期不同,结束时间各异。如果按最大可能长度预分配显存,大量空间会被浪费;如果不预分配,请求生成到一半发现显存不够就麻烦了。这就是 PagedAttention 要解决的问题——把 KV Cache 切成固定大小的 block,按需分配和回收,类似操作系统的虚拟内存分页。

动态 Batch 调度。 传统推理中,一个 batch 的请求进来、一起做 forward、一起返回。但 LLM 的请求有不同的 prompt 长度和生成长度,有的先到有的后到,有的早结束有的晚结束。如果等一个 batch 全部生成完毕才接新请求,GPU 会有大量空闲时间。Continuous Batching 允许在 batch 中的某些请求结束后立即插入新请求,保持 GPU 持续有活干。

Prefill 与 Decode 的资源竞争。 同一块 GPU 上可能同时有正在做 Prefill 的新请求和正在做 Decode 的旧请求。Prefill 需要大量计算,一个长 prompt 的 Prefill 可能占据 GPU 很长时间,导致正在 Decode 的请求等待,用户端感受到的就是生成卡顿。Chunked Prefill 把长 prompt 的 Prefill 拆成小块交替执行,Prefill/Decode Disaggregation 更进一步把两种计算分到不同的 GPU 上。

生成式推理的延迟指标。 传统推理关心整体 latency 和 throughput。生成式推理多了几个维度:TTFT(Time To First Token,从请求到达到第一个 token 返回的时间,主要受 Prefill 影响)和 TPOT/ITL(Time Per Output Token / Inter-Token Latency,生成阶段每个 token 的平均延迟,主要受 Decode 影响)。用户可能对 TTFT 和 TPOT 有不同的容忍度,调度策略需要在两者之间权衡。

LLM Serving 的系统挑战与优化技术

LLM Serving 系统管理的不仅是模型执行,还包括请求状态、缓存生命周期、GPU 资源,以及跨执行轮次的调度。 这和传统推理的"无状态函数调用"是完全不同的系统范式。

LLM Inference 优化技术地图
#

到这里,可以把上面的推导线索和对应的优化技术整理成一张简表:

技术解决的问题
FlashAttentionAttention 计算中的 HBM IO,避免物化 \(S \times S\) Score Matrix
GQA / MQA减少 KV Heads 数量,降低 KV Cache 容量和读取带宽
PagedAttentionKV Cache 的动态分页管理,避免碎片化和预分配浪费
Continuous Batching请求到达和结束时间不一致时的动态调度
Chunked PrefillPrefill 和 Decode 之间的资源协调与干扰控制
Prefix Caching相同前缀(如 System Prompt)的 KV Cache 复用
Speculative Decoding减少 Decode 阶段串行等待的轮次

这些技术处于不同的优化层次——有的在算子层面(FlashAttention),有的在模型架构层面(GQA/MQA),有的在系统调度层面(Continuous Batching、PagedAttention)。它们之间大多不互斥,可以叠加使用。

下一篇准备写 PagedAttention——KV Cache 的动态显存管理是怎么做的,为什么需要引入"分页"的思想。

参考资料
#

相关文章

从 CS336 到 ML Infra:理解 Arithmetic Intensity、Accelerator Intensity,以及 Compute Bound / Memory Bound

起因 # 这是 CS336 系列的又一篇。 之前那篇讨论了 FLOPs 到 Latency 之间的鸿沟:FLOPs 相同的两个 GEMM,latency 可能差几倍甚至十几倍。那篇文章的核心结论是 FLOPs 是 workload metric,不是 performance metric,并且引入了 Arithmetic Intensity 和 Roofline Model 作为分析工具。