当你向大语言模型提问时,可能注意过一个现象:模型的回答是逐字逐句流式出现的,而不是瞬间生成整段文本。这并非产品设计的"打字机效果",而是大模型生成机制的本质特性。
这个特性源于一个核心概念:自回归生成(Autoregressive Generation)。它规定了一个看似简单的约束——生成第 N 个词时,模型只能看到前 N-1 个词,不能"偷看"未来的内容。正是这个约束,决定了大模型必须像人类说话一样,一个词接一个词地输出。
概率视角下的文本生成
要理解自回归生成,首先需要回答一个根本问题:如何计算一段文本出现的概率?
假设我们要计算句子"今天天气很好"的概率。直接为所有可能的句子建立概率表是不现实的——自然语言允许无限种有效组合。解决思路是将联合概率分解为条件概率的乘积。
根据概率链式法则:
$$P(w_1, w_2, ..., w_T) = P(w_1) \times P(w_2|w_1) \times P(w_3|w_1,w_2) \times ... \times P(w_T|w_1,...,w_{T-1})$$展开后,整体概率变成了各个条件概率的乘积。每个条件概率 $P(w_i|w_1,...,w_{i-1})$ 表示:在已知前面所有词的情况下,第 $i$ 个词出现的概率。
flowchart LR
subgraph 概率分解
A["P(今天,天气,很好)"] --> B["P(今天)"]
A --> C["P(天气|今天)"]
A --> D["P(很好|今天,天气)"]
end
B --> E["×"]
C --> E
D --> E
E --> F["= 序列总概率"]
这个分解有一个重要含义:要计算整个序列的概率,必须按顺序从左到右处理。你不能跳过前面的词直接计算后面的概率,因为后面的概率依赖于前面的条件。
大语言模型正是通过学习这些条件概率来生成文本的。模型的训练目标是:给定前面的词,准确预测下一个词的概率分布。
因果约束:为何不能"预见未来"
你可能会问:为什么模型不能像人类阅读一样,同时看到整句话?答案在于训练与推理的一致性要求。
考虑一个具体例子。如果模型在预测"很好"时能看到"明天会下雨",它可能会得出"很好"在"明天会下雨"后很罕见的结论——这显然是错误的。训练时的"未来信息"会污染模型对当前词的预测。
Transformer 通过**因果掩码(Causal Mask)**来强制执行这个约束。在自注意力计算中,因果掩码是一个下三角矩阵:
graph LR
subgraph 因果掩码矩阵
A1["位置1: ✓"]
A2["位置2: ✓ ✓"]
A3["位置3: ✓ ✓ ✓"]
A4["位置4: ✓ ✓ ✓ ✓"]
end
subgraph 含义
B1["位置1只能看自己"]
B2["位置2可以看1,2"]
B3["位置3可以看1,2,3"]
B4["位置4可以看1,2,3,4"]
end
A1 --> B1
A2 --> B2
A3 --> B3
A4 --> B4
在数学上,这个掩码将注意力矩阵的上三角部分设为负无穷大,经过 softmax 后变成零。这样,每个位置只能"注意"到自己及之前的位置,无法看到未来。
# 因果掩码的实现原理
import torch
def create_causal_mask(seq_len):
# 创建下三角矩阵
mask = torch.tril(torch.ones(seq_len, seq_len))
# 转换为注意力掩码格式
# 1 表示可以注意,0 表示被屏蔽
return mask
# 对于长度为4的序列,因果掩码如下:
# [[1, 0, 0, 0],
# [1, 1, 0, 0],
# [1, 1, 1, 0],
# [1, 1, 1, 1]]
这种设计确保了模型的"因果性":预测当前词时,只使用已经生成或已知的信息。这也是为什么这类模型被称为"因果语言模型"(Causal Language Model)。
生成循环:一步一步预测下一个词
自回归生成的核心是一个简单的循环:
flowchart TD
A[输入提示词] --> B[模型前向传播]
B --> C[获取最后一个位置的logits]
C --> D[Softmax转换为概率分布]
D --> E{选择策略}
E -->|贪婪| F[选择最高概率词]
E -->|采样| G[从分布中随机采样]
F --> H[将新词追加到序列]
G --> H
H --> I{是否停止?}
I -->|否| B
I -->|是| J[输出完整序列]
每一步,模型都会输出一个在整个词表上的概率分布。以 GPT-2 为例,词表大小为 50,257 个 token。模型为每个 token 计算一个分数(logit),然后通过 softmax 转换为概率:
$$P(w_i|w_1,...,w_{i-1}) = \text{softmax}(h_{i-1} \cdot W_{vocab})$$其中 $h_{i-1}$ 是模型最后一个位置的隐藏状态,$W_{vocab}$ 是词表投影矩阵。
关键洞察:我们只使用最后一个位置的输出来预测下一个词。虽然模型会为序列中每个位置都产生输出,但在生成场景中,只有最后一个位置的预测有意义——它代表"下一个词是什么"。
为什么生成不能并行
这是很多人困惑的问题:既然 Transformer 可以并行处理整个输入序列,为什么生成时不能一次性输出所有词?
答案在于依赖关系。
在处理输入(称为 Prefill 阶段)时,所有词都是已知的。模型可以同时计算每个位置对其他所有位置的注意力——这是完全并行的。
但在生成阶段,情况完全不同:
flowchart TD
subgraph 生成依赖链
A["生成词1"] --> B["需要: 输入"]
C["生成词2"] --> D["需要: 输入 + 词1"]
E["生成词3"] --> F["需要: 输入 + 词1 + 词2"]
G["生成词N"] --> H["需要: 输入 + 词1...词N-1"]
end
问题在于:在生成第 N 个词之前,第 N-1 个词还不存在。你不能用"空"或"占位符"来代替,因为每个词的选择会显著影响后续的概率分布。
用一个极端例子说明:输入提示是"答案是",如果第一个生成的词选择了"42",后续的词很可能是数字或标点;如果第一个词选择了"因为",后续很可能是原因解释。这两个方向完全不同,无法提前预测。
这种依赖链是自回归生成的本质特征,也是生成必须串行的根本原因。
Prefill 与 Decode:推理的两个阶段
大模型推理明显分为两个阶段,它们的计算特性截然不同:
flowchart LR
subgraph Prefill阶段
P1[输入: 所有提示词] --> P2[计算: 并行]
P2 --> P3[输出: KV Cache]
P3 --> P4[首词预测]
end
subgraph Decode阶段
D1[输入: 单个新词] --> D2[计算: 串行]
D2 --> D3[输出: 追加KV Cache]
D3 --> D4[下一个词预测]
D4 --> D1
end
Prefill阶段 --> Decode阶段
Prefill(预填充)阶段:
输入整个提示词序列,模型并行计算所有位置的表示。这个阶段是"计算密集型"的——大量矩阵运算可以充分利用 GPU 的并行能力。主要产出是 KV Cache(键值缓存),存储了每个位置的键和值向量。
Decode(解码)阶段:
一次只处理一个新词。虽然计算量小(只有一个词的输入),但需要读取之前所有词的 KV Cache。这个阶段是"内存带宽密集型"的——瓶颈不在于计算,而在于从显存读取数据的速度。
gantt
title LLM推理时间线
dateFormat X
axisFormat %s
section Prefill
处理提示词 :0, 50
section Decode
生成词1 :50, 70
生成词2 :70, 90
生成词3 :90, 110
生成词N :110, 130
这两个阶段的差异解释了一个常见现象:第一个词生成较慢,后续每个词生成速度相对稳定。第一个词需要完成整个 Prefill 阶段,而后续每个词只需要一次 Decode 步骤。
解码策略:从概率分布到具体词
模型输出的是概率分布,如何从中选择具体的词?这涉及解码策略的选择。
贪婪搜索(Greedy Search):
最简单的策略——每次选择概率最高的词。
输入: "今天天气"
预测: {"很": 0.3, "不错": 0.25, "晴朗": 0.15, ...}
选择: "很" (最高概率)
输入: "今天天气很"
预测: {"好": 0.4, "不错": 0.2, "差": 0.1, ...}
选择: "好" (最高概率)
贪婪搜索的优点是确定性——相同的输入总是产生相同的输出。缺点是容易陷入重复和"安全但无聊"的回答,因为总是选择最高概率词会错过一些可能更自然的表达。
束搜索(Beam Search):
保留 top-k 个最可能的候选序列,而不是只保留一个。
flowchart TD
A["输入: 我有一个"] --> B["扩展: 梦想(0.3), 计划(0.2), 想法(0.15)"]
B --> C["'我有一个梦想' 扩展: 是(0.2), ...(0.1)"]
B --> D["'我有一个计划' 扩展: 是(0.15), ...(0.08)"]
B --> E["'我有一个想法' 扩展: 是(0.1), ...(0.05)"]
C --> F["保留分数最高的2个"]
D --> F
E --> F
束搜索通过同时探索多条路径,有更大机会找到高质量输出。但它倾向于产生"安全"但缺乏创意的结果,因为高概率路径往往对应最常见、最保守的表达。
Top-k 采样:
从前 k 个最可能的词中随机采样,而不是只选最高的。
# Top-k 采样示意
def top_k_sampling(logits, k):
# 只保留前k个词的概率
top_k_logits, top_k_indices = torch.topk(logits, k)
# 重新归一化
probs = torch.softmax(top_k_logits, dim=-1)
# 从分布中采样
selected_idx = torch.multinomial(probs, 1)
return top_k_indices[selected_idx]
Top-p(Nucleus)采样:
选择概率累计达到 p 的最小词集,然后从中采样。
# Top-p 采样示意
def top_p_sampling(logits, p):
# 排序并计算累计概率
sorted_logits, sorted_indices = torch.sort(logits, descending=True)
cumulative_probs = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1)
# 找到累计概率刚超过p的位置
sorted_indices_to_remove = cumulative_probs > p
# 保留这些词,其他词概率设为负无穷
sorted_logits[sorted_indices_to_remove] = float('-inf')
# 从剩余词中采样
probs = torch.softmax(sorted_logits, dim=-1)
return torch.multinomial(probs, 1)
Top-p 采样比 Top-k 更灵活:当分布集中时,选择范围自动变小;当分布平坦时,选择范围自动变大。这让它能更好地适应不同语境下概率分布的变化。
温度(Temperature):
温度参数控制概率分布的"尖锐程度":
$$P(w_i) = \frac{\exp(z_i / T)}{\sum_j \exp(z_j / T)}$$- 低温度(如 0.1):分布更尖锐,更倾向选择高概率词
- 高温度(如 1.5):分布更平坦,增加选择低概率词的机会
温度实际上是在"保守"和"创意"之间调节。任务需要精确性时用低温度(如代码生成),需要创造性时用较高温度(如故事写作)。
自回归的代价
自回归生成的设计带来了几个不可避免的代价:
推理速度瓶颈:
生成 100 个词需要 100 次串行的模型前向传播。每次传播虽然计算量不大,但 GPU 利用率低——因为主要瓶颈是内存带宽,而非计算能力。
假设生成每个词需要 20 毫秒,那么生成 100 个词就需要 2 秒。这解释了为什么大模型生成长回复时会明显感觉到等待时间。
KV Cache 内存占用:
为了避免重复计算,模型会缓存之前所有位置的键和值向量。内存占用公式:
$$\text{Memory} = 2 \times L \times B \times H \times d_{head} \times S \times \text{bytes}$$其中 L 是层数,B 是批量大小,H 是注意力头数,$d_{head}$ 是每个头的维度,S 是序列长度。
以 LLaMA-7B(32 层,32 头,头维度 128)为例,生成 4096 个 token 时,KV Cache 约需 4 GB 显存(float16 精度)。这还不包括模型权重本身的显存占用。
错误累积:
自回归生成没有"回头"机制。一旦某个词选错了,后续所有词都会在这个错误基础上继续生成。
举个例子:假设模型生成了"他很高兴,因为他…",如果"高兴"是错误的方向(上下文应该悲伤),后续生成只能在"高兴"的前提下继续,很难突然转向悲伤的表达。这种错误会沿序列传播并放大。
Teacher Forcing 偏差:
训练时,模型看到的是完美的上下文(真实的前文);推理时,模型看到的是自己生成的前文(可能包含错误)。这种训练和推理的不一致被称为"Exposure Bias",可能导致推理时性能下降。
加速技术:在约束中寻找突破
自回归的串行本质无法改变,但可以在其他地方优化:
KV Cache:
这是最基本的优化。缓存之前计算的键和值向量,每次只计算新词的 KV,避免重复计算。
# 无缓存:每次都处理整个序列
for _ in range(num_tokens):
output = model(full_sequence) # 处理所有词
# 有缓存:只处理新词
past_kv = None
for _ in range(num_tokens):
output, past_kv = model(new_token, past_kv) # 只处理1个词
KV Cache 将计算复杂度从 $O(T^2)$ 降到 $O(T)$——生成成本从随序列长度二次增长变为线性增长。
投机解码(Speculative Decoding):
用一个较小的"草稿模型"快速生成多个候选词,然后用大模型一次验证所有候选。
flowchart LR
A[草稿模型] -->|快速生成| B[候选词序列]
B --> C[大模型并行验证]
C --> D{验证结果}
D -->|全部接受| E[接受所有候选]
D -->|部分接受| F[接受正确部分]
D -->|全部拒绝| G[大模型重新生成]
如果草稿模型的预测与大模型一致,就能一次接受多个词,显著加速。实际应用中,投机解码可以将生成速度提升 2-4 倍。
连续批处理(Continuous Batching):
在服务多个用户时,不需要等所有请求都完成才处理下一批。当某个请求完成时,立即用新请求填充其位置,提高 GPU 利用率。
量化:
将模型权重和 KV Cache 从 float16 压缩到 int8 甚至 int4,减少内存带宽压力。虽然会损失一些精度,但现代量化技术已经能将精度损失控制在可接受范围内。
与非自回归模型的对比
存在另一类模型——非自回归模型(如 BERT)——采用完全不同的生成范式:
| 特性 | 自回归模型 | 非自回归模型 |
|---|---|---|
| 生成方式 | 逐词串行 | 并行生成所有词 |
| 依赖关系 | 只看前文 | 可看双向上下文 |
| 典型应用 | 文本生成 | 文本理解、完形填空 |
| 生成质量 | 通常更好 | 可能不一致 |
| 推理速度 | 较慢 | 较快 |
flowchart TD
subgraph 自回归模型GPT类
A1[输入] --> A2[词1]
A2 --> A3[词2]
A3 --> A4[词3]
A4 --> A5[...]
end
subgraph 非自回归模型BERT类
B1[输入] --> B2[词1]
B1 --> B3[词2]
B1 --> B4[词3]
B1 --> B5[...]
end
BERT 类模型可以并行预测所有位置,因为它们不需要按顺序生成。但这也限制了它们的生成能力——无法保证生成文本的整体连贯性,因为各个词是独立预测的。
这就是为什么几乎所有现代生成式大模型(GPT、LLaMA、Claude 等)都采用自回归架构:虽然慢,但生成质量更高、更可控。
训练与推理的一致性
自回归设计的一个重要好处是训练和推理的一致性。
训练时,模型学习的是:给定前文,预测下一个词。推理时,模型做的完全相同的事情:给定前文(提示词+已生成的词),预测下一个词。
这种一致性避免了训练-测试不匹配的问题,是自回归模型泛化能力强的重要原因之一。模型在训练时学到的每个"预测下一个词"的能力,都可以直接迁移到推理时。
小结
自回归生成是大语言模型的核心机制,它规定了:
- 概率分解:序列概率按链式法则分解为条件概率的乘积
- 因果约束:预测当前词只能看到之前的内容
- 串行本质:由于依赖关系,生成必须逐词进行
- 两阶段推理:Prefill 处理输入,Decode 逐词生成
这些约束不是缺陷,而是设计选择。它让模型学会了自然语言的本质规律——如何根据前文预测下一个合理的词。正是这个看似简单的"猜下一个词"任务,在足够大的数据和模型规模下,涌现出了理解、推理、创造等复杂能力。
当你下次看到大模型逐字输出时,现在你知道了:这不是效果的展示,而是模型工作原理的必然结果。每一个词都是模型在已知信息基础上的最佳猜测,然后这个猜测又成为下一个词的已知信息——如此循环,直到完成表达。