两种微调范式
理解两种微调范式的本质区别,有助于为你的任务选择合适的方法:
SFT 指令微调(Instruction SFT)
- 单轮:一个指令 → 一个回答
- Alpaca 格式 / 直接文本格式
- 适合:分类、提取、翻译、代码生成、摘要
- 数据构建简单,输入输出结构固定
- 模型学会遵从特定指令格式
- 每条样本独立,无需上下文
对话微调(Chat SFT)
- 多轮:上下文感知的对话序列
- ShareGPT / ChatML / Llama3 格式
- 适合:聊天机器人、客服系统、AI 助手
- 数据构建复杂,需要多轮对话标注
- 模型学会角色扮演和上下文保持
- 包含 system/user/assistant 三种角色
Chat Template
将结构化的对话(角色 + 内容列表)转换为模型输入文本的格式模板。不同的基座模型使用不同的特殊 token 分隔角色——Llama 3 用 <|start_header_id|>,Qwen/Mistral 用 <|im_start|>。使用错误的 Chat Template 是微调失败最常见的原因之一:模型无法识别角色边界,导致"角色串话"或格式混乱。
Alpaca 格式
Stanford Alpaca 论文(2023)提出的简单指令微调格式。模板:### Instruction:\n{指令}\n\n### Response:\n{回答}。无 system 角色,无多轮能力,适合简单任务的快速微调。许多公开的指令微调数据集采用此格式,便于数据共享。
ShareGPT 格式
从 ShareGPT.com(用户分享的 ChatGPT 对话)整理而来的多轮对话格式。结构为 conversations 数组,每条包含 from("human"/"gpt"/"system")和 value(消息内容)。是目前开源社区最常用的多轮对话格式,大量 HuggingFace 上的对话数据集采用此格式。
主流 Chat Template 格式详解
ChatML 格式(Qwen2、Mistral-Instruct 常用)
<|im_start|>system
你是一个专业的法律助手。<|im_end|>
<|im_start|>user
什么是合同违约?<|im_end|>
<|im_start|>assistant
合同违约是指合同一方当事人不履行合同义务或履行不符合约定的行为...<|im_end|>
<|im_start|>user
能举个具体例子吗?<|im_end|>
<|im_start|>assistant
当然,比如甲方与乙方约定在某日交付货物...<|im_end|>
Llama 3 格式(Meta-Llama-3.x 系列)
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
你是一个专业的法律助手。<|eot_id|><|start_header_id|>user<|end_header_id|>
什么是合同违约?<|eot_id|><|start_header_id|>assistant<|end_header_id|>
合同违约是指...<|eot_id|>
ShareGPT 数据格式(JSON,常用于存储)
{
"conversations": [
{"from": "system", "value": "你是一个专业的法律助手。"},
{"from": "human", "value": "什么是合同违约?"},
{"from": "gpt", "value": "合同违约是指合同一方当事人..."},
{"from": "human", "value": "能举个例子吗?"},
{"from": "gpt", "value": "当然,比如甲方与乙方约定..."}
]
}
Chat Template 使用的常见错误
- 错误的特殊 token:Llama 3 用 <|eot_id|> 结束一轮对话,但如果不小心使用了 Llama 2 的格式([INST]/[/INST]),模型会完全无法理解角色结构,训练无效。永远通过 tokenizer.apply_chat_template 来生成格式文本,不要手动拼接特殊 token。
- 缺少 eos_token:训练文本末尾必须加上 tokenizer.eos_token(通常是 <|end_of_text|> 或 </s>)。没有 eos_token 的话,模型不知道何时停止生成,会在推理时产生无限输出。
- add_generation_prompt 设置错误:训练时 add_generation_prompt=False(完整对话,不添加末尾的 assistant 开头 token);推理时 add_generation_prompt=True(提示模型开始生成 assistant 回答)。弄反会导致训练格式和推理格式不一致。
使用 apply_chat_template
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B-Instruct")
# 对话数据(标准格式:role + content 列表)
messages = [
{"role": "system", "content": "你是专业的客服助手,只回答与产品相关的问题。"},
{"role": "user", "content": "我的订单什么时候发货?"},
{"role": "assistant", "content": "您好!请提供您的订单号,我来帮您查询。"},
{"role": "user", "content": "订单号是 ORDER-123456。"},
{"role": "assistant", "content": "您的订单将在 2 个工作日内发货,预计明天出库..."}
]
# 训练时:tokenize=False(返回文本),add_generation_prompt=False(完整对话)
training_text = tokenizer.apply_chat_template(
messages,
tokenize=False, # 返回字符串而非 token id
add_generation_prompt=False # 不添加末尾的 assistant 开头
) + tokenizer.eos_token # 必须手动添加 eos
# 推理时:add_generation_prompt=True(让模型知道该开始生成回答了)
inference_input = tokenizer.apply_chat_template(
messages[:-1], # 不包含最后一条 assistant 消息
tokenize=True, # 返回 token id
add_generation_prompt=True, # 添加 assistant 的开头 token,提示模型开始生成
return_tensors="pt"
)
# 批量处理 ShareGPT 格式的数据集
def convert_sharegpt_to_training_text(examples):
texts = []
for convs in examples["conversations"]:
# ShareGPT 格式 → 标准 messages 格式
messages = []
role_map = {"system": "system", "human": "user", "gpt": "assistant"}
for turn in convs:
role = role_map.get(turn["from"], turn["from"])
messages.append({"role": role, "content": turn["value"]})
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=False
) + tokenizer.eos_token
texts.append(text)
return {"text": texts}
只对 Assistant 回答计算 Loss(Response-Only 训练)
这是对话微调中最重要的技术细节之一。在多轮对话训练中,模型在处理整个文本序列时会对每个 token 计算损失。如果不做处理,模型会同时学习"如何提问"和"如何回答"——前者是不需要的,甚至有害:
为什么要 Response-Only 训练
在对话序列中,system 消息和 user 消息是"输入",assistant 消息是"输出"。如果对所有 token 计算 loss,模型会花精力学习如何预测用户问题的下一个词——这既浪费训练效率,又可能导致模型在推理时混淆自己的角色(出现"角色串话":模型回答到一半突然开始生成 user 的提问)。正确做法是只对 assistant 角色的 token 计算 loss,其余 token 的 loss 用 -100 掩码(PyTorch 中 CrossEntropyLoss 会忽略 label=-100 的位置)。
# 方法一:使用 Unsloth 的 train_on_responses_only(最简便)
from trl import SFTTrainer
from unsloth import train_on_responses_only
trainer = SFTTrainer(...) # 正常初始化
# 告诉 trainer 哪些 token 是 instruction(屏蔽 loss),哪些是 response(计算 loss)
trainer = train_on_responses_only(
trainer,
instruction_part="<|start_header_id|>user<|end_header_id|>\n\n", # Llama3 格式
response_part="<|start_header_id|>assistant<|end_header_id|>\n\n",
)
# 此后 trainer.train() 只对 assistant 部分的 token 计算损失
# 方法二:手动构建 labels(通用方法,适用于任何框架)
import torch
def mask_user_tokens(input_ids: torch.Tensor, tokenizer) -> torch.Tensor:
"""将所有 user/system turn 的 label 设为 -100,只保留 assistant turn"""
labels = input_ids.clone()
# 找到所有 assistant 开始和结束的位置
assistant_start_id = tokenizer.encode(
"<|start_header_id|>assistant<|end_header_id|>",
add_special_tokens=False
)
# 将非 assistant 部分的 token 设为 -100(CrossEntropyLoss 会忽略这些位置)
in_assistant = False
for i, token_id in enumerate(input_ids[0]):
if not in_assistant:
labels[0, i] = -100 # 屏蔽非 assistant 部分
return labels
评估微调效果
from unsloth import FastLanguageModel
import torch
# 加载微调后的模型进行推理评估
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="./unsloth-output/checkpoint-best", # 或 best checkpoint 路径
max_seq_length=2048,
load_in_4bit=True
)
FastLanguageModel.for_inference(model) # 切换到推理优化模式(2× 推理速度)
def generate_response(user_message: str, system_prompt: str = None) -> str:
"""生成模型回复,用于手动评估"""
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": user_message})
# 推理时 add_generation_prompt=True
inputs = tokenizer.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_tensors="pt"
).to("cuda")
with torch.no_grad():
outputs = model.generate(
input_ids=inputs,
max_new_tokens=512,
temperature=0.7,
top_p=0.9,
do_sample=True,
pad_token_id=tokenizer.eos_token_id # 避免 padding 警告
)
# 只解码新生成的 token(去掉 input 部分)
new_tokens = outputs[0][len(inputs[0]):]
return tokenizer.decode(new_tokens, skip_special_tokens=True)
微调效果的系统化评估
方法一:手动抽检(定性评估)
准备 20-50 个代表性测试问题(覆盖训练数据的各类型),手动对比微调前后的回答质量。重点检查:是否遵循了指令格式、专业领域术语是否正确、是否出现了幻觉或混淆。这是最直观但最费时的方法。
方法二:自动评分(LLM-as-Judge)
用强力模型(GPT-4o 或 Claude)作为"裁判",对比微调前后模型对相同问题的回答,给出 1-10 的评分和原因。优点:可大规模批量化;缺点:裁判模型本身有偏见(通常偏向更长的回答),需要设计合理的评分标准。
方法三:任务特定指标
对于可量化的任务,使用领域专用指标:代码生成用 pass@k(代码能否通过测试);翻译用 BLEU/COMET;信息抽取用 F1;分类任务用准确率/F1。这些指标客观且可重现,是最可靠的评估方式,但需要构建标注测试集。
本章核心要点
- 两种范式的选择:单轮指令任务(分类、提取、代码生成)用 Alpaca 格式的 SFT;多轮对话产品(聊天机器人、AI 助手)用 Chat Template 的对话微调。混淆两者是新手最常见的错误。
- Chat Template 的重要性:每个基座模型有自己的特殊 token 格式。必须通过 tokenizer.apply_chat_template 来生成训练文本,不要手动拼接。训练格式和推理格式必须完全一致。
- add_generation_prompt 的区别:训练时 False(使用完整对话),推理时 True(在末尾添加 assistant 的开头 token,提示模型开始生成)。
- Response-Only 训练是必须的:对话微调时只对 assistant 部分计算 loss,用 -100 掩码 user/system token。不这样做会导致模型学习到错误的"角色串话"行为。Unsloth 的 train_on_responses_only 是最简便的实现方式。
- 评估不能只看 loss:training loss 下降是必要条件但不充分。必须结合手动抽检(质量感知)+ LLM 评分(规模化)+ 任务特定指标(客观量化),才能全面判断微调效果。