实操 07 微调实战:LoRA / QLoRA 从数据到部署
所属:AI 知识图谱 → 实操系列(practice/)。02 篇回答了"何时该微调、何时不该",本篇回答"决定要微调后,数据怎么造、显存够不够、训完怎么部署"。
速览卡
| 项 | 内容 |
|---|---|
| 一句话定位 | 跑通一次完整的小规模 LoRA 微调,并用评测集(而非感觉)证明它到底有没有变好 |
| 核心内容 | 数据集构造与清洗 checklist、LoRA/QLoRA 原理一页版、显存估算、训练全流程、训练前后评测对比、合并权重与部署 |
| 前置知识 | 实操 01、实操 05 评测集构建(微调的成败要用评测判定) |
| 读完能做到 | 独立完成一次领域微调,并判断"这次微调值不值、该不该上生产" |
| 配套正文 | 微调 vs RAG vs 提示词的决策树、数据质量 5 要点见 02 篇 |
⚠️ 开篇提醒:微调是三种增强手段里成本最高、最容易白做的一种。02 篇的判断是——知识更新用 RAG,格式/风格/行为用微调。跑本文之前先确认你的问题属于后者,否则大概率白花钱。
一、先确认:你的问题该不该微调
你的问题是"模型不知道某件事"? ──是─▶ RAG(不要微调)
↓ 否
是"输出格式/风格/行为不稳定"? ──是─▶ 微调(合适)
↓ 否
是"要极低成本跑特定窄任务"? ──是─▶ 微调小模型蒸馏(合适)
↓ 否
重新想清楚你要什么,大概率提示词工程就够了 ──▶ 实操 01 / 02适合微调的三类典型场景:
| 场景 | 例子 | 为什么 RAG 不行 |
|---|---|---|
| 稳定输出风格/人设 | 客服话术、品牌语气 | 风格是"行为"不是"知识",检索给不了 |
| 固定格式的结构化输出 | 病历→结构化字段、日志→分类 | 提示词能部分解决,微调后更稳且更省 Token |
| 窄领域专项能力 | 法律条款抽取、代码规范检查 | 需要大量隐性判断模式 |
不适合的:事实知识(会过时、不如 RAG 可更新)、需要溯源的场景(微调后无法引用来源)。
二、数据集:决定微调成败的 80%
2.1 数据格式(指令微调的标准形态)
{"instruction":"把下面的客户反馈分类并给出处理建议","input":"你们 app 一打开就闪退,气死了","output":"{\"sentiment\":\"负面\",\"category\":\"稳定性-崩溃\",\"priority\":\"P0\",\"suggestion\":\"立即排查最近版本的崩溃日志,联系用户获取设备型号与系统版本\"}"}
{"instruction":"把下面的客户反馈分类并给出处理建议","input":"希望增加深色模式","output":"{\"sentiment\":\"中性\",\"category\":\"功能建议\",\"priority\":\"P3\",\"suggestion\":\"记录需求,排入下个迭代评估\"}"}对话式任务用 messages 格式(与 API 调用结构一致):
{"messages":[{"role":"system","content":"你是严谨的法务助手"},{"role":"user","content":"这份合同里违约金条款有效吗"},{"role":"assistant","content":"根据《民法典》第五百八十五条……(仅当提供了具体条款内容时才能判断,当前信息不足)"}]}2.2 数据质量 checklist(展开 02 篇 5 要点)
- [ ] 数量:风格/格式类任务 200~1000 条即可见效;不要迷信"越多越好",1000 条高质量 > 10000 条噪声
- [ ] 一致性:同类问题的输出格式必须完全一致(字段名、顺序、语气)。不一致的数据会让模型学出随机性
- [ ] 多样性:覆盖边界与反例(含"信息不足时拒答"的样本),否则模型会变成"永远输出某个固定答案"
- [ ] 真实性:优先用真实业务数据(脱敏后),人工编造的数据分布往往与线上不符
- [ ] 去重与清洗:去重复、去含 PII 的样本、修正明显错误标注——脏数据比数据少更致命
- [ ] 留出评测集:从数据里随机留 10% 不参与训练,作为狭义效果验证(真正的效果判定还要靠实操 05 的业务评测集)
2.3 一个数据构造的可复用手段
用强模型生成候选 → 人工/规则校验 → 抽检。但要警惕:用模型生成数据微调模型,容易把它的偏见放大,必须人工抽检(建议 ≥ 50 条)。
三、LoRA / QLoRA:原理一页版
核心思想:不改动原模型权重(冻结),只在旁边训练一个"低秩增量" ΔW。
原权重 W (冻结,不训练)
输出 h = W·x + ΔW·x
└─ ΔW = B·A,A: r×d, B: d×r,r 通常 8~64(远小于 d)| 概念 | 含义 | 取值经验 |
|---|---|---|
r(秩) | 低秩矩阵的中间维度,越大表达力越强 | 风格/格式任务 8~16;复杂能力 32~64 |
alpha | 缩放系数 | 常设 2×r |
target_modules | 对哪些层加适配器 | 通常 q_proj,v_proj;想要更强加全部线性层 |
| QLoRA | 基座模型 4-bit 量化 + LoRA | 显存省一半以上,是消费级显卡的唯一可行路径 |
收益:可训练参数降到原模型的 0.1%~1%,显存主要花在基座前向/反向,而不是优化器状态。
显存估算(对照实操 03)
训练显存 ≈ 基座权重显存(FP16 或 QLoRA 4bit)
+ 激活值(与 batch×seq_len 成正比,常是隐藏大头)
+ LoRA 参数与梯度(很小,可忽略)
+ 优化器状态(很小)粗略参考(单卡、seq_len 1024、batch 1~2):
| 模型 | QLoRA(4bit) 训练 | LoRA(FP16) 训练 |
|---|---|---|
| 7B | ~8~10 GB | ~20~24 GB |
| 13~14B | ~14~16 GB | ~40 GB+ |
| 32B | ~26~30 GB | 需多卡 |
显存不够时的降序手段:缩短 max_seq_len → 减 batch + 加 gradient_accumulation → 开梯度检查点(省显存换速度)→ 换 QLoRA → 换更小基座。
四、训练全流程(以 QLoRA 为例)
pip install torch transformers peft trl bitsandbytes datasets accelerate# finetune.py —— 骨架(具体 API 以当期版本为准 [易变])
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
BASE = "Qwen/Qwen2.5-7B-Instruct" # [易变]
# ① 4-bit 量化加载基座
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4 是 QLoRA 论文推荐
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
BASE, quantization_config=bnb, device_map="auto")
model = prepare_model_for_kbit_training(model)
# ② 挂 LoRA 适配器
lora = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora)
model.print_trainable_parameters() # 确认可训练参数占比(应 < 1%)
# ③ 数据(JSONL 转 dataset;train/validation 切分见 2.2)
ds = load_dataset("json", data_files={"train": "train.jsonl", "validation": "val.jsonl"})
def format_row(row): # 统一成模型认识的文本格式
if "messages" in row:
return {"text": tokenizer.apply_chat_template(row["messages"], tokenize=False)}
return {"text": f"### 指令:\n{row['instruction']}\n### 输入:\n{row.get('input','')}\n### 输出:\n{row['output']}"}
tokenizer = AutoTokenizer.from_pretrained(BASE)
ds = ds.map(format_row)
# ④ 训练参数
args = TrainingArguments(
output_dir="./out",
per_device_train_batch_size=1,
gradient_accumulation_steps=8, # 等效 batch=8,显存不够时调大
num_train_epochs=3, # 小数据集 2~4 轮;过拟合就减
learning_rate=1e-4, # LoRA 常用 1e-4 ~ 2e-4
lr_scheduler_type="cosine",
logging_steps=10,
eval_strategy="steps", eval_steps=50, # 必须开验证,看是否过拟合
save_strategy="steps", save_steps=50,
bf16=True,
gradient_checkpointing=True, # 省显存
max_grad_norm=0.3,
)
trainer = SFTTrainer(
model=model, args=args,
train_dataset=ds["train"], eval_dataset=ds["validation"],
max_seq_length=1024,
)
trainer.train()
# ⑤ 保存适配器(只有几十~几百 MB)
model.save_pretrained("./out/lora-adapter")
tokenizer.save_pretrained("./out/lora-adapter")四个最容易踩的坑:
| 坑 | 症状 | 解法 |
|---|---|---|
learning_rate 沿用全参数微调的 1e-5 | 训不动,loss 几乎不变 | LoRA 用 1e-4 量级 |
| epoch 太多 | 训练 loss 狂降但验证 loss 反弹 | 开 eval_strategy,验证 loss 连续上升就停 |
| 数据格式与推理时不一致 | 训练效果好,上线效果差 | 训练时的 prompt 模板必须和线上调用完全一致(含 system、分隔符) |
| 只存了适配器丢了基座版本 | 几个月后无法复现 | 记录基座模型名+版本号+数据版本+超参,一并存档 |
五、训完了,怎么证明它变好了
只说"感觉更准了"不算数。 用实操 05的评测集,跑三次对比:
# compare.py —— 基座 vs 微调后,同一评测集
def compare(eval_cases, predict_base, predict_ft):
for name, fn in (("基座", predict_base), ("微调后", predict_ft)):
outs = [fn(c["input"]) for c in eval_cases]
ok = sum(check(c, o)[0] for c, o in zip(eval_cases, outs))
print(f"{name}: {ok}/{len(eval_cases)} ({ok/len(eval_cases):.0%})")判定标准(三条都要看):
- 目标能力提升:评测集通过率确实提高(通常期待 +10% 以上,否则不值得)
- 没有灾难性遗忘:在通用问答子集上的表现不能明显下降(微调常会损害通用能力,这是真实代价)
- 成本账:微调后能否用更小的模型/更短的提示词达到同样效果?如果能,省下的推理费可能覆盖训练成本(对照 07 篇经济账)
一个残酷但常见的结论:如果提升不到 10%,先用 RAG 或提示词工程再试一轮,微调很可能是亏的。
六、部署:合并还是不合并
| 方式 | 做法 | 适用 |
|---|---|---|
| 合并权重(推荐给固定场景) | 把 LoRA 增量加回基座,导出完整模型 | 只服务一个业务、追求推理速度(无适配器开销) |
| 保留适配器 + 多适配器切换 | 基座常驻,按请求加载不同 adapter | 多个客户/多任务共用基座,切换成本低 |
# 合并(PEFT 标准流程 [易变])
from peft import AutoPeftModelForCausalLM
merged = AutoPeftModelForCausalLM.from_pretrained("./out/lora-adapter")
merged = merged.merge_and_unload() # 合并成普通模型
merged.save_pretrained("./out/merged-model")合并后就是一个普通模型,按实操 03用 vLLM/Ollama 部署即可,与部署开源模型没有区别:
vllm serve ./out/merged-model --max-model-len 4096版本管理建议:每个适配器目录里放一份 meta.json(基座版本、数据版本、超参、评测通过率)——否则三个月后你完全无法回答"线上这个模型是哪次训的"。
七、动手任务
- [ ] 任务 1(零成本预演):先不做微调,用 20 条样本写提示词(few-shot)看能达到多少通过率。这是微调的基线——如果 few-shot 已经 90%,微调的收益空间就很小了
- [ ] 任务 2(数据先行):为你自己的场景构造 200 条数据,严格过一遍 2.2 的 checklist,重点检查"同类输入输出格式是否完全一致"
- [ ] 任务 3(跑通训练):用 QLoRA 在 7B 基座上跑通
finetune.py,记录:可训练参数占比、训练耗时、显存峰值、trainable params打印结果 - [ ] 任务 4(判定价值):用评测集对比基座 vs 微调后,并额外测一组通用问答验证有没有灾难性遗忘。给出"值不值得上线"的结论
- [ ] 任务 5(进阶):做 epoch=1 / 2 / 4 三次对比,画出"训练轮数 vs 目标通过率 vs 通用能力"的曲线,找到你的拐点
- [ ] 自检:能不看资料说出——什么情况下绝不该微调?LoRA 的 r 怎么选?为什么训练模板必须和线上调用一致?判定微调成功的三条标准?
延伸方向
- 还不确定该不该微调(最重要的一步)→ 02 篇 · 三路线决策树
- 怎么客观判定效果 → 实操 05 评测集构建与回归
- 训完怎么部署 → 实操 03 模型本地部署与推理优化
- 训练所需硬件与成本账 → 07 篇 · 算力与硬件底座
- 微调数据的合规与脱敏要求 → 09 篇