第四篇来了。前三篇我们算是把 OpenAI Agents SDK 的地基夯完了:从最基础的单 Agent 跑通,到 Function Tool 的接入,再到带状态的对话循环。但你用下来大概率会发现一个尴尬的事实——单 Agent 做个 Demo 还行,真要接进业务,光靠一个 Agent 什么都干,基本是给自己埋雷。一个 Agent 又要理解用户意图,又要动手查数据,又要做决策,指令稍微一复杂,模型就开始「左右互搏」,输出也飘。
所以这一篇我打算换个角度,不再教你怎么“加功能”,而是教你怎么“分工”。核心关键词只有两个——Handoffs(交接)和Guardrails(护栏),再带上Sessions(会话持久化)和Tracing(链路追踪)这两样配套。这四个东西组合在一起,才是 OpenAI Agents SDK 真正值钱的地方:让你从“写一个 Agent”升级到“编排一群 Agent,让它们各司其职”。这篇适合已经跑通前三篇示例、但正在为多场景复杂任务头疼的开发者,也适合想理解 Agent 架构怎么落地的人。
1. 先把编排的底层逻辑捋清楚
1.1 为什么第四篇才讲 Handoffs 和 Guardrails
先说个真实体会。前三篇你要是老老实实跟着做,其实你已经能拼出一个“能回答问题、能调用工具、能稍微记住上下文”的 Agent 了。但一旦放线上,你会发现两个非常普遍的问题。
第一个问题是Agent 不知道该把问题交给谁。比如你做一个客服机器人,里面涉及订单查询、退款申请、技术支持三类需求。你用一个大 Agent 全包,提示词写了两千字,把规则、话术、工具全塞进去。结果模型经常在“退款政策”和“技术报障”之间自己瞎猜,给出模棱两可的答案。最要命的是,当用户问“我要骂人了,你们怎么处理投诉” 这类非标问题,大 Agent 直接宕机。
第二个问题是没有任何保护机制。用户随手输入一句“我不活了你们赔我钱”,模型可能会顺着话头继续发挥,甚至编出一套根本不存在的赔偿方案。这不是模型“坏”,而是没有人在入口处做校验,也没有人在出口处做拦截。
Handoffs 和 Guardrails 就是冲着这两个问题来的。Handoffs 解决“分诊与转交”,Guardrails 解决“入口拦截与出口校验”。两者配合,你的 Agent 体系才不再是单个大杂烩,而是一套有边界、有流程的小团队。
1.2 Agent、Tool、Handoff 三者的协作关系
这里先把概念理清。OpenAI Agents SDK 里最基础的执行单元是Agent,它本身具备instructions(系统提示词)、tools(工具列表)、model(模型配置)这些属性。但你要理解,一个 Agent 不光能干活,它的能力还可以被另一个 Agent 直接“引用” —— 这就是 Handoff 的本质。
我更喜欢把这种关系类比成公司里的工单流转。用户提一个需求,先到前台(Triage Agent)那儿,前台看完了,觉得“这事归财务管”,就把整个对话上下文打包,转给财务 Agent。财务 Agent 继续读上下文,决定是自己处理,还是再转给法务。整个过程里,用户的每一句话、Agent 的每一步中间思考、已经调用的工具结果,都作为“上下文档案”被传递下去,不会断片。
在代码层级,Handoff 就是一个特殊的 Tool,但它不调用外部函数,而是“把控制权交给另一个 Agent”。所以你定义了一个 Handoff,本质上就是告诉当前 Agent:你碰到处理不了的情况时,可以主动申请交接,系统会带着完整会话上下文去启动另一个 Agent。
Guardrails 则是另一条独立链路。它在 Agent 真正运行之前(输入护栏)和运行结束之后(输出护栏)做检查。如果说 Handoffs 管的是“流程怎么走”,Guardrails 管的就是“能不能走、走了之后合不合规”。
2. 核心概念逐一拆解
2.1 Handoffs:让 Agent 学会“此事不归我管”
Handoffs 是 OpenAI Agents SDK 里最值得一提的机制。它在代码里就是一个普通的handoff()函数调用,但背后包含三个关键参数:agent(交接目标)、input_type(交接时附带的结构化数据类型)、on_handoff(交接时触发的回调函数)。
我在实际项目中经常这么写:
from agents import Agent, handoff refund_agent = Agent( name="RefundAgent", instructions="你负责处理所有退款相关的请求...", ) triage_agent = Agent( name="TriageAgent", instructions="你是前台客服,先判断用户意图。如果是退款问题,交接给 RefundAgent。", handoffs=[handoff(refund_agent)] )这里最关键的是handoff(refund_agent)这一句。它注册了一个交接目标,让 triage_agent 在认为必要的时候,主动发起转交。而 SDK 在处理这种 Handoff 时,会维护一个上下文延续链:转交出去的不仅是当前这句话,而是整个对话状态。
这里有个坑想提醒你——很多新手以为 handoffs 写得越多越好,把十几个 Agent 全挂在一个前台 Agent 下面。结果就是模型每轮都要从十几个交接目标里选一个,选择成本高,还经常选错。
我的建议是控制每个 Agent 的 handoffs 数量不超过 5 个,层级控制在两层以内。超过这个规模,就该考虑按业务域分组,中间再加一层“子前台”,而不是让一个 Agent 背上全公司的通讯录。
2.2 Guardrails:在入口和出口放两道闸
Guardrails 是容易被忽略、但线上最要紧的东西。SDK 里它本质上是一个函数,接收对话上下文和输入,返回一个GuardrailResult。结果里有个关键字段tripwire_triggered,一旦置为 True,这个 Agent 的执行会被立即终止,不再调用任何后续步骤。
入口护栏(Input Guardrail)最常见的用途是拦截违规内容、检测意图偏移、做情绪识别。比如客服场景里,用户如果输入“我要自杀你们公司得负责”这种极端内容,你肯定不希望模型接着分析退款逻辑,而是应该直接转人工并触发安抚流程。在 OpenAI Agents SDK 中,你可以用input_guardrail()装饰器包装一个异步检查函数,然后把函数对象传给Agent的guardrails参数。
出口护栏(Output Guardrail)则用来校验模型的最终输出。比如你限制模型“只能输出 JSON 格式的退款单”,模型却给你来了段自然语言,这时候出口护栏就能拦住,强制重试或走降级逻辑。
我之前做过一个生产事故复盘,客户要求 Agent 在退款时只能给出 5 元以下的补偿方案,结果模型“灵活发挥”,给出一个 100 元方案。这个事之后我立了个规矩:凡是涉及金额、权限、用户隐私的字段,出口护栏必须硬校验,宁可让 Agent 说“这个我处理不了”,也不能让它信口开河。
2.3 Sessions:把“记忆”从内存搬到存储
Sessions 解决的是多轮对话的记忆恢复问题。默认情况下,Agent 是无状态的:每次调用Runner.run()都是一次全新的独立运行。你想让用户在第二天回来继续聊,就得自己把历史消息存下来,再重新喂给模型。
OpenAI Agents SDK 提供了Session的概念来标准化这件事。你用Runner.run()时可以传入一个 session,SDK 会把该 session 里的历史消息自动带入上下文;你也可以把新产生的内容回写进 session,实现增量追加。
实际项目中我一般不直接用内存里的 session 对象,而是配合数据库自己做持久化。套路就是用户进入对话时,从库里拉出 session 元数据,重建 session,对话结束后,把新的消息数组保存回去。这个数据量不会太大,普通 mysql 或 redis 就够用,不需要上专门的向量库。
有个细节值得记一下:当 Agent 因为 Handoff 把控制权交接出去时,session 还是一整个,不会拆开。也就是说,用户从 triage 转到 refund,仍属于同一场对话,历史消息完整保留。这很符合直觉,但也意味着你得保证 session 里的数据别越积越多,长对话跑上几百轮之后,建议按轮次做摘要压缩,不然 token 成本会失控。
2.4 Tracing:性能问题一查便知
第四篇既然讲编排,Tracing 我建议你直接打开。当你有多个 Agent、多次 Handoff 时,一条用户请求可能会触发 5 到 10 次模型调用,中间任何一环变慢、变贵、出错,都很难从客户端日志里定位。SDK 内置了分布式追踪机制,会在每次Runner.run()里生成一个完整的 Trace,记录从输入到输出的每一步开销。
你在初始化 SDK 时如果启用了 tracing,就能在仪表盘里看到完整的调用瀑布流:哪一步调用了哪个 Agent、模型请求耗时多少、token 消耗多少、有没有被 Guardrail 拦截。排查“为什么这个请求特别慢”“哪个工具调用烧钱最多”这类问题,直接看 trace 比翻代码高效十倍。
3. 实操:从一个客服机器人开始
3.1 场景设定与整体思路
理论说再多,不如一个端到端的例子。我就拿最常见的“客服工单机器人”来演示。这个例子的业务规则是这样的:用户进来先分词——是退款的、是技术报障的、还是单纯骂人的;退款的需求交给退款 Agent,技术类问题交给技术支持 Agent;如果用户在输入里包含极端情绪词,入口护栏直接拦截,转人工处理;最后所有输出都要过一遍出口护栏,不允许模型给出超权限承诺。
整体链路画出来就是:
用户输入 -> 入口护栏 -> TriageAgent -> (Handoff) -> RefundAgent / TechSupportAgent -> 出口护栏 -> 返回这个结构不复杂,但足够把 Handoffs、Guardrails、Session 都串联起来,而且风格贴近真实业务,不是玩具 Demo。
3.2 编写 Tool 模块
为了让演示不空转,我们给退款 Agent 配一个模拟查订单的工具。真实的工具无非就是调数据库或第三方 API,原理一样,我这里用本地字典模拟,你替换成自己的实现即可。
from agents import function_tool # 模拟订单数据 ORDERS = { "1001": {"user_id": "u123", "amount": 299.0, "status": "paid"}, "1002": {"user_id": "u123", "amount": 89.0, "status": "refunding"}, } @function_tool def get_order_status(order_id: str) -> dict: """根据订单号查询订单状态,返回订单金额、状态等信息。""" order = ORDERS.get(order_id) if order is None: return {"error": "order not found"} return order @function_tool def initiate_refund(order_id: str, reason: str) -> dict: """提交退款申请,返回退款单号。""" if order_id not in ORDERS: return {"error": "order not found"} return {"refund_id": "R2024001", "status": "submitted", "order_id": order_id}这里的重点在于:工具函数的 docstring 要写得精确,因为模型是靠 docstring 来决定“什么时候调用这个工具、传什么参数的”。如果你写一句“查询订单”,模型可能把 order_id 传成用户名;如果你写成“根据订单号查询订单状态,参数为订单号字符串”,模型的误用率会明显下降。
3.3 编写 Guardrail 模块
护栏这里我写两道,一道拦入口,一道卡出口。
入口护栏负责检测极端情绪词和伤害性表达。这里没有用复杂的情绪分析模型,用了最简单也最可控的关键词检测。真实线上你可以接一个文本分类服务,但框架结构是一样的。
from agents import input_guardrail, GuardrailResult, RunContextWrapper SENSITIVE_WORDS = ["自杀", "不想活", "去死", "投诉到底", "曝光"] @input_guardrail async def sensitive_content_guardrail( context: RunContextWrapper, agent: Agent, input: str ) -> GuardrailResult: # 对输入做一次检测,命中关键词直接触发 tripwire for word in SENSITIVE_WORDS: if word in input: return GuardrailResult( tripwire_triggered=True, message="检测到高风险情绪表达,请转人工客服介入。" ) return GuardrailResult(tripwire_triggered=False)出口护栏的思路是禁止 Agent 在退款金额上“自作主张”。比如它如果说出“我们赔你双倍”“给你全额退 500 元”这种未经审批的话,就会被拦下来。
from agents import output_guardrail @output_guardrail async def no_overpromise_guardrail( context: RunContextWrapper, agent: Agent, output: str ) -> GuardrailResult: suspicious = ["双倍", "全额退", "赔偿", "保证到账", "马上退"] for s in suspicious: if s in output: return GuardrailResult( tripwire_triggered=True, message="检测到超出权限的赔偿承诺,请修正回复。" ) return GuardrailResult(tripwire_triggered=False)关于护栏的触发策略,我想多说一句。很多人以为 Guardrail 触发就代表“系统出错了”,其实恰恰相反:Guardrail 触发是系统在主动保护自己。你真正要关心的不是“哪条被拦了”,而是“该拦的没拦住”。所以建议上线后把 Guardrail 的命中记录全部存日志,每天看一眼命中分布,如果发现某个词天天撞,那不是用户的问题,是你的规则该更新了。
3.4 定义三个 Agent 并配置 Handoffs
现在定义三个 Agent。第一个是退款 Agent,专门处理退款。第二个是技术支持 Agent,处理报障和操作指导。第三个是前台分诊 Agent,它不负责具体处理,只负责判断意图并交接。
from agents import Agent refund_agent = Agent( name="RefundAgent", instructions=""" 你是退款专员。用户提出退款诉求时,你需要先调用 get_order_status 确认订单状态,然后调用 initiate_refund 发起退款。 如果订单不存在,明确告诉用户查不到,不要编造。 """, tools=[get_order_status, initiate_refund], ) tech_support_agent = Agent( name="TechSupportAgent", instructions=""" 你是技术支持专员。负责解答登录失败、页面报错、支付超时等技术问题。 回答尽量步骤化,帮助用户可操作。 """, ) triage_agent = Agent( name="TriageAgent", instructions=""" 你是客服前台。你的任务不是解决问题,而是判断问题归属。 如果用户提到退款、退货、赔偿,交接给 RefundAgent。 如果用户提到登录、报错、网页打不开、支付失败,交接给 TechSupportAgent。 如果用户没有明确意图,先询问一句"您遇到的具体问题是什么"。 不要尝试自己回答退款或技术问题。 """, handoffs=[ handoff(refund_agent), handoff(tech_support_agent), ], guardrails=[sensitive_content_guardrail], )注意我在triage_agent上挂了入口护栏,在refund_agent和tech_support_agent上要不要挂,取决于业务。一般入口只有一个,挂在最前面能拦住大部分风险,后面节点可以根据需要单独再设。
还有一个细节:RefundAgent和TechSupportAgent上面没有配置 handoffs,这意味着一旦交接过去,任务就由对应 Agent 独立完成,不会再甩回来。这个设计是有意的——专业 Agent 只处理自己的范围,避免互相踢皮球。
3.5 用 Runner 驱动一次完整请求
最后是执行入口。为了模拟真实场景,我把 Session 的持久化也一起写上。用内存字典模拟存储,换成数据库代码很容易。
from agents import Runner import asyncio # 模拟会话存储 session_store = {} async def main(): # 创建或复用 session session_id = "session_001" if session_id not in session_store: session_store[session_id] = [] # 第一次请求:退款意图 user_input = "我订单1001申请退款" result = await Runner.run( triage_agent, input=user_input, session_id=session_id, ) print("最终输出:", result.final_output) print("交接链路:", result.trace_id) # 第二次请求:技术问题 user_input2 = "我登录不上你们网站,一直转圈" result2 = await Runner.run( triage_agent, input=user_input2, session_id=session_id, ) print("最终输出:", result2.final_output) if __name__ == "__main__": asyncio.run(main())Runner.run()这里我传了session_id,SDK 会自动维护该会话的消息历史。第二次请求时,triage_agent 会带着第一轮的上下文一起判断——这在某些场景下很管用,比如用户先问退款又紧接着说“对了技术那边也有个事”,Agent 就能理解“技术那边”的指代关系。
跑完这段代码,你应该能在终端看到正确的分诊结果:第一个请求被交接到 RefundAgent 并查出订单状态,第二个请求被交接到 TechSupportAgent。如果某个请求的 final_output 明显像“前台自己在硬答”,大概率是 instructions 写得太模糊。
4. 常见问题与排查技巧
4.1 工具返回格式不标准,Agent 反复重试
这个坑我在做真实项目的第一周就踩了。Tool 返回的是个不带字段说明的字符串,比如"ok",模型根本不知道 ok 是什么意思,于是开始瞎猜,反复调工具,白白浪费 token。
标准做法是让 tool 返回结构化数据,反正 SDK 支持 dict,就永远别返回裸字符串。比如{"status": "success", "message": "退款已提交", "refund_id": "R2024001"}。模型拿到这种数据,直接就能转成自然语言,不费脑子。
另一个相关坑是 tool 的 docstring 写了“可选参数”但不说明默认值,模型会漏传。最好把必填、选填、默认行为写清楚,比如reason参数可以写“如果用户未说明原因,可以传 'user requested'”。
4.2 Handoff 死循环
Handoff 死循环多发生在两个 Agent 互相看不上的情况。A 说“这事归 B”,B 说“这事归 A”,两人就无限踢皮球,每次踢完都烧两轮 token。SDK 本身有最大步数限制,但默认的max_turns比较大,不会立刻终止,用户感知到的就是“转了半天没结果”。
我的习惯是在每个 Agent 的 instructions 里加一句“如果你不确定当前问题属于对方,直接告诉用户需要人工介入,不要反复交接”。同时配置Runner.run(..., max_turns=5),把最大轮次压下去,宁可提前终止也不要空转。
如果你已经遇到死循环,翻 Tracing 日志最直观:里面能看到两三个 Agent 的 turn 记录交错出现多次,你一眼就能定位是哪对组合在互相甩锅,然后回去改 instructions。
4.3 Guardrail 误杀和漏放并存
Guardrail 常态化问题是关键词太短,比如“去死”两个字,用户说“这破网速真能急死人”,直接命中。但你要是把词设得太长,又容易漏掉真实的高危表达。这里没有完美方案,我用的折衷是两层:第一层宽松关键词拦截,命中后不直接拒绝,而是转人工;第二层严苛精确规则,仅当同时命中多个特征时才终止任务。
在配置入口护栏时还要考虑一件事——让护栏的返回信息尽量“闭环”。比如sensitive_content_guardrail返回的 message 如果是“检测到高风险情绪”,模型会不知所措;但如果你写成“请安抚用户,并告知将安排专员回电”,模型就知道下一步该干什么了。Guardrail 不光是拦,它还可以指挥模型“接下来该怎么办”,这个细节很多人没想到。
4.4 会话丢失和上下文错乱
Session 持久化最常栽在“存了一半丢一半”。我见过有人把每次的result.to_input_list()全量覆盖存储,结果下一次丢掉了早前几轮的消息,因为消息数组被截断。正确姿势是增量追加:新消息来了,只往列表尾部加,而不是重建。
另一个问题是会话归属错了导致数据串线。特别是你用了session_id但没做用户维度隔离,用户 A 和用户 B 传了同一个 session_id,A 的订单信息就被 B 看到了。session 的命名一定要用能唯一标识用户的字段,不要用毫秒时间戳这类可能重复的东西。
4.5 并发场景下的 Handoff 冲突
当你的服务同时处理 100 个请求,每个请求都握着同一个 session,往往会出现消息顺序错乱。SDK 在设计上本身允许并发,但你要确保一个 session 不被两个线程同时写。
我这里给两个实战建议:一是按用户维度做锁,比如用 redis 的分布式锁包住整个Runner.run()过程,同一用户串行执行;二是对只读型查询,可以放开并发,但一旦进入需要写入 session 的环节,就必须抢锁。你不需要对所有请求都串行化,那样吞吐量直接腰斩。
5. 写在最后的个人体会
这四篇用下来,OpenAI Agents SDK 给我的感觉很像一套“Agent 组织架构师工具包”。它没有替你解决所有编排问题,但给了你足够的协议和抽象,让 Agent 之间的协作不再是拍脑袋,而是有标准交接、有护栏检查、有完整追踪。
我个人强烈建议你不管项目多大,都先把 Tracing 打开。没有追踪的情况下排障就像闭着眼睛修电路,有了追踪,一切争论都可以回到数据上——哪一步慢了,哪一步烧了多少钱,哪一步被护栏拦了,一目了然。
最后分享一个自己在生产环境的习惯:每次上线新 Agent 或新 Handoff,先用一套包含 20 个典型用户语句的回归用例,把整个链路跑一遍,同时检查 Trace 里的平均响应时间和 token 消耗。只要响应时间暴涨,基本可以断定某条 Handoff 链路写坏了。这个习惯帮我避过至少三次线上事故。你也试试,效果比看任何文档都直观。