想象你走进一家餐厅,服务员递给你一张精心设计过的点菜单:上面印着“顾客信息”“点餐备注”“后厨专用”三个区域,每道菜旁边有编号、辣度选项、忌口勾选框。正因为有了这张统一格式的菜单,厨师才能一眼看懂“3号桌,宫保鸡丁,微辣,不要花生”到底该怎么操作。
大模型的世界里也有这样一张“菜单”,它叫对话格式(Chat Format)。监督微调SFT的核心任务之一,就是教会模型如何看懂这张菜单——分清哪句话是“顾客说的”(用户指令)、哪句话是“服务员记的”(模型回答)、哪句话是“餐厅规定”(系统提示)。没有统一格式,模型就像收到一张字迹潦草、毫无结构的点菜单,根本不知道该听谁的、该回什么。
从 llama-2 的 [INST] 到 ChatML 的 <|im_start|>,从角色标记到损失掩码,这篇文章会用“餐厅订单模板”作为贯穿始终的比喻,带零基础的你完整掌握对话格式的原理、模板构建、数据准备与实战调优。
掌握对话格式是微调大模型的“入场券”——无论你用 LLaMA-Factory 还是 DeepSeek,所有 SFT 数据都必须以标准对话格式组织,这是 AI 从业者从事模型对齐、智能体开发、RAG 应用的基础功。
可视化演示
动态展示监督微调中对话格式的Token结构与损失掩码机制,对比训练Token与掩码Token的分布差异。
本节配有交互动效(进入视口后自动加载,约 3KB)。
SFT对话格式 用户Token 训练Token 系统/掩码 Loss=CE(y,y_hat) 仅对助手回复计算损失
对话格式基础
餐厅后厨最怕什么?怕顾客在纸上写一句“随便来点好吃的”——没有结构、没有分栏,厨师无从下手。于是餐厅设计了统一的点菜单:顾客信息栏、菜品栏、备注栏。对话格式(Chat Format) 就是这个道理:它是大模型与人之间约定的、结构化的消息组织方式。
在技术世界里,这张菜单通常包含三类角色:system(系统提示/餐厅规定)、user(用户指令/顾客点菜)、assistant(模型回复/服务员应答)。监督微调 SFT 的“监督”二字,正体现在这里——我们给模型提供“标准答案格式”,让它照着学。
为什么必须结构化?因为模型本质上一个劲地在预测“下一个 token”。如果对话内容像一条长流水账,模型无法分清哪些 token 是用户说的、哪些是自己该接的。格式就是“分栏”,让模型学会:看到用户栏的结尾,就知道该轮到自己开口了。
1. 系统消息:扮演“餐厅规定”的角色,给出全局行为约束,放在整段对话最开头。
2. 多轮求和:i 从 1 到 n 的每一轮,都是一对“用户问+助手答”,像点菜单上一行行菜品记录。
3. 交替拼接:user_i 和 assistant_i 必须严格交替,不能连续两条 user,否则模型会“精分”。
回到比喻:训练 SFT 模型,就是不断给它看“填好的点菜单”,让它掌握“看到顾客栏,我就该写菜品栏”的固定套路。格式一旦混乱,模型就会像新手服务员一样,把菜名写到备注栏里。
主流格式解析
不同餐厅有不同的菜单模板——中餐厅用三联单,西餐厅用桌边点单系统。不同模型家族也各有各的对话格式。目前主流的有三种,就像三种流传最广的“点菜单版本”。
llama-2 格式是 Meta 在 Llama 2 系列中使用的格式,用 [INST] 和 [/INST] 分别标记指令的开头和结尾,像在菜单上用粗线框出“顾客指令区”:
ChatML 格式由 OpenAI 在 ChatGPT 训练中推广,用 <|im_start|> 和 <|im_end|> 包裹每条角色消息,像每道菜都独立装盘、贴上标签:
\displaystyle \text{<|im_start|>user}\n\text{用户消息}\text{<|im_end|>}\n\text{<|im_start|>assistant}\n\text{模型回复}\text{<|im_end|>}
Alpaca 格式来自斯坦福的 Alpaca 项目,结构更简单,适合单轮指令跟随场景:
\displaystyle \text{### Instruction:}\n\text{用户指令}\n\text{### Response:}\n\text{模型回复}
1. 左尖括号:<s> 是句子起始符,告诉模型“菜单开始打印了”。
2. 框住指令:[INST] 和 [/INST] 配对出现,像点菜单上的“菜品栏从这里开始/到这里结束”。
3. 角色头:<|im_start|>user 是消息头,声明“下面这段话的说话人是用户”。
\text{<|im_start|>user}\n
4. 结束符:<|im_end|> 是一条消息的结束标记,像菜盘上盖的保鲜膜。
\text{<|im_end|>}
作为 AI 从业者,最大的坑是“混用格式”——今天用 llama-2 模板训练,推理时却用 ChatML 拼接,模型立刻进入“点菜单错版”状态,输出乱码式回复。选一种主流格式(推荐 ChatML),训练与推理全程锁死。
特殊标记作用
点菜单上的分隔线不是装饰——它能防止顾客把“不要香菜”写到“桌号”栏里。对话格式中的特殊标记(Special Tokens)扮演的正是“分隔线”的角色。它们的核心作用有三个。
第一,分隔角色。<|im_start|>user 和 <|im_start|>assistant 是两个不同的标记,模型看到它们,就像员工听到“顾客来了”和“轮到我了”两种不同的铃声。第二,定位边界。<|im_end|> 标记一条消息的结束,让模型知道“这句话讲完了”。第三,拼接多轮。多轮对话就是把多组消息按顺序串起来,标记像火车车厢之间的挂钩,保证每节车厢(每条消息)不脱节。
1. 消息体:M_i 代表第 i 条消息的文本内容,如“推荐一道微辣招牌菜”。
2. 结束标记:E_i 是第 i 条消息的结束符,如 ChatML 中的 <|im_end|>。
3. 交替连接:M_i + E_i 保证每条消息都自包含、不串味。
想象一下传菜员按铃、上菜、按铃、再上菜——特殊标记就是那个“铃”。没有铃,后厨永远不知道哪句话是上一道菜的尾声。训练时如果漏掉结束符,模型就学不会“什么时候该闭嘴”。
模板构建方法
餐厅有了菜单模板还不够,得把它印成空白单,服务员随时拿出来套用。构建对话模板就是把“角色消息列表”渲染成“带标记的标准字符串”的过程。
在工程实现上,模板通常是一个配置化定义的字符串,而非硬编码。比如用 Hugging Face Transformers 里的 apply_chat_template,把消息列表作为输入,自动加上所有特殊标记。这样做的好处是:换模型时只需换模板,不用改数据管道。
一个标准的 ChatML 模板在代码里长这样:"<|im_start|>{role}\n{content}<|im_end|>\n",其中 {role} 替换为 user/assistant/system,{content} 替换为消息正文。
\displaystyle \text{渲染结果} = \text{f}(\text{role}, \text{content}) = \text{"<|im_start|>"} + \text{role} + \text{"}\n\text{"} + \text{content} + \text{"<|im_end|>}\n\text{"}
1. 角色拼接:把 user、assistant 等角色名嵌入到 <|im_start|> 之后。
\text{"<|im_start|>"} + \text{role}
2. 内容嵌入:把消息正文放在角色名之后的换行符后面。
\text{role} + \text{"}\n\text{"} + \text{content}
3. 收尾封闭:最后补上 <|im_end|> 和换行,完成一条完整消息的渲染。
\text{content} + \text{"<|im_end|>}\n\text{"}
照这个比喻,模板就是复印机——数据管道只负责往空白单上填内容,模板负责保证每一张单子格式一致。实战中建议用社区成熟的模板(如 ChatML)而不是自创,因为预训练模型已经在特定格式上见过大量数据。
损失掩码原理
后厨培训新厨师时,不会让他把顾客说的每句话都背下来,只让他学“如何做菜”。SFT 的损失计算(Loss Masking) 遵循同样的原则:只对“助手回复”部分计算损失,对“用户指令”和“系统提示”部分进行掩码屏蔽。
为什么?因为模型需要学会的是“如何回答”,而不是“如何提问”。如果对用户消息也计算损失,相当于逼厨师把顾客台词背得滚瓜烂熟,纯属浪费。同时,用户消息中的敏感信息也不该被模型刻意“背诵”。
其中 答案位置 指 assistant 消息中所有 token 的下标集合。p_\theta(y_i \mid y_{<i}) 是模型对第 i 个 token 的预测概率。
1. 平均损失:1/N 表示对所有被计算损失的 token 取平均,N 是答案部分的总 token 数。
2. 集合限定:i \in 答案位置 表示只遍历助手回复中的位置,跳过 prompt。
3. 对数概率:log p_\theta 表示模型对正确 token 预测概率的对数,越大说明学得越好。
掩码机制就像培训手册上印的“红字答案”——学员只对着红字练,黑字(顾客台词)看一眼即可,不用背。实现时要注意:有些开源库默认不开启掩码,需要手动配置 ignore_index=-100 才能生效。
数据构建流程
有了模板和掩码,还差最重要的原材料——SFT 训练数据。就像餐厅需要把每天的顾客反馈整理成新菜谱,AI 工程师需要把真实对话清洗成标准训练样本。整个流程分四步。
第一步,清洗对话:去掉无关的闲聊、重复内容、敏感信息。第二步,格式转换:将原始对话转成 system/user/assistant 三段式,再套用模板渲染成字符串。第三步,质检校验:检查每一条数据的角色顺序是否正确、特殊标记是否闭合。第四步,规模配比:一般来说,对话数据中“多轮样本”要有一定占比,让模型学会上下文连贯。
1. 清洗函数:clean 指去除原始对话中的噪声(空行、乱码、格式不一致)。
2. 模板渲染:箭头 \xrightarrow{\text{模板}} 表示用配置好的模板把清洗结果转成标准字符串。
3. 编码器:tokenizer 把标准字符串切分成 token id 序列,交给模型训练。
回到比喻:后厨拿到一大堆顾客手写意见,先抄录、再分类、最后对账,才能变成标准菜谱。数据构建是 SFT 中最耗时、最决定上限的环节——垃圾进,垃圾出,格式错误的数据比没有数据更糟。
实战调优策略
最后是工程实战环节。即便理解了对仗格式的所有原理,实际微调时仍会遇到各种“菜单印刷错误”。这里给出三条核心调优策略。
第一,先检查模板。模型输出异常时,第一件事不是调学习率,而是打印一条训练样本,肉眼检查特殊标记是否正确闭合。第二,控制数据量。SFT 不是数据越多越好,通常几千到几万条高质量对话即可,重点是 format 一致性。第三,迭代实验。用固定模板训练一个小模型(或 LoRA),快速验证格式是否被模型学会,再做全量微调。
1. 格式正确率:统计模型生成的回复中,角色标记是否按预期出现。
2. 内容正确率:统计语义是否满足用户指令,属于更高层次的质量。
3. 比值定义:分子是“标记合法的回复数”,分母是“总回复数”,两者相除得到百分比。
调优的过程就像餐厅根据差评不断修改菜单排版:顾客说“看不见辣度选项”,就加粗那一栏;模型说“不知道何时结束回答”,就检查是不是漏了 <|im_end|>。小步快跑、持续迭代,SFT 的工程质量就是这样打磨出来的。
AI 应用场景
智能客服系统微调
阶段:SFT 数据准备与格式转换
解决的问题:传统客服系统基于关键词匹配,答非所问;用对话格式组织历史客服工单,微调开源大模型(如 Qwen),让模型掌握标准应答结构。
具体案例:某电商平台导出 1.2 万条历史客服对话,用 ChatML 格式构建训练集:<|im_start|>user 放用户问题,<|im_start|>assistant 放标准答复。微调后模型能正确识别“退款流程”“物流查询”等意图,并输出格式规范的回复,同时用 loss masking 只学习客服标准回答段。
角色扮演对话数据构建
阶段:对话模板构建与多轮拼接
解决的问题:构建多轮角色扮演数据时,角色交替混乱导致模型“精分”;用统一模板强制多轮对话结构。
具体案例:某游戏公司训练 NPC 对话模型,收集了 5 万条多轮剧本对话,按 system/user/assistant 严格交替组织,用 llama-2 格式([INST] 标记玩家指令)渲染训练。最终 NPC 能记住上下文、不跳出角色设定。
模型推理时的格式一致性保障
阶段:推理部署与格式校验
解决的问题:训练用的模板和推理不一致,模型输出崩溃;通过统一的 tokenizer 模板保证两端对齐。
具体案例:某企业用 vLLM 部署微调后的 Llama 3 模型,发现推理输出夹杂 <|im_start|> 字面量。排查发现训练用了 ChatML 模板、推理却走了默认模板,修复后启用 tokenizer.apply_chat_template 统一两端格式,问题解决。
Python 演示
# -*- coding: utf-8 -*-"""用 ChatML 格式构建 SFT 训练样本并演示 loss masking。"""from typing import List, Dict def build_chatml(messages: List[Dict[str, str]]) -> str: """将消息列表渲染为 ChatML 格式字符串。""" rendered = "" for msg in messages: role = msg["role"] content = msg["content"] rendered += f"<|im_start|>{role}\n{content}<|im_end|>\n" return rendered def mask_prompt_tokens(input_ids: List[int], tokenizer, prompt_len: int) -> List[int]: """将 prompt 部分的 token 标记为 -100(不参与 loss 计算)。""" labels = list(input_ids) for i in range(prompt_len): labels[i] = -100 return labels def build_sft_sample(messages: List[Dict[str, str]], tokenizer) -> Dict[str, List[int]]: """构建一条 SFT 训练样本:input_ids + labels(带掩码)。""" text = build_chatml(messages) input_ids = tokenizer(text, add_special_tokens=False)["input_ids"] # 假设 prompt 是前 N-? 个 token,简单示例取前 2 条消息的长度 prompt_text = build_chatml(messages[:-1]) prompt_ids = tokenizer(prompt_text, add_special_tokens=False)["input_ids"] labels = mask_prompt_tokens(input_ids, tokenizer, len(prompt_ids)) return {"input_ids": input_ids, "labels": labels} def main(): # 一个简化版 tokenizer:按字符切分 class DummyTokenizer: def __call__(self, text: str, add_special_tokens: bool = False) -> Dict[str, List[int]]: return {"input_ids": [ord(c) for c in text]} tok = DummyTokenizer() messages = [ {"role": "system", "content": "你是一位点餐助手。"}, {"role": "user", "content": "推荐一道微辣招牌菜。"}, {"role": "assistant", "content": "推荐宫保鸡丁,微辣。"}, ] sample = build_sft_sample(messages, tok) print("input_ids length:", len(sample["input_ids"])) print("labels sample:", sample["labels"][:20], "...") print("masked count:", sample["labels"].count(-100)) if __name__ == "__main__": main()
rendered += f"<|im_start|>{role}\n{content}<|im_end|>\n"每一条消息按 ChatML 格式拼接,角色和内容填入模板。for i in range(prompt_len): labels[i] = -100把 prompt 部分的标签设为 -100,让损失函数自动跳过这些位置。tokenizer(text, add_special_tokens=False)分词时不额外添加全局特殊 token,保证格式完全由模板控制。mask_prompt_tokens核心掩码函数,模拟了主流训练框架中ignore_index=-100的机制。sample["labels"].count(-100)统计被掩码的 token 数,验证 prompt 部分确实被屏蔽。
常见误区
误区很多人觉得对话格式不过是在消息前后加点尖括号,随便按自己的想法拼一个格式就行,反正模型能“猜”出来。
正解对话格式是模型在预训练阶段见过的“数据分布”。如果你自创一套格式,模型在推理时遇到 <|im_start|> 这类标记,可能完全不知道它代表什么。就像你用一张全新的火星菜单去点菜,厨师根本看不懂。必须复用社区验证过的模板(如 ChatML、llama-2 格式),且训练与推理严格一致。
为何常见:人类阅读文字时具有很强的容错性,而 LLM 本质是概率模型,对格式变化非常敏感,缺少 “格式敏感” 的直觉。
误区既然模型要理解用户问题,那损失计算就应该同时覆盖用户消息和助手回复,让模型“两样都学”。
正解SFT 的目标是提升模型的回答能力,不是让它背诵用户问题。对用户消息算损失会让模型把大量参数花在“记住提问”上,而不是“学会回答”。用 loss masking 只对 assistant 部分计算梯度——就像培训厨师时只让他练做菜,不用背顾客的每一句台词。
为何常见:人们凭直觉认为“输入输出都要学”,忽略了 SFT 中“监督”的对象是标准答案,而不是问题本身。
误区认为格式只是字符串层面的拼接,跟分词器(tokenizer)没有关系,最终反正都会变成 token。
正解特殊标记(如 <|im_start|>)在 tokenizer 中必须是独立 token,而非拆成多个字符 token。如果 tokenizer 词表中没有这些标记,它们会被切成碎片,模型学到的是“碎片之间的拼接”,而不是一个语义完整的“角色分隔符”。实用中,ChatML 格式的模型在 tokenizer 中显式声明了 <|im_start|> 等特殊 token。
为何常见:初学者通常把分词当作“黑盒”,很少检查特殊 token 的分词结果。
误区准备 SFT 数据时,认为对话轮次越多、上下文越长,模型能力越强,于是把几十轮的聊天记录全塞进去。
正解过长的多轮对话会导致两个问题:一是超出模型的上下文窗口,训练时被截断;二是格式复杂度上升,模型学到“格式”的难度大于学到“内容”。工程经验是单条样本控制在 5-10 轮以内,且保证每轮角色严格交替。就像点菜单写满了密密麻麻的菜,反而看不清重点。
为何常见:“数据越多越好”的直觉,忽略了模型有上下文长度上限。
面试题
Q1. 在 SFT 对话格式中,<|im_start|> 的主要作用是什么?
- A.表示文本的结束
- B.标记一条消息的开始和角色← 正确答案
- C.表示模型的回复内容
- D.控制生成随机性
答案:B。<|im_start|> 是 ChatML 格式中用于标记“消息开始”的特殊 token,后缀紧跟角色名(user/assistant/system),相当于点菜单上的“分栏线”。B 正确。A 是 <|im_end|> 的作用;C 是消息内容而非标记;D 与生成参数 temperature 相关。
Q2. llama-2 对话格式中,[INST] 和 [/INST] 用来包裹什么?
- A.系统提示词
- B.模型生成的回复
- C.用户的指令内容← 正确答案
- D.特殊结束标记
答案:C。在 llama-2 格式中,[INST] 和 [/INST] 配对使用,中间放置用户指令,相当于菜单上“顾客点菜栏”。C 正确。系统提示词通常放在最外层;模型回复紧跟 [/INST] 之后;结束标记是 </s> 而非 [/INST]。
Q3. 关于 SFT 中的损失掩码(loss masking),下列说法正确的是?
- A.对全部 token 都计算损失
- B.只对 assistant 回复部分计算损失← 正确答案
- C.只对 user 消息计算损失
- D.掩码会降低模型训练速度
答案:B。loss masking 的核心是只对标准答案(assistant 回复)计算损失,prompt 部分设 -100 忽略,B 正确。A 会让模型学习无意义的“背题”;C 完全颠倒;D 掩码通常用 ignore_index=-100 跳过计算,反而节省算力。
Q4. 为什么 SFT 对话格式必须与推理时保持一致?
- A.因为模型只会输出训练时见过的格式← 正确答案
- B.因为格式不一致会导致 tokenizer 报错
- C.因为格式不一致会改变模型参数量
- D.因为格式不一致会降低推理延迟
答案:A。模型通过大量 SFT 数据学习到“特定格式→回复”的映射关系,推理时若改用其他格式(如训练用 ChatML、推理用 llama-2),模型对特殊标记的语义理解失效,产生“格式错乱”的输出。A 正确。tokenizer 不报错,而是“按新格式切分”;参数量不变;延迟与格式无直接关系。
Q5. 特殊标记 <|im_end|> 在对话格式中的作用是什么?
- A.表示对话开始
- B.标记一条消息的结束← 正确答案
- C.表示模型开始生成
- D.分隔词表与文本
答案:B。<|im_end|> 是 ChatML 中一条消息的结束标记,相当于“这道菜上完了”的按铃声。B 正确。对话开始是 <|im_start|>;模型开始生成由推断框架触发;词表分隔是 tokenizer 的事。
Q6. 在 ChatML 模板中,系统提示词(system message)应放在什么位置?
- A.每条用户消息之后
- B.整个对话的最开头← 正确答案
- C.所有对话的最末尾
- D.穿插在任意位置
答案:B。系统提示词定义全局行为规则,必须在整段对话的最开头出现,让模型先“读到”行为约束,再开始处理具体请求。B 正确。放在中间或末尾会让模型在生成历史回复时没有受到约束,A/C/D 都违背了这一原则。
Q7. 构建 SFT 对话数据时,哪些做法是合理的?
- A.使用社区验证过的格式化模板← 正确答案
- B.自创一套格式以突出创新
- C.训练与推理使用同一套模板← 正确答案
- D.让用户消息和助手消息交替出现← 正确答案
答案:ACD。A 合理,复用已验证格式可保证模型理解;C 合理,两端一致性是工程生命线;D 合理,角色交替是对话格式的基本要求。B 不合理,自创格式会导致模型无法理解特殊标记,就像用火星菜单点菜。
Q8. 以下哪些属于对话格式中特殊标记的功能?
- A.分隔不同角色的消息← 正确答案
- B.标记消息的边界← 正确答案
- C.控制生成文本长度
- D.拼接多轮对话← 正确答案
答案:ABD。特殊标记的核心功能是分隔角色(A)、定位消息边界(B)、拼接多轮对话(D)。C 错误,控制生成长度由 max_new_tokens 等采样参数负责,与格式标记无关。
Q9. 关于 SFT 损失掩码的工程实现,以下描述正确的有?
- A.将 prompt 部分的标签设为 -100← 正确答案
- B.损失函数忽略标签为 -100 的位置← 正确答案
- C.掩码会让模型学习用户提问方式
- D.掩码使模型专注学习标准回复← 正确答案
答案:ABD。PyTorch 的交叉熵损失函数中 ignore_index=-100 会跳过这些位置(A/B 正确);掩码的目的是让模型专注学习 assistant 的标准回复,而非用户提问(D 正确 C 错误)。
Q10. 使用 Alpaca 格式时,下列哪种场景最合适?
- A.十轮以上的复杂多轮对话
- B.单轮指令跟随任务← 正确答案
- C.角色扮演长篇小说生成
- D.流式语音识别
答案:B。Alpaca 格式只有 ### Instruction 和 ### Response 两个区块,结构极简,适合单轮指令跟随。B 正确。A/C 更推荐 ChatML,它支持多轮角色标注;D 是完全不同的语音任务,与对话格式无关。