OpenAI 用 tool role + tool_call_id 关联,Anthropic 用 content 嵌套块,Google 用 FunctionCallingResult;跨平台复现时若结构错误会导致工具结果静默丢失。
包含工具调用的对话并非一条带着字段的消息,而是两到三个轮次——它们在角色嵌套和关联键上各 API 均有不同,而一次错误的回放不会报错,只会静默地把工具结果丢失。
同一个交换,用三种形态写出来。用户提问,模型调用工具,你的代码执行它,模型回答。四个逻辑步骤。
User:"What is the weather in Oslo?"
Assistant:调用 get_weather 并传入 city 参数。
Your code:执行它,得到温度和天气状况。
Assistant:"It is 4°C and overcast in Oslo."
步骤 2 和步骤 3 是发生变动的部分。以下全部内容都在讲它们去向何方以及如何相互绑定。
OpenAI 的 Chat Completions API 新增了一个专属角色。assistant 轮次携带一个 tool_calls 数组,在纯工具调用轮次中 content 为 null;每个结果作为独立消息返回,角色为 tool,通过 tool_call_id 关联(OpenAI, Chat Completions reference)。
[
{ "role": "user", "content": "What is the weather in Oslo?" },
{ "role": "assistant",
"content": null,
"tool_calls": [
{ "id": "call_abc123",
"type": "function",
"function": { "name": "get_weather",
"arguments": "{\"city\":\"Oslo\"}" } }
] },
{ "role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"temp_c\":4,\"condition\":\"overcast\"}" },
{ "role": "assistant", "content": "It is 4°C and overcast in Oslo." }
]
有两个细节容易让人踩坑。arguments 是 JSON 字符串而非 JSON 对象,因此使用前必须解析、回放前必须序列化——而且模型可能产生一个并非合法 JSON 的字符串,这是你的调度器必须处理的 case,不能假设它不会发生。tool 消息必须跟在一条确实包含匹配调用 ID 的 assistant 消息之后;如果历史记录中 assistant 轮次被摘要过,或者工具结果被保留而调用被丢弃,都会导致被拒绝。
Anthropic 的 Messages API 没有 tool 角色。所有内容都在 user 或 assistant 轮次内部的 content block 中,这是本页最重要的一个差异:工具结果由一条 role 为 user 的消息携带(Anthropic, Messages API)。
[
{ "role": "user", "content": "What is the weather in Oslo?" },
{ "role": "assistant",
"content": [
{ "type": "tool_use",
"id": "toolu_abc123",
"name": "get_weather",
"input": { "city": "Oslo" } }
] },
{ "role": "user",
"content": [
{ "type": "tool_result",
"tool_use_id": "toolu_abc123",
"content": "{\"temp_c\":4,\"condition\":\"overcast\"}" }
] },
{ "role": "assistant",
"content": [ { "type": "text", "text": "It is 4°C and overcast in Oslo." } ] }
]
值得注意的差异:input 是一个已解析的对象而非字符串,这更方便,但也意味着转换器必须在一个方向做序列化、另一个方向做解析。关联靠的是 tool_use_id 与 block 的 id 相对应。结果 block 还接受一个 is_error 标志,提供了一种表达工具失败的原生方式——tool-message shape 没有对应字段,所以往那个方向转换意味着要把错误折叠到 content 字符串中,从而失去这个区分度。另外 assistant 轮次可以混合 text block 和 tool_use block,所以如果转换器假设工具轮次没有正文,就会把正文丢弃。
Google 的 Gemini API 用 contents 而非 messages,角色用 user 和 model 而非 user 和 assistant,每个轮次有一个 parts 数组,其中容纳 functionCall 或 functionResponse(Google, generateContent reference)。
{ "contents": [
{ "role": "user",
"parts": [ { "text": "What is the weather in Oslo?" } ] },
{ "role": "model",
"parts": [ { "functionCall": { "name": "get_weather",
"args": { "city": "Oslo" } } } ] },
{ "role": "user",
"parts": [ { "functionResponse": {
"name": "get_weather",
"response": { "temp_c": 4, "condition": "overcast" } } } ] },
{ "role": "model",
"parts": [ { "text": "It is 4°C and overcast in Oslo." } ] }
] }
值得牢记的结构要点:没有调用 ID。响应通过函数名与调用关联。对于单次调用这没问题。但对于同一轮次中两次调用同一个函数——Oslo 和 Bergen 的天气——名称不是唯一键,任何假设 ID 存在的转换器都必须自行合成一个,并记住 wire 格式无法携带它。这个约束与序列化并行调用 shim 需要自己的标识符是同一个原因。
现在来到有用的部分:历史转换器会丢失哪些信息,按静默程度排序。
结果轮次的角色。 针对 tool-message shape 编写的转换器会查找 role 为 tool 的记录。针对 content-block 历史记录则没有这个角色——结果在 user 轮次内部——所以按 tool 消息过滤会一无所获,结果就此消失。模型随后看到自己调用了工具然后立即回答,没有数据。它通常还是会回答,而且很自信,所以这是这里最糟糕的失败方式。
arguments 作为字符串还是对象。 对已经序列化的参数字符串再做一次序列化,就会得到一个 JSON 编码的字符串,而期望的是一个对象。它能解析,但结果是错的。
错误标志。 tool-message shape 中没有 is_error 的对应物。把它编码为模型能够读取的 content 形式——一个简短的字符串说明工具失败及原因——然后接受机器可读的区分度已经丢失这个事实。
工具调用旁边的文本。 一个 assistant 轮次先用正文推理再调用工具,包含两个 block。只取第一个或只取工具 block 的转换器会改变可见的对话。
非文本工具结果。 一些 API 允许工具结果包含 image block;另一些只接受字符串。向更严格的一侧转换时,图片无法在结果中表示,必须附加到别处或丢弃。
未回答的调用。 assistant 轮次中的每次调用在对话继续前通常都必须得到回答。过滤历史记录——通常是丢弃旧轮次以适应上下文窗口——可能留下一个结果被裁剪过的调用,或者一个调用被裁剪过的结果。两种都会被拒绝,而错误指向的是你程序化构建的数组中的消息索引,这是一件极难调试的事情。按完整的调用-结果单元来裁剪。
结构上的修复是不要再把任何提供商的 wire 格式当作存储格式。如果你的数据库存储的是 OpenAI 形状的消息,因为你是从这里起步的,那么其他每个提供商都是从一种无法表达 is_error 的格式做的有损转换,每个新提供商又是一对一的转换器。
定义你自己的轮次类型——角色、一个有序的 parts 列表,每个 part 可以是 text、invocation 或 result,invocation 和 result 携带你自己生成的 ID,result 携带显式的 failure 标志——然后为每个提供商写一个序列化器。canonical form 应该是每个提供商所能表达的超集,这样转换只在 wire 层丢失信息,而不会在你的存储中丢失。这样记录在某个提供商上的对话就可以回放到另一个提供商,这才是使跨提供商的多轮工具调用测试成为可能的关键。
两条规则保证它的严谨性。自己生成调用 ID 而不是存储提供商的,这样历史记录就不与产生它的 API 绑定。在 canonical form 旁边保留提供商的原始响应一段时间,因为第一次转换出错时你需要看到实际返回了什么,而 canonical 记录无法展示它未能捕获的内容。
canonical form 的论据与网关在不同规模上所做的如出一辙:必须有某个东西拥有一份对话的单一表示和每个提供商一个序列化器,否则每个服务都写自己的,它们在边界 case 上就会产生分歧。Multigrid 在中心层面做到了这一点,但真正重要的属性是 canonical form 本身——在自己的代码库中构建它,无论有没有东西挡在提供商前面,都能获得可回放的历史记录。