一、问题背景:为什么非得自己微调
先说结论:如果你的任务只是"让模型说话更像客服",prompt engineering就够了;但如果要让模型掌握一批它预训练时压根没见过的领域术语、输出固定结构的字段、并且稳定不跑偏,微调是绕不过去的。
我这次的场景是工业设备的故障诊断问答。基座模型是Qwen2.5-7B-Instruct,直接问它"XX型号伺服驱动器报E-042怎么处理",它会一本正经地编——因为这套设备手册根本没进过它的预训练语料。RAG能缓解,但我们的部署环境是离线边缘盒子,检索库维护成本高,而且模型经常不按给定的JSON结构输出,后处理写得我头皮发麻。
所以目标很明确:用LoRA把领域知识"压"进模型,同时保证输出结构稳定。选LoRA而不是全参微调,理由很朴素——我只有一张4090,全参7B至少要8×80G,想都别想。QLoRA(4bit量化基座+LoRA)是备选,但实测下来24G卡跑bf16的LoRA已经够用(后面细说),量化反而拖慢速度,所以最终方案是bf16基座 + LoRA。
二、环境与版本
版本这东西必须写清楚,不然别人复现时踩的坑你根本想不到。
GPU: RTX 4090 24GB (单卡)
CUDA: 12.1
torch: 2.4.1+cu121
transformers: 4.45.2
peft: 0.13.2
trl: 0.12.1
accelerate: 1.0.1
bitsandbytes: 0.44.1 (备选QLoRA方案用)
datasets: 3.0.1
基座模型:Qwen2.5-7B-Instruct,bf16加载约15.2GB显存占用。
三、方案设计
整体思路分四块:
- 数据:手工整理+LLM辅助清洗,共12,000条,格式为Alpaca风格的三字段(instruction/input/output),其中output统一是结构化JSON。
- LoRA配置:rank=16,alpha=32,dropout=0.05,target_modules选
q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj——注意这里把MLP层也加上了,只调attention效果会明显差一截,这是我第一次实验的教训。 - 训练:3 epoch,batch_size=2 + gradient_accumulation=8(等效16),lr=2e-4,cosine调度,warmup_ratio=0.03,max_length=2048,bf16混合精度。
- 评估:自建200条领域评测集(准确率)+ C-Eval抽样100题(通用能力保持情况)+ 人工盲测输出结构合规率。
四、核心实现
4.1 数据准备
数据长这样,注意output是严格JSON,训练时我把schema说明也塞进了instruction里,让模型学会"看指令里的字段要求":
{
"instruction": "你是工业设备诊断助手。请根据故障描述输出JSON,字段包括 fault_code(string)、cause(string)、solution(string)、severity(high/medium/low)。",
"input": "伺服驱动器上电报E-042,电机抖动明显。",
"output": "{\"fault_code\": \"E-042\", \"cause\": \"编码器信号干扰或接线松动\", \"solution\": \"检查编码器屏蔽线接地,重新插拔CN2接口并锁紧\", \"severity\": \"high\"}"
}
加载和tokenize用datasets的map,关键是只对output部分计算loss,instruction和input的label设成-100。trl的DataCollatorForCompletionOnlyLM能做,但我更习惯自己写,可控性强:
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)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
ds = load_dataset("json", data_files={"train": "data/train.jsonl",
"valid": "data/valid.jsonl"})
PROMPT = ("system\n{instruction}\n"
"user\n{input}\n"
"assistant\n")
def preprocess(ex):
prompt = PROMPT.format(instruction=ex["instruction"], input=ex["input"])
answer = ex["output"] + tokenizer.eos_token
p_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
a_ids = tokenizer(answer, add_special_tokens=False)["input_ids"]
input_ids = p_ids + a_ids
labels = [-100] * len(p_ids) + a_ids
# 截断保护
input_ids = input_ids[:2048]
labels = labels[:2048]
return {"input_ids": input_ids, "labels": labels,
"attention_mask": [1] * len(input_ids)}
train_ds = ds["train"].map(preprocess, remove_columns=ds["train"].column_names)
valid_ds = ds["valid"].map(preprocess, remove_columns=ds["valid"].column_names)
数据清洗上花了大概两天:去重(MinHash,阈值0.85)、过滤output解析失败的样本、按fault_code做分层抽样保证长尾故障码也有足够样本。最后12,000条里,最少的故障码也有30条以上。
4.2 训练配置与脚本
LoRA + Trainer的核心代码:
import torch
from transformers import (AutoModelForCausalLM, TrainingArguments, Trainer)
from peft import LoraConfig, get_peft_model, TaskType
from dataclasses import dataclass
@dataclass
class Collator:
pad_id: int
def __call__(self, features):
max_len = max(len(f["input_ids"]) for f in features)
out = {"input_ids": [], "labels": [], "attention_mask": []}
for f in features:
pad = max_len - len(f["input_ids"])
out["input_ids"].append(f["input_ids"] + [self.pad_id] * pad)
out["labels"].append(f["labels"] + [-100] * pad)
out["attention_mask"].append(f["attention_mask"] + [0] * pad)
return {k: torch.tensor(v, dtype=torch.long) for k, v in out.items()}
model = AutoModelForCausalLM.from_pretrained(
model_path, torch_dtype=torch.bfloat16, device_map={"": 0},
trust_remote_code=True, attn_implementation="flash_attention_2")
lora_cfg = LoraConfig(
task_type=TaskType.CAUSAL_LM, 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")
model = get_peft_model(model, lora_cfg)
model.print_trainable_parameters() # trainable: 40,370,176 || all: 7,655,986,688 || 0.53%
args = TrainingArguments(
output_dir="out/lora-e042",
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps", eval_steps=200,
save_strategy="steps", save_steps=200, save_total_limit=3,
bf16=True, gradient_checkpointing=True,
optim="adamw_torch", report_to="none",
dataloader_num_workers=4, remove_unused_columns=False)
trainer = Trainer(model=model, args=args,
train_dataset=train_ds, eval_dataset=valid_ds,
data_collator=Collator(tokenizer.pad_token_id))
trainer.train()
trainer.save_model("out/lora-e042/final")
可训练参数40.4M,占比0.53%,checkpoint只有约80MB,这对多任务切换非常友好——不同领域各存一份adapter,推理时热插拔就行。
五、踩坑与优化
坑1:显存OOM。 一开始batch_size=4、max_length=2048、不开gradient_checkpointing,直接爆。开了checkpointing后batch=2+累积8稳定在21.3GB左右,峰值22.1GB。这里有个反直觉的点:gradient_checkpointing=True 必须配合 model.enable_input_require_grads()(peft的get_peft_model在新版本里会自动处理,老版本要手动调),否则LoRA梯度传不回去,loss纹丝不动——我卡在这上面排查了半小时。
坑2:QLoRA并没有更快。 用bitsandbytes 4bit加载基座,显存确实降到约9GB,但吞吐从约2.1 samples/s掉到1.3 samples/s,3个epoch从4.5小时变成7小时多。24G卡上没必要量化,除非你要塞更大的batch或更长的序列。QLoRA的真正价值在16G及以下的卡。
坑3:lr=2e-4对7B偏大。 第一轮用2e-4跑了500步后loss出现明显震荡,验证loss在第2个epoch开始回升。改成1e-4后曲线平滑很多,最终验证loss从0.71降到0.68。所以最终配置里lr是1e-4(上面脚本里写2e-4是初始实验值,这里更正一下,别照抄)。
坑4:只调attention效果差。 对比实验:target_modules只含q/k/v/o时,评测准确率76.5%;加上MLP三层后81.0%。差4.5个点,值得。
六、效果数据
Loss曲线(文字描述,原始曲线在wandb里):训练loss从1.87起步,前100步快速降到1.10左右,之后缓慢下降,3个epoch结束时0.62;验证loss在0.68附近收敛,第3个epoch末有轻微上翘(0.68→0.695),说明3个epoch基本到边界了,再多训会过拟合。
评测结果(200条领域集,人工核对):
| 配置 | 领域准确率 | JSON结构合规率 | C-Eval抽样 |
|---|---|---|---|
| 基座 Qwen2.5-7B-Instruct | 54.5% | 71.0% | 78/100 |
| LoRA (仅attention) | 76.5% | 93.5% | 76/100 |
| LoRA (attention+MLP) | 81.0% | 97.5% | 76/100 |
推理对比一个真实样例:
输入:"变频器运行中报OC,负载是风机,启动时就跳。"
基座输出(节选):"OC是过流故障,可能是负载过大、电机短路……建议检查电机绝缘、降低负载……"——泛泛而谈,没有结构化,也没提风机场景的惯性负载特性。
微调后输出:{"fault_code":"OC","cause":"风机惯性负载导致启动电流冲击超限","solution":"将加速时间参数P0.05由5s调整至15s,并确认转矩提升参数P1.12不超过15%","severity":"medium"}——直接可用,后处理零成本。
推理速度:合并adapter后(merge_and_unload())单条平均延迟无明显变化,vLLM部署下吞吐约与原模型持平。
七、总结
几个takeaway:单卡24G做7B的LoRA微调完全可行,bf16+gradient checkpointing是性价比最高的组合,别迷信QLoRA;target_modules一定要带上MLP;lr对7B来说1e-4比2e-4稳;数据质量比数据量重要,1.2万条清洗过的数据比3万条脏数据强得多。下一步打算试试DoRA和不同rank的消融,以及把adapter和vLLM的LoRA serving结合起来做多领域热切换。代码和数据格式都在上面了,照着改改就能跑,有问题评论区聊。