《AI Agent 开发入门》18.2 真实 API 冒烟(可选)

本节只讲「如何」做一次真实 API 冒烟,不真的调用:给出把 FakeModel 换成 AnthropicModel 的最小代码(密钥只从环境变量读),离线验证双向映射,说明网络、密钥与用户同意三项前提,并用一份录制的响应夹具经 RecordedModel 离线重放同一条代码路径,真跑贴出轨迹。默认离线,真实调用请自行决定。

18.2 真实 API 冒烟(可选)

18.1 的端到端跑得很漂亮,但有一个前提始终没变:模型是假的。整卷书都在假模型上开发,这没错——它让逻辑确定、免费、可断言。可假模型证明不了最后一件事:这套代码接上真实供应商,还能不能跑。本节回答的就是这个「最后一公里」的问题。

本节把 minagent 推进到「可选的真实冒烟」:给出把 FakeModel 换成 AnthropicModel 的最小代码,离线验证双向映射,说明运行前提(网络、密钥、用户同意),再用一份录制的响应夹具离线重放同一条代码路径。本节默认离线,不消耗任何额度;真实调用请你自己决定。

18.2.1 冒烟测什么、不测什么

先把「冒烟测试」(smoke test)的边界划清楚。它和 16.1 的确定性测试是两件事:

维度确定性测试(16.1)冒烟测试(本节)
断言对象轨迹、工具、参数、终值「接口通、格式对、有答复」
后端FakeModel真实供应商
可复现逐字节一致不保证
花费零按 token 计费
失败含义逻辑坏了网络/密钥/协议漂移

冒烟不比对具体文本——真实模型每次措辞都不同,assert result.final == "..." 必然失败。它只验证三件粗粒度的事实:请求发得出去、响应能被 from_anthropic 折回 ModelResponse、循环能收敛到一个非空答复。它是保险丝,不是回归网;回归网仍由假模型的评测集承担。两者分工,谁也别替谁。

为什么两层测试缺一不可?因为它们的失败含义完全不同。确定性测试红了,说明逻辑被改坏了——这是你可以当场修的;冒烟测试红了,说明现实变了——网络断了、密钥过期了、供应商调整了响应字段。前者是回归,后者是环境。把两者混在一起,你会分不清「代码错」还是「网络慢」,排查成本陡增。

还有一种常见替代方案是「手写 mock」——直接返回一段硬编码的响应。它比录制重放更省事,却有个致命缺陷:mock 反映的是你对响应的想象,不是供应商真实的响应。字段名写错、嵌套结构猜偏,mock 都会照单全收,直到真正上线才暴露。录制重放不一样,它录的是真实字节,走的又是生产代码里的映射函数——两者叠加,才能既离线又可信。三种做法的取舍:

做法成本保真度适合
直接真调每次花钱、依赖网络最高上线前一次
手写 mock零取决于你的想象快速搭骨架
录制重放录一次,之后零高(真实字节 + 生产映射)回归与 CI

本节选录制重放,正是因为它把「真实」与「可复现」这两件常常互斥的事解耦了。

18.2.2 把 FakeModel 换成 AnthropicModel

好消息是,换后端只改一行。第 2.2 节写的 AnthropicModel 与 FakeModel 实现的是同一个 Model 协议,run() 一行不用动:

import os
from minagent import run
from minagent.anthropic_adapter import AnthropicModel

MODEL_ID = "claude-opus-5-5"            # 占位:实际 id 以 models.list() 查询为准
model = AnthropicModel(model=MODEL_ID)  # 密钥从 os.environ["ANTHROPIC_API_KEY"] 读

result = run(model, build_registry(), QUESTION, system=SYSTEM)
print(result.final)

对比 18.1 的 FakeModel(e2e_script()),变的只有「谁来实现 complete()」这一个点。三条纪律必须重复一遍:密钥永远从 os.environ["ANTHROPIC_API_KEY"] 读,绝不硬编码、绝不写进任何文件;模型 id 不要凭记忆写,用 client.models.list() 查当前可用的再填;示例默认不真调,只有你真的执行了这段代码、且密钥已设,它才会发出网络请求。

这行代码能成立,靠的是 2.1 节把 Model 抽成协议的远见:循环只认「有个对象能 complete」,不关心它背后是脚本还是网络。换后端之所以只改一行,是因为差异全被适配器吸收了。

18.2.3 离线先验映射:不发一个请求

在花钱之前,有个白捡的便宜可以占:入参映射完全能离线验证。to_anthropic 接收的是 list[Message],与网络无关,跑一遍就能看清 system 与工具结果被翻译成了什么:

import json
from minagent.types import Message, ToolCall
from minagent.anthropic_adapter import to_anthropic

msgs = [
    Message(role="system", content="你是严谨的助手。"),
    Message(role="user", content="asyncio 适合什么场景?"),
    Message(role="assistant", content="我先检索一下资料。",
            tool_calls=[ToolCall("toolu_1", "search", {"query": "asyncio I/O 密集", "k": 2})]),
    Message(role="tool", content='[{"doc_id": "python-async-advanced"}]', tool_call_id="toolu_1"),
]
system, out = to_anthropic(msgs)
print("system =", repr(system))
print("messages =", json.dumps(out, ensure_ascii=False))

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

system = '你是严谨的助手。'
messages = [{"role": "user", "content": "asyncio 适合什么场景?"}, {"role": "assistant", "content": [{"type": "text", "text": "我先检索一下资料。"}, {"type": "tool_use", "id": "toolu_1", "name": "search", "input": {"query": "asyncio I/O 密集", "k": 2}}]}, {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "[{\"doc_id\": \"python-async-advanced\"}]"}]}]

三处细节一眼可验:system 被抽到顶层、没混进消息数组;助手消息里文本与工具调用按序放进同一个 content 数组;工具结果被翻译成 role: "user" + tool_result block,并用 tool_use_id 回指。映射对不对,不用联网也看得出来——这正是 2.2 节把两侧映射与网络解耦的价值。

出参方向同样能离线验:from_anthropic 接收的是一个「长得像 SDK 响应的对象」,用 AnthropicMessage.model_validate(raw) 就能从一段 JSON 造出来。18.2.6 的离线重放走的正是这条路——两侧映射都能在没有密钥、没有网络的机器上测,这就是把适配器单独成一层的回报。

18.2.4 运行前提:三项缺一不可

在按下回车之前,确认下面三项都满足:

前提怎么满足不满足的后果
网络可达api.anthropic.com 可连通连接超时或 APIConnectionError
密钥已设export ANTHROPIC_API_KEY=...构造时 KeyError: 'ANTHROPIC_API_KEY'
明确同意你本人决定消耗额度未经同意不应触发真实调用

第一项是硬约束:本机 api.anthropic.com 可达,而 api.openai.com 不可达,所以本书只把 Anthropic 写成可运行路径,OpenAI 只作接口对照(2.2 节)。第二项是安全纪律——密钥进环境变量,不进代码。第三项是本卷的态度:真实调用会花钱,花不花由读者决定,书里不替你做这个决定,更不会替你按下那个回车。

再多说一句密钥:把它写进代码、写进配置文件、写进 notebook,都是事故的种子——一次 git push 就可能把它推到公开仓库。正确做法是它只存在于运行环境里:本地用 export,CI 用密钥管理服务注入,容器用 secret 挂载。代码里永远只出现 os.environ["ANTHROPIC_API_KEY"] 这一个引用,看不到密钥本身。这也是本书正文从头到尾不出现任何密钥字面量的原因。

18.2.5 录制一份夹具(一次性)

真实冒烟会花钱,所以做一次就够——把响应录下来,之后永远离线重放。做法是在 SDK 的 messages.create 外面包一层,把每次响应 model_dump 存成夹具:

# record_smoke.py(一次性,需网络与密钥;本节不运行它)
import json
from pathlib import Path
from minagent.anthropic_adapter import AnthropicModel


class RecordingClient:
    """包一层 Anthropic client,把每次响应原样存成夹具帧。"""

    def __init__(self, client, path: str) -> None:
        self._client = client
        self._path = Path(path)
        self._frames: list[dict] = []

    @property
    def messages(self):
        return self

    def create(self, **kwargs):
        resp = self._client.messages.create(**kwargs)
        self._frames.append(resp.model_dump(mode="json"))
        self._path.write_text(json.dumps(
            {"model": kwargs["model"], "recorded_at": "2026-10-10T10:00:00+08:00",
             "frames": self._frames}, ensure_ascii=False, indent=2), encoding="utf-8")
        return resp


# 只在明确同意、密钥已设时执行:
# from anthropic import Anthropic
# model = AnthropicModel(model=MODEL_ID, client=RecordingClient(Anthropic(), "fixtures/smoke.json"))
# run(model, build_registry(), QUESTION, system=SYSTEM)

resp.model_dump(mode="json") 吐出的是一份完整的原始响应——比 2.2 节手写的夹具多若干字段(container、diagnostics 等),但这没关系:RecordedModel 走的是 AnthropicMessage.model_validate,多余的字段被忽略、缺失的可选字段被补默认值,两种形态都能被同一套代码吃下。录制完,fixtures/smoke.json 就是一份可反复重放的「录像」:

{
  "model": "claude-opus-5-5",
  "recorded_at": "2026-10-10T10: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": "search",
         "input": {"query": "asyncio I/O 密集", "k": 2}}
      ],
      "stop_reason": "tool_use", "stop_sequence": null,
      "usage": {"input_tokens": 88, "output_tokens": 20}
    },
    {
      "id": "msg_01BBB", "type": "message", "role": "assistant",
      "model": "claude-opus-5-5",
      "content": [
        {"type": "text",
         "text": "asyncio 适合 I/O 密集型任务,事件循环在单线程内调度协程。[python-async-advanced#0]"}
      ],
      "stop_reason": "end_turn", "stop_sequence": null,
      "usage": {"input_tokens": 160, "output_tokens": 24}
    }
  ]
}

18.2.6 离线重放:同一条代码路径

关键点在于:重放走的仍是生产代码里的 from_anthropic。RecordedModel 逐帧读 JSON,经适配器映射回 ModelResponse,再喂进同一个 run() 循环。零凭据、零网络,真跑(Python 3.14.6):

# smoke_offline.py
from minagent import Tool, ToolRegistry, read, run, search_tool
from minagent.recorded import RecordedModel

SYSTEM = "你是严谨的助手。回答每个论断时在句末标注来源 [doc_id#chunk_id]。"
QUESTION = "asyncio 适合什么场景?"

model = RecordedModel("fixtures/smoke.json")
print(f"模型: {model.model}  录制于 {model.recorded_at}")
result = run(model, build_registry(), QUESTION, system=SYSTEM)
for s in result.steps:
    print(f"[{s.index}] {s.kind:<9} {s.detail}")
print("最终回答:", result.final)

输出:

模型: claude-opus-5-5  录制于 2026-10-10T10:00:00+08:00
[0] tool_call search({'query': 'asyncio I/O 密集', 'k': 2}) -> [{"doc_id": "python-async-advanced", "chunk_id": 0, "score": 3.508, "snippet": "asyncio 适合 I/O 密集型任务,事件循环在单线程内调度大量协程。"}, {"doc_id": "python-concurrency-performance", "chunk_id": 2, "score": 1.693, "snippet": "I/O 密集型任务在等待期间会释放 GIL,因此多线程仍有收益。"}]
[1] final     asyncio 适合 I/O 密集型任务,事件循环在单线程内调度协程。[python-async-advanced#0]
最终回答: asyncio 适合 I/O 密集型任务,事件循环在单线程内调度协程。[python-async-advanced#0]

两步(一次 search 加一次 final)跑完,与 18.1 的端到端是同一套循环、同一套工具、同一个适配器。区别只在响应从哪来:18.1 来自 FakeModel 的脚本,这里来自一份 JSON 录像。这套「录制—重放」正是 2.2 节讲的录制夹具,本节把它接到了端到端通路上——录一次真实的,就能离线复现无限次,还顺带成了「供应商改了响应字段」的警报器:哪天真调变了形状,from_anthropic 会立刻报错。

对 CI 而言,这个性质格外重要:流水线里跑不起真实 API(既慢又花钱),但只要把录制夹具提交进仓库,smoke_offline.py 就能在每次构建里重放一遍,替你把「映射还在不在」守住。真实调用留给人工,留给发布前那一次。这正是 16.1 那句「两层测试」在真实冒烟上的落地:确定性的重放天天跑,非确定性的真调偶尔跑。

18.2.7 本节默认离线

把话说明白:本节没有发起任何真实 API 调用,没有消耗任何额度,正文里也没有出现任何密钥。上面的 record_smoke.py 是「怎么做」的说明,被注释掉了;真正跑的是 smoke_offline.py,它只读一个本地 JSON。你若想真的冒一次烟,请自己确认 18.2.4 的三项前提,再取消那两行注释——花不花这个钱,由你决定。

这也是本卷从头到尾的姿态:可运行的东西默认免费、离线、可复现;要花钱、要联网的路径一律标「可选」,并把控制权交回读者手里。假模型如此,录制重放如此,本节的真实冒烟同样如此。

冒烟之前,不妨按这张清单逐条打勾,避免花冤枉钱:

检查项命令 / 动作期望
密钥已设且不落盘test -n "$ANTHROPIC_API_KEY"返回 0
映射已离线验过跑 18.2.3 的片段输出与文中一致
夹具路径可写ls fixtures/目录存在
步数上限保守max_steps=4最坏花费可控
已想清楚要不要花你本人的决定是

18.2.8 真实模型下会怎样

一旦真的接上供应商,三个假模型不会暴露的差异会立刻浮现。其一,映射可能出错:to_anthropic 要把 system 抽到顶层、把工具结果包成 tool_result block——这些约定一旦写错,真实 API 会返回 400 而不是像假模型那样照单全收,所以第一次冒烟最该盯的就是这里(这也是 18.2.3 先离线验映射的原因)。其二,错误是常态:限流 429、超时、上下文超长都会发生,complete() 不再保证返回 ModelResponse,得靠第 14 章的重试包装器兜底。其三,usage 变成真金白银:假模型的 usage 是手写的,真实 usage 带缓存命中、思考 token 等字段,第 15.3 节的价目表要相应细化。

一句话收束:假模型负责「证明逻辑对」,真实冒烟负责「证明接线通」。前者是每天跑的回归网,后者是上线前按一次的保险丝。两件事都做完,你才算真的把这台 Agent 交付了出去。

阅读导航:上一节:18.1 端到端 · 下一节:18.3 回顾与路线 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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