# 30 - 模型训练原理与高效微调

---

**本章课程目标：**

- 能沿着一条样本解释 token 预测、Loss、梯度与优化器更新，分清输入上下文和直接监督的位置。
- 能计算 batch、梯度累积和 epoch 对有效批次与更新步数的影响，并解释学习率、预热和衰减的作用。
- 能说明训练显存的主要组成，分清数值精度与量化，理解序列长度、截断和梯度检查点的影响。
- 能比较全参数微调、LoRA 与 QLoRA，读懂 rank、alpha、target 和 dropout 的基本含义。
- 能把配置、训练与验证日志、检查点和实际回答联系起来，判断下一步应检查或调整什么。

**学习建议：** 先跟着分号例子讲清“预测 → 损失 → 梯度 → 更新”，再依次理解批次、学习率、长度、显存与 LoRA。第 8.1 节汇总一次完整训练安排，英文配置字段需要时再查；不用每学一个概念就重新读整张表。章末主线题按这个顺序自测，手算交叉熵与矩阵推导可以后看；读完后应能说明一份配置怎样安排训练，再进入第 31 章实操。

---

第 29 章已经准备好 1,600 条训练数据、200 条验证数据和 200 条测试数据。接下来，我们让 `Qwen/Qwen3-0.6B` 根据“文章 → 关键词”的示例，练习按要求提取关键词。

**先分清：训练设置、模型权重和观察结果。**

训练程序会根据文章和参考答案，调整模型内部参与计算的数值。这些数值叫作**模型参数，也叫权重**，由程序计算和更新。

我们填写的是**训练设置**：一次处理几条、整份数据练几遍、每次更新的幅度怎样控制。日常说“调训练参数”，通常就是修改这些设置。后文谈到“更新模型参数”，则是指程序改变内部权重。

程序还会计算 **Loss（损失值）**、记录训练进度，供我们观察。它们不需要预先填成某个目标数值；例如，“训练 3 轮”由我们设置，“现在完成了多少步”由程序记录。

**先认识五项设置。** 现在只看它们分别回答什么问题，具体变化在对应小节展开：

| 我们要决定的问题             | 训练设置                 | 本课程安排                          |
| ---------------------------- | ------------------------ | ----------------------------------- |
| 一次处理几条数据？           | 批处理大小（batch size） | 每批 4 条                           |
| 处理几批后更新一次模型参数？ | 梯度累积步数             | 累积 8 批再更新                     |
| 把整份训练数据练几遍？       | 训练轮数（epoch）        | 练习 3 轮                           |
| 每次更新的步子有多大？       | 学习率（learning rate）  | 按 `5e-5` 设置，随训练进度调节      |
| 一条训练样本最多保留多长？   | 截断长度（cutoff_len）   | 2048 个 token，包含输入和答案等内容 |

本课程使用 **LoRA**：训练时保持原模型权重不变，主要调整新增的一小部分权重。第 6 节介绍它的计算方式，以及怎样减少训练开销。

![训练分工教学示意：人填写训练设置，程序读取文章与参考答案并调整内部权重，人查看进度和关键词回答](images/30/30-0-0-1.png)

**阅读安排：** 正文结合例子解释训练过程和参数用途，手算与推导放在折叠的选读部分。

- **训练原理：** 上面五项设置的作用，训练怎样利用参考答案，LoRA 改了哪部分，以及 Loss 为什么不能代替实际回答质量。
- **配置参数：** 优化器、预热与调度、LoRA 的 rank 和 alpha、计算精度、梯度检查点等。完整配置在第 8.1 节汇总，实际运行时还需核对精度与显卡的匹配等条件。
- **进阶选读：** 交叉熵手算、标签数组、矩阵推导、量化编码和进一步的实验对照。

---

## 1、微调训练过程

**本节先掌握：** 训练围绕“预测 → 损失 → 调整”展开。模型根据输入预测答案，程序对照参考答案计算损失，再调整可训练权重。

沿用第 29 章的图书馆例子，先只看输入和参考答案：

```text
输入：市图书馆周末开设儿童阅读课，读者可通过公众号预约。
      请提取关键词，只输出关键词，并使用英文分号分隔。
参考答案：市图书馆;儿童阅读课;公众号预约
```

第 28 章已经区分过：普通推理使用已有权重，微调会更新可训练权重。现在只跟踪这条样本，看训练程序怎样利用参考答案。

先跟着这条样本走一遍。答案里的“市图书馆”后面应该接英文分号，假设模型在这里还不太倾向于使用分号，训练程序会怎样处理？

1. 模型读入文章、任务说明和前面的答案片段，计算下一个 token 的预测。
2. 程序用参考答案里的分号检查预测，把模型给正确项的概率换算成一个损失值。
3. 程序根据损失求出内部参数的调整方向，再按我们设定的更新规则改变可训练参数。
4. 执行更新后，后续样本会使用新参数继续计算。实际可以先累积多批信息再更新，第 2 节会解释这种安排。

**文章与参考答案没有被改写，改变的是模型内部用来计算预测的数值。** 上面只是跟踪一个位置来理解流程，实际训练会汇总多个位置、多个样本的信息；一次更新也不保证每个位置都预测得更好。下面先看程序怎样衡量预测，再看它怎样更新参数。

### 1.1 预测结果与损失

在“市图书馆”后面，模型会为许多候选 token 计算概率，英文分号只是其中一项。参考答案要求这里使用分号，因此我们先检查模型给它分配了多少概率。下面比较同一个位置的两种假设预测，不是模型实测输出：

| 对同一个正确 token 的预测 | 分号的概率 | 怎样理解                                   |
| ------------------------- | ---------- | ------------------------------------------ |
| 预测 A                    | 10%        | 模型给正确项的概率较低，这个位置的损失较大 |
| 预测 B                    | 80%        | 模型给正确项的概率较高，这个位置的损失较小 |

程序要用一个可计算的数衡量预测，这个数就是 **Loss（损失值）**。在这里，给正确 token 的概率越高，算出的损失越小。它提供了训练要努力降低的目标，第 1.2 节再解释怎样据此调整参数。

本课程使用**交叉熵（cross entropy）**把模型给正确 token 分配的概率换算成损失。

因此，即使两次预测最后都选中了分号，它们的损失也可能不同。交叉熵还会检查“模型给正确项分配了多大的概率”，不只是记一次对或错。

**先用于判断：Loss 是预测损失，不是关键词准确率。** 它由参考答案中需要学习的多个位置汇总而来。训练使用参考答案中的前文预测后面的 token，具体位置对应关系可展开查看。

<details>
<summary>进阶选读：预测位置怎样对应，为什么模型不能提前看答案</summary>

**分号的预测是怎样得到的？** 第 29 章已将输入和参考答案组织成训练序列。为便于说明，假设答案开头按 `[市] [图书馆] [;]` 拆分；实际分词以工具处理结果为准：

| 要预测的位置    | 模型可以使用的内容                     | 用来检查预测的参考 token |
| --------------- | -------------------------------------- | ------------------------ |
| 答案开头        | 用户文章、任务说明和助手回答的起始标记 | `市`                     |
| `市` 之后       | 上述上下文 + 参考答案中的 `市`         | `图书馆`                 |
| `市图书馆` 之后 | 上述上下文 + 参考答案中的 `市图书馆`   | `;`                      |

表中最后一行就是刚才检查的位置。虽然训练序列含有完整参考答案，但模型预测分号时，只能使用它之前的内容，不能看到待预测的分号及其后文。训练框架可以同时计算多个位置，每个位置都遵守这个限制。

训练时，前面的答案片段来自参考答案；实际让模型回答时，它要接着自己已经生成的内容往下预测。因此，训练不是先生成一整段回复，再拿两段文字逐字找不同。

一条参考答案里的文字、分号和结束标记都可能有需要预测的位置。程序计算并汇总需要计入的位置，得到训练使用的损失；哪些位置不计入，下一小节会说明。**日志中的 Loss 不是答错了几个词，也不是关键词准确率。** 第 9 节再结合实际日志说明汇总范围。

</details>

<details>
<summary>进阶选读：10% 和 80% 怎样变成损失值</summary>

本例每个预测位置都有一个确定的参考 token，按普通交叉熵计算，该位置的损失可以写成：

```text
这个位置的 Loss = -ln(正确 token 的概率)

概率 0.1：-ln(0.1) ≈ 2.3026
概率 0.8：-ln(0.8) ≈ 0.2231
```

`ln` 是自然对数，一种数学运算。它在这里把概率转换成损失：概率越接近 1，算出的损失越接近 0；概率越小，损失越大。

再假设一条短答案只有两个需要计分的位置，损失分别为 `2.3026` 和 `0.2231`。若按这两个位置取平均，得到约 `1.2629`。实际日志会进一步汇总多个样本和记录区间，见第 9 节。

公式与忽略标签的规则可对照 [PyTorch 交叉熵说明](https://docs.pytorch.org/docs/2.14/generated/torch.nn.CrossEntropyLoss.html)。这里用概率解释公式；直接调用 PyTorch 的 `CrossEntropyLoss` 时，接口接收的是模型尚未转换成概率的原始分数，不要把这段手算当成接口调用代码。

</details>

#### 1.1.1 训练标签与损失计算位置

输入中既有文章和任务说明，也有参考答案。那么，每个位置都要直接计算损失吗？

回到这个任务：我们希望模型学会“读文章、提取关键词”，而不是练习续写用户提交的文章。因此，本课程保留用户文章作为理解任务的上下文，主要根据助手答案中的预测位置计算损失。

这由课程配置中的 `train_on_prompt: false` 控制：用户文章提供上下文，其对应的预测位置不计入损失；助手答案对应的预测位置参与损失计算。下面的标签示例展示了这种区别。

<details>
<summary>调整时再看：train_on_prompt 改为 true 会改变什么</summary>

如果改为 `true`，用户文章中相应的 token 预测也会计入损失，程序就同时练习续写这部分内容。尤其是文章较长、关键词答案较短时，损失中会多出大量文章位置。训练目标的组成变了，不能拿修改前后两个 Loss 的大小直接判断关键词提取是否改善。本课程保持 `false`，将直接监督集中在助手答案上。

</details>

<details>
<summary>选读：训练标签 labels、-100 与预测位置怎样对应</summary>

训练程序用 **labels（训练标签）**指定这些位置，它是一串与输入位置对应的目标编号。采用本例的 `train_on_prompt=False` 设置时，Prompt 对应的标签通常记为 `-100`，表示计算损失时忽略这里；助手答案位置则保留要预测的目标编号。

把“能看到的上下文”和“用来检查预测的目标”分开看，就能理解为什么文章需要保留，却不一定直接计入损失：

![预测分号时可使用的上下文、不能提前看到的目标与后文，以及 Prompt 和答案的损失计算位置](images/30/30-1-1-1.svg)

图中按目标 token 的位置标记标签；训练内部会将前一位置的预测与下一个目标对齐。分词、角色边界和结束标记均为教学示意，实际仍需核对预处理结果。

**`-100` 只标记忽略损失的位置，用户文章仍完整保留在 `input_ids` 中。** 文章依然参与计算、占用序列长度，帮助模型理解任务。

第 29 章打印的模板文本只展示输入形式，没有展示训练标签。要确认实际哪些位置参与损失计算，还需要检查 LLaMA-Factory 预处理得到的 `input_ids` 和 `labels`，不能仅凭模板名称判断。第 4.2 节会结合长度和截断继续检查这一点。

</details>

**参考答案的质量也会直接影响训练。** 如果答案里多写了文章没有提到的关键词，训练程序仍会尝试提高这些词的概率。这就是为什么上一章既要检查分号，也要核对关键词含义，不能只确认 JSON 格式正确。

### 1.2 梯度与参数更新

现在，模型给正确分号的概率偏低，损失反映出了这个问题。但只拿到一个 Loss，程序还不知道内部那么多参数应该怎样改。

程序会根据损失算出各个可训练参数的调整线索，这些线索叫作**梯度**。再由**优化器**按规则执行更新，**学习率**控制步长。课程已经选好 AdamW 优化器，我们填写训练设置，计算和更新由程序完成。

Loss 描述当前预测的情况，梯度提供调整线索，优化器执行调整。

模型先从输入算出预测，这叫**前向计算**；得到损失后，沿着计算过程往回求梯度，这叫**反向传播**。求出梯度还不等于改好了权重，真正的更新由优化器执行。

**把图书馆样本放回训练流程：**

![图书馆教学样本经过 token 预测、交叉熵损失、反向传播和优化器更新的过程](images/30/30-1-2-1.svg)

读图时，沿着“预测 → 损失 → 梯度 → 更新参数”看一遍，再看回到模型的虚线：下一批数据会使用更新后的参数重新计算预测。改变的是计算结果的产生方式，训练文件里的文章和答案仍保持原样。

<details>
<summary>进阶选读：梯度怎样提供调整线索，并手算一次更新</summary>

先只看其中一个参数：在其他参数暂时不变时，它稍微增大一点，损失倾向于增大还是减小，变化有多明显？损失对这个参数的变化率，给出了当前位置的调整线索。把各个可训练参数的这些变化率放在一起，就是 **Gradient（梯度）**。

梯度由训练框架通过反向传播计算，不需要我们逐个修改参数去试，也不是在训练页面手工填写的数。

有了梯度，接下来由 **Optimizer（优化器）**按更新规则真正改变参数，**学习率（learning rate）**控制更新的步长。先假设某个参数和它的梯度如下，用最简单的规则算一次：

```text
某个可训练参数：0.30
当前梯度：0.20
学习率：0.10

按最简单的梯度下降规则：
新参数 = 原参数 - 学习率 × 梯度
       = 0.30 - 0.10 × 0.20
       = 0.28
```

这个参数的梯度是正数 `0.20`，表示在当前数值附近，增大它会使损失倾向于增大。因此，简单梯度下降往反方向调整，把参数从 `0.30` 减到 `0.28`；梯度为负时则相反。梯度描述的是当前位置附近的情况，步子迈得太大，仍可能越过合适位置，并不保证每步 Loss 都下降。

上面是**简单梯度下降算例**。课程训练使用 **AdamW 优化器**，还会参考之前梯度的统计信息，为各参数调整更新幅度。两种规则都需要先算出梯度，再执行参数更新。

</details>

回到图书馆任务，看到 Loss 偏高，下一步是检查数据与训练过程；训练结束后，再看新文章的关键词是否提取完整、是否按分号分隔。

实际训练通常把多条样本一起处理，还可能先累积几批梯度，再执行参数更新。下一节就来认识控制这个过程的设置。

---

## 2、训练轮次与批次

**本节先掌握：** 分清“一次处理几条”和“处理几批才更新一次”，算出课程为什么是每轮 50 步、3 轮 150 步。

上一节介绍了一次参数更新。现在有 1,600 条训练样本，我们需要决定：每次处理多少条、处理几批以后更新参数，以及整份数据训练几轮。

先看 LLaMA-Factory 的 **Train（训练）页面**。选好模型和数据后，下方就是训练参数。下面从真实界面中分别截出本节要认识的三项：批处理大小、梯度累积和训练轮数。截图只用于认识字段，完整配置在第 8.1 节汇总，第 31 章再实际填写。

<div role="group" aria-label="训练参数的三个真实界面局部" style="display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; margin: 1em 0;">
<div style="position: relative; flex: 0 1 290px; width: 100%; max-width: 100%; aspect-ratio: 290 / 108; overflow: clip; border: 1px solid #e5e7eb; border-radius: 6px;">
  <img src="images/30/30-2-0-1.png" alt="真实界面局部：批处理大小为4" data-no-zoom style="position: absolute; left: -107.5862%; top: -738.8889%; width: 517.9310%; max-width: none; height: auto; max-height: none; margin: 0; pointer-events: none;">
</div>
<div style="position: relative; flex: 0 1 290px; width: 100%; max-width: 100%; aspect-ratio: 290 / 108; overflow: clip; border: 1px solid #e5e7eb; border-radius: 6px;">
  <img src="images/30/30-2-0-1.png" alt="真实界面局部：梯度累积为8" data-no-zoom style="position: absolute; left: -208.9655%; top: -738.8889%; width: 517.9310%; max-width: none; height: auto; max-height: none; margin: 0; pointer-events: none;">
</div>
<div style="flex: 0 1 290px; width: 100%; max-width: 100%; min-height: 108px; border: 1px solid #e5e7eb; border-radius: 6px;">
<div style="position: relative; width: 238px; max-width: 100%; aspect-ratio: 238 / 101; overflow: clip;">
  <img src="images/30/30-2-0-1.png" alt="真实界面局部：训练轮数为3" data-no-zoom style="position: absolute; left: -110.0840%; top: -671.2871%; width: 631.0924%; max-width: none; height: auto; max-height: none; margin: 0; pointer-events: none;">
</div>
</div>
</div>

这里只查看批次和轮数字段。训练时使用 `keywords_train` 和独立的 `keywords_validation`，验证集比例填 `0`；截图大图中的 `keywords_small` 和比例 `0.01` 不用于这套练习，完整填写方法见第 31 章。

本章后面的计算统一采用 `Qwen/Qwen3-0.6B`、单卡、1,600 条训练样本、每批 4 条、梯度累积 8、训练 3 轮。先认识下面三项，其余设置随用随讲：

| 界面名称   | 配置文件中的字段                                                                               | 本章取值与作用                   |
| ---------- | ---------------------------------------------------------------------------------------------- | -------------------------------- |
| 批处理大小 | <code style="white-space: normal; overflow-wrap: anywhere;">per_device_train_batch_size</code> | `4`：每张卡一次处理 4 条样本     |
| 梯度累积   | <code style="white-space: normal; overflow-wrap: anywhere;">gradient_accumulation_steps</code> | `8`：累计处理 8 批后更新一次参数 |
| 训练轮数   | <code style="white-space: normal; overflow-wrap: anywhere;">num_train_epochs</code>            | `3`：整份训练数据训练 3 轮       |

这些设置也保存在 `案例与源码-4-微调/configs/keywords_clean_train.yaml` 中。YAML 是一种配置文件格式，每行用“参数名: 值”记录设置；例如 `num_train_epochs: 3` 就对应界面中的“训练轮数 3”。它保存训练安排，不保存训练样本。

### 2.1 批处理大小、训练轮数与更新步

先把界面设置与日志中的英文名称对应起来：

- **Batch（批次）**：一次放进模型一起处理的样本组。这里一个 batch 是 4 条。
- **Epoch（训练轮）**：训练集中的每条样本大致都被看过一遍，记为一轮。
- **Step（更新步）**：训练程序完成一次参数更新，记为一步。没有使用梯度累积时，处理一个 batch 通常会完成一步更新。

**在本课程按轮数训练的配置中，step 是程序算出的更新进度，无须再另填一个总步数。**

1,600 恰好能被 4 整除：

```text
1 个 epoch：1,600 ÷ 4 = 400 个小 batch
```

这 400 是**处理的小批次数**。课程还设置了梯度累积 8，因此不代表更新了 400 次。接下来算清楚：每处理 8 批才更新一次，会得到多少个更新步？

### 2.2 梯度累积与有效批次

**梯度累积（gradient accumulation）** 的意思是：处理完一个小 batch 后，先不更新参数，而是把本次得到的梯度暂存下来；连续处理若干个小 batch 后，再统一更新一次参数。

先按课程配置走一遍：用一张 GPU（通常简称“单卡”），第 1 批处理 4 条，暂不更新；第 2 批再处理 4 条，继续累积梯度……处理完第 8 批，才执行一次更新。然后开始下一组：

```text
单卡 batch size = 4
梯度累积次数 = 8

4 条 × 8 次 = 32 条样本后更新一次参数
```

这 32 条叫作**有效批次大小**。在单卡场景下，可以先这样理解：

```text
有效批次大小 = 单卡 batch size × 梯度累积次数
```

**假设换了一批更长的文章，模型已经加载成功，但每次处理 4 条时显存不够了。** 我们想改为每次处理 2 条，同时仍然每 32 条更新一次，应该一起改哪项设置？

先只改 batch：每批 2 条、仍然累积 8 批，就变成每 16 条更新一次。每次同时处理的样本少了，但更新也变得更频繁。

如果希望仍然每 32 条更新一次，就要把累积次数同步改为 16。**batch 管一次处理多少条，梯度累积管处理几批才更新。** 数据没有减半，变化的是分组方式：

| 单卡设置                   | 一次处理 | 更新前处理几批 | 有效批次 |
| -------------------------- | -------- | -------------- | -------- |
| 原配置：batch 4、累积 8    | 4 条     | 8 批           | 32 条    |
| 只改 batch：2、累积 8      | 2 条     | 8 批           | 16 条    |
| 配套修改：batch 2、累积 16 | 2 条     | 16 批          | 32 条    |

![两种单卡安排均累计32条才更新一次：每批4条累计8批，或每批2条累计16批；同组参数不更新，下一组使用新参数](images/30/30-2-2-1.svg)

图中对照的是表中的第一种和第三种安排。每个小框是一批，框内的数字是样本条数；沿箭头依次处理，并没有把 32 条一起放进 GPU。累计满 32 条以后才更新，下一组再使用新参数。

在样本长度等条件相同时，每次处理 2 条通常比处理 4 条少保存一些计算中间结果。**降低这部分显存占用靠的是减小单卡 batch；增加累积次数，是为了维持每次更新所用的样本数。** 如果 batch 仍为 4，只增加累积次数，当前这批 4 条所需的空间不会因此缩小。

<details>
<summary>调整时再看：有效批次相同，运行就一定相同吗</summary>

反过来，显存充足时，batch 8、累积 4 也能得到有效批次 32，但一次要同时处理更多样本。有效批次相同，不代表速度和训练结果完全相同，也不保证调整以后一定不会报错；第 5 节会解释其他显存占用。

</details>

再把一整轮算完：

```text
训练样本：1,600 条
小批次数：1,600 ÷ 4 = 400 批
每轮更新：400 ÷ 8 = 50 步
训练 3 轮：50 × 3 = 150 步
```

这个计算以单卡、关闭序列打包、全部 1,600 条训练样本被保留为前提；“打包”是把多条短样本拼在一起处理，课程配置不启用。第 31 章的示例启动日志也对应 50 步／轮、共 150 步。样本数或打包方式改变后，应重新核对日志；验证集不参与这些参数更新。

<details>
<summary>进一步对照：更新频率、验证时机和真实资源变化</summary>

把三种安排都算一遍，就能看到修改的连带影响：

| 单卡设置         | 每轮更新步数      | 3 轮总步数 | 每 50 步验证一次，相当于 |
| ---------------- | ----------------- | ---------- | ------------------------ |
| batch 4、累积 8  | `1600 ÷ 32 = 50`  | 150        | 每轮检查一次             |
| batch 2、累积 8  | `1600 ÷ 16 = 100` | 300        | 每半轮检查一次           |
| batch 2、累积 16 | `1600 ÷ 32 = 50`  | 150        | 每轮检查一次             |

三种配置都把训练集练了 3 遍，但第二种更新得更频繁，按步数设置的验证间隔也落在了不同进度上。**调整 batch 后，要一起核对有效批次、总步数和验证时机。** 第 9 节会解释验证与保存的具体设置。

这样修改会有多大资源收益？第 33 章已有一组真实对照：同一张 V100、同一个模型与课程数据，两组各完成 50 步。batch 4／累积 8 的最高采样显存约为 **27.92 GiB**，整次命令耗时约 **301 秒**；batch 2／累积 16 则约为 **21.71 GiB、560 秒**。本次每批处理更少，留出了更多显存余量，也花了更多时间。

这组 50 步实验统计了从加载到保存的最高采样显存与整次耗时，用来比较资源开销；任务效果还需另行评估。完整记录与复现方法见[第 33 章的 batch 对照](33-微调显存优化与多卡训练.md?id=_26-怎样判断调整是否有效)。

</details>

**读到这里可以自查：** 每次处理 2 条，能不能每 32 条才更新一次？能，需要累积 16 批；“一批”与“一次更新”不必是同一件事。

### 2.3 epoch 不是越多越好

**如果练习题越答越熟，新文章反而答得更差，还要继续增加轮数吗？** epoch 增加，会让模型反复看到同一批训练样本。适当重复有助于学习任务规律，但是否继续，要看未参与训练的数据。

第 29 章解释了[泛化与过拟合](29-微调数据准备与对话模板.md?id=_521-泛化与过拟合)。用到 epoch 上，就是每多练一轮，还要检查没有参与训练的同类文章是否也有改善。

例如，其他批次设置保持课程取值，训练 1 轮对应 50 次更新，3 轮对应 150 次。多出的两轮提供了更多学习机会，也增加了计算；是否值得，要看新文章上的回答和验证损失。

本章第 9 节已有同一次训练在第 50、100、150 步保存的结果，可以先比较这些阶段，再决定是否需要新实验。若验证表现持续变差，应按第 9.3.1 节回查原因，不能只因训练 Loss 还在下降就继续加轮数。

---

## 3、学习率与调度

**本节先掌握：** 学习率控制更新步长，调大不等于学得更好。预热和衰减先看懂变化方向，首次训练沿用课程设置。

批次和轮次决定了“多久更新一次、总共更新多少次”。学习率则影响“每次更新走多大一步”。

### 3.1 学习率设置

**把学习率调大，是否就能更快学好关键词抽取？** 学习率控制每次更新的步长。步子太小，有限轮数内变化可能不足；步子太大，也可能使训练不稳定。它不是“学习速度”或“掌握知识量”的直接刻度。

训练页中的“学习率”对应配置字段 `learning_rate`。课程取值是 `5e-5`，在 YAML 中写作 `learning_rate: 5.0e-5`，两种写法表示同一个数：

```text
5e-5 = 5 × 10⁻⁵ = 0.00005
```

这里的 `e-5` 是科学计数法的写法，用来简短地表示这样的小数。数值虽小，是否适合当前训练，仍要结合模型、数据和优化器判断。

| 学习率情况 | 可能发生什么                                     |
| ---------- | ------------------------------------------------ |
| 过大       | 参数更新过猛，Loss 可能剧烈波动，甚至越训越差    |
| 过小       | 参数变化缓慢，在有限轮数内可能还没有学到任务规律 |
| 合适       | 在当前设置下，训练较稳定，验证表现也有改善       |

把这个含义用到课程设置上：若从原模型重新开始训练，保持其他设置一致，将 `5e-5` 改为 `1e-4`，设定的学习率就从 `0.00005` 提高到 `0.0001`，变成原来的 2 倍。它会让优化器按更大的学习率更新；经过多步后，梯度和参数也会不同，不能推断损失下降或学习速度恰好翻倍。

<details>
<summary>调整时再看：用什么证据比较学习率</summary>

**怎样判断是否值得改？** 先确认样本、答案标签和 Adapter 加载都正确。如果在多个记录区间内，训练与验证损失改善都很慢，可以把较大学习率作为待比较的假设；如果训练反复出现大幅波动，可以比较较小学习率。某一条 Loss 上升只值得继续观察，还不足以决定改值。

比较时，先从日志确认实际学习率按新设置变化，再看相同训练进度下的损失趋势和同一批验证输入的回答。若数字降得更快，漏词或格式错误却更多，就不能只凭下降速度保留新配置。

</details>

<details>
<summary>进阶选读：只改学习率，手算一次更新幅度</summary>

回到第 1.2 节那个参数：原值为 `0.30`，梯度为 `0.20`。暂时保持二者不变，只换学习率，按同一条简单梯度下降规则计算：

| 学习率 | 本次减去的数值        | 更新后的参数           |
| ------ | --------------------- | ---------------------- |
| `0.10` | `0.10 × 0.20 = 0.02`  | `0.30 - 0.02 = 0.28`   |
| `0.01` | `0.01 × 0.20 = 0.002` | `0.30 - 0.002 = 0.298` |

学习率缩小到十分之一，这个算例中的更新幅度也缩小到十分之一。它控制的是“朝这个方向走多大一步”，不是“这次学会了多少知识”。上表继续使用教学数字和简单规则；实际 AdamW 的更新还涉及梯度历史，不能直接套用表中结果。

</details>

### 3.2 学习率预热与衰减

如果学习率填的是一个固定数字，为什么日志中的数字还会变化？因为还配置了**学习率调度器（scheduler）**，让学习率随训练进度变化。

界面中的“学习率调节器”就是选择调度规则的地方；展开“其它参数设置”还能找到预热步数。这三项配合使用：

| 界面名称     | 配置写法                    | 作用                            |
| ------------ | --------------------------- | ------------------------------- |
| 学习率       | `learning_rate: 5.0e-5`     | 本例预热结束时达到的学习率      |
| 学习率调节器 | `lr_scheduler_type: cosine` | 预热后按余弦曲线逐渐降低        |
| 预热步数     | `warmup_steps: 5`           | 开始的 5 个更新步逐步提高学习率 |

**预热（warmup）**是训练开始时，先使用较小学习率，再逐步升到设定值，用来缓和刚开始的参数更新。

这里用最初 5 个更新步做预热，包含在总共 150 步之内。预热阶段线性升高；预热结束后，学习率再按余弦曲线逐渐降低，这个后半程叫作**衰减**。

![根据课程配置绘制的学习率调度示意：前5步线性预热，之后余弦衰减；不是日志截图](images/30/30-3-2-1.svg)

主图横轴按实际步数比例绘制，前 5 步因此很窄；下方单独放大预热区，方便看清起步过程。这是配置示意，实际日志中的数值会在第 9.1 节一起看。

为什么后面逐渐降低？参数已经经过许多次调整，继续用较大的步长可能在较好的位置附近来回波动。衰减让后期更新逐渐减小，但它按预先设置的进度执行，并不会自动判断模型已经掌握了哪些知识。

<details>
<summary>调整时再看：预热步数与总步数变化会影响什么</summary>

假设总步数仍为 150，只把预热从 5 步改成 15 步，模型会用更多更新步慢慢升到 `5e-5`，后面的衰减阶段则由 145 步缩为 135 步。这个变化可以直接算出；训练是否更稳定，要结合日志检查。预热拖得过长，也会让有限训练中的更多步骤处于较小学习率。

总步数本身也参与调度。第 2 节若只减小 batch，使总步数从 150 变成 300，即使仍填 `cosine` 和预热 5 步，衰减也会按新的总进度展开。因此，比较同一次训练的第 50 步和第 150 步，与分别新跑“总共 50 步”和“总共 150 步”，不是同一个实验。具体规则见 [Transformers 学习率调度说明](https://huggingface.co/docs/transformers/en/main_classes/optimizer_schedules#transformers.get_cosine_schedule_with_warmup)。

</details>

---

## 4、序列长度与截断

**本节先掌握：** 长度上限包含输入和参考答案。缩短之前检查会丢掉什么，提高上限也不一定增加实际训练内容。

训练页面中的“截断长度”对应 `cutoff_len`，用来限制一条训练序列最多有多少 token。第 29 章已经看过对话模板和分词结果，这个长度需要把输入和参考答案一起算进去：

```text
用户文本 + 任务指令 + 模板标记 + 助手参考答案
                    ↓
             完整训练序列的 token
```

沿用图书馆样本，实际计入长度的内容包括：

```text
[用户角色标记] 市图书馆周末开设儿童阅读课……请提取关键词……
[助手角色标记] 市图书馆;儿童阅读课;公众号预约 [结束标记]
└──────────────── 整段分词后的 token 总数 ────────────────┘
```

这是内容组成示意，角色标记的实际写法由模板决定。`cutoff_len: 2048` 表示这段完整序列的上限为 2,048 token，不是文章篇数、汉字数，也不是 Chat 页单独设置的“最大生成长度”。

### 4.1 长度上限的影响

长度首先影响模型能看到什么，其次才是显存和速度。假设图书馆原本提供了一篇很长的活动介绍，“读者可通过公众号预约”放在文章后半段。如果截断后这句话没了，参考答案里的“公众号预约”却仍然保留，会发生什么？

模型看到的文章不再有预约方式，训练程序却仍要求它预测出这个关键词。这样一来，一条本来有依据的答案，在截断后就可能失去依据。**第 29 章检查的是原始样本是否合理，这里还要检查模型实际收到的样本是否完整。**

用长度表示，假设一条训练样本处理前共有 2,300 token，而上限只有 2,048，就不能完整保留它。不同部分被截去，会产生不同影响：

| 被截去的内容           | 对关键词任务的影响                     |
| ---------------------- | -------------------------------------- |
| 用户文章的一部分       | 答案可能提到了模型已经看不到的内容     |
| 参考答案的一部分       | 模型无法学习完整的关键词和结束方式     |
| 必要的任务说明或上下文 | 输入与答案可能不再构成清楚、完整的任务 |

工具会按自身预处理规则分配输入与答案的长度，不能一概认为只删除文章末尾。

<details>
<summary>进阶选读：填充与截断有什么区别，短样本也会补到上限吗</summary>

长度还涉及**填充（padding）**：两条长短不同的样本放在同一批处理时，工具通常需要把它们整理成一样长。例如，一条有 120 个 token，另一条有 200 个 token；如果按本批最长样本补齐，短的那条就要增加 80 个填充位置。

工具会标记这些用于对齐的填充位置，让它们不作为预测目标计入损失；这里也会用到第 1.1.1 节的标签忽略规则。

![填充把同批短样本补齐而不增加参考答案；截断表示完整序列超过上限，需要检查实际保留内容](images/30/30-4-1-1.svg)

读图时可以用两个问题区分：上半部分是“这一批长短不一，怎样排齐”，下半部分是“这一条太长，哪些内容还能保留”。图中没有规定工具具体截掉哪一段，实际结果需要按下一节的方法检查。

因此，`cutoff_len: 2048` 是长度上限，不等于每条短样本都补到 2,048。填充到哪里还取决于批次和工具设置。把上限提高到 8,192，也不能直接认定所有样本都会占用 8,192 个位置。

</details>

还要记住：**2048 是上限，不表示每条短样本都会占满 2048 个位置。** 实际占用还与批次及填充设置有关。

### 4.2 检查实际训练长度

先检查课程数据经过 LLaMA-Factory 处理后的长度。下面的结果对应 `Qwen/Qwen3-0.6B`、`qwen3_nothink` 模板、`cutoff_len: 2048` 和 `train_on_prompt: false`。这项检查只运行数据预处理，不需要加载模型权重或训练。

**先看样本有没有被丢弃或截断：**

| 数据文件                    | 原始条数 → 处理后条数 | 最长完整序列 | 被截断的条数 |
| --------------------------- | --------------------- | ------------ | ------------ |
| `keywords_train.jsonl`      | 1,600 → 1,600         | 957 token    | 0            |
| `keywords_validation.jsonl` | 200 → 200             | 604 token    | 0            |

这里的长度包含角色标记、用户输入、助手答案和结束标记，不是文章字数。两份数据都低于 2,048 的上限，预处理前后条数一致，也没有因截断改变答案目标的样本。

现在可以根据实际长度判断怎样改上限。保持当前数据和模板不变，先作下面的配置推演：

| 截断长度设置 | 对当前数据能作出的判断                   | 要检查什么                           |
| ------------ | ---------------------------------------- | ------------------------------------ |
| 保持 2048    | 已有预处理证据表明全部样本完整保留       | 对照上述条数与长度                   |
| 提高到 4096  | 当前最长只有 957，不会因此多读到文章内容 | 实际序列和填充是否变化，再观察资源   |
| 降到 512     | 至少已有最长样本无法完整保留             | 重新检查被截去的输入、答案及样本条数 |

**长度设置首先要保证输入和答案完整。** 上面的 4096 与 512 是按已有样本长度作出的推演；实际显存还受批次长度和填充方式影响。排查自己的数据时，可以展开下面的标签核验。

<details>
<summary>选读：实际样本的输入、答案标签与长度核验</summary>

**再看 `input_ids` 和 `labels` 是否对应。** 从这 1,800 条数据中，按完整序列长度选出最短、中间和最长的三条：

| 样本位置（文件行号从 1 开始） | 完整长度 | `labels = -100` 的位置数 | 参与损失计算的位置数 |
| ----------------------------- | -------- | ------------------------ | -------------------- |
| 短：验证集第 133 行           | 39       | 28                       | 11                   |
| 中：训练集第 636 行           | 158      | 136                      | 22                   |
| 长：训练集第 631 行           | 957      | 938                      | 19                   |

![LLaMA-Factory 实际预处理结果：训练集与验证集均无截断，三条样本的长度和标签统计，以及短样本的标签边界](images/30/30-4-2-1.jpg)

图中短样本的答案是 `高校学报;分编;期刊管理`。它的前 28 个位置是用户输入及角色标记，`labels` 为 `-100`；从位置 28 开始（索引从 0 计数），标签保留答案的 token ID。末尾的 `<|im_end|>` 和换行也计入目标，所以参与损失计算的位置数不等于关键词字数。`-100` 只表示该位置不作为预测目标，**不表示模型看不到这段输入**。

核对标签时，既要检查解码后的输入、答案和角色边界，也要确认每条样本都有参与损失计算的答案位置。表中三条样本与源记录对应，全部 1,800 条数据也没有答案标签全为 `-100` 的情况。可在配套 `examples/preprocessing/report.json` 中查找 `decoded_input`、`decoded_target` 和标签数组；下面给出检查脚本的运行方法。

</details>

<details>
<summary>代码复现：上传并运行预处理检查脚本</summary>

先完成第 31 章的环境安装、[模型下载](31-LLaMA-Factory环境搭建与微调实战.md?id=_51-模型与微调方式)和[数据上传](31-LLaMA-Factory环境搭建与微调实战.md?id=_61-数据上传与完整性检查)，不需要先启动训练。脚本只用 CPU 检查数据，不会替你下载模型。

在 **AutoDL 的 JupyterLab 终端**准备目录：

```bash
cd /root/autodl-tmp/LLaMA-Factory
source .venv/bin/activate
mkdir -p configs
```

在 JupyterLab 文件面板上传两份本机文件：

| 本机 `案例与源码-4-微调/` 中的文件  | 上传到 AutoDL 的位置     |
| ----------------------------------- | ------------------------ |
| `audit_keywords_preprocessing.py`   | `LLaMA-Factory/` 根目录  |
| `configs/keywords_clean_train.yaml` | `LLaMA-Factory/configs/` |

这里使用课程附带的 CLI 训练 YAML，不是 WebUI 的“保存训练参数”文件。若已上传同名文件，先核对内容；本脚本要求其中的 `model_name_or_path` 仍为 `Qwen/Qwen3-0.6B`，实际读取的本地模型位置由下面的 `--model-dir` 单独指定。

回到刚才的 AutoDL 终端，先检查文件，再运行脚本：

```bash
ls audit_keywords_preprocessing.py configs/keywords_clean_train.yaml
ls data/keywords-clean/keywords_train.jsonl data/keywords-clean/keywords_validation.jsonl

python audit_keywords_preprocessing.py \
  --data-dir data/keywords-clean \
  --config configs/keywords_clean_train.yaml \
  --model-dir /root/.cache/modelscope/models/Qwen--Qwen3-0.6B/snapshots/master \
  --output-dir /root/autodl-tmp/keywords-preprocessing-check
```

将 `--model-dir` 换成模型下载完成时返回的实际目录；`--output-dir` 必须是尚不存在的新目录，不要指向已有训练结果。`--data-dir` 是待检查的数据位置，`--config` 提供模板、长度等检查条件，并不会因为其中写了 `do_train: true` 就启动训练。

完成后，在文件面板打开输出目录中的 `report.json`，对照上面的条数、截断和标签统计。脚本报配置条件不符时，先核对所上传的 YAML；不要删掉检查条件强行运行。

</details>

换成自己的数据或调整模板后，需要重新运行上面的预处理检查，对照原文查看模型实际收到的文章与答案，确认两者仍然完整、对应。具体的解码、标签核验和脚本操作保留在展开框中。**当前数据适合 2,048 的上限，不代表换一批文档后仍然合适。**

---

## 5、训练显存

**本节先掌握：** 模型加载成功，训练仍可能放不下。分清权重与计算中间结果，知道减小 batch 主要减少哪一部分；精度按硬件支持选择。

**显存（VRAM）** 可以先理解为 GPU（训练时主要承担计算的显卡）用来摆放模型和计算中间结果的工作台。工作台放不下，训练就会报内存不足；工作台越大，通常能放下更大的模型、更长的样本或更大的 batch。

这也把前面的界面设置串了起来：“截断长度”影响一条样本有多长，“批处理大小”决定同时处理多少条，“计算类型”影响数值的表示方式。它们都会影响显存，所以不能只按模型文件大小选择显卡。

### 5.1 显存的主要占用

沿着第 1 节的顺序，看看训练用的这张“工作台”上要放什么：

1. **加载模型时，先放入权重。** 模型要使用这些数值计算，权重本身就占一部分空间。此时还没有处理训练样本。
2. **处理一批样本时，产生计算草稿。** 模型各层算出的中间结果叫作**激活值**。后面求梯度还会用到其中一些结果，所以算出预测后不能立刻把草稿全部丢掉。
3. **根据损失往回求梯度。** 程序沿着刚才的计算关系往回求梯度，这个过程叫作**反向传播**。梯度也需要存放；启用梯度累积时，处理下一小批之前还要保留已经累积的梯度。
4. **更新参数时，还要保留更新所需的信息。** AdamW 会参考历史梯度统计，这些信息叫作**优化器状态**，供后续更新继续使用。

因此，“模型加载成功”和“一批训练能够跑完”是两件事。后面几步还需要空间；训练框架、临时工作空间等也有额外的**运行开销**。

这也解释了第 2 节的配置调整：同一个模型，batch 从 4 改为 2，并不是把模型权重缩小一半，而是同时处理的样本少了，需要保存的中间结果通常也更少。文章变长，则会增加这部分负担。

所以，**权重大小主要看模型和数值表示；激活值还要看实际序列长度和单卡 batch。** 第 5.3 节的梯度检查点，就是在“保存草稿”和“需要时重算”之间作取舍。

![显存工作台教学示意：加载模型后，训练还需容纳激活值、梯度、优化器状态和运行开销；减小batch、LoRA和量化分别减少不同部分](images/30/30-5-1-1.png)

先比较两张工作台：**右边的模型没有变大，增加的是训练需要的其他内容。** 再看下方三种办法，分别减少哪一部分。图中的物体大小不表示实测占用比例。

遇到“显存不足”，先找到发生的阶段，再对照每种办法减少什么：

| 准备调整什么     | 主要影响工作台上的哪部分         | 如何判断方向是否合适             |
| ---------------- | -------------------------------- | -------------------------------- |
| 减小单卡 batch   | 同时处理样本产生的中间结果       | 模型已加载，处理批次时空间不足   |
| 降低实际训练长度 | 每条样本产生的中间结果           | 先检查输入和答案是否仍然完整     |
| 使用 LoRA        | 大量原权重对应的梯度、优化器状态 | 接受冻结原权重，只训练新增分支   |
| 量化基础权重     | 原模型权重本体                   | 加载大模型时，权重已占去过多空间 |

例如，连模型都没加载完就报错，改 batch 无法缩小待加载的权重；已经完成加载，处理长文章时才报错，则应先检查实际长度和 batch。学习率改变参数更新的幅度，并不减少这些数值的数量，不能用它解决显存容量问题。

### 5.2 数值精度与权重大小

模型参数是数值，计算机保存一个数时，也需要选择表示方式。这里说的**数值精度**，不是模型回答的准确率，而是这些数能表示得多细。

可以借记录小数理解：把 `0.12345` 写成 `0.12` 更粗略，写成 `0.1235` 更细；另外，一个格式能否表示特别大或特别小的数，又涉及它的**数值范围**。这是帮助区分“精细程度”和“范围”的例子，FP16 并不是固定保留两位小数。

FP32、FP16、BF16 是几种保存和计算数值的格式。在训练页的“计算类型”中，课程选择 `fp16`，配置对应 `fp16: true`、`bf16: false`。先看它们占用的空间：

| 格式 | 一个数占多少位 | 折合多少字节 |
| ---- | -------------- | ------------ |
| FP32 | 32 bit         | 4 字节       |
| FP16 | 16 bit         | 2 字节       |
| BF16 | 16 bit         | 2 字节       |

8 bit 等于 1 字节。FP16 和 BF16 虽然占的字节相同，数值范围和精度分配却不同，不是只换一个名字；本次 AutoDL V100 使用 FP16，不使用原生 BF16。具体硬件与安装检查放在第 31 章。

<details>
<summary>进阶选读：按字节计算权重大小，为什么整场显存不会同比例缩小</summary>

把 0.6B 粗略按 6 亿个参数计算，若每个权重用 2 字节保存：

```text
600,000,000 × 2 = 1,200,000,000 字节 ≈ 1.2 GB
```

同样的 6 亿个数，若全部按 FP32 的 4 字节保存，权重本体就是约 2.4 GB。数值的数量没变，表示它们所用的空间变了。这里均按十进制 GB 粗算，**只算权重本体**，还没有加入中间结果、梯度和优化器状态等。

所以，从 FP32 改用 FP16 后，不能只据这道题断言整场训练显存减半。实际还有混合精度安排，部分状态仍会使用 FP32；是否稳定、硬件是否支持，也要通过第 31 章的实际运算检查确认。LoRA 的可训练参数比例同样不能直接用来换算整场训练显存。

</details>

“计算类型”也不同于模型区域的“量化等级”。本章使用 FP16 计算、不启用量化；第 7 节再解释量化会改变什么。

<details>
<summary>选读：选择 FP16 后，日志为什么还会出现 FP32</summary>

**混合精度**是让不同计算或状态使用不同精度，不是所有数值都统一变成 16 位。示例配置开启 FP16，但启动日志还记录了 `Upcasting trainable params to float32`，表示可训练参数被提升为 FP32。它们并不矛盾：计算设置、参数保存和优化器状态需要分开看。

</details>

### 5.3 梯度检查点与显存

如果第 5.1 节中用于反向传播的“草稿”占了太多显存，能不能少保存一些，需要时再算？

**梯度检查点（gradient checkpointing，也称激活检查点）**会少保存一部分中间结果，需要时再计算一次。可以把它理解为：不把所有草稿都留在桌面上，只保留必要位置，后面用到时重算。代价是增加计算，换取较低的显存占用。

不要与训练存档混淆：

| 名称                           | 在这里做什么                                           |
| ------------------------------ | ------------------------------------------------------ |
| 梯度检查点                     | 在一次训练计算中减少中间结果的保存，节省显存           |
| 训练检查点，如 `checkpoint-50` | 把某个更新步的权重、状态等保存到磁盘，供后续加载或恢复 |

在其他设置相同的条件下，开启梯度检查点，相当于“少存草稿、多做重算”；关闭后则省去这部分重算，但要保存更多中间结果。如果原来刚好放得下，关闭后可能在处理批次时显存不足；开启后能否以可接受的时间完成，则要同时看资源和耗时。

本次启动日志已有 `Gradient checkpointing enabled.`，说明它已经开启。跟做时先核对启用状态；若要比较开关的影响，再记录完整运行中的占用与时间，不能拿“每 50 步保存文件”替代这个开关。具体排查方法留在第 33 章。

显存中的另一项大开销，是大量可训练参数对应的梯度和优化器状态。接下来介绍的 LoRA，主要从这一部分减少训练负担。

---

## 6、LoRA 高效微调

**本节先掌握：** 原权重继续参与计算，训练主要改变新增分支；得到的 Adapter 仍需原模型配合。第 6.2—6.6 节解释设置的用途，首轮沿用 rank 8、alpha 16、dropout 0、target all，先验证训练和实际回答。需要比较某个设置时，再设计一次有明确目的的实验，不要求一开始调优所有选项。

### 6.1 全参数微调与 LoRA

第 28 章区分了[“用什么反馈教模型”与“更新哪些参数”](28-大模型微调概述与整体流程.md?id=_33-sft-与-lora-是什么关系)。本节保持 SFT 的文章与参考答案不变，重点看第二个问题。

关键词抽取需要模型理解文章，也需要它遵守统一的输出要求。如果直接更新原模型中几乎全部参数，就是**全参数微调**。相应的大量梯度和优化器状态也要占用显存。

**LoRA**换了一种做法：原权重继续负责原来的计算，在选定的层旁边加一条可训练的分支，让它学习需要补上的调整量。两条路径一起参与计算，训练时只改新增分支中的少量参数。

这属于**参数高效微调（PEFT）**：训练时只更新一部分参数，降低相关开销。本章只展开课程会使用的 LoRA 及其量化方式 QLoRA。

LoRA 新增的这一组权重叫作 **Adapter（适配器）**。它不是独立的完整模型，计算时仍需要原模型。这里的“基础模型”是指它依附的原模型 `Qwen/Qwen3-0.6B`，不等于必须选第 28 章介绍的 `-Base` 预训练版本。

| 训练方式   | 原模型权重   | 新增参数                 | 主要保存的任务权重   |
| ---------- | ------------ | ------------------------ | -------------------- |
| 全参数微调 | 参与更新     | 通常不需要 LoRA 分支     | 更新后的完整模型权重 |
| LoRA       | 冻结，不更新 | 在选定层增加并训练小分支 | Adapter 增量权重     |

![LoRA 教学示意：同一输入进入原权重和新增分支，两路计算结果相加；原权重不更新，分支接受训练，结果继续传给后面的层](images/30/30-6-1-1.png)

图中只放大模型中的一个计算位置。**两条路都参与计算，只有小分支接受训练。** 这里相加的是计算结果，还不是最终关键词；图中块的大小也不表示显存占比。

“冻结”不是锁住模型文件，也不是训练时不加载它。原权重仍参与计算，只是不为这些冻结参数计算、保存用于更新它们的梯度和优化器状态。反向传播仍需经过相关计算，才能求出可训练分支的梯度。

回到显存组成，少了大量参数的梯度和优化器状态，这部分开销就能减少；原权重和计算中间结果仍需保留。至于关键词提取是否改善，还要看训练后的验证结果。

**冻结原权重，也不保证加载 LoRA 后所有旧任务都保持原样。** 新分支会影响计算结果，模型的回答方式仍可能改变。除了检查关键词效果，还应检查自己需要保留的行为，做法见[第 32 章的回归检查](32-微调效果评估与模型部署.md?id=finetuning-regression)。

在训练页向下展开“LoRA 参数设置”，先找到上方的秩、缩放系数和随机丢弃三项：

![LoRA 字段位置示例：秩8、缩放系数16、随机丢弃0，下方作用模块尚未填写](images/30/30-6-1-2.png)

下方的“LoRA 作用模块”在图中留空；课程配置在这里填 `all`，右侧“LoRA 附加模块”留空。第 6.5 节解释作用位置，第 31 章第 7.4 节展示完整填写结果。

| 界面名称      | 配置写法           | 先理解它控制什么                           |
| ------------- | ------------------ | ------------------------------------------ |
| LoRA 秩       | `lora_rank: 8`     | 新增分支中间有多宽，影响可训练参数量       |
| LoRA 缩放系数 | `lora_alpha: 16`   | 配合 rank，控制新增分支的增量缩放          |
| LoRA 随机丢弃 | `lora_dropout: 0`  | 训练时是否随机遮住部分分支输入；本章不开启 |
| LoRA 作用模块 | `lora_target: all` | 在当前模型中哪些可用的线性层上添加分支     |

先认识这四项的位置和用途，下面各节再分别解释。其他 LoRA 变体保持未启用，矩阵图和完整手算过程可以按需展开。

### 6.2 A、B 矩阵与计算过程

原权重不更新，输出仍能变化，因为新分支贡献了一个可学习的调整量。LoRA 把这条分支写成两块小的参数矩阵 **A、B**；矩阵就是按行列排列的数值表。

同一份输入在原路径中照常计算，同时先经过 A、再经过 B 得到调整量。两路结果相加后，继续传给后面的层。

**刚加上分支，会不会立刻把原来的输出改乱？** 常见默认初始化让 A 从随机数开始，B 全为零。输入经过 A 后，无论得到什么数，再经过全零的 B，分支贡献都是零；训练随后让这个增量逐渐发生变化。因此，初始分支可以先不改变原路径的结果。

不要把它改成“A、B 都全为零”：在这个两矩阵相乘的结构中，两者都为零会使常规梯度更新无法把分支学起来。默认初始化及其他可选方式可查 [PEFT LoRA 文档](https://huggingface.co/docs/peft/package_reference/lora)。本课保持工具的默认初始化。

<details>
<summary>进阶选读：A、B 的矩阵图、向量与一次完整手算</summary>

原模型权重不更新，为什么它的输出还能改变？看清这个问题，就能理解 LoRA 为什么需要与原模型一起加载。

模型内部用一组组数值计算。一组按顺序排列的数叫作**向量**，按行列排列的数值表叫作**矩阵**。本节关注的**线性层**会用参数矩阵对输入向量做计算，得到另一组数值。LoRA 就是在选定的这些位置，增加用 A、B 表示的两块小矩阵。

下面只画其中一个线性层，不是整个模型。`x` 表示送进这一层的一组数，`h` 表示这一层算出的结果，还不是最终关键词。**第一遍先沿箭头看“两条路径 → 相加”，图中的行列数留到第 6.3 节计算参数量时再看。**

![输入同时经过冻结的基础权重与可训练的 LoRA A、B 矩阵，两个结果相加形成输出](images/30/30-6-2-1.svg)

同一份输入走两条路：原权重照常计算，新增的 A、B 分支计算一个调整量，两路输出相加后传给下一层。训练时只更新 A、B，原权重保持不变。这就是图中“冻结”与“可训练”的区别。

先不算矩阵乘法，只看一个两维教学例子的结果。假设同一输入经过原路径，输出为 `[1, 2]`；经过 A、B，得到的调整量为 `[0.1, 0.2]`。暂时不加缩放系数，对应位置相加，就得到 `[1.1, 2.2]`，再交给后面的层处理。

这就是“原权重不改，计算结果仍能改变”的原因。这里的数值是为了说明两路相加，不是实际模型权重；这些中间数也还不是关键词。想知道调整量怎样算出来，可以展开下面的手算。

**选读：文字怎样变成参与计算的向量**

第 29 章把文字变成了 token 编号。例如，“市”的编号是 `22697`，但这个编号只是用来找到对应的 token，不表示它的含义就是数值二万多。

模型会先按编号取出一组数值，作为这个 token 的初始表示，这一步叫作 **Embedding（嵌入）**。这组向量会继续经过模型各层计算，与上下文结合，而不是直接拿 token 编号当作词义大小做加减。编号与向量的对应关系可对照 [PyTorch Embedding 说明](https://docs.pytorch.org/docs/2.14/generated/torch.nn.Embedding.html)。

**选读：A、B 怎样相乘，并用两维数值手算一次输出**

沿用图中“先经过 A，再经过 B”的顺序，先暂时不考虑缩放：

```text
权重增量：ΔW = B × A
等效新权重：W = W₀ + ΔW

原路径的输出：W₀ × x
新增路径的输出：B × (A × x)
这个线性层的输出：h = W₀ × x + B × (A × x)
```

`W₀` 是原权重，`ΔW` 读作“权重的改变量”。**权重与权重相加，输出与输出相加**，不能把 `W₀ × x` 的计算结果直接与 `B × A` 这个矩阵相加。两条路径都要处理同一个 `x`。

训练期间保留 `W₀`，更新 A、B；多个样本共同影响这些小矩阵中的数值。这是 [LoRA 的基本结构](https://arxiv.org/abs/2106.09685)。

**用小数字算一遍。** 为了手算，把图中的 1000 维缩成 2 维，并假设下面是一组训练后的数值；暂时仍不加缩放系数：

```text
输入 x = [1, 2]ᵀ        （ᵀ 表示把这两个数竖着排列）

原权重 W₀ = [1  0]      A = [0.1  0.2]      B = [0.2]
            [0  1]                              [0.4]
```

原路径保持输入不变；新增路径先把两个数算成一个数，再变回两个数：

```text
原路径：W₀ × x = [1, 2]ᵀ

先经过 A：0.1 × 1 + 0.2 × 2 = 0.5
再经过 B：[0.2 × 0.5, 0.4 × 0.5]ᵀ = [0.1, 0.2]ᵀ

两路相加：h = [1, 2]ᵀ + [0.1, 0.2]ᵀ = [1.1, 2.2]ᵀ
```

这两个数还不是最终关键词，而是继续传给后面层的中间输出。此例只用来说明“两条路径处理同一个输入，再把结果相加”；尺寸太小，不用它计算节省比例。下一节再换回大矩阵，计算参数量。

</details>

### 6.3 rank 与可训练参数量

**rank（秩，简称 r）控制新增分支中间有多宽。** 课程设置 `lora_rank: 8`，表示输入经过 A 后，先形成 8 个中间数，再由 B 继续计算。

从 8 改成 16，分支会增加可调整的数值，表达调整量的空间更大，相关训练开销也会增加。**更多可训练参数不保证关键词提取更好。** 首轮先沿用 8，需要比较分支容量时，再看下面的数量计算，并同时核对下一节的 alpha。

<details>
<summary>进阶选读：rank 8 与 16 的参数量、实际日志和比较方法</summary>

把第 6.2 节的分支画宽一点，具体会多出多少参数？假设某个线性层的输入和输出都包含 1000 个数，A 先把 1000 个数变成 `r` 个，B 再变回 1000 个：

| rank | A 的参数量           | B 的参数量           | 新增可训练参数合计 |
| ---- | -------------------- | -------------------- | ------------------ |
| 8    | `8 × 1000 = 8,000`   | `1000 × 8 = 8,000`   | 16,000             |
| 16   | `16 × 1000 = 16,000` | `1000 × 16 = 16,000` | 32,000             |

**从 8 改成 16，这个分支有了更多可调整的数值，能表达的修正范围更大；新增权重及其梯度、优化器状态也更多。** 原路径的 100 万个权重仍在。上表是同一个教学层的尺寸对照，不能推导出整场训练显存翻倍。

什么时候值得比较 rank？如果输入完整、标注一致、Adapter 已正确加载，而现有训练在训练集和验证集上都持续表现不足，可以将分支容量作为一个待验证因素。如果训练题已经答得很好，验证题却变差，就应先检查第 9 节的过拟合问题。

比较时，从相同基础模型分别新建 Adapter，并在日志中核对可训练参数量，再比较同一份验证数据上的损失、关键词回答与资源开销。调整时还要考虑下一节的 alpha，二者会共同决定增量缩放。

**沿用表中的 rank 8，计算节省了多少可训练参数。** A 的尺寸为 `8 × 1000`，B 为 `1000 × 8`。矩阵相乘后，`B × A` 仍是 `1000 × 1000`，能作为与原权重形状相同的增量。

```text
全参数更新这一个矩阵：1,000,000 个可训练参数
LoRA 更新 A 和 B：8,000 + 8,000 = 16,000 个可训练参数

16,000 ÷ 1,000,000 = 1.6%
1,000,000 ÷ 16,000 = 62.5
```

减少的是**这个层的可训练参数**，不是把原来的 100 万个权重删除了。此时原权重与 A、B 一共仍有 `1,016,000` 个参数，只是其中 `16,000` 个需要更新。不能由此说“整场训练显存缩小 62.5 倍”或“速度提升 62.5 倍”。

**这里的“秩”与原矩阵有什么关系？**

`B × A` 的秩不超过 `r`，不保证恰好等于 `r`。这个小宽度限制了增量能表达的变化，并不要求原矩阵本身的秩就是 8。

**实际模型更新了多少参数？** 本次清洗版训练使用 `r=8`，启动日志记录：

```text
trainable params: 5,046,272
all params: 601,096,192
trainable%: 0.8395
```

也就是约 505 万个参数参与更新，占日志统计全部参数的约 0.8395%；分母包含基础模型与新增 Adapter。

教学层的 1.6% 与整模型的 0.8395% 不矛盾：真实模型有多层、不同矩阵尺寸和不同的作用位置。完整启动记录见第 31 章的训练日志 `案例与源码-4-微调/results/keywords-clean/training/train.log`。

</details>

### 6.4 alpha 与增量缩放

**alpha 控制新增分支的结果怎样缩放后相加。** 在本课程的标准 LoRA 中，缩放倍数是 `alpha/r`。课程 `rank=8、alpha=16`，所以分支结果先乘以 2，再与原路径结果相加。

先分清两个环节：**学习率控制训练时权重怎样更新，alpha/r 控制计算时分支结果怎样加入。** alpha 不直接改变矩阵尺寸，也不能当作学习率的替代项。

首次保持 `rank=8、alpha=16`。以后改 rank 时，要连同这个比值一起看：只把 rank 改为 16，alpha 仍为 16，比值就从 2 变成 1。需要做配套对照时，再展开下面的例子。

<details>
<summary>进阶选读：alpha 的数值例子、与 rank 的配套变化及公式</summary>

沿用第 6.2 节的教学数字，暂时固定输入与 A、B：原路径输出 `[1, 2]`，新增分支算出 `[0.1, 0.2]`。当 `alpha/r` 为 1，两路相加得到 `[1.1, 2.2]`；保持 rank 不变，将 alpha 加倍，使系数变为 2，分支先乘 2，再相加得到 `[1.2, 2.4]`。**矩阵尺寸没有变，改变的是同一份分支结果加入时的大小。**

这也解释了为什么修改 rank 时需要一起看 alpha：

| 设置                  | rank | alpha | alpha/r | 发生了什么                 |
| --------------------- | ---- | ----- | ------- | -------------------------- |
| 课程起点              | 8    | 16    | 2       | 当前分支宽度与缩放         |
| 只增加 rank           | 16   | 16    | 1       | 分支变宽，缩放系数同时减半 |
| 增加 rank，并维持系数 | 16   | 32    | 2       | 分支变宽，缩放系数仍为 2   |

如果实验要比较“分支宽度变化、相加前的缩放倍数保持一致”，第三行就是一种配套安排。它仍不保证两组梯度、训练过程或输出相同。第二行也可以作为实验，只是结论要承认它同时改变了容量和缩放，不能全部归因于 rank。

本节使用标准 LoRA 的 `alpha/r` 规则，其他变体可能不同。[本次 PEFT 0.18.1 的缩放实现](https://github.com/huggingface/peft/blob/v0.18.1/src/peft/tuners/lora/layer.py)

分支参与计算的方式变化，也会影响随后求出的梯度。比较 alpha 时，应先固定 rank 与作用位置，核对配置中的系数，再比较验证表现。

**选读：把 alpha/r 放回公式中计算**

```text
权重增量：ΔW = (alpha / r) × B × A
线性层输出：h = W₀ × x + (alpha / r) × B × (A × x)
```

暂时固定 A、B，假设 `B × A` 中某个位置是 `0.01`，原权重对应位置是 `0.30`：系数为 1 时，等效权重是 `0.31`；系数为 2 时，是 `0.32`。这是前向计算中如何组合权重的例子，不是说优化器每次更新必然翻倍。

</details>

### 6.5 target modules 与作用位置

界面中的“LoRA 作用模块”对应 **target modules（目标模块）**，指定把 LoRA 分支加到哪些层上。课程使用 `lora_target: all`，由工具为当前模型识别可应用 LoRA 的线性层。它与旁边的“附加模块”不是一回事；本课程“附加模块”留空。

模型中同一种层名可能重复出现，每个匹配的层各有自己的 A、B；原权重继续保持冻结。

<details>
<summary>调整时再看：缩小作用范围会怎样，实际添加到了哪些层</summary>

假设保持 rank 为 8，把作用位置从当前的 `all` 缩小到只选 `q_proj、v_proj`：模型中匹配这两个名称的层仍添加分支，原先其他位置的分支则不再添加。因此，可训练参数和相关状态会减少，但模型也少了一些可以学习调整的位置。**rank 决定每条分支有多宽，target 决定哪些地方有分支。**

这类比较适合回答“缩小作用范围，能否在减少开销时保留任务效果”。先从启动日志核对可训练参数量，并查看 `adapter_config.json` 的 `target_modules`，确认实际作用位置；再比较同一份验证数据上的回答。

**这次 Qwen3 的哪些层加入了 LoRA：**

清洗版 Adapter 配置记录了以下名称：

| 层名                             | 所属计算部分                         | 这里需要理解什么               |
| -------------------------------- | ------------------------------------ | ------------------------------ |
| `q_proj、k_proj、v_proj、o_proj` | 注意力部分的线性映射                 | 这些位置会添加并训练 LoRA 分支 |
| `gate_proj、up_proj、down_proj`  | 前馈网络，即每层中继续变换数值的部分 | 这些位置同样参与本次 LoRA 训练 |

这些名称在不同网络层中重复出现，各自可以有对应的 A、B。更换模型时，应重新检查它的层名与实际作用位置。

</details>

### 6.6 LoRA dropout

界面中的“LoRA 随机丢弃”对应 **LoRA dropout**，是在训练时对 LoRA 分支的部分输入值进行随机丢弃：本次暂时遮住部分输入，下次重新随机选择。

第 6.1 节截图中的辅助文字写成了“LoRA 权重随机丢弃的概率”，容易让人误以为会删除权重。这里按[本次 PEFT 的实际计算](https://github.com/huggingface/peft/blob/v0.18.1/src/peft/tuners/lora/layer.py)理解为对分支输入应用 dropout，A、B 权重仍然保留。

课程设置为 `lora_dropout: 0`，即不启用。假设改为 `0.1`，训练时分支输入中的每个数值会以 10% 的概率被暂时置零，A、B 的尺寸和参数量保持不变。

<details>
<summary>调整时再看：什么时候比较 dropout，怎样检查效果</summary>

这样做是希望分支少依赖某些输入值的固定组合，有时有助于缓解过拟合，也可能让有限训练中的学习更困难。如果核对数据和预处理后，仍出现训练表现持续改善、验证表现持续变差，可以把 dropout 作为候选调整之一。它不负责修正错误标注，也不是省显存开关。

若进行对照，两组从相同基础模型开始，保持其他训练条件一致；重点看验证损失和实际回答是否改善，即使训练损失稍高，也要结合验证结果判断。

</details>

正常推理时 dropout 会关闭。首次训练保持课程的 `0`，遇到过拟合问题再考虑是否需要比较其他值。

现在再读本节开头的四项配置：target 决定**分支加在哪里**，rank 决定**分支有多宽**，alpha/r 决定**结果怎样缩放后相加**，dropout 决定**训练时是否随机遮住部分分支输入**。

---

## 7、QLoRA 量化微调

**本节先认识用途：** QLoRA 进一步节省基础权重的存储，但仍有其他训练开销。本课程首轮不启用量化，编码细节属于进阶选读。

本课程先保持“微调方法”为 `lora`、“量化等级”为 `none`，使用 Qwen3-0.6B 练习普通 FP16 LoRA。更换较大的模型后，如果基础权重占用过多显存，再考虑下面的 QLoRA；不要仅因页面提供量化选项就全部开启。

**QLoRA** 可以先理解为“量化版的 LoRA”。它仍然训练 LoRA Adapter；不同之处在于，训练时加载的基础模型会以更低位数保存，从而减少基础模型权重的显存占用。

这里的**量化（quantization）**，是用更少的比特近似保存模型参数。例如原来一个权重用 16 bit 保存，量化后用 4 bit 编码表示；省下了空间，但数值可能出现近似误差，任务效果仍需验证。

<details>
<summary>选读图解：全参数微调、LoRA 与 QLoRA 的完整对照</summary>

![从基础模型状态、训练时更新部分与训练后产物三个角度，对比全参数微调、LoRA 与 QLoRA](images/30/30-7-0-1.svg)

</details>

### 7.1 量化与近似误差

量化可以理解为：准备一组有限的代表值，把每个原数替换为接近的代表值，并保存对应编号。4 bit 能表示 16 个编号，因此不能精确保留任意小数。计算时根据编号还原近似值，这一步叫作**反量化**。

先只跟踪一个数：在下面的等间距量化教学例子中，原来的 `0.27` 经过“选一个接近的代表值 → 保存编号和缩放因子 → 还原”，得到的是 `0.304`，相差 `0.034`。

为什么还原后不再是 `0.27`？因为保存的是有限选项中的编号，没有保留这个数的全部信息。**量化用近似换空间，反量化取回的是近似值。** 具体编号怎样算、QLoRA 使用的代表值有什么不同，见下方选读。

<details>
<summary>选读：一个权重怎样变成 4-bit 编号，又怎样还原</summary>

将上面的教学例子展开，假设一组参数是：

```text
0.27，0.58，-0.75，1.52
```

这一组绝对值最大的数是 `1.52`。我们把它保留下来作为缩放因子，再把各数除以它，将数值缩到 -1～1 的范围。这里只跟踪第一个数 `0.27`：

| 步骤           | 计算或保存的内容                    | 这一步做了什么                              |
| -------------- | ----------------------------------- | ------------------------------------------- |
| 缩放到统一范围 | `0.27 ÷ 1.52 ≈ 0.1776`              | 先变成便于查表的小数                        |
| 找最近的代表值 | `0.1776 → 0.2`                      | 在 -1～1 的 16 个等间距代表值中选最近的一个 |
| 保存编号       | `0.2` 对应编号 `9`，二进制为 `1001` | 编号只需 4 bit；同时保留这组的缩放因子      |
| 反量化         | `0.2 × 1.52 = 0.304`                | 用编号查回代表值，再恢复到原来的量级        |

16 个代表值按从小到大编号为 0～15，编号 9 对应 `-1 + 9 × 2/15 = 0.2`。编号保存的是“选了哪个代表值”，并没有另外记住原数 `0.1776`。

所以，还原后是 `0.304`，不是原来的 `0.27`，绝对误差为 `0.034`。**反量化是恢复近似值，不是无损解压。** 模型是否还能完成任务，要继续评估，不能仅凭文件变小就判断效果。

这里使用的是**等间距量化示例**。QLoRA 中常用的 **NF4** 不是这张等间距表，而是针对权重分布设计的一组非等间距代表值；这里只借简单数字理解“存编号、存缩放因子、存在近似误差”。[QLoRA 论文](https://arxiv.org/abs/2305.14314)

</details>

<details>
<summary>拓展计算：为什么 4-bit 的实际存储还包含额外开销</summary>

除了参数的编号，还要保存把这一组数恢复到原量级的缩放因子等辅助信息。假设四个数为一组，原来都用 FP16 保存，一共是 `4×16=64 bit`。现在四个 4-bit 编号占 `16 bit`；若这一组的缩放因子用 FP32 保存，还要加 `32 bit`，合计 `48 bit`。即使暂时不计其他开销，这个小例子的总量也不是原来的四分之一。

四个数分一组只是为了方便计算。若改成每 64 个数共用一个 FP32 缩放因子，编号与缩放因子共占 `64×4+32=288 bit`，相当于每个数平均 `4.5 bit`。这样就能看出：缩放因子也占空间，只是被一组数分摊了。这里仍未计入码表等其他开销，也没有加入下一节的双重量化。

</details>

### 7.2 QLoRA 的显存开销

回到第 5 节的显存组成：LoRA 已经减少了需要更新的参数及相关状态，QLoRA 进一步压缩的是仍需加载的**基础模型权重**。Adapter 的梯度、优化器状态和中间计算结果仍然需要空间。

训练时，量化基础权重保持冻结，需要计算时按实现恢复到相应计算精度参与运算；可训练的 LoRA 参数并不是简单地全变成 4-bit。因此不能只看“模型已经量化”就认定一定能训。序列长度、单卡 batch、训练工具的实现和实际 GPU，仍会决定能否稳定运行。

还要区分报错发生在哪一步：**基础权重尚未加载完就显存不足**，先检查模型大小、加载精度和 GPU 上其他进程；**权重已加载、开始处理批次后才不足**，再优先检查长度和单卡 batch。减小 batch 不会缩小基础权重本体，详细排查放在第 33 章。

<details>
<summary>拓展术语：双重量化与分页优化器</summary>

QLoRA 还采用了两项优化：**双重量化（double quantization）** 进一步量化缩放因子等辅助常数，减少这些额外信息的存储；**分页优化器（paged optimizer）** 通过在 CPU 与 GPU 之间按需迁移优化器状态，缓解训练中的显存峰值，但可能增加传输等待。它们都不改变“冻结量化基础权重、训练 LoRA 参数”的更新范围，也不保证任何配置都能放进显存。[QLoRA 论文](https://arxiv.org/abs/2305.14314)

</details>

### 7.3 LoRA 与 QLoRA 的选择

| 当前情况                                   | 优先考虑什么                                     |
| ------------------------------------------ | ------------------------------------------------ |
| 本课程 Qwen3-0.6B 能在现有显卡上完成 LoRA  | 保持 LoRA，先检查训练与任务效果                  |
| 换大模型后，仅加载基础权重就占去过多显存   | 检查加载精度，再评估 QLoRA                       |
| 能加载模型，但处理长样本或大批次时显存不足 | 先检查长度与 batch，不能只靠量化解决             |
| 需要验证两种方法的资源差异                 | 固定模型、数据和其余配置，分别记录实际资源与效果 |

<details>
<summary>调整时再看：从 LoRA 改为 QLoRA，要比较哪些结果</summary>

把当前配置中的量化等级从 `none` 改为适当配置的 4-bit 加载，主要变化是基础权重的保存与计算方式；学习的仍然是 LoRA 分支。额外的量化、反量化计算和具体实现会影响速度，因此 QLoRA 也可能更慢。它带来的近似误差是否影响关键词选择，还要检查回答。

对本课程已能完成普通 LoRA 的 0.6B 模型，先用现有配置完成训练与效果检查。将来换大模型，若权重占用成为主要限制，再比较 QLoRA：核对量化是否实际生效，记录能否完整运行、显存与耗时，最后比较同一份验证数据的损失和回答。**能容纳模型、运行成本合适、任务效果可接受，是三个需要分别检查的结果。**

</details>

量化依赖、配置和资源对比留到[第 33 章](33-微调显存优化与多卡训练.md)第 2.5、2.6 节选做，结果整理方法见该章第 5.5 节。

---

## 8、训练配置与显存估算

**本节先掌握：** 对照第 8.1 节，把配置读成一次完整的训练安排。第 8.2 节的计算器是更换配置时的选读工具，估算还需要实机验证。

现在回到本章开头的任务：使用第 29 章清洗并划分的数据，在 AutoDL 的现有 V100-32GB 单卡上，对 `Qwen/Qwen3-0.6B` 做 LoRA 微调。下面不增加新参数，而是试着把整份配置读成一次训练安排。

### 8.1 首轮训练配置

第 2 节先认识了训练页面和三个批次参数。现在回到 `案例与源码-4-微调/configs/keywords_clean_train.yaml`，把整套设置连起来读：

先用本章开头的五项设置，把安排说清：每批 4 条、累积 8 批后更新，1,600 条训练数据练 3 轮；学习率按 `5e-5` 调度，每条完整训练序列最多保留 2048 个 token。预计每轮更新 50 步，合计 150 步。

再核对两个条件：使用匹配的模型与模板，V100 上采用已验证的 FP16。LoRA 先沿用课程设置；日志、验证与保存间隔属于观察和留存安排。

<details>
<summary>运行时查阅：首轮训练的完整字段与取值</summary>

| 中文名称与配置字段                                                                                               | 本次取值              | 对应前面学到的含义                                                                                           |
| ---------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
| 模型路径<br>`model_name_or_path`                                                                                 | `Qwen/Qwen3-0.6B`     | 指定要微调的模型                                                                                             |
| 对话模板<br>`template`                                                                                           | `qwen3_nothink`       | 按模型要求组织输入与答案                                                                                     |
| 思考模式<br>`enable_thinking`                                                                                    | `false`               | 请求关闭思考；实际处理取决于模板，见[第 32 章对照](32-微调效果评估与模型部署.md?id=_24-从单条观察到批量测试) |
| 训练集<br>`dataset`                                                                                              | `keywords_train`      | 1,600 条样本，用于更新参数                                                                                   |
| 验证集<br>`eval_dataset`                                                                                         | `keywords_validation` | 200 条样本，用于训练中检查                                                                                   |
| 自动验证划分<br>`val_size`                                                                                       | `0`                   | 已有独立验证文件，不再额外划分                                                                               |
| 截断长度<br>`cutoff_len`                                                                                         | `2048`                | 每条训练序列的 token 数上限                                                                                  |
| 单卡批处理大小<br><code style="white-space: normal; overflow-wrap: anywhere;">per_device_train_batch_size</code> | `4`                   | 每张卡每个小批次处理 4 条样本                                                                                |
| 梯度累积步数<br><code style="white-space: normal; overflow-wrap: anywhere;">gradient_accumulation_steps</code>   | `8`                   | 累积 8 个小批次后更新一次参数                                                                                |
| 训练轮数<br>`num_train_epochs`                                                                                   | `3`                   | 训练集学习 3 遍，预计共 150 个更新步                                                                         |
| 优化器<br>`optim`                                                                                                | `adamw_torch`         | 使用 PyTorch 的 AdamW 更新参数                                                                               |
| 学习率<br>`learning_rate`                                                                                        | `5.0e-5`              | 本例预热结束时达到的学习率                                                                                   |
| 学习率调度器<br>`lr_scheduler_type`                                                                              | `cosine`              | 预热后按余弦曲线降低学习率                                                                                   |
| 预热步数<br>`warmup_steps`                                                                                       | `5`                   | 前 5 个更新步逐步提高学习率                                                                                  |
| FP16 混合精度<br>`fp16`                                                                                          | `true`                | 开启本次 V100 使用的混合精度训练                                                                             |
| BF16 混合精度<br>`bf16`                                                                                          | `false`               | 本次不开启 BF16                                                                                              |
| LoRA 秩<br>`lora_rank`                                                                                           | `8`                   | 设置新增分支的中间宽度                                                                                       |
| LoRA 缩放系数<br>`lora_alpha`                                                                                    | `16`                  | 配合 rank，按 `alpha/r` 缩放分支结果                                                                         |
| LoRA 随机丢弃<br>`lora_dropout`                                                                                  | `0`                   | 不随机丢弃分支输入                                                                                           |
| LoRA 作用模块<br>`lora_target`                                                                                   | `all`                 | 在工具识别的适用线性层添加分支                                                                               |
| 日志间隔<br>`logging_steps`                                                                                      | `5`                   | 每 5 个更新步记录一次日志                                                                                    |
| 验证间隔<br>`eval_steps`                                                                                         | `50`                  | 每 50 个更新步检查一次验证损失                                                                               |
| 保存间隔<br>`save_steps`                                                                                         | `50`                  | 每 50 个更新步保存一次检查点                                                                                 |

</details>

200 条测试数据留给最终检查，不参与本轮训练或检查点选择。第 31 章会把这份配置对应到 WebUI 的填写位置，并提供可选的命令行入口。

这份配置的取值依据需要分开理解：模型与模板要匹配，计算精度要适合硬件，长度要能保留完整样本，这些都有明确的检查条件；batch、学习率、轮数和 LoRA 参数则组成了一套已运行过的起始安排。已有记录便于复现和比较，不能据此称它为最优组合。

<details>
<summary>选读图解：整套训练配置与产物的对应关系</summary>

![完整训练分工回顾：模型、数据、批次、自动计算、验证与训练产物的关系](images/30/30-8-1-1.png)

</details>

**先不翻前文，试着向别人说明这次训练：**

1. 训练、验证、测试各用多少条？哪一份数据会让参数发生更新？
2. 每次只处理 4 条，为什么不是每轮更新 400 次？第 50 步时准备做什么？
3. 使用 LoRA 后，训练结束得到什么？拿到这个文件，是否就能证明关键词抽取变好了？

<details>
<summary>参考回答：把配置连成一段完整的训练说明</summary>

我们用 1,600 条文章与参考答案训练，只更新选定层的 LoRA 分支。每批处理 4 条，累计 8 批、也就是 32 条后更新一次；每轮 50 步，3 轮共 150 步。每 5 步记录日志，每 50 步用 200 条验证数据检查并保存检查点。验证过程不更新参数，但其结果会用于选择检查点；200 条测试数据继续保留。

训练得到 Adapter、日志和检查点。Adapter 需要与对应的原模型一起使用；接下来还要比较实际生成的关键词，不能只凭 Loss 降了或文件保存成功，就宣布任务效果改善。

</details>

能说明这段安排以后，再做一道配置判断题：**假设模型加载成功，但处理批次时显存不足。同学建议同时把 batch 改成 2、学习率改成 `1e-4`、rank 改成 16、截断长度改成 512。你会怎样处理？** 先说出报错对应哪部分占用，再决定保留哪些修改。

<details>
<summary>参考判断：每个修改都要有目的和检查依据</summary>

先把 batch 改为 2，配合累积 16，维持有效批次 32；学习率和 rank 保持课程取值。这次要解决的是同时处理样本的空间问题，增大学习率不会减少占用，增大 rank 还会增加可训练参数的相关开销。

长度先保持 2048。已有记录中最长样本为 957，直接降到 512 会使部分样本无法完整保留。如果后续确实需要缩短，应先检查截断后的输入和答案，不能为了通过第一批而丢掉关键词的来源。

然后核对启动日志：有效批次仍为 32，总更新步数仍为 150。观察原来的报错位置是否通过，并继续看更长批次及验证过程，记录显存与耗时；若仍报错，再按第 33 章检查其他占用。资源问题解决后，实际回答仍按第 32 章验证。

</details>

修改配置前，可以用一句话记录实验目的：“我准备改什么，希望改变哪一步，之后用什么结果判断。”例如，比较学习率就固定数据、模型、模板和其他训练设置；比较 rank 并维持相加前的缩放倍数，则按第 6.4 节配套处理 alpha。**一次比较围绕一个明确问题，并记录相关设置的变化。** 不同实验从约定的相同起点开始、使用不同输出目录，才能把结果对上。

### 8.2 使用显存计算器

**本小节选读。** 首次跟做已有课程配置，可以先继续第 9 节。以后更换模型或训练设置时，再用计算器作粗略估算，之后通过实机试跑检查。

记住一个边界：计算器的估算值不等于真实峰值，也不能证明显卡精度兼容或训练一定能完成。

<details>
<summary>选读操作：填写显存计算器、理解结果并与实际运行对照</summary>

打开 [ApX 显存计算器](https://apxml.com/zh/tools/vram-calculator)，选择“微调”和“LoRA”，再填写模型与主要训练参数。不要使用“推理”页面估算训练，因为它不包含同样的训练状态。

对照上表，填写模型 Qwen3-0.6B、rank 8、batch 4、长度 2048、梯度累积 8：

![ApX 中的课程 LoRA 参数：Qwen3-0.6B、rank 8、batch 4、长度2048、梯度累积8](images/30/30-8-2-1.jpg)

图中 batch 的提示文字提到了“每次优化步”，读时要分清：输入框里的 `4` 是单卡每个小批次的样本数，配合累积 `8` 次，才得到每次参数更新的有效批次 `32`。

计算器中还有三个需要注意的地方：

- 精度统一显示为 FP16／BF16；本次 V100 配置使用 FP16，不能凭这个合并选项判断硬件兼容性。
- 没有 V100 预设时，选择单卡并自定义 32 GB 显存容量；这只匹配容量，不能据此估算 V100 的训练速度。
- 优化器选 AdamW，梯度检查点开启、页面比率为 100%。这是第 5.3 节说的减少中间结果保存，不是训练文件的保存频率。

同一组设置的结果约为 **9.8 GB**：

![ApX 在上述设置下的显存估算约9.8GB；这是估算，不是实测峰值](images/30/30-8-2-2.jpg)

这个数是按页面假设分项相加得到的预算：模型权重约 1.2 GB，中间结果约 7.1 GB，LoRA 优化器与梯度约 0.01 GB，框架开销约 1.5 GB，合计约 9.8 GB。各项是计算器估算值，不是训练过程中的测量值。

**为什么第 33 章实际采样达到约 27.92 GiB？** 两处记录的范围不同：

| 对照项       | 计算器的 9.8 GB                                                 | batch 4 对照实验的 27.92 GiB                               |
| ------------ | --------------------------------------------------------------- | ---------------------------------------------------------- |
| 怎样得到     | 按长度 2048 等输入与页面公式估算各项预算                        | 运行 LLaMA-Factory，每 200 毫秒采样一次整张 GPU 的已用显存 |
| 包含哪些条件 | 使用页面默认填充、运行开销等假设；没有 alpha、target 的独立入口 | 包含模型加载、训练、验证和保存期间的实际分配与缓存         |
| 数值表示什么 | 简化预算的合计                                                  | 全程最高采样值 28,588 MiB，约 27.92 GiB，即约 29.98 GB     |

实际软件在不同阶段会申请临时空间，并保留部分缓存；计算器的固定预算无法完整反映这个过程。现有记录没有逐项显存追踪，因此还不能把差额归到某一个组件。选配置时用估算筛选方案，确定能否稳定运行则要看[第 33 章的实际资源记录](33-微调显存优化与多卡训练.md?id=_26-怎样判断调整是否有效)。

读完这组截图，可以用前面的知识检查自己的理解：如果模型和精度不变，只把 batch 从 4 改为 8，模型权重不会随之翻倍，主要增加的是并行处理样本的计算占用；如果只提高长度上限，影响还取决于实际样本长度、填充方式以及计算器的假设。

需要估算新配置时，可以先只改 batch，记录新读数；再恢复原值，比较长度上限。假如计算器随上限提高而给出更高估算，先看它是否按所填长度计算，不要直接套到当前最长 957 token 的实际数据上。估算帮助筛选待试的方案，真正保留哪个设置，还要对照实际运行的占用、耗时和样本完整性。

**估算示例：同一个 8B 模型为什么推理与全参数训练占用不同**

下面比较同一计算器在两种输入条件下的估算，用于理解计算过程的差别：

| 模式       | 估算输入条件                                                       | 页面估算 |
| ---------- | ------------------------------------------------------------------ | -------- |
| 推理       | Qwen3-8B、FP16 权重、FP16/BF16 KV 缓存、batch 1、长度 1024、并发 1 | 17.2 GB  |
| 全参数微调 | Qwen3-8B、FP16/BF16、batch 1、长度 1024、梯度累积 1                | 143.4 GB |

全参数训练还要保存大量梯度和优化器状态，所以估算明显增大。143.4 GB 不是 8B LoRA 的显存需求，也不能把两项的比值当成所有模型的固定规律。

</details>

---

## 9、训练日志与检查点

**本节先掌握：** 看训练进行到哪里、验证结果是否改善、产物保存在哪里。训练 Loss 下降后，还要检查实际生成的关键词。

配置决定预期，日志告诉我们实际发生了什么。下面使用 `keywords-clean` 数据的一次 150 步训练作为示例：本章学习读日志，第 31 章学习怎样运行，第 32 章再检查对应 Adapter 的回答。

先按时间顺序看三个位置：**第 5 步出现一条训练日志，第 50 步完成第一轮并验证、保存，第 150 步完成三轮，再按验证结果选择检查点。** 下面的日志、曲线和文件列表，就按这条顺序来读。

### 9.1 阅读一条训练日志

训练启动后，界面日志区或终端会先列出训练安排。下面摘取示例日志中的关键字段，省略时间和日志前缀：

```text
Num examples = 1,600
Num Epochs = 3
Instantaneous batch size per device = 4
Total train batch size (w. parallel, distributed & accumulation) = 32
Gradient Accumulation steps = 8
Total optimization steps = 150
```

先找到 `Num examples = 1,600`、`Num Epochs = 3` 和 `Total optimization steps = 150`，就能与第 2 节的计算对应起来。有效批次对应 `Total train batch size = 32`；进度条中的 `21/150` 则表示已经完成 21 个更新步。

在示例的 `trainer_state.json` 中，第 5 步包含以下信息。这里只摘取四个字段，Loss 保留四位小数：

```json
{
  "step": 5,
  "epoch": 0.1,
  "loss": 4.1185,
  "learning_rate": 0.00004
}
```

| 字段                     | 这条记录表示什么                               |
| ------------------------ | ---------------------------------------------- |
| `step: 5`                | 已完成 5 个参数更新步                          |
| `epoch: 0.1`             | 第一轮约完成 10%；本轮每轮 50 步，5 ÷ 50 = 0.1 |
| `loss: 4.1185`           | 最近记录区间内汇总的训练损失                   |
| `learning_rate: 0.00004` | 第 5 次更新使用的学习率，即 `4e-5`             |

本次 `logging_steps: 5`，表示每 5 个更新步记录一次。结合梯度累积 8，可以把它读成：正常情况下处理 40 个小 batch 后，出现下一条训练损失记录。

<details>
<summary>调整时再看：日志记录更少，是否代表训练更新更少</summary>

若只把日志间隔从 5 改成 10，正常记录点会从 5、10、15……变成 10、20、30……，每条训练 Loss 的汇总区间也随之改变。更新参数的次数没有因此减半，只是观察得更稀疏了。日志点少，可能漏掉短暂波动；不要把记录点更少、曲线看起来更平滑误读成模型学得更稳。

</details>

读到这里，先确认进度在增长、Loss 为有效数值、学习率按预热与衰减安排变化。某一步的精确记录时序需要时再查。

<details>
<summary>查日志时再看：预热第 5 步的学习率为什么是 4e-5</summary>

**配置是 `5e-5`，第 5 步为什么记录 `4e-5`？** 本轮使用 5 步预热，日志记录本次更新所用的学习率。开始第 5 次更新前，调度器已推进 4 次，所以学习率为 `5e-5 × 4/5 = 4e-5`；这次更新完成后，调度器才推进到 5，为下一次更新准备 `5e-5`。这是本轮 [Transformers 5.8.0 的记录与调度顺序](https://github.com/huggingface/transformers/blob/v5.8.0/src/transformers/trainer.py#L1766-L1786)。

</details>

日志还可能带有 `grad_norm`，它是梯度整体大小的一个汇总量，可用于排查梯度异常。

**遇到异常时，先分清是哪一种。** 可以按下面的现象选择检查方向：

| 看到的现象                       | 首先检查什么                                                                                  |
| -------------------------------- | --------------------------------------------------------------------------------------------- |
| Loss 在有限数值之间上下波动      | 结合多个记录点与验证表现判断；不同批次难度不同，一次上升不能证明训练失败                      |
| Loss 或梯度反复出现 `NaN`、`Inf` | 保留首次异常附近的日志和配置，检查数据、有效答案标签、学习率与计算精度；不要靠增加 epoch 解决 |
| `grad_norm` 偶尔很大             | 对照异常批次和前后记录；这个数受模型、数据和统计方式影响，没有跨任务通用的正常阈值            |
| 报错包含 `CUDA out of memory`    | 按第 33 章的报错阶段检查显存占用；它与数值变成 NaN 是不同问题                                 |

`NaN` 表示运算没有得到有效数值，`Inf` 表示无穷值，可能与数值溢出等问题有关。先回看第 4.2 节的实际输入、截断后答案及 `labels`，确认仍有参与计算的答案位置；再核对精度与原配置。保留出问题的版本，每次只改变一个候选因素，才能判断哪项处理有效。若异常持续，应停止本次运行后排查，不把保存出的权重直接作为有效结果。

<details>
<summary>排查时再看：梯度裁剪、FP16 与日志里的异常值</summary>

**梯度裁剪**是在更新参数前，限制梯度整体大小的一种方法。以本课普通单卡训练使用的范数裁剪为例，当梯度整体大小超过 `max_grad_norm` 时，程序按比例缩小梯度；它限制的是梯度，不是把 Loss 压到这个数值。

本课 Transformers 5.8.0 的 `grad_norm` 记录来自裁剪前的范数。因此，假设阈值为 `1.0`、日志显示 `4.0`，并不说明裁剪没有生效。这里的数字是教学示例；裁剪与记录顺序可查[对应版本源码](https://github.com/huggingface/transformers/blob/v5.8.0/src/transformers/trainer.py#L2496-L2500)。

FP16 的数值表示范围有限。混合精度训练通常使用**梯度缩放**，先放大损失以减轻很小的梯度下溢，再在更新前还原梯度的尺度；它与裁剪处理的环节不同，也不能修复错误数据或保证任何模型都适合 FP16。精度排查仍需遵守第 31 章的显卡兼容条件，不能在 V100 上直接改用 BF16。详见 [PyTorch 混合精度说明](https://docs.pytorch.org/docs/2.14/amp.html#gradient-scaling)。

还要注意，训练工具可能过滤日志汇总中的非有限 Loss。本课版本的 `logging_nan_inf_filter` 只影响日志统计，不负责修复梯度；曲线看起来正常，也不能替代对异常日志和实际回答的检查。具体定义见[训练参数源码](https://github.com/huggingface/transformers/blob/v5.8.0/src/transformers/training_args.py)。

</details>

### 9.2 训练损失与验证损失

每处理一批训练数据，程序会计算损失并参与参数更新；**验证时则使用另一份数据计算损失，不执行参数更新**。

本次 `eval_steps: 50`，表示每完成 50 个更新步，就用全部 200 条验证样本做一次检查。因此正好在每轮结束时各有一次验证：

| 更新步 | 训练进度    | 验证 Loss |
| ------ | ----------- | --------- |
| 50     | 第 1 轮结束 | 1.7238    |
| 100    | 第 2 轮结束 | 1.5951    |
| 150    | 第 3 轮结束 | 1.5880    |

这三次验证使用同一份数据。从第 50 步到第 100 步，验证损失降低约 `0.1287`；再到第 150 步，降低约 `0.0071`。后 50 步的改善幅度变小，但三个点还不能证明继续训练一定更好或已经过拟合。

这张表支持的判断是：**本轮训练中，后面的检查点在同一验证集上的损失更低。** 若想判断多练一轮是否值得，下一步应让这些检查点在相同生成设置下回答同一批验证输入，检查漏词、名称与格式。多花了计算时间，任务错误是否也减少，需要这一步来回答。

例如，假设候选甲的验证 Loss 更低，候选乙在关键词检查中更少漏掉完整名称，该怎样选？先确认两者使用相同验证输入、模板和生成设置，再按预先确定的任务验收要求比较。**“按 Loss 选出的最佳检查点”只表示它在这个选择指标上最好。** 若改为按任务指标选型，应记录新的选择依据，并留待独立测试验收。

<details>
<summary>查日志时再看：最后一条 Loss 与全程 train_loss 为什么不同</summary>

训练结束还会出现 `train_loss`。它和中途日志里的 `loss` 不是同一个统计区间：

| 记录                    | 本次数值 | 统计含义                   |
| ----------------------- | -------- | -------------------------- |
| 第 150 步的 `loss`      | 1.4353   | 最近一个日志区间的训练损失 |
| 结束汇总的 `train_loss` | 1.8547   | 全程训练损失的汇总值       |
| 第 150 步的 `eval_loss` | 1.5880   | 这一轮验证数据的损失       |

因此，“最后一条 Loss 是 1.4353，为什么结束统计又是 1.8547”并不矛盾；前期较高的损失也计入全程统计。它们不是百分比，也不是关键词准确率。

</details>

表中数值保留四位小数。需要复算时，可在配套 `results/keywords-clean/training/` 中查看 `trainer_state.json` 和 `train_results.json`；阅读时先分清各字段的统计范围。

### 9.3 Loss 曲线与常见现象

下面分别是示例训练的训练曲线和验证曲线：

**训练曲线：每 5 个更新步记录一次。**

![150步示例训练的Loss曲线：原始线与平滑线表示同一类训练损失](images/30/30-9-3-1.png)

**验证曲线：只在第 50、100、150 步实际检查，共三个记录点。**

![示例训练在第50、100、150步记录的验证Loss](images/30/30-9-3-2.png)

_验证点之间的连线便于观察趋势，期间没有额外的验证记录。_

曲线横轴表示训练进度，纵轴是 Loss。每张图里的浅色 `original` 是原始记录，深色 `smoothed` 是平滑后的线，两条线都来自同一类损失，并非一条训练、一条验证。平滑只是便于观察趋势，没有额外做一次训练或验证。**两张图的纵轴范围不同，不能直接用线条的陡峭程度比较下降幅度。**

先观察整体趋势，再回到日志定位发生变化的位置：

![Loss 长期不降、剧烈波动、训练与验证分歧，以及损失下降但关键词仍错误时的检查方向](images/30/30-9-3-3.svg)

_图：教学示意，不是本次训练曲线。蓝色实线表示训练 Loss，橙色虚线表示验证 Loss；没有画验证线的两行，只讨论训练日志中的现象。_

不要给所有任务规定“Loss 必须降到 0.1”之类合格线。这里比较的是模型对参考答案的预测情况；实际关键词还需要单独检查。

**Loss 下降以后，还要检查实际回答。** 示例 Adapter 对一篇矿山降温文章能按分号格式输出，但相对参考答案仍漏了“制冷降温”“焓差”等词。第 32 章会[逐项拆解这条回答](32-微调效果评估与模型部署.md?id=_23-原始模型与-adapter-的回答对照)，再通过批量评估判断问题是否普遍。

#### 9.3.1 过拟合的判断与处理

先练习读一组变化。**下表是为讲解而构造的数字，不是真实运行结果，也不是判断合格的分数线。** 假设每轮结束都按相同方式统计损失，并使用同一份验证集：

| 训练进度    | 训练 Loss | 验证 Loss | 可以观察到什么                                    |
| ----------- | --------- | --------- | ------------------------------------------------- |
| 第 1 轮结束 | 1.8       | 2.0       | 留下第一次检查结果                                |
| 第 2 轮结束 | 1.4       | 1.6       | 两边都下降，验证数据上的预测也在改善              |
| 第 3 轮结束 | 1.1       | 1.7       | 训练继续改善，验证开始变差，需要继续核对          |
| 第 4 轮结束 | 0.9       | 1.9       | 验证连续变差，不应只因训练 Loss 更低就选择第 4 轮 |

第 2 轮以后，模型更贴近训练题的答案，验证题的损失却反而上升。这是需要检查过拟合的信号。重点是**同一训练过程中两边的变化趋势**，不是要求两个 Loss 必须相等，也不是用某一次差值下结论。

遇到这样的走势，按下面的顺序检查：

1. **先确认比较条件没变。** 是否一直使用同一份验证数据、同一套模板与预处理？文章或参考答案是否被截断？先排除数据与处理错误。
2. **再看实际回答。** 用较早和较晚的检查点，在相同生成设置下回答同一批验证输入。对照参考答案，检查新出现的漏词、多词和格式错误，不只盯着两条曲线。
3. **根据验证结果调整。** 如果较晚的检查点在验证损失和实际回答上都更差，可以保留较早的检查点，减少训练轮数；再回查训练数据是否重复过多、题材过于单一或答案有误，而不是继续加轮数。

这些选择都使用验证集，**不拿测试集反复挑检查点**。测试集仍按第 29 章的约定，留给方案确定后的最终比较。

回到本章的真实日志示例：三次验证 Loss 都在下降，不能把上表后两轮的现象套在它身上。下降幅度变小，也不等于已经过拟合。下一节再看示例配置怎样保存和选择检查点。

### 9.4 训练检查点与 Adapter

每隔一段时间保存训练状态，得到的存档就是**训练检查点（checkpoint）**。本次 `save_steps: 50`，实际保存了 `checkpoint-50`、`checkpoint-100` 和 `checkpoint-150`，分别对应完成 50、100、150 次更新时的状态。

把第 9.2 节的验证表与存档对上，就知道为什么同时设置“验证”和“保存”：第 50、100、150 步各有一份验证结果，也各有一份可加载的模型存档。

如果把验证、保存间隔都从 50 改成 25，这次 150 步训练就会在 25、50、75、100、125、150 步检查并存档。候选检查点更密集，验证计算、文件写入和保留存档的磁盘开销也可能增加。两种频率都不直接改变每次参数更新的学习率或 batch；调整时还要满足工具对最佳检查点选择的间隔要求，第 31 章保持二者一致。

配置还设置了 `load_best_model_at_end: true`：训练结束后，按 `eval_loss` 选择验证损失最低的检查点。本次最低值恰好出现在第 150 步，因此选中 `checkpoint-150`。如果最低值出现在较早的检查点，就应按所设规则选它，而不是固定选择最后一份。

跟做时，检查点保存在第 31 章配置的训练输出目录中。先认识两类内容即可，具体文件检查与下载留在第 31 章：

- `adapter_model.safetensors` 和 `adapter_config.json`：保存 LoRA 增量权重与配置，加载时需要匹配的基础模型。
- `checkpoint-*`：除相应 Adapter 外，还保存优化器、调度器、训练状态等，用于恢复训练或检查过程；只复制一份增量权重不等于完整续训存档。

示例 Adapter 权重约 20 MB，只保存新增的 LoRA 权重，不代表完整 Qwen 模型只有这么大。下载和保存时，仍需区分“用于加载的 Adapter”和“用于继续训练的完整检查点”。

推理时可以让基础模型与 Adapter 一起加载，也可以在支持的配置下把增量合并到对应基础权重，再导出完整模型：

![Adapter分开加载与合并为完整模型的关系示意](images/30/30-9-4-1.svg)

图中展示的是两种产物的关系。第 31 章先完成训练和文件保存，第 32 章再实际[加载、比较与导出模型](32-微调效果评估与模型部署.md)。

---

**章节思考题：**

1. 关键词 SFT 中，模型在预测什么？Loss、梯度和优化器怎样把参考答案用于参数更新？

   **参考思路：** 模型根据文章、任务说明和前面的参考答案片段，预测后续 token。交叉熵用模型给正确 token 分配的概率衡量损失；反向传播据此求可训练参数的梯度，优化器再按规则更新参数。Loss、梯度由程序计算，学习率等设置由我们配置；训练并不是先生成整段答案，再数错了几个关键词。

2. 本课关闭 train_on_prompt，为什么用户文章仍必须保留？如果参考答案中混入了无依据关键词，Loss 降低又说明什么？

   **参考思路：** 关闭该设置表示用户内容对应的预测位置不直接计入损失，文章仍作为上下文参与计算、占用序列长度。错误参考也会成为学习目标，训练可能提高那些错误词的预测概率。因此 Loss 降低只说明更贴近当前训练目标，还要核对参考答案质量和实际生成效果。

3. 单卡、不打包、1,600 条样本均保留，batch 为 4、梯度累积为 8、训练 3 轮：有效批次和总更新步数各是多少？只把 batch 改为 2 后，怎样维持原更新安排？

   **参考思路：** 原有效批次为 4×8=32，每轮 1,600÷32=50 步，3 轮共 150 步。只减 batch 时，有效批次变成 16，总步数变成 300；每 50 步验证也变为每半轮一次。将累积同步改成 16，可恢复有效批次 32 和总计 150 步，但耗时与数值结果仍需实际比较。

4. 学习率、预热和衰减分别控制什么？配置填了 learning_rate=5e-5，为什么日志中的学习率仍会变化？

   **参考思路：** 学习率控制参数更新步长；预热让初期学习率从较小值逐步升高，衰减让后期学习率逐步降低。本课同时设置 cosine 调度和 5 个预热步，5e-5 是预热结束时达到的值，之后按调度变化。调大学习率不等于学得更好，是否合适要结合训练稳定性与验证回答判断。

5. 训练显存主要用在哪里？Adapter 文件很小，为什么仍可能显存不足？梯度检查点又节省哪部分？

   **参考思路：** 主要包括模型权重、梯度、优化器状态和计算中间结果，此外还有运行开销。Adapter 文件只反映部分训练产物，不包含整场计算所需的空间。梯度检查点少保存一些中间结果，需要时重算，以额外计算换显存；它与按步数写入磁盘的训练检查点不是同一件事。

6. FP32、FP16、BF16 保存一个数分别占多少字节？为什么启用 FP16 不等于整场训练显存减半，也不同于 4-bit 量化？

   **参考思路：** 分别占 4、2、2 字节；FP16 与 BF16 的数值范围和精度分配不同，选择时还要检查硬件支持。混合精度让不同计算或状态使用不同精度，部分状态仍可能保留 FP32，整场训练也有其他占用。4-bit 量化则用更少比特近似保存基础权重，不表示全部训练计算或 LoRA 参数都变成 4 位。

7. 全参数微调、LoRA 和 QLoRA 分别更新哪些参数？LoRA 冻结原权重后，为什么仍能改变输出，又为什么需要基础模型？

   **参考思路：** 全参数微调更新原模型参数；LoRA 冻结基础权重，主要训练新增的低秩分支，分支结果与原层结果相加后影响输出。QLoRA 在此基础上低位量化基础权重，仍训练 LoRA 参数。LoRA 减少大量基础参数的梯度和优化器状态，但基础模型仍参与计算；量化进一步节省权重存储，也引入近似误差。

8. 配置中的 rank=8、alpha=16、target=all 和 dropout=0 分别表示什么？为什么增大 rank 不一定让效果更好？

   **参考思路：** rank 控制分支的中间宽度，标准 LoRA 按 alpha/r 缩放增量，此处为 2；target 决定在哪里加分支，all 指本工具识别的适用线性层；dropout=0 表示不随机遮住分支输入。它们改变的部分不同。增大 rank 会增加可训练参数，却不保证任务效果提高；调整时要记录其他设置并比较验证结果。

9. cutoff_len 限制的是字符数还是 token 数？当前最长训练序列为 957 token，上限从 2048 改成 4096 或 512，分别需要怎样判断？

   **参考思路：** 它限制预处理后的 token 序列长度，序列包括任务、文章、答案与模板标记。2048 已能容纳当前样本，改成 4096 不会凭空增加内容，显存还取决于实际长度与填充；降到 512 可能截断长样本，要检查文章、答案和保留条数。更换数据、模板或分词器后，应重新统计。

10. 训练 Loss 与验证 Loss 分别反映什么？如果训练 Loss 继续下降，验证 Loss 连续上升，怎样选择后续方案？

    **参考思路：** 前者来自参与学习的数据，后者来自不用于参数更新的验证数据。持续背离可能提示过拟合，也要先核对数据和预处理。比较较早与较晚检查点在同一批验证输入上的回答，再决定是否减少轮数或调整训练；更低 Loss 不能直接证明关键词更准确，测试集不用于反复调参。

<details>
<summary>进阶练习：LoRA 计算与参数变化</summary>

1. 常见默认 LoRA 初始化中，A 随机、B 为零，为什么分支开始时不改变原输出？A、B 都为零是否等价？

   **参考思路：** B 为零时乘积增量为零，但训练可以先更新 B，随后逐步学习分支。两矩阵都为零，会使这个乘积结构的常规梯度更新无法把分支学起来。这里讨论第 6.2 节的常见默认方式，其他初始化要按相应设计理解。

2. 把 rank 从 8 改成 16，alpha 保持 16，标准 LoRA 的缩放系数怎样变化？怎样保持原系数？

   **参考思路：** alpha/r 从 2 变为 1；若希望仍为 2，可把 alpha 改为 32。rank 增大还会增加分支参数量，保持缩放系数不代表输出或任务效果不变，因此仍需比较验证结果与开销。

3. 总计 150 步，预热从 5 步改成 15 步，训练安排会怎样变化？怎样判断这项调整是否有帮助？

   **参考思路：** 预热包含在总预算内，总数仍为 150 步，后续衰减阶段从 145 步变成 135 步。学习率上升过程拉长是确定变化；是否更稳定、任务效果是否更好，要在相同起点和其他条件下比较日志与验证回答。

4. LoRA dropout 从 0 改成 0.1，会删除 A、B 的部分权重吗？推理时是否继续随机丢弃？

   **参考思路：** 它在训练时随机遮住部分分支输入，A、B 权重尺寸和参数量不变，正常推理时关闭。这个设置有时有助于缓解过拟合，也可能影响有限训练中的学习；它不能修正错误标注，也不是节省显存的开关。

</details>

**本章小结：**

- 模型先根据上下文预测 token，Loss 衡量预测，反向传播计算梯度，优化器执行参数更新。本课主要直接监督助手答案的位置，用户文章仍是参与计算的上下文。
- batch、梯度累积和 epoch 决定样本怎样分批、多久更新一次和总共遍历几轮；学习率控制更新步长，预热与衰减安排它随进度的变化。
- 训练显存包括权重、梯度、优化器状态和中间结果，不能只按权重大小估算。混合精度为不同计算或状态选择适用精度；长度要按完整序列检查，梯度检查点则用重算减少部分中间结果的存储。
- 全参数微调更新原模型参数，LoRA 主要训练新增分支，QLoRA 进一步量化基础权重。rank 控制分支宽度，alpha 控制缩放，target 决定作用位置，dropout 在训练时随机遮住部分分支输入。
- 训练与验证 Loss 帮助观察学习过程，检查点保存阶段结果，实际回答用于检查任务表现。配置是否合适，需要结合数据、资源与验证结果判断，不能只凭一条曲线或 Adapter 文件大小下结论。

**建议下一步：** 回到第 8.1 节，解释每项配置的用途，算出有效批次与更新步数，再说明训练会留下哪些文件、怎样检查效果。然后进入[第 31 章](31-LLaMA-Factory环境搭建与微调实战.md)，把这些理解用于环境准备、配置填写和真实训练日志的核对。
