Transformers 聊天模板实战指南:用 Jinja 编写、存储与调试 chat template 的完整方法
本文基于 Transformers 仓库的官方文档 chat_templating_writing 展开,系统讲解如何为对话模型编写 Jinja 聊天模板(chat template):从最简模板的语法拆解,到多模态模板、空白裁剪、非 Python Jinja 兼容、模板的磁盘存储与加载优先级,再到工具调用(tool calling)场景下的模板写法。读完本文,你能够独立完成一个可复制运行的模板编写、验证与入库流程,并理解 apply_chat_template 底层的模板渲染机制与多模板(named templates)的解析逻辑。
什么是聊天模板
聊天模板是一段 Jinja 模板,存储在 tokenizer 的 chat_template 属性中。Jinja 是一种允许你书写类 Python 代码与语法的模板语言。下面是最典型的模板结构:
{%- for message in messages %}
{{- '<|' + message['role'] + '|>\n' }}
{{- message['content'] + eos_token }}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|assistant|>\n' }}
{%- endif %}
这段代码看起来很像 Python,只是带有一些奇怪的 {%- 语法。模板会遍历消息列表,对每条消息输出其角色(role)与内容(content),并追加序列结束符(end-of-sequence)token。当 add_generation_prompt=True 时,它会在对话末尾追加 assistant 消息的起始头,从而提示模型开始生成回复。
将模板写入 tokenizer
写好模板后,把它作为字符串赋给 tokenizer 的 chat_template 属性:
tokenizer.chat_template = template
一旦设置完成:
- 每次调用
apply_chat_template时都会使用该模板; - 调用
save_pretrained或push_to_hub时,模板会随 tokenizer 一起保存为 tokenizer 目录下的chat_template.jinja文件; - 你也可以直接编辑
chat_template.jinja文件来修改模板,这往往比在代码里操作模板字符串更方便。
从源码可以看到,保存逻辑集中在 save_chat_templates 方法中:单个字符串模板直接写入 chat_template.jinja,字典形式的多模板则把 default 条目写入 chat_template.jinja,其余按名称写入 additional_chat_templates/<name>.jinja 目录,同时会从 tokenizer_config.json 中移除旧的 chat_template 字段以避免重复(见 save_chat_templates 实现)。
模板编写技巧
从现有模板入手
最简单的起步方式是参考现有模板。对任意聊天模型执行 print(tokenizer.chat_template) 即可查看它使用的模板。建议从不调用工具、不支持 RAG 的简单模型开始——工具调用模型的模板往往非常复杂。编写时可随时查阅 Jinja 官方文档了解格式化与语法细节。
编写多模态聊天模板
对于多模态模板,chat_template 属性设置在 processor(处理器) 上,而不是 tokenizer 上。消息中 content 键的值常常不是单个字符串,而是一个内容字典列表。你可能需要检查列表中每个内容项的类型并分别处理。
一般原则是:模板不应直接访问图像或视频数据。这部分通常由处理器在模板渲染完成之后处理。模板遇到图像或视频内容时,只应输出一个特殊的占位 token(如 <|image|> 或 <|video|>),处理器稍后会把这一个特殊 token 展开成一段图像/视频 token 序列。具体要输出哪些 token 取决于你所使用的模型。文档强烈建议先加载一个现成的多模态 processor,观察它是如何组织数据的。
下面的示例模板处理了图文混合内容:
{%- for message in messages %}
{%- if loop.index0 == 0 %}
{{- bos_token }}
{%- endif %}
{{- '<|start_header_id|>' + message['role'] + '<|end_header_id|>\n\n' }}
{%- if message['content'] is string %}
{{- message['content'] }}
{%- else %}
{%- for content in message['content'] %}
{%- if content['type'] == 'image' %}
{{- '<|image|>' }}
{%- elif content['type'] == 'text' %}
{{- content['text'] }}
{%- endif %}
{%- endfor %}
{%- endif %}
{{- '<|eot_id|>' }}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|start_header_id|>assistant<|end_header_id|>\n\n' }}
{%- endif %}
这个多模态模板与前面简单模板非常相似,区别在于它检查 content 是否为列表,并遍历列表在需要的位置渲染 <|image|> token,从而使图像可以“插入到”用户文本的中间。
需要注意的是,并非所有模型都这样工作——有些模型会把所有图像移到用户消息的末尾。聊天模板必须始终与模型训练时的格式保持一致。
裁剪空白字符(Trimming whitespace)
Jinja 会输出文本块前后出现的任何空白字符。对聊天模板来说这是个隐患:加入模型训练时不存在的额外空白可能损害生成效果。解决办法是在 Jinja 行语法中添加 -(如 {%-、-%}),这样你既可以用 Python 风格的缩进和换行书写模板,又不会把缩进意外地打印到渲染结果中。
下面这个模板没有使用 -,会输出多余的空白:
{% for message in messages %}
{{ message['role'] + message['content'] }}
{% endfor %}
而推荐写法如下,确保只输出预期的内容:
{%- for message in messages %}
{{- message['role'] + message['content'] }}
{%- endfor %}
值得一提的是,从源码看 Transformers 的 Jinja 环境还默认开启了 trim_blocks=True 与 lstrip_blocks=True(见 编译模板的实现),即块标记行末尾的换行和行首空白会被自动去除;但表达式 {{ ... }} 前后紧邻的空白仍需用 - 显式控制,这也是文档坚持推荐该写法的原因。
特殊变量与可调用函数
模板中唯一的“内置常量”只有 messages 变量和 add_generation_prompt 布尔值,但你可以访问任何传递给 apply_chat_template 的关键字参数。
从源码实现可以印证这一点:apply_chat_template 会把 **kwargs 与 special_tokens_map 合并后作为 template_kwargs 传给渲染函数(见 模板参数注入),因此模板中可以引用任意传入的变量名。这种设计保留了灵活性,能支持设计模板规范时未预见的用法。最常用的额外变量是 tools,它包含一个 JSON schema 格式的工具列表。虽然你可以用任何变量名,但强烈建议遵循约定使用 tools,这会让模板与标准 API 保持兼容。
另外,你还能直接访问 tokenizer.special_tokens_map 中的任何 token(通常包括 bos_token、eos_token 等),使用时直接写名称即可,例如 {{- bos_token }}。
模板内还内置了两个可调用函数,调用形式为 {{- function_name(argument) }}:
raise_exception(msg):抛出TemplateException,适合调试或警告用户模板使用不当;strftime_now(format_str):按指定格式获取当前日期时间,system 消息中经常需要。它等价于 Python 的datetime.now().strftime(format_str)。
在源码中这两个函数(连同自定义的 tojson 过滤器)被注册进 Jinja 环境(见 函数注册位置)。注意 Transformers 重写了 Jinja 默认的 tojson 过滤器:默认的过滤器会转义 HTML 字符,而 重写后的实现 直接调用 json.dumps,并额外支持 indent、separators、sort_keys 等选项,这避免了工具调用参数在模板中被 HTML 转义破坏 JSON 结构。
与非 Python 的 Jinja 实现保持兼容
Jinja 有多种语言实现,语法大体一致。用 Python 编写模板时你可以使用 Python 方法,例如字符串的 lower() 或字典的 items()。但如果模板被用在非 Python 实现中(例如通过 JavaScript 或 Rust 部署),这些方法将不可用。
要保证跨实现兼容,建议做以下替换:
- 用 Jinja 过滤器替换 Python 方法。例如把
string.lower()换成string|lower,把dict.items()换成dict|dictitems。大多数改动遵循同样的模式,唯一例外是string.strip(),要替换成string|trim。完整的过滤器清单可参考 Jinja 内置过滤器文档。 - 把
True、False、None(Python 专有)替换为true、false、none。 - 直接渲染字典或列表在其他实现中可能返回不同结果。例如字符串条目可能从单引号变成双引号。为避免这种差异,请添加
tojson过滤器来保持一致性。
大型模板
支持 工具调用 或 RAG 等新特性的模型往往需要超过 100 行的大型模板。把大模板写到独立文件里通常更好写,而且独立文件中的行号与模板解析/执行报错的行号一一对应,便于定位问题。
先把现有模板提取到文件中:
open("template.jinja", "w").write(tokenizer.chat_template)
编辑完成后,再把文件内容读回 tokenizer:
tokenizer.chat_template = open("template.jinja").read()
聊天模板的存储与加载
聊天模板在磁盘上存在多种格式。现代 checkpoint 会把模板存为独立的 .jinja 文件,而旧 checkpoint 则内嵌在 tokenizer 或 processor 配置中。
存储格式
模板可能以以下任意格式存储:
chat_template.jinja(推荐):位于仓库根目录的独立 Jinja 文件,包含单个聊天模板。这是save_pretrained默认写入的格式。把模板单独存成文件便于检查、编辑和做 diff。tokenizer 与 processor 加载chat_template.jinja的方式相同。additional_chat_templates/<name>.jinja:一组独立 Jinja 文件目录,用于模型提供多个命名模板的场景(例如default模板与独立的tool_use模板)。default模板仍然放在仓库根目录的chat_template.jinja,其他命名模板则放在additional_chat_templates/<name>.jinja,文件名(不含扩展名)即模板名。
注意:下面两种旧格式仅为了向后兼容加载而保留,不要再往其中写入聊天模板。
tokenizer_config.json中的chat_template字段:tokenizer_config.json内嵌的 JSON 字符串形式的旧格式。当模型有多个命名模板时,该字段是[{"name": ..., "template": ...}]字典列表而非单个字符串。使用该格式的旧仓库仍可正常加载,但save_pretrained会写入现代的.jinja格式。chat_template.json:旧版多模态 processor checkpoint 使用的旧格式,形如{"chat_template": "<模板字符串>"}。旧仓库仍可加载,但ProcessorMixin.save_pretrained会写入现代的.jinja格式。如果一个 processor 仓库同时混用旧chat_template.json与现代.jinja文件,加载时会报错。
这些文件名常量在源码中统一定义于 hub 工具模块:CHAT_TEMPLATE_FILE = "chat_template.jinja"、CHAT_TEMPLATE_DIR = "additional_chat_templates"、LEGACY_PROCESSOR_CHAT_TEMPLATE_FILE = "chat_template.json"。
加载优先级
调用 from_pretrained 时,Transformers 按固定优先级解析这些存储格式:独立 .jinja 文件优先于内嵌在配置中的模板。加载流程是:
- 读取
tokenizer_config.json(或 processor 的旧chat_template.json)中存在的chat_template字段; - 如果仓库根目录存在
chat_template.jinja,读取它并作为default模板使用,覆盖第 1 步的结果; - 读取
additional_chat_templates/下的每个.jinja文件,以文件名(去扩展名)为键合并进来。
源码中该逻辑位于 tokenizer 的 _from_pretrained 类方法(见 加载优先级实现):本地目录会直接扫描 additional_chat_templates/ 下的 *.jinja 文件,远程仓库则通过 list_repo_templates 获取文件列表;最终只解析出唯一 default 模板时,chat_template 属性被设为对应字符串,存在多个命名模板时则设为 {name: template_string} 字典。
当 chat_template 是字典时,apply_chat_template 内部通过 get_chat_template 自动选择模板:传入 tools 时选用 tool_use 条目,否则选用 default(见 模板选择逻辑)。你也可以显式传 chat_template="模板名" 来强制使用某个命名模板,例如 tokenizer.apply_chat_template(messages, tools=[schema], chat_template="tool_use")。
保存
save_pretrained 与 push_to_hub 默认写入 .jinja 格式:
- 单个字符串模板 →
chat_template.jinja; - 字典形式命名模板 →
default条目写入chat_template.jinja,其余每个条目各写一个additional_chat_templates/下的文件; - 同时从
tokenizer_config.json中移除chat_template字段以避免重复。
目前没有受支持的方式把模板保存为旧格式——旧格式仅为加载老仓库而保留。
更新旧仓库
要把内嵌在 tokenizer_config.json 或 chat_template.json 中的模板迁移到推荐的 .jinja 格式,只需加载后再保存一次:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("your-org/your-model")
tokenizer.push_to_hub("your-org/your-model")
加载步骤会自动把仓库使用的任意旧格式归一化到 chat_template,push_to_hub 则产出 chat_template.jinja 文件,一步完成迁移。
为工具(Tools)编写模板
工具模板没有固定的专属格式,但最好遵循标准 API,这样模板才能在各个模型间被广泛使用,而不需要用户为你的模型编写自定义代码。
警告:空白和特殊 token 等格式细节因模型而异。务必确保模板与模型训练时的格式完全一致。
以下依次介绍标准 API 的构成要素:工具定义、工具调用与工具响应。
工具定义(Tool definitions)
工具 可以以 Python 函数或 JSON schema 传入。传入函数时,会自动生成 JSON schema 再交给模板——也就是说,模板访问 tools 变量时,拿到的永远是一个 JSON schema 列表。
函数到 schema 的自动转换由 get_json_schema 完成:它解析函数的类型注解和 Google 风格 docstring,生成包含函数名、描述及参数类型/描述/必填项的 schema(render_jinja_template 会在渲染前把每个函数工具统一转为 schema,见 工具转换逻辑)。
尽管模板总是以 JSON schema 形式接收工具,但你可能需要彻底改变其呈现形式以匹配模型训练格式。例如 Command-R 是用“Python 函数头”风格定义工具训练的,其模板在内部把 JSON schema 类型转换后,把输入工具渲染成 Python 函数头形式。
一个 JSON schema 格式的工具定义示例:
{
"type": "function",
"function": {
"name": "multiply",
"description": "A function that multiplies two numbers",
"parameters": {
"type": "object",
"properties": {
"a": {
"type": "number",
"description": "The first number to multiply"
},
"b": {
"type": "number",
"description": "The second number to multiply"
}
},
"required": ["a", "b"]
}
}
}
在模板中处理工具定义的一个例子如下(具体 token 和排版要按你模型训练时的格式修改):
{%- if tools %}
{%- for tool in tools %}
{{- '<tool>' + tool['function']['name'] + '\n' }}
{%- for argument in tool['function']['parameters']['properties'] %}
{{- argument + ': ' + tool['function']['parameters']['properties'][argument]['description'] + '\n' }}
{%- endfor %}
{{- '\n</tool>' }}
{%- endfor %}
{%- endif %}
工具调用(Tool calls)
除了渲染工具定义,模板还必须渲染工具调用和工具响应。
工具调用一般位于 "assistant" 消息的 tool_calls 键中。它永远是一个列表——尽管大多数工具调用模型只支持单次工具调用,列表通常只含一个元素:
{
"role": "assistant",
"tool_calls": [
{
"type": "function",
"function": {
"name": "multiply",
"arguments": {
"a": 5,
"b": 6
}
}
}
]
}
一个处理工具调用的常见模式如下。你可以把它作为起点,但务必确认模板与模型训练格式一致:
{%- if message['role'] == 'assistant' and 'tool_calls' in message %}
{%- for tool_call in message['tool_calls'] %}
{{- '
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00