一、问题背景:为什么我要微调一个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 适配器:
- 量化:基座模型以 4bit NF4 加载,double quant 打开,计算时用 bf16。这样权重显存从14GB压到约4.5GB。
- LoRA 注入:只在 attention 的 q/k/v/o 和 MLP 的 gate/up/down 七个线性层上加适配器,rank=16,alpha=32,dropout=0.05。
- 数据格式:统一转成 chat template,system prompt 里固化角色和输出约束。
- 训练策略: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 基础上做偏好对齐,把"编造故障码"这个指标再压一压。有进展再写一篇。