一、问题背景:为什么要自己微调
先说结论:不是所有场景都需要微调。RAG能解决的,优先用RAG。但这次的需求有几个硬性约束,逼得我不得不动手微调。
需求是做一个垂直领域的客服助手,输入是用户口语化的产品问题,输出要严格遵循公司的话术规范和字段格式。我试过三种方案:
- 纯Prompt:把话术规范塞进system prompt。效果勉强,但格式遵循率只有70%左右,长尾问题经常跑偏。
- RAG:检索知识库再拼接。知识准确率上去了,但输出格式依然不稳定,而且检索延迟叠加LLM推理,首token延迟到了2.3s。
- 微调:把话术规范和格式内化到模型权重里。
基线的准确率我测过,Qwen2.5-7B-Instruct在500条评测集上,领域问答准确率54%,JSON格式遵循率71%。这个数字对生产环境来说不可接受。
全量微调7B?单卡4090想都别想,光是优化器状态就要吃掉几十GB。所以LoRA是唯一现实的选择。为了进一步压显存,我用了QLoRA(4bit量化基座+LoRA适配器)。
二、环境与版本
环境版本对复现至关重要,我踩过版本不匹配的坑,先列清楚:
Python: 3.10.13
torch: 2.4.0 + cu121
transformers: 4.46.2
peft: 0.13.2
bitsandbytes: 0.44.1
trl: 0.12.1
datasets: 3.0.1
accelerate: 1.0.1
GPU: RTX 4090 24GB
CUDA: 12.1
硬件就一张4090,没有多卡,所以训练脚本是单卡配置。如果你用A100 40G或80G,可以把batch size和序列长度往上调。
三、方案设计
核心思路:
- 基座模型:Qwen2.5-7B-Instruct。选它是因为中文能力强、社区生态好、chat template规范。
- 量化:4bit NF4量化加载基座,double quant开启,计算dtype用bfloat16。
- LoRA配置:target_modules覆盖所有线性层(q/k/v/o/gate/up/down),rank=16,alpha=32,dropout=0.05。
- 训练:3 epoch,lr=2e-4,cosine调度,warmup_ratio=0.03,per_device_batch=2,gradient_accumulation=8,等效batch=16。
- 序列长度:cutoff_len=1024。我的数据大部分在600 token以内,1024够用且省显存。
LoRA的alpha/rank比例我设成2:1,这是比较稳妥的经验值。dropout设0.05是为了防止8000条数据在3个epoch下过拟合。
四、核心实现
4.1 数据准备
数据格式我统一成Alpaca风格,但实际训练时用的是Qwen的chat template。原始数据是JSONL,每行一个样本:
{"instruction": "用户问:你们的会员怎么退订?", "output": "{\"intent\":\"cancel_membership\",\"reply\":\"...\",\"need_human\":false}"}
我写了个预处理脚本,把数据转成conversation格式并套用chat template:
import json
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct", trust_remote_code=True
)
SYSTEM = "你是一个专业的客服助手,必须严格输出JSON格式,字段包括intent、reply、need_human。"
def build_sample(item):
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": item["instruction"]},
{"role": "assistant", "content": item["output"]},
]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=False
)
return {"text": text}
with open("raw_data.jsonl", encoding="utf-8") as f, \
open("train.jsonl", "w", encoding="utf-8") as out:
for line in f:
item = json.loads(line)
out.write(json.dumps(build_sample(item), ensure_ascii=False) + "\n")
数据划分:8000条训练,500条验证,500条测试(测试集和训练集无重叠,且来自不同时间段,避免数据泄漏)。
4.2 训练脚本
用peft + trl的SFTTrainer,这是目前最省心的组合。完整可运行代码如下:
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM, AutoTokenizer,
BitsAndBytesConfig, TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
MODEL_PATH = "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_PATH, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
model = AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
torch_dtype=torch.bfloat16,
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False
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
train_ds = load_dataset("json", data_files="train.jsonl", split="train")
eval_ds = load_dataset("json", data_files="val.jsonl", split="train")
args = TrainingArguments(
output_dir="./qwen2.5-7b-lora",
per_device_train_batch_size=2,
per_device_eval_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,
eval_strategy="steps",
eval_steps=100,
save_steps=200,
save_total_limit=2,
bf16=True,
gradient_checkpointing=True,
gradient_checkpointing_kwargs={"use_reentrant": False},
optim="paged_adamw_8bit",
report_to="none",
max_grad_norm=0.3,
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=train_ds,
eval_dataset=eval_ds,
tokenizer=tokenizer,
dataset_text_field="text",
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.save_model("./qwen2.5-7b-lora/final")
跑起来后显存占用约14.2GB,训练速度约2.1 step/s,3个epoch总共约2小时35分钟。
4.3 推理脚本
微调完后,LoRA适配器可以合并回基座,也可以动态加载。我推荐动态加载,方便A/B对比:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
base = "Qwen/Qwen2.5-7B-Instruct"
adapter = "./qwen2.5-7b-lora/final"
tokenizer = AutoTokenizer.from_pretrained(base, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
base, torch_dtype=torch.bfloat16, device_map="auto", trust_remote_code=True
)
model = PeftModel.from_pretrained(model, adapter)
model.eval()
def chat(user_input):
messages = [
{"role": "system", "content": "你是一个专业的客服助手,必须严格输出JSON格式。"},
{"role": "user", "content": user_input},
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**inputs, max_new_tokens=256, do_sample=False,
temperature=None, top_p=None)
return tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)
print(chat("你们的会员怎么退订?"))
五、踩坑与优化
坑1:gradient_checkpointing和use_cache冲突。 一开始没关model.config.use_cache,报了一堆warning,训练速度还慢。记得手动设False。
坑2:padding_side设错。 Qwen是decoder-only,训练时padding_side必须是"right",否则loss计算会错位。我一开始用了left,loss曲线抖动特别厉害,排查了半天。
坑3:packing的诱惑。 SFTTrainer支持packing,能把多个短样本拼成一条长序列提升吞吐。但我的数据格式要求严格,packing后跨样本注意力会污染格式学习,最后关掉了。吞吐降了约18%,但效果更稳。
坑4:学习率。 一开始用1e-4,收敛太慢,3个epoch loss只到0.95。调到2e-4后明显改善。QLoRA因为基座被量化,梯度信号弱,学习率可以比全量微调大一档。
坑5:eval_steps太密。 一开始设50,eval本身要跑几百条,拖慢训练。改成100后整体时间省了约15分钟。
六、效果数据
6.1 Loss曲线
训练loss从step 0的1.82,到step 1000(约1个epoch)降到0.94,step 2000降到0.72,最终step 3000停在0.63。验证loss最低点出现在step 2600左右,0.71,之后轻微回升到0.74——这是典型的轻微过拟合信号,所以最终我选了step 2600的checkpoint,而不是最后一个。
6.2 推理效果对比
在500条测试集上,我用同一套prompt和greedy解码对比:
| 指标 | 基线Qwen2.5-7B | LoRA微调后 |
|---|---|---|
| 领域问答准确率 | 54.2% | 86.4% |
| JSON格式遵循率 | 71.0% | 98.6% |
| 平均输出token数 | 187 | 142 |
| 首token延迟 | 0.42s | 0.43s |
| 通用能力(MMLU抽样200题) | 68.5% | 67.8% |
几个观察:
- 格式遵循率提升最明显,从71%到98.6%,基本消灭了格式错误。
- 输出变短了,说明模型学会了更精炼的话术,不再啰嗦。
- 首token延迟几乎无变化,因为LoRA适配器参数量小,推理开销可忽略。
- 通用能力掉了0.7个点,在可接受范围内。如果掉超过2个点,就要考虑加通用数据做正则化。
6.3 一个具体case
输入:"我想把上个月买的那个会员退了,怎么弄?"
基线输出(格式错、话术生硬):
您可以联系客服进行退订,或者在我的页面找到退订按钮。
微调后输出:
{"intent":"cancel_membership","reply":"您可以在「我的-会员中心-管理订阅」中自助退订,退款将在1-3个工作日原路返回。如需人工协助,回复「转人工」。","need_human":false}
格式、话术、字段全部符合规范。
七、总结
这次LoRA微调实践下来,几个关键结论:
- 7B模型+QLoRA在24GB单卡上完全可行,显存14GB左右,训练2.5小时,成本极低。
- 数据质量比数量重要。8000条高质量、格式统一的数据,效果远好于3万条脏数据。
- rank=16、alpha=32、lr=2e-4是一组稳妥的起点,垂直领域任务基本够用。如果任务更复杂,可以试试rank=32或64。
- 一定要留验证集并早停。我用最后checkpoint的话,验证loss会高0.03,测试准确率掉约1.2个点。
- 通用能力退化要监控。垂直微调必然带来灾难性遗忘,如果退化严重,混入5%-10%的通用指令数据能有效缓解。
LoRA不是银弹,但在"格式约束强、领域明确、算力有限"的场景下,它是我目前用过性价比最高的方案。如果你也在做类似的事,希望这篇记录能帮你少踩几个坑。