详细对比三大平台messages数组的结构差异:OpenAI用同质数组+角色区分,Anthropic分离system,Gemini用parts数组。
对话 transcript 是所有 Chat API 都认同的字段,也是所有 Chat API 塑造得最不一样的字段。重命名这个数组很容易;真正的问题是那些没有对应物的角色、在转换中被丢弃的字段,以及必须从自己的消息里挪出来塞进别人消息里的工具结果。
OpenAI 的 Chat Completions 端点接收一个扁平的数组,挂在一个 messages 下面。每个条目都有 role 和 content,其他所有东西——指令、tool_calls、tool_results——都作为同一数组里另一个条目,用不同的 role 来区分。这是一个单一同构列表,这也是它成为所有人对标对象的原因。
Anthropic 的 Messages API 也接收 messages,但数组只承载两种 role,系统指令被提取到同级参数里。OpenAI 用额外 role 表达的结构,在这里用 content 内部的类型化 block 来表达。
Google 的 Gemini generateContent 把这个数组整个重命名了。transcript 叫 contents,每个条目是 Content 对象,文本内容放在 parts 数组而不是 content 字段里。系统指令是独立的顶层 systemInstruction,系统 prompt 映射会单独覆盖到它。
这是 naive adapter 丢失信息的第一个地方。四套词汇有重叠但不是同一套:
OpenAI Chat Completions — system、developer、user、assistant、tool,以及已废弃的 function。
Anthropic Messages — 数组里只有 user 和 assistant。更新版本的模型也接受在数组中间放 system 条目作为操作符通道,但根本不存在 tool role。
Gemini — user 和 model。注意是 model 不是 assistant:这一个重命名是最常见的"翻译后请求在第一次调用就被拒绝"的原因。
所以 assistant turn 的映射是一个方向的 rename 加另一个方向的 rename,而 tool turn 的映射根本不是 rename——它无处可去,这个下文再说。
还有第二个更安静的差异:数组可以长成什么样子。OpenAI 接受连续相同 role 的消息并把它们当作一个 turn 处理。Anthropic 也接受并会合并它们,但要求第一个条目必须是 user turn,所以如果一个 transcript 以 assistant 打招呼开头——这是很多产品打开对话时的常见形态——那个打招呼必须被丢弃或重新安置。这两种行为单独来看都不值得记日志,但都会改变模型看到的内容。一个在发送前对数组做 normalize 的 adapter 应该显式地做这个 normalize,而不是依赖它恰好在对话的 provider 的宽容。
三者都接受纯字符串表示纯文本 turn,也都接受列表当 turn 是多模态或结构化的时候。列表元素在这里分叉了。OpenAI 用带 type discriminator 的 content parts。Anthropic 用 content blocks,概念相同但词汇不同,而且 block 类型宽得多,因为 blocks 同时也是它表达 tool calls、tool results 和 thinking 的方式。Gemini 用 parts,每个 part 是一个恰好只有一个键被填充的对象。
OpenAI message 对象上有两个字段在其他任何地方都没有对应,是通常的牺牲品。name,user 或 assistant message 上可选的参与者标签,会被直接丢弃:如果你的 prompt 依赖它来区分群组对话中的发言者,你必须在翻译前把它折叠进文本。另外,带 populated tool_calls 数组的 assistant message 在 Anthropic 侧会变成一个 assistant message,其 content 列表里每个调用对应一个 tool_use block——参数从 function.arguments 里的 JSON 字符串变成 input 里的解析后对象。
这里是一轮 OpenAI 形态的往返。两个工具被并行调用,所以 assistant turn 后面跟着两条 tool 消息:
{
"messages": [
{ "role": "user", "content": "Weather in Paris and Berlin?" },
{ "role": "assistant", "content": null,
"tool_calls": [
{ "id": "call_a1", "type": "function",
"function": { "name": "get_weather",
"arguments": "{\"city\":\"Paris\"}" } },
{ "id": "call_b2", "type": "function",
"function": { "name": "get_weather",
"arguments": "{\"city\":\"Berlin\"}" } }
] },
{ "role": "tool", "tool_call_id": "call_a1", "content": "18C, rain" },
{ "role": "tool", "tool_call_id": "call_b2", "content": "22C, clear" }
]
}
同一交换在 Anthropic 形态下。四条消息变成三条,tool role 消失,两个结果落进同一条 user turn 里:
{
"messages": [
{ "role": "user", "content": "Weather in Paris and Berlin?" },
{ "role": "assistant", "content": [
{ "type": "tool_use", "id": "toolu_a1", "name": "get_weather",
"input": { "city": "Paris" } },
{ "type": "tool_use", "id": "toolu_b2", "name": "get_weather",
"input": { "city": "Berlin" } }
] },
{ "role": "user", "content": [
{ "type": "tool_result", "tool_use_id": "toolu_a1",
"content": "18C, rain" },
{ "type": "tool_result", "tool_use_id": "toolu_b2",
"content": "22C, clear" }
] }
]
}
三个 rename 可见:tool_call_id 变成 tool_use_id,arguments 字符串变成解析后对象,相关标识符改变前缀。结构性变化才是咬人的地方——n 条 tool 消息塌陷成一条带 n 个 block 的 user message。如果一个 adapter 每条 tool result 发一条 user message,产生的 transcript 会被接受,但会阻止模型再次发起并行调用,因为它看到的形状是顺序调用的形状。Gemini 再次用不同方式表达同一件事:model turn 上的 functionCall parts 和 user turn 上的 functionResponse parts,通过函数名而非标识符关联。
name 字段。没有对应物。要么折叠进文本,要么丢失。
末尾的 assistant turn。OpenAI 接受末尾的 assistant 消息作为 prefill 让模型继续。Anthropic 当前的模型会返回 400 拒绝它,所以 prefill 必须重新表达为 structured-output 约束或系统指令。末尾 assistant turn 是唯一一个会大声失败而非静默失败的形态。
顺序自由。OpenAI 允许 system message 在任意索引位置。Anthropic 只有一个系统参数作用于整个请求,所以对话中段的指令必须变成别的东西——参见系统 prompt 映射。
缓存断点。Anthropic 可以把单个 content block 标记为缓存边界。没有每条消息的字段可以翻译过去,所以一轮往返经过标准化中间表示后会静默丢弃它,缓存也就停止被读取了。
Role 集合和消息级字段是这些 API 厂商扩展最频繁的部分——developer role 和数组中段的 system message 都是在上述形态稳定之后才加进来的。把这里的词汇表当作撰写本文时的已文档化集合,在依赖一个你近期没有发送过的 role 之前重新读一下请求参考。