苦猿的大模型日记 · Day10 · SFT 训练实操——用 QLoRA 微调 Qwen3-8B-帮普通人把AI学进简历系列
前言:那个 lr=2e-5 的凌晨
我给你讲个事。
上个月某个周三,凌晨两点半,我对着屏幕上跳动的 loss 数字发呆。
那时候我在调一个 QLoRA 微调任务,base 模型是 Qwen3-8B。我按照"教程默认值"配了r=64、alpha=128、lr=2e-5,自以为很稳——毕竟参数都是网上抄来的。
结果跑了 200 步,train loss 从 1.2 缓慢爬到 1.3,越训越烂。
我当时第一反应是——数据有问题。
换了一份数据集,还是烂。
第二反应——r 不够大。我把r调到 128,显存当场爆掉。
折腾到凌晨四点,我才反应过来一件事——
不是 r 的问题,是 lr 太小。
QLoRA 只训那 0.5% 的参数,你给它配一个为全量微调设计的2e-5,它当然纹丝不动。我把它提到1e-4,loss 立马开始下降。
那一刻我突然想明白——
Day09 给的是地图,Day10 要给的是铲子。
地图告诉你 SFT 是什么、LoRA 怎么省显存。但真到铲子下去那一刻,决定成败的不是你懂不懂原理,而是你会不会看 loss、会不会调那个该死的 lr。
今天这篇,就带你真刀真枪跑通一次 Qwen3-8B 的 QLoRA 微调——从数据准备到合并权重,从超参默认值到 loss 曲线诊断手册,Day09 埋的 5 个坑,我一个个带你填上。
PART 01:目标 & 显存账本
先把话说前面——
今天的目标:让 Qwen3-8B 学会按 Alpaca-zh 的指令风格回答问题。
不追求 SOTA,不追求商用,只追求一件事:跑通,且能看出来微调生效了。
为什么选 Qwen3-8B
我对比过几个常见尺寸:
- 7B(Qwen2.5/Llama):经典,但有点过气,中文表现被 Qwen3 压一头
- 8B(Qwen3):当前中文甜点尺寸——比 7B 新一代,比 14B 省一半显存
- 14B:效果好,但 QLoRA 都要 16G+ 起步,普通人玩不起
8B 是普通人在 12G 显存上能舒服跑起来的最大尺寸。这就是它的价值。
显存账本(必须重算)
Day09 我说过"6G 显存微调 7B",那是 Qwen2.5 时代的乐观估算。到 Qwen3-8B 这里,账要重算:
| 项目 | 占用 |
|---|---|
| 8B 权重(4bit 量化) | ~5-6 GB |
| 激活值(batch=1, seq=512) | ~1-2 GB |
| LoRA 参数 + 优化器状态 | ~0.5-1 GB |
| 梯度 + 临时缓冲 | ~0.5 GB |
| 合计 | 8-10 GB |
所以硬件建议是——
- 12G(3060 12G / 4070 12G):舒服,batch 能开到 2
- 8G(4060 / 4060Ti):极限可跑,batch=1 + grad_accum=8 凑等效 batch=8
- 6G 以下:建议换 7B 或者直接用云算力
别信那些"4G 显存微调大模型"的标题党——能跑起来和能训出东西是两回事。
工具链选择
今天主讲底层:transformers + peft + trl。
为什么不用 LLaMA-Factory 一把梭?因为出 bug 的时候你看不懂。面试官问你"QLoRA 的 target_modules 挂了哪些",你不能回答"我点了一下 yaml 就跑起来了"。
PART 07 我会附一份 LLaMA-Factory 的等价配置给懒人,但主篇幅必须走底层。
PART 02:数据准备(坑①②高发区)
数据准备是 SFT 里最容易翻车的环节——80% 的"训练不收敛"都是数据问题,不是模型问题。
Alpaca-zh 字段说明
我们用的是 Alpaca-zh,经典中文指令数据集,约 4.8 万条。每条三个字段:
{ "instruction": "请把这句话翻译成英文", "input": "今天天气真好", "output": "The weather is nice today." }instruction:指令本身input:指令的输入(可为空)output:期望的回答
看起来很简单对吧?坑就在"看起来简单"上。
Qwen3 的 ChatML template
你不能直接把这三段拼成一坨丢给模型。Qwen3 用的是ChatML 格式:
<|im_start|>user {instruction}{input}<|im_end|> <|im_start|>assistant {output}<|im_end|>那两个<|im_start|>和<|im_end|>是特殊 token,模型在预训练阶段就认识它们。你少了任何一个,模型就听不懂你在说啥。
loss mask:只算"答案"那段
这是 SFT 最关键的一个细节——
前文(user 那段)不算 loss,只算 assistant 那段。
为什么?因为 SFT 的目标是教模型"怎么回答",不是教它"怎么提问"。如果你把 user 段也算进 loss,模型会学着模仿你的提问方式,反而稀释了回答能力。
代码上,这通过labels实现:user 段的位置填-100(PyTorch 的 ignore_index),assistant 段填真实 token id。
坑①:chat template 拼错
最常见的错误是手拼 template——
# ❌ 错误做法:手拼 text = f"<|im_start|>user\n{instruction}{input}<|im_end|>\n<|im_start|>assistant\n{output}<|im_end|>"为什么错?因为你不知道<|im_end|>后面到底有没有换行、特殊 token 有没有被正确 tokenize。手拼出来的字符串,到 tokenizer 里很可能把<|im_start|>拆成 5 个普通字符而不是 1 个特殊 token。
正确做法——
# ✅ 正确做法:用 apply_chat_template messages = [ {"role": "user", "content": instruction + input}, {"role": "assistant", "content": output}, ] text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)让 tokenizer 自己拼,它知道每个特殊 token 怎么处理。
坑②:target_modules 漏挂
LoRA 不是随便挂哪都行——挂错位置,等于没挂。
很多人直接抄网上配置target_modules=["q_proj", "v_proj"],这是早期 LoRA 论文的配置,只挂 attention 的 Q 和 V。
但 Qwen3-8B 是个 36 层的大家伙,光挂 Q/V 太保守。正确做法是先 print 模型架构看清楚有哪些线性层——
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-8B", torch_dtype="auto") print(model.model.layers[0].self_attn) # 看 attention print(model.model.layers[0].mlp) # 看 MLP输出里会告诉你有q_proj/k_proj/v_proj/o_proj(attention 四件套)和gate_proj/up_proj/down_proj(MLP 三件套)。
我建议的配置是——attention 全挂 + MLP 全挂:
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]PART 03 会详细讲"只挂 attention vs 加 MLP"的效果差异。
PART 03:QLoRA 配置 & 超参到底怎么填
这是本文最干货的部分。每个超参我都给你三段式:默认值 / 为什么 / 调错后果。
LoRAr:能力上限旋钮
r是 LoRA 的秩,决定你能学多少新东西。
- r=8:默认值。够应付"改语气、改格式"这种轻量任务
- r=16:我推荐的新手起步值。能学新知识,显存涨得不多
- r=32:适合数据集大、任务重(比如代码生成、长文改写)
- r=64+:除非你知道为什么需要,否则别碰
调错后果:
- r 太小 →欠拟合,loss 降不下去,模型学不会新行为
- r 太大 → 显存暴涨 +过拟合,模型只会背训练集,换个问法就废
我的经验:先从 r=16 起步,看 loss 曲线再决定调不调。r 不是越大越好,是"够用就好"。
alpha:缩放系数
alpha控制 LoRA 那部分更新的"音量"。
默认公式:alpha = 2 × r。
- r=8 → alpha=16
- r=16 → alpha=32
什么时候偏离这个公式?
- 训练不收敛但 r 已经够大→ 试着把 alpha 调小(比如 alpha=r),让更新温和一点
- loss 降得太慢→ 试着把 alpha 调大(比如 alpha=4r),让更新激进一点
但新手别动 alpha,老老实实2r就行。它是高级玩家的微调旋钮,不是入门工具。
dropout:防过拟合开关
LoRA 默认dropout=0.05。
什么时候调到 0.1?
- 训练集 loss 远低于验证集 loss(比如 train=0.3,val=1.5)→ 典型过拟合,加 dropout
- 数据集很小(<1000 条)→ 容易背书,加 dropout
- 训练步数很多(>3 epoch)→ 同上
调错后果:
- dropout 太大(>0.2)→ 模型学不进去,欠拟合
- dropout=0 → 大多数时候没事,但小数据集上必过拟合
target_modules:挂哪些层
前面说过,attention 全挂 + MLP 全挂是我的推荐。但你要知道差异——
| 配置 | 可训参数 | 效果 | 显存 |
|---|---|---|---|
| 只挂 q_proj, v_proj | ~0.3% | 轻量任务够用 | 最省 |
| attention 四件套 | ~0.5% | 大多数 SFT 任务够用 | 省一点 |
| attention + MLP 全挂 | ~0.8% | 重任务、学新知识 | 稍涨 |
我的建议:8B 模型上直接全挂。多出来的显存占用对 12G 卡不算啥,但效果差异是肉眼可见的。
4bit 量化:NF4 还是 8bit
QLoRA 的核心是 4bit 量化,把权重压到 4bit 省显存。
- NF4(NormalFloat 4):默认推荐,正态分布优化的 4bit 格式
- double quant:再量化一次量化常数,再省 0.5GB
- 8bit:质量更好但显存翻倍
什么时候退回 8bit?
- 微调后效果明显变差(生成内容质量下降、出现乱码)→ 4bit 损失太大,换 8bit
- 显存充裕(16G+)→ 直接 8bit,质量优先
99% 的情况 NF4 + double quant 就够了。
lr / batch / grad_accum:调参三件套
这是新手最容易抄错的三个值——
| 参数 | 全量微调值 | QLoRA 推荐值 | 为什么差这么多 |
|---|---|---|---|
learning_rate | 2e-5 | 1e-4 ~ 2e-4 | QLoRA 只训 0.5% 参数,步子要迈大 |
per_device_batch | 8-32 | 1-2 | 显存受限 |
gradient_accumulation | 1 | 8-16 | 凑等效大 batch |
这就是我开头那个故事的根源——我把全量微调的 lr=2e-5 抄到 QLoRA 上,结果模型纹丝不动。
其他几个——
warmup_ratio=0.03:前 3% 步数线性升温,防止初期梯度爆炸weight_decay=0.01:正则化,防过拟合num_train_epochs=3:Alpaca-zh 这种数据量,3 epoch 起步
PART 04:跑起来 & 训练监控怎么读
配好超参,该跑了。
SFTTrainer 启动代码
直接上代码——
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training from trl import SFTTrainer, SFTConfig from datasets import load_dataset # 1. 加载 tokenizer + 模型(4bit) tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B") model = AutoModelForCausalLM.from_pretrained( "Qwen/Qwen3-8B", load_in_4bit=True, device_map="auto", ) # 2. 准备 4bit 训练 model = prepare_model_for_kbit_training(model) # 3. LoRA 配置 lora_config = LoraConfig( r=16, lora_alpha=32, 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_config) # 4. 加载数据(假设已转成 ChatML 格式的 jsonl) dataset = load_dataset("json", data_files="alpaca_zh_chatml.jsonl", split="train") # 5. 训练配置 sft_config = SFTConfig( output_dir="./qwen3-8b-qlora", num_train_epochs=3, per_device_train_batch_size=1, gradient_accumulation_steps=8, # 等效 batch=8 learning_rate=1e-4, # ⚠️ 不是 2e-5 warmup_ratio=0.03, weight_decay=0.01, logging_steps=10, save_strategy="epoch", bf16=True, # 40系卡用 bf16,30系换 fp16 ) # 6. 开训 trainer = SFTTrainer( model=model, args=sft_config, train_dataset=dataset, processing_class=tokenizer, ) trainer.train()跑起来你会看到每 10 步打印一次 loss——
{'loss': 1.85, 'learning_rate': 9.5e-05, 'epoch': 0.01} {'loss': 1.62, ...} {'loss': 1.43, ...} ...这串数字怎么读?这是本文最值钱的部分。
loss 曲线诊断手册
我总结过四种典型 loss 形态,每种对应一种病——
形态 1:正常下降
1.85 → 1.62 → 1.43 → 1.28 → 1.15 → ...前 50 步快速下降,之后缓慢收敛。这是健康的样子。
形态 2:train 降,val 不降(过拟合)
train loss 一路走低,但验证集 loss 开始回升——
train: 1.85 → 1.20 → 0.80 → 0.50 → 0.30 val: 1.90 → 1.40 → 1.20 → 1.35 → 1.60 ← 这里开始回升信号:模型在背训练集,没学到泛化能力。
对策:加 dropout / 减 epoch / 加数据。
形态 3:前 20% 步不降(lr 问题)
1.85 → 1.86 → 1.84 → 1.85 → 1.83 → ...(20 步后才开始动)信号:lr 太小,模型在原地踏步。
对策:lr × 5(比如 1e-4 → 5e-4)。这就是我开头那个坑。
形态 4:loss 突然飙 NaN(梯度爆炸)
1.20 → 1.15 → 1.10 → NaN → NaN → NaN信号:梯度炸了,权重被污染。
对策:
- 立刻停(别等)
- lr 减半重启
- 检查数据里有没有超长样本(>2048 token)
- bf16 不稳的话换 fp16,反之亦然
什么时候该早停
别迷信"训满 3 epoch"。
判断信号——
- train loss 连续 100 步不创新低 → 早停
- 生成质量开始下降(模型开始重复、胡说)→ 早停
- val loss 触底回升 → 立刻停
SFT 不是训得越久越好。很多模型在 1.5-2 epoch 就到顶了,第 3 epoch 反而训废。
如何判断微调真的生效
别只看 loss 数字。loss 从 1.8 降到 0.5,听起来很美好,但不代表模型变聪明了——可能只是它学会了"短回答"(短回答天然 loss 低)。
真正判断微调生效的方法是肉眼看生成质量——
# 训练完后立刻测一条 inputs = tokenizer.apply_chat_template( [{"role": "user", "content": "用三句话介绍一下中国春节"}], return_tensors="pt", add_generation_prompt=True ).to(model.device) output = model.generate(inputs, max_new_tokens=200) print(tokenizer.decode(output[0], skip_special_tokens=True))对比微调前后的输出——
- 微调前:base 模型可能续写成"用三句话介绍一下中国春节\n用三句话介绍一下美国圣诞节\n..."(接龙机器)
- 微调后:应该规规矩矩给你三句话
这才是"微调生效"的硬证据。
PART 05:调参经验法则小结
我把前面散落的经验整理成一张表,这是本文最该截图保存的部分——
| 症状 | 大概率原因 | 该动哪个旋钮 |
|---|---|---|
| loss 降不下去 | lr 太小 / r 太小 | 先 lr ×5,不行再 r ×2 |
| loss 突然 NaN | 梯度爆炸 | lr 减半,检查数据长度 |
| train 降 val 升 | 过拟合 | dropout↑ / epoch↓ / 加数据 |
| 生成内容重复 | lr 太大或训太久 | lr 减半 / 早停 |
| 模型不响应指令 | template 拼错 | 用 apply_chat_template |
| 输出乱码 | merge / tokenizer 问题 | 见 PART 06 |
还有几条心法——
心法 1:先调 lr,再调 r。
lr 是步长,r 是脑子大小。步长不对,脑子再大也走不动。
心法 2:数据不够先加数据,不是调参。
100 条数据调出花来也是过拟合。1 万条平庸数据 > 100 条精调数据。
心法 3:r 和 lr 要联动。
r 调大,参数变多,lr 要适当调小(否则容易炸)。这是一个联动旋钮,不是独立旋钮。
心法 4:永远先看 loss,再调参。
不要凭感觉动超参。loss 是模型在跟你说话,你得先听懂它在说什么。
PART 06:合并权重 & 推理验证(坑⑤)
训完之后,你拿到的是一个 LoRA adapter(几十 MB 的小文件),不是一个完整的模型。要部署的话,得合并。
merge LoRA 回 base
from peft import PeftModel from transformers import AutoModelForCausalLM # 1. 加载 base 模型(这次不量化,用 fp16) base = AutoModelForCausalLM.from_pretrained( "Qwen/Qwen3-8B", torch_dtype="float16", device_map="auto" ) # 2. 挂上 LoRA adapter model = PeftModel.from_pretrained(base, "./qwen3-8b-qlora/checkpoint-xxx") # 3. 合并 model = model.merge_and_unload() # 4. 保存 model.save_pretrained("./qwen3-8b-merged") tokenizer.save_pretrained("./qwen3-8b-merged")坑⑤:合并后输出乱码(三段式排查)
症状:合并完一推理,模型输出胡言乱语 / 重复 / 不响应指令。
排查三步:
- template 对齐了吗?
合并后的模型推理时,必须用和训练时完全一致的 chat template。训练用 ChatML,推理也得用 ChatML。这是最常见的翻车点。
- tokenizer 配置丢了吗?
save_pretrained默认只保存模型权重,tokenizer 的特殊 token 配置可能没保存全。检查合并目录下有没有tokenizer_config.json、special_tokens_map.json。没有的话手动复制一份过去。
- merge 真的成功了吗?
有时候 LoRA adapter 加载路径错了,合并出来其实是个"裸 base"。验证方法——
# 合并前后各生成一条,对比输出 # 如果完全一样,说明 merge 失败(LoRA 没生效)修复后,跑一遍对话验证——
# 简单对话验证 def chat(question): inputs = tokenizer.apply_chat_template( [{"role": "user", "content": question}], return_tensors="pt", add_generation_prompt=True ).to(model.device) output = model.generate(inputs, max_new_tokens=200, do_sample=True, temperature=0.7) return tokenizer.decode(output[0][inputs.shape[1]:], skip_special_tokens=True) print(chat("用三句话介绍中国春节"))输出规规矩矩给你三句话,微调闭环完成。
PART 07:懒人附赠——LLaMA-Factory 等价 yaml
前面讲了一堆底层,我知道有人会想——
"我不想知道原理,我只想跑起来。"
行,给你一份等价的 LLaMA-Factory 配置——
# qwen3-8b-qlora.yaml model_name_or_path: Qwen/Qwen3-8B stage: sft do_train: true finetuning_type: lora lora_target: q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj lora_rank: 16 lora_alpha: 32 lora_dropout: 0.05 quantization_bit: 4 quantization_method: nf4 dataset: alpaca_zh template: qwen cutoff_len: 1024 output_dir: ./qwen3-8b-qlora per_device_train_batch_size: 1 gradient_accumulation_steps: 8 learning_rate: 1e-4 num_train_epochs: 3 warmup_ratio: 0.03 bf16: true一行命令跑起来——
llamafactory-cli train qwen3-8b-qlora.yaml什么时候用懒人方案
✅适合用 LLaMA-Factory:
- 快速验证想法("这个数据集值不值得训")
- 不想懂底层、只要结果
- 跑标准流程、不折腾
❌必须回到底层:
- 训练出 bug 需要排查(yaml 报错你看不懂)
- 要定制非标准流程(比如自定义 loss)
- 面试被问"QLoRA 的 target_modules 挂了哪些"
我的建议——先用 LLaMA-Factory 跑通一遍找信心,再用底层重跑一遍找理解。两条腿走路,最稳。
PART 08:效果对比与避坑总结
不同 r / 不同 lr 下的效果差异
我用同一份 Alpaca-zh 子集(5000 条)做过对比——
| 配置 | 训练 loss | 生成质量 | 评价 |
|---|---|---|---|
| r=8, lr=2e-5 | 1.5(不降) | 没变化 | 抄默认值的悲剧 |
| r=8, lr=1e-4 | 0.9 | 能响应指令,回答偏短 | 轻量够用 |
| r=16, lr=1e-4 | 0.7 | 响应好,回答流畅 | 推荐配置 |
| r=32, lr=1e-4 | 0.6 | 接近 r=16,略过拟合 | 边际递减 |
| r=64, lr=2e-5 | 1.3(不降) | 没变化 | r 大 lr 小,走不动 |
| r=64, lr=2e-4 | 0.5 | 过拟合,开始胡说 | 太激进 |
看出规律没?
r 和 lr 是联动旋钮。光调 r 不调 lr,等于换了个更大的脑子但没给它吃饭。
Day09 留的 5 个坑,一次盘点
回到 Day09 结尾我埋的 5 个坑——
- chat template 拼错→ 用
apply_chat_template,别手拼(PART 02) - target_modules 漏挂→ print 架构,attention + MLP 全挂(PART 02)
- lr 抄全量微调的值→ QLoRA 用 1e-4,不是 2e-5(PART 03,本文最大的坑)
- 4bit 量化质量损失→ 默认 NF4 + double quant,效果差再退 8bit(PART 03)
- 合并后输出乱码→ template 对齐 + tokenizer 配置 + merge 验证(PART 06)
5 个坑,今天全填完了。
什么时候该上 DPO/RLHF
SFT 解决的是"会不会回答"。
但有些问题 SFT 解决不了——
- 模型回答太啰嗦("作为 AI 语言模型,我认为...")
- 模型回答有害(教人做坏事)
- 模型回答不一致(同一个问题不同时候答得不一样)
这些是对齐问题,需要RLHF 或 DPO——基于人类偏好再训一轮。
这就是 Day10 之后的下一站。先把 SFT 跑通,再谈对齐。
结尾:跑通是入场券,调对才是本事
最后说句掏心窝子的话——
跑通一次 QLoRA 微调,真的不难。网上教程一抓一大把,复制粘贴半小时就能跑起来。
但跑通和调对,中间隔着一万个 loss 曲线。
我见过太多人,照着教程跑通了就以为"我会 SFT 了"。一问"为什么 lr 用 1e-4"、"train 降 val 不降怎么办",瞬间哑火。
模型不会因为你跑通了代码就更聪明,只会因为你读懂了 loss 而更听话。
这才是 SFT 实操真正的门槛——不是代码,是听懂模型在说什么。
跑通是入场券,调对才是本事。
下一篇我们往哪走?RLHF 还是 DPO,你说了算。
互动时间:你第一次跑 SFT 时栽在哪个坑里?评论区聊聊,我把高赞的坑整理成"读者踩坑合集"。
下一篇预告:Day11——把"会回答"的模型,调成"答得好"的助手(RLHF/DPO 二选一)
— END —