[LLM 2/10] SFT + LoRA:只训练 1.69% 的参数,教模型学会当助手
上一章我们用继续预训练把知识灌进了模型权重。 但"知道"的模型不等于"会回答"的模型——base 模型只有一个职业:把文字续写下去。 这一章要讲的是用 LoRA 来做 SFT(Supervised Fine-Tuning):只训练约 1.7% 的参数, 却能改变整个模型的行为——在免费 Colab 上约 15 分钟跑完。 而你拿到手的,是一个约 40 MB 的 adapter 文件,它将成为本系列余下所有章节的脊梁。
Open in Colab02_sft_lora.ipynb
1. 问题(Problem statement)
拿上一章那个纯 base 模型 Qwen3-0.6B-Base,输入一句泰语 "帮我推荐几道泰国菜吧"。你得到的往往不是回答, 而是续写——它可能再帮你编三个问题、把话头接成一篇旅游文章, 或者写到一半切换成英语。因为它唯一被训练过的事情是:"互联网上这样的文字,后面通常跟着什么"。
回答的能力——接住指令、答在点上、然后停下来——不是 pretraining 自带的。 它来自 SFT:用几千到几百万对(指令,好的回答)继续训练。 你用过的每一个 instruct 模型,无一例外都走过这一步。
但真要自己动手,会撞上叠在一起的两层问题:
第一层——full fine-tuning 的成本。 如果训练全部参数,每个任务你都会得到一整个新模型(0.6B 模型约 1.2 GB)。 一个有十个任务的组织——文档摘要、公文起草、客服回复、投诉分类——就得存十份副本。 而且用偏高的学习率去动每一个权重,正是把第 1 章刚灌进去的知识抹掉的配方(还记得那个学习率警告框吗)。
第二层——泰语。 即便是已经 post-train 过的 Qwen3-0.6B,
也有一个我们全系列反复看到的症状:用泰语提问,回答却在句子中途滑回英语
(这正是本系列 th_ratio 指标的由来),因为它见过的 SFT 数据以英语为主。
这一章同时解决两层问题:用泰语指令数据做 SFT,并用 LoRA 代替 full fine-tuning。
2. 我们要做什么(Solution)
我们会拿 Qwen3-0.6B,用 4,000 条泰语指令-回答对来训练, loss 和第 1 章一模一样,外加两件新东西:
- 补全掩码(completion mask)——只在回答一侧的 token 上计 loss,不计提问一侧(3.1 节会解释跳过这一步会怎样以一种滑稽的方式翻车)
- LoRA(Low-Rank Adaptation)——把原有权重全部冻结,改为在每层之上叠加两个小矩阵来训练
你并不是在训练模型权重——你是在训练一个叠加在原权重之上的低秩"修正项"(correction)。
这就是 adapter 只有约 40 MB 而不是 1.2 GB 的原因, 是你可以在同一个 base 上存二十个 adapter 换着用的原因(二十个任务 = 0.8 GB,而不是 24 GB), 也是第 4 章(DPO)的 reference model 额外显存开销为零字节的原因—— 把 adapter 一关,就原封不动拿回了初始模型。
3. 公式(Equation)
3.1 SFT loss 与补全掩码
- = 一条训练样本—— 是指令部分(含 chat template), 是模型训练时真正看到的 token 序列
- = 第 个位置的 token, = 它前面的全部 token
- = 参数为 的模型预测出的概率
- = 补全掩码——只在回答一侧的 token 上为 1,在 prompt 的 token 上为 0
试着把 拿掉(即令 处处成立),这个式子立刻变回第 1 章 CPT 的 objective。 SFT 就是把文本布置成对话场景的 CPT,再加上一个掩码——没有更多了。
但这一个掩码就是成功的一半,因为 是最容易踩中的默认值
(很多 pipeline,包括 SFTTrainer,如果 collator 没配对,就会悄悄按这种方式训练)。
而在典型的泰语指令数据里,prompt 一侧的 token 占到样本的约 60%——
也就是说你的大部分梯度正在教模型学写用户的提问,而不是学回答。
它的后遗症会在第 9 节现出原形。
3.2 LoRA:训练修正项,而不是权重
LoRA 不直接更新权重矩阵 ,而是把 钉死,去学习一个由两个小矩阵相乘构成的差量:
- = 该层的原始权重,被冻结,完全不接收梯度
- 和 = 我们真正训练的两个 adapter 矩阵
- = 修正项的秩(rank)——LoRA 的主旋钮(本章用 )
- = 缩放系数——乘积 永远会被乘上 (本章 ,所以 )
这个定义里有两个细节,重要程度远超它们的长相:
整个矩阵被初始化为零,所以第一步时 —— 训练从 base 模型分毫不差地出发,不存在被随机权重扰动的阶段。 ( 则是随机 Gaussian——如果两个都设成零,双方的梯度会永远为零,因为各自都在和零相乘。)
里的除数 让 update 的量级与秩无关—— 把 翻倍, 的求和多出一倍的项,却刚好被除回去。 所以你可以扫一遍 的取值,而不必每次重新调学习率。
训练结束后你有两条路:merge(,得到一个没有额外 latency 的单一模型), 或分开保存——本系列选的是第二条,因为可插拔的 adapter 正是第 4 章免费变出 reference model 的道具。
3.3 可训练参数占比——一个要亲手验算的数字,不是背下来的数字
对一个 的矩阵,adapter 有 个参数,占比为
- = 原权重矩阵的维度
- = adapter 的秩
代入 Qwen3-0.6B 的真实数值(hidden 1024、intermediate 3072、28 层, 在 q、k、v、o、gate、up、down 全部 7 个矩阵上挂 adapter),可训练参数是 10,092,544 个, 基座是 596,049,920 个 = 1.69%。
几乎每篇 LoRA 文章都说"只训练不到 1% 的参数"。这个数字在 7B 以上的尺度是真的, 但对小模型不成立:adapter 参数按 增长——随 hidden size 线性, 而基座参数按 增长——二次方。模型越小,adapter 的占比反而越大。
再注意 1.69% 低于逐矩阵的占比(约 2.1–2.3%)——因为分母里还包含了
约 1.56 亿个我们没挂 adapter 的 embedding 参数。这些数字用纯算术就能验证,
notebook 会在第 7 节让 peft 把真实值打印给你亲眼看——信 print,不要信博客(包括这一篇)。
4. 把公式画出来(Visualize)
一个掩码,扭转整坨梯度的方向
Figure 2.1一对典型泰语问答的纯 token 记账(prompt 180 + 回答 120 token):如果不加掩码,loss 中 60% 的项都在学写用户的提问
这张图没有任何比数 token 更深的内容——但被跳过的恰恰就是数 token 这一步: 泰语的 prompt(含 system message 和 chat template)往往比回答更长, 不加掩码地训练,你就把大部分算力花在了教模型一件你根本不想要的事上。 notebook 会把抽样数据集的真实占比打印出来——图中的 60/40 只是一个典型样本的代表值,不是什么神圣常数。
为什么 rank 16 就"够了"——low-rank 假设
Figure 2.2一个合成 ΔW(1024×1024)的奇异值谱:rank 16 的信号被埋在满秩噪声之下——这是 low-rank 假设的示意插图,不是对真实 fine-tune 的测量
LoRA 的假设(Hu 等人,2021)是:相对于矩阵的全维度,fine-tune 只在为数不多的几个重要方向上挪动权重。 这张图人为构造了一个正好具有这种结构的假 (rank 16 信号 + 噪声), 再让 SVD 把它找回来——如果真实的 update 长这样,rank 16 的 adapter 就能留住几乎全部内容。 这张图没有证明的是:真实的 update 总是长这样。 那是研究的经验性发现,也是 LoRA "通常"逼近 full fine-tuning、而非"总是"的原因。
验算数字,不要背数字
Figure 2.3可训练参数占比随 rank 的变化,按 Qwen3-0.6B 的真实维度精确计算——r=16 那个点在 1.69%,高于常被引用的'低于 1%'线
log-log 轴上的直线印证了公式所说的:占比随 精确线性增长。 在这个模型上,"低于 1%"只有当 时才成立—— 而那不是任何人在语言任务上真正会用的值。我们用的点()是 1.69%,以 fp32 保存约 40 MB。
5. 准备环境(Environment)
打开 Colab,选择 Runtime → Change runtime type → T4 GPU(免费额度够用)。
Colab 的 T4 是 Turing 架构(SM 7.5),它不支持 bfloat16,也不支持 FlashAttention-2。
但 Qwen3-0.6B 的 config.json 里写着 torch_dtype: bfloat16。
所以 torch_dtype="auto" 是个陷阱:代码会崩掉或者慢得离谱,而且不会告诉你原因。
torch_dtype=torch.float16 # 不是 bfloat16
attn_implementation="sdpa" # 不是 flash_attention_2
fp16=True # 在 SFTConfig 里(不是 bf16=True)
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True(把 emulation 也算上了!)
is_bf16_supported() 在 T4 上会骗你较新的 torch 在 T4 上返回 True,因为它把**模拟(emulation)**也算作支持——而模拟比 fp16 慢得多。
请改为判断 compute capability ≥ 8.0(Ampere 及以上)。这是真正在 Colab 上跑才发现的 bug。
第 1 章我们训练前必须 model.float() 整个模型,因为训练的是全部参数。
这一章 base 被冻结——没有梯度——所以可以安安稳稳留在 fp16,省下一半显存。
必须是 fp32 的只有可训练的参数,也就是那约 1,000 万个 adapter 参数:
for p in model.parameters():
if p.requires_grad:
p.data = p.data.float() # 只转 adapter —— 不是整个模型
忘了这一步,你会看到 ValueError: Attempting to unscale FP16 gradients.——
和第 1 章一模一样的报错,只是这次修复的代价便宜得多:转 1,000 万个参数,而不是 5.96 亿个。
用了 LoRA 之后彻底改头换面的显存预算
还记得第 1 章 Adam 的 optimizer state 比模型本身还占地方吗( 和 合计约 4.4 GB)。
现在只训练 adapter,Adam 只需为 1,010 万个参数存 state——大约 0.08 GB,
连 adamw_bnb_8bit 都不再需要了。剩下的大头是 base 权重(fp16,约 1.2 GB)和 activations。
在计算器里切换 full fine-tuning 和不同的 LoRA rank,看看预算在哪里发生变化:
- Weights1.13 GiB
- Gradients19.25 MiB
- Optimizer state115.50 MiB
- Activations170.00 MiB
- KV cache—
It fits.This run needs 1.43 GiB and leaves 14.57 GiB of headroom on a free Colab T4.
6. 准备数据(Data)
我们使用 airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k——
WangchanX 团队的 120,000 对合成泰语指令-回答数据集(抽样 4,000 条使用)。
from datasets import load_dataset
ds = load_dataset("airesearch/wangchanx-seed-free-synthetic-instruct-thai-120k", split="train")
ds = ds.shuffle(seed=42).select(range(4000))
def to_text(ex):
messages = [
{"role": "user", "content": ex["instruction"]}, # 列名务必对照 dataset card 检查
{"role": "assistant", "content": ex["output"]},
]
return {"text": tok.apply_chat_template(messages, tokenize=False,
enable_thinking=False)}
train_ds = ds.map(to_text, remove_columns=ds.column_names)
有两行值得放慢速度读:
apply_chat_template把消息拼装成 Qwen3 被训练时的格式(<|im_start|>user…<|im_end|>…)。 我们只在这一个地方调用一次——原因见第 9 节的陷阱 2enable_thinking=False关闭 Qwen3 的 thinking 模式,让本章的样本保持简单、掩码逻辑直截了当
然后是一个应该养成习惯的仪式:永远把第一条样本 decode 出来亲眼看一遍。
print(train_ds[0]["text"][:400])
# 必须看到 <|im_start|>user ... <|im_end|> ... <|im_start|>assistant ... 每个 turn 各出现一次
notebook 还会打印抽样数据的 token 长度统计:prompt 一侧与回答一侧的中位数, 以及长度超过 768 token 的样本占比——后面这个数字会在陷阱 3 里回来讨债。
7. 核心代码(Main code)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig, DataCollatorForCompletionOnlyLM
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B", # post-trained 版 —— 本章修的是行为,不是灌知识
torch_dtype=torch.float16, # T4 没有 bf16
attn_implementation="sdpa", # T4 没有 FlashAttention-2
).cuda()
lora = LoraConfig(
r=16,
lora_alpha=32, # α/r = 2 —— 见公式 3.2
lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora)
model.print_trainable_parameters() # 信这一行,别信任何博客里的数字
for p in model.parameters(): # 第 5 节的 fp16 警告框
if p.requires_grad:
p.data = p.data.float()
peft 计算百分比时的分母是已含 adapter 的总参数量,而我们的 1.69% 除的是纯基座参数。
定义差一点点,数字就差一点点——而这恰恰是 3.3 节的重点:
这类数字必须知道分子分母各是什么,而不是把一个孤零零的数字背下来到处引用。
接下来是把公式 3.1 翻译成代码的那一块——充当 的 collator:
collator = DataCollatorForCompletionOnlyLM(
response_template="<|im_start|>assistant\n", # 这之前的一切 = prompt
tokenizer=tok,
)
这个 collator 把 <|im_start|>assistant 之前所有 token 的 label 设为 -100——
这是 PyTorch 的 loss function 会跳过的值,也正是第 4 章 seq_logp 函数里
用来掩掉 prompt 一侧的同一个值。同一个掩码,出现在不同的章节。
cfg = SFTConfig(
output_dir="sft-out",
dataset_text_field="text",
max_seq_length=768, # notebook 会打印被截断的样本数 —— 陷阱 3
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # effective batch = 16
num_train_epochs=1,
learning_rate=2e-4, # LoRA 扛得住 —— full FT 用这个值就是灾难(第 1 章)
lr_scheduler_type="cosine",
warmup_ratio=0.03,
packing=False, # completion mask 无法直接和 packing 一起用
fp16=True, # T4:不是 bf16
logging_steps=10,
)
trainer = SFTTrainer(
model=model,
args=cfg,
train_dataset=train_ds,
data_collator=collator,
processing_class=tok,
)
trainer.train() # 在 T4 上约 15 分钟
同样一个学习率,如果用来训练全部参数,几百个 step 就会把模型的能力抹掉。 但配上 LoRA 它是安全的,因为(1)5.96 亿个原权重被冻结——base 里的知识不可能被覆写; (2) 从零出发——第一步时模型就是原封不动的 base,然后才慢慢走出去。 LoRA 能造成的最坏结果是一个糟糕的 adapter,而 adapter 随时可以扔掉。
训练结束,只保存修正项:
model.save_pretrained("qwen3-0.6b-th-sft-lora") # ~40 MB —— 不是 1.2 GB
这个 adapter 正是第 4 章以 kobkrit/qwen3-0.6b-th-sft-lora 之名加载的那个,
既当初始 policy,又(在关掉 adapter 时)当 reference model——一份价值 40 MB 的跨章礼物。
8. 结果(Results)
notebook 会在训练前后各测三项指标,并写入 results.json:
- TH-INSTR——通过指令遵循 rubric(答在点上、自己收尾、不出戏)的回答占比, 来自 KobEval-TH 评测集,附 Wilson 95% CI
th_ratio——回答中泰文字符的占比,本系列的常驻指标。 初始的 Qwen3-0.6B 在泰语 prompt 上以滑回英语著称,用 4,000 条纯泰语数据做 SFT 应当让这个数字明显上移——如果没动,那就是先去排查掩码和 template 的信号,别的以后再说- 在 token 预算内以 eos 收尾的回答占比——捕捉陷阱 1 和陷阱 3 造成的"停不下来"症状
一百来道题的评测集给出的 CI 宽度约 ±10 个点。没有 CI 的数字还不算实验结果。
第 9 节的表格里凡是要等 notebook 跑出真实数字的地方都放了 ?——我们不在博客里预猜结果。
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. 对比(Comparison)
notebook 在同一份数据上训练三种配置,再与初始模型对照:
| 模型 | TH-INSTR (95% CI) | th_ratio | PPL | 训练参数 | 训练时间 |
|---|---|---|---|---|---|
| Qwen3-0.6B(base) | 73.3%(55.6–85.8) | 0.93 | 21.3 | — | — |
| LoRA r=16 + 补全掩码 | 83.3%(66.4–92.7) | 0.97 | 17.7 | 10.1M(1.69%) | 13.2 分钟 |
results.json。
SFT 在每个维度都有提升:TH-INSTR +10 分(73.3% → 83.3%),th_ratio 上升
(更多回答留在泰语,不再漂移到英语),困惑度从 21.3 降到 17.7 ——
而只训练了 1.69% 的参数,adapter 仅约 40 MB。
73.3% 对 83.3% 看着差距明显,但 Wilson 区间是 55.6–85.8 与 66.4–92.7,仍然重叠。
在 n=30 的规模下还达不到统计显著。更扎实的证据是建立在数千 token 上的 th_ratio 与 PPL。
要让 TH-INSTR 具备结论性,题目需增至数百(第 9 篇)。
关于全参数微调与不加掩码那一行: notebook 确实训练了一个不加掩码的 LoRA,用来展示模型 答完之后自问自答续写用户问题的行为(示例见 §8),但没有为它计算完整指标,也没有做 100% 参数的全参数微调 —— 那超出了免费 Colab 的预算。我们只报告实测到的数字,不为没有跑过的 实验填数。
10. 小结(Summary)
- SFT 就是对话版的 CPT + 补全掩码——loss 还是第 1 章那个,变的是数据被布置成了对话,以及掩码决定哪些 token 计入
- 掩码是成功的一半—— 会让 60% 的梯度去学写提问,训出一个答完还自己编问题的模型
- LoRA 训练的是"修正项",不是权重:, 从零出发,训练从 base 精确起步; 让换 rank 不用重调 lr
- 1.69% 不是"低于 1%"——adapter 随 hidden size 线性增长,base 按二次方增长,模型越小 adapter 占比越大。永远用
print_trainable_parameters()核实 - 每个任务约 40 MB 的 adapter:一个 base + 多个 adapter,这也是第 4 章 reference model 免费的原因
- lr 2e-4 之所以安全,是因为 base 被冻结——同一个值在全参数训练下会毁掉模型
- pad ≠ eos、template 只套一次、别让截断砍在回答中间——三个症状在 inference 现形、根子却在数据里的坑
SFT 教的是"格式与风格",远大于"知识"——4,000 条样本不会往模型里添加任何新的事实。 模型不知道的事,SFT 之后它依然不知道,只是错得格式更漂亮、语气更自信—— 这反而更危险。灌知识是第 1 章(CPT)的工作,不是本章的。
和每一章一样:4,000 条样本演示的是机制。生产级的 SFT 用的是数万到数百万对、 经过多层质量筛选的数据。你从本章得到的是"每个旋钮做什么、会怎么坏"的理解, 这部分可以迁移到真实规模——但请不要拿这个结果去宣称你得到了更强的泰语模型。
下一章: RLHF 与 PPO——模型会回答了,但"答得好"写不成 loss function。 我们会让人类比较回答、从这些偏好中训练 reward model,再用 reinforcement learning 把模型推向它—— 而起点,就是本章这个 40 MB 的 adapter。
参考文献(References)
- Hu et al. (2021). LoRA: Low-Rank Adaptation of Large Language Models — 第 3 节 W' = W₀ + (α/r)BA 公式的出处
- Aghajanyan et al. (2020). Intrinsic Dimensionality Explains the Effectiveness of Language Model Fine-Tuning — 微调具有低内在维度的证据——LoRA 为何有效
- Dettmers et al. (2023). QLoRA: Efficient Finetuning of Quantized LLMs — 用 4-bit 扩展 LoRA,使更大的模型能装进单张 GPU
- Biderman et al. (2024). LoRA Learns Less and Forgets Less — LoRA 以学得更少换取忘得更少——建议与第 9 节对读
- Ouyang et al. (2022). Training language models to follow instructions with human feedback — InstructGPT:整条 SFT -> RM -> PPO 流水线的源头
- Wang et al. (2022). Self-Instruct: Aligning Language Models with Self-Generated Instructions — 用模型自身生成指令数据
- Zhou et al. (2023). LIMA: Less Is More for Alignment — 几千条高质量样本就够——我们只用 4,000 条的理由
本系列的文章、代码与 notebook 均以 CC BY-NC-SA 4.0 授权 —— 可自由使用与改编,须署名、限非商业用途,并以相同方式共享。文中引用的第三方模型与数据集仍适用各自的许可证。
