一、为什么我要自己微调一个7B模型

先说背景。我们团队做的是工业设备售后客服系统,之前一直用某商业API,效果还行但成本压不住——日均3万次调用,账单看得人心疼。后来想着用开源模型自己部署,试了Qwen2.5-7B-Instruct的原始版本,通用对话很流畅,但一碰到我们领域的专业问题就露馅了。

举几个真实翻车案例:
- 用户问“变频器报E07故障怎么处理”,原始模型回答“建议联系厂家售后”——废话,用户就是来找解决方案的
- 问“伺服电机编码器零位校准步骤”,模型给出了一套通用流程,但和我们设备型号的按键操作完全对不上
- 更离谱的是问“PLC模拟量输入模块接线”,模型把24V和0V说反了,这要真按它说的接,烧模块

所以决定微调。选LoRA而不是全参微调,原因很现实:全参7B模型至少需要4张A100 80G,我们只有一张4090。QLoRA虽然更省显存,但4bit量化会带来一定的精度损失,而且推理时还得合并权重,工程上多一步。综合考虑,用LoRA + 4bit加载(训练时量化,推理时合并)的方案。

二、环境与版本

这套环境跑了几个项目都稳定,直接列出来:

- GPU: NVIDIA RTX 4090 (24GB)
- CUDA: 12.1
- PyTorch: 2.3.1
- transformers: 4.44.2
- peft: 0.12.0
- bitsandbytes: 0.43.3
- accelerate: 0.34.2
- trl: 0.9.6
- datasets: 2.21.0

模型用的是 Qwen/Qwen2.5-7B-Instruct,从ModelScope拉的,国内下载快。这里提醒一句:transformers版本别低于4.37,Qwen2.5的tokenizer配置在老版本上会报KeyError: 'qwen2',我一开始用4.36踩过这个坑。

三、数据准备:3200条样本怎么来的

数据是这次微调最关键的部分,模型效果上限基本由数据决定。我们的数据来源有三块:

  1. 历史工单:从客服系统导出了近两年的工单记录,约1.2万条,但大部分是“已解决”三个字,真正有对话内容的只有4000多条
  2. 产品手册:把PDF手册用脚本切段,人工整理了600组问答对
  3. 人工构造:针对高频故障码,让售后工程师写了200条标准处理流程

清洗规则:
- 去掉对话轮次少于2的样本
- 去掉回答长度小于30字的(大多是“好的”“收到”这类)
- 同一问题去重,保留回答最详细的那条
- 统一格式为Alpaca风格

最终得到3200条高质量样本,按9:1划分训练集和验证集。数据格式长这样:

{
  "instruction": "变频器显示E07故障码,应该如何处理?",
  "input": "设备型号:ATV320,运行环境:粉尘车间",
  "output": "E07表示直流母线过压。处理步骤:1. 检查输入电压是否超过额定值10%;2. 确认减速时间参数dEC是否设置过短,建议从3.0s调整到5.0s;3. 若带大惯量负载,需加装制动电阻。若上述无效,测量母线电容容量,低于标称值80%需更换。"
}

数据处理代码不复杂,关键是prompt模板要和推理时保持一致,否则训练和推理分布对不上,效果会打折:

from datasets import load_dataset

def format_prompt(example):
    if example["input"]:
        user_content = f"{example['instruction']}\n补充信息:{example['input']}"
    else:
        user_content = example["instruction"]

    messages = [
        {"role": "system", "content": "你是工业设备售后技术支持工程师,回答要具体、可操作,给出明确步骤和参数。"},
        {"role": "user", "content": user_content},
        {"role": "assistant", "content": example["output"]}
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False)
    return {"text": text}

dataset = load_dataset("json", data_files="train_data.jsonl", split="train")
dataset = dataset.map(format_prompt, remove_columns=dataset.column_names)

注意apply_chat_template生成的文本末尾已经带了`,训练时labels的mask逻辑要把user部分的token设为-100,只对assistant回复计算loss。trl的SFTTrainer`默认会处理这个,但如果你自己写训练循环,这点必须手动做。

四、训练配置与核心代码

LoRA参数调了几轮,最终定下来的配置:

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
import torch

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

model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", 
                    "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    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.5273

可训练参数4037万,占比0.53%,这就是LoRA香的地方——单卡能跑,checkpoint只有150MB左右。

训练超参:

from transformers import TrainingArguments
from trl import SFTTrainer

training_args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora-cs",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_strategy="epoch",
    evaluation_strategy="epoch",
    bf16=True,
    gradient_checkpointing=True,
    optim="paged_adamw_8bit",
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    tokenizer=tokenizer,
    dataset_text_field="text",
    max_seq_length=1024,
    packing=False,
)

trainer.train()

有效batch size = 2 × 8 = 16,学习率2e-4是LoRA的常见起点。paged_adamw_8bit优化器对显存友好,配合gradient_checkpointing,显存峰值控制在18.6GB,4090跑起来还有余量。

五、踩过的坑

坑一:第一次跑直接OOM。 按网上教程设了batch_size=4,结果第一步就炸,显存飙到23GB+。原因是max_seq_length设了2048,而我们的数据平均长度只有380 token,纯浪费。改成1024后显存降到19GB以内。教训:先统计一下数据长度分布,max_seq_length取95分位数就行。

坑二:loss前200步剧烈震荡。 从1.83跳到2.4又掉回1.9,画出来像心电图。排查发现是warmup_ratio设了0.1,前10%的步数学习率爬升太慢,加上数据里有些长样本(1200+ token)在batch里占比不均。改成warmup_ratio=0.03,并对数据做了长度分桶(按token数排序后分batch),震荡明显收敛。

坑三:验证集loss不降反升。 第2个epoch验证loss从0.72涨到0.78,典型过拟合。3200条数据对7B模型来说偏少。解决办法:lora_dropout从0.05提到0.1,同时把epoch从5降到3。第3个epoch验证loss稳在0.69,训练loss 0.61,gap可接受。

六、Loss曲线与推理效果对比

训练loss曲线(每10步记录):

阶段 step train_loss eval_loss
初始 0 1.83 1.79
epoch1结束 600 0.94 0.88
epoch2结束 1200 0.68 0.72
epoch3结束 1800 0.61 0.69

曲线形态是标准的L型:前300步快速下降(1.83→1.1),之后平缓收敛。eval_loss在第2个epoch后基本走平,说明3个epoch是合理的停止点。

推理对比,用同一套100条测试集(覆盖故障码、参数设置、接线三类问题),人工评分标准:回答包含正确故障原因且步骤可操作记1分,否则0分。

模型 故障码类 参数设置类 接线类 总体准确率
Qwen2.5-7B原始 58% 71% 55% 62%
LoRA微调后 92% 88% 86% 89%

提升最明显的是故障码类,从58%到92%。原始模型经常“编”故障原因,微调后能准确对应到我们设备手册里的定义。接线类提升也大,因为训练数据里有大量带端子编号的标准答案。

通用能力方面,用C-Eval的机器和电气子集抽了200题测了一下,原始模型71.5分,微调后69.2分,下降2.3分。这个代价在可接受范围内,毕竟我们只关心领域任务。

推理部署时把LoRA权重合并进基座:

from peft import PeftModel

base_model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
model = PeftModel.from_pretrained(base_model, "./qwen2.5-7b-lora-cs/checkpoint-1800")
model = model.merge_and_unload()
model.save_pretrained("./qwen2.5-7b-cs-merged")

合并后模型14.2GB(bf16),用vLLM部署,单卡4090上并发8路时吞吐约1200 token/s,P99延迟380ms,完全够我们日均3万次的量。

七、总结

这次微调整体顺利,几个关键结论:

  1. 数据质量比数量重要。3200条精清洗的数据,效果远好于之前试过的1万条未清洗数据(那次准确率只到74%)。
  2. LoRA的r值不用贪大。r=16在7B模型上已经够用,试过r=32,效果提升不到1个百分点,但显存多了1.2GB,训练慢了18%。
  3. 4bit加载 + LoRA训练 + bf16合并这套组合,是单卡24GB玩7B模型的甜点方案。
  4. 验证集loss走平就该停,别迷信固定epoch数。

下一步打算试试把训练数据扩到8000条,看准确率能不能推到93%以上。另外QLoRA也准备跑一版对比,如果精度损失在1%以内,以后就用QLoRA,显存还能再省3GB,能开更大的batch。

代码和数据模板都整理在仓库里了,有需要的可以自取。有问题评论区聊。