在大语言模型推理的实际工程中,理解 Prefill 和 Decode 两个阶段的差异,是进行性能优化、成本控制和问题排查的基础。很多开发者在使用 LLM API 或部署开源模型时,只关注输入和输出,却忽略了内部这两个关键步骤如何影响延迟、吞吐量和资源消耗。当遇到推理速度慢、显存占用高或长文本生成不稳定时,如果不清楚 Prefill 和 Decode 各自在做什么,排查将无从下手。
本文将从工程实践角度,深入解析 Prefill 和 Decode 阶段的核心机制、性能特征以及 KV Cache 在其中扮演的角色。我们会先理清概念,然后通过一个模拟的推理流程来直观展示两个阶段的工作,接着分析它们对计算和内存资源的不同需求,最后给出针对性的性能调优思路和常见问题排查清单。无论你是正在集成 LLM 服务的应用开发者,还是负责模型部署的算法工程师,理解这些细节都将帮助你更有效地设计系统、定位瓶颈。
1. 理解 LLM 推理流水线:从输入到输出的两个关键阶段
大语言模型的推理并非一次性计算整个输出序列。为了提高效率,它被设计成一个迭代式的自回归过程,这个过程清晰地划分为 Prefill 和 Decode 两个阶段。理解这个划分,是后续一切优化和问题分析的前提。
1.1 Prefill 阶段:一次性计算与上下文准备
Prefill,或称“预填充”阶段,发生在处理用户输入的 prompt(提示词)时。这个阶段的核心任务是:基于完整的输入 prompt,一次性计算出模型中所有 Transformer 层对于这些输入 tokens 的 Key 和 Value 向量,并将它们缓存起来,形成 KV Cache。
假设用户输入是:“请用 Python 写一个快速排序函数。” 这个句子被分词成 N 个 tokens。在 Prefill 阶段:
- 模型会并行处理这 N 个 tokens,通过前向传播,为每个 token 在每一层都生成对应的 Key (
K) 和 Value (V) 向量。 - 这些
K和V向量被存储在内存中,这就是 KV Cache 的初始内容。 - 同时,模型会生成 prompt 中最后一个 token(对应“函数。”)的隐状态,这个隐状态将作为 Decode 阶段生成第一个输出 token 的“种子”。
为什么需要 Prefill?因为 Transformer 的核心注意力机制(如自注意力)在计算某个 token 的输出时,需要看到序列中所有其他 token 的信息。对于固定的 prompt,一次性计算出所有 token 的 KV 并缓存,避免了在后续 Decode 阶段为生成每一个新 token 而反复计算整个 prompt 的注意力,这是 Transformer 推理得以高效的关键优化。
Prefill 阶段的计算特征是:计算量巨大,但高度可并行。其耗时与 prompt 长度N的平方(在原始注意力下)或线性(在某些优化注意力下)相关,并且会消耗大量显存来存储 KV Cache。
1.2 Decode 阶段:迭代式生成与缓存复用
Decode,或称“解码”或“生成”阶段,发生在模型逐个生成输出 tokens 时。这个阶段的核心任务是:利用 Prefill 阶段准备好的 KV Cache,以自回归的方式,每次只计算并生成一个新的 token。
接上例,模型开始生成回答。假设第一个输出 token 是 “def”。
- 模型将 “
def” 作为输入(对于第一步,输入是来自 Prefill 的最后一个 token 的隐状态所预测的 token)。 - 在每一层,模型计算当前输入 token(“
def”)的 Query (Q) 向量。 - 这个
Q向量会与 Prefill 阶段缓存的、属于 prompt 的所有K向量进行计算(注意力得分),也会与之前 Decode 步骤中缓存的、已生成 tokens 的K向量进行计算。 - 根据注意力得分加权求和对应的
V向量,得到当前层的输出。 - 模型最终预测出下一个 token(例如 “
quicksort”)。 - 关键一步:将当前步骤生成的 token(“
def”)的K和V向量也追加到 KV Cache 中,供下一步使用。
Decode 阶段的计算特征是:单步计算量小,但串行不可并行。每一步都只处理一个 token,计算其Q向量并与不断增长的 KV Cache 进行注意力计算。其耗时与当前总序列长度(Prompt长度 + 已生成长度)相关,并且随着生成进行,KV Cache 会不断增长,内存压力和每一步的注意力计算开销也会缓慢增加。
1.3 两阶段对比与 KV Cache 的角色
我们可以通过一个表格来清晰对比两个阶段:
| 特性维度 | Prefill 阶段 | Decode 阶段 |
|---|---|---|
| 触发时机 | 每个请求开始时,处理用户输入的 prompt。 | Prefill 结束后,循环执行直至生成结束。 |
| 输入长度 | 一次性处理整个 prompt,长度N。 | 每次只处理当前 token(长度为1)。 |
| 计算模式 | 计算密集型:并行计算整个 prompt 的所有 token。 | 内存带宽密集型:串行计算,每一步都需读取整个 KV Cache。 |
| 主要计算 | 为 prompt 的 N 个 token 计算并缓存 K, V 向量。 | 为当前 token 计算 Q 向量,并与历史 K 进行注意力计算。 |
| KV Cache | 创建并初始化KV Cache。 | 读取并扩展KV Cache(追加新 token 的 K, V)。 |
| 耗时关系 | 与 prompt 长度N强相关(通常为 O(N²) 或 O(N))。 | 与当前总序列长度(N + 已生成数)相关,单步耗时较短但步数多。 |
| 优化重点 | 降低长 prompt 的计算延迟(如使用 FlashAttention, PagedAttention)。 | 提高单步解码速度,管理不断增长的 KV Cache 内存。 |
KV Cache 的本质:它是一个用于存储历史序列(包括 prompt 和已生成部分)中每个 token 在每一层 Transformer 的 Key 和 Value 向量的内存空间。它避免了重复计算,是连接 Prefill 和 Decode 的桥梁,也是内存占用的主要部分。其大小公式约为:2 * 层数 * 隐藏维度 * 序列长度 * 数据类型字节数。
2. 环境准备与模拟推理流程
为了更直观地理解,我们不需要部署完整的百亿参数模型。我们可以通过一个高度简化的 Python 模拟程序,来演示 Prefill 和 Decode 的逻辑流程,以及 KV Cache 的变化。这有助于建立清晰的思维模型。
2.1 模拟环境设置
我们使用 Python 进行概念模拟。重点在于逻辑,而非真实计算。
# simulate_prefill_decode.py import numpy as np from typing import List, Dict, Tuple class SimpleKVCache: """一个极简的 KV Cache 模拟类""" def __init__(self): self.keys = [] # 模拟每一层的 K 向量列表 self.values = [] # 模拟每一层的 V 向量列表 def append(self, k_vector, v_vector): """向缓存中追加一个 token 的 K, V""" self.keys.append(k_vector) self.values.append(v_vector) def get_all(self): """获取当前所有缓存的 K, V(模拟注意力计算时的读取)""" return np.array(self.keys), np.array(self.values) def __len__(self): return len(self.keys) def dummy_attention(q: np.ndarray, k_cache: np.ndarray, v_cache: np.ndarray) -> np.ndarray: """模拟注意力计算:q 与所有 k 计算相似度,加权平均 v""" # 简化计算:假设相似度为点积 scores = np.dot(q, k_cache.T) # (1, cache_len) # 简化 softmax attn_weights = np.exp(scores) / np.sum(np.exp(scores)) # 加权求和 context = np.dot(attn_weights, v_cache) # (1, hidden_dim) return context def dummy_transformer_layer(input_vec: np.ndarray, kv_cache: SimpleKVCache, is_prefill: bool) -> Tuple[np.ndarray, np.ndarray]: """ 模拟单层 Transformer 的前向传播。 - input_vec: 输入 token 的向量 - kv_cache: 该层的 KV Cache - is_prefill: 是否为 Prefill 阶段 """ # 模拟生成当前 token 的 K, Q, V 向量(实际模型通过线性层产生) hidden_dim = input_vec.shape[-1] W_k = np.random.randn(hidden_dim, hidden_dim) * 0.01 W_q = np.random.randn(hidden_dim, hidden_dim) * 0.01 W_v = np.random.randn(hidden_dim, hidden_dim) * 0.01 k_vec = np.dot(input_vec, W_k) # (1, hidden_dim) q_vec = np.dot(input_vec, W_q) v_vec = np.dot(input_vec, W_v) if is_prefill: # Prefill: 计算并缓存 K, V, 用 Q 和当前步的 K 计算自注意力(此处简化) # 注意:实际 Prefill 是并行计算所有 prompt tokens,这里用循环模拟 kv_cache.append(k_vec, v_vec) # 对于 Prefill,我们简化输出,实际会输出处理后的向量序列 output_vec = input_vec # 简化 else: # Decode: 缓存当前 token 的 K, V kv_cache.append(k_vec, v_vec) # 使用当前 Q 与缓存中的所有 K 进行注意力计算 all_k, all_v = kv_cache.get_all() context = dummy_attention(q_vec, all_k, all_v) # 简化后续处理(如FFN),直接返回 context output_vec = context return output_vec, k_vec, v_vec2.2 模拟完整的 Prefill-Decode 流程
下面我们模拟一个包含 2 层 Transformer 的微型“模型”处理 prompt 并生成 3 个 token 的过程。
def simulate_inference(prompt_tokens: List[str], generate_len: int = 3): """ 模拟推理流程 prompt_tokens: 模拟的 prompt token 列表 generate_len: 要生成的 token 数量 """ hidden_dim = 8 # 极小的隐藏维度,用于演示 num_layers = 2 print("="*50) print(f"开始推理模拟") print(f"Prompt Tokens: {prompt_tokens}") print(f"目标生成长度: {generate_len}") print("="*50) # 初始化每层的 KV Cache kv_caches = [SimpleKVCache() for _ in range(num_layers)] # ---------- Prefill 阶段 ---------- print("\n[Prefill 阶段开始]") prompt_vectors = [np.random.randn(1, hidden_dim) for _ in prompt_tokens] # 模拟 prompt 向量化 # 逐层处理整个 prompt for layer_id in range(num_layers): print(f"\n 处理第 {layer_id+1} 层:") layer_kv_cache = kv_caches[layer_id] # 在实际 Prefill 中,所有 token 是并行计算的。这里用循环模拟每个 token 的处理逻辑。 for idx, token_vec in enumerate(prompt_vectors): # 注意:为了简化,这里每层的输入都是上层的输出。我们直接传递 token_vec。 output_vec, k, v = dummy_transformer_layer(token_vec, layer_kv_cache, is_prefill=True) prompt_vectors[idx] = output_vec # 更新,作为下一层的输入(简化) print(f" Token '{prompt_tokens[idx]}': 缓存了 K/V (形状 {k.shape}/{v.shape})") print(f" 本层 Prefill 结束。当前 KV Cache 长度: {len(layer_kv_cache)}") # Prefill 后,最后一个 token 的输出向量作为 Decode 的起始状态 decoder_input_vec = prompt_vectors[-1] print(f"\n[Prefill 阶段结束] 最后一层输出向量形状: {decoder_input_vec.shape}") print(f"各层 KV Cache 长度: {[len(c) for c in kv_caches]}") # ---------- Decode 阶段 ---------- print("\n[Decode 阶段开始]") generated_tokens = [] for step in range(generate_len): print(f"\n --- 生成第 {step+1} 步 ---") current_vec = decoder_input_vec # 逐层解码 for layer_id in range(num_layers): layer_kv_cache = kv_caches[layer_id] output_vec, k, v = dummy_transformer_layer(current_vec, layer_kv_cache, is_prefill=False) current_vec = output_vec print(f" 第 {layer_id+1} 层: 追加了新的 K/V 到缓存。当前缓存总长度: {len(layer_kv_cache)}") # 模拟从输出向量预测下一个 token (这里随机选择一个) predicted_token = f"[Token_{step+1}]" generated_tokens.append(predicted_token) print(f" 预测 Token: {predicted_token}") # 将预测的 token 向量化,作为下一步的输入(简化:这里用随机向量模拟) decoder_input_vec = np.random.randn(1, hidden_dim) print(f"\n[Decode 阶段结束]") print(f"最终生成的 Tokens: {generated_tokens}") print(f"最终各层 KV Cache 长度: {[len(c) for c in kv_caches]} (Prompt:{len(prompt_tokens)} + Generated:{generate_len})") if __name__ == "__main__": # 模拟一个包含 3 个 token 的 prompt simulate_inference(prompt_tokens=["[CLS]", "请", "写"], generate_len=3)运行上述模拟代码,你会看到类似以下的输出,它清晰地展示了两个阶段的分界和 KV Cache 的动态增长:
================================================== 开始推理模拟 Prompt Tokens: ['[CLS]', '请', '写'] 目标生成长度: 3 ================================================== [Prefill 阶段开始] 处理第 1 层: Token '[CLS]': 缓存了 K/V (形状 (1, 8)/(1, 8)) Token '请': 缓存了 K/V (形状 (1, 8)/(1, 8)) Token '写': 缓存了 K/V (形状 (1, 8)/(1, 8)) 本层 Prefill 结束。当前 KV Cache 长度: 3 处理第 2 层: Token '[CLS]': 缓存了 K/V (形状 (1, 8)/(1, 8)) Token '请': 缓存了 K/V (形状 (1, 8)/(1, 8)) Token '写': 缓存了 K/V (形状 (1, 8)/(1, 8)) 本层 Prefill 结束。当前 KV Cache 长度: 3 [Prefill 阶段结束] 最后一层输出向量形状: (1, 8) 各层 KV Cache 长度: [3, 3] [Decode 阶段开始] --- 生成第 1 步 --- 第 1 层: 追加了新的 K/V 到缓存。当前缓存总长度: 4 第 2 层: 追加了新的 K/V 到缓存。当前缓存总长度: 4 预测 Token: [Token_1] --- 生成第 2 步 --- 第 1 层: 追加了新的 K/V 到缓存。当前缓存总长度: 5 第 2 层: 追加了新的 K/V 到缓存。当前缓存总长度: 5 预测 Token: [Token_2] --- 生成第 3 步 --- 第 1 层: 追加了新的 K/V 到缓存。当前缓存总长度: 6 第 2 层: 追加了新的 K/V 到缓存。当前缓存总长度: 6 预测 Token: [Token_3] [Decode 阶段结束] 最终生成的 Tokens: ['[Token_1]', '[Token_2]', '[Token_3]'] 最终各层 KV Cache 长度: [6, 6] (Prompt:3 + Generated:3)这个模拟清晰地展示了:
- Prefill一次性处理了 3 个 prompt tokens,为每层创建了长度为 3 的 KV Cache。
- Decode每生成一个 token,都会向每层的 KV Cache 追加一对 K/V,导致缓存长度从 3 增长到 6。
- 在真实模型中,Decode 每一步的注意力计算都需要读取这个不断增长的完整 KV Cache。
3. 性能特征分析与工程影响
理解了基本流程后,我们需要从工程角度分析这两个阶段对系统性能(延迟、吞吐量、内存)的不同影响。这是进行容量规划、资源分配和问题排查的核心。
3.1 计算复杂度与延迟构成
Prefill 阶段延迟 (T_prefill)
- 主要来源:对长度为
N的 prompt 进行前向传播。计算量主要在于注意力机制。 - 复杂度:
- 原始自注意力:
O(N² * d),其中d是隐藏维度。这是平方级增长,长 prompt 延迟显著。 - 使用 FlashAttention、PagedAttention 等优化后:可降低到接近
O(N * d),并更好地利用 GPU 显存带宽。
- 原始自注意力:
- 工程表现:用户点击“发送”后,到看到第一个字开始输出之前的等待时间,主要就是
T_prefill。对于长文档总结、长上下文问答,这个延迟可能达到数秒甚至数十秒。
Decode 阶段延迟 (T_decode_per_token)
- 主要来源:生成单个 token 的前向传播。计算量相对固定且较小。
- 复杂度:
O((N+M) * d),其中M是已生成 token 数。随着生成进行,(N+M)线性增长,每一步需要读取的 KV Cache 也线性增长,导致单步解码时间 (T_decode_per_token) 会缓慢增加。 - 工程表现:决定输出文字的“打字速度”。
T_decode_per_token乘以需要生成的 token 数量M,就是整个生成过程的流式输出时间。通常T_decode_per_token在几毫秒到几十毫秒之间。
注意:在流式输出场景中,用户感知的“首字延迟”是
T_prefill,而后续输出速度则受T_decode_per_token影响。优化T_prefill能更快得到响应,优化T_decode_per_token能让输出更流畅。
3.2 内存占用:KV Cache 是主要挑战
内存占用主要来自两部分:模型参数和 KV Cache。
- 模型参数:固定大小,与序列长度无关。例如,一个 7B 的模型,参数大约占用 14 GB(FP16)。
- KV Cache:动态大小,是内存管理的核心。其占用公式可估算为:
KV Cache 大小 ≈ 2 * batch_size * num_layers * hidden_size * seq_len * bytes_per_param2: 代表 K 和 V 两个缓存。batch_size: 同时处理的请求数(批处理大小)。num_layers: Transformer 层数。hidden_size: 每层的隐藏维度。seq_len:当前总序列长度(Prompt + Generated)。bytes_per_param: 数据类型字节数(如 FP16 是 2,INT8 是 1)。
对 Prefill 的影响:Prefill 需要为整个 prompt 一次性分配 KV Cache 内存。如果 prompt 很长(例如 32K tokens),即使 batch_size=1,KV Cache 也可能占用数十 GB 显存,导致 OOM(内存不足)。
对 Decode 的影响:Decode 过程中,KV Cache 随生成不断增长。如果生成很长(例如聊天历史很长或生成长文档),最终序列长度可能超过预设的最大上下文长度,导致缓存溢出,模型无法继续生成或性能骤降。
3.3 吞吐量(Throughput)的权衡
吞吐量指单位时间(如每秒)内处理的 token 总数。它受批处理(Batching)策略影响极大。
- Prefill 阶段:计算密集,GPU 利用率高,非常适合大批次(Large Batch)处理。可以将多个用户的 prompt 打包成一个批次,一次性进行 Prefill 计算,显著提高 GPU 利用率和整体吞吐量。
- Decode 阶段:内存带宽受限,且每个请求的生成步调不一致(有的生成长,有的短)。进行批处理(Continuous Batching 或 Iteration-Level Batching)更复杂,但能有效提高吞吐量。其原理是动态地将正在解码的多个请求组合成批次,当一个请求生成结束后,用新请求替换它,保持 GPU 持续工作。
工程上的矛盾点:提高 Prefill 吞吐量需要增大批次,但这会瞬间申请巨大的 KV Cache 内存(batch_size * seq_len)。提高 Decode 吞吐量需要高效的动态批处理调度,以避免 GPU 空闲。
4. 常见问题、排查路径与优化策略
基于以上分析,我们可以系统地应对 LLM 推理中遇到的各种性能问题。
4.1 问题一:首字延迟(Time To First Token, TTFT)过高
现象:用户发送请求后,等待很长时间才看到第一个输出 token。
根因分析:这几乎总是 Prefill 阶段耗时过长导致的。
排查与解决思路:
- 检查 Prompt 长度:确认是否传入了过长的 prompt。通过日志或监控查看输入 token 数。
- 分析 Prefill 计算:
- 是否使用了优化的注意力算子?确认推理引擎(如 vLLM, TensorRT-LLM, TGI)是否启用了 FlashAttention 或类似优化。对于长 prompt(>2K),启用这些优化至关重要。
- 硬件是否匹配?Prefill 是计算密集型,需要强大的 GPU 算力(如 H100, A100)。在低端 GPU 上处理长 prompt 必然慢。
- 考虑 Chunked Prefill:如果模型和框架支持(如 vLLM),可以将超长 prompt 分块(chunk)进行 Prefill,虽然可能略微增加总计算量,但能平滑延迟,避免单次巨大计算造成的卡顿。
- 优化 Prompt:考虑是否可以通过提示词工程缩短必要 prompt。移除冗余信息。
4.2 问题二:生成速度慢(输出卡顿)
现象:第一个字出来之后,后续输出断断续续,速度很慢。
根因分析:这通常是 Decode 阶段单步耗时 (T_decode_per_token) 过长,或吞吐量不足。
排查与解决思路:
- 检查当前序列长度:随着生成进行,KV Cache 变长,每一步的注意力计算开销增大。监控生成过程中的单步延迟是否随生成长度增加而明显上升。
- 检查批处理配置:
- 是否启用了动态批处理(Continuous Batching)?这是提高 Decode 吞吐量的关键。确保使用的推理服务器支持此功能(如 vLLM, TGI)。
- 批处理大小是否合理?过小的 batch_size 无法充分利用 GPU;过大的 batch_size 可能导致内存不足,触发显存交换,反而更慢。需要根据 GPU 显存和模型大小调整。
- 检查解码参数:
- 是否使用了低效的采样策略?贪婪解码(Greedy)最快,采样(Sampling)稍慢,束搜索(Beam Search)会成倍增加计算量(beam width 倍)。评估是否必须使用束搜索。
max_new_tokens是否设置过大?无限制的生成长度会持续增加 KV Cache 和延迟。
- 使用量化:将模型权重和 KV Cache 从 FP16 量化到 INT8 甚至 FP4,可以大幅减少内存占用和带宽压力,从而提升 Decode 速度。但需注意可能带来的精度损失。
4.3 问题三:显存不足(OOM)
现象:推理服务崩溃,日志报错 “CUDA out of memory”。
根因分析:总内存占用(模型参数 + KV Cache)超过 GPU 显存。
排查与解决思路:
- 计算 KV Cache 预算:根据前面的公式,估算在目标
batch_size和max_seq_len下 KV Cache 的占用。例如:- 模型:Llama2-7B (hidden_size=4096, num_layers=32)
- 批次:batch_size=4
- 序列:max_seq_len=4096
- 精度:FP16 (2 bytes)
- KV Cache 大小 ≈
2 * 4 * 32 * 4096 * 4096 * 2 bytes ≈ 8.6 GB - 加上模型参数 ~14 GB,总需求 > 22 GB,显然在 24G 显存的卡上就很紧张。
- 调整关键参数:
- 降低
batch_size:最直接有效,但会降低吞吐量。 - 降低
max_seq_len:限制单个请求的最大长度,防止超长请求耗尽内存。 - 使用 PagedAttention(vLLM):这是解决内存碎片化和 OOM 的利器。它允许 KV Cache 以非连续块(Page)的形式存储在显存中,极大提高了显存利用率,通常可以支持更大的 batch_size。
- 启用 KV Cache 量化:如前所述,将 KV Cache 量化为 INT8。
- 降低
- 监控与限流:在生产环境部署监控,跟踪每个请求的 prompt 长度和生成长度。实现请求限流,防止突发的大量长上下文请求同时打满显存。
4.4 优化策略速查表
| 优化目标 | 可采取的措施 | 说明与注意事项 |
|---|---|---|
| 降低首字延迟 (TTFT) | 1. 启用 FlashAttention 等优化算子。 2. 对超长 prompt 使用 Chunked Prefill。 3. 升级 GPU 算力。 4. 优化/缩短 prompt。 | FlashAttention 对长文本效果显著。Chunked Prefill 是 trade-off,可能增加总计算量但改善延迟体验。 |
| 提高生成速度 | 1. 启用 Continuous Batching。 2. 使用更快的采样方式(如贪婪解码)。 3. 对模型和 KV Cache 进行量化。 4. 使用如 vLLM 的高效推理引擎。 | Continuous Batching 是提高 Decode 吞吐量的核心技术。量化需测试精度是否可接受。 |
| 节省显存 | 1. 使用 PagedAttention (vLLM)。 2. 量化 KV Cache 和模型权重。 3. 合理设置 max_seq_len和batch_size。4. 使用模型并行将大模型拆分到多卡。 | PagedAttention 能显著提升显存利用率,支持更大批次。量化是牺牲精度换容量。 |
| 提高吞吐量 | 1. 增大 Prefill 批次大小。 2. 使用 Continuous Batching。 3. 优化调度策略(如优先调度短请求)。 4. 使用多 GPU 并行服务多个模型副本。 | Prefill 和 Decode 的批处理策略不同,需要推理引擎良好支持。吞吐量和延迟通常需要权衡。 |
5. 生产环境最佳实践与扩展方向
在理解了基本原理和常见问题后,要将 LLM 推理稳定、高效地应用于生产,还需要考虑更多工程细节。
5.1 推理服务选型与配置
不要从零开始搭建推理服务。优先选择成熟的开源推理引擎,它们已经集成了上述大多数优化。
- vLLM:目前高性能 LLM 推理的事实标准之一。核心优势是 PagedAttention 和高效的 Continuous Batching。配置简单,吞吐量高,非常适合自建 API 服务。
- Text Generation Inference (TGI):Hugging Face 推出的推理服务。同样支持 Continuous Batching、FlashAttention 等,与 Hugging Face 模型库集成好。
- TensorRT-LLM:NVIDIA 官方优化方案,能将模型编译成高度优化的 TensorRT 引擎,在 NVIDIA GPU 上达到极致性能。但使用复杂度较高。
配置示例 (vLLM):
# 启动一个 vLLM 服务,加载 Llama2-7B 模型,启用量化 python -m vllm.entrypoints.api_server \ --model meta-llama/Llama-2-7b-chat-hf \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.9 \ --max-model-len 8192 \ # 最大序列长度 --quantization awq # 使用 AWQ 量化节省显存关键参数:
--max-model-len: 定义了 KV Cache 的最大长度,直接影响内存预分配。--gpu-memory-utilization: 目标 GPU 显存利用率,vLLM 会根据此值动态管理批处理大小。--quantization: 指定量化方法,如awq,squeezellm。
5.2 监控与可观测性
在生产环境中,必须监控以下核心指标:
- 延迟指标:
prefill_latency_ms: Prefill 阶段延迟。decode_latency_per_token_ms: 单 token 解码延迟(可统计 P50, P99)。time_to_first_token_ms: 首字延迟。total_request_latency_ms: 请求总延迟。
- 吞吐量指标:
tokens_per_second: 每秒处理的 token 数(区分 Prefill 和 Decode)。requests_per_second: 每秒处理的请求数。
- 资源与容量指标:
gpu_memory_used: GPU 显存使用量。kv_cache_memory_used: KV Cache 内存使用量(如果引擎暴露)。batch_size_current: 当前动态批次大小。queue_size: 请求排队数量。
- 业务指标:
input_tokens_per_request: 每个请求的输入 token 数分布。output_tokens_per_request: 每个请求的输出 token 数分布。
当prefill_latency异常高时,检查输入长度;当decode_latency随生成增长而飙升时,检查序列长度是否接近max_model_len;当gpu_memory_used持续高位,可能需调整批次大小或启用量化。
5.3 面向未来的扩展:Chunked Prefill 与 Streaming LLM
对于超长上下文(如 128K+)场景,传统的 Prefill 和 Decode 机制面临挑战:
- Chunked Prefill:将超长 prompt 分成多个块(chunk),逐块进行 Prefill 计算并更新 KV Cache。这可以将一次性的巨大计算和内存压力分散开,改善 TTFT,但需要推理引擎和模型架构的支持。
- Streaming LLM / 无限上下文:这是更前沿的方向,旨在解决 Decode 阶段 KV Cache 无限增长的问题。通过类似滑动窗口、重点保留(H2O, StreamingLLM)或递归压缩(Mamba, RWKV)的机制,在保持主要性能的同时,将 KV Cache 的大小限制在一个固定值,从而实现真正的“无限”生成能力。在选择模型和推理方案时,可以关注是否支持此类特性。
理解 Prefill 和 Decode 的二分法是驾驭 LLM 推理性能的起点。在实际项目中,你需要根据具体的模型规模、请求负载模式(长/短文本,高/低并发)和硬件条件,在这两个阶段之间找到平衡点。从配置一个高效的推理服务器开始,细致地监控其核心指标,并依据本文提供的排查清单应对性能瓶颈,是构建稳定、可扩展 LLM 应用服务的关键一步。