深入剖析 chat template 的 Jinja2 渲染机制、add_generation_prompt 的作用,以及为何训练和推理时的模板前缀必须逐字节一致;指出 assistant 角色的 role token 才是结构化关键,而非「messages」对象本身。
我脑海中曾有一种模糊的画面:role 是结构化元数据,模型内部有东西在读取它。不对。
[{"role": "user", "content": "What is the Young's modulus of graphene?"}]
这个列表只存在于你的 Python 进程中,除此之外无处可去。到达模型的内容是:
[gMASK]<sop><|user|>
What is the Young's modulus of graphene?<|assistant|>
token 化之后:
[151331, 151333, 151336, 198, ...content ids..., 151337, 198]
这些前缀数字是 GLM-4 的 [gMASK]、<sop>、<|user|> 和 <|assistant|> 的 id。每个模型系列都有自己的 id,所以不要在不同模型之间硬编码。
role 是一个 token,不是字段。模型之所以知道轮到自己说话了,是因为它看到了 id 151337,就像它通过任何其他模式学习一样。没有 JSON,没有 key 查找,没有 schema。只有一个序列。
下面几乎所有内容都源于此。
Jinja2 是一个文本模板引擎,最初为 Flask 渲染 HTML 而构建。核心概念只有三个:
HuggingFace 借它来做一件事:将结构化消息扁平化为模型训练时看到的那段字符串,且每次渲染方式完全一致。最小形态:
{%- for message in messages %}
{{- '<|' + message['role'] + '|>\n' + message['content'] + '<|endoftext|>\n' }}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|assistant|>\n' }}
{%- endif %}
横杠不是风格装饰
{%- 和 -%} 会去除标签周围的空白。真正的模板里满是这些东西。
我曾以为这只是格式偏好。并非如此。模板会缩进以方便人类阅读,如果不剥离,源文件中的每个换行和缩进都会原封不动地进入 prompt。多一个 \n,token 序列就和训练时看到的不同了。
如果自己写模板,用 repr() 打印结果,不要相信眼睛。
transformers 在 jinja2.sandbox.ImmutableSandboxedEnvironment 中渲染,所以任意属性访问和副作用都被阻止。你不能从里面调用随机 Python。能访问的只有:
两个地方,同一个字符串。
旧方式是内联在 tokenizer_config.json 里。这是 AutoTokenizer.from_pretrained() 读取的文件,注意它不存放词汇表——词汇表在 tokenizer.json,或 vocab.json 加 merges.txt,或 SentencePiece 的 *.model 里。它只记录如何构建 tokenizer 对象:
{
"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 碎片。如果被拆了,role 标记就不再是一个干净的信号,模型只能从 "< then | then user" 推断边界。它能学会。没理由白白浪费这份容量。
padding_side 训练时应该 right,批处理生成时必须 left。在 right-padded 的生成批次中,任何短于最长序列的输出都是垃圾。
eos_token 和 pad_token 同时控制生成在哪里停止,以及哪些位置被从 loss 中剔除。
新方式是独立的 chat_template.jinja,新版 transformers 默认从 save_pretrained() 写入。理由很实际:JSON 里每个换行是 \n,每个引号要转义,所以一个 200 行的模板变成一行无法阅读、无 diff、无高亮的东西。
文件优先级更高。很多仓库同时附带两者以保持向后兼容,这就埋下了一个陷阱。一旦两者出现分歧,行为就取决于你的 transformers 版本,而这是很难追踪的。在调试其他任何东西之前,先确认两者一致。
这才是今天真正卡住我的地方。一个 SFT 样本:
msgs = [
{"role": "user", "content": "What is the Young's modulus of graphene?"},
{"role": "assistant", "content": "About 1 TPa."},
]
训练时你有答案。那是监督信号,所以用 add_generation_prompt=False 渲染整个对话:
tok.apply_chat_template(msgs, add_generation_prompt=False)
渲染结果:
<|user|>
What is the Young's modulus of graphene?<|assistant|>
About 1 TPa.<|endoftext|>
设为 True 会在答案后面追加第二个空的 <|assistant|>\n。一个悬空的 role 标记,模型会学着输出它。
推理时你没有答案。生成答案是目的,所以传入 msgs[:-1]:
tok.apply_chat_template(msgs[:-1], add_generation_prompt=True)
渲染结果:
<|user|>
What is the Young's modulus of graphene?<|assistant|>
add_generation_prompt=True 就是负责追加那个尾部 <|assistant|>\n 的。没有它,模型看到的最后一个 token 是 ?,它会愉快地继续用户的回合——编造一个后续问题而不是回答。有了它,模型正站在训练时下一个 token 是答案开头的确切位置。
训练: <|user|>\nWhat is ... graphene?<|assistant|>\n | About 1 TPa.<|endoftext|>
推理: <|user|>\nWhat is ... graphene?<|assistant|>\n | ← generation starts here
└──────────── must match exactly ───────────┘
竖线左侧是模型的 conditioning context。训练教它"给定这个前缀,输出 About"。如果推理重建前缀时多了一个换行,或者多了一个训练时没有的空格,模型 conditioning 的就超出了训练分布。
麻烦之处在于它静默退化。打印两个字符串看起来完全一样。日志里什么都看不到。
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) 就是答案开始的位置。
role 是 token 不是字段,所以没有字段边界可以切。你需要自己定位 assistant span。
直接做法是用长度差:
labels = [-100] * len(prefix) + full[len(prefix):]
边界自然落在 <|assistant|>\n 之后。干净。
看起来更聪明的替代方案是用正则从渲染文本中匹配 <|assistant|> 并将字符偏移映射回 token 索引。别这样做。一旦消息内容本身合法地包含该字符串,这就错了,而且错得静默。
长度差技巧有一个真正的限制:只处理最后一轮。对于多轮数据如果你想让每一轮 assistant 回复都有 loss,得逐轮增量渲染,这很快变得丑陋。
正确的做法是在模板中标记 assistant span:
{%- if message['role'] == 'assistant' %}
{% generation %}{{- message['content'] + '<|endoftext|>' }}{% endgeneration %}
{%- endif %}
然后请求 mask:
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。之后再调用 tok(text),默认的 add_special_tokens=True 会再加一个:
text = tok.apply_chat_template(msgs, tokenize=False)
ids = tok(text, add_special_tokens=False)["input_ids"] # ← required
或者用 tokenize=True 跳过往返,内部本来就用 add_special_tokens=False。
在数据管道里写 f"<|user|>\n{q}<|assistant|>\n" 能工作,直到官方模板改变,或者你换了底座模型,或者有人加了系统 prompt。让模板成为唯一的事实来源,别再想它了。
有些手改的模板在循环内发出 <|assistant|>\n 而在 add_generation_prompt 分支发出 <|assistant|>,少一个换行。官方 GLM 和 Qwen 模板没问题,但如果你改过模板,或者加了 tool 定义或系统 prompt,那个 assert 是唯一能告诉你出问题的。一致性检查是唯一能发现这类问题的手段。
Jinja 做的是纯字符串拼接,不转义。包含字面文本 <|assistant|> 的 content 字段会被 token 化为真正的特殊 token,模型读到的是一个真实的回合边界。Prompt 注入,训练数据版。如果你的语料是爬取或模型生成的,先扫描一遍。
说到底就一件事:你训练用的字节串和你推理用的字节串必须有完全相同的前缀。Jinja2 生成那个字符串,tokenizer_config.json 和 chat_template.jinja 保存配方,而 add_generation_prompt 是两条路径之间唯一应该有的差异。
说实话,大多数时候根本不会遇到这些。使用库存模型在标准管道上,apply_chat_template 处理一切,你永远不需要知道底下发生了什么。只有当你开始构建自己的数据、计算自己的 mask、或更换底座模型时,它才会浮出水面。而一旦浮出水面,它不会抛错。它只是让效果稍微变差——这正是为什么提前了解它是有价值的。
在服务侧有一个同构的问题。Little's Law 和 vLLM 自动扩缩容中 QPS-as-load-signal 的错误具有相同的形状:一个看起来合理的指标在悄悄地测量别的东西。
延伸阅读:transformers 文档中的 chat templating 指南,以及 Jinja2 模板设计者参考文档中关于空白控制的章节。