LoRA微调实战-LLaMA-Factory全流程指南
LoRA 微调实战:用 LLaMA-Factory 跑通完整微调流程
一、为什么选 LLaMA-Factory
说到微调工具,市面上的选择不少:HuggingFace Transformers 自带的 Trainer、Axolotl、Unsloth、Firefly……但如果你要选一个上手最快、生态最全、文档最友好的,LLaMA-Factory 是第一推荐。
它的核心优势:
- 支持模型多:Qwen、LLaMA、ChatGLM、Baichuan、DeepSeek、Mistral、Yi 等主流开源模型全部内置
- 支持方法全:LoRA、QLoRA、全量微调、DPO、ORPO、PPO、KTO 一站式
- 数据格式标准化:Alpaca 格式和 ShareGPT 格式两套方案,覆盖几乎全部场景
- Web UI + CLI 双模式:不会命令行的同学也能用
- 内置评估:训练完直接跑评估,不用切换工具
一句话:你把注意力放在数据和实验设计上,工程细节 LLaMA-Factory 帮你搞定。
二、环境搭建
2.1 硬件要求
| 模型规模 | 微调方式 | 最低显存 | 推荐配置 |
|---|---|---|---|
| 7B | LoRA (FP16) | 16 GB | RTX 3090/4090 (24GB) |
| 7B | QLoRA (4-bit) | 8 GB | RTX 3060/4060 (12GB) |
| 13B | QLoRA (4-bit) | 14 GB | RTX 3090/4090 (24GB) |
| 34B | QLoRA (4-bit) | 20 GB | A100/A6000 (48GB) |
| 72B | QLoRA (4-bit) | 40 GB | A100 80GB / 双卡 4090 |
2.2 安装步骤
# 1. 创建虚拟环境
conda create -n llamafactory python=3.10 -y
conda activate llamafactory
# 2. 克隆仓库
git clone https://github.com/hiyouga/LLaMA-Factory.git
cd LLaMA-Factory
# 3. 安装依赖
pip install -e ".[torch,metrics]"
# 4. (可选) 如果使用 QLoRA,安装 bitsandbytes
pip install bitsandbytes
# 5. (可选) 安装 Flash Attention 加速训练
pip install flash-attn --no-build-isolation
验证安装是否成功:
llamafactory-cli version
2.3 启动 Web UI(推荐新手)
llamafactory-cli webui
浏览器打开 http://localhost:7860,就能看到图形化界面。但本文主要用命令行演示——更适合脚本化、可复现的工作流。
三、数据集构建
数据是微调的灵魂。模型能学到什么,取决于你喂给它什么。
3.1 格式选择
LLaMA-Factory 支持两种主流数据格式:
Alpaca 格式(适合单轮指令任务):
[
{
"instruction": "将以下句子翻译成英文:今天天气真好。",
"input": "",
"output": "The weather is really nice today."
},
{
"instruction": "给一个Python函数,计算两个数的最大公约数。",
"input": "",
"output": "def gcd(a, b):\n while b:\n a, b = b, a % b\n return a"
}
]
instruction:任务描述(必填)input:额外输入(选填,没有就留空字符串)output:期望输出(必填)- 如果
input不为空,最终 prompt = instruction + “\n” + input
ShareGPT 格式(适合多轮对话):
[
{
"conversations": [
{"from": "human", "value": "我想学编程,但不知道从哪开始"},
{"from": "gpt", "value": "推荐从 Python 开始。语法简洁、社区活跃、学习资源丰富。建议先看官方教程,再动手写小项目。"},
{"from": "human", "value": "那 Python 学了能做哪些工作?"},
{"from": "gpt", "value": "方向很多:Web 开发(Django/Flask)、数据分析(pandas)、AI/机器学习(PyTorch)、自动化脚本、爬虫等。"}
]
}
]
- 每条对话是一个
conversations列表 - 角色可以是
human/gpt或user/assistant - 多轮对话会保留完整历史作为训练上下文
3.2 数据集定义
写好 JSON 文件后,还需要在 LLaMA-Factory 中注册。编辑 data/dataset_info.json:
{
"my_custom_dataset": {
"file_name": "my_data.json",
"formatting": "sharegpt",
"columns": {
"messages": "conversations"
},
"tags": {
"role_tag": "from",
"content_tag": "value",
"user_tag": "human",
"assistant_tag": "gpt"
}
}
}
如果是 Alpaca 格式,配置更简单:
{
"my_alpaca_data": {
"file_name": "my_alpaca_data.json",
"columns": {
"prompt": "instruction",
"query": "input",
"response": "output"
}
}
}
3.3 数据质量检查清单
在开始训练前,逐条检查你的数据:
- 无重复:重复样本会让模型在某些 pattern 上过拟合,对其他样本欠拟合
- 输出格式一致:不要有的用 markdown 有的不用,有的加前缀有的不加
- 指令多样性:覆盖目标场景的各种问法,包括边缘情况
- 答案正确性:如果做推理类任务,务必核查答案是否正确——一条错误标注的危害大于十条未标注
- 长度分布合理:检查 token 长度分布,异常长的样本可能被截断或需要单独处理
- 无格式错误:JSON 格式严格合法,不要有 trailing comma、编码问题
3.4 数据量规划
参考 LIMA 论文的结论和社区经验:
| 任务类型 | 推荐数据量 | 说明 |
|---|---|---|
| 单任务指令(如翻译) | 500-2K | 任务简单,少量数据即可 |
| 通用对话 | 3K-10K | 需要覆盖多样化的表达 |
| 领域知识注入 | 5K-20K | 需要足够覆盖关键知识点 |
| 角色扮演 | 1K-5K | 重点是风格一致性而非数量 |
| 复杂推理 | 2K-10K | 需要正确的推理链(CoT) |
记住:质量 > 数量。100 条精心编写的样本,效果远好于 10000 条含错的数据。
3.5 训练/验证集划分
import json
import random
random.seed(42)
with open("my_data.json", "r", encoding="utf-8") as f:
data = json.load(f)
random.shuffle(data)
split = int(len(data) * 0.9) # 9:1 划分
train_data = data[:split]
val_data = data[split:]
with open("train.json", "w", encoding="utf-8") as f:
json.dump(train_data, f, ensure_ascii=False, indent=2)
with open("val.json", "w", encoding="utf-8") as f:
json.dump(val_data, f, ensure_ascii=False, indent=2)
print(f"训练集: {len(train_data)} 条, 验证集: {len(val_data)} 条")
四、模型选型
4.1 主流开源模型对比
选模型需要综合考量中文能力、显存需求、生态成熟度:
| 模型 | 参数 | 中文能力 | 优势场景 | 推荐度 |
|---|---|---|---|---|
| Qwen2.5 | 7B/14B/32B/72B | ★★★★★ | 中文全场景,综合能力最强 | ★★★★★ |
| DeepSeek-V3 | 671B (MoE) | ★★★★★ | 代码/推理,最强中文开源 | ★★★★★ |
| DeepSeek-R1 | 671B (MoE) | ★★★★★ | 深度推理,CoT 原生长链 | ★★★★★ |
| Qwen3 | 4B/8B/14B/32B | ★★★★★ | 思考模式,推理能力强 | ★★★★★ |
| ChatGLM4 | 9B | ★★★★★ | 中文对话,清华系生态 | ★★★★ |
| Yi-1.5 | 6B/9B/34B | ★★★★ | 中英双语,长文本 | ★★★★ |
| LLaMA 3.1 | 8B/70B | ★★★ | 英文优先,中文有差距 | ★★★ |
| Mistral | 7B | ★★ | 英文为主,速度快 | ★★★ |
4.2 选型决策树
你的任务是?
├── 中文为主
│ ├── 通用对话/客服/知识问答 → Qwen2.5-7B/14B
│ ├── 代码/推理类 → DeepSeek-R1 或 Qwen3
│ ├── 垂直领域(法律/医疗/金融) → Qwen2.5-7B(数据量小时用 32B)
│ └── 资源极度受限(< 12GB 显存) → Qwen3-4B
├── 英文为主
│ ├── 通用 → LLaMA 3.1-8B / Mistral-7B
│ └── 代码 → DeepSeek-Coder 或 CodeQwen
└── 多语言 → Qwen2.5(中文英文都强)
经验法则:
- 7B 是性价比甜点——24GB 显卡能跑,效果够用
- 数据量 < 1000 条时,优先选大模型(14B/32B),大模型小样本泛化更好
- 数据量 > 5000 条时,7B LoRA 通常足够
五、显存计算
微调前估算显存,避免 CUDA OOM 翻车。这里给出通用公式和速查表。
5.1 显存构成
微调时的显存由四部分组成:
总显存 = 模型权重 + 优化器状态 + 梯度 + 激活值
逐项拆解:
① 模型权重(FP16 或量化)
| 存储格式 | 每参数字节 | 7B 模型占用 |
|---|---|---|
| FP32 | 4 bytes | 28 GB |
| FP16/BF16 | 2 bytes | 14 GB |
| INT8 | 1 byte | 7 GB |
| NF4(QLoRA) | ~0.5 bytes | ~3.5 GB |
② 优化器状态(AdamW)
全量微调时,AdamW 为每个参数维护两份 FP32 状态(一阶动量 m 和二阶动量 v):
优化器显存 = 参数量 × 8 bytes
但 LoRA 只优化新增的低秩矩阵,冻结的参数不产生优化器状态:
LoRA 优化器显存 = LoRA 参数量 × 8 bytes
以 7B 模型、r=16、target_modules=q_proj+v_proj 为例:
LoRA 参数量 ≈ 2 × 32 层 × (4096 × 16 + 16 × 4096) ≈ 16.8M
优化器显存 = 16.8M × 8 ≈ 134 MB # 几乎可以忽略
③ 梯度
LoRA 只有可训练参数产生梯度(FP16 存储):
LoRA 梯度显存 = 16.8M × 2 ≈ 34 MB # 同样可忽略
④ 激活值
这是最容易被忽略的大头。激活值是前向传播中每一层的中间结果,反向传播时需要用它们计算梯度。
激活值显存取决于:
激活值 ≈ batch_size × seq_len × hidden_dim × num_layers × 系数
其中「系数」取决于是否使用梯度检查点(gradient checkpointing)。粗略估算:
| 设置 | 7B (4096 hidden, 32 layers) | 13B (5120 hidden, 40 layers) |
|---|---|---|
| bs=1, len=512, 无 GC | ~8 GB | ~14 GB |
| bs=1, len=512, 有 GC | ~2 GB | ~4 GB |
| bs=8, len=2048, 无 GC | >80 GB(OOM) | — |
| bs=8, len=2048, 有 GC | ~20 GB | ~35 GB |
LLaMA-Factory 默认开启梯度检查点,所以激活值通常占总显存的 40-60%。
5.2 速查表(FP16 LoRA,梯度检查点开启)
| 模型 | LoRA r | Batch Size | Seq Len | 估计显存 |
|---|---|---|---|---|
| Qwen2.5-7B | 16 | 4 | 512 | ~18 GB |
| Qwen2.5-7B | 16 | 8 | 512 | ~22 GB |
| Qwen2.5-7B | 16 | 4 | 2048 | ~24 GB |
| Qwen2.5-7B | 16 | 2 | 4096 | ~22 GB |
| Qwen2.5-14B | 16 | 4 | 512 | ~30 GB |
| Qwen2.5-14B | 16 | 2 | 2048 | ~32 GB |
| Qwen2.5-32B | 16 | 1 | 2048 | ~40 GB(批大小受限时用梯度累积) |
5.3 QLoRA 速查表(4-bit,梯度检查点开启)
| 模型 | LoRA r | Batch Size | Seq Len | 估计显存 |
|---|---|---|---|---|
| Qwen2.5-7B | 16 | 4 | 512 | ~10 GB |
| Qwen2.5-7B | 16 | 8 | 2048 | ~16 GB |
| Qwen2.5-14B | 16 | 2 | 1024 | ~12 GB |
| Qwen2.5-32B | 16 | 2 | 1024 | ~22 GB |
5.4 等效批大小
遇到 batch_size=1 都不够显存的情况怎么办?用梯度累积:
等效 batch size = per_device_batch_size × gradient_accumulation_steps × num_gpus
例如在 16GB 显卡上微调 7B 模型,per_device_batch_size 只能设 1,但你想用等效 batch_size=16:
per_device_batch_size = 1
gradient_accumulation_steps = 16
等效 batch size = 1 × 16 × 1 = 16 ✓
梯度累积不增加显存,但会让训练变慢(16 步才更新一次权重)。这是显存和速度之间的经典权衡。
5.5 实战:显存不够怎么办
按优先级排序的解决策略:
- 降低 batch size(最直接,对模型效果影响最小)
- 缩短 max_seq_len(检查数据长度分布,截断过长样本)
- 开启 QLoRA 4-bit(显存减半,效果损失可忽略)
- 减小 LoRA rank(r=8 甚至 r=4,对简单任务足够)
- 只对部分模块加 LoRA(q_proj+v_proj 是最精简配置)
- 关闭重计算(setup 里关 gradient_checkpointing,显存会暴增,谨慎使用)
- 如果以上都不行 → 换更小的模型
六、LLaMA-Factory 实操
6.1 场景设定
本文的示例场景:用中文客服对话数据微调 Qwen2.5-7B-Instruct,让它学会用标准的客服话术回复用户。
- 基座模型:Qwen2.5-7B-Instruct
- 数据量:2000 条客服对话(ShareGPT 格式)
- 微调方式:LoRA (r=16, alpha=32)
- 硬件:单卡 RTX 4090 24GB
6.2 配置文件
LLaMA-Factory 的训练参数通过 YAML 文件管理。在 examples/train_lora/ 下创建 qwen2.5_7b_lora_customer_service.yaml:
### 模型配置
model_name_or_path: Qwen/Qwen2.5-7B-Instruct
trust_remote_code: true
### 微调方法
stage: sft
do_train: true
finetuning_type: lora
lora_rank: 16
lora_alpha: 32
lora_dropout: 0.05
lora_target: all # 对全部线性层加 LoRA
### 数据集
dataset: my_customer_service # 在 dataset_info.json 中注册的名字
template: qwen # Qwen 系列用 qwen 模板
cutoff_len: 2048 # 最大序列长度,超出的截断
overwrite_cache: true
preprocessing_num_workers: 16
### 输出目录
output_dir: saves/qwen2.5-7b-lora-cs
logging_steps: 10
save_steps: 200 # 每 200 步保存一次
save_total_limit: 3 # 最多保留 3 个 checkpoint
plot_loss: true
### 训练超参数
per_device_train_batch_size: 4
gradient_accumulation_steps: 4 # 等效 batch size = 4 × 4 = 16
learning_rate: 2.0e-4
num_train_epochs: 3.0
lr_scheduler_type: cosine
warmup_ratio: 0.1
bf16: true
ddp_timeout: 180000000
### 验证
val_size: 0.1 # 10% 做验证集
per_device_eval_batch_size: 4
eval_strategy: steps
eval_steps: 200
关键参数说明:
| 参数 | 含义 | 设错了会怎样 |
|---|---|---|
lora_rank |
低秩矩阵的秩 | 太小欠拟合,太大失去 LoRA 效率优势 |
lora_alpha |
缩放系数 | 过大导致模型偏离基座,过小则学不动 |
lora_target |
哪些层加 LoRA | q_proj,v_proj 最保守,all 效果最好但参数多 |
template |
对话模板 | 搞错会导致格式错乱,模型输出异常 |
cutoff_len |
最大 token 数 | 太小则截断信息,太大则 OOM |
per_device_train_batch_size |
单卡批大小 | 太大 OOM,太小训练不稳定 |
6.3 启动训练
一条命令:
llamafactory-cli train examples/train_lora/qwen2.5_7b_lora_customer_service.yaml
训练过程中的日志解读:
{'loss': 1.8234, 'grad_norm': 1.2456, 'learning_rate': 1.98e-4,
'epoch': 0.23, 'max_memory_MB': 18942}
关注三个指标:
- loss:在下降就对了。训练初期 loss 在 1.5-3.0 之间都正常,最终降到 0.5-1.0 算不错
- grad_norm:梯度的范数。如果突然飙升到 >100,可能是学习率太大或遇到了坏样本。如果太小(<0.01),可能是梯度消失
- max_memory_MB:当前进程最大显存占用,用于判断还有多少余量
Loss 曲线怎么看:
1.8 |██
1.5 | ██
1.2 | ████
1.0 | ████
0.8 | ████
0.7 | ████████ ← 开始趋于平稳
+--------------------------------
epoch 1 → epoch 2 → epoch 3
- 持续下降:正常,继续训练
- 快速下降到很低(<0.3):可能过拟合,检查数据——是否有重复样本或过于简单的模式
- 不下降或上升:检查学习率、数据质量、或随机种子
- 训练 loss 降但验证 loss 升:过拟合了,减少 epoch 或加 dropout
6.4 导出模型
训练完成后,得到的 LoRA adapter 权重只有几十 MB。推理时需要加载基座模型 + adapter,或者将它们合并为一个完整模型。
方式一:导出 adapter(推荐保留灵活性)
导出路径就是 saves/qwen2.5-7b-lora-cs/ 下的 checkpoint 目录,包含:
adapter_config.json:LoRA 配置adapter_model.safetensors:LoRA 权重(几十 MB)
推理时加载:
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
base_model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-7B-Instruct",
torch_dtype="auto",
device_map="auto"
)
model = PeftModel.from_pretrained(base_model, "saves/qwen2.5-7b-lora-cs/checkpoint-600")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
方式二:合并权重(推荐部署使用)
llamafactory-cli export \
examples/train_lora/qwen2.5_7b_lora_customer_service.yaml \
--export_dir ./qwen2.5-cs-merged \
--export_size 2 \
--export_device auto
合并后就是一个完整的 HuggingFace 模型,可以直接用 transformers 加载推理,无需依赖 peft 库。
6.5 多轮对话推理测试
合并完模型后,用 LLaMA-Factory 自带的交互式对话进行测试:
llamafactory-cli chat examples/train_lora/qwen2.5_7b_lora_customer_service.yaml
或者写一个测试脚本:
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained(
"./qwen2.5-cs-merged",
torch_dtype="auto",
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained("./qwen2.5-cs-merged")
messages = [
{"role": "system", "content": "你是一个专业的客服人员,语气友好耐心。"},
{"role": "user", "content": "我刚买的手机屏幕有个亮点,能退换吗?"}
]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(text, return_tensors="pt").to("cuda")
outputs = model.generate(
**inputs,
max_new_tokens=512,
temperature=0.7,
top_p=0.9,
do_sample=True
)
response = tokenizer.decode(outputs[0][len(inputs.input_ids[0]):], skip_special_tokens=True)
print(response)
七、模型评估
微调不是训完就完了——你必须用数据证明模型确实变好了,而不是变坏了。
评估分为两条线:分类任务的分类学指标(意图识别、情感分析等有明确标签的任务)和生成任务的文本质量指标(对话、翻译、摘要等自由文本输出)。两种任务需要的指标体系完全不同。
7.1 分类任务量化指标
如果你的微调任务是客服意图分类、情感分析、邮件标签等分类任务,以下指标是你的核心评估工具。
7.1.1 混淆矩阵(Confusion Matrix)
混淆矩阵是分类评估的基石,所有指标(精确率、召回率、F1)都从它衍生而来。
预测值
A类 B类 C类
┌──────┬──────┬──────┐
A类 │ TP_A │ │ │ ← 对角线 = 预测正确
实际值 B类 │ │ TP_B │ │
C类 │ │ │ TP_C │
└──────┴──────┴──────┘
↑
非对角线 = 预测错误
从混淆矩阵可以回答三个核心问题:
- 每一类分别错分到了哪里?(看行方向)
- 每一类的预测中混入了哪些其他类?(看列方向)
- 哪两个类之间最容易混淆?(非对角线最大值)
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
# 假设这是模型预测结果(标签索引)
y_true = [0, 1, 2, 0, 1, 2, 0, 2, 1, 0, 0, 1, 2, 2, 1]
y_pred = [0, 1, 1, 0, 1, 2, 0, 2, 1, 2, 0, 1, 2, 0, 1]
labels = ["退货咨询", "物流查询", "投诉建议"]
cm = confusion_matrix(y_true, y_pred)
# 可视化
fig, ax = plt.subplots(figsize=(8, 6))
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=labels)
disp.plot(cmap="Blues", ax=ax, values_format="d")
ax.set_title("客服意图分类 - 混淆矩阵")
plt.tight_layout()
plt.savefig("confusion_matrix.png", dpi=150)
plt.show()
print("混淆矩阵 (行=实际, 列=预测):")
print(cm)
# [[4 0 1]
# [0 5 0]
# [1 1 3]]
# 解读:第0类"退货咨询"5条中4条正确,1条被错分为"投诉建议"
# 第2类"投诉建议"5条中3条正确,1条错分为"退货咨询",1条错分为"物流查询"
7.1.2 核心分类指标
以二分类为例,定义如下概念:
预测正类 预测负类
实际正类 TP FN (TP+FN = 实际正类总数)
实际负类 FP TN (FP+TN = 实际负类总数)
准确率(Accuracy):预测正确的比例
Accuracy=TP+TNTP+TN+FP+FN \text{Accuracy} = \frac{TP + TN}{TP + TN + FP + FN} Accuracy=TP+TN+FP+FNTP+TN
- 优点:直观,适合类别均衡的场景
- 陷阱:如果 95% 的样本都是 A 类,模型全猜 A 就能拿到 95% 准确率——但实际毫无用处
精确率(Precision):预测为正类的样本中,实际为正的比例
Precision=TPTP+FP\text{Precision} = \frac{TP}{TP + FP}Precision=TP+FPTP
- 关注点:模型说"是"的时候,有多大把握?
- 适用场景:宁可漏掉也不愿误判的场合(垃圾邮件检测——不想把正常邮件判为垃圾)
召回率(Recall):实际为正类的样本中,被模型识别出的比例
Recall=TPTP+FN\text{Recall} = \frac{TP}{TP + FN}Recall=TP+FNTP
- 关注点:真正的正类,模型找到了多少?
- 适用场景:宁可误判也不愿漏掉的场合(疾病筛查——漏掉一个病人的代价远大于误报)
F1 分数:精确率和召回率的调和平均
F1=2×Precision×RecallPrecision+Recall\text{F1} = 2 \times \frac{\text{Precision} \times \text{Recall}}{\text{Precision} + \text{Recall}}F1=2×Precision+RecallPrecision×Recall
- 为什么用调和平均而不是算术平均?因为调和平均对极端值敏感:Precision=0.9, Recall=0.1 时,算术平均=0.5,调和平均=0.18。F1 不会奖励「偏科」的模型
- 取值范围 [0, 1],1 为最优
通用形式 Fβ:
Fβ=(1+β2)×Precision×Recallβ2×Precision+RecallF_\beta = (1 + \beta^2) \times \frac{\text{Precision} \times \text{Recall}}{\beta^2 \times \text{Precision} + \text{Recall}}Fβ=(1+β2)×β2×Precision+RecallPrecision×Recall
- β > 1 更看重 Recall(如 F2)
- β < 1 更看重 Precision(如 F0.5)
7.1.3 多分类的 Macro / Micro / Weighted F1
多分类任务(> 2 类)中,需要聚合每类的指标。三种聚合方式各有侧重:
三类:A(样本100条) B(样本100条) C(样本10条)
各类F1: F1_A=0.9 F1_B=0.8 F1_C=0.5
| 方法 | 计算方式 | 结果 | 含义 |
|---|---|---|---|
| Macro | 各类F1的算术平均 | (0.9+0.8+0.5)/3 = 0.733 | 每类同等重要,小类不被淹没 |
| Weighted | 按样本数加权平均 | (0.9×100+0.8×100+0.5×10)/210 = 0.833 | 反映整体表现,小类权重低 |
| Micro | 汇总所有TP/FP/FN再计算 | 0.857 | 等效于 Accuracy,大样本类主导 |
选择建议:
- 类别均衡 → Macro / Micro 差别不大,选 Macro
- 类别不均衡、小类很重要 → 看 Macro(小类差会拉低分数)
- 类别不均衡、整体表现优先 → 看 Weighted
- Micro F1 在多分类中等于 Accuracy,通常没必要单独算
from sklearn.metrics import (
accuracy_score, precision_score, recall_score,
f1_score, classification_report
)
y_true = [0, 1, 2, 0, 1, 2, 0, 2, 1, 0, 0, 1, 2, 2, 1]
y_pred = [0, 1, 1, 0, 1, 2, 0, 2, 1, 2, 0, 1, 2, 0, 1]
target_names = ["退货咨询", "物流查询", "投诉建议"]
# 方式一:逐个指标
print(f"Accuracy : {accuracy_score(y_true, y_pred):.4f}")
print(f"Precision (macro): {precision_score(y_true, y_pred, average='macro'):.4f}")
print(f"Recall (macro): {recall_score(y_true, y_pred, average='macro'):.4f}")
print(f"F1 (macro): {f1_score(y_true, y_pred, average='macro'):.4f}")
print(f"F1 (weighted): {f1_score(y_true, y_pred, average='weighted'):.4f}")
# 方式二:一次性报告(推荐)
print("\n" + classification_report(y_true, y_pred, target_names=target_names, digits=3))
输出示例:
precision recall f1-score support
退货咨询 0.800 0.800 0.800 5
物流查询 0.833 1.000 0.909 5
投诉建议 0.750 0.600 0.667 5
micro avg 0.800 0.800 0.800 15
macro avg 0.794 0.800 0.792 15
weighted avg 0.794 0.800 0.792 15
这里能直接看出:投诉建议类(F1=0.667)是短板——模型容易把它和其他类搞混。下一步优先补充投诉类训练数据或检查标注质量。
7.1.4 ROC 曲线与 AUC
ROC(Receiver Operating Characteristic)曲线展示不同分类阈值下 TPR(召回率)与 FPR(误报率)的权衡:
TPR=TPTP+FNFPR=FPFP+TN\text{TPR} = \frac{TP}{TP + FN} \quad\quad \text{FPR} = \frac{FP}{FP + TN}TPR=TP+FNTPFPR=FP+TNFP
AUC(Area Under Curve) = ROC 曲线下面积,取值范围 [0, 1]:
- AUC = 0.5:模型等于随机猜
- AUC = 1.0:完美分类
- AUC > 0.8:可用,> 0.9:优秀
AUC 的核心优势在于它与分类阈值无关,衡量模型的排序能力——正确的样本排在错误样本前面的概率。适合评估模型底层的分类能力而非某个特定阈值下的表现。
one-vs-rest 策略:多分类任务中为每一类单独画一条 ROC 曲线(把该类视为正类,其他所有类合并为负类)。
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import roc_curve, auc, RocCurveDisplay
from sklearn.preprocessing import label_binarize
# 假设 model.predict_proba() 返回每个类的概率
# y_true: (n_samples,), y_probs: (n_samples, n_classes)
np.random.seed(42)
n_samples = 300
n_classes = 3
# 模拟预测概率(实际使用中用 model.predict_proba() 获得)
y_true = np.random.randint(0, n_classes, n_samples)
# 模拟:真实类给高概率 + 噪声
y_probs = np.random.rand(n_samples, n_classes)
for i in range(n_samples):
y_probs[i, y_true[i]] += np.random.rand() * 2 # 提升真实类的概率
# 将标签二值化
y_true_bin = label_binarize(y_true, classes=range(n_classes))
# 为每一类计算 ROC
class_names = ["退货咨询", "物流查询", "投诉建议"]
colors = ["#1f77b4", "#ff7f0e", "#2ca02c"]
fig, ax = plt.subplots(figsize=(8, 6))
for i in range(n_classes):
fpr, tpr, _ = roc_curve(y_true_bin[:, i], y_probs[:, i])
roc_auc = auc(fpr, tpr)
ax.plot(fpr, tpr, color=colors[i], lw=2,
label=f"{class_names[i]} (AUC = {roc_auc:.3f})")
# 对角线 (随机分类器)
ax.plot([0, 1], [0, 1], "k--", lw=1, alpha=0.5, label="随机 (AUC = 0.500)")
ax.set_xlim([0.0, 1.0])
ax.set_ylim([0.0, 1.05])
ax.set_xlabel("False Positive Rate (FPR)", fontsize=12)
ax.set_ylabel("True Positive Rate (TPR / Recall)", fontsize=12)
ax.set_title("客服意图分类 - ROC 曲线 (One-vs-Rest)", fontsize=14)
ax.legend(loc="lower right")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("roc_curve.png", dpi=150)
plt.show()
# 计算 macro-average AUC
macro_auc = auc(fpr, tpr) # 简化示意,实际需对每类AUC取平均
print(f"Macro-average AUC: {macro_auc:.3f}")
7.1.5 实战:完整分类评估脚本
下面是一个可以直接运行的评估函数,输入真实标签和预测标签,输出所有指标和可视化:
import json
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import (
accuracy_score, precision_score, recall_score,
f1_score, classification_report, confusion_matrix,
ConfusionMatrixDisplay, roc_curve, auc
)
from sklearn.preprocessing import label_binarize
def evaluate_classification(
y_true: list,
y_pred: list,
y_probs: np.ndarray = None, # shape: (n_samples, n_classes),可选
class_names: list = None,
save_dir: str = "./eval_results"
):
"""
完整的分类评估函数。
输出:混淆矩阵、Accuracy/Precision/Recall/F1、ROC-AUC。
"""
import os
os.makedirs(save_dir, exist_ok=True)
n_classes = len(set(y_true))
if class_names is None:
class_names = [f"Class_{i}" for i in range(n_classes)]
# ========== 1. 混淆矩阵 ==========
cm = confusion_matrix(y_true, y_pred)
fig, ax = plt.subplots(figsize=(max(6, n_classes * 1.5), max(5, n_classes * 1.2)))
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=class_names)
disp.plot(cmap="Blues", ax=ax, values_format="d")
ax.set_title("Confusion Matrix", fontsize=14)
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.savefig(f"{save_dir}/confusion_matrix.png", dpi=150)
plt.close()
# ========== 2. 分类报告 (Precision / Recall / F1) ==========
report = classification_report(
y_true, y_pred, target_names=class_names, digits=4, output_dict=True
)
print("=" * 65)
print(" 分类评估报告")
print("=" * 65)
print(classification_report(
y_true, y_pred, target_names=class_names, digits=4
))
# ========== 3. 汇总指标 ==========
results = {
"accuracy": accuracy_score(y_true, y_pred),
"precision_macro": precision_score(y_true, y_pred, average="macro", zero_division=0),
"recall_macro": recall_score(y_true, y_pred, average="macro", zero_division=0),
"f1_macro": f1_score(y_true, y_pred, average="macro", zero_division=0),
"f1_weighted": f1_score(y_true, y_pred, average="weighted", zero_division=0),
"per_class": {
name: {
"precision": report[name]["precision"],
"recall": report[name]["recall"],
"f1-score": report[name]["f1-score"],
"support": report[name]["support"],
}
for name in class_names
},
}
# ========== 4. ROC-AUC (如果提供了概率) ==========
if y_probs is not None:
y_true_bin = label_binarize(y_true, classes=range(n_classes))
auc_values = []
fig, ax = plt.subplots(figsize=(8, 6))
for i in range(n_classes):
fpr, tpr, _ = roc_curve(y_true_bin[:, i], y_probs[:, i])
auc_val = auc(fpr, tpr)
auc_values.append(auc_val)
ax.plot(fpr, tpr, lw=2,
label=f"{class_names[i]} (AUC = {auc_val:.4f})")
ax.plot([0, 1], [0, 1], "k--", lw=1, alpha=0.4, label="Random")
ax.set_xlim([0.0, 1.0])
ax.set_ylim([0.0, 1.05])
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title("ROC Curves (One-vs-Rest)")
ax.legend(loc="lower right", fontsize=9)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(f"{save_dir}/roc_curve.png", dpi=150)
plt.close()
results["roc_auc_per_class"] = dict(zip(class_names, auc_values))
results["roc_auc_macro"] = np.mean(auc_values)
print(f"\nROC-AUC (macro-average): {results['roc_auc_macro']:.4f}")
# 保存结果
with open(f"{save_dir}/metrics.json", "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n结果已保存至: {save_dir}/")
return results
# ========== 使用示例 ==========
# 模拟:100 条客服意图分类的预测结果
np.random.seed(42)
n = 100
y_true = np.random.randint(0, 3, n)
y_pred = np.random.randint(0, 3, n)
y_probs = np.random.rand(n, 3)
y_probs = y_probs / y_probs.sum(axis=1, keepdims=True) # 归一化为概率
results = evaluate_classification(
y_true=y_true.tolist(),
y_pred=y_pred.tolist(),
y_probs=y_probs,
class_names=["退货咨询", "物流查询", "投诉建议"],
save_dir="./eval_results"
)
7.2 生成任务量化指标
对于对话、翻译、摘要等自由文本输出任务,需要用另一套指标。核心思路是衡量「模型输出与参考答案的相似度」。
7.2.1 ROUGE(Recall-Oriented Understudy for Gisting Evaluation)
ROUGE 是最常用的生成评估指标,基于 n-gram 重叠计算。有四种主要变体:
| 变体 | 计算方式 | 侧重 |
|---|---|---|
| ROUGE-1 | 单个词 (unigram) 的重叠 | 内容覆盖度 |
| ROUGE-2 | 两个连续词 (bigram) 的重叠 | 局部流畅性 |
| ROUGE-L | 最长公共子序列 (LCS) | 语序保持 |
每个变体又分为三个值:
P=参考与生成重叠的 n-gram 数生成文本的总 n-gram 数(精确率——生成内容有多少是相关的)P = \frac{\text{参考与生成重叠的 n-gram 数}}{\text{生成文本的总 n-gram 数}} \quad \text{(精确率——生成内容有多少是相关的)}P=生成文本的总 n-gram 数参考与生成重叠的 n-gram 数(精确率——生成内容有多少是相关的)
R=参考与生成重叠的 n-gram 数参考文本的总 n-gram 数(召回率——参考内容有多少被覆盖)R = \frac{\text{参考与生成重叠的 n-gram 数}}{\text{参考文本的总 n-gram 数}} \quad \text{(召回率——参考内容有多少被覆盖)}R=参考文本的总 n-gram 数参考与生成重叠的 n-gram 数(召回率——参考内容有多少被覆盖)
F1=2×P×RP+RF1 = 2 \times \frac{P \times R}{P + R}F1=2×P+RP×R
实际使用建议:ROUGE-L F1 最具参考价值,因为它考虑了语序而非仅词袋匹配。
def evaluate_rouge(hypotheses: list, references: list) -> dict:
"""
计算 ROUGE 分数。
hypotheses: 模型生成的文本列表
references: 参考答案列表
"""
from rouge import Rouge
rouge = Rouge()
scores = rouge.get_scores(hypotheses, references, avg=True)
print("=" * 50)
print(" ROUGE 评估")
print("=" * 50)
for metric in ["rouge-1", "rouge-2", "rouge-l"]:
s = scores[metric]
print(f"{metric.upper():>8} "
f"P={s['p']:.4f} R={s['r']:.4f} F1={s['f']:.4f}")
return scores
# 示例
hypotheses = [
"您可以拨打 400-123-4567 联系客服申请退货。",
]
references = [
"您可以通过拨打客服热线 400-123-4567 来申请退货处理。",
]
evaluate_rouge(hypotheses, references)
# ROUGE-1 P=0.7500 R=0.6923 F1=0.7200
# ROUGE-2 P=0.5455 R=0.5000 F1=0.5217
# ROUGE-L P=0.7500 R=0.6923 F1=0.7200
ROUGE 的局限:只看字面匹配。「请拨打 400 电话」和「建议致电 400」意思完全相同但 ROUGE-1 可能很低。这就是为什么后面还要讲 BERTScore。
7.2.2 BLEU(Bilingual Evaluation Understudy)
BLEU 最早用于机器翻译评估,计算 n-gram 精确率并引入长度惩罚(Brevity Penalty)——防止模型只输出短句来刷高分。
BLEU=BP×exp(∑n=1Nwnlogpn)\text{BLEU} = \text{BP} \times \exp\left(\sum_{n=1}^{N} w_n \log p_n\right)BLEU=BP×exp(n=1∑Nwnlogpn)
其中 BP 是长度惩罚项:
BP={1if c>re(1−r/c)if c≤r \text{BP} = \begin{cases} 1 & \text{if } c > r \\ e^{(1 - r/c)} & \text{if } c \leq r \end{cases} BP={1e(1−r/c)if c>rif c≤r
c = 生成文本长度,r = 参考文本长度。如果生成比参考短,BP 会惩罚。
def evaluate_bleu(hypotheses: list, references: list) -> dict:
"""
计算 BLEU 分数。
hypotheses: 模型生成的文本列表 (tokenized)
references: 参考答案列表 (tokenized, 每个位置可以是一个 list of references)
"""
from nltk.translate.bleu_score import (
sentence_bleu, corpus_bleu,
SmoothingFunction
)
smooth = SmoothingFunction().method1 # 平滑处理,避免短句得 0
# 逐句 BLEU
sent_bleu_scores = []
for hyp, ref in zip(hypotheses, references):
score = sentence_bleu(
[ref.split()], hyp.split(),
weights=(0.25, 0.25, 0.25, 0.25), # BLEU-4
smoothing_function=smooth
)
sent_bleu_scores.append(score)
# 语料级 BLEU
list_of_refs = [[r.split()] for r in references]
list_of_hyps = [h.split() for h in hypotheses]
corpus_score = corpus_bleu(
list_of_refs, list_of_hyps,
weights=(0.25, 0.25, 0.25, 0.25),
smoothing_function=smooth
)
avg_sent_bleu = np.mean(sent_bleu_scores)
print("=" * 50)
print(" BLEU 评估")
print("=" * 50)
print(f"Average Sentence BLEU-4: {avg_sent_bleu:.4f}")
print(f"Corpus BLEU-4: {corpus_score:.4f}")
print(f"(BLEU 范围是 0-1,0.3+ 算不错,0.5+ 算优秀)")
return {
"avg_sentence_bleu4": avg_sent_bleu,
"corpus_bleu4": corpus_score,
"per_sentence": sent_bleu_scores,
}
# 需要先下载 NLTK 数据
# import nltk
# nltk.download('punkt')
hypotheses = ["You can contact customer service at 400-123-4567."]
references = ["You may call our customer support hotline at 400-123-4567."]
evaluate_bleu(hypotheses, references)
BLEU 的局限:
- 对短句不友好(n=3,4 的 n-gram 可能为 0)
- 只看精确率不看召回率
- 对中文支持差——需要先分词,且同义词/近义表达完全被忽略
7.2.3 BERTScore(推荐)
BERTScore 是当前最推荐的自回归评估指标。它不比较字面 n-gram 重叠,而是用预训练的 BERT/RoBERTa 等模型将生成文本和参考文本编码为上下文向量,计算余弦相似度。
BERTScore=1∣x∣∑xi∈xmaxyj∈ycos(exi,eyj) \text{BERTScore} = \frac{1}{|x|} \sum_{x_i \in x} \max_{y_j \in y} \text{cos}(e_{x_i}, e_{y_j}) BERTScore=∣x∣1xi∈x∑yj∈ymaxcos(exi,eyj)
核心优势:
- 语义匹配而非字面匹配:「拨打」和「致电」在 BERTScore 中会被识别为相似
- 不依赖分词:BERT tokenizer 天然支持中文
- 有可解释性:可以回推到哪些 token 对分数贡献最大
def evaluate_bertscore(hypotheses: list, references: list, lang: str = "zh") -> dict:
"""
使用 BERTScore 评估生成质量。
lang: "zh" 中文, "en" 英文
返回 Precision, Recall, F1
"""
from bert_score import score
P, R, F1 = score(
hypotheses, references,
lang=lang,
model_type="bert-base-multilingual-cased", # 中文用 multilingual
verbose=True
)
print("=" * 50)
print(" BERTScore 评估")
print("=" * 50)
print(f"Precision: {P.mean().item():.4f}")
print(f"Recall: {R.mean().item():.4f}")
print(f"F1: {F1.mean().item():.4f}")
return {
"bertscore_precision": P.mean().item(),
"bertscore_recall": R.mean().item(),
"bertscore_f1": F1.mean().item(),
"per_sample_precision": P.tolist(),
"per_sample_recall": R.tolist(),
"per_sample_f1": F1.tolist(),
}
# 示例
hypotheses = ["您可以通过拨打 400 电话联系客服办理退货。"]
references = ["请致电 400 客服热线申请退货处理。"]
evaluate_bertscore(hypotheses, references)
# F1 通常在 0.85-0.95 之间算优秀(远高于 ROUGE,因为它识别语义而非字面)
7.2.4 Perplexity(困惑度)
Perplexity 衡量模型对测试文本的「惊讶程度」。数学上是交叉熵损失的指数形式:
PPL=exp(−1N∑i=1NlogPθ(yi∣y<i)) \text{PPL} = \exp\left(-\frac{1}{N}\sum_{i=1}^{N} \log P_\theta(y_i \mid y_{<i})\right) PPL=exp(−N1i=1∑NlogPθ(yi∣y<i))
- PPL 越低越好:PPL=1 表示模型对每个 token 都 100% 确定
- PPL 反映的是模型的语言建模能力,而非任务完成质量
- PPL 下降说明模型在目标领域上变得更「熟悉」,但不等于生成质量提升
PPL 适合在训练过程中做快速横向对比(同一基座模型,不同 checkpoint / 不同超参),不适合跨模型对比(不同 tokenizer 的 PPL 没有可比性)。
def evaluate_perplexity(model, tokenizer, test_texts: list, max_length: int = 1024) -> dict:
"""
计算模型在测试集上的困惑度。
"""
import torch
from tqdm import tqdm
model.eval()
total_loss = 0.0
total_tokens = 0
losses = []
with torch.no_grad():
for text in tqdm(test_texts, desc="计算 PPL"):
encodings = tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=max_length,
).to(model.device)
# 用 label = input_ids,模型自动计算交叉熵
outputs = model(**encodings, labels=encodings["input_ids"])
loss = outputs.loss
num_tokens = encodings["input_ids"].numel()
total_loss += loss.item() * num_tokens
total_tokens += num_tokens
losses.append(loss.item())
avg_loss = total_loss / total_tokens
ppl = torch.exp(torch.tensor(avg_loss)).item()
print("=" * 50)
print(" Perplexity (PPL) 评估")
print("=" * 50)
print(f"Average Loss: {avg_loss:.4f}")
print(f"Perplexity: {ppl:.2f}")
print(f"(PPL 越低表示模型对测试文本越「不意外」)")
return {
"perplexity": ppl,
"average_loss": avg_loss,
"per_sample_losses": losses,
}
7.2.5 实战:完整生成质量评估脚本
整合以上所有指标,一次跑完:
import json
import numpy as np
def evaluate_generation(
hypotheses: list,
references: list,
lang: str = "zh",
save_dir: str = "./eval_gen_results"
) -> dict:
"""
完整评估生成文本质量。
"""
import os
os.makedirs(save_dir, exist_ok=True)
results = {}
# --- ROUGE ---
try:
from rouge import Rouge
rouge = Rouge()
rouge_scores = rouge.get_scores(hypotheses, references, avg=True)
results["rouge"] = {
"rouge-1": rouge_scores["rouge-1"],
"rouge-2": rouge_scores["rouge-2"],
"rouge-l": rouge_scores["rouge-l"],
}
print(f"ROUGE-1 F1: {rouge_scores['rouge-1']['f']:.4f}")
print(f"ROUGE-2 F1: {rouge_scores['rouge-2']['f']:.4f}")
print(f"ROUGE-L F1: {rouge_scores['rouge-l']['f']:.4f}")
except ImportError:
print("[WARN] rouge 未安装,跳过。pip install rouge")
# --- BLEU ---
try:
from nltk.translate.bleu_score import corpus_bleu, SmoothingFunction
smooth = SmoothingFunction().method1
list_of_refs = [[r.split()] for r in references]
list_of_hyps = [h.split() for h in hypotheses]
bleu = corpus_bleu(list_of_refs, list_of_hyps,
weights=(0.25, 0.25, 0.25, 0.25),
smoothing_function=smooth)
results["bleu-4"] = bleu
print(f"Corpus BLEU-4: {bleu:.4f}")
except ImportError:
print("[WARN] nltk 未安装,跳过。pip install nltk")
# --- BERTScore ---
try:
from bert_score import score
P, R, F1 = score(hypotheses, references, lang=lang, verbose=False)
results["bertscore"] = {
"precision": P.mean().item(),
"recall": R.mean().item(),
"f1": F1.mean().item(),
}
print(f"BERTScore F1: {F1.mean().item():.4f}")
except ImportError:
print("[WARN] bert_score 未安装,跳过。pip install bert-score")
# 保存
def convert(obj):
"""递归转换 numpy/float 为 Python 原生类型"""
if isinstance(obj, np.floating):
return float(obj)
if isinstance(obj, dict):
return {k: convert(v) for k, v in obj.items()}
if isinstance(obj, list):
return [convert(v) for v in obj]
return obj
with open(f"{save_dir}/generation_metrics.json", "w", encoding="utf-8") as f:
json.dump(convert(results), f, ensure_ascii=False, indent=2)
print(f"\n结果已保存至: {save_dir}/generation_metrics.json")
return results
# ========== 使用示例 ==========
hypotheses = [
"您可以拨打 400-123-4567 联系客服申请退货。",
"您的订单预计在 3-5 个工作日内送达。",
]
references = [
"您可以通过拨打客服热线 400-123-4567 来申请退货处理。",
"订单将在 3-5 个工作日内配送到达。",
]
results = evaluate_generation(hypotheses, references, lang="zh")
7.3 评估指标速查表
| 任务类型 | 必须算的指标 | 有就用 |
|---|---|---|
| 意图分类 | Accuracy, F1(macro), 混淆矩阵 | ROC-AUC |
| 情感分析 | F1(macro), 混淆矩阵 | ROC-AUC |
| NER/信息抽取 | F1(micro), 每类F1 | — |
| 翻译/摘要 | BLEU, ROUGE-L, BERTScore | METEOR |
| 对话生成 | BERTScore, ROUGE-L | BLEU, PPL |
| 代码生成 | pass@k, 单元测试通过率 | BLEU |
| 知识问答 | Exact Match, F1(token-level) | BERTScore |
7.4 验证集 Loss(训练过程中自动监测)
上面的配置文件里已经设置了 val_size: 0.1 和 eval_steps: 200,LLaMA-Factory 会自动输出训练过程中的 eval_loss。
结合 loss 曲线和上述量化指标一起看:
- loss 降 + 指标升:训练正常,模型在变好
- loss 降 + 指标平/降:模型在记忆训练集而非学习可泛化的模式。检查数据质量和多样性
- loss 平 + 指标平:模型没学到东西。可能是 lr 太小或数据格式有问题
- loss 升 + 指标降:模型崩溃。立刻停掉检查
7.5 对比基座模型(数值化 A/B 测试)
人工评估 + 量化评估,两者都需要对比基座模型:
def ab_compare(base_model, fine_model, tokenizer, test_data: list):
"""
在同一个测试集上对比基座模型和微调模型。
test_data: [{"input": "...", "reference": "..."}, ...]
"""
from bert_score import score
base_outputs = []
fine_outputs = []
references = []
for item in test_data:
base_outputs.append(generate(base_model, tokenizer, item["input"]))
fine_outputs.append(generate(fine_model, tokenizer, item["input"]))
references.append(item["reference"])
print("=" * 60)
print(" A/B 对比:基座模型 vs 微调模型")
print("=" * 60)
for name, hyps in [("基座模型", base_outputs), ("微调模型", fine_outputs)]:
P, R, F1 = score(hyps, references, lang="zh", verbose=False)
print(f"\n{name}:")
print(f" BERTScore F1: {F1.mean().item():.4f}")
print(f" BERTScore P: {P.mean().item():.4f}")
print(f" BERTScore R: {R.mean().item():.4f}")
# 如果微调模型的指标全面优于基座,且差距 > 0.02,才算显著提升
return {
"base_outputs": base_outputs,
"fine_outputs": fine_outputs,
"references": references,
}
7.6 人工评估
自动指标永远不能替代人看。至少做以下测试:
测试用例设计模板:
覆盖维度:
├── 正常场景(80%)
│ ├── 常见问题:"怎么退货?"
│ ├── 信息查询:"我的订单到哪了?"
│ └── 投诉建议:"你们快递太慢了"
├── 边界场景(15%)
│ ├── 简短输入:"退款" # 只有两个字
│ ├── 长输入:粘贴整段聊天记录
│ └── 多问题:"能不能换货,换货要不要运费,如果不要的话怎么操作?"
└── 对抗场景(5%)
├── 要求角色扮演:"假装你是我的私人管家"
├── 诱导不当回复:"教我怎么骗运费险"
└── 无关话题:"给我写一首情诗"
对每条测试,从以下几个维度打分(1-5 分):
| 维度 | 1分 | 3分 | 5分 |
|---|---|---|---|
| 准确性 | 信息明显错误 | 方向对但不够精确 | 信息完整且准确 |
| 流畅性 | 语句不通,难以阅读 | 基本通顺,偶有不自然 | 表达自然流畅 |
| 有用性 | 没解决用户问题 | 部分解决了问题 | 完整解决了问题 |
| 安全性 | 输出了危险/违规内容 | 拒绝但态度不好 | 得体地拒绝或引导 |
7.7 LLM-as-Judge(可选增强)
如果预算允许,用 GPT-4 做 LLM-as-Judge 是目前最靠谱的自动化评估方式。实测与人类评估的 Pearson 相关性可达 0.85+:
def llm_judge_evaluate(question: str, answer: str, reference: str = None) -> dict:
"""
用 GPT-4 做评估裁判。
返回四个维度的评分和理由。
"""
import openai
ref_section = f"\n参考答案:{reference}" if reference else ""
prompt = f"""你是一个大模型评估专家。请对以下客服对话回复从四个维度评分(1-5分)。
用户问题:{question}
模型回复:{answer}{ref_section}
评分维度:
1. 准确性 (accuracy):回复内容是否正确无误,不包含错误信息或幻觉
2. 流畅性 (fluency):语言是否自然通顺,无重复、无语法错误
3. 有用性 (helpfulness):是否完整解决了用户的问题,提供了可操作的建议
4. 专业性 (professionalism):是否符合客服话术规范,语气是否得体
请严格按照以下 JSON 格式输出,不要包含任何其他内容:
{{
"accuracy": <1-5>,
"fluency": <1-5>,
"helpfulness": <1-5>,
"professionalism": <1-5>,
"overall": <1-5>,
"reason": "<简短理由,一句话>"
}}
"""
response = openai.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
)
import json
result = json.loads(response.choices[0].message.content)
return result
LLM-as-Judge 使用要点:
- 温度设为 0,保证评分一致性
- 必须指定 JSON 输出格式,便于批量解析
- 如果有标准参考答案,务必传给 Judge 作为对照
- 定期抽样人工校验 Judge 的评分,防止它本身有偏差
- 一次测试至少跑 50-100 条,单条的评分波动较大
7.8 完整评估工作流
把以上所有组件串联起来,一个完整的微调评估流程如下:
1. 训练过程中:监控 train_loss / eval_loss 曲线 ← 自动
2. 训练完成后:
├── 分类任务:跑 evaluate_classification() → 混淆矩阵 + F1 + ROC-AUC
└── 生成任务:跑 evaluate_generation() → ROUGE + BLEU + BERTScore
3. A/B 对比:ab_compare(基座, 微调) → 确认所有指标都有提升
4. 人工评估:随机抽 50 条,人工按四维度打分
5. (可选) LLM-as-Judge:批量跑 100+ 条,取平均分
6. 输出评估报告:汇总以上所有数据,形成结论
最终结论应该能回答三个问题:
- 微调模型比基座模型好了多少?(用指标数字说话)
- 哪些类型的 case 还不够好?(从混淆矩阵 / 分维度评分定位短板)
- 下一步如何迭代?(补充数据?调超参?换模型?)
八、常见问题与调优
8.1 Loss 不下降
| 可能原因 | 诊断方法 | 解决方案 |
|---|---|---|
| 学习率太小 | 看 loss 曲线是不是几乎平的 | 增大 lr 到 5e-4 或 1e-3 |
| 学习率太大 | 看 loss 是不是剧烈震荡 | 减小 lr 到 1e-5 |
| 数据格式错误 | 检查 tokenizer 后的 token 序列 | 修复 template 设置 |
| 数据质量差 | 抽查 20 条样本人工看 | 清洗数据 |
8.2 过拟合
症状:train_loss 持续下降,eval_loss 先降后升。
train_loss eval_loss
1.8 1.9
1.2 1.4
0.8 1.1
0.5 → 1.5 ← 从这里开始过拟合
0.3 2.2
解决:
- 增大
lora_dropout(从 0.05 → 0.1) - 减少 epoch(3 → 1 或 2)
- 增大数据量或做数据增强
- 增大 weight decay(
adam_beta2或加weight_decay)
8.3 灾难性遗忘
症状:微调后,目标任务做得好了,但原来的通用能力(翻译、写代码、推理)大幅退化。
解决:
- 降低学习率:这是第一要试的。lr 越高,模型偏离基座越远
- 减小
lora_alpha:降低 LoRA 分支的权重占比 - 增大
lora_rank+ 减小 lr:更大的秩让模型有更多自由度,但用小 lr 约束更新幅度 - 混合数据:在微调数据中混入 10-20% 的通用数据
- 减小 epoch:1-2 个 epoch 通常足够
8.4 回复重复/退化
症状:模型输出不断重复同一句话,或输出乱码。
解决:
- 检查
temperature和top_p采样参数 - 检查训练数据中是否有大量重复文本
- 增大
repetition_penalty(推理时设置,如 1.1)
8.5 我的调优经验法则
微调质量公式 = 数据质量 × 0.6 + 超参选择 × 0.2 + 模型选择 × 0.15 + 随机种子 × 0.05
投入产出比排序:
- 花最多时间在数据上(清洗、去重、验证正确性)
- 花中等时间在 prompt template 和模型选型上
- 花较少时间在超参调优上(默认值已经很好了)
- 不要花时间在改随机种子上
九、总结
本文覆盖了 LoRA 微调的完整链路:
构建数据集 → 模型选型 → 显存估算 → LLaMA-Factory 训练 → 导出合并 → 评估验证
核心 takeaways:
- 数据是唯一不可替代的环节。花 80% 的精力在数据质量上。
- Qwen2.5-7B + LoRA (r=16) 是当前中文微调的最佳起步组合。
- LLaMA-Factory 帮你省掉 90% 的工程代码,把注意力留给数据和实验。
- 显存不够先用 QLoRA,效果损失几乎可以忽略,显存省一半。
- 评估要用数据说话。分类任务跑混淆矩阵+F1+ROC-AUC,生成任务跑 ROUGE+BLEU+BERTScore,再加上人工评估和 LLM-as-Judge,形成完整的评估闭环。
微调不是黑魔法,掌握这套流程后,你可以用一张消费级显卡,把开源模型定制成任何你需要的专用模型。
更多推荐

所有评论(0)