微调:SFT / LoRA / 数据构造
微调改变的是「行为」,不是「知识」。 记住这句,能避开 80% 的失败。
一、先做决定:要不要微调
| 你想要的 | 该做什么 |
|---|---|
| 模型知道我的文档/数据库 | RAG |
| 模型按固定格式输出 | Prompt + 结构化输出 |
| 模型说话像我们的客服/编辑 | 微调 |
| 模型稳定完成一类窄任务、且要便宜 | 微调小模型 + 蒸馏 |
| 模型会用我的工具 | Agent / function calling,不用训 |
| 降成本降延迟 | 量化 + 小模型,不是微调 |
微调塞知识的典型翻车
微调模型读一遍《产品手册》后再问细节,它会用非常专业、非常自信的口吻编造。 因为微调教的是「这类问题的回答长什么样」,不是「这些事实是什么」。 知识用 RAG,风格用微调。
二、三种粒度
| 方式 | 训练参数 | 显存(7B) | 效果 | 用途 |
|---|---|---|---|---|
| 全参数微调 | 100% | 80GB+ | 上限最高 | 资源充足、领域差异大 |
| LoRA | 0.1%–1% | 16–24GB | 接近全参 | 默认选择 |
| QLoRA | 0.1%–1%(4bit 基座) | 8–16GB | 略低于 LoRA | 单卡消费级显卡 |
LoRA 原理(一句话)
冻结原权重 $W$,在旁边加一个低秩旁路:$W' = W + \frac{\alpha}{r}BA$,其中 $B$ 是 $d\times r$、$A$ 是 $r\times k$,$r \ll d$。
- 只需训练 $A, B$(参数量从 $d\times k$ 降到 $r(d+k)$)
- 训练完可把 $BA$ 合并回 $W$,推理零额外开销
| 参数 | 含义 | 常用值 |
|---|---|---|
r(rank) | 旁路秩,表达力 | 8 / 16 / 32 / 64(任务越复杂越大) |
alpha | 缩放系数,常取 2r | 16 / 32 |
dropout | 防过拟合 | 0.05–0.1 |
target_modules | 加在哪些层 | 通常全部 q/k/v/o + gate/up/down |
三、数据构造:成败的 90% 在这里
一句话
数据质量 > 数据数量 > 模型选择 > 超参数。 1000 条精心构造的数据,胜过 10 万条爬来的脏数据。
一条 SFT 数据的结构
json
{
"messages": [
{"role": "system", "content": "你是某电商平台的售后助手,语气克制专业。"},
{"role": "user", "content": "买的手机屏幕有坏点,能换吗?"},
{"role": "assistant", "content": "可以的。签收 15 天内出现屏幕坏点属于质量问题,支持换新。请提供订单号与坏点位置照片,我为您发起换货申请。"}
]
}数据构造五条硬标准
| 标准 | 说明 | 反例 |
|---|---|---|
| 一致性 | 同样的输入,回答风格统一 | 有的很客气有的很生硬 |
| 正确性 | 事实必须对,最好有人工校验 | 编造政策条款 |
| 多样性 | 覆盖真实输入的各种表达 | 1000 条全是「怎么退款」 |
| 长度合理 | 与你期望的输出长度分布匹配 | 全是 500 字长文,实际要 2 句话 |
| 无泄漏 | 测试样本不能出现在训练集 | 同一问题换个标点算新样本 |
数据从哪来(按性价比排序)
| 来源 | 成本 | 质量 | 建议 |
|---|---|---|---|
| 真实客服/业务日志(人工精修) | 中 | 最高 | 首选 |
| 强模型生成 + 人工抽检 | 低 | 中高 | 主力方案 |
| 强模型生成 + 规则过滤 | 极低 | 中 | 冷启动 |
| 公开数据集 | 零 | 参差 | 只做补充 |
| 蒸馏(大模型答小模型学) | 低 | 高 | 成本敏感场景 |
用强模型造数据的正确姿势
python
PROMPT = """你是资深{domain}专家。请针对下面的用户问题,写出符合以下要求的回答:
1. 先给结论,再给依据
2. 涉及政策时只引用 <policy> 中的条款,不得编造
3. 长度 60–150 字
4. 语气:专业、克制、不承诺超出政策的内容
<policy>
{policy_text}
</policy>
用户问题:{question}
回答:"""关键:造数据时也要给它 RAG 上下文,否则生成的内容会脱离你的业务事实。
数据量与配比
| 目标 | 数据量 | 训练轮次 |
|---|---|---|
| 调风格/口吻 | 200–500 条 | 1–3 epoch |
| 窄任务(分类/抽取) | 500–3000 条 | 2–3 epoch |
| 复杂指令跟随 | 5000–50000 条 | 1–2 epoch |
一个易被忽略的原则
保留 5%–10% 的通用数据混进去,能显著缓解灾难性遗忘(模型忘了怎么说人话)。
四、动手:QLoRA 微调一个 7B
python
# pip install transformers peft trl bitsandbytes datasets accelerate
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
base = "Qwen/Qwen2.5-7B-Instruct"
tok = AutoTokenizer.from_pretrained(base, trust_remote_code=True)
tok.pad_token = tok.eos_token
bnb = BitsAndBytesConfig(
load_in_4bit=True, bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
model = AutoModelForCausalLM.from_pretrained(
base, quantization_config=bnb, device_map="auto", trust_remote_code=True)
model = prepare_model_for_kbit_training(model)
lora = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05, bias="none",
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() # 通常 ~0.5%
args = SFTConfig(
output_dir="out/lora", num_train_epochs=2,
per_device_train_batch_size=2, gradient_accumulation_steps=8, # 等效 bs=16
learning_rate=1e-4, lr_scheduler_type="cosine", warmup_ratio=0.03,
logging_steps=10, save_strategy="epoch", bf16=True,
max_length=1024, dataset_text_field="text", report_to="none")
trainer = SFTTrainer(model=model, args=args,
train_dataset=train_ds, tokenizer=tok)
trainer.train()
trainer.save_model("out/lora/final")合并权重(推理时用)
python
from peft import PeftModel
from transformers import AutoModelForCausalLM
base_model = AutoModelForCausalLM.from_pretrained(base, torch_dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base_model, "out/lora/final").merge_and_unload()
merged.save_pretrained("out/merged")
tok.save_pretrained("out/merged")也可以不合并,用 vLLM 直接加载 LoRA adapter(多 adapter 热切换时更方便)。
五、微调故障排查
| 症状 | 原因 | 处理 |
|---|---|---|
| loss 降到 0.1 但回答很差 | 过拟合 / 数据重复 | 加数据多样性、降 epoch、加 dropout |
| 模型变笨,通用能力崩 | 灾难性遗忘 | 混入通用数据、降 lr、减 epoch、LoRA 而已 |
| 回答被截断 | max_length 太小 | 提高截断长度,或过滤长样本 |
| 重复输出同一句 | 数据里有大量重复样本 | 去重,加 repetition penalty |
| 格式对但内容空洞 | 数据回答质量低 | 换数据,不是调参 |
| loss 不降 | lr 太小 / target_modules 太少 / 数据格式错 | 检查模板与 mask |
| 显存 OOM | 序列太长 / batch 太大 | 梯度累积 + 降 max_length |
关于 loss 曲线
SFT 的 loss 降到 0.5 以下往往已经过拟合。看 loss 不如看评测:每 epoch 跑一次固定测试集。
六、怎么证明微调有价值
不要只报 loss。必须做三方对比:
| 方案 | 准确率 | 格式合法率 | 平均延迟 | 单次成本 |
|---|---|---|---|---|
| Prompt-only(大模型) | 82% | 94% | 2.1s | ¥0.030 |
| Prompt-only(小模型) | 61% | 88% | 0.6s | ¥0.002 |
| 微调后(小模型) | 88% | 99.5% | 0.6s | ¥0.002 |
这张表才是微调的成果 —— 「比大模型准、比小模型原来强 27 个点、成本降 15 倍」。
七、什么时候该停下来
出现以下情况,说明微调不是正确答案:
- 失败样本的共同点是「模型根本不知道这个事实」→ 用 RAG
- 训练 3 轮后验证集不再提升,且错误类型分散 → 数据质量到顶,去补数据
- 你已经调了两周超参,指标只涨 1 个点 → 收益已尽,去做工程优化