一、问题背景:为什么要自己微调

先说结论:不是所有场景都需要微调,但有些场景不微调真的不行。

我这次的任务是给一个内部知识问答系统做模型适配。基座模型选的是Qwen2.5-7B-Instruct,通用能力不错,但直接用它回答业务问题时,问题很明显:

  • 输出格式不稳定。要求返回JSON,它经常给你加一段“好的,以下是……”的开场白。
  • 领域术语理解偏差。我们内部有一批专有名词(比如特定的产品代号、流程名称),基座模型要么不认识,要么理解成字面意思。
  • 回答风格不对。业务方希望是简洁的分点回答,基座模型倾向于长篇大论。

一开始试过纯Prompt工程,写了很长的system prompt加few-shot示例。效果能提升到70%左右,但有几个硬伤:prompt太长导致推理成本上升、few-shot示例占上下文、而且格式遵循还是偶尔翻车。

所以决定上LoRA微调。选择LoRA而不是全量微调的原因很实际:全量微调7B模型至少需要4张A100 80G,我手上只有一张RTX 4090 24G。QLoRA(4-bit量化+LoRA)能把这个门槛降到单卡可跑。

二、环境与版本

先列一下完整环境,版本号很重要,LoRA这块不同版本的API差异不小:

GPU: NVIDIA RTX 4090 24GB
CUDA: 12.1
PyTorch: 2.3.1
transformers: 4.44.2
peft: 0.12.0
bitsandbytes: 0.43.3
trl: 0.9.6
datasets: 2.20.0
accelerate: 0.33.0

模型用的是 Qwen/Qwen2.5-7B-Instruct,从HuggingFace拉取。这里提醒一句:bitsandbytes在Windows上支持不好,我是Ubuntu 22.04环境,Windows用户建议用WSL2。

三、方案设计

整体方案有几个关键决策,先说清楚为什么这么选:

1. 量化策略:4-bit NF4 + 双重量化

QLoRA的核心是把基座模型量化到4-bit存储,训练时反量化计算。用NF4(NormalFloat4)而不是普通的int4,因为NF4对正态分布的权重更友好,精度损失小。双重量化(double quantization)进一步压缩量化常数,能再省约0.4GB显存。

2. LoRA配置:r=16, alpha=32, target_modules覆盖全部线性层

rank选16是经验值。试过r=8,欠拟合明显;r=32效果提升有限但显存和时间成本上去了。alpha=2*r是常见做法。target_modules我覆盖了q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj全部七个,只调q/v的话表达能力不够。

3. 训练数据:8000条指令样本,格式统一

数据是业务方提供的问答对,加上我手工构造的一部分格式遵循样本。统一转成Alpaca格式(instruction/input/output),用chat template包装。

4. 训练超参

  • epoch: 3
  • batch_size: 2(per device),gradient_accumulation: 8,等效batch 16
  • learning_rate: 2e-4,cosine调度,warmup_ratio 0.03
  • max_length: 1024
  • optimizer: paged_adamw_8bit(省显存)
  • fp16: False, bf16: True(4090支持bf16)

四、核心实现

4.1 数据准备

数据格式统一成对话形式,用Qwen自己的chat template:

import json
from datasets import Dataset

def build_dataset(path):
    records = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            item = json.loads(line)
            messages = [
                {"role": "system", "content": "你是一个严谨的业务助手,回答简洁、分点、必要时输出JSON。"},
                {"role": "user", "content": item["instruction"] + ("\n" + item["input"] if item["input"] else "")},
                {"role": "assistant", "content": item["output"]},
            ]
            records.append({"messages": messages})
    return Dataset.from_list(records)

dataset = build_dataset("train.jsonl")
dataset = dataset.train_test_split(test_size=0.05, seed=42)
print(dataset)
# DatasetDict({
#   train: Dataset({features: ['messages'], num_rows: 7600})
#   test:  Dataset({features: ['messages'], num_rows: 400})
# })

数据量不大,8000条,但质量比数量重要。我清洗掉了约600条低质量样本(回答过短、格式错误、重复问题),最终7600条训练。

4.2 模型加载与LoRA注入

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

model_name = "Qwen/Qwen2.5-7B-Instruct"

bnb_config = 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_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    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,
    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,986,688 || trainable%: 0.5273

可训练参数约4037万,占总参数的0.53%。这就是LoRA的核心优势:只训练极小一部分参数。

4.3 训练配置

用TRL的SFTTrainer,注意版本0.9.6的API和旧版有差异:

from transformers import TrainingArguments
from trl import SFTTrainer

def formatting_func(example):
    return tokenizer.apply_chat_template(
        example["messages"], tokenize=False, add_generation_prompt=False
    )

training_args = TrainingArguments(
    output_dir="./output_lora",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_strategy="epoch",
    evaluation_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    max_grad_norm=0.3,
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset["train"],
    eval_dataset=dataset["test"],
    formatting_func=formatting_func,
    max_seq_length=1024,
    packing=False,
)

trainer.train()
trainer.save_model("./output_lora/final")

训练时长:3个epoch,7600条样本,等效batch 16,大约475个step/epoch,总1425步。4090上跑了约6小时10分钟,平均每步15.6秒。显存峰值18.7GB。

五、踩坑与优化

记录几个真实踩过的坑:

坑1:loss不下降,卡在1.8

第一次跑的时候learning_rate设了1e-4,3个epoch后loss只从1.82降到1.65,基本没学到东西。后来把lr提到2e-4,同时把target_modules从只调q/v扩展到全部线性层,loss才正常下降。QLoRA的lr一般要比全量微调高一个量级,因为4-bit量化本身有噪声,学习信号弱。

坑2:gradient_checkpointing和use_reentrant

transformers 4.44 + peft 0.12 组合下,gradient_checkpointing默认的use_reentrant=True会报“element 0 of tensors does not require grad”的错。必须显式传 gradient_checkpointing_kwargs={"use_reentrant": False}。

坑3:packing导致loss异常

一开始开了packing=True想提升吞吐,结果验证集loss飙到2.5。原因是packing把多条短样本拼进一个1024的序列,attention mask处理不当,模型学到了跨样本的伪关联。关掉packing后正常。数据本身长度分布不极端的话,没必要开packing。

坑4:eval阶段OOM

训练时18.7GB没事,eval时突然OOM。因为SFTTrainer的eval默认batch更大。把per_device_eval_batch_size显式设成1才解决。

优化:学习率调度

试过linear和cosine,cosine在最后阶段loss更平滑,验证集loss低约0.03。warmup_ratio从0.1降到0.03,因为数据量不大,warmup太长浪费step。

六、效果数据

6.1 Loss曲线

训练loss从1.82起步,前100步快速降到1.15左右,之后平缓下降:

  • Step 100: train_loss 1.15
  • Step 475 (epoch 1): train_loss 0.89, eval_loss 0.92
  • Step 950 (epoch 2): train_loss 0.76, eval_loss 0.80
  • Step 1425 (epoch 3): train_loss 0.71, eval_loss 0.78

epoch 3相比epoch 2,eval_loss只降了0.02,说明已经接近收敛,再多训会过拟合。这也是我选3个epoch的原因。

6.2 推理效果对比

合并LoRA权重后(用peft的merge_and_unload,注意要先反量化回fp16),在同一批200条测试集上对比基座模型和微调模型:

指标 基座模型 LoRA微调后
格式遵循准确率(JSON/分点) 62% 89%
领域术语理解正确率 58% 91%
回答简洁度(人工评分1-5) 3.2 4.4
通用对话能力(MMLU子集500题) 71.3% 70.8%

格式遵循和术语理解提升明显,通用能力只掉了0.5个百分点,基本可忽略。这是LoRA的典型特征:局部适配,不灾难性遗忘。

一个具体case对比:

输入:“查询订单A12345的退款状态,用JSON返回”

基座模型输出:

好的,我来帮您查询订单A12345的退款状态。根据系统信息,该订单的退款状态是处理中,预计3-5个工作日到账。

微调后输出:

json {"order_id": "A12345", "refund_status": "processing", "estimated_days": "3-5"}

格式遵循的差异一目了然。

6.3 推理性能

合并后的模型用vLLM部署,fp16精度,单卡4090:

  • 首token延迟(TTFT):约180ms(输入512 token)
  • 吞吐:约38 token/s(batch=1)
  • 显存占用:14.2GB

如果用4-bit量化部署,显存能压到7GB左右,吞吐略降5%左右。

七、总结

这次LoRA微调的整体感受:

  1. QLoRA让单卡微调7B成为现实。4090 24G跑7B的4-bit LoRA,显存峰值18.7GB,还有余量。如果用QLoRA跑13B,24G也勉强够(显存会到22GB左右),但序列长度得压到512。

  2. 数据质量 > 数据数量。7600条清洗后的数据,效果比一开始1.2万条未清洗的好。低质量样本对LoRA的伤害比对全量微调更大,因为可训练参数少,噪声样本的梯度占比高。

  3. 超参没有银弹。lr=2e-4、r=16、覆盖全部线性层,这组配置在我的任务上work,但换任务大概率要调。建议先用小规模数据(1000条)快速跑一轮验证配置,再上全量。

  4. 评估要分层。光看loss不够,一定要有任务层面的评估集。我这次准备了200条人工标注的测试样本,分格式、术语、简洁度三个维度打分,比单看eval_loss有说服力得多。

  5. 合并权重别忘了反量化。merge_and_unload前如果模型还是4-bit,合并出来的权重精度会出问题。正确做法是先load_in_4bit=False加载基座,再加载LoRA adapter合并,或者用peft的dequantize逻辑处理。

代码和数据格式都在上面了,基本可以直接跑。有问题的欢迎评论区交流。