版本口径:本文基于本地克隆 openai/openai-agents-python(pyproject.toml:3 声明版本 0.20.0;src/agents/version.py:4 通过 importlib.metadata 读取版本,未安装时回退 0.0.0)。文中所有 file:line 均相对 0-agent-framework/20260810-openai-agents/。


我第一次读 openai-agents 的源码时,有一种强烈的错位感。

pip install 之后,面向开发者的公共 API 小得可怜:Agent、Runner、Handoff、Guardrail、Session、Tracing——十几个类,一个文件就能数完。写一个多 agent 应用,核心代码通常不超过 30 行:声明几个 Agent,挂上 tools 和 handoffs,调一次 Runner.run。

但当我点开 src/agents/ 目录时,run.py 有 112KB,run_state.py 有 197KB,run_internal/ 下面躺着 turn_resolution.py(142KB)、tool_execution.py(104KB)、run_loop.py(99KB)……这个「轻量」框架的执行引擎,重量级得不像话。

这篇文章想讲清楚这层错位:API 的轻量是刻意设计,执行引擎的庞大是代价。我沿着三层结构读代码——Agent 声明(dataclass)、运行循环(Runner + NextStep 状态机)、多 agent 编排(Handoff + Agent-as-Tool)——最终得出一个中心论点:

openai-agents 把「多 agent 协作」收敛成了「工具调用」一种原语。Agent 是一个 dataclass,as_tool() 与 Handoff 共享同一工具抽象,LLM 统一通过工具调用做编排决策,循环由 NextStep 四态状态机驱动。API 层刻意保持轻量,但执行引擎与 OpenAI 协议深度耦合,跨 Provider 能力有限。

如果你正在用 LangGraph(图)、MetaGPT(角色)或自研框架做多 agent 编排,这篇文章也会给你一个对照坐标系:多个 agent 之间如何传递控制权与数据,是每个框架都要回答的同一个问题。

先看它长什么样

空谈无益,先放一个能跑通的多 agent 客服示例——全文后面所有的机制分析,都回到这段代码:

from dataclasses import dataclass
from agents import Agent, Runner, function_tool, handoff

@dataclass
class Ticket:
    """结构化输出:客服结论"""
    category: str
    resolution: str

@function_tool
def check_refund_eligibility(order_id: str) -> str:
    """查询订单是否可退款"""
    return f"order {order_id}: 已发货 3 天,符合 7 天无理由退款。"

billing_agent = Agent(name="billing", instructions="处理账单与支付问题。")
refund_agent = Agent(
    name="refund",
    instructions="处理退款问题,用 check_refund_eligibility 核实资格。",
    tools=[check_refund_eligibility],
    output_type=Ticket,
)

triage = Agent(
    name="triage",
    instructions="判断用户诉求,交接给 billing 或 refund;无法判断就自己回答。",
    handoffs=[handoff(billing_agent), handoff(refund_agent)],
)

result = await Runner.run(triage, "我买的东西要退款,订单号 abc123。")
print(result.final_output)  # Ticket(category='refund', resolution=...)

这段代码里已经出现了本文要讲的全部机制:Agent 是 dataclass(第 2 节)、Runner.run 启动状态机循环(第 3 节)、@function_tool 定义工具(第 4 节)、handoff 与 output_type(第 5 节)。你只写了 30 行,框架替你跑了一个「triage → refund → 工具核实 → 结构化输出」的完整工作流。轻量 API 背后是庞大的执行引擎,这句话从现在开始有了具体所指。

1. 定位与包结构:轻量 API 背后是什么

轻量 API 与重量级执行引擎的对照
轻量 API 与重量级执行引擎的对照

1.1 「lightweight yet powerful」在代码层面如何体现

README 用 lightweight yet powerful 自述定位,这个表述在代码里的落点是 src/agents/__init__.py:全部公共 API 从一个入口集中导出——Agent、Runner、Handoff、Guardrail、Session、Tracing,以及各类 Tool。面向使用者,你不需要知道 run_internal/ 里任何一个模块的名字。

这份导出清单本身是一份「能力宣言」:单 agent 执行(Runner)、多 agent 协作(Handoff、as_tool)、护栏(Guardrail)、会话(Session)、可观测(Tracing)、工具协议(FunctionTool、WebSearchTool 等)——所有你期望一个生产级 agent 框架拥有的东西,都收在同一个命名空间里。对比 LangGraph 需要从多个子包(langgraph.graph、langgraph.prebuilt、langgraph.checkpoint……)拼装,MetaGPT 需要理解 Role/Environment/Message 的框架内术语,openai-agents 的导入面是刻意压平的:竞品把编排语义暴露成显式概念——图、边、子图、环境、角色;它则把复杂性藏进实现,只给开发者留一个「声明 agent → 跑起来」的平面模型,*一行 from agents import 之外,你几乎不需要再学新名词**。

1.2 包结构:一张图看懂大小分布

打开 src/agents/,模块按职责分成几个梯队:

src/agents/
├── agent.py                 # 47KB —— Agent 声明(dataclass)
├── run.py                   # 112KB —— Runner / AgentRunner 入口
├── run_state.py             # 197KB —— RunState:整个 run 的状态载体
├── run_internal/            # 真正的执行引擎
│   ├── run_loop.py          # 99KB
│   ├── turn_resolution.py   # 142KB —— 把上一轮输出解析为下一步动作
│   ├── tool_execution.py    # 104KB —— 工具执行
│   ├── run_steps.py         # NextStep 状态机定义
│   ├── turn_preparation.py
│   └── session_persistence.py # 43KB
├── handoffs/                # Handoff 与历史映射
├── guardrail.py             # 护栏
├── items.py                 # 41KB —— RunItem 类型体系
├── models/                  # 模型适配(OpenAI Responses / ChatCompletions)
├── memory/                  # Session 协议与实现
├── tracing/                 # 遥测
├── mcp/  sandbox/  realtime/  voice/   # 扩展面

图 1|API 面(小)vs 执行引擎(大)

上层是 __init__.py 导出的十几个公共类;下层是 run_internal/ 里动辄 100KB+ 的执行模块,各自标注行数。

1.3 观察:轻量是 API 层的,重量级是执行引擎的

这个结构本身就是设计取舍的注脚。面向开发者的心智负担小,意味着框架内部必须替你做更多事:turn 解析、工具执行、护栏触发、会话持久化、遥测埋点——每一条链路都是一个 100KB 级模块的复杂度来源。

记住这张图,后面每一节都会往里填细节:第 2 节讲 API 层的 Agent 声明,第 3、4 节讲执行引擎的循环与工具,第 5 节讲编排,第 6 节讲扩展面。

2. Agent 声明:一切能力都是 dataclass 字段

Agent dataclass 的字段全景
Agent dataclass 的字段全景

2.1 声明即配置

Agent 的定义朴素得惊人(src/agents/agent.py:295-296):

@dataclass
class Agent(AgentBase, Generic[TContext]):

一个 dataclass。没有 Builder,没有 Config 类,没有 YAML DSL。你声明一个 agent 的方式,就是构造一个 dataclass 实例:

from agents import Agent

triage_agent = Agent(
    name="triage",
    instructions="你是前台。判断请求归属,交给 billing 或 refund。",
    handoffs=[billing_agent, refund_agent],
)

2.2 核心字段全景

从 agent.py:309 到 :397,Agent 的全部能力以字段形式配置化,可以分成四组:

行为(agent 怎么想)

  • instructions(:309):字符串或 Callable——动态函数可以在每次调用时根据 context 生成 system prompt。
  • prompt(:325):Prompt 对象,把 instructions/tools 的配置挪到代码之外动态下发,仅 OpenAI Responses API 可用。

能力(agent 能做什么)

  • tools(AgentBase 字段,本节未展开):可调用的工具列表——注意 tools 定义在基类 AgentBase 上,不在本段 agent.py:309-397 的范围内,但它是能力的核心。
  • handoffs(:331):可委托的子 agent 列表。
  • model(:337):字符串 / Model 对象 / None。None 时走默认模型——源码中写死为 gpt-5.6-luna(agent.py:341 注释明确写着 currently "gpt-5.6-luna")。注意这是代码现状,不代表任何模型路线判断。
  • model_settings(:344):temperature 等模型级调参。
  • output_type(:360):结构化输出 schema——传一个 dataclass / Pydantic / TypedDict,output_type=None 时输出就是 str。
  • tool_use_behavior(:373):默认 "run_llm_again"(工具跑完把结果送回 LLM),也可 "stop_on_first_tool"(第一次工具输出直接当最终结果,不再回送 LLM)。

护栏(agent 的边界)

  • input_guardrails(:350):首个 agent 生成响应前的并行检查。
  • output_guardrails(:355):最终输出产生后的检查。

生命周期

  • hooks(:369):各类生命周期回调。
  • reset_tool_choice(:395):工具调用后是否重置 tool choice 防死循环,默认 True。

图 2|一个 Agent 的字段全景图

按「行为 / 能力 / 护栏 / 输出」分组展示上述字段。

2.3 校验与复用:__post_init__ 和 clone

dataclass 字段只是声明,框架在 __post_init__(agent.py:432-546)里做类型校验与归一化——比如把工具、handoffs 整理成内部结构。这对使用者透明,但保证了「声明式」之外的防御性。

复用走 clone(**kwargs)(agent.py:548):浅拷贝出一个新 agent 再覆盖指定字段。想派生一个「同样工具、不同 instructions」的变体,一行搞定,不用重新组装。

2.4 把这些字段组合起来

上面每个字段单独看都平淡无奇,组合起来才是 Agent 的完整表达力。一个「动态指令 + 结构化输出 + 双护栏」的 agent:

from agents import Agent, InputGuardrail, output_guardrail

def dynamic_instructions(ctx, agent):
    """动态 instructions:按用户身份生成 system prompt"""
    return f"你是 {ctx.context['tier']} 用户的专属客服,语气必须专业克制。"

@output_guardrail
async def no_jargon(ctx, agent, output):
    """输出护栏:检测黑话,命中则抛 tripwire 中断"""
    ...

agent = Agent(
    name="support",
    instructions=dynamic_instructions,       # 行为:动态生成
    tools=[check_refund_eligibility],         # 能力
    output_type=Ticket,                       # 能力:结构化输出
    input_guardrails=[InputGuardrail(...)],   # 护栏:响应前检查
    output_guardrails=[no_jargon],            # 护栏:输出后检查
    model="gpt-5.6-luna",                     # 显式指定模型
)

注意 instructions 的动态函数签名(agent.py:311-314):它接收 RunContextWrapper 和 agent 自己,返回字符串。这意味着 system prompt 可以按运行时 context 定制——同一个 Agent 在不同会话里看到不同指令,声明一次、处处复用。这是 dataclass 字段 + 回调的组合拳:配置是声明式的,但关键行为点是可编程的。

2.5 预告:as_tool()

Agent.as_tool()(agent.py:576-598)把整个 agent 变成一个 FunctionTool,供其他 agent 当工具调用。这是全文的关键机制之一,第 5 节展开。这里先记住它的本质区别(agent.py:601-605 注释):

  1. handoff 时新 agent 接管对话历史;as_tool 时子 agent 收到的是生成的输入。
  2. handoff 时新 agent 接管对话;as_tool 时子 agent 只是被调用的工具,原 agent 继续对话。

3. 运行循环:Runner 与 NextStep 状态机

3.1 入口

一次 run 从 Runner.run(run.py:219)开始。它的 docstring 用四句话描述了循环语义(run.py:237-244):

  1. agent 接收输入被调用;
  2. 产生最终输出(符合 output_type)→ 循环终止;
  3. 产生 handoff → 换新 agent 再跑一轮;
  4. 否则执行工具调用,再跑一轮。

Runner.run 本身是薄壳,实际执行委托给模块级单例 DEFAULT_AGENT_RUNNER(run.py:287)。

3.2 NextStep 四态状态机

循环的语义被显式建模成四个 dataclass(run_internal/run_steps.py:155-181):

状态dataclass含义
终止NextStepFinalOutput产生最终输出,循环结束
再跑NextStepRunAgain工具执行完毕,把结果送回 LLM 继续
交接NextStepHandoff切换到新 agent 继续
中断NextStepInterruption工具需要审批,等待 approve/reject

每一步执行后,SingleStepResult.next_step(run_steps.py:199)携带这四态之一,驱动下一轮。

图 3|NextStep 状态机:

Runner 的 NextStep 四态状态机
Runner 的 NextStep 四态状态机

四个状态的归属模块:RunAgain 对应 turn_resolution.py(解析下一动作)+ tool_execution.py(执行工具);Handoff 由 handoff 工具触发(第 5 节);Interruption 由审批机制触发(第 4 节)。

3.3 一次 run 的完整时间线

把状态机和模块对上,一次 Runner.run(triage, "我要退款") 内部大致是这样推进的:

第 1 轮
  triage 被调用(turn_preparation 组装 system prompt + 历史)
  → LLM 调用 refund 交接工具(handoff 也是工具调用)
  → turn_resolution 识别为 handoff 工具调用
  → NextStep = Handoff(new_agent=refund_agent)
第 2 轮
  refund_agent 接管(带上经过 history mapper 处理的对话)
  → LLM 返回 tool_calls: check_refund_eligibility
  → tool_execution 执行工具,结果回填 RunItem
  → NextStep = RunAgain
第 3 轮
  refund_agent 再次被调用(工具结果已在历史里)
  → LLM 返回结构化输出(匹配 output_type=Ticket)
  → NextStep = FinalOutput → 循环终止

三条值得注意的语义:

  1. 一轮 = 一次 LLM 调用。max_turns(run.py:259-261)计的是 AI 调用次数(含伴随的工具调用),默认 DEFAULT_MAX_TURNS = 10(run_config.py:43),超出抛 MaxTurnsExceeded(run.py:247)。多 agent + 多工具的链式任务,turn 数涨得很快——设计 loop 时心里要有这个预算。
  2. 切换 agent 不是重开对话。handoff 后新 agent 看到的是处理过的历史(第 5 节),上下文是连续的。
  3. 工具结果是「历史的一部分」。RunAgain 之所以能继续,是因为工具输出项已被写回对话历史,下一轮 LLM 自然能看到——这也是为什么「工具 → 回填 → 再调 LLM」是循环的主干。

3.4 与 helloagents 的对照:循环语义的显式化

helloagents(教学框架)的循环是朴素的 while + max_tool_iterations:一个循环里「调用 LLM → 执行工具 → 再调用」,直到无工具调用为止。

openai-agents 把同样的语义显式建模为四态状态机。区别不在能力,而在词汇:「终止 / 再跑 / 交接 / 中断」是 agent loop 的通用词汇,一旦显式化,就能被复用、被扩展(比如 interruption 态天然支撑 HITL)、被测试。这是这篇文章里最值得迁移的抽象之一——写你自己的 agent loop 时,先定义你的「步态」。

4. 工具机制:@function_tool 与工具调用回填

4.1 定义一个工具

@function_tool 装饰器把任意普通函数变成工具(src/agents/tool.py):

from agents import function_tool

@function_tool
def get_weather(city: str) -> str:
    """查询城市天气"""
    return f"{city}: 晴, 24°C"

值得注意的默认值:FunctionTool.strict_json_schema: bool = True(tool.py:468-470)——工具参数 schema 默认开 strict mode,官方注释的理由是「能显著提高正确 JSON 输入的概率」。这个默认值的取向很 OpenAI:与其靠 prompt 碰运气,不如从 schema 层面提高工具调用的结构化成功率。

4.2 RunItem:工具调用如何回到循环

工具调用不是「裸消息」,而是被建模为 RunItem 类型体系(items.py,41KB):消息项、工具调用项、工具输出项、handoff 项……整个循环里流动的都是 RunItem,SingleStepResult 的 new_step_items(run_steps.py:196)就是本轮新增的 items。

全链路是(见第 3 节的状态机):

LLM 输出 tool_calls
  → turn_resolution.py 解析(把 tool_calls 变成工具执行计划)
  → tool_execution.py 执行(真正调你的函数)
  → 结果回填为 ToolOutputItem 进 RunItem 流
  → NextStep 判定:RunAgain(默认)→ 结果送回 LLM

图 4|一次工具调用的全链路

模型输出 tool_calls → turn_resolution 解析 → tool_execution 执行 → RunItem 回填 → NextStep 判定。

@function_tool、RunItem 与工具回填循环
@function_tool、RunItem 与工具回填循环

4.3 Context:工具不是孤立函数

Runner.run 的 context 参数(run.py:224)贯穿整个循环:工具、handoffs、guardrails、hooks 都能通过 RunContextWrapper[TContext] 访问它。一个常用模式是把用户/会话数据塞进 context,工具从中取,而不是靠参数传递:

def get_tier(ctx: RunContextWrapper[dict]) -> str:
    """工具签名里带 ctx,就能读到 context"""
    return ctx.context["tier"]

@function_tool
def suggest_tier_upgrade(ctx: RunContextWrapper[dict]) -> str:
    tier = ctx.context["tier"]
    return f"建议升级到 {'pro' if tier == 'free' else 'enterprise'}"

这是 dataclass 声明之外的另一个关键设计:工具是有状态的,状态由 context 注入。这也解释了为什么 Agent 是 Generic[TContext](agent.py:296)——agent、工具、护栏共享同一份运行时上下文,类型安全地。

4.4 HITL:needs_approval 与 RunState

工具字段 needs_approval(tool.py:486-493)为 True 时,本轮不执行工具,而是产生 NextStepInterruption(run_steps.py:170-181),把待审批的工具调用挂起。外部通过 RunState.approve() / RunState.reject()(run_state.py:623/634)恢复循环。

完整的恢复路径是:

Runner.run(...)                        # 第一次调用
  → 工具 needs_approval=True
  → NextStepInterruption 挂起,run 返回
调用方(你的 Web 层/队列)收到中断,向用户展示审批卡片
  → 用户点「批准」
调用方构造 RunState,调 state.approve(approval_item)   # 参数是 ToolApprovalItem,run_state.py:623
  → 再次 Runner.run(state) 续跑
  → 被批准的工具有条件地执行,循环继续

注意 RunState 本身就是 Runner.run 的合法输入(run.py:222 的 input 参数接受 RunState)——所以「中断 → 外部审批 → 续跑」不是 hack,而是把整个中间状态序列化之后重新喂给 Runner。approve 的参数是待审批项 ToolApprovalItem(run_state.py:623),批准后该工具才有条件地执行;reject 则否决并继续(run_state.py:634)。这也是为什么 HITL 能跨进程做:RunState 是可持久化的(run_state.py 197KB 不是白写的)。

这个设计把 human-in-the-loop 做成框架原语而不是业务 hack:审批是循环的一个合法状态,而不是「跑了一半抛异常」。对比很多框架把 HITL 做成外部 while 包装,openai-agents 的做法更干净。

4.5 对照 helloagents:从注册表到编排原语

helloagents 的工具执行走 ToolRegistry.execute_tool(name, input_text)(20260810-helloagents-framework/hello_agents/tools/registry.py:132)——注册表模式,职责对应,但工具只是「被调用的函数」。openai-agents 把工具调用提升为编排层语义:handoff 是工具、子 agent 是工具(第 5 节),LLM 的所有「决策动作」都通过同一个 tool_calls 通道表达。这是它与教学框架的本质分野。

5. Handoff 与 Agent-as-Tool:多 agent 编排的两种姿势

5.1 Handoff 也是一个工具

Handoff 是一个 dataclass(handoffs/__init__.py:125-126),字段包括 tool_name、tool_description、input_json_schema、on_invoke_handoff(:134-153)——没错,它长得很像一个工具。事实正是如此:handoff 以工具形式暴露给 LLM,LLM 通过一次工具调用发起交接。

工厂函数 handoff() 是标准用法:

from agents import handoff

billing_agent = Agent(name="billing", instructions="处理账单问题")
refund_agent = Agent(name="refund", instructions="处理退款问题")

triage_agent = Agent(
    name="triage",
    instructions="根据用户诉求决定交接目标。",
    handoffs=[handoff(billing_agent), handoff(refund_agent)],
)

执行时:LLM 发起 billing 工具调用 → handoff 解析分支(run_internal/turn_resolution.py)把控制权交给 billing_agent → 新 agent 带上对话历史继续 → NextStepHandoff 驱动下一轮。交接时还可以通过 input_filter / HandoffHistoryMapper 做历史映射(handoffs/history.py;handoffs/__init__.py:158-172)——比如只把「最近的 N 条 + 摘要」传给新 agent,而不是全量历史。

与工具一致,handoff 参数也默认 strict_json_schema: bool = True(handoffs/__init__.py:181-183),同一个「提高结构化调用成功率」的取向。

5.2 as_tool:把 agent 当工具调用

Agent.as_tool()(agent.py:576-605)把 agent 包成一个 FunctionTool,调用方(原 agent)在工具返回后继续主导对话:

researcher = Agent(name="researcher", instructions="做资料调研,返回要点。")
writer = Agent(
    name="writer",
    instructions="基于调研结果写文章。",
    tools=[researcher.as_tool(tool_name="do_research", tool_description="调研指定主题")],
)

运行时:writer 调用 do_research → 框架在内部起一个嵌套 run 跑 researcher(可带独立 max_turns、run_config,agent.py:586-597)→ researcher 的输出提取成工具返回值 → writer 拿到结果继续。对 writer 而言,researcher 就是一个会「想很久」的工具。

5.3 handoff 的运行时细节:历史映射

回到客服例子,handoff(refund_agent) 执行时发生三件事(handoffs/__init__.py:125-172):

  1. LLM 发起 refund 工具调用,参数是 on_invoke_handoff 定义的 JSON payload(:148-153);
  2. 触发 NextStepHandoff(run_steps.py:155-157),turn_resolution 的 handoff 分支接管;
  3. 新 agent 收到历史——默认全量,但可以通过 HandoffHistoryMapper 换成「摘要 + 最近 N 条」之类的压缩形式(handoffs/history.py)。

第三个细节容易被忽略,但它决定了长对话场景的内存与 token 成本。input_filter(handoffs/__init__.py:158-172)是另一个闸口:你可以在交接时剔除敏感工具、截断旧消息。交接不是简单的指针切换,它是一道可编程的数据变换——这正是它被设计成 dataclass 而不是内置魔法的原因。

5.4 两种姿势的取舍

维度handoffas_tool
控制权转移给新 agent原 agent 继续
新 agent 的输入对话历史(可映射)生成的工具参数
适用场景专业分工:客服分流、专家接力子任务委托:调研、计算、检索后回到主线
类比电话转接外包给供应商

图 5|handoff vs as_tool 对比图

左 handoff——控制权箭头从 triage 指向 billing,billing 接续对话;右 as_tool——writer 调用 do_research 工具,researcher 返回结果,箭头回到 writer。

Handoff 与 Agent-as-Tool 的控制权和数据传递对照
Handoff 与 Agent-as-Tool 的控制权和数据传递对照

判断准则(作者经验):如果子任务完成后还得回到主线继续,用 as_tool;如果任务是「一类完整会话」(账单、退款、售后),用 handoff——它天然适合把长期对话按域切分。

5.5 系列对照:四种多 agent 编排哲学

「多个 agent 之间如何传递控制权与数据」,openai-agents 的答案是工具调用。对照另三个框架:

  • MetaGPT:用环境消息总线广播——角色向环境 publish_message(20260810-metagpt/metagpt/environment/base_env.py:175,docstring :126-127 定义了「角色可向环境发布消息、可被其他角色观察」),松耦合但控制流隐式。
  • LangGraph:用图结构 + Send 原语(20260810-langgraph/libs/langgraph/langgraph/types.py:664)做显式分支与并行,控制流显式但概念多(图、节点、边、状态、checkpointer)。
  • helloagents:教学级 while 循环,控制流最简单,能力也最受限。

openai-agents 选择了「最少概念」的中间路线:不给图、不给环境,把编排决策全部交给 LLM 的工具调用。代价是控制流不可静态预览——这是它和 LangGraph 最本质的分野。

6. 会话、Guardrails 与 Tracing:框架提供的周边能力

Session、Guardrails 与 Tracing 的位置
Session、Guardrails 与 Tracing 的位置

这三块能力决定了一个多 agent 应用能不能「出实验室」。大纲把这节列为周边,但读代码后要修正一点:它们不是可选插件,而是框架一等公民。

6.1 会话与记忆:Session 只是对话历史

memory/session.py:15-21 定义 Session 协议:session_id + get_items + add_items。实现有 SQLiteSession(本地持久化)和 OpenAIConversationsSession(服务端会话),另有 OpenAIResponsesCompactionSession 做上下文压缩。

用法很直白:Runner.run(agent, input, session=SQLiteSession(...)),循环自动把每一步产生的 RunItem 写入 session(run.py:639 的 session_persistence_enabled 分支),下次 run 再取回。对多轮产品(客服、聊天应用),这意味着多轮历史管理是框架替你做掉的,你不用自己维护消息数组。

两个关键事实:

  1. Session 不是长期记忆。协议只有 get_items/add_items 两个方法,存的就是对话历史(session.py:19-20 注释:stores conversation history)。没有向量库、没有知识注入——想要 RAG,得自己叠 memory/ 之外的东西。也就是说,memory/ 这个目录名有点误导:它管的是「短期对话记忆」,不是「长期事实记忆」。
  2. 服务端会话仅 OpenAI 可用。conversation_id / previous_response_id 把历史交给 OpenAI 服务端管理(run.py:626-638 的 OpenAIServerConversationTracker)。run.py:276-278 注释直接警告:建议只在纯用 OpenAI 模型时启用,其他 provider 不写 Conversation 对象,会留下「半截会话」。

6.2 Guardrails:进出都有闸口

input_guardrails / output_guardrails 挂在 Agent 字段上(agent.py:350/355),另有工具级 tool_guardrails.py。每个 guardrail 包一层 guardrail_span 参与遥测。值得注意的语义(agent.py:352/357 注释):input guardrail 只在链上第一个 agent 跑,output guardrail 只在产生最终输出时跑——guardrail 是「边界检查」,不是每轮检查。

一个 output guardrail 的形态(沿用引言示例的 Ticket 输出;hit 时抛 tripwire 中断整个 run):

from agents import GuardrailFunctionOutput, output_guardrail

@output_guardrail
async def forbid_pii(ctx, agent, output: Ticket):
    if "身份证" in output.resolution:
        return GuardrailFunctionOutput(
            output_info=output,
            tripwire_triggered=True,   # 触发即抛 OutputGuardrailTripwireTriggered
        )
    return GuardrailFunctionOutput(output_info=output, tripwire_triggered=False)

这段代码背后是第 3 节的状态机:guardrail 命中不是一个 if 分支,而是一个异常路径(run.py:248-249 注释里 InputGuardrailTripwireTriggered / OutputGuardrailTripwireTriggered)。也就是说,护栏与 HITL、final output 一样,都是循环语义的一等成员,而不是包在循环外面的装饰。

6.3 Tracing:自动埋点

tracing/ 提供 Trace/Span 层级(docs/tracing.md:23-43),group_id 把一次多 agent 会话的多个 run 串起来(tracing/create.py:48-58),TracingProcessor 可插拔(默认打到 OpenAI 平台,可换成自己的后端)。

group_id 是产品层最值得用的一个参数:一次用户请求可能触发多个 run(triage 一次、handoff 后 refund 一次、as_tool 嵌套又几次),没有 group_id 你在 trace 面板里看到的是散落的 run;带上它,整个请求的 trace 树一目了然:

from agents import RunConfig

result = await Runner.run(
    triage, "我要退款",
    run_config=RunConfig(group_id=f"user-session-{session_id}"),  # 关联整条链路
)

重点是「自动」:开发者不写一行埋点代码,Runner.run 全程自动埋 Trace/Span。对比 helloagents 的 TraceLogger(20260810-helloagents-framework/observability/trace_logger.py)需要手动调 log,openai-agents 把遥测做到了零侵入级别——这背后是执行引擎(第 3 节)里无处不有的 tracing 参数透传。代价是(作者判断):如果你不想让遥测出网,得自己实现并注册 TracingProcessor,而不是关掉开关那么简单。

7. 设计取舍、局限与可迁移经验

7.1 设计亮点(可迁移)

  1. 声明式 Agent:dataclass 字段即配置(agent.py:296-397)。无 Builder、无 DSL,schema 就是类型本身。
  2. 编排收敛为工具调用一种原语:as_tool + Handoff 共享同一工具抽象,LLM 统一通过 tool_calls 做决策。
  3. NextStep 四态状态机(run_steps.py:155-181):循环语义的干净抽象,HITL 是其中一个合法状态。
  4. 全链路自动遥测:零侵入 tracing。
  5. HITL 原生支持:RunState.approve/reject(run_state.py:623/634)是框架能力而非业务 hack。

7.2 局限

  1. 「轻量」只是 API 层。执行引擎庞大(run.py 112KB、run_state.py 197KB),且真正干活的 AgentRunner 标注 experimental、不属公共 API(run.py:506-510 警告 not part of the public API)——API 稳定不代表引擎稳定。
  2. 深度耦合 OpenAI 协议。models/interface.py:67-100 的 Model.get_response 直接用 OpenAI Responses 类型(TResponseInputItem)当输入/输出;服务端会话、compaction 仅 OpenAI 可用(run.py:274-278)。换 Provider 不是换一个 base_url 的事,是换一套类型协议。
  3. 无内置长期记忆。Session 只是对话历史(memory/session.py:15-21)。
  4. OpenAI 生态特判。默认模型写死 gpt-5.6-luna(agent.py:341),默认推理设置按 GPT-5 系列特判(models/default_models.py:16-48 的 _GPT_5_* 常量)。代码如实如此,选择哪个模型路线是 OpenAI 的事,不是本文的判断。

7.3 适用边界(作者判断,非框架承诺)

  • 很合适:官方模型栈 + 需要快速搭多 agent 应用(客服分流、专家协作、工具型 agent),要的是「半小时出活」和全链路可观测。
  • 不适合:需要跨 Provider(Azure 之外的厂商模型混用)、需要静态可控的控制流(LangGraph 的图更适合)、需要长期记忆 / 深度定制的 RAG。

7.4 四视角收束

「多 agent 编排」的四个框架视角:

框架编排抽象控制流可见性一句话
openai-agents工具调用(handoff / as_tool)隐式(LLM 决策)少概念,快出活
LangGraph图 + Send显式控制流可控,概念多
MetaGPT环境消息总线隐式角色广播,松耦合
llama_indexWorkflow(llama_index/core/workflow/)显式步骤面向 RAG 流水线
helloagentswhile 循环最简教学用途

这张表回答的还是同一个问题:多个 agent 之间如何传递控制权与数据。openai-agents 的答案是「把控制权变成工具调用的返回值」——LLM 每次 tool_calls 既是执行动作,也是编排决策;LangGraph 的答案是「把控制权画成图上的边」,状态转移由图结构保证,不依赖 LLM 是否碰巧选了正确的工具;MetaGPT 的答案是「把控制权放进环境」,谁都能发布、谁都能观察,协作由角色自主达成;llama_index 则更保守,Workflow 是显式的步骤编排,适合确定性流水线而非自由协作。

选型没有对错,只有权衡:你想要「LLM 自由发挥 + 框架托底」(openai-agents),还是「结构先行 + LLM 填内容」(LangGraph / llama_index)。前者起步快、上限高但控制流不可静态预览;后者可控、可测试,但要把每个分支都画出来。对于「先验证想法」的阶段,前者几乎总是更划算;对于「要交付给客户」的生产控制流,后者的静态可见性是实打实的资产。

图 6|「工具调用作为唯一编排原语」哲学图

LLM → 工具调用(FunctionTool / Handoff / Agent-as-Tool 三种)→ 执行层(turn_resolution / tool_execution),标注 NextStep 判定。作为收尾。

7.5 给自研框架的迁移清单

如果这篇文章对你有一点点行动价值,最实用的收获是这张清单——你在自己的 agent 框架里可以照搬的四条设计:

  1. 用 dataclass/配置对象表达 Agent,别用一堆散落的全局变量。字段分组(行为/能力/护栏/输出)本身就是文档。
  2. 把「控制权转移」建模为状态,而不是异常或回调。NextStep 四态(run_steps.py:155-181)值得原样抄一遍:FinalOutput / RunAgain / Handoff / Interruption,四态足以覆盖绝大多数 agent loop。
  3. 让工具调用成为唯一的执行通道。handoff、子 agent、普通函数都走 tool_calls——LLM 侧只有一种决策原语,解析侧只有一条执行管线。
  4. 遥测内建而不是外挂。在循环的每个状态迁移点埋 span(tracing 模块的 guardrail_span、run span 都是这么做的),比事后在业务代码里打 log 干净一个量级。

反过来,也要记住 openai-agents 的教训:「轻量 API + 重型引擎」是一笔有账可查的交易。当你选择框架时,算的不是 API 好不好看,而是引擎与你的技术栈(模型提供商、会话存储、观测后端)的耦合度——这正是它和 LangGraph 分道扬镳的地方。

结论

回到开头的错位感。openai-agents 的答案现在很清楚了:

它用「一个 dataclass」换来了开发者的低心智负担,用「一套工具调用原语」统一了多 agent 协作,用「NextStep 四态状态机」撑起整个执行循环。 API 的轻量是送给使用者的礼物,执行引擎的庞大是这礼物的账单,而 OpenAI 协议耦合是账单里最显眼的一项。

如果你从这篇文章只带走三句话:

  1. Agent 就是 dataclass,能力都是字段——先学会读 agent.py:296-397 的字段清单,胜过读十篇博客。
  2. handoff / as_tool / function_tool 是同一个东西的三种用法——多 agent 编排 = 让 LLM 做工具调用。
  3. NextStep 四态(run_steps.py:155-181)是 agent loop 的通用词汇——写自己的循环前,先定义自己的状态机。

最后提醒一句:以上所有 file:line 都来自工作区内的本地克隆,openai-agents 版本为 pyproject.toml:3 声明的 0.20.0。框架迭代很快,读代码永远比读文章新。


附录:证据清单

断言证据位置(相对 0-agent-framework/)
Agent dataclass 与字段src/agents/agent.py:296、:309-397
默认模型 gpt-5.6-lunasrc/agents/agent.py:341、src/agents/models/default_models.py:16-48
as_tool 与 handoff 区别src/agents/agent.py:576-605
Runner 入口与委托src/agents/run.py:219、:287
循环语义 docstringsrc/agents/run.py:237-244
NextStep 四态src/agents/run_internal/run_steps.py:155-181
工具 strict schemasrc/agents/tool.py:468-470
HITL approve/rejectsrc/agents/tool.py:486-493、src/agents/run_state.py:623/634
Handoff 即工具src/agents/handoffs/__init__.py:125-183
服务端会话仅 OpenAIsrc/agents/run.py:626-638、:274-278
Session 无长期记忆src/agents/memory/session.py:15-21
Tracing 层级docs/tracing.md:23-43、src/agents/tracing/create.py:48-58
AgentRunner experimentalsrc/agents/run.py:506-510
OpenAI 协议耦合src/agents/models/interface.py:67-100
版本读取方式src/agents/version.py:4、pyproject.toml:3
helloagents execute_tool20260810-helloagents-framework/hello_agents/tools/registry.py:132
MetaGPT 消息总线20260810-metagpt/metagpt/environment/base_env.py:126-127、:175
LangGraph Send20260810-langgraph/libs/langgraph/langgraph/types.py:664

写作说明

成文时 openai-agents 未在本机安装(importlib.metadata 读取失败、回退 0.0.0),版本号引用 pyproject.toml:3 声明的 0.20.0;若发布前需要运行期版本号,请 pip install openai-agents 后以 importlib.metadata.version("openai-agents") 实测为准。两处与大纲原始行号不一致的对照引用已按本地代码修正:MetaGPT 消息总线在 base_env.py:126-127/:175(非 :133),helloagents registry 完整路径含 hello_agents/ 前缀。