一、为什么要自己微调一个7B模型
先说结论:不是所有场景都需要微调。RAG能解决的,别上微调;Prompt能搞定的,别上微调。但如果你遇到下面这些情况,微调就是刚需:
- 领域术语太多,通用模型总是答偏,比如医疗、法律、工业协议
- 输出格式要求严格,比如必须返回固定JSON结构,Prompt调不稳
- 需要模型学会一套内部知识体系,RAG检索效果差或知识不成文档
我这次的任务是让模型理解某工业设备的故障诊断术语。用Qwen2.5-7B-Instruct做基座,直接问它“E104报警怎么处理”,它会给出泛泛的通用建议,甚至编造不存在的处理流程。RAG试过,因为设备手册是PDF表格,切分后检索召回率只有50%左右。所以决定走LoRA微调这条路。
为什么是LoRA而不是全量微调?很简单,全量微调7B模型,FP16下光权重就要14GB,加上优化器状态和梯度,单卡24GB根本不够。LoRA只训练低秩矩阵,可训练参数不到原来的1%,24GB卡绰绰有余。QLoRA更进一步,把基座量化到4bit,显存还能再降,但训练速度会慢一些。我这次用LoRA,因为4090的24GB刚好够用,QLoRA留作后续显存不够时的备选。
二、环境与版本
环境这东西,版本不对齐,坑能踩一天。我直接把我的配置列出来:
- GPU:RTX 4090 24GB
- CUDA:12.1
- PyTorch:2.3.1
- transformers:4.44.2
- peft:0.12.0
- trl:0.9.6
- bitsandbytes:0.43.3(虽然这次没用QLoRA,但装了备用)
- accelerate:0.33.0
- 基座模型:Qwen2.5-7B-Instruct
安装命令就一行:
pip install torch==2.3.1 transformers==4.44.2 peft==0.12.0 trl==0.9.6 accelerate==0.33.0 datasets==2.20.0
注意:transformers和peft的版本要匹配,我试过peft 0.11.0配transformers 4.44.2,加载LoRA权重时报key不匹配,升到0.12.0就好了。
三、方案设计
整体思路很直接:
- 数据准备:把领域问答整理成Alpaca格式,instruction/input/output三段式
- 基座加载:Qwen2.5-7B-Instruct,torch_dtype=bf16,device_map=auto
- LoRA配置:target_modules选q_proj/k_proj/v_proj/o_proj/gate_proj/up_proj/down_proj,r=16,alpha=32,dropout=0.05
- 训练:trl的SFTTrainer,3个epoch,batch_size=4,gradient_accumulation_steps=4,等效batch=16
- 推理对比:用同样的测试集,对比微调前后输出
关键参数选择理由:
- r=16:7B模型上,r=8欠拟合,r=32过拟合且显存涨,16是甜点
- alpha=32:alpha/r=2是常用比例,我试过alpha=16,loss下降慢
- target_modules:只调q/v也能work,但加上gate/up/down后效果明显更好,代价是显存多约0.8GB
- lr=2e-4:LoRA常用学习率,cosine调度,warmup_ratio=0.03
四、核心实现
4.1 数据准备
我的原始数据是CSV,两列:question, answer。先转成Alpaca格式的JSONL。
import json
import pandas as pd
df = pd.read_csv("fault_qa.csv") # 3800行
data = []
for _, row in df.iterrows():
item = {
"instruction": "你是一个工业设备故障诊断专家,请根据用户描述的报警代码,给出可能原因和处理步骤。",
"input": row["question"],
"output": row["answer"]
}
data.append(item)
# 划分训练集和验证集,9:1
split = int(len(data) * 0.9)
train_data = data[:split]
val_data = data[split:]
with open("train.jsonl", "w", encoding="utf-8") as f:
for item in train_data:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
with open("val.jsonl", "w", encoding="utf-8") as f:
for item in val_data:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
print(f"训练集:{len(train_data)}条,验证集:{len(val_data)}条")
# 输出:训练集:3420条,验证集:380条
数据质量比数量重要。我最初用了8000条,但其中2000条是重复或近似重复的,去掉后反而效果更好。另外,output的长度控制在200字以内,太长的样本训练时容易被截断。
4.2 训练脚本
这是完整的训练代码,可以直接跑:
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer
# 1. 加载模型和tokenizer
model_name = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.bfloat16,
device_map="auto",
trust_remote_code=True
)
# 2. LoRA配置
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,801,600 || trainable%: 0.2643
# 3. 数据格式化
def format_data(example):
text = f"### Instruction:\n{example['instruction']}\n\n### Input:\n{example['input']}\n\n### Output:\n{example['output']}"
return {"text": text}
train_dataset = load_dataset("json", data_files="train.jsonl", split="train")
val_dataset = load_dataset("json", data_files="val.jsonl", split="train")
train_dataset = train_dataset.map(format_data)
val_dataset = val_dataset.map(format_data)
# 4. 训练参数
training_args = TrainingArguments(
output_dir="./qwen2.5-7b-lora-fault",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
evaluation_strategy="epoch",
bf16=True,
gradient_checkpointing=True,
optim="adamw_torch",
report_to="none",
save_total_limit=2,
load_best_model_at_end=True,
metric_for_best_model="eval_loss"
)
# 5. 训练
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=val_dataset,
tokenizer=tokenizer,
dataset_text_field="text",
max_seq_length=512,
packing=False
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora-fault/final")
跑起来后,显存占用稳定在18.7GB左右,训练速度约1.8 it/s,3个epoch大约4.2小时。
五、踩坑与优化
坑1:loss不下降。第一次跑,lr=1e-4,3个epoch后loss只从1.83降到1.52。查了半天,发现是target_modules只设了q_proj和v_proj。改成全attention+FFN后,loss下降明显加快。结论:7B模型上,LoRA的target_modules别省,该加的都加上。
坑2:eval_loss震荡。第2个epoch的eval_loss比第1个还高,典型过拟合。把dropout从0.1降到0.05,同时把r从32降到16,问题解决。dropout不是越大越好,LoRA本身参数量少,dropout太大反而欠拟合。
坑3:推理时输出重复。微调后的模型在推理时偶尔会重复输出同一句话。原因是训练数据里有几条output结尾没有明确终止符。在每条output末尾统一加上eos_token后,问题消失。
坑4:显存碎片。训练到第2个epoch时OOM。加了gradient_checkpointing=True后解决,代价是速度慢约15%。24GB卡上,7B模型+LoRA+gradient_checkpointing是标配。
优化点:把max_seq_length从1024降到512。我的数据output都在200字以内,512足够。序列长度减半后,显存从22GB降到18.7GB,速度提升约20%。
六、效果数据
6.1 Loss曲线
训练集loss:epoch 1结束1.21,epoch 2结束0.79,epoch 3结束0.58。验证集loss:epoch 1结束1.35,epoch 2结束0.91,epoch 3结束0.72。整体从1.83降到0.58,下降约68%。验证集loss没有反弹,说明没有明显过拟合。
6.2 推理效果对比
测试集是人工标注的200条领域问答。评价指标:准确率(回答包含关键处理步骤且无编造)。
| 模型 | 准确率 | 平均响应长度 | 显存占用 |
|---|---|---|---|
| Qwen2.5-7B-Instruct原版 | 41% | 156字 | 14.2GB |
| +LoRA微调 | 87% | 128字 | 15.4GB |
| +LoRA微调+4bit量化推理 | 85% | 128字 | 5.8GB |
微调后准确率从41%提升到87%,提升46个百分点。响应长度反而变短,因为模型学会了直接给处理步骤,不再绕弯子。
一个具体例子:
输入:E104报警怎么处理?
原版输出:E104报警通常表示设备出现了某种故障。建议您先查看设备手册,确认E104的具体含义。如果不清楚,请联系厂家技术支持。同时检查电源、连接线等是否正常。
微调后输出:E104表示主轴过热。处理步骤:1. 立即停机,等待主轴冷却至40℃以下;2. 检查冷却液液位,低于MIN线则补充;3. 检查冷却泵是否工作,听是否有异响;4. 若冷却泵正常但温度仍高,检查主轴轴承是否磨损。注意:不要强行重启,否则可能烧毁主轴电机。
差别非常明显。原版在兜圈子,微调后直接给可操作的步骤。
6.3 推理代码
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
base_model = "Qwen/Qwen2.5-7B-Instruct"
lora_path = "./qwen2.5-7b-lora-fault/final"
tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
base_model, torch_dtype=torch.bfloat16, device_map="auto", trust_remote_code=True
)
model = PeftModel.from_pretrained(model, lora_path)
model.eval()
prompt = "### Instruction:\n你是一个工业设备故障诊断专家,请根据用户描述的报警代码,给出可能原因和处理步骤。\n\n### Input:\nE104报警怎么处理?\n\n### Output:\n"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=256, temperature=0.1, do_sample=True)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))
七、总结
LoRA微调7B模型,在24GB卡上完全可行。关键点就几个:target_modules别省、r=16是甜点、dropout=0.05、gradient_checkpointing必开、max_seq_length按数据实际长度设。数据质量比数量重要,3800条干净数据比8000条脏数据效果好。
这次微调后,模型在领域问答上的准确率从41%到87%,显存只多了1.2GB,推理速度几乎没变。如果你也有类似场景,建议先试RAG,不行再上LoRA。LoRA的成本比想象中低,一个下午就能跑完一轮。
后续我打算试试QLoRA,把基座量化到4bit,看看能不能在16GB卡上跑起来。如果成功,会再写一篇。