首页
/ Transformers 聊天模板实战指南:用 Jinja 编写、存储与调试 chat template 的完整方法

Transformers 聊天模板实战指南:用 Jinja 编写、存储与调试 chat template 的完整方法

2026-09-06 13:01:35作者:宣聪麟

本文基于 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_pretrainedpush_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=Truelstrip_blocks=True(见 编译模板的实现),即块标记行末尾的换行和行首空白会被自动去除;但表达式 {{ ... }} 前后紧邻的空白仍需用 - 显式控制,这也是文档坚持推荐该写法的原因。

特殊变量与可调用函数

模板中唯一的“内置常量”只有 messages 变量和 add_generation_prompt 布尔值,但你可以访问任何传递给 apply_chat_template 的关键字参数

从源码实现可以印证这一点:apply_chat_template 会把 **kwargsspecial_tokens_map 合并后作为 template_kwargs 传给渲染函数(见 模板参数注入),因此模板中可以引用任意传入的变量名。这种设计保留了灵活性,能支持设计模板规范时未预见的用法。最常用的额外变量是 tools,它包含一个 JSON schema 格式的工具列表。虽然你可以用任何变量名,但强烈建议遵循约定使用 tools,这会让模板与标准 API 保持兼容。

另外,你还能直接访问 tokenizer.special_tokens_map 中的任何 token(通常包括 bos_tokeneos_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,并额外支持 indentseparatorssort_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 内置过滤器文档。
  • TrueFalseNone(Python 专有)替换为 truefalsenone
  • 直接渲染字典或列表在其他实现中可能返回不同结果。例如字符串条目可能从单引号变成双引号。为避免这种差异,请添加 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 文件优先于内嵌在配置中的模板。加载流程是:

  1. 读取 tokenizer_config.json(或 processor 的旧 chat_template.json)中存在的 chat_template 字段;
  2. 如果仓库根目录存在 chat_template.jinja,读取它并作为 default 模板使用,覆盖第 1 步的结果
  3. 读取 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_pretrainedpush_to_hub 默认写入 .jinja 格式:

  • 单个字符串模板 → chat_template.jinja
  • 字典形式命名模板 → default 条目写入 chat_template.jinja,其余每个条目各写一个 additional_chat_templates/ 下的文件;
  • 同时从 tokenizer_config.json 中移除 chat_template 字段以避免重复。

目前没有受支持的方式把模板保存为旧格式——旧格式仅为加载老仓库而保留。

更新旧仓库

要把内嵌在 tokenizer_config.jsonchat_template.json 中的模板迁移到推荐的 .jinja 格式,只需加载后再保存一次:

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("your-org/your-model")
tokenizer.push_to_hub("your-org/your-model")

加载步骤会自动把仓库使用的任意旧格式归一化到 chat_templatepush_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'] %}
            {{- '
登录后查看全文
热门项目推荐
相关项目推荐