Qwen3.8-27B 的 chat_template 逐段拆:四类消息分别怎么渲染

2026-08-16

翻 Qwen3.8-27B 这个权重仓的时候,最容易被跳过的文件是 chat_template.jinja。它不是权重、不是 config.json,看起来只是一段模板;但你把消息数组交给它之后,模型真正看到的那串文本长什么样,全由这 170 行决定。更值得注意的是:这个仓里关于工具调用的完整格式约定,只写在这个文件里——截至 2026-08-16 我们采集时,在 README.md 全文里 grep tool_call 是 0 命中,而模板里它出现在 68、128、130、133、143 五行。也就是说,想知道这模型的工具调用协议长什么样,除了读模板没有第二处可读。

先把文件本身的坐标钉住。截至 2026-08-16 我们采集时,chat_template.jinja 是 8,952 字节、170 行,全文纯 ASCII,文件末尾没有换行。同一份内容还以字符串形式存在 tokenizer_config.jsonchat_template 字段里(该文件第 285 行),两者做过字节级比对,长度都是 8,952、md5 同为 519239a4908bb1f805bbce5fa8c8a242,完全一致。两处各存一份、当前又完全相同这件事值得先记着:改了其中一份而另一份没跟着改,两者就会不一致;至于加载时实际用的是哪一份,我们没有核实。

还要先说清本文的口径:下面全部是对 model card 与随仓文件的字面阅读。我们没有下载权重、没有部署、也没有用 apply_chat_template 渲染过任何一条消息,所以只列模板里的分支条件与字面输出片段,不给「某种输入会渲染成某串文本」的结论。模型仓库内容随上游更新而变动,行号与写法都可能变,请以仓库最新文件为准。

主循环之前:两件必须先办的事

四类消息的分支要到第 102 行开始的消息主循环(102-162 行)才出现,但前面那一大段不是铺垫,是主循环依赖的两处前置计算。

第一件是 render_content(第 3 行定义)。它负责把 content 这个字段——可能是字符串、可能是一个列表——摊平成文本。字符串就原样输出(4-5 行);列表就逐项判断类型:图片项的判定条件是 'image' in item or 'image_url' in item or item.type == 'image'(第 8 行),最终输出 <|vision_start|><|image_pad|><|vision_end|>(第 18 行);视频项判定条件在第 19 行,输出 <|vision_start|><|video_pad|><|vision_end|>(第 29 行);有 text 键的输出 item.text(30-31 行)。宏还带一个 do_vision_count 开关,为真时图片和视频各自的计数器自增;再配合 add_vision_id,会在占位符前面加上 Picture N: Video N: 前缀(15-17 行、26-28 行)。注意宏的第三个形参 is_system_content:一旦在 system 消息里遇到图片或视频,模板直接抛 System message cannot contain images.(第 10 行)和 System message cannot contain videos.(第 21 行)。

第二件是 ns.last_query_index(88-98 行)。模板倒着扫一遍 messages,找「最后一条不是纯工具返回的 user 消息」的下标。判定「纯工具返回」的条件写在第 93 行:trim 之后的正文同时<tool_response> 开头、以 </tool_response> 结尾。扫完一圈还没找到,就抛 No user query found in messages.(99-101 行)。这个下标唯一的用处是在 assistant 分支里决定思考块留不留,下面会用到。

同样在主循环之前,57-87 行已经把 system 消息拼完了。有 tools 时(第 57 行判定 tools and tools is iterable and tools is not mapping),模板先开一个 <|im_start|>system\n(第 58 行),如果推理档位提示词非空就先放它、再空两行(59-61 行),然后才是固定文案 # Tools\n\nYou have access to the following functions:\n\n<tools>(第 62 行),每个工具用 {{- tool | tojson }} 序列化(63-66 行),最后闭合 \n</tools>(第 67 行)。第 68 行是一整行长文案,规定函数调用格式,并带四条 <IMPORTANT>:调用必须遵循给定格式且 <function=...></function> 必须嵌在 <tool_call></tool_call> 里;必填参数必须给;可以在函数调用之前用自然语言给出可选推理、但不能在之后;没有可用函数就正常回答、且不要告诉用户函数调用的事。原用户 system 正文接在这一大段后面(69-74 行),统一以 <|im_end|>\n 收口(第 75 行)。没有 tools 时走 76-87 行,逻辑简单些,但同样是在这里把 system 拼好的。

system:主循环里几乎什么都不做

于是就有了第一个反直觉的地方。主循环的 system 分支只有 104-107 行,内容是:如果这条不是第一条消息,就抛 System message must be at the beginning.(抛错文案在第 106 行);否则什么都不输出。

不是模板忘了渲染 system,而是它的正文早在 57-87 行就随着推理档位提示词、工具说明一起拼好了。主循环里这一支只剩一个位置校验。所以 104-107 行这一支判的是位置,不是内容本身:不在第一条,就是那句 System message must be at the beginning.

顺带记一笔:推理档位提示词是以 system 消息的形式注入 prompt 的,不是模型里的某个开关。45-56 行解析 reasoning_effort,默认值由 |default('xhigh') 给出(第 47 行),合法值集合是 ('xhigh', 'medium', 'low')(第 48 行),不在集合内直接抛 Unexpected reasoning effort ...(第 49 行)。整块被 enable_thinking is undefined or enable_thinking is true 包住(第 46 行)。

user:全模板最短的一支

user 分支就一步(108-109 行):输出 <|im_start|> + message.role + \n + 正文 + <|im_end|> + \n,没有别的拼装动作。

这里的正文是第 103 行 render_content(message.content, true)|trim 的结果。这里 do_vision_count 传的是 true——也就是说,图片和视频的编号计数只在主循环里发生,前面 88-98 行扫描 last_query_index 时调的是 render_content(message.content, false)(第 92 行),不参与计数。

assistant:思考块和工具调用都在这

assistant 是四支里最长的(110-146 行)。

先取 reasoning_content:111-115 行要求 message.reasoning_content is string 才采用,随后 |trim,否则保持空串。接着是 preserve_thinking 在全仓中的唯一实现处,就是第 116 行这个条件:

{%- if preserve_thinking is undefined or preserve_thinking is true or loop.index0 > ns.last_query_index %}
    {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
{%- else %}
    {{- '<|im_start|>' + message.role + '\n' + content }}
{%- endif %}

未定义或为 true 时,所有历史 assistant 消息都带 <think>...</think> 块;为 false 时,只有下标大于 ns.last_query_index 的那些还带思考块,其余只输出正文。这里和 model card 的措辞粒度不一样:model card 自述(README.md:474)设为 False 时「只保留最新一条 user 消息的 thinking 块」,而模板第 116 行的字面条件依赖的是 88-98 行定义的 ns.last_query_index。两处措辞不同,我们只记录模板里的字面条件,不做等价性判断。

再往下是 tool_calls 的序列化(121-145 行)。若 tool_call.function is defined,先把 tool_call 替换成 tool_call.function(123-125 行)。第一个调用要看正文:正文非空时前置 '\n\n<tool_call>\n<function=' + tool_call.name + '>\n',为空时不加前导换行(126-131 行);后续每个前置 '\n<tool_call>\n<function=...>\n'(132-133 行)。参数遍历 tool_call.arguments|items,每个输出 <parameter=名字> 换行、值、再 \n</parameter>\n(136-141 行);值的处理在第 138 行:是字符串就 | string,否则 | tojson | safe。每个调用以 '</function>\n</tool_call>' 收尾(第 143 行),整条 assistant 消息统一以 <|im_end|>\n 结束(第 146 行)。

值得对着 tokenizer_config.json 看一眼的是:<tool_call>(第 116 行起)、<think>(第 196 行起)、</think>(第 205 行起)这些条目的 special 字段都是 false,而 <|im_start|><|im_end|> 那一系是 trueadditional_special_tokens(第 269 行)里列的 13 个 token 也不含 <think><tool_call> 这些。至于模板里的 <function=...><parameter=...><tools><IMPORTANT>,它们压根不在 added_tokens_decoder 的 33 个条目里,是普通文本。

tool:以 user 的身份出场

第二个反直觉的地方在 147-158 行。role 写的是 tool,但这一支输出的开头是 <|im_start|>user(148-150 行),条件是「上一条消息存在且不是 tool」。正文包成 \n<tool_response>\n + 内容 + \n</tool_response>(151-153 行)。收口的条件是「下一条不是 tool、或本条是最后一条」,满足时补 <|im_end|>\n(154-158 行)。

把这几处条件放在一起读:起头的 <|im_start|>user 由「上一条不是 tool」控制,收尾的 <|im_end|>\n 由「下一条不是 tool、或本条是最后一条」控制,中间每条各自包一层 <tool_response>。我们没有渲染过任何一条消息,这里只陈述模板里的分支条件本身。可以顺带对照第 93 行那个「同时以 <tool_response> 开头和结尾」的判定:last_query_index 的计算用的正是这同一个条件,两处写法适合放在一起看。

四支之外还有一个兜底:其余 role 一律抛 Unexpected message role.(159-160 行)。

结尾的生成提示

163-170 行处理 add_generation_prompt:为真时先输出 <|im_start|>assistant\n,然后分两种情况——enable_thinking 已定义且为 false(第 165 行)就直接预填一个空思考块 <think>\n\n</think>\n\n;其余情况预填 <think>\n,把生成起点放进思考块内部。

这里有一处写法差异可以顺手记下:第 46 行的判定是 enable_thinking is undefined or enable_thinking is true,第 165 行是 enable_thinking is defined and enable_thinking is false。我们只记录这两处的字面条件,不推断其后果。

排查时可以直接用的两张对照

模板里的 raise_exception 一共 9 处,报错文案是定位的最快线索:System message cannot contain images.(10 行)、System message cannot contain videos.(21 行)、Unexpected item type in content.(33 行)、Unexpected content type.(39 行)、No messages provided.(43 行)、Unexpected reasoning effort ...(49 行)、No user query found in messages.(100 行)、System message must be at the beginning.(106 行)、Unexpected message role.(160 行)。看到任意一条,直接跳对应行看条件就行。

另外两处模板与 model card 口径不同的地方,只陈述差异:其一,README.md:260mediumxhighlow 并列描述为一档,而模板第 48 行的合法值集合虽然含 medium,51-55 行却只有 xhighlow 两个分支会设置 reasoning_instructions,选 medium 时该变量保持第 45 行设的空串。其二,README.mdtool_call 一词 0 命中,工具调用的完整协议只写在 chat_template.jinja:57-68121-145147-158 三处。以我们实读的仓库状态为准。

要自己核,把这几行号打开对着看最快:chat_template.jinja 的 3、46、57、88、102、116、147、163 行——分别是宏定义、思考开关、tools 拼装、last_query_index、主循环、preserve_thinking、tool 分支、生成提示。这几个锚点串起来,整份模板的走向就清楚了。

延伸阅读


本文依据 Hugging Face 仓库 Qwen/Qwen3.8-27B 的 model card 与随仓配置文件 (config.jsongeneration_config.jsonpreprocessor_config.jsonchat_template.jinja 等)整理, 核对日 2026-08-16,对应仓库快照 1d4bf0f。 本文内容为 model card 与配置文件口径,我们没有下载权重、没有部署、也没有推理过这个模型, 因此不涉及生成质量、推理速度与显存占用的任何描述;文中所有评测数字均为 model card 自述,我们没有复现。 模型仓库内容随上游更新而变动,请以官方最新说明为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。