当你向大语言模型提问时,可能注意过一个现象:模型的回答是逐字逐句流式出现的,而不是瞬间生成整段文本。这并非产品设计的"打字机效果",而是大模型生成机制的本质特性。

这个特性源于一个核心概念:自回归生成(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 等)都采用自回归架构:虽然慢,但生成质量更高、更可控。

训练与推理的一致性

自回归设计的一个重要好处是训练和推理的一致性。

训练时,模型学习的是:给定前文,预测下一个词。推理时,模型做的完全相同的事情:给定前文(提示词+已生成的词),预测下一个词。

这种一致性避免了训练-测试不匹配的问题,是自回归模型泛化能力强的重要原因之一。模型在训练时学到的每个"预测下一个词"的能力,都可以直接迁移到推理时。

小结

自回归生成是大语言模型的核心机制,它规定了:

  1. 概率分解:序列概率按链式法则分解为条件概率的乘积
  2. 因果约束:预测当前词只能看到之前的内容
  3. 串行本质:由于依赖关系,生成必须逐词进行
  4. 两阶段推理:Prefill 处理输入,Decode 逐词生成

这些约束不是缺陷,而是设计选择。它让模型学会了自然语言的本质规律——如何根据前文预测下一个合理的词。正是这个看似简单的"猜下一个词"任务,在足够大的数据和模型规模下,涌现出了理解、推理、创造等复杂能力。

当你下次看到大模型逐字输出时,现在你知道了:这不是效果的展示,而是模型工作原理的必然结果。每一个词都是模型在已知信息基础上的最佳猜测,然后这个猜测又成为下一个词的已知信息——如此循环,直到完成表达。