Skip to content

约 9 分钟阅读

实践:用 LoRA 微调资料助手 ​

前两篇讲了 SFT 的数据、模板和损失掩码,以及 LoRA 为什么只训练很少的参数就够用。这一篇把它们串起来在一台笔记本上真正跑一遍:给一个 5 亿参数的小模型准备几十条“按资料回答、注明条款编号、查不到就说没有”的样本,训练前后各评估一次,再看具体回答里哪些变好了、哪些学歪了。

选小模型是为了让每个人都能复现:整个过程在 Apple M4、24 GB 内存的 MacBook 上完成,训练 7 分钟出头。换成有 NVIDIA 显卡的 Linux 机器,脚本不用改;换成 8B 模型,要改的只是显存预算(见训练显存账本)。读完你应该能独立完成一次“基线评估 → LoRA 训练 → 对比评估 → 合并或挂载适配器”的闭环,并且知道一个小实验的结果能说明什么、不能说明什么。

实验设计:先想清楚要验证什么 ​

资料助手的回答流程是:检索系统先找到相关的制度条款,把条款和问题一起放进提示词,模型据此作答。所以训练样本里也要带着“检索到的资料”,模型要学的是怎样使用资料,而不是背下资料。这一点决定了整个实验的设计:

要验证的行为评估集怎样构造判定方法
换一种问法也能答对并注明条款训练过的条款,用训练集里没出现过的问法提问(6 条)回答含该问题的关键事实,且含“第 X.X 条”
没见过的条款也能按同样格式回答两条条款(报销时限、设备领用)完全不出现在训练集里(8 条)同上
资料里没有答案时不编问婚丧假、体检、停车等资料外的问题(4 条)回答含“资料中没有相关规定”

第二类最关键:如果模型只是背住了训练集里的六条制度,它在新条款上会失败;如果它学会的是“从资料里找答案并标出编号”,新条款上也应该有效。

员工手册是为这次实验单独虚构的,共 8 条,条款编号和前几篇举例用的制度(年假在第 4.2 条)不同,例如:

text
第 3.2 条 年假:入职满 1 年不满 10 年的员工每年享有 5 天年假,满 10 年不满 20 年 10 天,满 20 年 15 天。
年假在 OA 系统“假期申请”中提交,需提前 3 个工作日,由直属主管审批。

环境与数据 ​

本次实测环境:macOS,Apple M4(24 GB 统一内存),Python 3.12.14,用 Apple 的 MPS 后端在 GPU 上训练。库版本为 torch 2.14.1、transformers 5.21.0、peft 0.21.2、trl 1.16.0、datasets 5.1.0。模型是 Qwen2.5-0.5B-Instruct(Apache 2.0 许可,约 4.9 亿参数,下载约 1 GB)。

bash
mkdir lora-demo && cd lora-demo
uv venv -p 3.12 .venv && source .venv/bin/activate
uv pip install torch transformers peft trl datasets accelerate

把三个脚本下载到 lora-demo 目录:make_data.py(生成数据)、train.py(训练)、evaluate.py(评估)。然后生成数据:

bash
python make_data.py
# 输出:train 66 eval 18 {'新问法': 6, '新条款': 8, '无答案': 4}

第一次运行时会从 Hugging Face 下载模型;网络不通时可以设置 HF_ENDPOINT 指向镜像站,或者先从 ModelScope 下载 Qwen/Qwen2.5-0.5B-Instruct 到本地目录,再把脚本里的模型名换成目录路径。

make_data.py 生成两个 JSONL 文件。每条训练样本是 TRL 的 prompt-completion 格式:prompt 里是系统提示和带资料的用户消息,completion 里是标准回答。

json
{"prompt": [
  {"role": "system", "content": "你是公司资料助手。只根据<资料>中的内容回答员工问题,回答简洁,并在末尾用“(依据:第 X.X 条)”注明条款编号。资料中没有答案时,只回答:“资料中没有相关规定,请联系 HR。”"},
  {"role": "user", "content": "<资料>\n第 5.3 条 差旅住宿:……每晚上限 600 元,其他城市每晚上限 400 元。\n第 8.4 条 培训补贴:……\n</资料>\n问题:深圳出差住 700 元的酒店能全报吗?"}],
 "completion": [
  {"role": "assistant", "content": "住宿费按实际发生报销,北京、上海、广州、深圳每晚上限 600 元,其他城市每晚上限 400 元。(依据:第 5.3 条)"}]}

用这种格式有一个直接的好处:TRL 默认只对 completion 部分计算损失,系统提示和资料只作为输入,这正是第 09 篇讲的损失掩码,不需要自己构造标签。每个问题都配了一条相关条款和一条随机的干扰条款,让模型练习“从两条资料里挑对的那条”。另有 8 个资料外的问题(每个问题随机配两次资料,共 16 条),回答统一是拒答句。66 条样本很少,足够演示流程,但远不足以训练一个可用的助手,后面会看到它带来的副作用。

训练前先测基线 ​

不先测基线,训练后的分数就没有参照。evaluate.py 对 18 条评估样本逐条用贪心解码生成回答,然后按上面的表格判定:

bash
python evaluate.py           # 不带参数:评估原始模型

原始模型的结果(每格是“满足条件的条数 / 该类总数”):

评估类别含关键事实注明了正确条款拒答
新问法(6)301
新条款(8)602
无答案(4)——3

原始模型其实能从资料里找到不少答案,比如问“新员工去哪领电脑?”它答“IT 服务台”,但从不注明条款编号,系统提示里的格式要求它基本不遵守;它还会在答案明明在资料里时拒答(“我想周三周四在家办公”),以及在资料外的问题上编造(问“丧假有几天?”,它答“3天”,资料里根本没有丧假)。这三类问题正好是 SFT 擅长解决的:格式、何时拒答、只用资料。

训练:66 条样本,51 步 ​

train.py 的核心只有十几行:

python
peft_config = LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05,
                         target_modules="all-linear", task_type="CAUSAL_LM")
args = SFTConfig(
    output_dir="out", num_train_epochs=3, per_device_train_batch_size=4,
    learning_rate=2e-4, lr_scheduler_type="cosine", warmup_steps=3,
    logging_steps=5, max_length=1024, save_strategy="no", report_to=[], seed=42,
)
trainer = SFTTrainer(model="Qwen/Qwen2.5-0.5B-Instruct", args=args,
                     train_dataset=data, peft_config=peft_config)
trainer.train()
trainer.save_model("adapter")

几个取值的理由:r = 16、α = 32、作用于全部线性层,是第 10 篇讨论过的常用起点;学习率 2e-4 比全参数微调常用的 1e-5 量级大一个数量级以上,因为 LoRA 的 B 矩阵从 0 开始,需要更大的步子;3 轮、每批 4 条,66 条样本每轮 17 步,共 51 步。

训练开始时会打印可训练参数:

text
trainable params: 8,798,208 || all params: 502,830,976 || trainable%: 1.7497

这个数可以手算核对。Qwen2.5-0.5B 有 24 层,隐藏维度 896,FFN 中间维度 4864,2 个 KV 头、每头 64 维,所以 k、v 投影的输出是 128 维。一个 d_in × d_out 的矩阵加 LoRA 后多出 r × (d_in + d_out) 个参数:

text
q、o:16 × (896 + 896)  = 28,672   × 2 = 57,344
k、v:16 × (896 + 128)  = 16,384   × 2 = 32,768
gate、up、down:16 × (896 + 4864) = 92,160 × 3 = 276,480
每层合计 366,592 × 24 层 = 8,798,208

比例是 1.75%,比 Llama-3.1-8B 的 0.52% 高,因为小模型的矩阵窄,r × (d_in + d_out) 相对 d_in × d_out 就显得大。

本机训练耗时 430 秒,进程峰值内存约 7.2 GB(模型以 FP32 加载)。日志里的训练损失:

轮次0.290.590.881.181.472.062.94
损失0.3980.0510.0040.0200.0010.00190.0004

损失在第一轮就掉到接近 0,token 准确率达到 100%。按读懂训练曲线的标准,这是明显的过拟合信号:66 条样本里同一条款的回答完全相同,模型很快就把它们背了下来。这次没有单独的验证损失,是因为评估集太小,更直接的检查是看生成结果。

训练后评估:哪些变好了 ​

bash
python evaluate.py adapter   # 加载底座并挂上刚训练的适配器
评估类别含关键事实注明了正确条款拒答
新问法(6)3 → 60 → 61 → 0
新条款(8)6 → 70 → 72 → 1
无答案(4)——3 → 4

变化最大的是条款引用:从 0 到 13/14,而且没见过的两条条款上也能正确标出“第 5.6 条”“第 7.1 条”。这说明模型学会的是一种做法(从资料里找到对应条款、照格式标出编号),不只是记住了六条制度。资料外的问题四条全部拒答,原来编造的“丧假 3 天”消失了。

哪些学歪了 ​

只看上面的表,结论会是“完全成功”。逐条读回答,能看到两个从数字里看不出的问题。

第一,回答变成了整段照抄。 问“离职要还电脑吗?”,训练后的回答是“新员工入职当天到 IT 服务台领取笔记本电脑,离职时须归还并办理签收。(依据:第 7.1 条)”。事实没错,但前半句与问题无关。原因在数据:make_data.py 给同一条款的所有问法都配了同一段标准回答,模型学到的正是“找到条款、复述整段”。要让回答针对问题,训练数据里的回答就要针对问题来写,这是数据问题,调参数解决不了。

第二,需要一步推理的问题仍被拒答。 问“三个月前的发票还能报吗?”,资料里有“60 天内提交,逾期需部门负责人书面说明”,正确回答要先算出三个月超过了 60 天,再说明逾期怎样处理。原始模型和训练后的模型都回答“资料中没有相关规定”,训练没能纠正它。看训练数据就知道原因:即使问题需要换算(“深圳出差住 700 元的酒店能全报吗?”“工作 12 年了有几天年假?”),标准回答也只是复述条款原文,从没写出“超出部分 100 元不能报”这样的结论,模型没有机会学会“先推一步再回答”;同时 16 条拒答样本又在强化“对不上原句就拒答”。这正是第 09 篇提醒过的拒答过度风险。修正办法是把这类样本的回答改写成针对问题的结论并注明依据,再在评估里把“该答却拒答”单独统计。

还要清楚这次实验的边界:评估集只有 18 条,每一条的变化都会让比例跳 5 个百分点以上,按评估里的样本量算法,这远不足以比较两个版本的细微差距。它能说明“格式和拒答行为确实被训练改变了”,不能说明“训练后的模型可以上线”。另外评估用的是贪心解码,换成线上常用的采样参数,结果还要重测。

使用训练好的适配器 ​

适配器目录 adapter/ 只有约 45 MiB(du -sh 显示 45M),里面的 adapter_model.safetensors 就是那 880 万个参数(FP32 存储,8,798,208 × 4 字节 ≈ 35 MB),其余主要是约 11 MB 的分词器文件和配置。使用时有两种方式。

挂载:底座保持不变,加载时把适配器挂上去,evaluate.py 就是这样做的。

python
from peft import PeftModel
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
model = PeftModel.from_pretrained(model, "adapter")

推理服务也可以用同样的方式同时挂多个适配器,例如 vLLM 启动时加 --enable-lora --lora-modules hr=./adapter,请求里的 model 字段写 hr 就会使用它。部署步骤见实践:用 vLLM 跑起资料助手,多适配器的取舍见第 10 篇。

合并:把 (α/r)·BA 加回原矩阵,得到一个普通的模型目录,之后不再需要 peft。

python
base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, "adapter").merge_and_unload()
merged.save_pretrained("merged")
AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct").save_pretrained("merged")

本机运行后 merged/ 约 953 MiB(du -sh 显示 953M),其中 model.safetensors 为 988 MB(约 4.94 亿参数 × 2 字节),和原模型的 BF16 权重文件一样大,其余是分词器文件。合并后的模型不能再挂同一个适配器,否则改动量会被加两次;合并到低精度权重上也可能引入额外误差,所以合并后要用同一份评估集再跑一次。

换成真实规模时要改什么 ​

把实验搬到 Llama-3.1-8B 或 Qwen3-8B 上,流程完全相同,变化在三处。显存:8B 的 BF16 底座约 16 GB,加上 LoRA 状态和激活,一张 24 GB 显卡要靠梯度检查点和较短的序列,显存紧张时改用 QLoRA(BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4"),需要 NVIDIA GPU),具体账见第 03 篇。数据:几十条只够演示,真实项目要几百到几千条按问题写的回答,覆盖可答、需推理、资料缺失、资料冲突几类,并留出按条款分组的评估集。评估:至少几百条,按类别分别统计,并加上一组通用问题检查有没有把别的能力训坏。

训练时常见的问题和定位方法:

现象先检查什么
TypeError: ... unexpected keyword argument 'warmup_ratio'本次环境里 SFTConfig 不接受这个参数,改用 warmup_steps;网上教程的参数名常常落后于当前版本,以安装版本的文档为准
MPS 上报某个算子不支持设置 PYTORCH_ENABLE_MPS_FALLBACK=1 让该算子退回 CPU,或者在 CPU 上跑(会慢几倍)
训练后回答格式完全没变适配器是否真的加载了;评估时用的对话模板是否和训练时一致
损失一开始就是 0 或 NaN是否所有 token 都被掩码掉了(例如 completion 为空);学习率是否过大
显存或内存不足减小 per_device_train_batch_size,用 gradient_accumulation_steps 补足;开启 gradient_checkpointing=True

这次实验从基线到对比评估走完了一个完整闭环:只用 66 条样本、880 万个可训练参数,就把条款引用率从 0 提到 13/14,并让格式迁移到没见过的条款上;同时逐条阅读暴露了照抄整段和过度拒答两个问题,它们都来自数据,而不是超参数。下一个问题由此而来:SFT 只给模型看“标准答案”,没法告诉它“照抄整段”和“简洁作答”哪个更好、“编一个数字”比“说不知道”糟多少,这类比较要靠偏好对齐。

别急,先让缓存热一下。