tok.apply_chat_template(msgs, add_generation_prompt=True)
这行我抄过不知道多少遍,从来没细想过参数在管什么。官方 example 这么写,跑起来结果也对,就过去了。
直到要自己造 SFT 数据、自己算 loss mask,才发现这一行里每个东西都有讲究。今天把它拆开看了一遍,记一下。
最反直觉的一条先放这儿:模型根本不知道 messages 这个东西存在。
TL;DR
- 模型只见过渲染完的那一整串文本。
messages是给 Python 看的,角色是词表里真实的 token。 - chat template 就是一段 Jinja2 程序,存在
tokenizer_config.json里,或者单独一个chat_template.jinja。两个都有的话,独立文件优先。 - 训练渲染完整对话,
add_generation_prompt=False。推理渲染msgs[:-1],add_generation_prompt=True。 - 这两条路径渲染出的前缀必须一模一样,差一个换行都不行。一行 assert 能查。
- 模板渲染完别再让 tokenizer 加特殊 token,BOS 会变成两个。
模型不知道有 messages 这回事
我原先隐约以为,角色是某种结构化的元数据,模型内部有个什么机制去读它。并没有。
1 | [{"role": "user", "content": "石墨烯的杨氏模量是多少?"}] |
这个列表只活在你的 Python 进程里。真正进入模型的是:
1 | [gMASK]<sop><|user|> |
tokenize 之后:
1 | [151331, 151333, 151336, 198, ...正文的 ids..., 151337, 198] |
前面那几个数字是 GLM-4 里 [gMASK]、<sop>、<|user|>、<|assistant|> 的 id。每家模型都不一样,别跨模型硬编码。
所以角色是 token,不是字段。模型知道”该我说话了”,靠的是看见 id 151337,跟它学会任何别的模式没有区别。推理时没有 JSON,没有键值查找,没有 schema,就是一条序列。
后面所有的坑,基本都是这一条的推论。
Jinja2:负责渲染的那一层
Jinja2 是个文本模板引擎,本来是给 Flask 渲染 HTML 用的。语法就三种:
| 语法 | 作用 |
|---|---|
{{ expr }} |
输出表达式的值 |
{% ... %} |
控制流:for、if、set |
{# ... #} |
注释,不输出 |
HuggingFace 借它干一件事:把结构化的 messages 压平成模型见过的那种扁平字符串,而且每次压得一模一样。最简形态:
1 | {%- for message in messages %} |
短横线不是代码风格
{%- 和 -%} 是去空白:吃掉标签前后的空白和换行。真实模板里到处都是它。
这个我一开始以为是格式偏好,其实不是。模板为了给人读会缩进,不去空白的话,你源文件里的每个换行和缩进都会原封不动跑进 prompt。多一个 \n,token 序列就和训练时对不上了。
自己写模板的话,写完用 repr() 打一遍渲染结果,别靠眼睛看。
模板里能拿到什么
transformers 用 jinja2.sandbox.ImmutableSandboxedEnvironment 渲染,禁掉了任意属性访问和副作用,所以里面调不了随便什么 Python。能拿到的是:
messages,以及传了工具定义时的toolsadd_generation_promptbos_token、eos_token这些,从 tokenizer 取raise_exception(),给需要拒绝畸形对话的模板用,比如 system 消息位置不对strftime_now(),给需要注入当前日期的模板用
模板存在哪
两个地方,内容是同一段字符串。
老写法是内联在 tokenizer_config.json 里。这个文件是 AutoTokenizer.from_pretrained() 读的,注意它不含词表本身,词表在 tokenizer.json、或 vocab.json + merges.txt、或 SentencePiece 的 *.model 里。它只记录怎么把 tokenizer 对象构造出来:
1 | { |
做 SFT 的话,这几项值得盯一眼。
added_tokens_decoder 决定 <|user|> 是一个原子 token,还是被 BPE 切成好几片。切碎了角色标记就不再是干净信号,模型得从”< 后面跟 | 后面跟 user“这个组合里去猜边界。能学会,但没必要浪费这个容量。
padding_side 训练用 right,批量生成必须 left。右 padding 的生成 batch 里,比最长序列短的样本全是垃圾输出。
eos_token 和 pad_token 同时管着生成何时停、哪些位置要从 loss 里挖掉。
新写法是单独存一个 chat_template.jinja,较新的 transformers 版本 save_pretrained() 默认就这么写了。理由很朴素:塞在 JSON 里每个换行都是 \n、每个引号都要转义,两百行的模板变成一整行不可读的东西,没法 diff 也没有高亮。
优先级是独立文件赢。很多仓库两处都放着,为了兼容旧版本。这就留下一个隐患:两边一旦漂移,模型行为取决于你的 transformers 版本,而这种问题查起来很费劲。排查别的之前先确认这两处一致。
add_generation_prompt:训练和推理差在哪
这是我今天真正卡住的地方。拿一条 SFT 样本:
1 | msgs = [ |
训练时你手里有答案,那就是监督信号,所以整段渲染,不加生成提示:
1 | tok.apply_chat_template(msgs, add_generation_prompt=False) |
1 | <|user|> |
设成 True 会在答案后面再补一个空的 <|assistant|>\n,一个悬空的角色标记,而模型会学着把它吐出来。
推理时你没有答案,生成它才是目的,所以只喂 msgs[:-1]:
1 | tok.apply_chat_template(msgs[:-1], add_generation_prompt=True) |
1 | <|user|> |
add_generation_prompt=True 干的就是补上末尾这个 <|assistant|>\n。没有它,模型看到的最后一个 token 是 ?,然后会顺理成章地接着续写用户那一轮,编出第二个问题而不是回答。有了它,模型正好站在训练时”下一个 token 是答案开头”的那个位置。
前缀必须一模一样
把两条路径对齐:
1 | 训练: <|user|>\n石墨烯的杨氏模量是多少?<|assistant|>\n | 约 1 TPa。<|endoftext|> |
竖线左边是模型的条件上下文。训练教给它的是”看到这个前缀,输出 约“。推理时如果这个前缀被重建得多了个换行、或者多了个训练路径没有的空格,模型就在训练分布之外做条件生成。
麻烦的地方在于它掉得看不见。两个字符串打印出来长得一模一样,你眼睛分不出来,日志里也没有任何异常。
所以直接 assert:
1 | full = tok.apply_chat_template(msgs, tokenize=True, add_generation_prompt=False) |
三行,顺便还白送了 loss mask 的边界:len(prefix) 就是答案开始的位置。
loss mask 怎么切
角色是 token 不是字段,所以序列里没有现成的边界可以切,得自己定位 assistant 那一段。
最直接的是长度差:
1 | labels = [-100] * len(prefix) + full[len(prefix):] |
边界天然落在 <|assistant|>\n 之后,干净。
另一个看着更”聪明”的做法是用正则在渲染后的文本里找 <|assistant|>,再把字符偏移映射回 token 下标。别这么干,只要哪条消息的 content 里正当地含有这串字符,它立刻就错,而且错得悄无声息。
长度差法有个真实的局限:只处理最后一轮。多轮对话里想让每一轮 assistant 都算 loss,就得逐轮增量渲染,很快就写得难看了。
正统解法是在模板里直接标出 assistant 段:
1 | {%- if message['role'] == 'assistant' %} |
然后直接要 mask:
1 | out = tok.apply_chat_template( |
任意轮数都对。代价是模板里必须有 {% generation %} 块,而很多官方发布的模板并没有——这也是我暂时还没用上它的原因,用之前得先确认,或者在自己那份副本里补上。
几个坑
双 BOS。 模板已经吐了 BOS,你再调 tok(text),默认的 add_special_tokens=True 会再加一个:
1 | text = tok.apply_chat_template(msgs, tokenize=False) |
或者干脆用 tokenize=True 一步到位,它内部本来就是 add_special_tokens=False。
手拼字符串。 在数据管线里写 f"<|user|>\n{q}<|assistant|>\n" 是能跑的,跑到官方模板改版、或者你换 base model、或者有人加了个 system prompt 为止。让模板当唯一真相来源,省心。
模板分支不一致。 有些手改过的模板,循环里写 <|assistant|>\n,add_generation_prompt 分支里写 <|assistant|>,少个换行。GLM 和 Qwen 的官方模板没这问题,但只要你动过模板、加过工具定义或 system prompt,上面那个 assert 就是唯一会告诉你的人。
用户内容里混进特殊标记。 Jinja 做的是纯字符串拼接,不转义。某个 content 里要是含有字面量 <|assistant|>,它会被 tokenize 成真正的特殊 token,模型读到的就是一次真实的轮次切换。这算 prompt injection 在训练数据侧的版本。语料是爬来的或模型生成的,训练前扫一遍。
最后
整件事就一条:训练用的字节串和推理用的字节串,前缀必须完全一致。Jinja2 是生成这串字节的手段,tokenizer_config.json 和 chat_template.jinja 是配方放的地方,add_generation_prompt 是两条路径之间唯一该有的差别。
老实说这些东西平时不会碰到。用现成模型、走标准 pipeline,apply_chat_template 帮你把一切摆平了,你完全不需要知道下面在发生什么。它只在你开始自己造数据、自己算 mask、或者换 base model 的时候浮上来。而那个时候它不报错,只是让效果变差一点,所以特别值得提前知道。
推理侧其实有个同构的问题,Little’s Law 与 vLLM 扩缩容 里那个用 QPS 判断负载的错误也是这样:指标看着合理,其实在测另一件事。
延伸阅读:transformers 文档的 chat templating 指南,还有 Jinja2 模板设计手册 里空白控制那一节。
English version: What Actually Gets Tokenized in SFT