《AI Agent 开发入门》2.1 Model 协议设计

本节把「调用大模型」抽象成 minagent 的 Model 协议:用一个 complete(messages, tools) 方法屏蔽供应商差异,用统一的 ModelResponse 承载回复、停止原因与用量。我们讲清协议为什么存在、complete 与 stream 如何取舍,再给出 EchoModel 与 ScriptedModel 两个可运行实现,并验证它们可以互换。

2.1 Model 协议设计

上一章我们把 Message / ToolCall / ModelResponse 这几个数据类立了起来,也跑通了第一个假模型。但那时假模型是「就地写死」的:循环直接调一个具体类,换后端就得改循环。本节要把这一步补上——先在「调用大模型」这件事上抽出一个稳定的接口,让后面所有章节都对着接口写,而不是对着某家 SDK 写。

本节把 minagent 推进到「有协议层」:定义 Model Protocol 与统一响应 ModelResponse,给出 EchoModel 与 ScriptedModel 两个实现,并用一个只依赖协议的函数证明它们可互换。到本节结束,run() 循环(第 3 章)就有东西可依赖了。

2.1.1 直接调 SDK 会带来什么

新手最自然的写法,是在需要模型的地方直接 client.messages.create(...)。这在单个脚本里没问题,一旦要写成可测试的引擎,就会同时踩三个坑:

问题表现后果
强耦合业务逻辑里到处是供应商参数名(max_tokens、system)换供应商要改遍全仓库
不可测单元测试必须联网、必须真调CI 慢、要密钥、结果不确定
不可离线断网或没额度时整条链路瘫掉无法做确定性回归

这三个坑有一个共同的解法:把「调用模型」这件事收敛到一个接口后面。接口描述「要什么、给什么」,不描述「怎么做到」。业务代码只认接口,具体供应商是实现接口的一个可替换零件。

2.1.2 Model 协议:一个方法,两个参数

minagent 的 Model 协议只有一个方法:

# minagent/types.py(承接第 1 章,本节新增 Model)
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol

Role = Literal["system", "user", "assistant", "tool"]


@dataclass
class ToolCall:
    id: str
    name: str
    arguments: dict[str, Any]


@dataclass
class Message:
    role: Role
    content: str = ""
    tool_calls: list[ToolCall] = field(default_factory=list)
    tool_call_id: str | None = None


@dataclass
class ModelResponse:
    message: Message
    stop_reason: str = "end_turn"      # "end_turn" | "tool_use" | "max_tokens"
    usage: dict[str, int] = field(default_factory=dict)


class Model(Protocol):
    def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse: ...

这里用了 typing.Protocol 而不是抽象基类。区别很实际:Protocol 是结构化的——任何对象只要「长得像」它(有签名匹配的 complete 方法),就自动满足协议,不需要显式继承。这意味着第三方 SDK 的封装类、测试用的假模型、甚至一个临时写的小对象,都不用去 class X(Model),直接就能塞进 run()。

方法签名里两个参数各有含义:

  • messages: list[Message]——完整对话历史,包含 system、user、assistant、tool 四种角色。协议要求实现方自行从中提取 system 提示(因为有的供应商把 system 放在顶层参数,有的放在消息数组里)。
  • tools: list[dict]——可用工具的 JSON Schema 列表。传空列表表示这一轮不允许调用工具。

返回一个 ModelResponse。注意协议里没有 model、max_tokens、temperature 这些参数——它们是实现的构造参数,不是接口的一部分。这样切换模型配置只改实现,不改调用方。

2.1.3 统一响应:ModelResponse 的三个字段

不同供应商的响应结构差别很大,但归一化之后,Agent 循环真正需要的信息只有三项:

字段类型含义循环怎么用
messageMessage助手这一轮的回复(文本 + 工具调用)追加进历史;有工具调用就执行
stop_reasonstr这一轮为什么停下决定继续还是结束
usagedict[str, int]输入/输出 token 数成本核算、预算控制

stop_reason 的取值我们收敛成三个:

  • end_turn——模型自然说完,本轮结束。
  • tool_use——模型要求调用工具,循环应执行工具后继续。
  • max_tokens——被输出上限截断,需要提示用户或重试。

真实供应商的停止原因可能更多(还有 stop_sequence、pause_turn 等),但适配器的职责就是把它们映射到这三个上。循环只认这三个,简单且够用。

2.1.4 complete 与 stream 两种形态

调用模型有两种形态:一次性返回(complete)和流式返回(stream)。它们解决的是不同问题:

形态返回适用代价
complete完整 ModelResponse需要结构化结果、要工具调用首字延迟高
stream一串增量事件面向用户的打字机效果需要额外的组装逻辑

关键判断是:Agent 的循环逻辑只依赖最终结果,不依赖中间过程。无论底层是不是流式,循环要的都是「这一轮完整的助手消息 + 停止原因」。所以我们的做法是:协议层只暴露 complete;流式作为一种「展示层能力」,在第 7 章单独引入,它内部仍然把增量事件组装成一个完整的 ModelResponse 再交给循环。这样协议只有一个方法,所有实现(假模型、录制重放、真实适配器)都只需实现 complete。

如果把 stream 也塞进协议,每个假模型都得伪造一套增量事件,测试会变得又长又脆。能用一个方法表达清楚的接口,就不要用两个——这条经验在整卷都会反复出现。

2.1.5 一个最小实现:EchoModel

先写一个最简单的实现,用来验证协议「接得上」:

# minagent/models.py
from __future__ import annotations
from minagent.types import Message, ModelResponse, ToolCall


class EchoModel:
    """最小可用后端:把最后一条 user 消息原样回显。用于冒烟与协议一致性检查。"""

    def __init__(self) -> None:
        self.calls: list[tuple[list[Message], list[dict]]] = []

    def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse:
        self.calls.append((list(messages), list(tools)))
        last_user = next((m for m in reversed(messages) if m.role == "user"), None)
        text = last_user.content if last_user else ""
        return ModelResponse(
            message=Message(role="assistant", content=f"echo: {text}"),
            stop_reason="end_turn",
            usage={"input_tokens": 0, "output_tokens": 0},
        )

EchoModel 没有任何依赖,但它做了一件重要的事:把每次调用的入参记录下来(self.calls)。这是假模型的通用习惯——断言「循环到底把什么消息发给了模型」,往往比断言模型输出更能定位 bug。

2.1.6 可编程实现:ScriptedModel

EchoModel 只会回显,没法模拟「先调工具、再回答」的多轮行为。ScriptedModel 用一份预先写好的剧本解决这个问题:

# minagent/models.py(续)
class ScriptedModel:
    """按剧本返回:每次调用弹出队首的 ModelResponse。剧本耗尽即抛错。"""

    def __init__(self, script: list[ModelResponse]) -> None:
        self._script = list(script)
        self.calls: list[tuple[list[Message], list[dict]]] = []

    def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse:
        self.calls.append((list(messages), list(tools)))
        if not self._script:
            raise RuntimeError("剧本已耗尽:Agent 未在预期步数内收敛")
        return self._script.pop(0)

「剧本耗尽即抛错」是一个刻意的设计:如果循环本该在第 3 步停下却走了第 4 步,测试会直接炸掉,而不是静默多转一圈。第 1.3 节的 FakeModel 在此基础上还支持「字符串」与「ToolCall 列表」两种简写,ScriptedModel 是它的最小版本,本节先用它把协议讲清。

2.1.7 协议的意义:一个函数,两种后端

现在写一个只依赖协议的函数,看看两个实现能不能互换:

# run21.py
import sys
sys.path.insert(0, "/tmp/ab_work")
from minagent.types import Message, ModelResponse, ToolCall
from minagent.models import EchoModel, ScriptedModel


def describe(model) -> str:
    """只依赖 Model 协议,不关心背后是哪家供应商。"""
    reply = model.complete(
        [Message(role="system", content="你是助手"),
         Message(role="user", content="你好,介绍一下你自己")],
        tools=[],
    )
    return f"{reply.message.content!r} stop={reply.stop_reason} usage={reply.usage}"


print("EchoModel    ->", describe(EchoModel()))
script = [
    ModelResponse(message=Message(role="assistant", content="我先查一下",
                                  tool_calls=[ToolCall("t1", "get_time", {"tz": "UTC"})]),
                  stop_reason="tool_use", usage={"input_tokens": 30, "output_tokens": 12}),
    ModelResponse(message=Message(role="assistant", content="现在是 10:00"),
                  stop_reason="end_turn", usage={"input_tokens": 48, "output_tokens": 9}),
]
m = ScriptedModel(script)
print("ScriptedModel->", describe(m))
print("ScriptedModel->", describe(m))
print("calls recorded:", len(m.calls))

真跑(/tmp/agentbook/venv/bin/python run21.py)的输出:

EchoModel    -> 'echo: 你好,介绍一下你自己' stop=end_turn usage={'input_tokens': 0, 'output_tokens': 0}
ScriptedModel-> '我先查一下' stop=tool_use usage={'input_tokens': 30, 'output_tokens': 12}
ScriptedModel-> '现在是 10:00' stop=end_turn usage={'input_tokens': 48, 'output_tokens': 9}
calls recorded: 2

describe() 完全不知道背后是 EchoModel 还是 ScriptedModel,它只调 complete。这就是协议的全部价值:调用方与实现解耦。你甚至可以写一个测试,把两个后端都传进同一个函数,断言它们返回的都是合法的 ModelResponse——这就是「协议一致性检查」。下面把这份收益拆成三块:

收益机制在第几章兑现
可测用假模型注入确定的轨迹,断言 stop_reason 与调用序列第 16 章
可换换后端只改 run(model=...) 一个参数本节起全卷
可离线录制重放不需要网络与密钥2.2 节

还有一条不在表里的收益:可组合。因为协议只有一个方法,任何「包一层的模型」(裁剪预算、加缓存、记日志)都能用同一个协议再实现一次,像积木一样叠起来。2.3 节的 BudgetedModel 就是第一块这样的积木。

2.1.8 协议边界:什么该进、什么不该进

设计协议时最容易犯的错,是把它当成「所有想法的垃圾桶」。一条实用的判断标准是:只有「循环必须知道」的信息才进协议。据此可以划出两条边界:

候选进不进协议理由
messages / tools / ModelResponse进循环的输入输出,缺一不可
model 名、max_tokens、temperature不进属于实现配置,换后端时才关心
供应商原始响应对象不进会把供应商类型泄漏进调用方
流式事件不进(第 7 章另开)循环只消费最终结果
usage 里的成本字段进(作为 dict 的键)预算与核算要用,但不强制所有后端都有

usage 用 dict[str, int] 而不是一个固定 dataclass,也是同一个思路:不同后端的计量口径不同,假模型可以给 {"input_tokens": 0},真实适配器给更多键,循环只读它认识的那几个,读不到就当 0。协议要稳定,但允许实现携带额外的、可选的信息。

还有一条边界值得强调:协议不负责重试。限流、超时、断线这些属于「可靠性」,第 14 章会用包装器单独处理(RetryingModel 之类),而不是塞进 complete 的语义里。这样每一层只做一件事:协议管形状,包装器管策略。

真实模型下会怎样

真实供应商的 complete 要复杂得多:它要发 HTTP 请求、处理限流与超时、把 SDK 的响应对象翻译回 ModelResponse,usage 里还会有缓存命中、思考 token 等更多字段。但对调用方来说,这些都被挡在协议后面——run() 循环看到的永远是一个 ModelResponse。这正是下一节要做的适配器工作:把某家 SDK 的细节翻译成这个统一形状,再额外提供一个「离线替身」,让没有密钥的人也能跑通全书的例子。

阅读导航:上一节:1.3 可编程假模型 FakeModel · 下一节:2.2 真实供应商适配与离线替身 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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