一、问题背景:为什么我要自己微调一个7B模型
先说结论:通用大模型在我们的业务场景里"能用但不好用"。我们做的是工业设备运维问答,用户提问里充斥着"轴承游隙""液压站蓄能器压力不足""PLC 模块 SF 灯常亮"这类术语,基座模型(我们试了Qwen2.5-7B-Instruct和Llama-3.1-8B-Instruct)回答时经常出现三个问题:
- 术语理解偏差:把"游隙"解释成"间隙"还算好的,有一次把"蓄能器"和"电容器"混为一谈;
- 格式不遵循:我们要求输出固定三段式(现象判断/可能原因/处置建议),base模型遵守率只有七成左右;
- 幻觉:编造不存在的设备型号和参数阈值。
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。代码已经整理成脚本,换个数据集就能复用,有需要的可以按上面的配置直接跑。