引言
提示工程能解决大部分「让模型按格式输出」的问题,但当任务需要稳定的领域知识、固定的输出结构、特定的语气风格时,仅靠提示词就会撞上天花板:上下文塞不下、推理成本高、格式时好时坏。这时微调(Fine-tuning)才是正解。
本文聚焦参数高效微调这条主流路线:先讲清 LoRA 与 QLoRA 的原理直觉,再手把手构造指令数据、配置训练、合并权重、部署推理,最后给出评测与避坑清单。全文代码基于 HuggingFace 生态(transformers + peft + trl),单卡 24GB 显存即可跑通 7B 模型的 QLoRA 微调。
前置:迁移学习实战讲清了「预训练 + 微调」的基本范式;本文把它推进到大模型与低秩适配的工程细节。量化原理可参考 https://plumephp.com/ml-model-compression-quantization/,上线链路见 https://plumephp.com/ml-model-deployment/。
目录
- 1. 为什么微调:全参、LoRA 与提示工程的取舍
- 2. LoRA 与 QLoRA 原理
- 3. 指令数据构造与格式化
- 4. 训练配置与显存优化
- 5. 用 PEFT 跑通一次微调
- 6. 合并权重与推理部署
- 7. 评测:怎么知道微调有没有用
- 8. 常见坑与排查清单
- 9. 总结
- 延伸阅读
1. 为什么微调:全参、LoRA 与提示工程的取舍
1.1 三种适配手段的定位
| 手段 | 改动对象 | 成本 | 适用场景 |
|---|---|---|---|
| 提示工程 | 输入文本 | 零训练成本 | 格式约束、简单任务 |
| RAG | 外部知识库 | 检索链路成本 | 知识频繁更新、需要引用 |
| 微调 | 模型参数 | 训练与运维成本 | 固定风格、结构化输出、领域术语 |
三者不是互斥关系。实践中常见组合是:RAG 供事实,微调管风格与格式。
1.2 全参微调为什么贵
以 7B 模型 fp16 为例,训练时显存大致分四块:
模型权重 7B x 2B = 14 GB
梯度 7B x 2B = 14 GB
Adam 状态 7B x 8B = 56 GB ← 大头
激活值与临时 取决于 batch 与序列长度
合计 约 84 GB 起
单张 24GB 显卡连门槛都摸不到,至少要 4 张 A100 80G 做 ZeRO-3 切分。对绝大多数团队,这个成本不可接受。
1.3 LoRA 的核心收益
LoRA 只训练一对极小的低秩矩阵,冻结原权重:
| 维度 | 全参微调 | LoRA |
|---|---|---|
| 可训练参数 | 100% | 0.1%~1% |
| 优化器显存 | 权重 x 4 以上 | 与基座无关 |
| 单任务产物 | 14 GB | 10~200 MB |
| 多任务切换 | 每个任务一份完整权重 | 热插拔适配器 |
最后一行才是真正的杀手锏:一个基座 + 几十个适配器,按请求路由,工程上极其优雅。
2. LoRA 与 QLoRA 原理
2.1 低秩假设
微调的本质是「在原权重上叠加一个增量」。论文作者的观察是:这个增量矩阵是低秩的——大模型在适配下游任务时,真正需要的自由度远小于参数量。于是把增量 ΔW 分解成两个小矩阵的乘积。
2.2 数学形式
原始前向: h = W x
LoRA 前向: h = W x + (alpha / r) * B A x
W 形状 (d, k) 冻结;A 形状 (r, k) 高斯初始化;
B 形状 (d, r) 零初始化;r << min(d, k),常取 8 / 16 / 32
B 初始化为零,保证训练起点时 ΔW = 0,模型行为与基座完全一致,不会一上来就把预训练知识破坏掉。
2.3 缩放系数 alpha
alpha / r 是缩放因子。固定 alpha 而改变 r 时,等效学习率会随之漂移,因此调 r 时同步调 alpha,让 alpha / r 大致稳定:
| r | alpha | 说明 |
|---|---|---|
| 8 | 16 | 小任务、数据少 |
| 16 | 32 | 通用默认 |
| 64 | 128 | 复杂任务、数据多 |
2.4 QLoRA:4bit 量化 + LoRA
QLoRA 在 LoRA 之上叠了三项技术,把 7B 微调压进单卡 24GB:
- NF4 量化:基座权重以 4bit NormalFloat 存储,比 fp16 省 4 倍显存;
- 双重量化:对量化常数本身再做一次量化,每个参数再省约 0.37 bit;
- 分页优化器:优化器状态在显存不足时换出到内存,避免 OOM 中断训练。
前向计算时 4bit 权重临时反量化为 bf16,因此计算精度基本无损,而 LoRA 适配器始终以 bf16 训练。
2.5 该把适配器挂在哪
默认只挂注意力的 q、v 投影。若任务较难,可扩展到全部线性层:
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"]
覆盖面越大,可训练参数越多、效果上限越高,显存与过拟合风险也随之上升。
3. 指令数据构造与格式化
3.1 一条指令样本的三要素
instruction : 用户要模型做什么(任务描述)
input : 任务需要的输入(可为空)
output : 期望的模型回答(监督信号)
多轮对话则用 messages 列表:
sample = {
"messages": [
{"role": "system", "content": "你是一名严谨的法律助手"},
{"role": "user", "content": "帮我解释这条合同的免责条款"},
{"role": "assistant", "content": "该条款的含义是……"},
]
}
3.2 数据从哪来
| 来源 | 优点 | 风险 |
|---|---|---|
| 人工标注 | 质量最高 | 贵、慢 |
| 强模型蒸馏 | 快、量大 | 需清洗,含幻觉 |
| 业务日志回捞 | 贴合真实分布 | 需脱敏、噪声大 |
| 开源数据集 | 零成本起步 | 领域不匹配 |
质量远比数量重要。经验阈值:1000~5000 条高质量样本足以让 7B 模型学会一种输出格式。
3.3 用 chat template 统一格式
不同基座的对话模板不同(Llama 用特殊 token,Qwen 用 ChatML)。不要手拼字符串,交给 tokenizer:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
text = tok.apply_chat_template(
sample["messages"], tokenize=False,
add_generation_prompt=False, # 训练时不要生成提示
)
print(text)
3.4 只对回答部分计算损失
关键技巧:把 instruction 部分的 label 置为 -100(忽略),只让模型学习回答。
def mask_prompt_labels(labels, prompt_len):
labels = list(labels)
for i in range(min(prompt_len, len(labels))):
labels[i] = -100
return labels
否则模型会把大量算力花在「复述问题」上,训练信号被稀释。
4. 训练配置与显存优化
4.1 关键超参
| 超参 | 推荐起点 | 说明 |
|---|---|---|
| learning_rate | 1e-4 ~ 2e-4 | LoRA 比全参大 10 倍量级 |
| lr_scheduler | cosine | 配合 warmup 更稳 |
| warmup_ratio | 0.03 | 防止初期震荡 |
| epochs | 2 ~ 3 | 超过 3 轮易过拟合 |
| batch_size | 1~2 + 梯度累积 | 累积到等效 16~64 |
| max_seq_len | 1024 ~ 4096 | 按数据实际长度设 |
| gradient_checkpointing | True | 省显存 30%+,慢约 20% |
4.2 显存账本
7B QLoRA 在 seq=2048 时的大致占用:
4bit 基座权重 约 3.5 GB
LoRA 参数与梯度 约 0.2 GB
优化器状态 约 0.4 GB
激活值(checkpoint)约 6~10 GB
CUDA 上下文与碎片 约 1 GB
峰值 约 12~16 GB → 24GB 卡可跑
4.3 省显存的组合拳
from transformers import TrainingArguments
args = TrainingArguments(
output_dir="./out",
per_device_train_batch_size=1,
gradient_accumulation_steps=16, # 等效 batch 16
gradient_checkpointing=True, # 用计算换显存
optim="paged_adamw_8bit", # 优化器状态 8bit
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
num_train_epochs=3,
bf16=True, # 优先 bf16 而非 fp16
)
若仍 OOM,按顺序尝试:缩短 max_seq_len → 减 LoRA 层数 → 提高梯度累积 → 换更小的基座。
5. 用 PEFT 跑通一次微调
5.1 加载 4bit 基座
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
quantization_config=bnb,
device_map="auto",
)
5.2 注入 LoRA 适配器
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
model = prepare_model_for_kbit_training(model) # 让 4bit 模型可训练
lora_cfg = 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_cfg)
model.print_trainable_parameters()
# trainable params: 约 8~20M || all params: 7B || trainable%: 0.2%
5.3 训练
from transformers import Trainer
trainer = Trainer(model=model, args=args, train_dataset=ds, data_collator=collator)
trainer.train()
model.save_pretrained("./lora-adapter") # 只存适配器,几十 MB
6. 合并权重与推理部署
6.1 合并成完整权重
推理时若不合并,每步都要算两个分支,有额外开销。合并后就是一个标准模型:
from peft import PeftModel
from transformers import AutoModelForCausalLM
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct", torch_dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, "./lora-adapter").merge_and_unload()
merged.save_pretrained("./merged-7b")
合并后权重回到 bf16,约 14GB,不再有 4bit 的显存优势——显存紧张就保留适配器不合并。
6.2 用 vLLM 起服务
from vllm import LLM, SamplingParams
llm = LLM(model="./merged-7b", dtype="bfloat16", max_model_len=4096)
out = llm.generate(["解释一下什么是低秩分解"],
SamplingParams(temperature=0.7, max_tokens=256))
print(out[0].outputs[0].text)
6.3 多适配器热插拔
vLLM 支持 LoRARequest 按请求指定适配器,一个基座服务多个业务线:请求里把 model 字段写成 legal-lora 或 sql-lora,服务端加载对应适配器,无需为每个业务线部署一份完整权重。
7. 评测:怎么知道微调有没有用
7.1 三层评测
| 层级 | 手段 | 回答的问题 |
|---|---|---|
| 训练指标 | loss 曲线 | 有没有学进去 |
| 自动评测 | 格式合法率、字段命中率、ROUGE/BLEU | 输出是否符合预期结构 |
| 人工与 LLM 评审 | 盲评打分、胜率对比 | 到底比基座好多少 |
格式类任务最实用的指标是结构合法率:用正则或 JSON 解析器统计输出能否被解析。
import json, re
def structural_pass_rate(preds):
hits = 0
for p in preds:
m = re.search(r"\{.*\}", p, re.S)
try:
json.loads(m.group(0)); hits += 1
except (json.JSONDecodeError, AttributeError):
pass
return hits / len(preds)
7.2 过拟合与灾难性遗忘
两个必须盯住的信号:
- 过拟合:训练 loss 持续下降但验证集输出开始「背诵」训练样本、泛化变差。解法是减 epoch、加 dropout、扩充数据多样性。
- 灾难性遗忘:模型在学会新格式的同时,通用能力(闲聊、常识问答)明显退化。解法是混合 10%~20% 通用指令数据一起训练,而不是纯领域数据。
评测集务必留出训练时完全没见过的样本,否则数字好看但线上会翻车。
8. 常见坑与排查清单
| 现象 | 可能原因 | 处理 |
|---|---|---|
| loss 一开始就很高且不降 | 学习率过大 / 数据格式错 | 降到 1e-4,打印一条样本肉眼检查 |
| loss 降但输出胡言乱语 | 只训了适配器却忘了 chat template | 检查 apply_chat_template 是否与训练一致 |
| 输出永远带一串特殊 token | 保存时漏了 tokenizer | 用 tokenizer.save_pretrained 一起存 |
| 合并后效果变差 | 合并精度损失 / 版本不匹配 | 用 bf16 合并,锁定 peft 版本 |
| 训练集表现完美、线上崩 | 数据泄漏或过拟合 | 划分严格独立的验证集 |
| 显存溢出在 epoch 末尾 | 评测与保存同时驻留 | 关掉训练中评测,或分批保存 |
三条铁律:训练与推理的模板必须逐字符一致;验证集必须干净;先跑 100 条数据的小规模冒烟,再上全量。
9. 总结
9.1 全流程回顾
选基座 → 构造指令数据 → 套 chat template → 4bit 加载 + LoRA 注入
→ 训练(小 lr、2~3 epoch、梯度检查点)→ 合并或热插拔
→ 三层评测 → 混合通用数据防遗忘 → 上线监控
9.2 关键决策点
| 问题 | 选择 |
|---|---|
| 显存只有 24GB | QLoRA + 梯度检查点 |
| 需要多任务并行 | 保留适配器,热插拔不合并 |
| 数据只有几百条 | 降 r 到 8,减 epoch,加通用数据 |
| 输出必须严格 JSON | 评测看结构合法率,而非 loss |
| 通用能力退化明显 | 混入 10%~20% 通用指令 |
9.3 一句话心法
微调不是「再训一遍模型」,而是「用最少的新参数,把基座塑造成你要的形状」——数据质量决定上限,训练配置决定能否跑完,评测决定值不值得上线。
延伸阅读
- https://plumephp.com/ml-transfer-learning/ — 迁移学习与微调的基本范式
- https://plumephp.com/ml-model-compression-quantization/ — 量化与剪枝原理,理解 QLoRA 的 4bit 基础
- https://plumephp.com/ml-model-deployment/ — 模型上线、版本与监控全链路
- https://plumephp.com/ml-model-evaluation/ — 更系统的评估方法论
- HuggingFace PEFT 文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。