Skip to main content

02 · AIMessage 与三协议:模型返回的信息,是怎么被”统一”的

开头的现象

小林用 show_full() 打印过无数次模型返回的 AIMessage。他见过它长这样:
但他一直有个疑问堵在心里:“这个 AIMessage,到底是智谱给你的,还是 LangChain 造的?为什么我换成 DeepSeek,拿到的还是同一个形状?” 有一天,他手贱去裸调了三个 API——OpenAI 兼容、Anthropic 兼容、OpenAI Responses——然后把三份原始 JSON 并排放在屏幕上。他盯着看了十分钟,得出了一个让他后背发凉的结论:这三份 JSON 长得完全不一样,但 LangChain 把它们的差异全部”抹平”成了一个 AIMessage。

第一幕:三份原始 JSON——同一个回答,三种长相

小林给三家 API 发同一个问题”你好”,收到了三份完全不同的 JSON:
小林的眼睛在三个 JSON 之间来回扫:
  • 取文本:第一个要 choices[0].message.content,第二个要 content[0].text,第三个要 output[0].content[0].text——三种取法!
  • system 消息:第一个塞在 messages 数组里当 role,第二个放顶层 system 参数,第三个叫 instructions——三个位置!
  • 工具调用:第一个是 tool_calls,第二个是 content 里的 tool_use 块,第三个是 output 里的 function_call item——三个结构!
他心想:“如果我的业务代码直接处理这三份 JSON,我得写三套逻辑。这跟我第 1 篇手写 API 遇到的’换供应商就换语言’一模一样。”
想亲眼看三份 JSON 吗?把 reference-api-protocols.md 翻到第一、二、三节——三份协议的完整请求/响应 JSON 都在,小林那十分钟盯着看的,就是这些。

第二幕:AIMessage 是”统一信封”——协议适配层干的活

小林回到 LangChain,翻 langchain_anthropiclangchain_openai 的源码。他发现每个包里都有一套”协议适配层”函数——比如 langchain_anthropic/chat_models.py 里的 _format_messages(发送时翻译)、_make_message_chunk_from_anthropic_event(接收时翻译)。 他画了一张图,这是他理解 LangChain 的转折点:
他明白了:AIMessage 不是任何一家 API 给的,是 LangChain 的协议适配层把三家的 JSON”翻译”成的一个统一信封。 你永远只跟这个信封打交道——不管底层是智谱、DeepSeek 还是 OpenAI。 他还发现了信封里几个字段的分工:
  • content:模型说的话(或做的别的)
  • tool_calls:统一后的工具调用
  • usage_metadata:统一的 token 统计(三家 API 的 usage 结构不一样,这里统一成 input/output/total)
  • response_metadata:协议原样塞进来的元信息(stop_reasonmodel_provider 等)——model_provider 字段能告诉你”这个 AIMessage 是哪个协议翻译来的”,排错神器
  • additional_kwargs:协议私有的”额外负载”(比如 DeepSeek 的思考文本 reasoning_content 被塞这里)
想验证”统一信封”吗?跑 01_first_chat.py10_tools.py,打印完整 AIMessage——不管底层是智谱还是别的,你拿到的都是这个形状:content/response_metadata/usage_metadata/tool_calls/type/id。response_metadata.model_provider 会告诉你来源协议。

第三幕:最精彩的统一——tool_calls 的 args 永远是 dict

小林发现整个统一里最”魔术”的部分,是工具调用。三家 API 的工具调用参数,格式完全不同:
但到了 AIMessage 里,tool_calls 永远是同一个结构,args 永远是 dict 对象
小林在源码里找到了”谁干的”:
  • OpenAI 系(Chat Completions / Responses):parse_tool_call() 里的 json.loads(arguments)——把 JSON 字符串解析成 dict
  • Anthropic 系:extract_tool_calls() 直接拿 block["input"]——本来就是对象,直接透传
他恍然大悟:“所以我写业务代码时永远不需要 json.loads(tool_call["args"])——LangChain 早就帮我解析好了。OpenAI 系靠 json.loads,Anthropic 系直接拿,结果统一成 dict。”
想验证”args 永远是 dict”吗?跑 10_tools.py,打印 resp.tool_calls[0]["args"]——它是一个 dict(可以直接 args["city"] 取值)。如果你在流式 chunk 里看,tool_call_chunks[].args 才是字符串(流式中间态),但最终聚合的 tool_calls[].args 一定是 dict。

🔧 技术细节:这一章涉及的关键类和签名

① 协议适配层函数——谁在翻译三家 JSON(实测源码位置) ② AIMessage 的完整字段(model_dump() 输出,9 个):
③ ToolCall 结构(三协议统一后的样子):
④ 判断来源协议:response_metadata["model_provider"]
想验证字段吗?print(AIMessage.model_fields.keys())——会列出全部 9 个字段名。print(resp.tool_calls[0]["args"])——永远是 dict。

第四幕:边界——不是所有差异都能统一

小林很兴奋,但他在第 6 篇已经吃过一次亏。他专门去试了”哪里的差异统一不了”,结论让他冷静下来: 统一得了的:文本(content)、工具调用(tool_calls)、token 用量(usage_metadata)、协议元信息(response_metadata)、消息角色(type)。 统一不了的——底层能力差异
  • 思考内容:Anthropic 协议的 thinking 块能读到全文;OpenAI 兼容协议只能看到 reasoning_tokens 计数,内容没有。LangChain 统一不了”上游根本没给的数据”。(这就是第 6 篇的结论)
  • 结构化输出的实现方式:不同协议走不同机制(工具调用 vs JSON 模式),with_structured_output 内部处理,但效果因模型而异。
小林画了最后一张图:
想验证边界吗?同一段代码,ChatAnthropic(智谱)能看到思考块全文,换成 ChatDeepSeek 立刻只剩 reasoning_tokens 计数——不是代码错,是协议能力差异。

结论(小林用一天换来的)

AIMessage 是协议适配层造的”统一信封”:三家 API(OpenAI 兼容 / Anthropic 兼容 / Responses)的原始 JSON 结构完全不同,LangChain 的适配层函数把它们翻译成同一个 AIMessage 结构——content 取文本、tool_calls 统一成 args 是 dict(OpenAI 靠 json.loads,Anthropic 直接拿)、usage_metadata 统一用量、response_metadata 保留协议原样信息。 边界:结构差异能统一,能力差异统一不了——思考内容这种”一个协议有、另一个根本没有”的,只能换协议。这就是第 1 篇”统一插口”的底层实现。

复现信号:什么时候你会想起这一章

  1. resp.tool_calls[0]["args"] 是 dict 还是字符串?——永远是 dict,别自己 json.loads(流式 chunk 的 tool_call_chunks 才是字符串)。
  2. 想判断这个回答是哪个协议来的——看 resp.response_metadata["model_provider"]
  3. 模型思考内容读不到——先确认协议:Anthropic(智谱)有 thinking 块,OpenAI 兼容只有计数。
  4. content 是列表不是字符串——模型这轮调了工具/思考了/多模态,块列表是正常的,遍历找 block["text"]
小林的”统一信封”搞懂了,但他还有一个日常困惑没解开:提示词模板到底有啥用?create_agent 内部真的用模板吗?——这是他跟同事争论了一下午的话题,翻到第 3 篇:模板与多轮历史