《AI Agent 开发入门》17.1 库的对外接口

本节把 minagent 从「一堆模块」整理成「一个库」:用 __init__.py 收敛出稳定的公开 API 面,用 __all__ 与下划线前缀划清公开契约与内部实现的边界,并定下 SemVer 版本策略——破坏性变更何时允许、哪些名字属于契约。最后用 import minagent as ma 真跑一段只碰公开面的稳定性示例。

17.1 库的对外接口

前 16 章,minagent 一直以「一堆模块」的形式存在:types.py、tools.py、loop.py、agent.py……你用 from minagent.types import Message、from minagent.tools import ToolRegistry,一个文件一个文件地拼。作为学习项目这没问题;但一旦要给别人用——同事、下游服务、甚至三个月后的你自己——这套「按文件导入」的用法就危险了:你改一次内部文件布局,所有人的 import 全部碎掉。要让它成为库,第一步是把「哪些名字是给外人用的」这件事,从文件布局里解耦出来。

本节把 minagent 推进到「可发布的库」:用 minagent/__init__.py 收敛出一个稳定的公开 API 面,用 __all__ 与 _ 前缀划清「公开契约」与「内部实现」,并定下 SemVer 版本策略。

17.1.1 「能导入」不等于「能依赖」

先说清一个容易混淆的点。minagent 到现在每一行都能被导入——from minagent.loop import run 完全合法。但「能导入」和「能依赖」是两回事:前者是 Python 的语法事实,后者是你对使用者的承诺。当同事写下 from minagent.tools import ToolRegistry,他就把「ToolRegistry 住在 tools.py」这个事实焊进了他的代码。哪天你为了重构,把工具相关的东西挪进 minagent/core/tools.py,他的代码当场报 ModuleNotFoundError——而你自认为只是「整理了一下目录」。

这个坑的根源是:公开面被绑定在了文件布局上。只要用户按文件路径导入,你的文件结构就成了 API 的一部分,重构的自由被锁死。解法是经典的「包门面」模式:让所有外部使用者只从一个稳定入口导入,内部文件怎么摆随你。在 Python 里,这个入口就是包的 __init__.py。

判断「该不该把它当公开 API」,有一条朴素的尺子:使用者会不会在自己的代码里把这个名字直接写出来? 会——它就得进公开面,改名要打招呼;不会(比如只是内部某一步的辅助函数)——它就该藏起来,随便改。按这把尺子,minagent 的公开面大致是三类:契约类型(消息、响应、轨迹)、引擎入口(跑循环的 run 与 Agent)、扩展点(自定义模型与工具要继承/实现的 Model、Tool)。其余全是内部实现。

把「按路径导入」和「按名字导入」摆在一起,差别一目了然:

# 脆弱写法:把内部文件布局焊进使用者的代码
from minagent.types import Message, ToolCall
from minagent.tools import Tool, ToolRegistry
from minagent.loop import run

# 稳定写法:只依赖包顶层这一个入口
from minagent import Message, ToolCall, Tool, ToolRegistry, run

两段代码今天跑起来一模一样,但重构时命运截然不同:上面那段,你动一次文件就全碎;下面那段,你随便重排内部文件都不受影响。公开面存在的意义,就是让使用者写下面那段。

17.1.2 __init__.py:把公开面钉在一个文件里

包门面的实现就是一个汇总导出的 __init__.py:

# minagent/__init__.py
from __future__ import annotations

from .agent import Agent
from .events import Event
from .loop import run
from .model import FakeModel
from .session import Session
from .tools import Tool, ToolRegistry
from .types import (
    Message,
    Model,
    ModelResponse,
    Role,
    RunResult,
    Step,
    ToolCall,
)

__version__ = "0.17.0"

__all__ = [
    "Agent", "Event", "FakeModel", "Message", "Model", "ModelResponse",
    "Role", "RunResult", "Session", "Step", "Tool", "ToolCall",
    "ToolRegistry", "run", "__version__",
]

这段代码有两个设计决定值得停一下。

第一,所有公开名字都从子模块「搬」到包顶层。使用者从此只写 from minagent import Message, run, Agent,不再关心 Message 到底住在 types.py 还是别的文件。哪怕你明天把 types.py 拆成 message.py + tool.py,只要 __init__.py 里的导出不变,使用者的代码一行都不用动。文件布局变成内部私事,公开面被固定在这一个文件里。

第二,__version__ 也是公开 API 的一部分。把版本号放在包顶层,使用者能 minagent.__version__ 直接读到,日志、错误上报、依赖检查都能用。它和函数一样是契约:语义化版本的含义(见 17.1.5)要靠它承载。

17.1.3 __all__ 与 _ 前缀:公开与内部的两道栅栏

__all__ 是一个字符串列表,声明「这个包的公开名字」。它有两个实际作用:from minagent import * 时只导入列表里的名字;以及更重要的——它是给人看的契约声明。IDE 会据此提示,help() 会据此展示,代码审查时一眼能看出「这一版对外承诺了什么」。

但 __all__ 只管「顶层导出的名字」,管不到子模块内部。所以还需要第二道栅栏:下划线前缀。Python 的约定是,以 _ 开头的名字是「内部使用,不保证稳定」。比如 7.1 里那个把文本切成近似 token 的函数 _tokenize,还有 Session 序列化用到的 message_to_dict——前者带下划线,后者虽然不带但在子模块里、也不在 __init__ 的导出清单里,两者都不属于公开面。

把这两道栅栏合起来,公开与内部的边界就清晰了:

类别例子进 __all__改名自由
契约类型Message / ToolCall / ModelResponse是否,属破坏性变更
引擎入口run / Agent / ToolRegistry是否
扩展点Model(协议)/ FakeModel / Tool是否
状态对象Session是否
版本号__version__是否
内部辅助_tokenize / message_to_dict否是,随便改
内部模块文件loop.py / model.py否(不该被直接 import)是

注意最后一行:内部模块文件本身也不该是公开面。使用者不该写 from minagent.loop import run,而应写 from minagent import run。这是「包门面」的核心纪律——唯一的合法入口是包顶层。只要守住这条,你就能自由地拆分、合并、重命名内部文件,而不惊动任何使用者。

反过来,如果一个名字既想暴露又拿不准该不该稳定,正确的做法是先不放进 __all__,观察一段时间再加。公开面只能加不能减(减就是破坏性变更),所以「宁可晚点加,也别早早承诺」。

还有两样东西经常被漏算进公开面,其实也是契约的一部分:函数签名里的类型标注,和公开名字的文档字符串。前者的例子是 run(model: Model, registry: ToolRegistry, user_input: str, system: str = "", max_steps: int = 8)——这些类型和默认值一旦写出来,使用者就会依赖(比如传一个 max_steps=None 之前,会先看你标注的是不是 int)。后者是「这个函数干什么、参数什么意思」的说明;文档写错了,比不写更坑人,因为使用者会照着你写的用。所以严格说,公开面 = 名字 + 签名 + 类型 + 文档,四者都是承诺。

17.1.4 稳定性示例:import minagent as ma 真跑一遍

光说「公开面稳定」是空话,得有一段「只碰公开面」的代码来证明它够用。下面这段刻意不导入任何子模块,全部通过包顶层访问:

import minagent as ma

print("版本:", ma.__version__)
print("公开名字:", ", ".join(n for n in ma.__all__ if not n.startswith("__")))

reg = ma.ToolRegistry()
reg.add(ma.Tool(
    name="add",
    description="两数相加",
    parameters={
        "type": "object",
        "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
        "required": ["a", "b"],
    },
    func=lambda a, b: a + b,
))

model = ma.FakeModel([
    [ma.ToolCall("c1", "add", {"a": 2, "b": 3})],
    "结果是 5。",
])
result = ma.run(model, reg, "2+3 等于多少?", system="你是计算助手")

assert [s.kind for s in result.steps] == ["tool_call", "final"]
assert result.final == "结果是 5。"
print("轨迹:", [(s.kind, s.detail) for s in result.steps])
print("最终:", result.final)

本机实测(Python 3.14.6)的输出:

版本: 0.17.0
公开名字: Agent, Event, FakeModel, Message, Model, ModelResponse, Role, RunResult, Session, Step, Tool, ToolCall, ToolRegistry, run
轨迹: [('tool_call', "add({'a': 2, 'b': 3}) -> 5"), ('final', '结果是 5。')]
最终: 结果是 5。

这段代码的价值不在它跑了什么,而在它怎么访问:从头到尾只出现 ma. 前缀,没有一处 from minagent.xxx import。这意味着它对你内部的文件结构零耦合。这就是「库」和「一堆模块」的分水岭——使用者依赖的是名字,不是路径。

顺带一提,assert 那两行也不是摆设。它们把「公开面的行为」写成了可执行的断言:只要 run 的返回值里 steps[0].kind 还是 "tool_call"、final 还是那句文本,这条契约就成立。把这类断言放进一个 test_public_api.py,你的库就有了「防止手滑破坏公开面」的回归网——内部怎么重构都行,一旦动了公开面的行为,测试立刻红。

17.1.5 版本策略:SemVer 与「破坏性变更」的边界

公开面定下来之后,下一个问题是:以后怎么改? 答案是用语义化版本(SemVer),形如 MAJOR.MINOR.PATCH:

  • PATCH:只修 bug,不改变任何签名与行为契约。
  • MINOR:向后兼容地新增功能(新函数、新可选参数)。
  • MAJOR:允许破坏性变更。

但 SemVer 最容易翻车的不是「记不记得加版本号」,而是判断什么算破坏性变更。很多改动看着人畜无害,其实是破坏:

变更算破坏吗版本位例子
新增可选参数否MINORrun(..., retry=0)
修 bug 不改签名否PATCH修 ToolRegistry.call 的转义
新增一个函数/类型否MINOR加 Agent.stream
删除或改名公开字段是MAJORRunResult.final 改名
改默认值 / 改行为是MAJORmax_steps 默认 8 → 4
给现有函数加必填参数是MAJORrun(model, registry, input, must)

第二行「改默认值」常被低估。把 max_steps 默认从 8 调到 4,签名一字未变,但依赖默认值的使用者行为全变了——这就是破坏性变更,尽管 git diff 只动了一个数字。同理,「修 bug」也可能破坏兼容:如果有人已经依赖了那个 bug 的行为,修它对他就是破坏。所以工程上的稳妥做法是:行为上的不兼容改动,即便不删名字,也当成 MAJOR 处理,或者至少给一个弃用过渡期。

那 0.x 阶段呢?SemVer 官方规定「0.x 期间任何东西都可能变」。minagent 现在是 0.17.0,属于这个阶段。实践里的惯例是:0.x 下,MINOR 位可以承载破坏性变更,PATCH 位保持兼容。也就是说 0.17.0 → 0.18.0 允许动公开面,0.17.0 → 0.17.1 不许。等公开面稳定到「一年不用大改」,再发 1.0.0,从那以后破坏性变更就必须走 MAJOR。

17.1.6 什么时候才允许破坏公开面

即便到了 1.0,破坏也不是绝对禁止,而是要走流程。三条可以破坏的正当理由:

理由说明
修一个被证明有害的设计比如某字段语义含混,长期误导使用者
安全或正确性缺陷不改会持续造成错误结果
收拢过度暴露的内部细节早期误把内部实现放进了公开面

三条之外的「我觉得这样更好看」,不构成破坏公开面的理由。真要破坏,标准动作是两段式:先发一个 MINOR 版本,把旧名字标成 DeprecationWarning(还能用,但会告警),并在文档里给出迁移路径;隔一个 MINOR 周期后,再在 MAJOR 里删掉。这样使用者在升级时有告警、有文档、有时间,而不是某天 pip install -U 之后全线崩。

弃用过渡在代码里的样子很简单——旧名字变成一个「转发到新名字、顺便告警」的薄壳:

import warnings


def run_legacy(model, registry, user_input, **kw):
    """已弃用:请改用 run(),将在 1.0.0 移除。"""
    warnings.warn(
        "run_legacy 已弃用,请改用 run();将在 1.0.0 移除",
        DeprecationWarning,
        stacklevel=2,
    )
    return run(model, registry, user_input, **kw)

这段壳子做三件事:还能用(转发给新实现)、会告警(DeprecationWarning,且 stacklevel=2 让告警指向调用者而不是库内部)、写清出路(文档字符串说明该改用什么、什么时候删)。使用者只要开着告警跑一次测试,就能收到「该迁移了」的信号。这就是「有过渡」和「直接删」的区别:前者给人时间,后者把人打懵。

minagent 作为教学库,暂时用不上这么重的流程,但这条纪律现在就该建立:公开面是承诺,改它要有理由、有过渡、有版本号背书。 这一章后面两节做的 HTTP 服务与 TS 客户端,都会把 minagent 当「稳定依赖」来用——正因为 17.1 把公开面钉住了,它们才敢这么做。

17.1.7 小结

  • 库与「一堆模块」的区别在于:使用者依赖名字而不是路径。
  • 公开面收敛在 __init__.py:所有对外名字从包顶层导出,内部文件布局变成私事。
  • __all__ 声明公开契约,_ 前缀标记内部实现;公开面 = 名字 + 签名 + 类型 + 文档。
  • 版本用 SemVer:0.x 下 MINOR 可含破坏性变更,PATCH 保持兼容;「改默认值 / 改行为」也算破坏。
  • 破坏公开面要两段式:先弃用告警,再在下一个 MAJOR 删除。
  • 稳定性靠断言守护:把公开面的行为写成 test_public_api.py,内部随便重构。

17.1.8 真实模型下会怎样

本节的所有讨论都与真假模型无关——__init__.py 导出什么名字、__all__ 里放哪些契约、版本怎么升,这些是「库的接口设计」,不因为后端换了个真实供应商就改变。但真实场景会给这套设计加两条约束:

其一,真实适配器也要走同一个公开面。 第 2 章那个真实供应商适配器(可选)实现的是同一个 Model 协议,from minagent import Model 拿到的就是它要满足的契约。这意味着「换后端」对使用者而言只是构造参数不同,公开面一字不变——这正是把 Model 放进 __all__ 的意义:扩展点本身也是契约。

其二,真实依赖会暴露版本策略的必要性。 一旦有下游项目 pip install minagent==0.17.0,你的每一次破坏性变更都直接砸在他们头上。假模型时代没有外部依赖,版本号可以随便跳;有了真实使用者,0.x 期间也得开始认真对待「MINOR 才允许破坏」这条线。库的成熟度,是从「有人依赖」那一刻开始被逼出来的。

一句话:接口设计与模型无关,但是否认真对待接口设计,取决于有没有人真的在依赖你。这一节把公开面钉下来,正是为了让第 18 章那个端到端项目能安稳地站在 minagent 之上。

把这一节和前 16 章对照,会发现一个有意思的转折:前 16 章我们一直在往 minagent 里加东西——加类型、加工具、加记忆、加编排;这一节第一次往回收,把「哪些是给外人看的」单独拎出来。加是能力,收是纪律。一个能长期用下去的库,两者缺一不可:只加不收,公开面会膨胀成一团乱麻,没人敢动;只收不加,功能又长不出来。先有能力,再划边界,这正是把「打包与 API」放在全卷倒数第二章的原因。

关注点前 16 章本节之后
代码形态一堆可导入的模块一个有门面的包
使用者导入按文件路径按包顶层名字
内部重构可能碎掉使用者代码与使用者无关
版本号没有__version__ + SemVer
契约守护无test_public_api.py 断言

到这里,minagent 有了稳定的公开面,能当一个正经的库被依赖了。下一节 17.2 HTTP 服务化 会把它跨出进程边界,暴露成 HTTP 服务。想回看这一路的评测收尾,翻 16.3 对比实验 。

阅读导航:上一节:16.3 对比实验 · 下一节:17.2 HTTP 服务化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「agent」更多文章

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