📌 回顾
上篇我们讲了模型评测工具箱——如何用自动化评测脚本、标准 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-Factory 或 Axolotl 这类框架——它们内置了完整的 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 条粗制滥造的数据:
- 上下文长度够长 — 每条数据至少 3-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 把模型跑起来做推理加速?下篇我们来聊聊生产环境部署。
参考资料
- HuggingFace Chat Template 文档:https://huggingface.co/docs/transformers/main/en/chat_templating
- Qwen2.5 Chat Template 源码:tokenizer_config.json
- LLaMA 3 对话格式:Meta LLaMA 3 Prompt Format
- PEFT 官方示例:https://github.com/huggingface/peft
- Axolotl 多轮对话训练框架:https://github.com/OpenAccess-AI-Collective/axolotl
- LLaMA-Factory:https://github.com/hiyouga/LLaMA-Factory
转载请注明:Falost的小窝 » 多轮对话微调实战:从单轮到真正的对话助手(附Chat Template指南与避坑表)


