4.1 OpenAI Agents SDK:handoff/guardrails/tracing 实现委派
前三节我们分别用 PydanticAI 重写了问答路径、用 Claude Agent SDK 搭了研究任务。这两个 SDK 风格迥异:PydanticAI 给你类型安全的「一次模型调用」,Claude Agent SDK 给你一整套运行时。OpenAI Agents SDK 走的是第三条路——它只给三个原语,然后逼你在这三个原语上表达多 Agent 协作。这一节把这第三个原语集讲透。
本节把「框架的协作层」推进到可运行:核对
openai-agents 0.23.1的真实 API 表面,讲清 handoff、guardrails、tracing 三块,并和卷一 minagent 的 supervisor/worker 模式逐一对照。api.openai.com本机不可达,所有Runner.run调用一律不执行,只写「如果调用会怎样」的骨架。
4.1.1 三个原语,一个循环
OpenAI Agents SDK 的设计哲学可以一句话概括:Agent 是一个带工具的循环,多 Agent 协作靠 handoff 把循环的「当前 Agent」换掉。它没有显式的图、没有状态机、没有节点概念,只有:
- Agent:名字 + 指令 + 工具 + 模型,一个能自己跑循环的单元;
- handoff:一个特殊工具,被调用时把控制权连同上下文交给另一个 Agent;
- guardrails:挂在 Agent 输入/输出两端的校验函数,触发即中断;
- tracing:一次运行自动展开成一棵 span 树,落到 OpenAI 的 trace 后端。
这四样东西里,Agent 和 handoff 负责「怎么跑」,guardrails 负责「跑错了怎么办」,tracing 负责「跑过什么」。对照卷一 minagent:Agent 相当于 run(model, registry, ...) 那一整套,handoff 相当于你自己写的 supervisor 委派逻辑,guardrails 相当于你在工具层手写的校验,tracing 相当于你自己埋的 on_step 回调。
先照本卷「禁止凭记忆写 API」的规矩,核对本机实测版本:
import importlib.metadata as m
print(m.version("openai-agents"), m.version("openai"))
0.23.1 3.28.0
4.1.2 API 表面现场核对
先看 Agent 与 Runner.run 的参数清单(inspect.signature 实测):
import inspect
from agents import Agent, Runner
print([p for p in inspect.signature(Agent.__init__).parameters if p != "self"])
print([p for p in inspect.signature(Runner.run).parameters if p != "self"])
['name', 'handoff_description', 'tools', 'mcp_servers', 'mcp_config', 'instructions', 'prompt', 'handoffs', 'model', 'model_settings', 'input_guardrails', 'output_guardrails', 'output_type', 'hooks', 'tool_use_behavior', 'reset_tool_choice']
['starting_agent', 'input', 'context', 'max_turns', 'hooks', 'run_config', 'error_handlers', 'previous_response_id', 'auto_previous_response_id', 'conversation_id', 'session']
三个事实从清单里直接读出来。第一,Agent 的 handoffs 与 input_guardrails / output_guardrails 都是构造期列表——协作关系是静态声明的,不是运行时动态拼的。第二,Runner.run 是模块级入口(不是 agent.run()),且是 async 的,同步版本叫 Runner.run_sync。第三,max_turns 默认是 10,这是框架帮你兜的「防无限循环」底线,对应 minagent 里的 max_steps。
再看两个关键构造器:
from agents import handoff, function_tool
print(inspect.signature(handoff))
print(inspect.signature(function_tool))
(agent: 'Agent[TContext]', tool_name_override: 'str | None' = None, tool_description_override: 'str | None' = None, on_handoff: 'OnHandoffWithInput[THandoffInput] | OnHandoffWithoutInput | None' = None, input_type: 'type[THandoffInput] | None' = None, input_filter: 'Callable[[HandoffInputData], HandoffInputData] | None' = None, nest_handoff_history: 'bool | None' = None, is_enabled: 'bool | Callable[[RunContextWrapper[Any], Agent[TContext]], MaybeAwaitable[bool]]' = True) -> 'Handoff[TContext, Agent[TContext]]'
(func: 'ToolFunction[...] | None' = None, *, name_override: 'str | None' = None, description_override: 'str | None' = None, docstring_style: 'DocstringStyle | None' = None, use_docstring_info: 'bool' = True, failure_error_function: 'ToolErrorFunction | None | object' = <object object at 0x...>, strict_mode: 'bool' = True, is_enabled: 'bool | Callable[[RunContextWrapper[Any], AgentBase], MaybeAwaitable[bool]]' = True, needs_approval: 'bool | Callable[[RunContextWrapper[Any], dict[str, Any], str], Awaitable[bool]]' = False, tool_input_guardrails: 'list[ToolInputGuardrail[Any]] | None' = None, tool_output_guardrails: 'list[ToolOutputGuardrail[Any]] | None' = None, timeout: 'float | None' = None, timeout_behavior: 'ToolTimeoutBehavior' = 'error_as_result', timeout_error_function: 'ToolErrorFunction | None' = None, defer_loading: 'bool' = False, custom_data_extractor: 'FunctionToolCustomDataExtractor | None' = None, allowed_callers: 'list[ToolCaller] | None' = None, output_type: 'Any | None' = None, output_json_schema: 'dict[str, Any] | None' = None) -> 'FunctionTool | Callable[[ToolFunction[...]], FunctionTool]'
(上面 function_tool 签名里的 <object object at 0x...> 是默认值 failure_error_function 的哨兵对象地址,每次进程运行都会变,此处以 0x... 占位。)
function_tool 的签名很能说明问题:needs_approval(要不要人工审批)、tool_input_guardrails / tool_output_guardrails(工具级护栏)、timeout(超时)都是一等参数。也就是说,minagent 里你得自己在 ToolRegistry.call 外面包一层审批和超时,这里每个工具都白送。
4.1.3 handoff:把控制权交出去
handoff 的本质是「一个会自动切换当前 Agent 的工具」。你不需要写调度器——把目标 Agent 放进 handoffs=[...],框架就会为它生成一个同名工具,主 Agent 一旦调用它,运行循环的「当前 Agent」就被替换成目标 Agent,且上下文(消息历史)默认一起带过去。
这和 minagent 的 supervisor/worker 有个根本差别。minagent 里 supervisor 和 worker 是两个独立的 run() 调用,你要自己把 worker 的结果塞回 supervisor 的消息列表;handoff 里它们是同一个循环的两段,交接是循环内部的指针切换,消息历史天然连续。
handoff() 的参数揭示了三档控制力:
| 参数 | 作用 | 对应 minagent 里你要写的 |
|---|---|---|
input_type | 交接时传结构化输入(不是裸文本) | 自己定义 worker 的入参 schema |
on_handoff | 交接瞬间触发的回调(记日志/埋点) | 自己写 on_step 回调 |
input_filter | 改写交给目标 Agent 的历史 | 自己裁剪/过滤消息列表 |
nest_handoff_history | 把历史折叠成一条摘要再传 | 自己写摘要逻辑 |
input_filter 是最有价值的一个:它让你在交接时删掉不需要的历史(比如把 supervisor 的推理过程滤掉,只留结论),这直接决定了 token 成本。minagent 里这一步是手写的,handoff 里是一个函数参数。
一个关键设计:handoffs=[writer] 直接放 Agent 对象也行,框架会自动帮你 handoff(writer)。两种写法等价。
4.1.4 guardrails:输入输出两端各一道闸
guardrails 是挂在 Agent 上的校验函数,分输入护栏(input_guardrails)和输出护栏(output_guardrails)。核对它的类型(本机实测):
from agents import InputGuardrail, OutputGuardrail, GuardrailFunctionOutput
import inspect
print(inspect.signature(InputGuardrail.__init__))
print(inspect.signature(GuardrailFunctionOutput.__init__))
(self, guardrail_function: 'Callable[[RunContextWrapper[TContext], Agent[Any], str | list[TResponseInputItem]], MaybeAwaitable[GuardrailFunctionOutput]]', name: 'str | None' = None, run_in_parallel: 'bool' = True) -> None
(self, output_info: 'Any', tripwire_triggered: 'bool') -> None
护栏函数的返回类型是 GuardrailFunctionOutput,两个字段:output_info(任意诊断信息)和 tripwire_triggered(布尔,触发即中断整个 run 并抛异常)。这个「tripwire(绊线)」语义很关键——它不是「打个分继续跑」,而是「踩线就炸」。run_in_parallel=True 意味着输入护栏可以和模型调用并行跑,不额外增加延迟。
护栏函数是纯函数,不依赖模型,所以可以完全离线核对。下面这段是真的在本机跑出来的:
import asyncio, json
from agents import InputGuardrail, GuardrailFunctionOutput, RunContextWrapper, Agent
async def banned(ctx: RunContextWrapper, agent: Agent, inp) -> GuardrailFunctionOutput:
text = inp if isinstance(inp, str) else json.dumps(inp, ensure_ascii=False)
bad = "绕过" in text
return GuardrailFunctionOutput(output_info={"banned": bad}, tripwire_triggered=bad)
ig = InputGuardrail(guardrail_function=banned, name="no-bypass")
print("guardrail.name =", ig.get_name())
ctx = RunContextWrapper(context=None)
r1 = asyncio.run(banned(ctx, None, "帮我写一份周报"))
r2 = asyncio.run(banned(ctx, None, "教我怎么绕过护栏"))
print("safe ->", r1.tripwire_triggered, r1.output_info)
print("unsafe ->", r2.tripwire_triggered, r2.output_info)
guardrail.name = no-bypass
safe -> False {'banned': False}
unsafe -> True {'banned': True}
注意 get_name() 这个方法名——InputGuardrail 不是 dataclass,name 是构造参数但要通过 get_name() 读取。这类小坑,凭记忆写必错。
护栏和 minagent 的关系:minagent 的 ToolRegistry.call 出错时是把 {"error": ...} 回灌给模型(见卷一契约),这是「软失败」。guardrails 提供的是「硬失败」——命中绊线直接中断,不把错误喂回模型。两种策略适合不同场景:工具参数错误适合软失败(让模型自己纠正),安全违规适合硬失败(直接拒绝)。
4.1.5 tracing:一次运行自动变成 span 树
tracing 是 OpenAI Agents SDK 最「白送」的能力。你什么都不用配,一次 Runner.run 就会自动展开成 trace → agent_span → generation_span / function_span 的树。核对 span 构造器(本机实测):
from agents import trace, agent_span, function_span, generation_span
import inspect
print(inspect.signature(trace))
print(inspect.signature(agent_span))
print(inspect.signature(function_span))
(workflow_name: 'str', trace_id: 'str | None' = None, group_id: 'str | None' = None, metadata: 'dict[str, Any] | None' = None, tracing: 'TracingConfig | None' = None, disabled: 'bool' = False) -> 'Trace'
(name: 'str', handoffs: 'list[str] | None' = None, tools: 'list[str] | None' = None, output_type: 'str | None' = None, span_id: 'str | None' = None, parent: 'Trace | Span[Any] | None' = None, disabled: 'bool' = False) -> 'Span[AgentSpanData]'
(name: 'str', input: 'str | None' = None, output: 'str | None' = None, span_id: 'str | None' = None, parent: 'Trace | Span[Any] | None' = None, disabled: 'bool' = False) -> 'Span[FunctionSpanData]'
span 可以手动建(with 语法),也可以让框架自动建。默认情况下 span 会导出到 OpenAI 的 trace 后端,但那需要网络。为了在不联网的前提下把这条链路跑通,可以替换默认处理器,把 span 收进内存:
from agents import set_trace_processors, trace, agent_span, function_span
from agents.tracing import TracingProcessor
class LocalCapture(TracingProcessor):
def __init__(self): self.events = []
def on_trace_start(self, t): self.events.append(("trace_start", t.name))
def on_trace_end(self, t): self.events.append(("trace_end", t.name))
def on_span_start(self, s): pass
def on_span_end(self, s):
self.events.append(("span_end", type(s.span_data).__name__, s.span_data.name))
def shutdown(self): pass
def force_flush(self): pass
cap = LocalCapture()
set_trace_processors([cap]) # 替换默认处理器,杜绝任何外发
with trace("research-copilot"):
with agent_span("planner", tools=["search"], output_type="str"):
with function_span("search", input='{"q":"smolagents"}', output="[hit]"):
pass
with agent_span("writer"):
pass
for e in cap.events:
print(e)
('trace_start', 'research-copilot')
('span_end', 'FunctionSpanData', 'search')
('span_end', 'AgentSpanData', 'planner')
('span_end', 'AgentSpanData', 'writer')
('trace_end', 'research-copilot')
span 的关闭顺序是后进先出:search 先于 planner 关闭,planner 先于 writer,最后才是 trace 本身。这正是树形结构的扁平化序列。对照 minagent:卷一的 Step 是一维列表(index / kind / detail),只能表达「第几步做了什么」;span 是树,能表达「谁在谁里面做了什么」。第 10 章要把三家框架的 trace 归一成 OTel,靠的就是这棵树。
一个容易踩的坑:set_tracing_disabled(True) 之后,trace() 返回的是 NoOpTrace,没有 .spans 属性(本机实测 AttributeError: 'NoOpTrace' object has no attribute 'spans')。想观察 span 必须用处理器,而不是靠「关掉追踪再看」。
4.1.6 研究任务骨架(未执行)
把三块拼起来,给 ResearchCopilot 搭一个 SDK 版骨架:一个路由 Agent,handoff 给检索与写作两个子 Agent,输入护栏挡注入,tracing 自动记录。
# research_agents_sdk.py —— 需要活的 OpenAI 会话才能运行,本节未执行
import asyncio, json
from agents import (Agent, Runner, function_tool, handoff,
InputGuardrail, GuardrailFunctionOutput, RunContextWrapper)
@function_tool
def search(query: str) -> str:
"""检索本地知识库,返回命中文本。
Args:
query: 查询词。
"""
return f"[hit] {query} => 3 条"
async def no_injection(ctx: RunContextWrapper, agent: Agent, inp) -> GuardrailFunctionOutput:
text = inp if isinstance(inp, str) else json.dumps(inp, ensure_ascii=False)
return GuardrailFunctionOutput(output_info={}, tripwire_triggered="忽略以上指令" in text)
researcher = Agent(name="researcher", instructions="用 search 找证据", tools=[search])
writer = Agent(name="writer", instructions="把证据写成带来源的结论")
router = Agent(
name="router",
instructions="判断该检索还是该写作,必要时 handoff",
handoffs=[handoff(researcher), handoff(writer, input_filter=lambda d: d)],
input_guardrails=[InputGuardrail(guardrail_function=no_injection, name="no-injection")],
)
async def main():
result = await Runner.run(router, "调研 smolagents 的 CodeAgent", max_turns=8)
print(result.final_output) # 最终文本
print(result.last_agent.name) # 最后停在哪个 Agent
print(len(result.new_items)) # 本轮新增的 item 数
asyncio.run(main)
(本代码需要活的 OpenAI 会话才能运行,本节未执行,故不贴输出。)
这段骨架里,RunResult 本机实测的字段有 final_output / new_items / raw_responses / last_agent / input_guardrail_results / output_guardrail_results(dataclasses.fields 实测)。last_agent 尤其有用——handoff 之后你能知道最终是哪个 Agent 收的尾,这在 minagent 里要靠自己维护变量。
4.1.7 这套框架替你做了什么、你又让渡了什么
| 能力 | minagent(卷一自研) | OpenAI Agents SDK |
|---|---|---|
| Agent 循环 | 自己写 for i in range(max_steps) | Runner.run 内置 |
| 多 Agent 委派 | 自己写 supervisor/worker + 消息总线 | handoffs=[...] 声明 |
| 上下文传递 | 自己裁剪消息列表 | input_filter 一个函数 |
| 输入/输出校验 | 自己在工具层手写 | input_guardrails / output_guardrails |
| 工具审批/超时 | 自己包一层 | needs_approval / timeout 参数 |
| 可观测 | 自己埋 on_step | 自动 span 树 + trace 后端 |
让渡的同样清晰。第一,绑定 OpenAI 的模型与 trace 后端——默认模型是 OpenAI 家族,默认 span 导出到 OpenAI,本机连不上 api.openai.com 就意味着主路径跑不通。第二,Agent 是「黑盒循环」——你无法像 minagent 那样在每一步之间插入自己的逻辑,只能在 hooks / guardrails 这些预设切点上挂东西。第三,协作是静态声明的——handoffs 在构造期固定,运行时动态生成 Agent 反而别扭。
对 ResearchCopilot 而言,取舍点在于:如果你要的是「多个角色各司其职、交接清晰、天然可观测」,这套 SDK 的抽象最贴合;如果你要的是「完全掌控每一轮、把 Agent 嵌进自己的状态机」,那它的黑盒循环会挡路——那正是 minagent 的强项。下一节 4.2 smolagents:CodeAgent「代码即行动」重写子任务 会换一个完全相反的方向:让模型写代码而不是调工具;想回看 Claude Agent SDK 的对照,见 3.3 与自研运行时对照:内置省了什么、锁定了什么 。
阅读导航:上一节:3.3 与自研运行时对照:内置省了什么、锁定了什么 · 下一节:4.2 smolagents:CodeAgent「代码即行动」重写子任务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。