Goal

承接 LLM System(一):GPT 结构与 Forward 计算,从 GPT Forward 输出的 Logits 开始,介绍训练目标、参数更新和生成计算的内部机制。

  • 训练目标:Cross-Entropy 怎样衡量预测误差,SFT 怎样选择监督位置,GRPO 怎样利用回答评价构造 Loss。
  • 参数更新:Backward 怎样计算梯度,Optimizer 怎样更新参数,以及梯度累积与 LoRA 怎样接入训练。
  • 生成计算:KV Cache 怎样支持增量 Attention,Greedy、Sampling 与 Beam Search 怎样选择生成结果。

LLM System(一):GPT 结构与 Forward 计算 已说明 Input IDs 怎样转换为 Logits。阶段流程见 大模型工作流程:从训练到推理,以 nanochat 为例,具体工程实现见 nanochat:LLM 核心架构与实现。

示例沿用 Cats sit on mats:输入 Cats sit on,Target IDs 为 45、78、90;词表大小 V=256,两层简化模型使用 C=4、H=2、D=2。模型结构与参数见第一篇,以下承接其计算结果。

训练目标:从 Logits 到 Loss

承接第一篇 GPT Model 的 Logits,本章说明 Cross-Entropy 如何得到预测 Loss、SFT 如何选择监督位置,以及 GRPO 如何把回答评价转换为策略 Loss。阶段流程与数据准备见 LLM Workflow。

Cross-Entropy Loss

问题

第一篇 LM Head 已输出各位置的词表 Logits。例如,on 位置给 mats 的分数为 0.9073。训练文本提供的正确答案是 mats,ID 为 90。

现在有了预测分数和正确答案,需要计算一个 Loss,表示模型给正确答案分配的概率有多低,再用于后续参数更新。

方案

Cross-Entropy 使用两组数据:

  • 模型分布 p:LM Head 输出的全部 Logits 经过 Softmax,得到各候选 token 的预测 Probability。
  • 目标分布 y:由训练数据的 Target ID 定义,正确 token 为 1,其余为 0,即 One-hot 分布。

Cross-Entropy 根据这两个分布计算 Loss:模型给正确答案的 Probability 越高,Loss 越小。

结构与核心逻辑

LM Head 输出 Logits [B,T,V]       训练数据提供 Targets [B,T]
             │                              │
             ▼                              ▼
          Softmax                   正确 ID 为 1,其余为 0
             │                              │
             ▼                              ▼
       模型分布 p [B,T,V]              目标分布 y [B,T,V]
             │                              │
             └──────── Cross-Entropy ────────┘
                              │
                              ▼
                         各位置 Loss
                              │
                              ▼
                       按有效位置取平均
                              │
                              ▼
                     标量 Loss → Backward

对每个输入位置,用 Target ID 取出正确 token 的预测 Probability,计算它的自然对数,再取负,得到该位置的 Loss。

实际实现无需创建完整的 One-hot 数组 y,直接使用 Target ID 取出对应项。下面展开按有效位置取平均的内部逻辑:

def cross_entropy(logits, targets, ignore_index=-100):
    # 1. 排除 Target 为 ignore_index 的位置
    logits, targets = select_valid_positions(logits, targets, ignore_index)

    # 2. 将词表 Logits 转为各候选的 Log Probability
    log_probs = log_softmax(logits)
    # 3. 按 Target ID 取出正确 token 对应的一项
    target_log_probs = select_by_target_id(log_probs, targets)
    # on 位置:取出 Target ID=90 的值,约为 -4.6455

    # 4. 取负得到各位置 Loss,再按有效位置平均
    position_losses = -target_log_probs
    return mean(position_losses)  # 本例约为 4.7943


# logits:LM Head 输出的原始分数 [B,T,V]
# targets:训练数据提供的正确 token ID [B,T]
loss = cross_entropy(
    logits.reshape(-1, 256), targets.reshape(-1), ignore_index=-100
)

例子

先跟踪 on 位置:

训练文本:Cats sit on mats
输入:    Cats sit on
Target:  mats,ID=90

承接 LM Head 的输出,下面按候选 ID 对照三组数据。仅列出四个候选,其余 252 项合并标注;Softmax 的分母仍包含全部 256 项:

数据sit(45)on(78)mats(90)chairs(91)其余 252 项
LM Head 输出的 Logits-0.02750.45820.9073-0.0911每项为 0
Softmax 得到的模型分布 p0.0037720.0061300.0096050.003539合计约 0.976954
Target ID 定义的目标分布 y0010每项为 0

计算 mats 的 Probability:

Z = 252×exp(0) + exp(-0.0275) + exp(0.4582)
    + exp(0.9073) + exp(-0.0911)
  ≈ 257.9447

p[90] = exp(0.9073) / Z ≈ 0.009605

代入 Cross-Entropy:

Loss_on = -(0 × ln(0.003772) + 0 × ln(0.006130)
            + 1 × ln(0.009605) + 0 × ln(0.003539) + 其余零项)
        = -ln(0.009605)
        ≈ 4.6455

正确答案获得的概率越接近 1,Loss 越接近 0;概率趋近 0 时,Loss 趋近正无穷。

对三个输入位置使用相同规则,得到:

输入位置Target该位置 Loss
Catssit(45)4.9190
siton(78)4.8186
onmats(90)4.6455

三个位置均有效时:

Batch Loss = (Loss_Cats + Loss_sit + Loss_on) / 3
           ≈ (4.9190 + 4.8186 + 4.6455) / 3
           ≈ 4.7943

这个平均 Loss 交给后文 Backward,计算各参数的 Gradient。

SFT:监督位置怎样影响 Loss

问题

一条 SFT 训练数据同时包含 User 问题和标准 Assistant 回答。怎样利用模型对这段对话的预测,计算它与标准回答之间的 Loss,同时排除 User 等非监督目标?

方案

SFT 沿用前节 Cross-Entropy,需要两组数据:

  • 模型预测:完整训练对话经过一次 Forward,得到各输入位置的词表 Logits;每行经 Softmax 得到该位置对下一个 token 的预测 Probability。
  • 标准答案:训练数据中的 Assistant 回答及其结束标记,提供各监督位置的 Target token ID;正确候选在目标分布中的值为 1,其余为 0。

Target Mask 选择参与监督的位置。每个保留位置用标准答案的 Target ID,从该位置的预测分布中取出正确候选的 Probability,按前节规则计算位置 Loss;最后对保留位置的 Loss 求平均,得到这条训练数据的标量 Loss。

结构与核心逻辑

每个位置使用两级索引:输入位置决定取哪一行 Logits,Target ID 决定从该行概率分布中取哪个候选。Mask 标记的是这个位置的预测目标是否参与监督;Target 的准备与对齐见 Workflow:SFT。

def sft_loss(input_ids, targets, target_mask):
    # 1. 完整对话参与 Forward,每个输入位置输出一行词表 Logits
    logits = model(input_ids)

    # 2. 保留 Assistant 目标的 ID,其他目标替换为 -100
    masked_targets = targets.copy()
    for position in positions:
        if target_mask[position] == 0:
            masked_targets[position] = -100

    # 3. 对保留位置应用前节 Cross-Entropy 的计算规则
    position_losses = []
    for position, target_id in enumerate(masked_targets):
        if target_id == -100:
            continue
        probabilities = softmax(logits[position])
        target_probability = probabilities[target_id]
        position_losses.append(-ln(target_probability))

    # 4. 只对监督位置平均,输出这条数据的标量 Loss
    return mean(position_losses)

这里展开单条数据的计算,省略 Batch 维度。实际可将 masked_targets 直接交给前节 cross_entropy,通过 ignore_index=-100 实现同样的筛选和计算。User 内容仍作为上下文参与 Forward。

例子

沿用标准回答 Cats sit on mats:

User:Write a sentence about cats sitting on mats.
Assistant:Cats sit on mats

User 内容简化为两个 token,特殊 token ID 仅作示意;242 表示 <END>。下面按输入位置对齐预测目标,Target ID 对应目标 token:

输入位置       <USER>    问题前半  问题后半  <ASSISTANT>  Cats  sit  on  mats
预测目标       问题前半  问题后半  <ASSISTANT>  Cats     sit   on  mats <END>
Token ID          200       201       241        12       45   78   90   242
Target Mask         0         0         0         1        1    1    1     1
masked_targets   -100      -100      -100        12       45   78   90   242

以下 Probability 为本节演算假设值。每个值来自对应输入位置的整行 Logits 经 Softmax 后,再按标准答案 Target ID 选出的那一项:

输入位置标准答案 tokenTarget IDMask取出的 Probability位置 Loss
<USER>问题前半2000—不计入
问题前半问题后半2010—不计入
问题后半<ASSISTANT>2410—不计入
<ASSISTANT>Cats121p[12]=0.5-ln(0.5) ≈ 0.6931
Catssit451p[45]=0.25-ln(0.25) ≈ 1.3863
siton781p[78]=0.5-ln(0.5) ≈ 0.6931
onmats901p[90]=0.25-ln(0.25) ≈ 1.3863
mats<END>2421p[242]=0.5-ln(0.5) ≈ 0.6931
这条数据的 Loss ≈ (0.6931 + 1.3863 + 0.6931 + 1.3863 + 0.6931) / 5
               ≈ 0.9704

输出 0.9704 是五个监督位置的平均 Loss,交给 Backward 计算参数 Gradient。

GRPO:回答评价怎样构成优化目标

问题

Workflow:GRPO 已得到同一个 Prompt 的多个回答及其 Reward。现在需要把评价连接到模型的预测,构造可供 Backward 使用的 Loss。

评价针对整段回答,但模型逐 token 生成内容。怎样将回答的好坏作用到各个生成位置,并控制训练中的调整幅度?

方案

GRPO 先根据回答评价确定学习方向,再按 token 位置 → 单个回答 → 同题回答组 三个层级计算 Loss:

  1. 确定每个回答的学习方向:通过组内 Reward 比较得到 Advantage。正值鼓励提高该回答中已生成 token 的概率,负值鼓励降低概率;同一回答的所有有效生成位置共用这个值。
  2. 计算每个 token 位置的 Loss:对同一位置、同一已生成 token,比较旧概率和当前概率,得到 Ratio。结合该回答的 Advantage 和 Clipping,计算位置 Loss。Advantage 决定方向,Ratio 表示概率变化,Clipping 限制继续奖励过大变化。
  3. 计算单个回答的 Loss:对回答内所有有效生成位置的 Loss 求平均,分别得到回答 A、B、C 等各自的 Loss。
  4. 计算同题回答组的 Loss:对同一个 Prompt 的多个回答 Loss 求平均,得到该组的标量 Loss,交给 Backward。

结构与核心逻辑

下面重点展开 Loss 计算。回答生成、评分及训练循环见 Workflow:GRPO;概率的计算方式沿用前文。

输入是同一 Prompt 对应的一组回答及其 Reward、旧概率、当前概率和有效生成位置标记:

数据在 Loss 计算中的作用
Reward → Advantage通过组内比较,确定每个回答的鼓励或抑制方向;同一回答的有效位置共用这个值
旧概率生成这批回答时记录的 token 概率,作为本批固定的比较基准
当前概率每次训练 Forward 重新计算同一批回答中已生成 token 的概率,参与求导
有效位置标记只让实际生成的回答 token 参与计算,排除 Prompt 和 Padding
        Reward                        旧概率、当前概率
           │                                  │
           ▼                                  ▼
┌────────────────────┐            ┌──────────────────────┐
│     组内标准化     │            │  当前概率 / 旧概率   │
└──────────┬─────────┘            └───────────┬──────────┘
           │                                  │
      Advantage                            Ratio
           │                                  │
           └─────────────────┬────────────────┘
                             ▼
          ┌────────────────────────────────────┐
          │     每个有效 token 的位置 Loss     │
          │  结合 Advantage、Ratio、Clipping   │
          │         比较两个目标并取负         │
          └──────────────────┬─────────────────┘
                             │
                         位置 Loss
                             │
                             ▼
          ┌────────────────────────────────────┐
          │             Loss 汇总              │
          │   回答内有效位置平均 → 回答 Loss   │
          │       同题回答平均 → 组 Loss       │
          └──────────────────┬─────────────────┘
                             │
                          组 Loss
                             │
                             ▼
                         Backward

Clipping 限制的是 Loss 的奖励机制,本例范围取 [0.8,1.2]:

def grpo_loss(rewards, old_probs, current_probs, generated_mask):
    # 1. 确定回答的学习方向,各有效生成位置共用该回答的 Advantage
    advantages = (rewards - mean(rewards)) / (std(rewards) + 1e-8)

    answer_losses = []
    for answer in range(len(rewards)):
        position_losses = []

        for position in valid_positions(generated_mask[answer]):
            # 2. 计算这个 token 位置的 Loss:先比较当前概率与旧概率
            ratio = current_probs[answer, position] / old_probs[answer, position]

            # 结合该回答的 Advantage 和 Clipping,构造位置目标并取负
            advantage = advantages[answer]
            clipped_ratio = clip(ratio, 0.8, 1.2)
            objective = min(ratio * advantage, clipped_ratio * advantage)
            position_losses.append(-objective)

        # 3. 对当前回答的有效位置 Loss 求平均,得到单个回答的 Loss
        answer_losses.append(mean(position_losses))

    # 4. 对同题回答的 Loss 求平均,得到该组的标量 Loss
    return mean(answer_losses)

例子

承接 Workflow:GRPO 四个回答的 Reward。以下展示同一批回答已经执行过一次 GRPO 更新后,再次计算 Loss 的情况。数值演算将每个回答简化为两个有效生成位置,概率为演示假设值。计算使用未舍入值,结果保留四位小数。

先确定每个回答的学习方向:按组内 Reward 计算 Advantage,同一回答的两个有效生成位置共用这个值。Standard Deviation 按四项的平均平方差计算:

Reward:[0.9,0.3,0.8,0.2]
mean = (0.9+0.3+0.8+0.2)/4 = 0.55
std = sqrt(((0.9-0.55)² + (0.3-0.55)²
            + (0.8-0.55)² + (0.2-0.55)²) / 4)
    ≈ 0.3041

Advantage_A = (0.9-0.55)/std ≈  1.1508
Advantage_B = (0.3-0.55)/std ≈ -0.8220
Advantage_C = (0.8-0.55)/std ≈  0.8220
Advantage_D = (0.2-0.55)/std ≈ -1.1508
回答Reward位置 1 的 Advantage位置 2 的 Advantage目标方向
A0.91.15081.1508鼓励
B0.3-0.8220-0.8220抑制
C0.80.82200.8220鼓励
D0.2-1.1508-1.1508抑制

再计算各 token 位置的 Loss。先求每个位置的 Ratio;每行的旧概率与当前概率都对应该位置实际生成的同一个 token:

回答生成位置旧概率当前概率Ratio=当前/旧
A10.200.260.26/0.20 = 1.3
A20.100.110.11/0.10 = 1.1
B10.200.140.14/0.20 = 0.7
B20.100.090.09/0.10 = 0.9
C10.200.220.22/0.20 = 1.1
C20.100.120.12/0.10 = 1.2
D10.200.180.18/0.20 = 0.9
D20.100.080.08/0.10 = 0.8

接着结合 Advantage、Ratio 和 Clipping 计算位置 Loss。范围取 [0.8,1.2]。

A 的两个位置都使用正 Advantage 1.1508。位置 1 的概率已变为旧概率的 1.3 倍,目标使用受限的 1.2;位置 2 的 1.1 仍在范围内:

A 位置 1:
Loss = -min(1.3×1.1508, 1.2×1.1508) ≈ -1.3810

A 位置 2:
Loss = -min(1.1×1.1508, 1.1×1.1508) ≈ -1.2659

B 的两个位置都使用负 Advantage -0.8220。位置 1 的概率已降至旧概率的 0.7 倍,目标使用受限的 0.8;位置 2 的 0.9 仍在范围内:

B 位置 1:
Loss = -min(0.7×(-0.8220), 0.8×(-0.8220)) ≈ 0.6576

B 位置 2:
Loss = -min(0.9×(-0.8220), 0.9×(-0.8220)) ≈ 0.7398

按相同规则计算 C、D,得到全部位置 Loss:

回答位置 1 Loss位置 2 Loss
A-1.3810-1.2659
B0.65760.7398
C-0.9042-0.9864
D1.03570.9206

接着计算单个回答的 Loss:分别对 A、B、C、D 内部两个有效位置的 Loss 求平均:

回答 A:(-1.3810 - 1.2659)/2 ≈ -1.3234
回答 B:( 0.6576 + 0.7398)/2 ≈  0.6987
回答 C:(-0.9042 - 0.9864)/2 ≈ -0.9453
回答 D:( 1.0357 + 0.9206)/2 ≈  0.9782

最后计算同题回答组的 Loss:对四个回答各自的 Loss 求平均:

组 Loss ≈ (-1.3234 + 0.6987 - 0.9453 + 0.9782)/4
        ≈ -0.1480

这个标量交给 Backward。其求导路径连接到当前概率;Advantage 和旧概率在本次计算中固定。

算法与实现说明参见 Hugging Face: GRPO Trainer。

通用学习机制:从 Loss 到参数更新

前章已经得到标量 Loss。Backward 沿计算依赖求出参数 Gradient,Optimizer 使用这些 Gradient 更新参数;Gradient Accumulation 改变多批 Gradient 的聚合方式,LoRA 改变可训练参数的表示。

Loss → Backward → Parameters.grad → Optimizer → 更新后的 Parameters
                                                    ↓
                                              下一次 Forward 使用

Backward

问题

前文 Cross-Entropy 已得到 Batch Loss ≈4.7943。怎样沿模型计算依赖,得到 LM Head、Final Norm、各 Transformer Layer 和 Embedding 参数各自的 Gradient,供 Optimizer 使用?

方案

Backward 计算各参数的 Gradient,即参数微小变化时 Loss 的局部变化率:

  1. 保留求导依据:Forward 保存运算依赖和求导所需的中间值。
  2. 逆序传递影响:从 Loss 开始,按 Forward 的逆序计算各模块的输入与参数 Gradient。
  3. 汇总各路径贡献:遇到共享参数、Attention 信息读取和 Residual 分支时,累加各路径贡献。
  4. 保存参数 Gradient:将贡献写入参数 .grad,交给 Optimizer;参数值在本次 Backward 中保持不变。

结构与核心逻辑

完整反向路径展开到 Embedding,各模块分别累加自己的参数 Gradient,并把输入 Gradient 继续传向前面的模块:

                      Loss
                        │
                        ▼
        ┌──────────────────────────────┐
        │       Loss 的反向计算        │
        └───────────────┬──────────────┘
                        │
                 Logits Gradient
                        │
                        ▼
        ┌──────────────────────────────┐
        │           LM Head            │──→ Weight.grad
        └───────────────┬──────────────┘
                        │
                        ▼
        ┌──────────────────────────────┐
        │          Final Norm          │──→ Weight.grad
        └───────────────┬──────────────┘
                        │
                        ▼
        ┌──────────────────────────────┐
        │     Transformer Layer 2      │──→ 本层参数 .grad
        └───────────────┬──────────────┘
                        │
                        ▼
        ┌──────────────────────────────┐
        │     Transformer Layer 1      │──→ 本层参数 .grad
        └───────────────┬──────────────┘
                        │
                        ▼
        ┌──────────────────────────────┐
        │       Token Embedding        │──→ 对应参数行 .grad
        └──────────────────────────────┘

Forward 保存的数据在下面代码中记为 cache。它包括各模块的输入、求导需要的中间值和输入 token IDs;这里用于 Backward,区别于生成时保存历史 K/V 的 KV Cache。

各模块的反向函数接收输出 Gradient,结合本模块参数和保存的数据,累加参数 Gradient,并返回输入 Gradient。模型级代码连接整条路径:

def model_backward(grad_logits, model, cache):
    # 1. LM Head:保存 Weight Gradient,返回输入特征的 Gradient
    grad = lm_head_backward(grad_logits, model.lm_head, cache.lm_head)

    # 2. Final Norm:保存缩放参数 Gradient,传回最后一层输出
    grad = norm_backward(grad, model.final_norm, cache.final_norm)

    # 3. Transformer:从最后一层向第一层反传
    for index in reversed(range(len(model.layers))):
        grad = layer_backward(grad, model.layers[index], cache.layers[index])

    # 4. Embedding:按输入 token ID 累加到对应参数行
    for position, token_id in enumerate(cache.input_ids):
        model.embedding.weight.grad[token_id] += grad[position]

每层 Forward 已定义为:

r = x + attention(norm1(x))
y = r + mlp(norm2(r))

Backward 先处理 MLP,再处理 Attention。两次 Residual 都保留直接路径,并与子模块路径的贡献相加:

def layer_backward(grad_y, layer, cache):
    # 1. 第二次 Residual:直接路径保留收到的 Gradient
    grad_r = grad_y.copy()

    # 2. MLP 路径:经过 MLP、Norm 2,与直接路径相加
    grad_norm2 = mlp_backward(grad_y, layer.mlp, cache.mlp)
    grad_r += norm_backward(grad_norm2, layer.norm2, cache.norm2)

    # 3. 第一次 Residual:同样保留直接路径
    grad_x = grad_r.copy()

    # 4. Attention 路径:经过 Attention、Norm 1,汇总到输入
    grad_norm1 = attention_backward(grad_r, layer.attention, cache.attention)
    grad_x += norm_backward(grad_norm1, layer.norm1, cache.norm1)

    return grad_x

子模块按第一篇 Transformer 定义的计算依赖执行反向规则:

模块反向计算内容
LM Head使用 Hidden States 计算 Weight Gradient,使用 Weight 计算输入 Gradient
MLP逆向经过 Down Projection、Activation、Up Projection,计算参数与输入 Gradient
Attention逆向处理 Output Projection、各 Head、读取权重与 Q/K/V,汇总不同位置对输入的贡献
Norm计算缩放参数 Gradient,以及归一化前输入的 Gradient
Embedding按输入 token ID 累加到对应参数行;重复 ID 的贡献继续累加

下面沿用前文 Cross-Entropy,为模型反向入口提供 Logits Gradient。示例三个位置均有效,省略 Batch 维度;参数 .grad 已在本次累积开始前清零:

def cross_entropy_backward(logits, targets):
    # 根据预测分布和正确 Target,计算各位置的 Logit Gradient
    grad_logits = softmax(logits)
    for position, target_id in enumerate(targets):
        grad_logits[position, target_id] -= 1

    # 前文 Loss 按有效位置平均,Gradient 同样按位置数缩放
    return grad_logits / len(targets)


grad_logits = cross_entropy_backward(logits, targets)
model_backward(grad_logits, model, cache)

最终各参数的 .grad 包含相关路径的贡献。输入 token IDs 只用于定位 Embedding 行,本身不求 Gradient。

例子

继续使用 Cats sit on 的三个有效位置,跟踪 mats 候选及 LM Head 参数 w=W[mats,c3]=0.3。计算使用前文显示精度,结果取近似值。

先计算 on 位置的 Logit Gradient。Target 为 mats,所以该候选的预测概率减 1;Batch Loss 对三个位置取平均,再除以 3:

预测 Probability:0.009605
正确 Target:mats

Loss_on 对 mats Logit 的 Gradient:
0.009605 - 1 = -0.990395

Batch Loss 对该 Logit 的 Gradient:
-0.990395 / 3 ≈ -0.330132

再将这一贡献传向参数。第一篇 Final Norm 输出在 on 位置的 c3 特征为 1.7522:

该位置对 w.grad 的贡献:
-0.330132 × 1.7522 ≈ -0.578457

同一个 w 也参与了 Cats、sit 两个位置的打分。它们的 Target 都不是 mats,该候选的 Logit Gradient 为其预测概率除以 3:

输入位置p(mats)Targetmats 的 Logit Gradienth_c3对 w.grad 的贡献
Cats0.007204sit0.007204/3 ≈ 0.0024011.11410.002675
sit0.010159on0.010159/3 ≈ 0.0033860.86590.002932
on0.009605mats(0.009605-1)/3 ≈ -0.3301321.7522-0.578457

最后汇总三个位置对共享参数的贡献:

w.grad ≈ 0.002675 + 0.002932 - 0.578457
       ≈ -0.57285

Backward 后:
w      = 0.3
w.grad ≈ -0.57285

这个负 Gradient 表示,保持其他参数不变,微小增大 w,Loss 在当前参数附近会下降。

下面使用同一两层模型未舍入的 Forward 结果,继续反向计算到 Embedding。计算保留三个位置,下表只摘录 on 位置的向量 Gradient:

反向到达的位置on 位置的 Gradient
Final Norm 输出[-0.066341,-0.131573,-0.031755,-0.098827]
Final Norm 输入,即 Layer 2 输出[-0.015602,-0.013969,-0.008004,0.007508]
Layer 2 输入,即 Layer 1 输出[-0.014462,-0.023547,-0.008080,0.012506]
Layer 1 输入,即 Embedding 输出[-0.040909,-0.131007,-0.003027,0.059970]

Backward 完成后,各组参数分别保存自己的 .grad,其 Shape 与对应参数相同。下面统一展示五类参数的最终 Gradient,矩阵只摘取部分行:

# LM Head:mats(ID=90)对应的打分参数 Gradient
lm_head.weight.grad[90]
# tensor([0.002580, -0.312774, 0.016978, -0.572860])

# Final Norm:四个逐维缩放参数的 Gradient
final_norm.weight.grad
# tensor([-0.026763, -0.284987, -0.198043, -0.237038])

# 第二层 Attention:Output Projection 参数第 3 行的 Gradient
layers[1].attention.Wo.grad[3]
# tensor([-0.012041, -0.019549, -0.021298, 0.031453])

# 第二层 MLP:Down Projection 参数第 3 行的 Gradient
layers[1].mlp.W_down.grad[3]
# tensor([-0.019836, -0.007320, -0.022560, 0.023984])

# Token Embedding:输入 token IDs 对应参数行的 Gradient
embedding.weight.grad[12]  # Cats
# tensor([-0.008727, -0.008054, -0.097432, 0.128552])
embedding.weight.grad[45]  # sit
# tensor([-0.025421, -0.047753, -0.005087, 0.077225])
embedding.weight.grad[78]  # on
# tensor([-0.040909, -0.131007, -0.003027, 0.059970])

各参数值保持不变,Optimizer 随后使用这些 .grad 更新对应参数。

Optimizer

问题

Backward 已将 Gradient 写入各参数的 .grad。怎样据此确定本次更新量、写回参数,并保存下一次更新需要的历史信息?

方案

Optimizer 读取当前参数、Gradient 和历史 State,计算本次更新量,再将新参数与新 State 写回。下面以 AdamW 为主展开:

  1. 汇总历史方向与尺度:分别更新 Gradient 和 Gradient 平方的移动统计,避免只依赖当前一步的数值。
  2. 确定本次更新量:修正统计从零开始造成的偏差,再结合 Learning Rate、历史方向与尺度计算更新量。
  3. 写回参数和状态:独立施加 Weight Decay,减去更新量;保存历史统计,供下一次更新使用。

Gradient Descent 直接使用当前 Gradient,Momentum 加入历史方向;它们作为更新规则的对照放在例子末尾。

原理介绍见 Optimizer: What Does It Do and Why We Need It(HF 社区文章),配置与使用见 Optimizers and schedulers,算法定义见 PyTorch: AdamW。

结构与核心逻辑

┌────────────────────────────────────────┐
│ 输入:Parameter、Gradient、历史 State  │
└────────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────┐
│       更新历史统计并修正初始偏差       │
│      得到方向 m_hat 与尺度 v_hat       │
└────────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────┐
│               确定更新量               │
│   Learning Rate 缩放、按历史尺度调整   │
└────────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────┐
│        写回 Parameter 和 State         │
│  参数施加 Weight Decay,再减去更新量   │
└────────────────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────┐
│         输出:新参数、新 State         │
│    下一次 Forward / 下一次更新使用     │
└────────────────────────────────────────┘

Optimizer 在训练开始前建立,持有模型参数的引用。每个参数分别保存自己的历史统计:

数据职责
Parameter、Parameter.grad当前参数与本次 Backward 的结果
State:step、m、v更新次数、Gradient 移动平均、Gradient 平方移动平均
lr、beta1、beta2更新幅度、两份历史统计的保留比例
epsilon、weight_decay防止分母为零、控制参数的独立收缩
def adamw_step(parameter, state, lr, beta1, beta2, epsilon, weight_decay):
    # 1. 读取 Gradient,更新每个参数自己的历史统计
    g = parameter.grad
    state.step += 1
    state.m = beta1 * state.m + (1 - beta1) * g
    state.v = beta2 * state.v + (1 - beta2) * g**2

    # 2. 修正初始偏差,结合方向和尺度确定更新量
    m_hat = state.m / (1 - beta1**state.step)
    v_hat = state.v / (1 - beta2**state.step)
    update = lr * m_hat / (sqrt(v_hat) + epsilon)

    # 3. 写回参数;新的 m、v、step 保留在 State 中
    parameter.value *= 1 - lr * weight_decay
    parameter.value -= update

optimizer.step() 为所管理的参数执行更新;optimizer.zero_grad() 清空 Gradient,保留 State。下一次 Forward 读取更新后的参数。

例子

承接 Backward 的同一参数:w=0.3、w.grad≈-0.57285。本次使用 AdamW,设置 lr=0.1、beta1=0.9、beta2=0.999、weight_decay=0.01、epsilon=1e-8,首次更新前 step=m=v=0。

先更新方向和尺度统计:

step = 1
m = 0.9×0 + 0.1×(-0.57285) = -0.057285
v = 0.999×0 + 0.001×(-0.57285)² ≈ 0.000328157

再修正初始偏差,计算本次更新量:

m_hat = -0.057285 / (1-0.9) = -0.57285
v_hat ≈ 0.000328157 / (1-0.999) ≈ 0.328157
update = 0.1×m_hat / (sqrt(v_hat)+1e-8) ≈ -0.1

最后写回参数,并保留 State:

Weight Decay 后:w = 0.3×(1-0.1×0.01) = 0.2997
减去更新量后:  w = 0.2997 - (-0.1) ≈ 0.3997
数据更新前更新后
w0.30.3997
step01
m0-0.057285
v00.000328157

下次 Forward 使用 w≈0.3997,下次 AdamW 继续使用表中保存的 m、v、step。以上近似值由未舍入的中间结果计算。

同一初始参数、Gradient 和 Learning Rate 使用不同算法时,首步对照如下;各行是独立更新:

算法首步计算更新后 w
Gradient Descent0.3 - 0.1×(-0.57285)0.357285
Momentum,初始 velocity=0velocity=0.9×0-0.57285,再执行 w-lr×velocity0.357285
AdamW,初始 m=v=0本节完整演算0.3997

Gradient Accumulation

问题

一次参数更新需要使用一组训练数据,但显存无法同时容纳其 Forward 和 Backward 所需的中间数据。拆成多个 micro-batch 后,怎样将各批贡献合成为同一个整体训练目标的 Gradient,并只更新一次参数?

方案

Gradient Accumulation 分批完成计算,累积期间保持参数不变,最后统一更新:

  1. 确定整体目标的权重:统计各批有效目标数及总数,让每个有效目标在整体平均 Loss 中具有相同权重。
  2. 分批计算并累加:每批平均 Loss 乘以本批目标数占比,再执行 Backward,将贡献累加到同一份 .grad。
  3. 完成一次更新:全部批次完成后,Optimizer 使用累积 Gradient 更新一次,再清空 Gradient。

有效目标数相等时,两批的权重各为 1/2;数量不等时按数量加权。拆批与执行流程见 Workflow:Gradient Accumulation。

结构与核心逻辑

                                本次更新的同一组参数
                                          │
                   ┌──────────────────────┴──────────────────────┐
                   ▼                                             ▼
┌────────────────────────────────────┐        ┌────────────────────────────────────┐
│           micro-batch 1            │        │           micro-batch 2            │
│       平均 Loss × 目标数占比       │        │       平均 Loss × 目标数占比       │
└────────────────────────────────────┘        └────────────────────────────────────┘
                   │                                             │
                   ▼                                             ▼
┌────────────────────────────────────┐        ┌────────────────────────────────────┐
│              Backward              │        │              Backward              │
└────────────────────────────────────┘        └────────────────────────────────────┘
                   │                                             │
                   │                                             │
                   └──────────────────────┬──────────────────────┘
                                          │
                                          ▼
                     ┌────────────────────────────────────────┐
                     │         同一份 Parameter.grad          │
                     │           累加所有批次的贡献           │
                     └────────────────────────────────────────┘
                                          │
                                          ▼
                     ┌────────────────────────────────────────┐
                     │           Optimizer 更新一次           │
                     └────────────────────────────────────────┘
数据或状态在本次累积中的职责
各批有效目标数、总有效目标数确定各批平均 Loss 的权重
Parameter所有 micro-batch 读取相同参数,累积结束才更新
Parameter.grad每次 Backward 累加本批加权后的贡献
def accumulation_step(micro_batches, optimizer):
    # 1. 按本次更新的全部有效目标,确定各批权重
    counts = [count_valid_targets(batch) for batch in micro_batches]
    total_count = sum(counts)
    optimizer.zero_grad()

    # 2. 保持参数不变,分批计算并累加 Gradient
    for batch, count in zip(micro_batches, counts):
        if count == 0:
            continue
        batch_loss = mean_loss(batch)
        weighted_loss = batch_loss * (count / total_count)
        weighted_loss.backward()

    # 3. 全部批次完成后更新一次,清空本次 Gradient
    optimizer.step()
    optimizer.zero_grad()

这里的 mean_loss 使用本批有效目标计算平均 Loss,监督位置的识别沿用前文。

例子

下面使用独立的 Gradient 数值验证整体目标。假设四个有效目标对同一个参数的 Gradient 分别为 [1,3,-1,5],参数初始值为 0.3。

完整批的平均 Gradient 为:

(1+3-1+5)/4 = 2

拆成两批,各批读取的参数仍为 0.3:

micro-batch目标的 Gradient有效目标数本批平均 Gradient权重对 .grad 的贡献
1[1,3]222/41
2[-1,5]222/41
累计 parameter.grad = 1+1 = 2

累积结果与完整批一致。使用 lr=0.1 的普通 Gradient Descent,只更新一次:

parameter_new = 0.3 - 0.1×2 = 0.1

再用目标数不等的情况验证同一加权规则。取三个有效目标的 Gradient 为 [1,3,5],分成两批:

micro-batch目标的 Gradient本批平均 Gradient权重对 .grad 的贡献
1[1,3]22/34/3
2[5]51/35/3
累计 Gradient = 4/3 + 5/3 = 3
完整批平均 Gradient = (1+3+5)/3 = 3

上述对应关系要求累积期间参数不变,且各目标的 Forward 计算不因拆批而改变。

LoRA:改变可训练参数的表示

问题

全量 SFT 对全部模型参数进行训练,需要保存相应的 Gradient 和 Optimizer State,显存开销较大。怎样减少这些训练开销,同时保留调整模型输出的能力?

方案

LoRA 将训练对象从完整 Weight 改为少量新增的低秩参数,基础参数继续提供原有计算:

  1. 减少需要训练的参数:冻结基础 Weight W,新增两个较小的矩阵 A/B,Gradient 和 Optimizer State 只为 A/B 保存。
  2. 保留输出调整能力:输入同时经过基础路径和 Adapter 路径,将 A/B 产生的调整量加到原输出上,输出维度保持不变。
  3. 接入原有学习机制:Loss 经 Backward 为 A/B 计算 Gradient,Optimizer 只更新 A/B,随后同一输入就能得到调整后的输出。

基础 Weight 本身仍需保存,训练仍有 Activations 开销。使用场景与训练流程见 Workflow:LoRA。

结构与核心逻辑

                                       输入 x
                                          │
                  ┌───────────────────────┴───────────────────────┐
                  ▼                                               ▼
┌──────────────────────────────────┐            ┌──────────────────────────────────┐
│           基础路径:W            │            │        Adapter 路径:A/B         │
│             冻结参数             │            │           低秩、可训练           │
└──────────────────────────────────┘            └──────────────────────────────────┘
                  │                                               │
             base_output                                     adjustment
                  │                                               │
                  └───────────────────────┬───────────────────────┘
                                          ▼
                     ┌────────────────────────────────────────┐
                     │                两路相加                │
                     │             保留原输出维度             │
                     └────────────────────────────────────────┘
                                          │
                                          ▼
                     ┌────────────────────────────────────────┐
                     │            后续模块与 Loss             │
                     └────────────────────────────────────────┘
                                          │
                                          ▼
                     ┌────────────────────────────────────────┐
                     │                Backward                │
                     │            A/B 的 Gradient             │
                     └────────────────────────────────────────┘
                                          │
                                          ▼
                     ┌────────────────────────────────────────┐
                     │           Optimizer 更新 A/B           │
                     │            基础 W 保持不变             │
                     └────────────────────────────────────────┘
参数或配置职责
W [out,in]原模块基础 Weight,冻结,继续参与 Forward
A [rank,in]、B [out,rank]可训练 Adapter,将调整量表示为两个低秩矩阵的组合
rankAdapter 中间维度,可训练参数量为 rank×(in+out)
scale=alpha/rank控制 Adapter 调整量的缩放
def lora_forward(x, frozen_weight, A, B, scale):
    # 1. 基础路径使用冻结的 Weight,保留原有变换
    base_output = x @ frozen_weight.T

    # 2. Adapter 用 A/B 计算可训练的输出调整
    adapter_hidden = x @ A.T
    adjustment = (adapter_hidden @ B.T) * scale

    # 3. 合并两路结果,交给原有后续模块
    return base_output + adjustment


# 参数配置:W 冻结,Optimizer 只管理 A/B
freeze(W)
optimizer = Optimizer([A, B])

需要向可训练前序模块传递 Gradient 时,基础路径和 Adapter 路径仍参与反传。Loss、Backward 与参数更新规则沿用前文。

例子

先验证训练存储怎样减少。对同一个 [4096,4096] Weight,全量训练需要更新 16777216 个参数;LoRA 取 rank=8,只训练 A [8,4096] 和 B [4096,8],共 65536 个参数。

Gradient 与 AdamW 两份移动统计均按 FP32 的每元素 4 Bytes 计算:

统计项Full Fine-tuningLoRA
可训练参数量4096×4096 = 167772168×4096 + 4096×8 = 65536
Gradient64 MiB256 KiB
AdamW 的 m、v128 MiB512 KiB
Gradient 与 m、v 合计192 MiB768 KiB

仅统计该模块的 Gradient 与两份移动统计,不含基础 Weight、Adapter 参数本身、Activations 和其他运行状态。

再用小矩阵验证输出调整与学习。取一组演示参数:W 为四阶单位矩阵,rank=1、scale=1:

A = [[1,0,0,0]]
B = [[0.1],[0.2],[0.3],[0.4]]
x = [1,-2,3,-4]

两条路径的 Forward 结果为:

base = x @ W.T             # [1,-2,3,-4]
adapter_hidden = x @ A.T   # [1]
adjustment = adapter_hidden @ B.T  # [0.1,0.2,0.3,0.4]
output = base + adjustment # [1.1,-1.8,3.3,-3.6]

假设后续计算传回输出 Gradient [1,1,1,1],本次 Backward 得到:

A.grad = [[1,-2,3,-4]]
B.grad = [[1],[1],[1],[1]]
W.grad 不保存

使用 lr=0.01 的普通 Gradient Descent,根据本次 Gradient 同时更新 A/B:

A_new = [[0.99,0.02,-0.03,0.04]]
B_new = [[0.09],[0.19],[0.29],[0.39]]
W 仍为四阶单位矩阵

同一个输入重新计算,基础路径保持原值,Adapter 给出新的调整量:

x @ A_newᵀ = [0.70]
adjustment_new = [0.063,0.133,0.203,0.273]
output_new = [1.063,-1.867,3.203,-3.727]

这次只更新 A/B,模块输出已发生变化。

生成计算:从 Logits 到下一个 Token

生成计算复用第一篇的模型参数与 Forward。下面分别展开历史 K/V 的存储与增量读取,以及最后位置的 Logits 怎样转为 token ID;输入准备、生成循环和输出流程见 LLM Workflow。

KV Cache:缓存结构与增量 Attention

问题

模型读完 Cats sit on 后,已经计算出各层、各位置的 K/V。新增 mats 时,Attention 仍需读取这些历史信息。

如果再次输入完整序列,历史位置会重复执行 Forward。怎样保存已有结果,让模型只计算新增位置,同时继续读取完整上下文?

保存历史结果又会增加显存占用:每层、每个样本、每个 KV Head 都需要保存 K/V,序列越长,缓存越大。怎样减少需要保存的 K/V 份数?

方案

KV Cache 按 Layer 保存历史 K/V:

  • Prefill 处理完整输入,建立各层缓存。
  • Decode 只处理新增 token。每层计算新增 Q/K/V,追加 K/V,再用当前 Q 读取历史及当前 K/V。

缓存容量还取决于 KV Head 数。MHA 为各 Query Head 使用独立 K/V;GQA 按组共享;MQA 让全部 Query Head 共享,从而减少缓存份数。

结构与核心逻辑

缓存保存当前模型各层已经处理的位置:

KV Cache
├── Layer 1
│   ├── K:[Batch, KV Head, Cached Token, Head Dimension]
│   └── V:[Batch, KV Head, Cached Token, Head Dimension]
└── Layer 2
    ├── K:同上
    └── V:同上

每层的新增 Q/K/V 来自该层当前输入。沿用第一篇 RoPE,Q/K 已包含对应位置的信息;V 保留内容特征。

def cached_attention(q, new_k, new_v, layer_cache, query_positions):
    # 1. 读取本层已经保存的历史 K/V
    history_k = layer_cache.K
    history_v = layer_cache.V

    # 2. 将新增位置追加到历史之后
    all_k = concat(history_k, new_k, axis="token")
    all_v = concat(history_v, new_v, axis="token")

    # 3. 当前 Q 读取可见的历史及新增 K/V
    output = causal_attention(q, all_k, all_v, query_positions)

    # 4. 保存更新后的缓存,供下一次 Decode 使用
    layer_cache.K = all_k
    layer_cache.V = all_v
    return output

Prefill 从空缓存开始;后续 Decode 在已有缓存上追加。Attention 输出继续经过本层后续计算,进入下一层,最终得到新增位置的 Logits。Q 只服务当前读取,不保存到历史缓存。

多个 Query Head 可以共用同一组 K/V。以下每行表示一种组织方式,方框内是实际保存的一组历史 K/V:

       ┌────────────────┬────────────────┬────────────────┬────────────────┐
MHA    │  Q0→[K0/V0]    │  Q1→[K1/V1]    │  Q2→[K2/V2]    │  Q3→[K3/V3]    │  4 组
       ├────────────────┴────────────────┼────────────────┴────────────────┤
GQA    │          Q0,Q1→[K0/V0]           │          Q2,Q3→[K1/V1]           │  2 组
       ├─────────────────────────────────┴─────────────────────────────────┤
MQA    │                        Q0,Q1,Q2,Q3→[K0/V0]                        │  1 组
       └───────────────────────────────────────────────────────────────────┘

共用 K/V 的 Head 仍有各自的 Q,可以算出不同的读取权重和输出。共享 K/V 减少了需要保存的历史缓存份数;采用哪种共享方式,由模型结构及对应参数决定。

GPT-3 Small(125M)每层有 12 个 Query Head、12 个 KV Head,一一对应,属于 MHA。配置见 Language Models are Few-Shot Learners。

GQA/MQA 节省缓存显存的代价是独立的 K/V 表示变少。例如,原来四个 Head 分别学习四组 K/V,分成两组后只能学习两组,不同关系与内容特征的表达可能受到限制。参见 GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints。

例子

沿用两层简化模型。Prefill 输入 Cats sit on 后,Layer 1、Head 0 已保存:

位置Token缓存 K缓存 V
0Cats[0.7303,-0.3651][0.7303,-0.3651]
1sit[-1.8665,0.0567][-0.9608,1.6013]
2on[-0.6663,0.0230][0.2981,0.5963]

新增 mats,位置为 3。取 embedding.weight[90]=[0.3,0.1,0.2,0.4],按第一篇 Transformer 中的 Norm、Projection、RoPE 计算,得到:

Q_mats = [-1.1360,-0.2069]
K_mats = [-1.1360,-0.2069]
V_mats = [1.0954,0.3651]

缓存前三行保持不变,追加第四行:

位置Token缓存 K缓存 V
3mats[-1.1360,-0.2069][1.0954,0.3651]

mats 的 Q 读取缓存中的四行 K/V,得到它在 Head 0 的 Attention 输出:

读取位置:  Cats      sit       on       mats
Scores:  [-0.5332,  1.4910,   0.5319,  0.9428]
Weights: [ 0.0631,  0.4777,   0.1831,  0.2761]

Head 0 输出 ≈ [-0.0559,0.9519]
缓存长度:3 → 4

各 Head 和 Layer 按相同方式处理新增位置,最终得到 mats 位置的 Logits,与完整输入 Cats sit on mats 重新计算的最后位置结果一致。

不同策略的缓存占用对照如下。配置为 N=32、B=1、长度=4096、Query Head 数=32、D=128,每元素 2 Bytes,仅统计 K/V Tensor:

组织方式KV Head 数K/V 缓存
MHA322 GiB
GQA8512 MiB
MQA164 MiB

缓存机制参见 Hugging Face: How caching works。

Token 选择:概率分布与采样

问题

模型已经输出下一 token 的词表 Logits。怎样根据这些分数选择生成结果,控制候选范围和结果变化,并在需要时比较多条候选序列的得分?

方案

常见的基础生成策略包括 Greedy Search、Sampling 和 Beam Search,见 Hugging Face: Generation strategies:

  1. Greedy Search:每步选最高分 token,只保留一条生成路径,返回该步的 token ID。
  2. Sampling:用 Temperature 调整分数差距,按需执行 Top-k / Top-p 筛选,再按保留候选的概率分布选择一个 token ID。
  3. Beam Search:同时保留多条候选序列。每轮扩展各序列,累加新 token 的 Log Probability,再在全部扩展结果中保留得分最高的若干条;完成后返回最高分的保留序列。

生成循环及输出衔接见 LLM Workflow。

结构与核心逻辑

                                 各候选序列最后位置的 Logits
                                              │
              ┌───────────────────────────────┼───────────────────────────────┐
              ▼                               ▼                               ▼
┌──────────────────────────┐    ┌──────────────────────────┐    ┌──────────────────────────┐
│      Greedy Search       │    │         Sampling         │    │       Beam Search        │
│    取最高分 token ID     │    │     Temperature 调整     │    │  扩展候选、累计序列得分  │
└──────────────────────────┘    │    Top-k / Top-p 筛选    │    │   保留前 num_beams 条    │
              │                 │        按概率采样        │    └──────────────────────────┘
              │                 └──────────────────────────┘                  │
              │                               │                               │
              │                               │                               ▼
              │                               │                 ┌──────────────────────────┐
              │                               │                 │      下一轮候选序列      │
              └───────────────┬───────────────┘                 │      继续扩展与筛选      │
                              │                                 └──────────────────────────┘
                              ▼                                               │
        ┌──────────────────────────────────────────┐                          │
        │              Next Token ID               │                          ▼
        │            交给下一次 Decode             │            ┌──────────────────────────┐
        └──────────────────────────────────────────┘            │          完成后          │
                                                                │    返回最高分保留序列    │
                                                                └──────────────────────────┘

Greedy、Sampling 每步完成一次 token 选择。核心代码沿用前文概率计算方式,Sampling 保留 Temperature、Top-k、Top-p 的处理位置:

def select_token(logits, mode, temperature=1, k=None, p=None):
    # 1. Greedy 直接返回最高分候选 ID
    if mode == "greedy":
        return argmax(logits)

    # 2. Sampling 用 Temperature 调整分数,按需保留 Top-k
    scores = logits / temperature
    candidates = all_token_ids()
    if k is not None:
        candidates = top_k_ids(scores, k)
    probabilities = softmax(scores[candidates])

    # 3. Top-p 保留累计概率首次达到阈值的候选集合
    if p is not None:
        order = sort_by_probability_descending(probabilities)
        keep = prefix_until_cumulative_reaches(order, probabilities, p)
        candidates = candidates[keep]
        probabilities = normalize(probabilities[keep])

    # 4. 按最终分布采样,返回原词表中的 ID
    return sample(candidates, probabilities)

Top-p 包含使累计概率达到或超过阈值的那个候选。Top-k 与 Top-p 都是可选项;本例组合时先 Top-k,再对当前候选分布应用 Top-p。

Beam Search 在多个生成步骤间保留候选序列。num_beams 指每轮保留几条;本例序列得分为新增 token 的 Log Probability 之和:

def beam_search(prompt, num_beams, max_new_tokens, eos_id):
    beams = [(prompt, 0)]

    for step in range(max_new_tokens):
        candidates = []
        for sequence, score in beams:
            # 1. 已结束的序列保留得分,其他序列继续预测
            if sequence[-1] == eos_id:
                candidates.append((sequence, score))
                continue
            log_probs = log_softmax(model(sequence).last_logits)

            # 2. 扩展各候选,累加新 token 的 Log Probability
            for token_id in top_ids(log_probs, num_beams):
                candidates.append(
                    (sequence + [token_id], score + log_probs[token_id])
                )

        # 3. 在所有扩展结果中统一保留前 num_beams 条
        beams = top_sequences(candidates, num_beams)
        if all_ended(beams, eos_id):
            break

    # 4. 返回最高分的保留序列
    return best_sequence(beams)

例子

输入仍为 Cats sit on,词表大小为 256。下面另设一组 Logits,比较 mats 与 chairs 两个候选:

候选mats(90)chairs(91)其余 254 项
Logit0.90730.6000每项为 0
完整词表 Probability0.0095920.007054合计约 0.983354

计算使用未舍入值,结果取近似值。

Greedy 取最高分 0.9073,直接返回 ID=90,即 mats。

Sampling 设置 Temperature=1、Top-k=2、Top-p=0.8。Temperature 为 1,分数不变;Top-k 保留 mats、chairs,在这两个候选上计算 Probability:

mats:  exp(0.9073)/(exp(0.9073)+exp(0.6)) ≈ 0.5762
chairs:exp(0.6)/(exp(0.9073)+exp(0.6))    ≈ 0.4238
按概率排序的候选Probability累计 ProbabilityTop-p=0.8 是否保留
mats(90)0.57620.5762是
chairs(91)0.42381.0000是

两项都保留,归一化后概率不变。假设均匀随机数为 u=0.7:

mats:  u ∈ [0,0.5762)
chairs:u ∈ [0.5762,1)

u=0.7
next_token_id=91,即 chairs

本次选择结果 91 交给 Decode。

再执行 Beam Search,设置 num_beams=2、max_new_tokens=2,结束标记为 <END>,ID 为 242。第一步使用上表的完整词表 Probability,保留两条候选:

新增序列Probability累计 Log Probability
mats0.009592-4.6468
chairs0.007054-4.9541

第二步分别预测 Cats sit on mats、Cats sit on chairs 后的 token。本节为这两个上下文另设以下完整词表分布,用于演示后续预测怎样影响序列排名:

已新增内容下一 token 候选及 Probability其余 254 项
mats<END>:0.40;chairs:0.30均分剩余 0.30
chairs<END>:0.80;mats:0.10均分剩余 0.10

各取前两个扩展结果,按累计得分统一排名:

新增序列累计 Log Probability本轮是否保留
chairs <END>-4.9541 + ln(0.80) ≈ -5.1773是
mats <END>-4.6468 + ln(0.40) ≈ -5.5631是
mats chairs-4.6468 + ln(0.30) ≈ -5.8508否
chairs mats-4.9541 + ln(0.10) ≈ -7.2567否

两条保留序列均已结束,返回得分最高的一条:

新增 token IDs:[91,242]
新增内容:chairs <END>

chairs 第一步概率较低,但它后接 <END> 的概率较高,最终序列得分超过 mats <END>。

最后单独对照 Sampling 参数,沿用本例初始 Logits:Top-k=2 后,若 Top-p 改为 0.5,仅保留 mats,其 Probability 归一化为 1,返回 90。

只调整 Temperature、不执行 Top-k 和 Top-p 时,完整词表分布对比如下:

候选Temperature=1Temperature=0.5
mats0.9592%2.3300%
chairs0.7054%1.2602%