本节摘要:语言模型只认 token 序列,不认"消息列表"——chat template 就是把结构化对话序列化成 token 序列的协议:谁说的、从哪开始、到哪结束,全部用 special tokens 画出来。本节先看协议的全貌(messages → 包裹标记 → 序列),再拆 nanochat 采用的 Harmony 风格标记家族(<|beginmessage|>、<|messageuser|>、<|messageassistant|>、<|endoftext|>,发布帖 Discussion #1 口径),讲清 special tokens 为什么必须进词表、终止符为什么"既是文本又是控制信号";最后给出渲染代码与"逐字节一致"的校验方法——这份校验将贯穿第 3 章训练与第 6 章部署。
阅读完本节,你应当能够:
第 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|> |
整轮回答的终止符——模型的"我说完了" |
⚠️ 上表是发布帖描述的家族形态,具体拼写以官方仓库为准(当前仓库实现可能微调命名);教学重点在结构不在拼写——"边界 + 作者 + 终止"三件套缺一不可。
tok.convert_tokens_to_ids("<|endoftext|>") 返回固定 id;训练数据渲染、生成时终止检测、部署时停止条件,三方都引用这同一个 id——这是 4.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 节。
序列拼对了,模型开口的"声音"还有最后一组旋钮——4.2 节:温度、top-p 与重复惩罚的手感调音台。