第九章 微调:从数据设计到 LoRA/QLoRA 工程¶
微调的核心问题不是“选哪个框架”,而是“希望模型的什么行为发生变化”。知识更新优先考虑 RAG;输出风格、任务格式、工具选择和领域表达可考虑 SFT;偏好与安全边界进入第十章。先定义评估,再决定是否微调。

图 9-1 冻结原权重 \(W\),只训练低秩增量 \(BA\);部署时可保留适配器或合并权重。
9.1 全量微调与参数高效微调¶
先建立直觉。 全量微调更新全部参数,表达能力强但显存与遗忘风险高;参数高效微调冻结大部分权重,只训练适配参数,更适合受限资源和多任务版本管理。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。
把过程拆开看:
- 明确想改变知识还是行为。
- 估算可训练参数与优化器状态。
- 选择目标模块。
- 与基础模型做同条件比较。
最小例子。 LoRA 把更新写成 BA,rank r 远小于输入输出维;基础 W 不变,训练和保存的参数明显减少。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。
容易踩坑。 参数少不代表激活显存小、所有任务都适合相同 rank、忘记保存 tokenizer/chat template,都会影响结果。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。
动手任务:计算一个 4096×4096 线性层全量参数与 rank=16 LoRA 参数之比。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。
全量微调更新所有参数,容量最大,但需要保存梯度、优化器状态和主权重,显存与存储成本高,也更容易破坏原能力。PEFT 只训练少量新增参数。
LoRA 假设任务适配所需的权重变化可近似为低秩矩阵:
$$ W'=W+\Delta W,\qquad \Delta W=\frac{\alpha}{r}BA, $$ 其中 \(A\in\mathbb{R}^{r\times d_{in}}\)、\(B\in\mathbb{R}^{d_{out}\times r}\)。通常初始化使初始 \(\Delta W=0\),不改变基础模型输出。
import math
import torch
from torch import nn
class LoRALinear(nn.Module):
def __init__(self, base: nn.Linear, rank=8, alpha=16, dropout=0.0):
super().__init__()
self.base = base
for p in self.base.parameters():
p.requires_grad = False
self.rank = rank
self.scale = alpha / rank
self.dropout = nn.Dropout(dropout)
self.A = nn.Parameter(torch.empty(rank, base.in_features))
self.B = nn.Parameter(torch.zeros(base.out_features, rank))
nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
def forward(self, x):
delta = (self.dropout(x) @ self.A.T) @ self.B.T
return self.base(x) + self.scale * delta
代码解读:B 零初始化让增量初始为零;rank 控制容量与参数量;alpha 控制缩放。目标模块不能机械照抄,注意力的 q/k/v/o 投影与 FFN 投影对任务影响不同,应结合模型结构和消融实验选择。
Adapter 在层间插入瓶颈模块;IA3 学习通道缩放;Prefix/Prompt/P-Tuning 在隐藏空间或嵌入层学习软提示。不同方法的参数少不等于训练显存一定最低,激活、序列长度和基础模型精度仍占主要部分。
9.2 QLoRA¶
从问题出发。 QLoRA 将冻结的基础权重量化存储,在其上训练较高精度 LoRA 参数。4-bit 权重节省存储,但计算通常会反量化到合适精度,并不等于原生 4-bit 训练。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。
沿数据流逐步检查:
- 加载量化基础权重。
- 准备 k-bit 训练与 norm。
- 注入 LoRA。
- 用高精度优化器状态更新适配器。
用小数据走一遍。 NF4 针对近似正态分布权重设计量化格点;double quantization 进一步压缩量化尺度。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。
这里最容易出现的误解。 把量化 dtype、计算 dtype 和参数 dtype 混为一谈、量化后全量更新、CPU offload 带宽不足,都会导致错误或变慢。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。
小实验:记录 QLoRA 运行时各类参数 dtype、可训练参数数和峰值显存。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。
QLoRA 将冻结的基础模型以 4-bit 形式存储和计算,同时训练 LoRA 参数。常见 NF4 面向近似正态分布权重,双重量化进一步压缩量化常数。训练仍会在较高计算精度中进行部分运算,不能把“4-bit 加载”理解为全流程所有张量都是 4-bit。
量化会引入误差,显存收益也依赖 kernel、优化器、序列长度和 checkpointing。开始前用实际 batch 做显存剖析,不要只按参数数 × 0.5 字节估算。
9.3 指令数据格式¶

图 09-2 用户和系统 token 可作为上下文输入,但通常不作为要预测的训练标签。 先看它解决什么。 指令数据不仅是三段文本,还包含角色、轮次、工具消息、系统规则与损失掩码。模板决定模型实际学习哪些 token。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。
可以把实现分成以下环节:
- 统一消息 schema。
- 用模型官方 chat template 编码。
- 只对目标 assistant 区域计算 loss。
- 检查截断后轮次完整性。
一个可以手算的例子。 多轮数据中用户 token 通常设为 -100,不参与交叉熵;若模板把 EOS 放错,模型可能学不会正常停止。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。
排错时先看这些地方。 手写模板与推理模板不一致、重复 BOS、所有消息都算 loss、长样本截断只剩答案尾部,都会造成训练异常。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。
现在动手:打印一条样本的 token、role 与 label 三列,人工核对每个特殊 token。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。
一条样本通常含 system、user、assistant 多轮消息。必须使用目标模型的 chat template,并只对期望模型学习的 token 计算损失。若把用户问题也作为标签,模型可能学习复述输入。
sample = {
"messages": [
{"role": "system", "content": "你是设备维修助手,只根据手册回答。"},
{"role": "user", "content": "E17 报警是什么意思?"},
{"role": "assistant", "content": "E17 表示进水超时。请先检查进水阀和水压;断电后再操作。"},
],
"source": "manual_v3_p42",
"quality": "human_verified",
}
高质量数据应覆盖正常、边界、拒答、纠错和多轮场景;答案要事实正确、格式一致、难度分层。自动生成数据可扩规模,但需要去重、规则验证和人工抽检。划分训练/验证集时按文档、用户或任务模板分组,避免近重复泄漏。
9.4 使用 PEFT/TRL 的标准流程¶
抓住这一节的主线。 PEFT/TRL 等框架减少样板代码,但数据、模板、目标模块、精度、保存与评估仍需显式确认。标准流程先做几十步可解释 smoke test,再扩大训练。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。
真正动手时按这个顺序走:
- 加载模型与 tokenizer。
- 格式化数据并检查标签。
- 配置 LoRA/Trainer。
- 训练、保存、合并并独立加载验证。
先做最小实验。 训练后只保存 adapter 时,部署必须同时指定正确基础模型版本;合并权重则要记录合并 dtype 与校验输出。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。
别被表面现象带偏。 直接复制旧版本参数、target_modules 漏掉实际层名、保存目录覆盖基础模型、训练完成未从磁盘重载测试,都会埋雷。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。
验证任务:写一个训练前检查脚本,拒绝无有效标签、模板不匹配和无可训练参数的配置。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。
框架接口会变化,稳定流程是:加载 tokenizer 与模型;应用正确模板;构造只监督回答的标签;配置 LoRA;训练与保存 adapter;离线评估;必要时合并并验证。
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
peft_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM",
)
args = SFTConfig(
output_dir="outputs/sft_adapter",
learning_rate=2e-4,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
num_train_epochs=2,
logging_steps=10,
save_strategy="steps",
save_steps=200,
bf16=True,
)
# model 与 train_dataset 省略:必须先确认 chat template 和标签掩码
trainer = SFTTrainer(model=model, train_dataset=train_dataset,
args=args, peft_config=peft_config)
trainer.train()
trainer.save_model()
学习率示例不是通用最优值。先做短跑检查 loss、梯度和生成样例,再扩完整训练。保存 adapter 后记录基础模型精确版本;没有基础模型就无法正确复原。
9.5 超参数与显存¶
先把概念落到可观察对象上。 显存由权重、梯度、优化器状态、激活、临时 buffer 与碎片共同构成。LoRA 主要减少前三项中的可训练部分,长序列仍可能让激活成为主因。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。
把抽象概念还原成操作:
- 先测单样本峰值。
- 调 batch 与梯度累积。
- 选择序列长度和 packing。
- 必要时梯度检查点与量化。
把它缩小到能逐项检查。 micro batch×累积步数×数据并行度决定有效 batch,但 token 长度分布还会改变每步实际 token。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。
需要特别守住的边界。 只看平均长度、用累积步数解决单样本 OOM、盲目开 gradient checkpointing、学习率不随有效 batch 重新验证,都会失败。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。
本节练习:建立显存预算表并通过 profiler 校准误差,找出最大占用项。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。
关键参数包括学习率、rank、alpha、目标模块、dropout、有效 batch、序列长度、epoch、warmup 和梯度裁剪。rank 越大容量越高但更易过拟合;数据少时多轮 epoch 可能造成灾难性遗忘和输出模式僵化。
显存主要由权重、梯度、优化器状态和激活组成。LoRA 大幅减少可训练参数相关内存,但激活仍随 batch × 序列长度 × 层数增长。梯度 checkpointing 用额外计算换激活显存;packing 提高有效 token 比例,但要正确处理边界。
9.6 评估:与基础模型做成对比较¶
先建立直觉。 微调评估要回答‘在哪些目标上变好、在哪些基础能力上变坏’。必须与未微调模型使用同一模板、解码和评估集成对比较。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。
把过程拆开看:
- 目标任务离线指标。
- 通用能力回归。
- 安全与拒答测试。
- 人工盲评和失败分类。
最小例子。 格式正确率提高但事实正确率下降,说明模型更会服从形式却不一定更可靠;两个指标必须同时报告。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。
容易踩坑。 只展示精选样例、基础模型提示不同、评测数据与训练重复、没有置信区间,会产生虚假收益。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。
动手任务:为 50 条目标样例和 30 条回归样例做 A/B 盲评,记录胜/平/负与原因。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。
建立固定 prompt 集,比较基础模型与微调模型:任务正确率、格式遵循、事实性、拒答、安全、原通用能力和延迟。只看训练 loss 无法判断是否学会目标行为。
对生成任务可用规则、单元测试、人工盲评和模型评委组合。报告置信区间与失败样例。检查数据记忆:把训练答案中的独特字符串放入测试会产生虚假提升。
9.7 常见失败¶
从问题出发。 微调失败应从数据重复、模板、标签、截断、学习率、训练轮数、解码和部署版本逐层排查。复读和风格坍缩往往首先是数据与过拟合问题。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。
沿数据流逐步检查:
- 抽样解码训练前数据。
- 统计有效标签与重复率。
- 观察 train/val 差距。
- 比较不同 checkpoint 生成。
用小数据走一遍。 train loss 持续下降而 validation loss 上升,同时输出复现训练短语,说明继续训练可能加剧过拟合。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。
这里最容易出现的误解。 一出现复读就只调 repetition_penalty、没有验证 EOS、训练与推理模板不同、adapter 加载两次,都会误诊。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。
小实验:建立微调故障表,为 NaN、OOM、无学习、复读和遗忘各写三个证据检查。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。
- 输出重复:数据模板重复、epoch 过多、解码参数或 EOS 处理错误。
- 微调后变笨:学习率过高、数据窄、标签掩码错误、灾难性遗忘。
- 训练 loss 降但格式不对:chat template 或推理模板不一致。
- 合并后结果变化:dtype、缩放、目标模块或 tokenizer 版本不一致。
- 法律/医疗答案“更专业”却不可靠:语言风格提升不代表事实正确,必须领域评估与人工复核。
9.8 一份可信的微调工程手册¶
先看它解决什么。 可信工程手册应让另一个人能从原始数据复现模型:数据卡、配置、环境、日志、checkpoint、评估和模型卡必须互相指向。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。
可以把实现分成以下环节:
- 数据与代码版本化。
- 实验命名包含关键变量。
- 每次训练自动生成报告。
- 发布前独立重载与安全检查。
一个可以手算的例子。 模型卡写清基础模型、adapter、许可、目标任务、已知限制和不适用场景,不能只写一个下载链接。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。
排错时先看这些地方。 配置只存在命令历史、日志无样本版本、失败实验被删除、指标无评测脚本,会让结果不可审计。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。
现在动手:为一次 LoRA 实验生成最小可复现包,并让全新环境按 README 重跑评估。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。
项目应记录数据 Schema、过滤与抽检、模板、基础模型、量化配置、LoRA 参数、训练曲线、评估集、对比结果、失败样例、模型卡和回滚方式。不要把框架命令当作项目亮点;真正的亮点是数据与评估设计、资源约束下的取舍以及可复现结果。
练习:把一个线性层替换为 LoRALinear,验证初始输出一致;比较 rank 4/16/64 的参数量与验证指标;构造含拒答样本的数据集并检查微调前后越界回答率。
延伸阅读:LoRA 论文、Hugging Face PEFT 文档、TRL 与 PEFT 集成、B 站:LoRA 原理与实战。
本章配套代码¶
下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。
examples/09_lora.py:手写 LoRA Linear。examples/09_sft_label_mask.py:多轮 SFT 标签掩码。
本章端到端实验:把知识变成可复现证据¶
本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。
实验步骤¶
- 计算一个 4096×4096 线性层全量参数与 rank=16 LoRA 参数之比。
- 记录 QLoRA 运行时各类参数 dtype、可训练参数数和峰值显存。
- 打印一条样本的 token、role 与 label 三列,人工核对每个特殊 token。
- 写一个训练前检查脚本,拒绝无有效标签、模板不匹配和无可训练参数的配置。
- 建立显存预算表并通过 profiler 校准误差,找出最大占用项。
- 为 50 条目标样例和 30 条回归样例做 A/B 盲评,记录胜/平/负与原因。
每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。
验收标准¶
- 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
- 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
- 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
- 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
- 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。
本章自测¶
- 不看正文,用自己的话解释“全量微调更新全部参数,表达能力强但显存与遗忘风险高”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“QLoRA 将冻结的基础权重量化存储,在其上训练较高精度 LoRA 参数”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“指令数据不仅是三段文本,还包含角色、轮次、工具消息、系统规则与损失掩码”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“PEFT/TRL 等框架减少样板代码,但数据、模板、目标模块、精度、保存与评估仍需显式确认”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“显存由权重、梯度、优化器状态、激活、临时 buffer 与碎片共同构成”,并给出一个可以证伪的测试。
- 不看正文,用自己的话解释“微调评估要回答‘在哪些目标上变好、在哪些基础能力上变坏’”,并给出一个可以证伪的测试。
回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。