4.1 chat-template:对话的序列化协议


4.1 chat-template:对话的序列化协议

本节摘要:语言模型只认 token 序列,不认"消息列表"——chat template 就是把结构化对话序列化成 token 序列的协议:谁说的、从哪开始、到哪结束,全部用 special tokens 画出来。本节先看协议的全貌(messages → 包裹标记 → 序列),再拆 nanochat 采用的 Harmony 风格标记家族(<|beginmessage|>、<|messageuser|>、<|messageassistant|>、<|endoftext|>,发布帖 Discussion #1 口径),讲清 special tokens 为什么必须进词表、终止符为什么"既是文本又是控制信号";最后给出渲染代码与"逐字节一致"的校验方法——这份校验将贯穿第 3 章训练与第 6 章部署。

学习目标

阅读完本节,你应当能够:

  1. 手绘"两条消息的对话渲染成序列"的全过程,标出每段 token 的归属。
  2. 说出 special tokens 的三个设计要点(进词表、单 token、不参与普通分词)。
  3. 写出渲染代码并做逐字节一致性校验。

一、协议全貌:从结构到序列

第 2 章的契约是结构化的,模型吃的是线性的。中间的翻译:

messages(结构) token 序列(线性) ────────────────────── ────────────────────────────────────── system: 你是科普助手 ├──► <|beginmessage|>system ... <|endmessage|> user: 黑洞是什么? ├──► <|beginmessage|>user 黑洞是什么? <|endmessage|> assistant: 黑洞是……(待生成) ├──► <|beginmessage|>assistant 黑洞是…… <|endoftext|>

消息边界(谁在说、从哪到哪)在原始文本里不存在——"黑洞是什么?"这句话本身不带作者签名。special tokens 就是加在文本上的签名与围栏:训练时它们告诉模型"这段是用户、那段是你";推理时它们是模型判断"轮到我说了"的唯一依据。

nanochat 采用 Harmony 风格的消息标记(官方发布帖 Discussion #1 口径,词表约 2000 的 BPE 之外追加的一小组 special tokens):

标记(示意) 作用
<|beginmessage|> 一条消息的开始
<|messageuser|> / <|messageassistant|>(及 system 对应标记) 消息作者签名
<|endmessage|> 一条消息的结束
<|endoftext|> 整轮回答的终止符——模型的"我说完了"

⚠️ 上表是发布帖描述的家族形态,具体拼写以官方仓库为准(当前仓库实现可能微调命名);教学重点在结构不在拼写——"边界 + 作者 + 终止"三件套缺一不可

二、special tokens 的三个设计要点

  1. 必须进词表:special token 在分词器里注册为独立 token(不按 BPE 规则切分)。若只当普通文本处理,"assistant"会被切成多个普通 token,边界判断从"看一个 token"退化成"看一个 token 组合"——既不可靠也让终止检测变慢(第 6 章部署时,终止符要被 llama.cpp 单 token 级别地识别)。
  2. 对应唯一的 token idtok.convert_tokens_to_ids("<|endoftext|>") 返回固定 id;训练数据渲染、生成时终止检测、部署时停止条件,三方都引用这同一个 id——这是 4.3 节一致性地基。
  3. 不与普通文本冲突<| |> 这类括号形式就是为了让它们不可能从普通语料里自然分词出来。不要用裸词当 special token(比如直接把 END 设成终止符),否则模型聊到这个词就会戛然而止。

追加 special tokens 的操作(衔接 1.2 的词表产物):

# add_special_tokens.py —— 向词表追加对话标记(写法示意,以官方仓库 README 为准) from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained("base/tokenizer") n_added = tok.add_special_tokens({ "additional_special_tokens": [ "<|beginmessage|>", "<|endmessage|>", "<|messageuser|>", "<|messageassistant|>", ], }) print(f"新增 {n_added} 个 special token,词表 {len(tok)}") # 注意:若在训练中途扩词表,模型 embedding 矩阵也要同步扩(resize_token_embeddings), # nanochat 的做法是在分词器训练阶段就把这组标记留好——顺序问题见 4.3 的错位三。

三、渲染代码与逐字节校验

模板在 HuggingFace 体系里存成 tokenizer 的一个 Jinja2 字符串(tok.chat_template),apply_chat_template 负责执行:

# render_chat.py —— 模板渲染与逐字节校验(写法示意) from transformers import AutoTokenizer tok = AutoTokenizer.from_pretrained("runs/sft_124m/final") msgs = [{"role": "user", "content": "黑洞是什么?"}] # 训练形态:含回答(掩码在 3.1 里按此求差) full = tok.apply_chat_template( msgs + [{"role": "assistant", "content": "黑洞是致密天体。"}], tokenize=False) # 推理形态:以 assistant 起始标记收尾,等模型续写 prompt = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True) print(full) print(prompt) assert full.startswith(prompt) # 逐字节校验:推理提示必须是训练序列的前缀

两个形态的对照(示意输出):

full: <|beginmessage|>user 黑洞是什么? <|endmessage|> <|beginmessage|>assistant 黑洞是致密天体。<|endoftext|> prompt: <|beginmessage|>user 黑洞是什么? <|endmessage|> <|beginmessage|>assistant ← add_generation_prompt 补的"起跑线"

assert full.startswith(prompt) 就是"逐字节一致"的机器化表达:推理时发给模型的每个 token,都必须是训练时它见过的形态。3.1 掩码的"两次渲染求差"之所以成立,正是建立在这个前缀关系上——两节互为因果,现在闭环了。

💡 把这个 assert 写进推理服务的启动自检(第 6 章会用同款思路对齐 llama.cpp 的模板配置):一行断言,拦掉 4.3 节的大半事故。

四、终止符的双重身份

<|endoftext|> 在流水线里身兼两职:训练目标(模型要学会在回答后生成它——3.1 里它被划入学习段的最后一个 token)与控制信号(推理循环见到它就停止生成,不再喂回模型)。两职共用同一个 token id,这是一致性的自然保障;但它也意味着一个部署侧的坑:第 6 章转换 GGUF 时若终止符 id 配错,模型要么永远不停(症状:回答后接乱码)、要么说半句就停(症状:回答被腰斩)。先记住这个伏笔,诊断手段在 4.3 节。

本节要点回顾

  1. chat template = 消息的序列化协议:边界 + 作者 + 终止三件套(Harmony 风格标记家族,拼写以官方仓库为准)。
  2. special tokens 三要点:进词表、唯一 id、不与普通文本冲突;追加时同步扩 embedding。
  3. 一致性的机器化表达:推理提示必须是训练序列的字节前缀——把 assert 写进启动自检。

序列拼对了,模型开口的"声音"还有最后一组旋钮——4.2 节:温度、top-p 与重复惩罚的手感调音台。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U