Skip to content

实操 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 数据格式(指令微调的标准形态) ​

jsonl
{"instruction":"把下面的客户反馈分类并给出处理建议","input":"你们 app 一打开就闪退,气死了","output":"{\"sentiment\":\"负面\",\"category\":\"稳定性-崩溃\",\"priority\":\"P0\",\"suggestion\":\"立即排查最近版本的崩溃日志,联系用户获取设备型号与系统版本\"}"}
{"instruction":"把下面的客户反馈分类并给出处理建议","input":"希望增加深色模式","output":"{\"sentiment\":\"中性\",\"category\":\"功能建议\",\"priority\":\"P3\",\"suggestion\":\"记录需求,排入下个迭代评估\"}"}

对话式任务用 messages 格式(与 API 调用结构一致):

jsonl
{"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 为例) ​

bash
pip install torch transformers peft trl bitsandbytes datasets accelerate
python
# 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的评测集,跑三次对比:

python
# 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%})")

判定标准(三条都要看):

  1. 目标能力提升:评测集通过率确实提高(通常期待 +10% 以上,否则不值得)
  2. 没有灾难性遗忘:在通用问答子集上的表现不能明显下降(微调常会损害通用能力,这是真实代价)
  3. 成本账:微调后能否用更小的模型/更短的提示词达到同样效果?如果能,省下的推理费可能覆盖训练成本(对照 07 篇经济账)

一个残酷但常见的结论:如果提升不到 10%,先用 RAG 或提示词工程再试一轮,微调很可能是亏的。


六、部署:合并还是不合并 ​

方式做法适用
合并权重(推荐给固定场景)把 LoRA 增量加回基座,导出完整模型只服务一个业务、追求推理速度(无适配器开销)
保留适配器 + 多适配器切换基座常驻,按请求加载不同 adapter多个客户/多任务共用基座,切换成本低
python
# 合并(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 部署即可,与部署开源模型没有区别:

bash
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 怎么选?为什么训练模板必须和线上调用一致?判定微调成功的三条标准?

延伸方向 ​