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 回顾与路线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。