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

先说结论:如果你的任务只是"让模型说话更像客服",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显存占用。

三、方案设计

整体思路分四块:

  1. 数据:手工整理+LLM辅助清洗,共12,000条,格式为Alpaca风格的三字段(instruction/input/output),其中output统一是结构化JSON。
  2. 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. 训练:3 epoch,batch_size=2 + gradient_accumulation=8(等效16),lr=2e-4,cosine调度,warmup_ratio=0.03,max_length=2048,bf16混合精度。
  4. 评估:自建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结合起来做多领域热切换。代码和数据格式都在上面了,照着改改就能跑,有问题评论区聊。