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

先说背景。我们团队做的是一个面向中小企业的SaaS客服系统,之前一直调OpenAI的API。问题有两个:一是成本,日均20万次调用,账单肉眼可见地涨;二是数据合规,客户对话内容不能随便出境。于是决定用开源7B模型自己部署。

一开始直接拿Qwen2.5-7B-Instruct做zero-shot推理,效果不能说不能用,只能说勉强。典型badcase是:用户问"我这个套餐能不能开票",基座模型会泛泛而谈"一般企业套餐可以开票,具体请咨询客服",而我们的业务规则是"标准版不支持,专业版及以上支持,需在订单页申请"。这类领域知识,prompt里塞不下全部,塞多了又挤占上下文。所以结论很明确:必须微调。

选LoRA而不是全参微调,理由很朴素:24GB显存全参微调7B(fp16权重就要14GB,加上优化器状态和梯度直接爆),而LoRA只训练约0.1%的参数,单卡完全够用。QLoRA(4bit量化基座+LoRA)我也试了,后面会讲为什么最终没用。

二、环境与版本

环境这块我踩过版本坑,直接给能跑通的组合:

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

特别提醒:transformers 4.44+ 和 trl 0.9.x 有API不兼容(SFTTrainer的dataset_text_field参数行为变了),我当时升上去直接报错,退回4.43.3才稳定。peft 0.12 对Qwen2系列支持没问题,再低(0.10以下)会有target_modules匹配不上的问题。

三、方案设计

整体思路:

  1. 数据:清洗成Alpaca格式的instruction/input/output三元组,约3200条,按9:1切训练/验证。
  2. 基座:Qwen2.5-7B-Instruct,fp16加载(不量化)。
  3. LoRA配置:r=16,lora_alpha=32,lora_dropout=0.05,target_modules覆盖q/k/v/o_proj和gate/up/down_proj(全线性层)。
  4. 训练:3 epoch,batch_size=2,gradient_accumulation=8(等效batch 16),lr=2e-4,cosine调度,warmup_ratio=0.03,bf16混合精度,gradient_checkpointing开启。
  5. 评估:训练loss/验证loss曲线 + 50条人工构造的领域测试集做准确率对比。

为什么target_modules要加MLP层?只调attention的话,我实测领域知识注入效果偏弱,loss降得慢;加上gate/up/down三个MLP投影后,参数量从约400万涨到约2000万(占7B的0.28%),但收敛明显更好,显存也就多几百MB,划算。

四、核心实现

4.1 数据准备

数据格式统一成:

{"instruction": "标准版套餐可以开发票吗?", "input": "", "output": "标准版套餐不支持开具发票。专业版及以上套餐支持开票,请在「订单中心-发票管理」中提交申请,电子发票一般1个工作日内开出。"}

加载和格式化的代码:

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)

dataset = load_dataset("json", data_files="data/train.json", split="train")
dataset = dataset.train_test_split(test_size=0.1, seed=42)

def format_sample(ex):
    messages = [
        {"role": "system", "content": "你是XX公司的客服助手,回答需严格依据公司业务规则。"},
        {"role": "user", "content": ex["instruction"] + ("\n" + ex["input"] if ex["input"] else "")},
        {"role": "assistant", "content": ex["output"]},
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False)
    return {"text": text}

train_ds = dataset["train"].map(format_sample, remove_columns=dataset["train"].column_names)
eval_ds = dataset["test"].map(format_sample, remove_columns=dataset["test"].column_names)
print(train_ds[0]["text"][:300])

这里我特意用 apply_chat_template 而不是手工拼prompt,因为Qwen2.5的chat template里带了特殊token(``等),手工拼很容易漏,导致训练和推理时格式不一致,loss看着降但推理效果差——这个坑我第一版就踩了。

4.2 训练配置

import torch
from transformers import AutoModelForCausalLM, TrainingArguments
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer

model = AutoModelForCausalLM.from_pretrained(
    model_path,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    trust_remote_code=True,
)
model.gradient_checkpointing_enable()
model.enable_input_require_grads()  # gradient checkpointing + LoRA 必须

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: 20,185,088 || all params: 7,635,689,472 || trainable%: 0.2644

args = TrainingArguments(
    output_dir="./output_lora",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    per_device_eval_batch_size=2,
    gradient_accumulation_steps=8,
    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,
    save_total_limit=2,
    bf16=True,
    gradient_checkpointing=True,
    optim="adamw_torch",
    report_to="none",
    max_grad_norm=1.0,
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    dataset_text_field="text",
    max_seq_length=1024,
    tokenizer=tokenizer,
    packing=False,
)
trainer.train()
trainer.save_model("./output_lora/final")

关键参数解读:lr=2e-4 是LoRA的常规区间(1e-4到3e-4),我试过5e-4,loss震荡明显;max_seq_length=1024 是因为我们的对话样本平均400 token,最长不到900,设1024够用还不浪费显存。packing=False 是为了让loss曲线更干净(packing会把多条样本拼一起,单步loss波动大)。

4.3 推理加载

from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.bfloat16, device_map="auto")
model = PeftModel.from_pretrained(base, "./output_lora/final")
model = model.merge_and_unload()  # 合并后推理更快,无额外延迟
model.eval()

def chat(q):
    msgs = [{"role": "system", "content": "你是XX公司的客服助手,回答需严格依据公司业务规则。"},
            {"role": "user", "content": q}]
    text = tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
    ids = tokenizer(text, return_tensors="pt").to(model.device)
    out = model.generate(**ids, max_new_tokens=256, temperature=0.1, do_sample=False)
    return tokenizer.decode(out[0][ids["input_ids"].shape[1]:], skip_special_tokens=True)

print(chat("标准版套餐可以开发票吗?"))

五、踩坑与优化

坑1:gradient checkpointing 下 loss 不降。 开了gradient_checkpointing但没调 enable_input_require_grads(),导致LoRA层梯度为None,训练跑得飞快但loss纹丝不动。这个坑排查了半小时,看 model.print_trainable_parameters() 正常,但实际梯度没回传。

坑2:QLoRA在我这个场景不划算。 我用bitsandbytes 4bit加载基座试了一版,显存从18GB降到9GB,但训练速度慢了约35%(4bit反量化有开销),而且最终验证loss比fp16 LoRA高0.04左右。既然24GB单卡fp16 LoRA能跑(峰值显存21.3GB),就没必要上QLoRA。QLoRA更适合显存≤16GB的场景。

坑3:过拟合。 第3个epoch后期验证loss开始抬头(0.69→0.73),训练loss还在降。我把dropout从0.05提到0.1、加了weight_decay=0.01后缓解,最终取第2.5个epoch附近的checkpoint(save_steps=200,约第600步)。数据量3200条对7B模型偏少,如果你们数据更少,建议epoch控制在2以内。

优化:学习率调度。 cosine + warmup_ratio=0.03 比constant稳,前期loss下降更平滑,没有明显spike。

六、效果数据

Loss曲线(每100步记录验证loss):

步数 train_loss eval_loss
100 1.52 1.41
300 0.98 0.92
600 0.72 0.71
900 0.67 0.75

训练总步数约1080(3200×0.9/16×3),耗时4小时37分,峰值显存21.3GB。最终train_loss 1.82→0.67,验证loss最低0.71(第600步),之后回升,印证了过拟合判断。

推理效果对比(自建50条领域测试集,人工判定答案是否包含正确业务规则):

模型 领域准确率 通用对话(MT-Bench简版20题)
Qwen2.5-7B-Instruct 基线 58% (29/50) 7.1
+ LoRA (本方案) 89% (44/50) 6.9

领域准确率提升31个百分点,通用能力只掉0.2分,可以接受。典型改善案例:问"专业版能开几张发票",基线答"通常可以开一张",微调后正确回答"专业版支持按订单拆分开具,单笔订单最多3张"。剩下6条错误主要是多轮上下文里的指代消解,单轮微调解决不了,后续考虑加多轮样本。

推理延迟方面,merge_and_unload后单次生成(256 token)在4090上约1.2秒,与基线基本一致,LoRA没有引入额外开销。

七、总结

这次微调的核心结论:单卡24GB用fp16 LoRA微调7B完全可行,r=16、alpha=32、lr=2e-4、target覆盖全线性层是一组比较稳的起点;数据质量比数据量重要,3200条清洗干净的样本就能带来30个点的领域提升;QLoRA不是银弹,显存够就别用。下一步我打算做两件事:一是补多轮对话样本,二是试试DPO对齐一下回答风格。代码和数据格式都在上面,直接抄能跑,版本号别乱升就行。