一、问题背景:为什么我最终选了LoRA而不是全参微调
事情起因很简单:我们有个内部知识问答场景,基座用Qwen2.5-7B-Instruct效果“能答但不专业”,术语经常答偏,格式也不稳定。全参微调听起来最彻底,但算了一笔账就放弃了——7B模型全参微调,即使开ZeRO-3加CPU offload,单卡4090也基本跑不动,租A100 80G×4的成本对一个小团队来说不现实。
更现实的问题是:我们只有约4000条高质量样本。全参微调在这种数据量下极易过拟合,且灾难性遗忘明显(通用能力掉点)。而LoRA只训练低秩旁路矩阵,冻结原模型权重,天然适合“小数据+保留通用能力”的诉求。QLoRA则进一步把基座量化到4bit,让24GB卡也能舒服地跑7B。
最终选型:LoRA为主,QLoRA作为显存不足时的备选。本文主线用LoRA(bf16),因为4090跑7B的LoRA在24GB下刚好够,量化会带来额外精度损失,没必要。
二、环境与版本
环境这块踩过版本坑,先把确定能跑通的组合列出来:
- GPU:RTX 4090 24GB(单卡)
- CUDA 12.1 / PyTorch 2.3.1
- transformers 4.42.3
- peft 0.11.1
- trl 0.9.6
- bitsandbytes 0.43.1(QLoRA备用时用)
- accelerate 0.31.0
- Python 3.10
提醒一句:peft和trl的版本耦合比较紧,trl 0.9.x 配 peft 0.11.x 是验证过的组合,别随便升到最新,我一开始用peft 0.12+trl 0.9.6出现过SFTTrainer对peft_config参数处理不一致的报错。
三、方案设计
整体思路:数据格式化 → LoRA注入 → SFTTrainer训练 → 合并/加载适配器推理对比。
关键配置决策:
- LoRA秩 r=16,alpha=32(alpha=2r是常见经验值),dropout=0.05
- target_modules 选
q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj(全线性层注入,比只注q/v效果更稳,参数量仍很小) - 学习率 2e-4,cosine调度,warmup_ratio=0.03
- batch_size=2,gradient_accumulation=8(等效16)
- max_seq_length=1024(我们的样本大多在600 token内,截断率<3%)
- 训练2个epoch,bf16混合精度
数据准备上,我把原始数据整理成Alpaca风格的三字段(instruction/input/output),然后用Qwen的chat template拼成对话格式。这一步很关键——模板必须和基座训练时一致,否则模型会“学歪”。
四、核心实现
4.1 数据准备代码
import json
from datasets import Dataset
from transformers import AutoTokenizer
MODEL_PATH = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
def build_sample(item):
# item: {"instruction":..., "input":..., "output":...}
user_content = item["instruction"]
if item.get("input"):
user_content += "\n" + item["input"]
messages = [
{"role": "user", "content": user_content},
{"role": "assistant", "content": item["output"]},
]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=False
)
return {"text": text}
with open("train.jsonl", "r", encoding="utf-8") as f:
raw = [json.loads(line) for line in f]
dataset = Dataset.from_list([build_sample(x) for x in raw])
split = dataset.train_test_split(test_size=0.05, seed=42)
train_ds, eval_ds = split["train"], split["test"]
print(f"train={len(train_ds)}, eval={len(eval_ds)}")
# 输出: train=3610, eval=190
4.2 LoRA训练代码
import torch
from transformers import AutoModelForCausalLM, TrainingArguments
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer
model = AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=True,
)
model.gradient_checkpointing_enable()
model.enable_input_require_grads() # 配合gradient checkpointing
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"],
bias="none",
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 20,185,088 || all params: 7,635,900,416 || trainable%: 0.2643
args = TrainingArguments(
output_dir="./lora_out",
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
num_train_epochs=2,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_strategy="steps",
save_steps=200,
save_total_limit=2,
bf16=True,
gradient_checkpointing=True,
optim="paged_adamw_8bit",
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=train_ds,
eval_dataset=eval_ds,
dataset_text_field="text",
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.model.save_pretrained("./lora_adapter")
tokenizer.save_pretrained("./lora_adapter")
注意 packing=False:我们样本长度分布不均,packing虽然能提吞吐,但会让loss统计和截断边界变复杂,调试期先关掉。
五、踩坑与优化
几个真实踩过的坑,按痛苦程度排序:
坑1:gradient checkpointing 导致 loss 不下降。 开了checkpointing但没调 enable_input_require_grads(),LoRA参数梯度为None,loss一直平。加上那行就好了。
坑2:显存OOM。 batch=2、seq=1024、grad_accum=8时峰值显存约21.3GB,勉强够。如果seq拉到2048直接OOM。优化手段:optim="paged_adamw_8bit" 省了约1.5GB,gradient checkpointing 再省一大截(代价是训练慢约25%)。
坑3:eval loss 波动大。 一开始eval_steps=50,eval集才190条,曲线锯齿严重。改成eval_steps=100、并在eval时关掉dropout(model.eval()由trainer自动处理)后平滑很多。
坑4:学习率偏高。 试过5e-4,第1个epoch末loss就到0.7但eval loss反弹,典型过拟合。2e-4是这张卡+这份数据下的甜点。
坑5(QLoRA备选时):4bit量化 + LoRA 时,bnb_4bit_compute_dtype 要设成 bfloat16,否则在4090上会出现compute dtype不匹配的慢速路径,吞吐掉一半。
六、效果数据
Loss曲线(每100步记录一次eval loss):
| 步数 | train loss | eval loss |
|---|---|---|
| 0 | — | 1.82 |
| 100 | 1.31 | 1.24 |
| 200 | 1.06 | 1.08 |
| 300 | 0.92 | 1.01 |
| 400 | 0.85 | 0.97 |
| 451(epoch1末) | 0.81 | 0.96 |
| 600 | 0.74 | 0.94 |
| 902(epoch2末) | 0.68 | 0.94 |
可以看到epoch1末eval loss已基本收敛,epoch2主要是在train loss上继续下探(0.81→0.68),eval loss只从0.96降到0.94,边际收益很小。结论:这份数据1.5个epoch就够,2个epoch已接近过拟合边缘。
推理效果对比(同一批50条领域测试题,人工+规则双重评分):
| 指标 | 基座模型 | LoRA微调后 |
|---|---|---|
| 领域术语准确率 | 61% | 84% |
| 输出格式合规率 | 72% | 96% |
| 通用问答(MMLU子集200题) | 68.5% | 67.2% |
| 首token延迟(ms) | 142 | 153 |
| 生成吞吐(tok/s) | 78 | 72 |
通用能力只掉了1.3个点,符合预期(LoRA的灾难性遗忘确实小);延迟增加约8%,主要来自adapter的额外矩阵乘,可接受。
一个具体case:问“XX协议的握手超时重传机制”,基座答得泛泛而谈还混入了HTTP的表述;微调后能准确说出三次重传的间隔和退避策略,格式也按我们要求的“结论+要点”输出。
七、总结
这次实践最深的体会是:LoRA的调参空间比想象中小,真正决定效果的是数据和模板对齐。r=16还是32、alpha翻倍,loss曲线差异不到0.02;但chat template错一个special token,效果直接崩。另外,别迷信epoch数,盯eval loss比盯train loss有用得多——我们这份数据1.5 epoch就是最优。
后续计划:把QLoRA路径也跑一遍做对比(用4bit基座+相同LoRA配置),看量化损失在领域任务上到底有多大;另外试试rsLoRA(rank-stabilized)在r=64时是否比r=16有明显提升。有结论再写一篇。