一、问题背景:为什么我要自己微调一个7B模型

先说结论:通用大模型在我们的业务场景里"能用但不好用"。我们做的是工业设备运维问答,用户提问里充斥着"轴承游隙""液压站蓄能器压力不足""PLC 模块 SF 灯常亮"这类术语,基座模型(我们试了Qwen2.5-7B-Instruct和Llama-3.1-8B-Instruct)回答时经常出现三个问题:

  1. 术语理解偏差:把"游隙"解释成"间隙"还算好的,有一次把"蓄能器"和"电容器"混为一谈;
  2. 格式不遵循:我们要求输出固定三段式(现象判断/可能原因/处置建议),base模型遵守率只有七成左右;
  3. 幻觉:编造不存在的设备型号和参数阈值。

RAG能缓解一部分,但格式和术语这两块还是得靠微调。全参微调7B模型至少需要4张A100 80G,我们只有一张A100 40G,所以LoRA是唯一现实的选择。顺便做了QLoRA对比,看看4-bit量化对效果的影响到底有多大。

二、环境与版本

环境这块我踩过版本坑,先把确定能跑的配置贴出来:

  • GPU:单卡 A100 40GB(PCIe版)
  • CUDA 12.1,驱动 535.129
  • Python 3.10.13
  • torch 2.3.1+cu121
  • transformers 4.42.4
  • peft 0.11.1
  • trl 0.9.6
  • bitsandbytes 0.43.1(QLoRA需要)
  • accelerate 0.31.0
  • datasets 2.20.0

提醒一句:peft 0.10 之前和 trl 0.9 的 SFTTrainer 在 max_seq_length 参数上有兼容问题,升级到 0.11.1 后正常。另外 bitsandbytes 在 CUDA 12.1 下必须用 0.43 以上版本,否则 load_in_4bit 会报 undefined symbol。

三、方案设计

3.1 数据准备

原始数据来自三部分:历史工单(约8000条)、设备手册问答对(约2500条)、人工构造的边界case(约1500条),合计约1.2万条。统一转成 Alpaca 格式:

{
  "instruction": "液压站蓄能器压力从12MPa掉到8MPa,可能是什么原因?",
  "input": "",
  "output": "【现象判断】蓄能器保压能力下降...\n【可能原因】1. 皮囊破损...\n【处置建议】..."
}

数据处理脚本核心逻辑(去重、长度过滤、格式校验):

import json
import hashlib

def build_dataset(raw_path, out_path, min_len=20, max_len=1800):
    seen = set()
    kept = []
    with open(raw_path, encoding="utf-8") as f:
        for line in f:
            item = json.loads(line)
            text = item["instruction"] + item["output"]
            # 用 md5 去重,工单数据重复率很高
            h = hashlib.md5(text.encode()).hexdigest()
            if h in seen:
                continue
            seen.add(h)
            if not (min_len <= len(item["output"]) <= max_len):
                continue
            # 强制校验三段式格式,格式不对的直接丢弃而不是让模型学坏
            if not all(k in item["output"] for k in ["【现象判断】", "【可能原因】", "【处置建议】"]):
                continue
            kept.append(item)
    with open(out_path, "w", encoding="utf-8") as f:
        for item in kept:
            f.write(json.dumps(item, ensure_ascii=False) + "\n")
    print(f"raw={len(seen)}, kept={len(kept)}")

build_dataset("raw.jsonl", "train_clean.jsonl")

最终保留 11,436 条,按 95:5 切分训练集和验证集。这个"格式不对就丢弃"的策略很关键——早期我试过保留脏数据让模型自己学,结果模型把错误格式也学进去了,格式遵循率反而下降。

3.2 LoRA 配置

核心超参如下,都是试出来的:

参数 值 说明
lora_rank (r) 16 试过8/16/32,16性价比最高
lora_alpha 32 固定为 2r
lora_dropout 0.05 防过拟合
target_modules q/k/v/o/gate/up/down_proj 全线性层
learning_rate 2e-4 LoRA常用区间
lr_scheduler cosine warmup_ratio=0.03
batch_size 4 × grad_accum 8 = 32 显存刚好
max_seq_length 2048 覆盖99.2%样本
epochs 3 第3轮开始验证loss抬头
optim paged_adamw_8bit 省显存

四、核心实现

4.1 训练脚本

import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig

MODEL_PATH = "Qwen/Qwen2.5-7B-Instruct"

# QLoRA 用 4bit,纯 LoRA 把这行换成 None 即可
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    quantization_config=bnb_config,
    device_map="auto",
    torch_dtype=torch.bfloat16,
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)

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: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.527

def format_sample(example):
    messages = [
        {"role": "system", "content": "你是工业设备运维专家,回答必须按【现象判断】【可能原因】【处置建议】三段式输出。"},
        {"role": "user", "content": example["instruction"]},
        {"role": "assistant", "content": example["output"]},
    ]
    return {"text": tokenizer.apply_chat_template(messages, tokenize=False)}

train_ds = load_dataset("json", data_files="train_clean.jsonl", split="train")
train_ds = train_ds.map(format_sample, remove_columns=train_ds.column_names)

args = SFTConfig(
    output_dir="./qwen2.5-7b-lora",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,
    learning_rate=2e-4,
    num_train_epochs=3,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    max_seq_length=2048,
    dataset_text_field="text",
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=train_ds,
    tokenizer=tokenizer,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora/final")

可训练参数占比 0.527%,约 4037 万参数,checkpoint 只有 160MB 左右,非常轻。

4.2 推理加载

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH, torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./qwen2.5-7b-lora/final")
model = model.merge_and_unload()  # 合并后推理速度提升约15%
model.eval()

def generate(prompt, max_new_tokens=512):
    messages = [
        {"role": "system", "content": "你是工业设备运维专家,回答必须按【现象判断】【可能原因】【处置建议】三段式输出。"},
        {"role": "user", "content": prompt},
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(**inputs, max_new_tokens=max_new_tokens,
                             do_sample=False, temperature=1.0,
                             repetition_penalty=1.05)
    return tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

五、踩坑与优化

坑1:loss 前100步几乎不动。 一开始 learning_rate 设了 1e-4,前100步 loss 从 1.83 只降到 1.79,以为代码有问题。后来把 lr 提到 2e-4,第50步就开始明显下降。LoRA 的 lr 确实比全参微调要高一个量级。

坑2:第3个epoch验证loss抬头。 训练loss一路降到0.62,但验证loss在第2个epoch末(0.76)之后第3个epoch升到0.81,典型过拟合。最后采用2个epoch + 保存第2轮checkpoint,验证loss 0.78,实测效果比3轮更好。

坑3:QLoRA 掉点。 4-bit QLoRA 训完后领域准确率 84%,比 bf16 LoRA 的 89% 低5个点,但显存占用从 28GB 降到 14GB。如果卡够,优先 bf16 LoRA;卡不够,QLoRA 也完全可用。

坑4:merge_and_unload 后显存翻倍。 合并时会把 base 权重和 LoRA 权重同时加载,40G 卡上 7B bf16 模型合并瞬间峰值到 32GB,差点 OOM。解决办法是先 model.to("cpu") 合并再搬回 GPU,或者直接用 PEFT 动态加载不合并(推理慢15%左右)。

六、效果数据

评测集是人工标注的 300 条领域问答,指标定义:术语准确率(关键术语判断正确)、格式遵循率(三段式完整且顺序正确)、人工可用率(运维工程师判定可直接使用)。

模型 术语准确率 格式遵循率 人工可用率
Qwen2.5-7B-Instruct (base) 62% 71% 48%
+ LoRA (bf16, r=16) 89% 96% 82%
+ QLoRA (4bit, r=16) 84% 94% 77%

训练曲线:train loss 1.83 → 0.71(2 epoch 时 0.74),val loss 1.79 → 0.78,曲线平滑无异常抖动。单 epoch 约 1.5 小时(A100 40G,QLoRA 模式约 1.1 小时),总训练成本不到 20 块钱电费。

推理速度:合并后 bf16 模型在 A100 上约 42 tokens/s(batch=1),比 base 模型慢不到 3%,LoRA 的推理开销基本可以忽略。

七、总结

这次微调最大的感受是:数据质量比超参重要得多。我前期花了两天调 lr、r、dropout,提升加起来不到2个点;后来把脏数据清掉、强制格式校验,直接涨了8个点。LoRA 本身没什么魔法,r=16 对7B模型足够,target_modules 覆盖全线性层比只调 q/v 效果好3-4个点。

如果重来一次,我会:第一,先做200条的小样本快速验证 pipeline 通不通,别一上来就全量跑;第二,验证集一定要带格式指标,光看 loss 会骗人;第三,QLoRA 只在显存真的不够时用,能 bf16 就 bf16。代码已经整理成脚本,换个数据集就能复用,有需要的可以按上面的配置直接跑。