数据截至 (上游 commit f9be26e5e26d)
4. 输出侧:工具、MCP 桥接、Token
这章讲 agent 怎么和外部世界连:本地工具的标准形态、把远程 MCP 服务器“编译”成本地工具、以及 token 计数/裁剪与 hook。
4.1 BaseTool:和 agent 同构的工具接口
工具就是另一种“吃 schema、吐 schema”的东西。BaseTool[InputSchema, OutputSchema] 用的是和 AtomicAgent 一模一样的泛型截获 + 三级回退机制(base/base_tool.py:50-105),只多一个抽象方法 run(params) -> OutputSchema(base/base_tool.py:127-141)。
一个真实工具长这样(计算器,atomic-forge/tools/calculator/tool/calculator.py:54-91):输入 schema 描述“一个数学表达式”,run 用 sympy.sympify 求值,返回输出 schema。tool_name / tool_description 默认从输入 schema 的 JSON schema title/description 取,可被 BaseToolConfig 覆盖(base/base_tool.py:107-125)。
这种同构是“链式拼接”能成立的根因:agent 和工具的接口形状相同,所以一个的输出能当另一个的输入(回看 02-schema-and-chaining.md)。
4.2 MCPFactory:把远程 MCP 工具变成本地 BaseTool 子类
MCP(Model Context Protocol,模型上下文协议)服务器对外暴露工具/资源/提示,但都是 JSON schema 描述的“远程能力”。MCPFactory 的工作是:连上去、读定义、在运行时动态合成一批 BaseTool 子类,让远程工具用起来和本地工具毫无差别。
流程(以工具为例,connectors/mcp/mcp_factory.py:124-388):
MCP 服务器 MCPDefinitionService MCPFactory
│ list tools ──► fetch_*_definitions() ──► _create_tool_classes()
│ (name/description/ │
│ input_schema/output_schema) │
│ ├─ SchemaTransformer 把 JSON schema
│ │ → 真正的 Pydantic 输入/输出模型
│ ├─ 合成 async run + sync run 闭包
│ └─ types.new_class() 造出 BaseTool 子类
▼
生成的工具类:tool.run(params) 时才真正连服务器、call_tool、把结果塞回输出 schema
三个值得看的设计:
(1) JSON schema → Pydantic 模型的翻译器。 SchemaTransformer.create_model_from_schema 递归处理 $ref / oneOf / anyOf / 数组 / 嵌套对象,把远程 JSON schema 变成本地 Pydantic 类(connectors/mcp/schema_transformer.py:147-206)。输入 schema 会额外加一个 tool_name 字面量字段(Literal["该工具名"]),输出 schema 不加——因为输出时工具已经选定了(connectors/mcp/schema_transformer.py:189-195)。这个字面量字段是下面编排 Union 能区分的关键。
(2) 三种传输 + 两种会话模式。 工具被调用时,按 transport_type 选 STDIO / HTTP_STREAM / SSE 三种传输(connectors/mcp/mcp_factory.py:187-217);并支持“每次调用开新连接”(legacy)或复用构造时传入的常驻 client_session(connectors/mcp/mcp_factory.py:234-241)。HTTP 用尾斜杠 /mcp/ 规避重定向,代码里还附了对应的 python-sdk issue 链接(connectors/mcp/mcp_factory.py:200-206)。
(3) 结果抽取有优先级。 当工具带 typed output schema 时,从结果里抽结构化数据有一条明确的降级链(connectors/mcp/mcp_factory.py:242-327):
① tool_result.structuredContent(MCP 规范主路径)
② content[0].text 当 JSON 解析
③ content[0].data 字典
④ dict 里的 structuredContent / content 键
⑤ 直接当 dict 用
都失败 ⇒ 报错(typed schema 没有通用 result 字段,不能兜底)
没有 typed schema 的工具走老路:把结果塞进通用的 MCPToolOutputSchema(result=...)(connectors/mcp/mcp_factory.py:329-337)。
4.3 编排 schema:让一个 agent 从多个工具里选
有了一堆工具类,怎么让 agent “选一个工具并填参数”?create_orchestrator_schema 把所有工具的输入 schema 组成一个 Union,做成一个输出字段 tool_parameters(connectors/mcp/mcp_factory.py:390-466):
# 摘自 create_orchestrator_schema,connectors/mcp/mcp_factory.py:424-432(节选)
ToolUnion = Union[tuple(tool_schemas)] # 所有工具输入 schema 的联合
field_defs["tool_parameters"] = (ToolUnion, Field(..., description="..."))
模型被要求输出一个匹配某个工具 schema 的对象;因为每个工具输入 schema 带 tool_name 字面量字段(见 4.2),这个 Union 实际上是一个判别联合(discriminated union)——tool_name 的值就告诉你模型选了哪个工具。资源和提示同理,各占 resource_parameters / prompt_parameters 字段。
这就是“orchestration agent”的骨架:agent 的输出 schema = 工具集合的 Union,一次 LLM 调用同时完成“选工具 + 出参数”。
4.4 Token 计数与历史裁剪
max_context_tokens 设了之后,每次 run 前会 _trim_context()。它的 token 数从哪来?get_context_token_count() 把上下文按 Instructor 的真实方式序列化后交给 TokenCounter(agents/atomic_agent.py:481-564)。
精妙处:它连 schema/tools 的开销也算进去。TOOLS 模式下用 Instructor 真实生成的 tools 定义算 token;JSON 模式下把 schema 文本追加进系统消息再算(agents/atomic_agent.py:539-552)。TokenCounter 底层走 LiteLLM 的 token_counter,因此与厂商无关——OpenAI/Anthropic/Gemini 等 100+ 模型通吃(utils/token_counter.py:86-118)。
算 tools 开销的小技巧:有消息时用“整列消息 + tools”减去“整列消息无 tools”;仅在空消息(schema-only)场景退化为“占位消息 + tools”减去“占位消息无 tools”,差值就是 tools 净开销(utils/token_counter.py:210-223)。裁剪本身按 turn 整轮删最老的,直到总数达标;若单个 turn 就超限则直接报错让你调大预算(agents/atomic_agent.py:318-348)。