跳到主要内容

2. Schema 的原子哲学:为什么对齐 schema 就能拼

这章讲框架的世界观:把 agent/工具都降成“吃一个 schema、吐一个 schema”的函数。看懂 schema 怎么定义、怎么被框架取出,你就懂了“原子”二字。

2.1 它要解决的小问题

LLM 默认吐自由文本。要把它当软件组件用,你需要两件事:(a) 输出有确定结构,能被下一步直接消费;(b) 这个结构自带文档,好让模型知道每个字段是什么意思。Atomic Agents 用一个基类 BaseIOSchema 同时解决这两点。

2.2 BaseIOSchema:强制带文档的 Pydantic 模型

它就是一个 pydantic.BaseModel 子类,但加了三条规矩(base/base_io_schema.py:6-39):

  1. 必须有非空 docstring。 子类定义时若没写文档字符串,直接抛错(base/base_io_schema.py:22-30_validate_description)。
  2. docstring 会被注入进 JSON schema 的 descriptionmodel_json_schema 重写,base/base_io_schema.py:32-39
  3. __str__ 输出 model_dump_json(),方便直接塞进消息内容。

为什么强制 docstring?因为这段文档会原样进入发给模型的 schema description——它不是给人看的注释,而是 prompt 的一部分。这是一个把“写好文档”从美德变成硬约束的设计。

# 示意,非源码:一个合法的输出 schema 长这样
class QueryOutput(BaseIOSchema):
"""模型要生成的搜索查询集合。""" # ← 这句会进 schema.description,缺了就报错
queries: list[str] = Field(..., description="互不重复的搜索查询")

有一个豁免:Instructor 自己内部生成的、或带 from_streaming_response 的 schema 不受 docstring 检查约束(base/base_io_schema.py:27-29),避免框架卡住第三方生成的临时模型。

2.3 generic 类型参数:框架怎么知道 input/output schema 是谁

你写的是 AtomicAgent[InputSchema, OutputSchema]。框架要在运行时把这两个类型取出来当真实 schema 用。Python 现代 generic 语法下 __orig_class__ 不可靠,所以框架走了一条更稳的路:在子类创建时截获。

两种用法、三级回退。先看截获逻辑(agents/atomic_agent.py:165-181):

# __init_subclass__:子类定义时就把方括号里的两个类型存成类属性
for base in cls.__orig_bases__:
if get_origin(base) is AtomicAgent:
args = get_args(base)
if len(args) == 2:
cls._input_schema_cls = args[0] # 偷出 InputSchema
cls._output_schema_cls = args[1] # 偷出 OutputSchema

然后 input_schema / output_schema 属性按三级回退取值(agents/atomic_agent.py:233-275):

取 input_schema 时:
① 有 _input_schema_cls 吗(继承式:class MyAgent(AtomicAgent[A, B]))──► 用它
② 有 __orig_class__ 吗(直接实例化:AtomicAgent[A, B]())────────► get_args 取
③ 都没有(裸用,无类型信息)─────────────────────────────────► 回退默认 BasicChatInputSchema

怎么读这张回退链:从上到下是优先级,命中即停。 第①档服务“定义一个新 agent 类”;第②档服务“直接把 AtomicAgent[A,B]() 当实例用”;第③档兜底裸用。BaseTool 用的是完全相同的三级回退(base/base_tool.py:50-105)——这种一致性正是“原子”的体现:agent 和工具在类型机制上是一对孪生。

2.4 链式拼接:对齐 schema = 拼装管线

有了上面的机制,一个工具类的输入 schema 可以通过 Tool.input_schema 拿到,于是你能把它直接当作某个 agent 的输出 schema。这就是 README 里的招牌写法(README.md 的 Chaining 节):

# 示意,非源码:让 query agent 的“输出 schema”正好是搜索工具的“输入 schema”
query_agent = AtomicAgent[QueryAgentInputSchema, SearXNGSearchTool.input_schema](
AgentConfig(client=..., model="gpt-5-mini", system_prompt_generator=...)
)
queries = query_agent.run(QueryAgentInputSchema(instruction="...", num_queries=3))
results = SearXNGSearchTool().run(queries) # 上一步的输出,直接喂下一步——零胶水

精妙处: query_agent 的输出对象,就是 SearXNGSearchTool 的合法输入对象,因为它们是同一个 Pydantic 类。要换搜索引擎?把方括号里第二个类型换成 AnotherSearchTool.input_schema 即可。组合从“写适配代码”退化成了“写对类型”。

2.5 关键细节 / 坑

  • output_schema 不是字段,是从泛型取的。 想动态改输出结构,要换泛型参数或子类,而不是改一个属性(README 里 query_agent.config.output_schema = ... 是演示意图,真正生效路径是构造时的泛型)。
  • docstring 即 prompt。 改 schema 的 docstring 等于改提示词——这是优点也是坑:别在 docstring 里写无关的实现注释。
  • 空 docstring 直接炸。 这是 __pydantic_init_subclass__ 阶段抛的(base/base_io_schema.py:17-19),发生在“定义类”那一刻,不是运行时——能早暴露问题。

下一章:输入侧的三块状态——系统提示、上下文注入、历史。