一、问题背景:为什么非得自己微调

先说结论:不是所有场景都值得微调,但我们这个场景确实绕不过去。

我手上是一个工业设备运维的问答系统,用户问的是"XX型号伺服驱动器报E-07怎么处理"这类问题。拿Qwen2.5-7B-Instruct直接跑,效果是这样的:回答格式很漂亮,但内容经常张冠李戴,把A型号的故障码套到B型号上,甚至会编出不存在的复位步骤。RAG也试过,检索到的文档片段它读得懂,但组织答案时还是会漏关键参数,尤其是那种需要"先断电、再短接、保持3秒"的顺序性操作,它经常把顺序打乱。

这类问题的本质是:基座模型缺的不是知识,而是领域内的表达范式和推理路径。RAG能补知识,但补不了"这个领域的回答应该长什么样"。所以我们决定做LoRA微调,目标很明确——让模型学会用运维工程师的口吻、按标准作业流程来组织答案。

约束条件也很现实:只有一张4090,24GB显存,全参数微调7B模型想都别想(bf16下光权重就14GB,加上优化器状态和梯度直接爆)。所以LoRA是必选项,QLoRA是加分项。

二、环境与版本:别小看版本号

这套组合我调了两天才稳定,版本错一个就报奇怪的错:

- Python 3.10.13
- torch 2.3.1 + cu121
- transformers 4.42.3
- peft 0.11.1
- bitsandbytes 0.43.1
- accelerate 0.31.0
- trl 0.9.6
- GPU: RTX 4090 24GB, 驱动 550.54.14

几个版本上的血泪点先提前说:peft 0.11.x 对 target_modules 的匹配逻辑改过,用字符串列表匹配时是全名匹配,不是包含匹配,写 ["q_proj"] 没问题,但如果你想用 "all-linear" 这个快捷方式,得 peft>=0.8 才行。bitsandbytes 0.43.1 是当时在cu121下唯一不报 CUDA Setup failed 的版本,0.44 反而在我这有问题。transformers 别用 4.43+,和 trl 0.9.6 的 SFTTrainer 在 packing 参数上有冲突。

三、方案设计:为什么选QLoRA而不是纯LoRA

先算笔账。纯LoRA(bf16基座)下,7B模型权重14GB,LoRA参数(r=16)大概40MB,梯度忽略不计,但前向的激活值和KV cache在batch=4、seqlen=1024时会吃掉6-8GB,加上优化器状态,实测峰值在21GB左右——能跑,但batch只能压到2,训练慢且不稳。

QLoRA把基座量化到4bit NF4,权重降到约4.2GB,激活值不变,实测峰值17.8GB,batch能开到4,gradient_accumulation=4,等效batch=16。代价是训练速度慢约25%(反量化有开销),但换来的是稳定性和更大的有效batch,我觉得值。

关键配置决策:

  • rank=16:试过8和32。r=8时loss能降但领域准确率只到74%,欠拟合;r=32到80.1%但显存多1.2GB、速度慢15%,性价比不高。16是拐点。
  • alpha=32:按惯例取2倍rank,实测比alpha=16收敛快半个epoch。
  • target_modules:覆盖 q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj 全部线性层。只调q/v的话准确率掉3个点,MLP层对领域知识注入的帮助比想象中大。
  • 学习率2e-4,cosine调度,warmup_ratio=0.03。LoRA常用1e-4到3e-4,2e-4在我们的数据量上最稳。
  • 3个epoch:第3个epoch后eval_loss开始抬头,过拟合了。

四、核心实现:数据准备与训练代码

4.1 数据准备

原始数据是从工单系统导出的,1.2万条,清洗后剩3200条高质量QA对。清洗规则:去掉答案长度system\n你是工业设备运维专家,回答需按标准作业流程分步骤给出。"
f"\nuser\n{ex['instruction']}{ex['input']}"
f"\nassistant\n{ex['output']}")
return {"text": text}

dataset = load_dataset("json", data_files={
"train": "data/train.json", "validation": "data/val.json"
})
dataset = dataset.map(format_sample, remove_columns=dataset["train"].column_names)

args = TrainingArguments(
output_dir="./lora_out",
num_train_epochs=3,
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
gradient_accumulation_steps=4,
gradient_checkpointing=True,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=200,
save_total_limit=2,
bf16=True,
optim="paged_adamw_8bit",
report_to="none",
max_grad_norm=0.3,
)

trainer = SFTTrainer(
model=model,
args=args,
train_dataset=dataset["train"],
eval_dataset=dataset["validation"],
peft_config=lora_config,
dataset_text_field="text",
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.model.save_pretrained("./lora_final")

注意 `optim="paged_adamw_8bit"`,这是QLoRA的标配,能省掉约1.5GB的优化器显存,不加的话batch=4会OOM。

### 4.3 推理脚本

```python
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

base = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map={"": 0},
)
model = PeftModel.from_pretrained(base, "./lora_final")
model = model.merge_and_unload()   # 合并后推理,速度提升约30%
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

def chat(q):
    msgs = [{"role":"system","content":"你是工业设备运维专家,回答需按标准作业流程分步骤给出。"},
            {"role":"user","content":q}]
    text = tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
    ids = tokenizer(text, return_tensors="pt").to("cuda")
    out = model.generate(**ids, max_new_tokens=512, temperature=0.2,
                         top_p=0.9, repetition_penalty=1.05)
    return tokenizer.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=True)

推理时用 merge_and_unload() 把LoRA权重合进基座,比不合并快约30%,且不用担心peft层的额外开销。

五、踩坑与优化:那些让我凌晨两点还在看日志的问题

坑1:loss从第一步就卡在0.87不动。 排查了半天发现是 prepare_model_for_kbit_training 之后忘了开 gradient checkpointing 的 use_reentrant=False,在torch 2.3下默认的reentrant模式会让部分LoRA梯度不回传。改成 model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False}) 后正常。

坑2:eval_loss比train_loss还低。 一开始以为捡到宝,后来发现是验证集里有些样本答案极短(我清洗时漏了),模型学会了"少说少错"。补了一条答案长度下限80字的规则重新清洗后,曲线就正常了。

坑3:第3个epoch后期loss抖动。 学习率2e-4在后期偏大,加了 max_grad_norm=0.3(默认是1.0)后抖动明显收敛。另外把 lr_scheduler_type 从linear换成cosine,末尾收敛更平滑。

优化点:最初 max_seq_length=2048,显存峰值直接顶到23.5GB,batch被迫降到1。统计了一下训练样本的token分布,95分位是890,于是压到1024,显存降到17.8GB,batch回到4,整体训练时间从4小时10分缩短到2小时40分。

六、效果数据:loss曲线与推理对比

Loss曲线(每50步记录一次eval_loss):

步数 train_loss eval_loss
0 0.91 0.87
100 0.62 0.63
200 0.49 0.52
300 0.43 0.45
400 0.40 0.42
450 0.39 0.41
500 0.37 0.44(抬头)

第450步(约2.7个epoch)是最优点,eval_loss 0.41。之后train_loss还在降但eval_loss回升,典型的过拟合起点,所以最终取的是 save_steps=200 时第400步的checkpoint,实测比第450步在测试集上还稳一点。

领域测试集准确率(200条人工标注,判定标准是故障码正确+步骤顺序正确+关键参数齐全):

  • 基座模型(Qwen2.5-7B-Instruct):58.3%
  • 基座+RAG(top3检索):66.5%
  • LoRA微调后:79.6%
  • LoRA微调+RAG:83.2%

推理效果对比,同一个问题"XX型号伺服驱动器报E-07怎么处理":

微调前(基座):

E-07通常是过流故障。建议您检查电机接线是否松动,确认负载是否过大,必要时联系厂家售后。复位方法请参考产品手册。

微调后:

E-07为过流保护动作。处理步骤:1)切断主电源,等待母线电容放电不少于5分钟,用万用表确认母线电压低于36V;2)断开U/V/W相电缆,测量各相对地绝缘电阻,正常应大于1MΩ,低于此值检查电缆破损或电机绕组;3)检查驱动器参数Pr.5.12过流阈值是否被误改,出厂值为额定电流的150%;4)确认无误后上电,按RESET键复位,若E-07复现且负载正常,判定为驱动器功率模块故障,需返修。

差别很明显:基座给的是"通用建议",微调后给的是"可执行的作业流程",故障码、参数名、阈值、判定条件全都落到了具体值上。这正是我们要的效果。

七、总结

几点结论供参考:

  1. 单卡24G微调7B完全可行,QLoRA + batch=4 + seqlen=1024 是这套硬件上的甜点配置,显存峰值17.8GB,留了6GB余量给系统。
  2. 数据质量比数据量重要得多。我们从1.2万条洗到3200条,准确率反而比直接用全量高4个点。清洗时那400条错误样本如果留着,模型会学歪。
  3. rank不是越大越好,16在我们的场景是拐点,再大就是浪费显存和过拟合。
  4. LoRA和RAG不冲突,微调教"怎么说",RAG补"说什么",两者叠加还能再涨3.6个点。

下一步打算试试DoRA和rsLoRA,看看在同样rank下能不能再挤出一两个点;另外打算把target_modules扩展到embedding层做对比实验。有进展再写一篇。

代码和数据格式都在上面了,环境版本对上的话应该能直接复现。有问题评论区聊。