02 · AIMessage 与三协议:模型返回的信息,是怎么被”统一”的
开头的现象
小林用show_full() 打印过无数次模型返回的 AIMessage。他见过它长这样:
第一幕:三份原始 JSON——同一个回答,三种长相
小林给三家 API 发同一个问题”你好”,收到了三份完全不同的 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_callitem——三个结构!
想亲眼看三份 JSON 吗?把 reference-api-protocols.md 翻到第一、二、三节——三份协议的完整请求/响应 JSON 都在,小林那十分钟盯着看的,就是这些。
第二幕:AIMessage 是”统一信封”——协议适配层干的活
小林回到 LangChain,翻langchain_anthropic 和 langchain_openai 的源码。他发现每个包里都有一套”协议适配层”函数——比如 langchain_anthropic/chat_models.py 里的 _format_messages(发送时翻译)、_make_message_chunk_from_anthropic_event(接收时翻译)。
他画了一张图,这是他理解 LangChain 的转折点:
content:模型说的话(或做的别的)tool_calls:统一后的工具调用usage_metadata:统一的 token 统计(三家 API 的 usage 结构不一样,这里统一成 input/output/total)response_metadata:协议原样塞进来的元信息(stop_reason、model_provider等)——model_provider字段能告诉你”这个 AIMessage 是哪个协议翻译来的”,排错神器additional_kwargs:协议私有的”额外负载”(比如 DeepSeek 的思考文本 reasoning_content 被塞这里)
想验证”统一信封”吗?跑01_first_chat.py和10_tools.py,打印完整 AIMessage——不管底层是智谱还是别的,你拿到的都是这个形状:content/response_metadata/usage_metadata/tool_calls/type/id。response_metadata.model_provider会告诉你来源协议。
第三幕:最精彩的统一——tool_calls 的 args 永远是 dict
小林发现整个统一里最”魔术”的部分,是工具调用。三家 API 的工具调用参数,格式完全不同: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 个):
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 篇”统一插口”的底层实现。
复现信号:什么时候你会想起这一章
resp.tool_calls[0]["args"]是 dict 还是字符串?——永远是 dict,别自己 json.loads(流式 chunk 的 tool_call_chunks 才是字符串)。- 想判断这个回答是哪个协议来的——看
resp.response_metadata["model_provider"]。 - 模型思考内容读不到——先确认协议:Anthropic(智谱)有 thinking 块,OpenAI 兼容只有计数。
content是列表不是字符串——模型这轮调了工具/思考了/多模态,块列表是正常的,遍历找block["text"]。