2026年9月12日
01 对话模版:消息如何拼成模型输入
API 里你传的是结构化的消息列表,但模型真正接收的是一条连续的 token 序列。中间负责转换的,叫做对话模板(chat template)。
一个具体例子
假设你调用 API 时传入这样的消息:
[
{"role": "system", "content": "你是一个简洁的助手"},
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "你好!有什么可以帮你?"},
{"role": "user", "content": "1+1等于几"}
]以开源模型常用的 ChatML 格式为例,它会被拼成下面这样一整段文本:
<|im_start|>system
你是一个简洁的助手<|im_end|>
<|im_start|>user
你好<|im_end|>
<|im_start|>assistant
你好!有什么可以帮你?<|im_end|>
<|im_start|>user
1+1等于几<|im_end|>
<|im_start|>assistant
然后这一整段被切成 token,送进模型。不同模型的模板写法不同,比如 Llama 3 用的是 <|start_header_id|>、<|eot_id|> 这类标记,但思路完全一样。
几个关键设计
特殊 token 是角色的边界。 <|im_start|>、<|im_end|> 这类标记不是普通文字,而是词表中专门保留的特殊 token,各自对应一个独立的编号。即使用户在输入框里打出这几个字符,分词器也会把它们当作普通字符切开,而不是识别成特殊 token。这是一道基本的安全防线,防止用户伪造一条"system 消息"。
末尾那个"未完成的 assistant 开头"是关键。 注意拼接结果的最后一行:只有 <|im_start|>assistant,没有内容。这相当于告诉模型"现在轮到助手说话了"。模型的任务仍然只是"预测下一个 token",但在这个位置,它在后训练中学到的最合理的续写,就是以助手身份给出回答。所以,对话本质上是一种续写,模板把它伪装成了一问一答。
结束标记决定模型何时停下。 模型生成完回答后,会输出 <|im_end|> 这个特殊 token。推理程序看到它就停止生成。这和 stop sequence 的原理相同,只是这个停止信号是模型自己学会输出的。
预填充就是不封口。 上次提到的 prefill,在这里就很好理解了:如果你在最后写 <|im_start|>assistant\n{,模型就只能从 { 后面接着写。
工具调用也是同样的拼接
Agent 中的工具调用并没有特殊魔法,同样是拼接进序列:模型以特定格式输出一段"我要调用某工具,参数是……",你的程序截获这段输出、执行工具,再把结果作为一条新消息(通常是 tool 角色,或放在 user 消息里)拼回序列,模型再继续往下生成。整个 agent 的运行过程,就是这条序列不断变长的过程。
这也解释了为什么 agent 很容易撑满 context window:每次工具调用的参数和返回结果都会留在历史里,一次返回大量网页内容或文件内容,就可能占用成千上万个 token。
闭源 API 与开源模型的区别
使用 Claude、GPT 这类闭源 API 时,模板由服务端处理,你看不到也改不了,只需要按 API 格式传消息。比如 Anthropic 的 API 中,system prompt 是一个单独的参数,而不是放在消息列表里。使用开源模型时,模板通常定义在模型的配置文件中(比如 Hugging Face 模型的 tokenizer_config.json),可以用 apply_chat_template 等函数查看拼接结果。如果自己做微调,训练数据必须使用和推理时完全相同的模板,否则模型会学到错误的格式,表现明显下降。
对 agent 开发的实际启发
理解了拼接方式,有一个很实用的优化思路:提示缓存(prompt caching)。因为每次请求都要重新发送完整序列,而 system prompt 和早期历史往往不变,服务端可以缓存这段"前缀"的计算结果,下次直接复用。前面查到 Bedrock 上的缓存部分可以享受很大幅度的折扣,Anthropic 和 OpenAI 的官方 API 也有类似机制。
缓存要求前缀完全一致,所以在设计 agent 时,应该把固定不变的内容(角色定义、工具说明、规则)放在 system prompt 的最前面,把经常变化的内容(当前时间、用户特定数据)放在后面。哪怕开头改动一个字,后面的缓存也会全部失效。