Chat Template 到底 tokenize 了什么

模型看不到你的 messages 列表,只看得到渲染完的那一串文本。想通这件事,add_generation_prompt、loss mask、双 BOS 这几个坑就串起来了。

Posted by Jessie Jia on 2026-08-12

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
2
[gMASK]<sop><|user|>
石墨烯的杨氏模量是多少?<|assistant|>

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 }} 输出表达式的值
{% ... %} 控制流:forifset
{# ... #} 注释,不输出

HuggingFace 借它干一件事:把结构化的 messages 压平成模型见过的那种扁平字符串,而且每次压得一模一样。最简形态:

1
2
3
4
5
6
{%- for message in messages %}
{{- '<|' + message['role'] + '|>\n' + message['content'] + '<|endoftext|>\n' }}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|assistant|>\n' }}
{%- endif %}

短横线不是代码风格

{%--%} 是去空白:吃掉标签前后的空白和换行。真实模板里到处都是它。

这个我一开始以为是格式偏好,其实不是。模板为了给人读会缩进,不去空白的话,你源文件里的每个换行和缩进都会原封不动跑进 prompt。多一个 \n,token 序列就和训练时对不上了。

自己写模板的话,写完用 repr() 打一遍渲染结果,别靠眼睛看。

模板里能拿到什么

transformersjinja2.sandbox.ImmutableSandboxedEnvironment 渲染,禁掉了任意属性访问和副作用,所以里面调不了随便什么 Python。能拿到的是:

  • messages,以及传了工具定义时的 tools
  • add_generation_prompt
  • bos_tokeneos_token 这些,从 tokenizer 取
  • raise_exception(),给需要拒绝畸形对话的模板用,比如 system 消息位置不对
  • strftime_now(),给需要注入当前日期的模板用

模板存在哪

两个地方,内容是同一段字符串。

老写法是内联在 tokenizer_config.json 里。这个文件是 AutoTokenizer.from_pretrained() 读的,注意它不含词表本身,词表在 tokenizer.json、或 vocab.json + merges.txt、或 SentencePiece 的 *.model 里。它只记录怎么把 tokenizer 对象构造出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"tokenizer_class": "PreTrainedTokenizerFast",
"model_max_length": 131072,
"padding_side": "left",
"bos_token": "[gMASK]",
"eos_token": "<|endoftext|>",
"pad_token": "<|endoftext|>",
"clean_up_tokenization_spaces": false,
"added_tokens_decoder": {
"151329": { "content": "<|endoftext|>", "special": true, "normalized": false },
"151336": { "content": "<|user|>", "special": true, "normalized": false }
},
"chat_template": "{%- for message in messages %}..." // ← 内联,一整行
}

做 SFT 的话,这几项值得盯一眼。

added_tokens_decoder 决定 <|user|> 是一个原子 token,还是被 BPE 切成好几片。切碎了角色标记就不再是干净信号,模型得从”< 后面跟 | 后面跟 user“这个组合里去猜边界。能学会,但没必要浪费这个容量。

padding_side 训练用 right,批量生成必须 left。右 padding 的生成 batch 里,比最长序列短的样本全是垃圾输出。

eos_tokenpad_token 同时管着生成何时停、哪些位置要从 loss 里挖掉。

新写法是单独存一个 chat_template.jinja,较新的 transformers 版本 save_pretrained() 默认就这么写了。理由很朴素:塞在 JSON 里每个换行都是 \n、每个引号都要转义,两百行的模板变成一整行不可读的东西,没法 diff 也没有高亮。

优先级是独立文件赢。很多仓库两处都放着,为了兼容旧版本。这就留下一个隐患:两边一旦漂移,模型行为取决于你的 transformers 版本,而这种问题查起来很费劲。排查别的之前先确认这两处一致。

add_generation_prompt:训练和推理差在哪

这是我今天真正卡住的地方。拿一条 SFT 样本:

1
2
3
4
msgs = [
{"role": "user", "content": "石墨烯的杨氏模量是多少?"},
{"role": "assistant", "content": "约 1 TPa。"},
]

训练时你手里有答案,那就是监督信号,所以整段渲染,不加生成提示:

1
tok.apply_chat_template(msgs, add_generation_prompt=False)
1
2
3
<|user|>
石墨烯的杨氏模量是多少?<|assistant|>
约 1 TPa。<|endoftext|>

设成 True 会在答案后面再补一个空的 <|assistant|>\n,一个悬空的角色标记,而模型会学着把它吐出来。

推理时你没有答案,生成它才是目的,所以只喂 msgs[:-1]

1
tok.apply_chat_template(msgs[:-1], add_generation_prompt=True)
1
2
<|user|>
石墨烯的杨氏模量是多少?<|assistant|>

add_generation_prompt=True 干的就是补上末尾这个 <|assistant|>\n。没有它,模型看到的最后一个 token 是 ,然后会顺理成章地接着续写用户那一轮,编出第二个问题而不是回答。有了它,模型正好站在训练时”下一个 token 是答案开头”的那个位置。

前缀必须一模一样

把两条路径对齐:

1
2
3
训练:  <|user|>\n石墨烯的杨氏模量是多少?<|assistant|>\n | 约 1 TPa。<|endoftext|>
推理: <|user|>\n石墨烯的杨氏模量是多少?<|assistant|>\n | ← 从这里开始生成
└──────────────── 必须完全一致 ───────────────┘

竖线左边是模型的条件上下文。训练教给它的是”看到这个前缀,输出 “。推理时如果这个前缀被重建得多了个换行、或者多了个训练路径没有的空格,模型就在训练分布之外做条件生成。

麻烦的地方在于它掉得看不见。两个字符串打印出来长得一模一样,你眼睛分不出来,日志里也没有任何异常。

所以直接 assert:

1
2
3
full   = tok.apply_chat_template(msgs, tokenize=True, add_generation_prompt=False)
prefix = tok.apply_chat_template(msgs[:-1], tokenize=True, add_generation_prompt=True)
assert full[:len(prefix)] == prefix

三行,顺便还白送了 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
2
3
{%- if message['role'] == 'assistant' %}
{% generation %}{{- message['content'] + '<|endoftext|>' }}{% endgeneration %}
{%- endif %}

然后直接要 mask:

1
2
3
4
5
out = tok.apply_chat_template(
msgs, tokenize=True, return_dict=True,
return_assistant_tokens_mask=True,
)
mask = out["assistant_masks"] # assistant token 上为 1

任意轮数都对。代价是模板里必须有 {% generation %} 块,而很多官方发布的模板并没有——这也是我暂时还没用上它的原因,用之前得先确认,或者在自己那份副本里补上。

几个坑

双 BOS。 模板已经吐了 BOS,你再调 tok(text),默认的 add_special_tokens=True 会再加一个:

1
2
text = tok.apply_chat_template(msgs, tokenize=False)
ids = tok(text, add_special_tokens=False)["input_ids"] # ← 必须 False

或者干脆用 tokenize=True 一步到位,它内部本来就是 add_special_tokens=False

手拼字符串。 在数据管线里写 f"<|user|>\n{q}<|assistant|>\n" 是能跑的,跑到官方模板改版、或者你换 base model、或者有人加了个 system prompt 为止。让模板当唯一真相来源,省心。

模板分支不一致。 有些手改过的模板,循环里写 <|assistant|>\nadd_generation_prompt 分支里写 <|assistant|>,少个换行。GLM 和 Qwen 的官方模板没这问题,但只要你动过模板、加过工具定义或 system prompt,上面那个 assert 就是唯一会告诉你的人。

用户内容里混进特殊标记。 Jinja 做的是纯字符串拼接,不转义。某个 content 里要是含有字面量 <|assistant|>,它会被 tokenize 成真正的特殊 token,模型读到的就是一次真实的轮次切换。这算 prompt injection 在训练数据侧的版本。语料是爬来的或模型生成的,训练前扫一遍。

最后

整件事就一条:训练用的字节串和推理用的字节串,前缀必须完全一致。Jinja2 是生成这串字节的手段,tokenizer_config.jsonchat_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