• 欢迎关注我的微信公众号“ Falost ” 右边扫描关注 --->>

多轮对话微调实战:从单轮到真正的对话助手(附Chat Template指南与避坑表)

AI / 大模型 神棍 145℃ 0评论

📌 回顾

上篇我们讲了模型评测工具箱——如何用自动化评测脚本、标准 Benchmark 和 LLM-as-Judge 来判断微调效果。但系列里所有的微调样例都是单轮问答:一个 user 消息,一个 assistant 回复。

现实中的对话不是这样的。用户会追问、补充、切换话题,模型需要理解上下文才能给出连贯的回复。这期我们就来攻克这个缺口。

一、为什么单轮问答不够用?

只用单轮数据微调,模型在真实对话中会出现几个典型问题:

  • 上下文断裂 — 第二轮对话把上一轮的信息全忘了
  • 角色混淆 — 分不清哪句是用户说的、哪句是自己的回答
  • 指代失效 — 用户说”刚才那个方案再改一下”,模型不知道”刚才那个”是什么

这些问题本质上是同一个:模型没有学会在对话上下文中继续生成,它仍然把每轮对话当成独立的问题来处理。

那怎么解决?答案就是 Chat Template,以及结构化的多轮对话数据

二、什么是 Chat Template?

Chat Template 是 HuggingFace transformers 库提供的一种机制,用来把结构化的对话消息列表(messages)格式化为模型能理解的文本格式。

它的核心是每一条消息都带一个 role 字段:

messages = [
    {"role": "system",  "content": "你是一个知识渊博的助手。"},
    {"role": "user",    "content": "Python 和 JavaScript 有什么区别?"},
    {"role": "assistant","content": "Python 常用于数据分析、AI;JS 用于前端开发。"},
    {"role": "user",    "content": "那哪个更适合入门?"},
    {"role": "assistant","content": "看方向:数据/AI 选 Python,网页选 JS。"},
]

然后调用 tokenizer.apply_chat_template() 把它转成文本。但不同模型用的是不同的模板格式——用错了模板,微调直接白费:

模型 模板格式示例
Qwen2.5(ChatML 格式) <|im_start|>system\n你是助手<|im_end|>\n<|im_start|>user\n你好<|im_end|>\n<|im_start|>assistant\n你好!<|im_end|>
LLaMA 3 <|begin_of_text|><|start_header_id|>system<|end_header_id|>\n你是助手<|eot_id|>\n<|start_header_id|>user<|end_header_id|>\n你好<|eot_id|>

好消息是你不需要手动拼接——apply_chat_template 会自动按照模型对应的模板格式化。每个模型的 tokenizer.chat_template 属性里存的就是这个模板字符串,你可以直接打印出来看。

使用方式非常简单:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

messages = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!有什么可以帮你的?"},
    {"role": "user", "content": "帮我查一下北京的天气"},
    {"role": "assistant", "content": "北京今天晴,温度15-22°C。"},
]

# 自动应用模型的 Chat Template(训练模式不加生成标记)
text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=False
)
print(text)

三、配置多轮对话数据集

训练多轮对话需要先把数据组织成上面的 messages 格式,然后保存为 JSON/JSONL 文件。

推荐的数据格式(ShareGPT/LLaMA-Factory 兼容格式):

[
    {
        "id": "sample_001",
        "conversations": [
            {"from": "system", "value": "你是一位编程导师。"},
            {"from": "user", "value": "Python 怎么读取 CSV?"},
            {"from": "assistant", "value": "用 csv 模块或 pandas 的 read_csv 方法。"},
            {"from": "user", "value": "能给个具体例子吗?"},
            {"from": "assistant", "value": "import csv\nwith open('data.csv') as f:\n    reader = csv.reader(f)\n    for row in reader:\n        print(row)"}
        ]
    }
]

加载时转成 HuggingFace 的 messages 格式:

role_map = {"system": "system", "user": "user", "assistant": "assistant"}

def format_conversation(item):
    return [
        {"role": role_map[msg["from"]], "content": msg["value"]}
        for msg in item["conversations"]
    ]

每条样本包含多轮 user-assistant 交替。关键点:每个 assistant 回复都要参与损失计算,而不仅仅是最后一轮。

四、微调时如何正确处理 Chat Template

实操环节。训练时核心要点有三个。

4.1 训练时不加 generation prompt

text = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=False  # 训练时不要加!
)

add_generation_prompt=True 会在末尾添加一个空的 assistant 头,用于推理时让模型开始生成。训练时用了会导致 label 错位——模型会学到在生成 assistant 回复之前多产生一个空 header,影响输出质量。

4.2 只对 assistant 回复计算损失(Loss Masking)

如果对整个对话文本计算损失,模型会学习”预测用户的问题”和”预测 system prompt”——这完全不对。标准做法是把 user/system 部分的 token 在 labels 中设为 -100,PyTorch 的 CrossEntropyLoss 会自动忽略它们。

def mask_non_assistant_tokens(input_ids, tokenizer, messages):
    labels = input_ids.copy()

    # 逐条消息处理,对每条 assistant 消息逐个标记
    offset = 0
    for msg in messages:
        msg_text = tokenizer.apply_chat_template(
            [msg], tokenize=False, add_generation_prompt=False
        )
        msg_tokens = tokenizer.encode(msg_text, add_special_tokens=False)
        msg_len = len(msg_tokens)

        if msg["role"] == "assistant":
            pass  # 保留这些位置的 label
        else:
            # user / system 部分设为 -100
            labels[offset:offset + msg_len] = [-100] * msg_len

        offset += msg_len

    return labels

实际工程中,大部分人使用 LLaMA-FactoryAxolotl 这类框架——它们内置了完整的 loss masking 逻辑,你只需要按格式提供数据即可。

以 LLaMA-Factory 为例,数据配置是这样的:

# dataset_info.json 里添加一条:
{
    "multi_turn_chat": {
        "file_name": "multi_turn_data.json",
        "formatting": "sharegpt",
        "columns": {
            "messages": "conversations"
        },
        "tags": {
            "role_tag": "from",
            "content_tag": "value",
            "user_tag": "user",
            "assistant_tag": "assistant",
            "system_tag": "system"
        }
    }
}

然后在训练命令中指定 --dataset multi_turn_chat,框架自动完成模板格式化和 loss masking。

4.3 截断策略:保留最新的对话

多轮对话通常很长。如果超过 max_length,需要丢弃最早的历史:

inputs = tokenizer(
    text,
    truncation=True,
    padding="max_length",
    max_length=2048,
    truncation_side="left"  # 保留最新的对话,丢弃最老的
)

为什么从左截断?因为最近的对话上下文对当前回复最重要。早期的寒暄或无关讨论可以牺牲。这个设置会在截断时扔掉开头的 token,保留末尾的最新内容。

五、构建好数据的5条原则

数量不如质量。200-500 条精心设计的样本胜过 2000 条粗制滥造的数据:

  1. 上下文长度够长 — 每条数据至少 3-5 轮对话,太短学不会上下文理解
  2. 包含指代和省略 — 故意加入”那个””刚才的””第二个方案”这类指代,让模型学会回看上文
  3. 不要全是直线对话 — 加入用户修正、追问、打断的场景
  4. 角色一致性 — 确保你的回答始终符合设定的语气和风格
  5. 数量参考 — 200-500 条高质量多轮对话,效果远超 2000 条低质量单轮数据

六、常见坑一览

后果 解决
训练和推理用不同 tokenizer 模板格式不一致,生成乱码 始终用同一个 tokenizer(或同一个模型目录)
训练时误加 generation prompt label 偏移,训练效果差 训练用 add_generation_prompt=False
截断策略用默认右截断 system prompt 和早期对话被保留,最新内容丢失 显式设 truncation_side="left"
没设 pad_token batch 训练报错 tokenizer.pad_token = tokenizer.eos_token
用 CPU 跑大模型训练 慢到怀疑人生 用 QLoRA + 4bit 量化(参考第4篇)

七、完整训练脚本参考

以下是用 HuggingFace Trainer 做多轮对话 LoRA 微调的完整骨架:

from transformers import (
    AutoTokenizer, AutoModelForCausalLM,
    TrainingArguments, Trainer
)
from datasets import Dataset
import torch

model_name = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token

def prepare_example(example):
    messages = [
        {"role": m["from"], "content": m["value"]}
        for m in example["conversations"]
    ]

    full_text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=False
    )

    # Tokenize(从左截断保留最新对话)
    inputs = tokenizer(
        full_text,
        truncation=True,
        max_length=2048,
        truncation_side="left",
        return_tensors=None
    )

    # labels 默认和 input_ids 相同
    inputs["labels"] = inputs["input_ids"].copy()
    return inputs

dataset = Dataset.from_json("multi_turn_data.json")
dataset = dataset.map(prepare_example)

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto"
)

# LoRA 配置
from peft import LoraConfig, get_peft_model
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)

trainer = Trainer(
    model=model,
    args=TrainingArguments(
        output_dir="./multi_turn_lora",
        per_device_train_batch_size=1,
        gradient_accumulation_steps=8,
        learning_rate=2e-4,
        num_train_epochs=3,
        logging_steps=10,
        save_steps=100,
        bf16=True,
    ),
    train_dataset=dataset,
)
trainer.train()

注意上面的脚本没有实现精确的 loss masking。生产环境建议用 LLaMA-Factory —— 它已经帮你处理好了模板解析和 loss 对齐,配置格式也标准化了。

总结

多轮对话微调的本质并不复杂:把数据从”一问一答”变成”有来有回”,用 apply_chat_template 正确格式化,对每个 assistant 回复计算损失。做好这三步,你的模型就不再是个”一问一答”的机器人,而是能记住上下文、进行连贯对话的助手。

核心就三件事:用对 Chat Template → 做好 Loss Masking → 构建高质量多轮数据。模板错了训练全白费,Loss Masking 错了模型学用户说话,数据质量差了练再多也没用。

下期预告:模型部署与推理优化 —— 微调好了怎么上线?怎么用 vLLM、TGI 把模型跑起来做推理加速?下篇我们来聊聊生产环境部署。


参考资料

  1. HuggingFace Chat Template 文档:https://huggingface.co/docs/transformers/main/en/chat_templating
  2. Qwen2.5 Chat Template 源码:tokenizer_config.json
  3. LLaMA 3 对话格式:Meta LLaMA 3 Prompt Format
  4. PEFT 官方示例:https://github.com/huggingface/peft
  5. Axolotl 多轮对话训练框架:https://github.com/OpenAccess-AI-Collective/axolotl
  6. LLaMA-Factory:https://github.com/hiyouga/LLaMA-Factory

转载请注明:Falost的小窝 » 多轮对话微调实战:从单轮到真正的对话助手(附Chat Template指南与避坑表)

如果你觉得这篇文章不错或者对你有帮助,想请我喝一杯咖啡,可以打赏
喜欢 (0)
发表我的评论
取消评论

表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址