重看chat_template:训练与推理共用的协议与攻击面

chat_template 是一份同时约束训练数据编码、输入渲染与输出解析的协议

1. Intro

chat_template 是 tokenizer_config.json 里的一段 Jinja2 模板:把”对话”按格式渲染,再交给 tokenizer 编码成 token。

模板的执行入口是 HuggingFace transformers 里 tokenizer 的标准方法 apply_chat_template:

1
2
3
4
5
6
apply_chat_template(
messages, # 对话:角色 + 内容
add_generation_prompt=True, # 是否追加 assistant 起始段
tools=..., # 工具定义(可选)
chat_template_kwargs=..., # 透传参数,如 thinking_mode
) -> str # 渲染后的协议文本

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
]~!b[]~b]system
You are a security research assistant. 严格遵守授权边界,只对授权目标执行操作。

<thinking_instructions>... Current thinking mode: adaptive. ...</thinking_instructions>[e~[
]~b]developer
你是渗透测试助手,所有动作必须基于用户明确授权。

# Tools
...(<tools> JSONSchema 定义,节选)...
[e~[
]~b]user
这是目标主机的拓扑图,请结合图片和文字分析。]<]image[>[[e~[
]~b]user
帮我检查一下 10.0.0.5 的 80 端口是否开放[e~[
]~b]ai
<mm:think>用户要求检查目标主机 10.0.0.5 的 80 端口。我可以直接调用 nmap_scan 工具完成扫描。</mm:think>好的,我来扫描。]<]minimax[>[<tool_call>
]<]minimax[>[<invoke name="nmap_scan">]<]minimax[>[<host>10.0.0.5]<]minimax[>[</host>]<]minimax[>[<ports>]<]minimax[>[<item>80]<]minimax[>[</item>]<]minimax[>[<item>443]<]minimax[>[</item>]<]minimax[>[</ports>]<]minimax[>[<options>]<]minimax[>[<timeout>30]<]minimax[>[</timeout>]<]minimax[>[</options>]<]minimax[>[</invoke>
]<]minimax[>[</tool_call>[e~[
]~b]tool
<response>PORT STATE SERVICE
80/tcp open http
443/tcp open https</response>[e~[
]~b]ai
<mm:think>扫描结果显示 80 和 443 端口均开放。我可以直接给出结论。</mm:think>扫描完成,10.0.0.5 的 80 端口开放(http),443 端口也开放(https)。[e~[

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)。

容错与安全是张力关系:解析器越宽容,注入面越宽。