> ## Documentation Index
> Fetch the complete documentation index at: https://www.yuan111.asia/doc/llms.txt
> Use this file to discover all available pages before exploring further.

# 02 · AIMessage 与三协议：模型返回的信息，是怎么被"统一"的

> AIMessage 统一信封：OpenAI 兼容 / Anthropic 兼容 / Responses 三种协议的差异如何被抹平。

# 02 · AIMessage 与三协议：模型返回的信息，是怎么被"统一"的

## 开头的现象

小林用 `show_full()` 打印过无数次模型返回的 AIMessage。他见过它长这样：

```json theme={null}
{
  "content": "你好小林！",
  "response_metadata": { "stop_reason": "end_turn", "usage": {...} },
  "usage_metadata": { "input_tokens": 9, "output_tokens": 23, "total_tokens": 32 },
  "type": "ai",
  "id": "lc_run--abc",
  "tool_calls": [],
  "invalid_tool_calls": []
}
```

但他一直有个疑问堵在心里：**"这个 AIMessage，到底是智谱给你的，还是 LangChain 造的？为什么我换成 DeepSeek，拿到的还是同一个形状？"**

有一天，他手贱去裸调了三个 API——OpenAI 兼容、Anthropic 兼容、OpenAI Responses——然后把三份原始 JSON 并排放在屏幕上。他盯着看了十分钟，得出了一个让他后背发凉的结论：**这三份 JSON 长得完全不一样，但 LangChain 把它们的差异全部"抹平"成了一个 AIMessage。**

## 第一幕：三份原始 JSON——同一个回答，三种长相

小林给三家 API 发同一个问题"你好"，收到了三份完全不同的 JSON：

```jsonc theme={null}
// OpenAI 兼容协议（DeepSeek/通义）：content 是字符串，在 choices 里
{
  "choices": [ { "message": { "role": "assistant", "content": "你好！", "tool_calls": null } } ],
  "usage": { "prompt_tokens": 9, "completion_tokens": 5 }
}

// Anthropic 兼容协议（智谱/Claude）：content 是块列表，在顶层
{
  "content": [ { "type": "text", "text": "你好！" } ],
  "stop_reason": "end_turn",
  "usage": { "input_tokens": 9, "output_tokens": 5 }
}

// OpenAI Responses 协议（o系列/gpt-5系列）：output 是 item 数组
{
  "output": [ { "type": "message", "role": "assistant",
                "content": [ { "type": "output_text", "text": "你好！" } ] } ],
  "usage": { "input_tokens": 9, "output_tokens": 5 }
}
```

小林的眼睛在三个 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_anthropic` 和 `langchain_openai` 的源码。他发现每个包里都有一套"协议适配层"函数——比如 `langchain_anthropic/chat_models.py` 里的 `_format_messages`（发送时翻译）、`_make_message_chunk_from_anthropic_event`（接收时翻译）。

他画了一张图，这是他理解 LangChain 的转折点：

```text theme={null}
OpenAI 兼容响应 ─┐
Anthropic 响应 ──┼──→ 协议适配层（各家包的翻译函数）──→ AIMessage（统一信封）
Responses 响应 ─┘

AIMessage 统一信封里：
  ├ content          ← 文本（说人话=字符串；干别的=块列表）
  ├ tool_calls       ← 工具调用（永远是 dict，见第三幕）
  ├ usage_metadata   ← 统一 token 用量（input/output/total）
  ├ response_metadata ← 协议原样元信息（id/model/stop_reason/usage）
  └ additional_kwargs ← 协议私有信息（function_call/audio/parsed）
```

他明白了：**AIMessage 不是任何一家 API 给的，是 LangChain 的协议适配层把三家的 JSON"翻译"成的一个统一信封。** 你永远只跟这个信封打交道——不管底层是智谱、DeepSeek 还是 OpenAI。

他还发现了信封里几个字段的分工：

* `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 的工具调用参数，格式完全不同：

```jsonc theme={null}
// OpenAI 兼容：arguments 是【JSON 字符串】
"tool_calls": [{ "id": "call_1", "function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" } }]

// Anthropic 兼容：input 是【对象】
"content": [{ "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "city": "北京" } }]

// Responses：arguments 也是【JSON 字符串】
"output": [{ "type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"北京\"}" }]
```

但到了 AIMessage 里，`tool_calls` **永远是同一个结构，args 永远是 dict 对象**：

```python theme={null}
resp.tool_calls
# → [{'name': 'get_weather', 'args': {'city': '北京'}, 'id': 'call_1', 'type': 'tool_call'}]
#        ↑ args 是 dict！不是 JSON 字符串！
```

小林在源码里找到了"谁干的"：

* 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（实测源码位置）**

| 协议        | 接收时（原始JSON→AIMessage）                                                                                | 发送时（AIMessage→协议JSON）      |
| --------- | ---------------------------------------------------------------------------------------------------- | -------------------------- |
| Anthropic | `langchain_anthropic/chat_models.py` 的 `_make_message_chunk_from_anthropic_event` / `_format_output` | `_format_messages`         |
| OpenAI    | `langchain_openai/chat_models/base.py` 的 `_convert_dict_to_message`                                  | `_convert_message_to_dict` |
| Responses | `base.py` 的 `_construct_lc_result_from_responses_api`                                                | —                          |

**② AIMessage 的完整字段（`model_dump()` 输出，9 个）：**

```python theme={null}
AIMessage(
    content,               # str 或 list[dict]（块列表）
    additional_kwargs,     # dict：协议私货（function_call/audio/parsed）
    response_metadata,     # dict：协议元信息（id/model/stop_reason/usage/model_provider）
    type="ai",             # str：消息类型
    name,                  # str | None
    id,                    # str | None
    tool_calls,            # list[ToolCall]：统一工具调用（args 永远是 dict）
    invalid_tool_calls,    # list[InvalidToolCall]：解析失败的工具调用
    usage_metadata,        # UsageMetadata | None：input/output/total tokens
)
```

**③ ToolCall 结构（三协议统一后的样子）：**

```python theme={null}
ToolCall(
    name="get_weather",     # str
    args={"city": "北京"},  # dict ← 永远是对象，不是 JSON 字符串
    id="call_1",            # str | None
    type="tool_call",       # str
)
```

**④ 判断来源协议：`response_metadata["model_provider"]`**

```python theme={null}
resp = model.invoke("你好")
print(resp.response_metadata.get("model_provider"))
# → "anthropic"  （智谱/Claude）或 "openai"（DeepSeek/通义）
```

> 想验证字段吗？`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` 内部处理，但效果因模型而异。

小林画了最后一张图：

```text theme={null}
能统一的（协议层差异）：文本、工具、用量、元信息 → AIMessage 统一信封
统一不了的（能力层差异）：思考内容、模型能力 → 换协议才有

判断标准：如果差异是"结构不同"，LangChain 能统一；
        如果差异是"一个协议有、另一个根本没有"，LangChain 也变不出来。
```

> 想验证边界吗？同一段代码，`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 篇：模板与多轮历史](/doc/doc/narrative-course/03-模板与多轮历史)。
