chat_template 是一份同时约束训练数据编码、输入渲染与输出解析的协议
1. Intro
chat_template 是 tokenizer_config.json 里的一段 Jinja2 模板:把”对话”按格式渲染,再交给 tokenizer 编码成 token。
模板的执行入口是 HuggingFace transformers 里 tokenizer 的标准方法 apply_chat_template:
1 | apply_chat_template( |
tools、thinking_mode 这些变量决定渲染出什么结构:有没有工具块、思考指令怎么下、生成前缀是什么。
一次交互涉及两个方向:
- 编码(输入侧):把 API 请求里的 messages + tools + thinking_mode 渲染成协议文本、再分词成 token 流,喂给模型;
- 解析(输出侧):把模型吐出的协议文本(<mm:think>、<tool_call> 这些)用 reasoning parser / tool parser 拆回结构化响应(reasoning_content / content / tool_calls)。
模型没有语法知识,只有 token 分布:训练数据用同一套模板编码,推理时输入渲染同构,模型就会继续输出同构的协议文本——所以输出”看起来像模板”,本质是模型在模仿协议,而不是模板在处理输出。
2. 训练侧
典型 SFT 流水线:
graph LR
A["采样 dataspec"] --> B["清洗 Clean"]
B --> C["模板化 Context(ChatML)"]
C --> D["BIN tokenize + packing"]
D --> E["NPY 索引 / 掩码"]
E --> F["Data-str 数据流"]
F --> G["SFT 训练"]
G --> H["评测 / 门禁"]
各层职责:采样(dataspec 控制混合比例与难度)→ 清洗轨迹 → 模板化(把原始样本转成 ChatML 协议文本,即训练侧的 chat_template)→ BIN(tokenizer 编码 + packing 成固定长度稠密序列)→ NPY(样本边界与 loss 掩码索引)→ Data-str(混合数据流,支持断点续训)→ SFT → 评测门禁。
为什么必须先转 chat_template,有四个硬约束:
- 分布一致性:训练学的是 token 序列分布,而推理输入正是模板渲染文本,编码不一致则输出语法漂移(角色标记缺失、EOS 乱发);
- packing 边界:多个样本拼进一条序列,靠协议 token 切分起止;
- loss masking:只对 assistant 段算 loss,掩码区间按角色标记定位;
- 词表耦合:<mm:think>、ns_token 等必须以 added token 注册,训练与推理共用同一份 tokenizer。
3. 推理侧
vLLM 对 M3 的适配分两条(官方博客):
- --tool-call-parser minimax_m3:把模型输出的 <tool_call> 解析成结构化 tool_calls;
- --reasoning-parser minimax_m3:把 <mm:think>…</mm:think> 提取为 reasoning_content。
parsers turn model-specific text conventions into structured API responses——解析器把模型吐出的协议文本翻译回结构化 API 结果。
thinking_mode 三态:API 层的思考意图由服务端翻译成模板 kwarg,模板据此渲染系统指令与生成前缀:
| thinking_mode | 生成前缀(add_generation_prompt 后) |
|---|---|
| adaptive | ]~b]ai(无标记,模型自决) |
| enabled | ]~b]ai\n<mm:think>(预填起始标记) |
| disabled | ]~b]ai\n</mm:think>(预填结束标记) |
注意 disabled 预填的是闭合标记——模型要从”思考已结束”的中间状态续写,所以解析器必须容忍开头的 stray closer。
M3 协议 token 全部注册为 added token(tokenizer_config.json 共 61 个):
| token | id | 角色 |
|---|---|---|
| ]!p~[ | 200000 | pad |
| ]~b] | 200019 | BOS / 段起始 + 角色 |
| [e~[ | 200020 | EOS / 段结束 |
| ]~!b[ | 200034 | BOD 文档起始 |
| <tool_call> | 200052 | 工具块开始 |
| </tool_call> | 200053 | 工具块结束 |
| ]<]minimax[>[ | 200058 | ns_token 命名空间 |
| <mm:think> | 200059 | 思考开始 |
| </mm:think> | 200060 | 思考结束 |
而 <response>、<invoke>、<item> 不是 added token,只是普通文本,靠 ns_token 前缀保证边界原子性——需要原子性的协议边界用单 id 特殊 token,语义性标签退化为普通文本。
段骨架与角色映射
- 角色映射:root → system(高优先级),system/developer → developer(低优先级);
- 渲染骨架:BOD + BOS + system 开头,每个消息段以 [e~[ 结尾。
工具调用渲染
- 调用块用 ns_token + <tool_call> 包裹,每个调用再套 <invoke name=”…”>;
- 参数递归展开成 XML:数组 → <item>,对象 → 子元素,None 省略。
下面的真实渲染示例,正好一次演示完这两层结构。
真实渲染一轮多轮对话:
1 | ]~!b[]~b]system |
4. 攻击面
协议属性即攻击面:渲染把多轮对话线性化成单一 token 流,角色标记只是普通 token,用户内容可与协议标记碰撞。ChatML 类模板的注入手法(先知社区基于 DeepSeek-R1 的分析)在 M3 上等价:BOS 后伪造 system 段、伪造思考标记、伪造 tool 标签诱导工具调用。
M3 的防御是 ns_token 防碰撞——TensorRT-LLM 解析器注释说它是 “opaque sentinel chosen so the tag set cannot collide with anything the model might naturally produce”。但防碰撞不等于防注入;更现实的脆弱点是引擎与模型的语法不匹配,vLLM 的 bugfix 也许就会演化成攻击面:streaming 时思考标记以可见文本泄漏(#45713)、解析器被迫容忍坏格式(#51075)、thinking 开启时结构化输出被静默忽略(#51230)。
容错与安全是张力关系:解析器越宽容,注入面越宽。