微调的工程实践——不是研究员才能做的事
微调的工程实践——不是研究员才能做的事
适读人群:想动手做模型微调的工程师 | 阅读时长:约20分钟 | 核心价值:LoRA/QLoRA 微调的完整工程流程,含真实成本数据
我第一次做微调是在一个法律文书生成的项目里。需求很简单:基础模型生成的法律文书格式不规范,要加很多人工后处理。甲方希望模型能直接输出符合他们格式要求的文书。
我当时以为微调是研究员才做的事,自己要从头看很多论文,要有专用的 GPU 集群。
结果发现:LoRA 微调的门槛比想象的低得多,用一块 A100(租 GPU)跑 QLoRA 的成本很可控,整个流程 3 天能跑通,不需要是研究员,工程师完全可以做。
这篇文章把那个项目里的完整经历写出来,从数据准备到训练到评估到部署,每个环节都有具体的代码和数据。
先搞清楚你需不需要微调
微调不是万能药,在决定微调之前,先问三个问题:
提示词工程解决不了吗? 很多人以为需要微调的问题,换一个更好的 prompt 就解决了。格式问题、风格问题、很多都可以用 few-shot examples 解决,不需要微调。
RAG 解决不了吗? 如果问题是"模型不知道某些特定领域的知识",RAG 通常比微调更合适——更新方便,不需要重新训练。
真的需要改变模型的输出行为吗? 微调最适合的是:输出格式高度特化、风格要求很具体、需要学习某种专有的表达模式。
我的那个项目符合最后一条:法律文书有非常具体的格式要求(条款编号方式、段落结构、特定法律术语的使用),这种格式知识用 prompt 难以完整传达,用微调就很自然。
数据准备:最花时间的环节
微调效果 80% 取决于数据质量,20% 取决于训练配置。这不是夸张,是真实体验。
我们的数据来源是甲方历史积累的法律文书,大概有 3000 份文件。但原始文件不能直接用,要做几件事:
格式转化。 原始文件是 Word 格式,要转成干净的纯文本。
构造问答对。 微调用的训练数据通常是"指令-输出"对。我们的做法是:用原始文书的案情描述作为"指令"(输入),用标准格式的文书正文作为"输出"。
import anthropic
import json
from pathlib import Path
def extract_training_pairs_from_document(doc_text: str) -> dict:
"""
从一份法律文书中提取训练对
输入:完整文书文本
输出:{instruction: 案情描述, output: 标准格式文书}
"""
client = anthropic.Anthropic()
prompt = f"""以下是一份法律文书。请提取:
1. 案情描述部分(通常在"基本案情"或"事实认定"部分)
2. 裁判文书的正文部分(包含格式完整的裁判内容)
以 JSON 格式返回:
{{
"instruction": "请根据以下案情,起草一份裁定书:[案情描述]",
"output": "文书正文内容(保留完整格式)"
}}
文书内容:
{doc_text[:4000]}"""
response = client.messages.create(
model="claude-haiku-20240307",
max_tokens=2000,
messages=[{"role": "user", "content": prompt}]
)
import re
json_match = re.search(r'\{.*\}', response.content[0].text, re.DOTALL)
if json_match:
return json.loads(json_match.group())
return None
def prepare_training_dataset(docs_dir: str, output_file: str):
"""
批量处理文书,构建训练集
最终格式符合 Alpaca/ShareGPT 格式
"""
docs_path = Path(docs_dir)
training_data = []
for doc_file in docs_path.glob("*.txt"):
doc_text = doc_file.read_text(encoding='utf-8')
pair = extract_training_pairs_from_document(doc_text)
if pair and len(pair.get('output', '')) > 100: # 过滤掉太短的输出
training_data.append({
"instruction": pair["instruction"],
"input": "",
"output": pair["output"]
})
# 划分训练集和验证集(9:1)
split_idx = int(len(training_data) * 0.9)
train_set = training_data[:split_idx]
val_set = training_data[split_idx:]
with open(f"{output_file}_train.json", 'w', encoding='utf-8') as f:
json.dump(train_set, f, ensure_ascii=False, indent=2)
with open(f"{output_file}_val.json", 'w', encoding='utf-8') as f:
json.dump(val_set, f, ensure_ascii=False, indent=2)
print(f"训练集:{len(train_set)} 条,验证集:{len(val_set)} 条")数据清洗要做:去重(有些文书格式雷同)、过滤质量差的样本(输出太短、格式明显错误的)、检查数据平衡(不同类型文书的比例)。
我们最终用了 2400 条训练样本,270 条验证样本。数据量不大,但质量不错。
QLoRA 微调:低资源下的实战配置
QLoRA(Quantized LoRA)是低资源微调的主流方案,核心思想是:把基础模型量化到 4-bit(大幅降低显存需求),只训练 LoRA 的低秩适配矩阵(参数量很小)。
我们用的基础模型是 Qwen2.5-7B-Instruct(7B 参数,中文效果好),在一块 A100 40GB 上跑 QLoRA。
# train_qlora.py
import torch
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
def load_model_for_qlora(model_name: str):
"""加载量化模型"""
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 4-bit 量化
bnb_4bit_quant_type="nf4", # NF4 量化类型
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True, # 双重量化,进一步省显存
)
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True
)
tokenizer = AutoTokenizer.from_pretrained(
model_name,
trust_remote_code=True
)
tokenizer.pad_token = tokenizer.eos_token
return model, tokenizer
def setup_lora(model):
"""配置 LoRA 参数"""
# 先准备模型用于 k-bit 训练
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=16, # LoRA 秩,越大参数越多效果可能越好但训练越慢
lora_alpha=32, # LoRA 缩放因子,通常设为 r 的 2 倍
target_modules=[ # 哪些层加 LoRA 适配
"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: 20,000,000 || all params: 7,241,748,480 || trainable%: 0.28%
return model
def train(
model_name: str = "Qwen/Qwen2.5-7B-Instruct",
train_data_path: str = "data/train.json",
val_data_path: str = "data/val.json",
output_dir: str = "output/lora_weights"
):
model, tokenizer = load_model_for_qlora(model_name)
model = setup_lora(model)
# 加载数据集
dataset = load_dataset('json', data_files={
'train': train_data_path,
'validation': val_data_path
})
def format_instruction(sample):
"""格式化为模型输入格式"""
return f"""<|im_start|>system
你是一个专业的法律文书助手。<|im_end|>
<|im_start|>user
{sample['instruction']}<|im_end|>
<|im_start|>assistant
{sample['output']}<|im_end|>"""
training_args = TrainingArguments(
output_dir=output_dir,
num_train_epochs=3,
per_device_train_batch_size=4,
per_device_eval_batch_size=4,
gradient_accumulation_steps=4, # 等效 batch_size = 4*4 = 16
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
evaluation_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=50,
save_total_limit=3,
load_best_model_at_end=True,
bf16=True, # 使用 bfloat16 计算
gradient_checkpointing=True, # 省显存
report_to="wandb", # 用 WandB 跟踪训练
)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset['train'],
eval_dataset=dataset['validation'],
formatting_func=format_instruction,
max_seq_length=2048,
args=training_args,
)
trainer.train()
# 保存 LoRA 权重
trainer.model.save_pretrained(f"{output_dir}/final")
tokenizer.save_pretrained(f"{output_dir}/final")
print("训练完成,LoRA 权重已保存")
if __name__ == "__main__":
train()真实的成本数据
这是很多教程不写的部分,我把实际数字列出来。
GPU 租用: A100 40GB,在 AutoDL 上的价格大约 ¥25-30/小时。
训练时间: 2400 条数据,7B 模型,QLoRA,3 个 epoch,大约 4-5 小时。
总训练成本: 约 ¥120-150。
推理部署: 合并 LoRA 权重后的完整模型约 14GB(fp16),可以用 vLLM 在一块 A10 或 4090 上推理。
数据准备成本: 用 Claude API 处理 3000 份文书,约 $30。
整个项目的 AI 成本(不含人工)约 ¥1000 以内,大头是反复调试的实验成本。
评估:怎么知道微调效果好不好
这是经常被忽视的环节。很多人训练完直接上线,不做严格评估。
我们的评估分两部分:
自动化指标: 用验证集算 ROUGE 分数(衡量生成文本和参考文本的重叠度)。训练前基础模型 ROUGE-L 约 0.35,微调后达到 0.71,提升明显。
人工评估: 更重要。我们让甲方的两位法务人员,盲测对比基础模型和微调模型各 50 个输出,不告诉他们哪个是哪个,让他们打分。微调模型的"格式正确性"得分从 3.2/5 提升到 4.6/5,"直接可用率"(不需要修改就能用)从 22% 提升到 68%。
这个人工评估结果比任何自动指标都有说服力,也是甲方最在意的。
部署:合并权重和 vLLM 推理
LoRA 训练出来的是适配权重,生产部署时通常要合并到基础模型:
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
def merge_lora_weights(
base_model_name: str,
lora_weights_path: str,
output_path: str
):
"""合并 LoRA 权重到基础模型,方便部署"""
print("加载基础模型...")
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
torch_dtype=torch.float16,
device_map="cpu" # 先在 CPU 上合并,省 GPU 显存
)
print("加载 LoRA 权重...")
model = PeftModel.from_pretrained(base_model, lora_weights_path)
print("合并权重...")
model = model.merge_and_unload()
print("保存合并后的模型...")
model.save_pretrained(output_path)
tokenizer = AutoTokenizer.from_pretrained(base_model_name)
tokenizer.save_pretrained(output_path)
print(f"合并完成,保存到 {output_path}")
# 然后用 vLLM 部署
# vllm serve ./output/merged_model --port 8000微调真的不是研究员的专利。工程师需要的是:理解数据准备的重要性、会用 PEFT 库的标准接口、知道怎么评估效果。这些都是工程能力,不需要深度学习的理论背景。
