Chapter 06

指令微调 vs 对话微调:Chat Template

正确的格式是微调成功的基础。格式错误可以让再好的数据也白费功夫。

两种微调范式

理解两种微调范式的本质区别,有助于为你的任务选择合适的方法:

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 使用的常见错误

使用 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。这些指标客观且可重现,是最可靠的评估方式,但需要构建标注测试集。
本章核心要点