一、问题背景:为什么我要自己微调一个7B模型
事情的起因很朴素:我们有个内部工单系统,需要把用户口语化的报障描述转成结构化JSON(包含故障类型、紧急程度、涉及模块、建议动作)。一开始直接调Qwen2.5-7B-Instruct的API做few-shot prompting,效果能用但不够稳——200条抽样里格式合规率只有54%,经常多输出解释性文字、字段名拼错、或者把"紧急程度"写成中文而不是约定的枚举值。
换更大的模型能解决一部分问题,但成本和延迟上去了。我判断这本质上是个格式与领域术语对齐的问题,不是模型能力不够,所以微调是性价比最高的路子。7B模型+SFT足够,不需要上RLHF那套。
选LoRA而不是全参微调,理由很实际:全参7B在bf16下光权重就14GB,加上优化器状态和梯度,单卡24G根本放不下(除非上DeepSpeed ZeRO+offload,但那就得改训练框架了)。LoRA只训练约0.5%的参数,配合4bit量化(QLoRA)能把显存压到22GB以内,单卡跑得动。
二、环境与版本
环境这块我踩过版本不匹配的坑,直接给一套验证过的组合:
- GPU:RTX 4090 24GB,驱动 550.54
- CUDA 12.1 / PyTorch 2.3.1
- transformers 4.43.3
- peft 0.12.0
- bitsandbytes 0.43.3
- trl 0.9.6
- accelerate 0.33.0
- datasets 2.9.0
基础模型用 Qwen/Qwen2.5-7B-Instruct。这里提醒一句:trl在0.9.x和0.8.x之间SFTTrainer的参数名有变动(dataset_text_field、max_seq_length这些),如果你抄的是老博客的代码,报unexpected keyword argument基本就是版本对不上。
三、方案设计
整体思路分四层:
- 数据层:把历史工单人工标注成"输入→JSON输出"的指令对,统一成Alpaca风格的三字段(instruction/input/output),用chat template包一层。
- 量化层:QLoRA,4bit NF4量化+double quant,计算时用bf16。这样基座权重只占约4.5GB。
- 适配层:LoRA挂在所有线性层上(q/k/v/o + gate/up/down),r=16,alpha=32,dropout=0.05。
- 训练层:trl的SFTTrainer,packing开启提升吞吐,cosine调度,warmup 3%。
为什么r选16?我试过r=8和r=32:r=8时验证loss下降偏慢、术语准确率差3个点左右;r=32显存多占约1.2GB但效果没有明显提升,性价比不行。r=16是这张卡上的甜点。
四、核心实现
4.1 数据准备
数据是8600条原始标注,清洗规则:去掉输出不是合法JSON的、去重、去掉超长(>1024 token)的,剩7820条,按 95:5 切训练/验证。格式统一成下面这样,训练时用Qwen自己的chat template,不要自己手拼特殊token,否则容易和推理时不一致。
import json
from datasets import load_dataset
def to_chat(example):
# 系统提示固定,保证训练/推理一致
system = "你是工单结构化助手,只输出JSON,不要输出任何解释。"
user = f"{example['instruction']}\n{example['input']}"
assistant = example["output"]
return {
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
{"role": "assistant", "content": assistant},
]
}
raw = load_dataset("json", data_files="tickets_clean.jsonl", split="train")
ds = raw.map(to_chat, remove_columns=raw.column_names)
split = ds.train_test_split(test_size=0.05, seed=42)
train_ds, eval_ds = split["train"], split["test"]
print(len(train_ds), len(eval_ds)) # 7429 391
4.2 训练配置与训练脚本
下面是核心训练代码,bitsandbytes的4bit配置和LoRA的target_modules是关键,漏了down_proj效果会明显打折。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
model_id = "Qwen/Qwen2.5-7B-Instruct"
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb,
device_map={"": 0},
attn_implementation="flash_attention_2", # 4090支持,省显存提速
torch_dtype=torch.bfloat16,
)
model = prepare_model_for_kbit_training(model)
lora_cfg = 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_cfg)
model.print_trainable_parameters()
# trainable params: 40,370,176 || all params: 7,655,976,960 || trainable%: 0.527
sft_cfg = SFTConfig(
output_dir="./qwen2.5-7b-lora-ticket",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效batch 16
num_train_epochs=3,
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_strategy="steps",
save_steps=200,
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
max_seq_length=1024,
packing=True,
dataset_text_field="text", # 若用messages字段则交给chat template处理
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=sft_cfg,
train_dataset=train_ds,
eval_dataset=eval_ds,
tokenizer=tokenizer,
)
trainer.train()
trainer.model.save_pretrained("./lora_adapter")
实际跑下来:单步约1.9s,3个epoch共约1400步,总耗时2小时08分,显存峰值21.3GB(batch=2、grad_ckpt开启)。如果把batch提到4会OOM,别试。
4.3 loss曲线
训练日志的形态大致是这样(每10步记录):
- step 10:loss 1.87
- step 200:loss 1.12
- step 500:loss 0.83
- step 900:loss 0.69
- step 1400(结束):loss 0.62
验证loss在step 100时0.94,step 600左右降到0.71后基本走平,最后三个eval点分别是0.713/0.709/0.711。也就是说第2个epoch末基本收敛,第3个epoch主要在打磨格式细节,没有明显过拟合(验证loss没反弹)。如果你数据量更小(比如2000条以内),建议epoch降到2,否则容易记住训练集。
有个细节:packing开启后单步loss的抖动会变大(因为一个batch里拼了不同长度的样本),看趋势别看单点,我一开始被step 10的1.87吓到以为学习率炸了,其实是warmup还没走完。
五、踩坑与优化
坑1:prepare_model_for_kbit_training之后忘了开gradient checkpointing,显存直接冲到23.8GB然后OOM。加上gradient_checkpointing=True并配use_reentrant=False(否则和peft的梯度挂钩冲突会报错),峰值降到21.3GB。
坑2:target_modules漏了MLP层。第一版只挂q/k/v/o,术语准确率只有79%。补上gate/up/down后到88%。参数量从约13M涨到40M,训练时间多了约15分钟,值得。
坑3:训练和推理的chat template不一致。训练时我用SFTTrainer自动套template,推理时手写了prompt,结果模型输出带一堆`残留。后来统一用tokenizer.apply_chat_template(..., add_generation_prompt=True)`就好了。这个坑很隐蔽,loss曲线完全正常,只有推理时才暴露。
坑4:学习率。2e-4对LoRA是常见值,但我一开始试了5e-4,loss在第300步左右开始抖(1.2→1.6反复横跳),降到2e-4后平滑。QLoRA场景下学习率别贪大。
优化点:开flash_attention_2后单步从2.4s降到1.9s,吞吐提升约20%。另外packing=True让有效token利用率从约62%提到接近100%,这是训练时间能压到2小时的主因。
六、推理效果对比
推理时把adapter合并回基座(或直接peft加载),用同一套prompt、temperature=0.1、max_new_tokens=256,在自建200条测试集上对比。
| 指标 | 基座+few-shot | LoRA微调后 |
|---|---|---|
| 格式合规率(合法JSON且字段齐全) | 54% | 96% |
| 字段名完全正确率 | 71% | 99% |
| 领域术语准确率 | 61% | 88% |
| 紧急程度枚举值正确率 | 68% | 97% |
| 平均输出token数 | 187 | 96 |
几个直观例子:基座模型经常在JSON前后加"好的,以下是解析结果:"这种废话,微调后基本只吐JSON;"涉及模块"字段基座会把"支付网关"写成"支付系统",微调后对齐到内部术语表。剩下4%不合规的case主要是输入本身歧义(比如用户只说"系统卡了"),这个靠SFT解决不了,得补数据或加澄清逻辑。
延迟方面,合并后7B模型在4090上单条推理约0.6s(输出96 token),比调API快且便宜,这是我们最终决定本地部署的直接原因。
七、总结
这次微调验证了一件事:7B模型+LoRA在单张24G卡上做领域对齐是完全可行的,2小时训练、40M可训练参数、0.5%的参数量换来格式合规率54%→96%的提升,投入产出比很高。
给想复现的同学三条建议:一是版本一定锁死,trl和peft的API变动很勤;二是target_modules别只挂attention,MLP层对术语类任务影响明显;三是训练和推理的template必须一致,这个坑不看推理结果根本发现不了。代码和数据清洗脚本我已经整理好,核心部分就是上面两段,改改数据路径就能跑。