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/gptuser/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 实战:显存不够怎么办

按优先级排序的解决策略:

  1. 降低 batch size(最直接,对模型效果影响最小)
  2. 缩短 max_seq_len(检查数据长度分布,截断过长样本)
  3. 开启 QLoRA 4-bit(显存减半,效果损失可忽略)
  4. 减小 LoRA rank(r=8 甚至 r=4,对简单任务足够)
  5. 只对部分模块加 LoRA(q_proj+v_proj 是最精简配置)
  6. 关闭重计算(setup 里关 gradient_checkpointing,显存会暴增,谨慎使用)
  7. 如果以上都不行 → 换更小的模型

六、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=1Nwnlog⁡pn)\text{BLEU} = \text{BP} \times \exp\left(\sum_{n=1}^{N} w_n \log p_n\right)BLEU=BP×exp(n=1Nwnlogpn)

其中 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(1r/c)if c>rif cr
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∈xmax⁡yj∈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=x1xixyjymaxcos(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=1Nlog⁡Pθ(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=1NlogPθ(yiy<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.1eval_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. 输出评估报告:汇总以上所有数据,形成结论

最终结论应该能回答三个问题:

  1. 微调模型比基座模型好了多少?(用指标数字说话)
  2. 哪些类型的 case 还不够好?(从混淆矩阵 / 分维度评分定位短板)
  3. 下一步如何迭代?(补充数据?调超参?换模型?)

八、常见问题与调优

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 回复重复/退化

症状:模型输出不断重复同一句话,或输出乱码。

解决

  • 检查 temperaturetop_p 采样参数
  • 检查训练数据中是否有大量重复文本
  • 增大 repetition_penalty(推理时设置,如 1.1)

8.5 我的调优经验法则

微调质量公式 = 数据质量 × 0.6 + 超参选择 × 0.2 + 模型选择 × 0.15 + 随机种子 × 0.05

投入产出比排序

  1. 花最多时间在数据上(清洗、去重、验证正确性)
  2. 花中等时间在 prompt template 和模型选型上
  3. 花较少时间在超参调优上(默认值已经很好了)
  4. 不要花时间在改随机种子上

九、总结

本文覆盖了 LoRA 微调的完整链路:

构建数据集 → 模型选型 → 显存估算 → LLaMA-Factory 训练 → 导出合并 → 评估验证

核心 takeaways:

  1. 数据是唯一不可替代的环节。花 80% 的精力在数据质量上。
  2. Qwen2.5-7B + LoRA (r=16) 是当前中文微调的最佳起步组合。
  3. LLaMA-Factory 帮你省掉 90% 的工程代码,把注意力留给数据和实验。
  4. 显存不够先用 QLoRA,效果损失几乎可以忽略,显存省一半。
  5. 评估要用数据说话。分类任务跑混淆矩阵+F1+ROC-AUC,生成任务跑 ROUGE+BLEU+BERTScore,再加上人工评估和 LLM-as-Judge,形成完整的评估闭环。

微调不是黑魔法,掌握这套流程后,你可以用一张消费级显卡,把开源模型定制成任何你需要的专用模型。


Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐