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

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

我负责的是一个工业设备运维知识助手。基座模型用Qwen2.5-7B-Instruct,直接prompt的效果是:通用问答很流畅,但一碰到具体设备型号、故障代码、检修SOP就开始"自由发挥"。典型表现是术语混用(把"轴承游隙"说成"间隙")、格式不遵守(要求输出JSON,它给你一段散文)、以及偶尔编造不存在的故障码。

我先试过RAG,把2000多页手册切块塞进向量库。效果有改善,但问题在于:RAG解决的是"知识不在模型里",而我这里的痛点更多是"模型不会按我的方式说话"。领域术语体系、输出结构、判断逻辑,这些靠检索补不上。

所以决定走LoRA微调。选LoRA不选全参微调的原因很现实:我只有一张4090,24GB显存,全参微调7B模型至少要80GB以上(fp16权重14GB + 梯度14GB + Adam优化器状态56GB,还没算激活值)。LoRA只训练不到1%的参数,配合4bit量化完全可行。

二、环境与版本

环境这块我列清楚,因为版本不匹配是新手最容易卡住的地方:

  • GPU:RTX 4090 24GB(单卡)
  • CUDA 12.1,驱动 535.104
  • Python 3.10.13
  • torch 2.3.1+cu121
  • transformers 4.43.3
  • peft 0.12.0
  • bitsandbytes 0.43.3
  • trl 0.9.6
  • accelerate 0.33.0
  • datasets 2.20.0

特别提醒:peft 0.12 和 transformers 4.43 是配套的,我之前用 peft 0.10 + transformers 4.44 出现过 target_modules 匹配不到的问题。另外 bitsandbytes 在 Windows 上支持一直不太好,建议 Linux 或 WSL2。

三、方案设计

整体思路是 QLoRA + LoRA 适配器:

  1. 量化:基座模型以 4bit NF4 加载,double quant 打开,计算时用 bf16。这样权重显存从14GB压到约4.5GB。
  2. LoRA 注入:只在 attention 的 q/k/v/o 和 MLP 的 gate/up/down 七个线性层上加适配器,rank=16,alpha=32,dropout=0.05。
  3. 数据格式:统一转成 chat template,system prompt 里固化角色和输出约束。
  4. 训练策略:3 epoch,cosine 调度,warmup 比例 0.03,学习率 2e-4(LoRA 常用量级,比全参微调高一个数量级)。

为什么不调 embedding 层和 lm_head?7B 模型词表 15万,这两个层参数量太大,LoRA 加进去收益不明显还吃显存。领域术语靠上层适配器学就够了。

四、核心实现

4.1 数据准备

我的原始数据是人工整理的问答对,存成 jsonl,每条包含 instruction、input(可空)、output。先做清洗:去掉长度超过2048 token的样本(会截断),去掉output为空或纯"不知道"的样本,去重。最终剩 12,340 条。

然后转成 chat 格式并 tokenize:

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)
tokenizer.pad_token = tokenizer.eos_token

SYSTEM_PROMPT = (
    "你是工业设备运维助手。回答必须使用标准术语,"
    "涉及故障诊断时按【现象】【原因】【处理】三段输出。"
    "不确定时明确说不知道,不要编造故障码。"
)

def format_and_tokenize(example):
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": example["instruction"] +
            ("\n" + example["input"] if example["input"] else "")},
        {"role": "assistant", "content": example["output"]},
    ]
    text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=False
    )
    enc = tokenizer(
        text, truncation=True, max_length=2048,
        padding=False, return_tensors=None,
    )
    return {"input_ids": enc["input_ids"], "attention_mask": enc["attention_mask"]}

ds = load_dataset("json", data_files="train.jsonl", split="train")
ds = ds.map(format_and_tokenize, remove_columns=ds.column_names, num_proc=8)
ds = ds.train_test_split(test_size=0.02, seed=42)
print(ds)

这里有个细节:我只对 assistant 部分算 loss 吗?理想情况是只算 assistant token 的 loss,但 trl 的 SFTTrainer 默认对整段算。我的数据里 system+user 占比不高,且模型本身已会理解指令,就没做 masking。如果你的数据里 prompt 很长,建议手动把 prompt 部分的 label 置为 -100。

4.2 训练配置

import torch
from transformers import (
    AutoModelForCausalLM, BitsAndBytesConfig, TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    quantization_config=bnb_config,
    device_map={"": 0},
    trust_remote_code=True,
    attn_implementation="flash_attention_2",
)
model = prepare_model_for_kbit_training(model)
model.gradient_checkpointing_enable()

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 40,370,176 || all params: 7,655,897,088 || trainable%: 0.527

args = TrainingArguments(
    output_dir="./lora_out",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,      # 等效 batch = 16
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=20,
    save_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    max_grad_norm=0.3,
    report_to="none",
    dataloader_num_workers=4,
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=ds["train"],
    eval_dataset=ds["test"],
    tokenizer=tokenizer,
    max_seq_length=2048,
    dataset_text_field=None,   # 已预 tokenize
    packing=False,
)
trainer.train()
trainer.model.save_pretrained("./lora_out/final")

关键参数解释几个:

  • per_device_train_batch_size=2 + grad_accum=8:seq_len 2048 下,bs=2 显存约 17.5GB,bs=4 就到 23GB 边缘了,容易 OOM。
  • optim="paged_adamw_8bit":8bit 优化器状态,省显存,配合 4bit 权重是 QLoRA 标配。
  • max_grad_norm=0.3:比默认 1.0 小,LoRA 训练时梯度容易尖峰,压一下更稳。
  • attn_implementation="flash_attention_2":需要装 flash-attn,装不上的话去掉这行,显存会多 1.5GB 左右。

五、踩坑与优化

坑一:loss 一开始不降反升。 前 40 步 loss 从 1.83 涨到 2.1,我一度以为数据有问题。实际是 warmup 太短 + lr 偏大,LoRA 初始适配器是零初始化,前几步在"找方向"。把 warmup_ratio 从 0.01 提到 0.03、lr 从 3e-4 降到 2e-4 后曲线正常了。

坑二:eval loss 在第 2 个 epoch 后开始抬头。 训练 loss 一直降,eval loss 在 1.2 epoch 见底(0.68)后回升到 0.74。说明 3 epoch 对这个数据量偏多。我最终保留了 3 epoch 的 checkpoint 但实际部署用的是 epoch 2 的权重。如果你数据量小于 5000 条,建议 2 epoch 起步。

坑三:推理时输出重复。 微调后模型偶尔出现整段重复。排查发现是训练时 pad_token = eos_token,而推理时没设 eos_token_id 导致停不下来。推理侧显式指定 eos_token_id 并加 repetition_penalty=1.05 解决。

另外提一句吞吐:训练时约 1.9 samples/s(等效 batch 16,即约 0.12 step/s),3 epoch 共 2310 步,实测 6 小时 32 分。

六、效果数据

Loss 曲线(每 20 步记录,节选):

step train_loss eval_loss
20 1.79 -
200 1.12 -
770 0.81 0.68
1540 0.67 0.71
2310 0.62 0.74

训练 loss 平滑下降无异常尖峰,eval 在第 2 epoch 见底,符合预期。

推理对比:我从测试集里抽了 200 条,人工评估三个维度:

指标 基座模型 LoRA微调后 提升
领域术语准确率 61.5% 92.5% +31.0pp
输出格式遵循率 48.0% 95.0% +47.0pp
故障码编造率 14.5% 3.0% -11.5pp

一个具体例子。输入:"3号磨机振动值突增到8.2mm/s,怎么处理?"

基座输出大意是"检查轴承、检查润滑、联系维修",术语泛化。微调后输出:

```
【现象】磨机振动值8.2mm/s,超过报警阈值7.1mm/s。
【原因】优先排查:1) 磨辊轴承游隙异常;2) 联轴器对中偏差;3) 料层厚度波动。
【处理】1) 停机检查轴承游隙,标准值0.08-0.12mm;2) 激光对中仪复测,偏差应 数据数量。我最初 3 万条里混了不少模板化样本,清洗到 1.2 万条后效果反而更好。格式约束要写进 system prompt,让模型在训练时就见到。
2.
QLoRA 让单卡微调 7B 成为日常操作。18GB 显存、6.5 小时、0.5% 可训练参数,成本可控。rank 16 对这个任务够用,我试过 rank 8 效果掉约 4 个点,rank 32 提升不到 1 个点但显存多了 2GB。
3.
微调不是万能的**。它擅长"改变说话方式",不擅长"注入大量新知识"。知识密集的部分还是交给 RAG,两者组合才是生产环境的正解。

下一步打算试试用 DPO 在 LoRA 基础上做偏好对齐,把"编造故障码"这个指标再压一压。有进展再写一篇。