《AI Agent 开发入门》2.2 真实供应商适配与离线替身

本节给 Model 协议接上真实供应商:以 Anthropic SDK(anthropic 1.13.0)为例,把 minagent 的 Message 翻译成 SDK 入参、把 SDK 响应折叠回 ModelResponse,密钥只用 os.environ 读取、示例默认不真调。重点讲「录制夹具」:把一次真实响应存成 JSON,离线用 RecordedModel 重放,做到零凭据可回归。

2.2 真实供应商适配与离线替身

上一节我们立好了 Model 协议,也证明了假模型能塞进去。但假模型终究不会真的回答问题,这一节要回答一个很实际的问题:怎么把一家真实供应商接进这个协议,同时又不在没有密钥、没有额度的情况下把读者挡在门外。答案分两半——一个「翻译层」适配器,一个「录像机」离线替身。

本节把 minagent 推进到「能接真实后端,也能离线复现」:写出 AnthropicModel 适配器完成双向映射,再引入 RecordedModel 用存盘的响应 JSON 重放。到本节结束,同一份循环代码既能打真实 API,也能在断网时跑通。

2.2.1 适配器的职责:两侧翻译

适配器是协议与某家 SDK 之间的翻译官。它只做两件事:

方向输入输出难点
入参映射list[Message] + toolsSDK 的请求参数system 位置、工具调用的 block 结构
出参映射SDK 的响应对象ModelResponse内容块拼接、停止原因归一化

它不做的事同样重要:不重试、不加缓存、不改业务逻辑。这些留到后面的包装器章节。保持「一层只干一件事」,适配器才能被单独测。

2.2.2 入参映射:Message 列表变成 SDK 参数

Anthropic 的 Messages API 有两个特点:system 提示是顶层参数,不放在消息数组里;工具调用与工具结果都是消息里的 content block。所以映射时要把 minagent 的四种角色拆开处理:

# minagent/anthropic_adapter.py
from __future__ import annotations
import os

from anthropic import Anthropic
from anthropic.types import Message as AnthropicMessage

from minagent.types import Message, ModelResponse, ToolCall


def to_anthropic(messages: list[Message]) -> tuple[str, list[dict]]:
    """把 minagent 的 Message 列表翻译成 Anthropic 的 (system, messages)。"""
    system_parts: list[str] = []
    out: list[dict] = []
    for m in messages:
        if m.role == "system":
            system_parts.append(m.content)
        elif m.role == "user":
            out.append({"role": "user", "content": m.content})
        elif m.role == "assistant":
            blocks: list[dict] = []
            if m.content:
                blocks.append({"type": "text", "text": m.content})
            for tc in m.tool_calls:
                blocks.append({"type": "tool_use", "id": tc.id,
                               "name": tc.name, "input": tc.arguments})
            out.append({"role": "assistant", "content": blocks})
        elif m.role == "tool":
            out.append({"role": "user", "content": [
                {"type": "tool_result", "tool_use_id": m.tool_call_id,
                 "content": m.content}]})
    return "\n".join(system_parts), out

几个容易写错的点:

  • system 要抽出来拼接,不能当成普通消息塞进 messages,否则 SDK 会报错。
  • 助手消息若既有文本又有工具调用,要按顺序放进同一个 content 数组(先 text 再 tool_use)。
  • 工具结果在 Anthropic 里是 role: "user" 的消息,且必须用 tool_use_id 回指是哪次调用的结果。这正是我们 Message.tool_call_id 字段存在的理由。

2.2.3 出参映射:SDK 响应折回 ModelResponse

反向映射要把 SDK 的 content block 列表「拍平」:文本块拼成一个字符串,工具调用块收集成 ToolCall 列表。

# minagent/anthropic_adapter.py(续)
def from_anthropic(resp: AnthropicMessage) -> ModelResponse:
    """把 SDK 的 Message 折叠回统一的 ModelResponse。"""
    texts: list[str] = []
    tool_calls: list[ToolCall] = []
    for block in resp.content:
        if block.type == "text":
            texts.append(block.text)
        elif block.type == "tool_use":
            tool_calls.append(ToolCall(id=block.id, name=block.name,
                                       arguments=dict(block.input)))
    return ModelResponse(
        message=Message(role="assistant", content="".join(texts),
                        tool_calls=tool_calls),
        stop_reason=resp.stop_reason or "end_turn",
        usage={"input_tokens": resp.usage.input_tokens,
               "output_tokens": resp.usage.output_tokens},
    )

Anthropic 的 stop_reason 取值(end_turn / tool_use / max_tokens)恰好与我们的三个常量一致,所以这里直接透传。换一家供应商时,这里就得写一张映射表——这正是适配器该吸收的差异。

2.2.4 组装 AnthropicModel

把两侧映射包进实现协议的对象里:

# minagent/anthropic_adapter.py(续)
class AnthropicModel:
    """真实供应商适配器。本节示例默认不真调;只在显式构造 client 时才联网。"""

    def __init__(self, model: str, max_tokens: int = 1024,
                 client: Anthropic | None = None) -> None:
        self.model = model
        self.max_tokens = max_tokens
        self._client = client or Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

    def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse:
        system, msgs = to_anthropic(messages)
        resp = self._client.messages.create(
            model=self.model,
            max_tokens=self.max_tokens,
            system=system,
            messages=msgs,
            tools=tools or [],
        )
        return from_anthropic(resp)

三条纪律必须写清楚:

  • 密钥永远从 os.environ["ANTHROPIC_API_KEY"] 读,绝不硬编码,也不要把真实密钥写进任何文件或正文。
  • 本节示例默认不真调。只有你显式构造了 client 并且真的调用 complete,它才会发网络请求。
  • 模型 id 不要凭记忆写。用 client.models.list() 或 Models API 查当前可用的 id,再填进 model=。书里统一用示例 id claude-opus-5-5 只是占位。

2.2.5 离线验证映射:不发一个请求

好消息是:入参和出参映射都可以离线验证,因为出参映射接收的是「一个长得像 SDK 响应的对象」,而不是网络返回。用 AnthropicMessage.model_validate(raw) 就能从一段 JSON 造出这个对象:

# run22a.py
import sys, json
sys.path.insert(0, "/tmp/ab_work")
from minagent.types import Message, ToolCall
from minagent.anthropic_adapter import to_anthropic, from_anthropic
from anthropic.types import Message as AM

# 入参映射
msgs = [
    Message(role="system", content="你是严谨的助手"),
    Message(role="user", content="北京现在几点?"),
    Message(role="assistant", content="", tool_calls=[ToolCall("t1", "get_time", {"tz": "Asia/Shanghai"})]),
    Message(role="tool", content="10:00", tool_call_id="t1"),
]
system, out = to_anthropic(msgs)
print("system =", repr(system))
print("messages =", json.dumps(out, ensure_ascii=False))

# 出参映射:用一段「录制」的原始响应
raw = {
    "id": "msg_01ABC", "type": "message", "role": "assistant",
    "model": "claude-opus-5-5",
    "content": [
        {"type": "text", "text": "现在是北京时间 10:00。"},
        {"type": "tool_use", "id": "t2", "name": "add", "input": {"a": 1, "b": 2}},
    ],
    "stop_reason": "tool_use", "stop_sequence": None,
    "usage": {"input_tokens": 52, "output_tokens": 18},
}
resp = from_anthropic(AM.model_validate(raw))
print("content =", repr(resp.message.content))
print("tool_calls =", resp.message.tool_calls)
print("stop_reason =", resp.stop_reason, "usage =", resp.usage)

真跑的输出(无需密钥、无需网络):

system = '你是严谨的助手'
messages = [{"role": "user", "content": "北京现在几点?"}, {"role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "get_time", "input": {"tz": "Asia/Shanghai"}}]}, {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "10:00"}]}]
content = '现在是北京时间 10:00。'
tool_calls = [ToolCall(id='t2', name='add', arguments={'a': 1, 'b': 2})]
stop_reason = tool_use usage = {'input_tokens': 52, 'output_tokens': 18}

注意工具结果被翻译成了 role: "user" + tool_result block,工具调用被折成了 ToolCall(id='t2', ...)。映射对不对,一眼可见,这就是可离线验证的价值。

2.2.6 录制夹具:一次真实、多次重放

出参映射既然能喂 JSON,就可以把「一次真实响应」存成文件,之后反复重放。这套做法叫录制夹具(recording fixture),它把「真实」与「可复现」解耦:

时机做什么需要密钥/网络吗
录制真调一次,把响应 JSON 存盘需要(一次性)
重放读 JSON,走同一套 from_anthropic不需要
回归每次 CI 跑重放,断言轨迹不变不需要

关键点:重放走的仍是生产代码里的 from_anthropic。所以录制的 JSON 一旦更新(供应商改了字段),测试会立刻失败——它同时是回归测试和「协议漂移」警报器。

2.2.7 夹具 JSON 的结构

夹具就是一个 JSON 文件,外层是元信息,frames 是逐轮响应:

{
  "model": "claude-opus-5-5",
  "recorded_at": "2026-09-24T10:00:00+08:00",
  "frames": [
    {
      "id": "msg_01AAA", "type": "message", "role": "assistant",
      "model": "claude-opus-5-5",
      "content": [
        {"type": "text", "text": "我先查一下时间。"},
        {"type": "tool_use", "id": "toolu_1", "name": "get_time",
         "input": {"tz": "Asia/Shanghai"}}
      ],
      "stop_reason": "tool_use", "stop_sequence": null,
      "usage": {"input_tokens": 41, "output_tokens": 22}
    },
    {
      "id": "msg_01BBB", "type": "message", "role": "assistant",
      "model": "claude-opus-5-5",
      "content": [{"type": "text", "text": "现在是北京时间 10:00。"}],
      "stop_reason": "end_turn", "stop_sequence": null,
      "usage": {"input_tokens": 73, "output_tokens": 11}
    }
  ]
}

frames 数组的顺序就是调用的先后顺序。recorded_at 不是装饰——它让你知道这份录像有多旧,供应商若改了响应形状,你能迅速定位是不是录像过期。

2.2.8 RecordedModel:离线重放

RecordedModel 实现同一份 Model 协议,逐帧重放:

# minagent/recorded.py
from __future__ import annotations
import json
from pathlib import Path

from anthropic.types import Message as AnthropicMessage

from minagent.anthropic_adapter import from_anthropic
from minagent.types import Message, ModelResponse


class RecordedModel:
    """离线重放:读取录制好的响应 JSON,逐帧经适配器映射回 ModelResponse。

    零凭据、零网络,用来做确定性回归测试。
    """

    def __init__(self, fixture_path: str | Path) -> None:
        data = json.loads(Path(fixture_path).read_text(encoding="utf-8"))
        self.model = data["model"]
        self.recorded_at = data["recorded_at"]
        self._frames = data["frames"]
        self._i = 0

    def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse:
        if self._i >= len(self._frames):
            raise RuntimeError("录像已放完:真实对话比录制时更长")
        raw = self._frames[self._i]
        self._i += 1
        return from_anthropic(AnthropicMessage.model_validate(raw))

真跑(读 2.2.7 的夹具,逐轮打印):

模型: claude-opus-5-5 录制于 2026-09-24T10:00:00+08:00
[1] stop=tool_use text='我先查一下时间。' tools=['get_time'] usage={'input_tokens': 41, 'output_tokens': 22}
[2] stop=end_turn text='现在是北京时间 10:00。' tools=[] usage={'input_tokens': 73, 'output_tokens': 11}

它和 AnthropicModel 实现的是同一个协议,所以 run(model=...) 里可以无缝互换——真实路径与离线路径共用一份循环代码,这正是 2.1 节协议设计的回报。

2.2.9 关于 OpenAI 的对照

很多读者会问「那 OpenAI 呢」。本节只讲接口差异,不给可运行代码,原因很直接:本机 api.openai.com 不可达,把它写成主路径会跑不通。差异主要是三处:

  • system 的位置:OpenAI 把 system 当成 messages 里的一条(role: "system"),不像 Anthropic 放在顶层参数。映射时不必单独抽取。
  • 工具调用形态:OpenAI 的工具调用挂在 message.tool_calls 字段上,参数是字符串化的 JSON(需要 json.loads),而 Anthropic 是 content block 里的结构化 input。
  • 停止原因:OpenAI 用 finish_reason(stop / tool_calls / length),与我们的三常量要做一张映射表。

换供应商时,要改的只有适配器,不改协议、不改循环——这就是把供应商隔离在协议之后的全部意义。

真实模型下会怎样

真实调用还要处理一堆本节故意省略的事:限流返回 429 时要退避重试、请求超时要重发、usage 里会有缓存命中与思考 token、长响应要改流式。但这些都不改变协议——它们要么属于第 14 章的可靠性包装器,要么属于第 7 章的流式。本节要建立的信念是:先把形状统一,再逐层加策略。真实与离线共用同一份 from_anthropic,就是你敢在 CI 里断言「线上逻辑没坏」的底气。

阅读导航:上一节:2.1 Model 协议设计 · 下一节:2.3 Token 预算与截断策略 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距