一、为什么要做这次微调
先交代背景。我手上有一个垂直领域的问答场景,基座是Qwen2.5-7B-Instruct。直接用原始模型测试,在领域术语理解和固定输出格式上表现一般:我构造了200条测试集,人工评估准确率只有54%,主要问题集中在三类——专业术语答错、输出格式不统一(有时带markdown有时不带)、以及偶尔"过度发挥"编造细节。
全量微调7B模型对我不现实:单卡4090,全参训练即使开ZeRO也要多卡,成本扛不住。所以选择LoRA。这里顺带说一句QLoRA:它通过4-bit量化基座进一步压缩显存,代价是训练速度变慢、精度略有损失。我的场景24GB显存跑LoRA(bf16)已经够用,就没上QLoRA,但后文会给出QLoRA的配置差异。
二、环境与版本
环境这块我踩过版本坑,先把确定可用的组合列出来:
- GPU:RTX 4090 24GB,驱动 550.54
- CUDA 12.1
- Python 3.10.13
- torch 2.3.1 + cu121
- transformers 4.44.2
- peft 0.12.0
- trl 0.9.6
- datasets 2.20.0
- accelerate 0.33.0
- bitsandbytes 0.43.3(QLoRA时需要)
重点提醒:peft 和 transformers 版本要匹配,我一开始用 peft 0.11 + transformers 4.44 出现过 target_modules 识别不到的问题,升到 0.12.0 后正常。
三、数据准备
数据格式我采用对话格式(messages),因为基座是Instruct模型,保持和它预训练对齐的模板最省事。每条样本长这样:
{
"messages": [
{"role": "system", "content": "你是一个XX领域助手,回答需简洁准确。"},
{"role": "user", "content": "问题..."},
{"role": "assistant", "content": "标准答案..."}
]
}
数据处理代码(截断 + 应用chat template):
from datasets import load_dataset
from transformers import AutoTokenizer
model_path = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
def format_and_tokenize(example, max_len=1024):
text = tokenizer.apply_chat_template(
example["messages"],
tokenize=False,
add_generation_prompt=False
)
out = tokenizer(
text,
truncation=True,
max_length=max_len,
padding=False
)
return out
ds = load_dataset("json", data_files={"train": "train.jsonl", "valid": "valid.jsonl"})
ds = ds.map(format_and_tokenize, remove_columns=ds["train"].column_names)
数据量分配:训练集12000条,验证集800条。我特意控制了单条长度,95分位在780 token左右,max_len设1024足够,避免padding浪费。
一个容易忽略的点:assistant部分的loss才应该被计算。trl 的 SFTTrainer 在较新版本里支持 DataCollatorForCompletionOnlyLM,但用messages格式时更推荐直接用官方的对话collator,它会自动只对assistant token计算loss。我早期版本手动mask,写过一堆bug,后来直接交给trainer。
四、训练配置
LoRA的核心参数:rank=16,alpha=32,dropout=0.05。target_modules 我覆盖了 attention 的 q/k/v/o 和 MLP 的 gate/up/down,全线性层都挂上,效果比只挂q/v明显。
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,
attn_implementation="flash_attention_2", # 需要装flash-attn
)
model.gradient_checkpointing_enable()
model.enable_input_require_grads()
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj","k_proj","v_proj","o_proj",
"gate_proj","up_proj","down_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.527
可训练参数约4037万,占比0.527%。
训练参数:
args = TrainingArguments(
output_dir="./qwen_lora_out",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效batch=16
learning_rate=1e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
num_train_epochs=3,
bf16=True,
logging_steps=10,
save_strategy="epoch",
eval_strategy="epoch",
gradient_checkpointing=True,
optim="adamw_torch",
report_to="tensorboard",
max_seq_length=1024,
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=ds["train"],
eval_dataset=ds["valid"],
)
trainer.train()
学习率1e-4是我试出来的:5e-5收敛太慢,3个epoch验证loss还在1.1以上;2e-4又震荡明显。1e-4配合cosine和3% warmup比较稳。
五、踩坑与优化
坑1:显存OOM。 一开始batch=4、没开gradient checkpointing,直接爆。开checkpointing后batch降到2,配合梯度累积8,显存峰值19.3GB,稳定。
坑2:loss不下降。 发现是数据里有大量重复样本(爬的问答有冗余),去重后从15000条降到12000条,loss曲线立刻正常。
坑3:推理时输出重复。 训练时没加 repetition_penalty,推理时加了1.1解决。
坑4:QLoRA的速度。 我对比过QLoRA(4-bit)配置,同样数据单epoch要2.6小时,LoRA(bf16)只要1.5小时,慢约70%。如果显存紧张再选QLoRA。
六、效果数据
loss曲线(训练集/验证集,节选关键点):
| epoch | train_loss | eval_loss |
|---|---|---|
| 0.5 | 1.18 | 1.21 |
| 1 | 0.96 | 1.02 |
| 2 | 0.79 | 0.90 |
| 3 | 0.74 | 0.87 |
验证loss从初始1.42降到0.87,没有明显过拟合(train和eval差距0.13,可接受)。
推理效果对比,用同一批200条领域测试集:
- 基线Qwen2.5-7B-Instruct:准确率54%,格式合规率61%
- LoRA微调后:准确率83%,格式合规率97%
举一个具体case:
问:"XX工艺的临界温度是多少?"
- 基线回答:给了一个泛泛的、数值错误的答案,还带了一段无关解释。
- 微调后:直接给出正确数值,两句话,格式统一。
推理代码(合并LoRA权重或直接加载adapter):
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
base = AutoModelForCausalLM.from_pretrained(
model_path, torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./qwen_lora_out/checkpoint-2250")
model = model.merge_and_unload() # 合并后可当普通模型用
tokenizer = AutoTokenizer.from_pretrained(model_path)
messages = [
{"role": "system", "content": "你是一个XX领域助手,回答需简洁准确。"},
{"role": "user", "content": "XX工艺的临界温度是多少?"},
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
out = model.generate(**inputs, max_new_tokens=256,
do_sample=False, repetition_penalty=1.1)
print(tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))
七、总结
这次LoRA微调整体符合预期:0.5%的可训练参数拿到了29个点的准确率提升,单卡4.5小时完成。几点经验:数据质量比数据量重要,去重和格式对齐收益最大;target_modules全挂线性层比只挂q/v效果好;学习率1e-4配cosine是个稳妥起点。QLoRA适合显存更紧的场景,但速度代价要提前评估。下一步我打算试试 rank=32 和更长序列(2048)看还有没有提升空间。