Agent 工程体系 · 第 64/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

多 Agent Supervisor 与 Handoff:控制权、上下文、返回和死循环

多 Agent 系统最容易被低估的部分,不是如何创建多个 Agent,而是谁在什么时候拥有控制权、下一个 Agent 能看到什么、一次运行最终返回什么,以及什么时候必须停止

一个系统即使已经实现了分类、路由和多个专业 Agent,也可能出现以下问题:

  • Supervisor 选择了专家,但专家无法理解前文;
  • 专家完成了任务,却没有把结果正确返回给用户;
  • 一个 Agent 以为自己只是调用了另一个 Agent,实际上已经把整轮对话交给了对方;
  • 两个 Agent 互相 Handoff,Runner 持续消耗模型调用;
  • 上一轮由退款 Agent 处理,下一轮却错误地回到了 Supervisor;
  • 日志只能看到“最终答案”,无法知道控制权在哪一步发生了转移。

这些问题的共同根源是:把“多 Agent”误认为“多个模型调用”。实际上,多 Agent 更接近一个带有动态控制流的运行时系统。


一、先建立四个核心对象

为了讨论清楚 Supervisor 和 Handoff,需要先区分四个对象:

  1. Agent:具有指令、模型、工具、输出约束和可选 Handoff 的执行单元;
  2. Supervisor:一种架构角色,负责选择、协调或审查其他 Agent;
  3. Handoff:把当前对话的主动控制权转移给另一个 Agent 的机制;
  4. Run:从起始 Agent 和输入开始,到产生最终输出、异常或暂停为止的一次运行。

OpenAI Agents SDK 的基本抽象包括 Agent、工具、Handoff、Guardrail、Session 和 Tracing。官方文档把 Handoff 描述为一种 Agent 委派任务的机制,并将 Handoff 暴露为模型可调用的工具。(openai.github.io)

这里的 Supervisor 不一定是 SDK 中名为 Supervisor 的特殊类。在常见实现中,它通常就是一个普通 Agent,只是承担以下一种或多种职责:

  • 对用户请求分类;
  • 选择合适的专业 Agent;
  • 调用多个专家并综合结果;
  • 对专家结果做质量检查;
  • 决定是否继续执行、重试、回退或结束。

因此,Supervisor 的关键不是名字,而是它是否拥有下一步控制流的决定权


二、两种多 Agent 关系:调用专家,还是交出控制权

多 Agent 体系首先要区分两种关系:

flowchart LR
    U[用户请求] --> S[Supervisor]

    S -->|Agent as tool| R[研究 Agent]
    R -->|返回子任务结果| S
    S -->|综合后继续决策| F[最终回答]

    S2[路由 Agent] -->|Handoff| C[客服 Agent]
    C -->|直接继续当前对话| U2[用户]

1. Agents as tools:Supervisor 保留控制权

当 Supervisor 通过 Agent.as_tool() 调用专家时,专家相当于 Supervisor 的一个“高级工具”。

控制流是:

SESFS \rightarrow E \rightarrow S \rightarrow F

其中:

  • SS 是 Supervisor;
  • EE 是 Expert;
  • FF 是最终输出。

专家完成子任务后,结果返回给 Supervisor。Supervisor 仍然可以:

  • 调用另一个专家;
  • 比较多个专家的结果;
  • 修改问题;
  • 继续调用工具;
  • 决定最终回复。

这种模式适合“专家提供证据,Supervisor 负责最终回答”的场景。例如:

  • 研究 Agent 搜集资料;
  • 计算 Agent 计算指标;
  • 风险 Agent 提供风险判断;
  • Supervisor 汇总成一份报告。

2. Handoff:当前 Agent 交出控制权

Handoff 的控制流不同:

ShandoffEFS \xrightarrow{\text{handoff}} E \rightarrow F

Handoff 发生后,EE 成为当前活跃 Agent。它通常直接面向用户完成当前轮对话。Supervisor 不会自动重新获得控制权,也不会自动替专家总结结果。

官方文档明确区分了这两种模式:Agents as tools 适合专家完成受限子任务、由管理 Agent 保持最终回答权;Handoffs 适合路由本身就是流程的一部分,并由被转交的专业 Agent 接管当前轮对话。(openai.github.io)

这也是最常见的误解:

Handoff 不是“调用专家并拿回返回值”,而是“把当前对话的执行主体换成专家”。


三、Supervisor 的控制权到底是什么

“控制权”不能只理解成一个变量 current_agent。它至少包括四部分:

C=(A,I,P,R)C = (A, I, P, R)

其中:

  • AA:当前活跃 Agent;
  • II:当前 Agent 将看到的输入;
  • PP:当前运行的策略,例如最大轮数、工具权限和 Guardrail;
  • RR:当前运行结果的归属,包括最后输出和下一轮建议使用的 Agent。

一次模型调用完成后,Runner 通常根据模型输出执行三种分支:

  1. 输出满足最终输出条件,运行结束;
  2. 请求 Handoff,更新当前 Agent 和输入后继续循环;
  3. 请求工具调用,执行工具、追加工具结果后继续循环。

OpenAI Agents SDK 的 Runner 正是按照这一生命周期运行,并通过 max_turns 限制 Agent-loop 的模型调用轮数。超过限制时会抛出 MaxTurnsExceeded。(openai.github.io)

可以形式化为:

(At,It)LLMOt(A_t, I_t) \xrightarrow{\text{LLM}} O_t

若:

Final(Ot)=true\operatorname{Final}(O_t)=\text{true}

则:

RunDoneRun \rightarrow Done

若模型请求工具 TT,则:

It+1=It+ ⁣ ⁣+Output(T)I_{t+1}=I_t \mathbin{+\!\!+} \operatorname{Output}(T)

并继续由同一个 Agent 执行。

若模型请求 Handoff 到 AA',则:

At+1=AA_{t+1}=A'

同时:

It+1=Shape(It,H)I_{t+1}=\operatorname{Shape}(I_t, H)

其中 HH 是 Handoff 规则,Shape 可能保留全部历史,也可能通过过滤器或历史映射器改变下一 Agent 看到的输入。

所以,Handoff 的核心不是“传递一个函数返回值”,而是同时改变:

  • 谁负责下一次模型调用;
  • 下一次模型调用的输入;
  • 最终输出的生产者;
  • 下一轮继续对话时通常使用的 Agent。

四、为什么 Supervisor 不应默认通过 Handoff 调用所有专家

假设有三个 Agent:

  • triage_agent:负责分类;
  • billing_agent:负责账单问题;
  • refund_agent:负责退款问题。

最简单的 Handoff 写法如下:

from agents import Agent, Runner, handoff

billing_agent = Agent(
    name="Billing agent",
    instructions="处理账单、扣款和发票问题。",
)

refund_agent = Agent(
    name="Refund agent",
    instructions="处理退款资格、退款进度和退款说明。",
)

triage_agent = Agent(
    name="Triage agent",
    instructions=(
        "先判断用户问题属于账单还是退款。"
        "如果属于账单,转交 Billing agent;"
        "如果属于退款,转交 Refund agent。"
    ),
    handoffs=[
        billing_agent,
        refund_agent,
    ],
)

result = Runner.run_sync(
    triage_agent,
    "我被重复扣款了,想申请退款。",
    max_turns=6,
)

print(result.final_output)
print(result.last_agent.name)

这段代码的生命周期是:

  1. triage_agent 接收用户消息;
  2. 模型看到两个 Handoff 工具;
  3. 模型选择 transfer_to_refund_agent
  4. Runner 更新当前 Agent;
  5. refund_agent 继续处理同一轮输入;
  6. refund_agent 产生最终文本;
  7. result.final_output 是最后一个 Agent 的输出;
  8. result.last_agent 通常是 refund_agent

Handoff 的工具名称和目标 Agent 之间存在直接关系。SDK 可以根据 Agent 名称生成类似 transfer_to_refund_agent 的工具;也可以通过 handoff() 覆盖工具名称、描述、回调和输入过滤逻辑。(openai.github.io)

但如果需求是:

先让账单 Agent 判断是否重复扣款,再让退款 Agent 生成退款策略,最后由 Supervisor 统一回复。

这就不适合简单地把两个 Agent 都注册成 Handoff。因为第一次 Handoff 后,账单 Agent 就拥有当前控制权,它不会自动把结果返回给 Supervisor。

这时更适合使用“Agent as tool”:

from agents import Agent, Runner

billing_agent = Agent(
    name="Billing analyst",
    instructions=(
        "分析账单问题。只返回结构化、简洁的事实:"
        "扣款类型、是否可能重复扣款、需要哪些证据。"
    ),
)

refund_agent = Agent(
    name="Refund policy analyst",
    instructions=(
        "根据输入判断退款政策和下一步操作。"
        "不要直接对用户说话,只返回给调用方。"
    ),
)

supervisor_agent = Agent(
    name="Support supervisor",
    instructions=(
        "你负责最终回答用户。"
        "先根据需要调用账单分析和退款政策分析,"
        "再综合结果,用中文给出明确、可执行的回复。"
    ),
    tools=[
        billing_agent.as_tool(
            tool_name="analyze_billing",
            tool_description="分析账单、重复扣款和支付记录。",
        ),
        refund_agent.as_tool(
            tool_name="analyze_refund_policy",
            tool_description="分析退款条件、证据和下一步流程。",
        ),
    ],
)

result = Runner.run_sync(
    supervisor_agent,
    "我被重复扣款了,想申请退款。",
    max_turns=8,
)

print(result.final_output)

这里的控制流是:

SupervisorBilling AnalystSupervisorRefund Policy AnalystSupervisorFinalSupervisor \rightarrow Billing\ Analyst \rightarrow Supervisor \rightarrow Refund\ Policy\ Analyst \rightarrow Supervisor \rightarrow Final

专家返回的是子任务结果,而不是接管用户对话。官方编排文档也把“Supervisor 调用多个专家并综合输出”列为 Agents as tools 的典型用途。(openai.github.io)


五、Handoff 中的上下文:三个概念不能混用

“上下文”至少有三种含义:

  1. 本地运行上下文:Python 代码和工具可以读取的对象;
  2. 模型上下文:发送给 LLM 的消息、工具结果和历史;
  3. Handoff 输入元数据:模型在调用 Handoff 时额外生成的数据。

把这三者混在一起,是多 Agent 系统中最常见的数据边界错误。

1. 本地运行上下文:给代码,不给模型

OpenAI Agents SDK 使用 RunContextWrapper 承载本地上下文。调用 Runner.run(..., context=...) 时传入的对象,可以被工具、生命周期回调和 Handoff 回调读取;这个对象本身不会自动发送给 LLM。(openai.github.io)

from dataclasses import dataclass
from agents import Agent, Runner, RunContextWrapper

@dataclass
class AppContext:
    user_id: str
    request_id: str
    locale: str

def get_user_id(ctx: RunContextWrapper[AppContext]) -> str:
    return ctx.context.user_id

agent = Agent(
    name="Support agent",
    instructions="处理用户问题。",
)

result = Runner.run_sync(
    agent,
    "查询我的退款进度。",
    context=AppContext(
        user_id="u_123",
        request_id="req_456",
        locale="zh-CN",
    ),
)

AppContext 适合放:

  • 用户 ID;
  • 请求 ID;
  • 数据库客户端;
  • 日志器;
  • 权限检查器;
  • 内部服务客户端;
  • 事务或租户信息。

不适合直接把它当成模型可见信息。模型若需要知道某个字段,必须通过:

  • 用户消息;
  • Agent 指令;
  • 工具返回值;
  • 显式模型输入。

这条边界很重要。例如,user_id 可以留在本地上下文中用于数据库查询,但不应默认把内部数据库连接、权限对象或密钥放入 Prompt。

2. 模型上下文:决定 Agent 能理解什么

模型上下文是 LLM 实际看到的输入。Handoff 默认情况下,接收方可以看到之前的对话历史。官方文档将 Handoff 描述为“新 Agent 接管对话”,并指出接收方通常会看到完整的前序历史,除非配置了 input_filter 或历史映射。(openai.github.io)

因此,下面两种设计含义完全不同:

输入 A:
用户:我的订单是 1001。
助手:我会查询订单。
工具:订单 1001 已发货。
Handoff:转交退款 Agent。

与:

输入 B:
用户:订单 1001 已发货,但我想退款。
Handoff:转交退款 Agent。

输入 A 中,退款 Agent 可能看到完整的工具调用和中间消息;输入 B 中,它只看到经过整理的用户请求。前者信息更完整,但容易带入无关内容、内部提示、旧决策和过期状态;后者更干净,但需要你显式保留足够事实。

3. Handoff 输入元数据:模型临时生成的数据

有时路由 Agent 不仅要选择目标,还需要附带一个原因、优先级或摘要。例如:

from pydantic import BaseModel
from agents import Agent, RunContextWrapper, handoff

class EscalationData(BaseModel):
    reason: str
    priority: str

async def on_escalation(
    ctx: RunContextWrapper[None],
    input_data: EscalationData,
):
    print(
        f"escalation reason={input_data.reason}, "
        f"priority={input_data.priority}"
    )

human_agent = Agent(
    name="Human escalation agent",
    instructions="说明需要人工介入,并整理交接信息。",
)

escalation_handoff = handoff(
    agent=human_agent,
    input_type=EscalationData,
    on_handoff=on_escalation,
)

input_type 描述的是 Handoff 工具调用参数的结构。SDK 会把它暴露给模型,校验模型返回的 JSON,再将解析后的对象传给 on_handoff。它不是应用状态,也不会替换接收 Agent 的主输入,更不会根据参数自动选择另一个目标 Agent。(openai.github.io)

因此:

  • context:放已有的本地依赖和应用状态;
  • input_type:放模型在路由时生成的小块元数据;
  • input_filter:改变接收 Agent 看到的模型输入;
  • Handoff 目标:由注册的目标 Agent 决定。

六、Handoff 的输入历史如何变化

一次 Handoff 的输入可以抽象成:

Ih=H0+ ⁣ ⁣+P+ ⁣ ⁣+NI_h = H_0 \mathbin{+\!\!+} P \mathbin{+\!\!+} N

其中:

  • H0H_0:运行开始前的输入历史;
  • PP:Handoff 发生前已经产生的项目;
  • NN:当前轮新产生的项目,包括 Handoff 调用和输出;
  • ++:按顺序拼接。

SDK 的 HandoffInputData 将这些内容拆分为 input_historypre_handoff_itemsnew_items,并允许通过 input_items 指定传递给下一 Agent 的实际输入。(openai.github.io)

这意味着“过滤历史”并不等于“删除运行记录”。一个过滤器可能只改变下一 Agent 的模型输入,但运行结果、Session 或审计记录仍然需要保留原始事件。

例如,下面这个过滤器只把用户消息和最近的工具结果传给退款 Agent:

from agents import Agent, HandoffInputData, handoff

def keep_relevant_input(data: HandoffInputData) -> HandoffInputData:
    filtered = []

    for item in data.input_items:
        # 实际项目中应根据具体 InputItem 类型判断,
        # 这里只展示过滤边界。
        if item.get("role") in {"user", "tool"}:
            filtered.append(item)

    return HandoffInputData(
        input_history=data.input_history,
        pre_handoff_items=data.pre_handoff_items,
        new_items=data.new_items,
        input_items=filtered,
        run_context=data.run_context,
    )

refund_agent = Agent(
    name="Refund agent",
    instructions="只根据传入的相关事实处理退款。",
)

refund_handoff = handoff(
    agent=refund_agent,
    input_filter=keep_relevant_input,
)

生产代码不能简单假设所有输入项目都是字典,也不能仅按文本内容去重。消息内容相同,不代表它们是同一次事件;例如用户两次发送了完全相同的问题,应该保留为两个不同的输入事件。

当前 SDK 文档还描述了可选的嵌套 Handoff 历史能力,用于将可摘要的历史压缩成有序的助手摘要片段;该能力被标记为可选 Beta,默认关闭,且已有自定义 input_filter 的代码不会自动改变行为。(openai.github.io)

这类能力解决的是“历史太长或层层 Handoff 后难以阅读”的问题,不解决“状态属于谁”的问题。订单状态、退款状态和审批状态仍然应该由共享状态系统负责,而不是依赖 Prompt 中的一段摘要。


七、返回值:谁的返回值,返回到哪里

在多 Agent 系统中,“返回”至少有四种不同含义:

  1. 工具函数的返回值;
  2. Agent-as-tool 的子任务结果;
  3. 最后一个 Agent 的最终输出;
  4. 一次 Run 的完整运行记录。

1. 工具返回值

工具返回值回到调用它的 Agent,并作为下一次模型调用的输入项目:

Agenttool callToolresultAgentAgent \xrightarrow{tool\ call} Tool \xrightarrow{result} Agent

工具没有改变当前 Agent 的控制权。

2. Agent-as-tool 返回值

当一个 Agent 被包装成工具时,它的结果返回给调用方 Supervisor。Supervisor 可以继续推理和调用其他专家。

3. Handoff 的最终输出

Handoff 不会把专家输出作为普通字符串返回给原 Supervisor。专家完成后,它的输出直接成为当前 Run 的最终输出。

SDK 的 final_output 表示最后一个运行 Agent 的最终输出;如果最后 Agent 没有配置 output_type,通常是字符串;如果配置了结构化输出,则是该 Agent 的输出类型;如果运行在审批中断等情况下停止,可能为 None。(openai.github.io)

这会带来一个类型问题:

Supervisor.output_type = SupervisorAnswer
RefundAgent.output_type = RefundAnswer

如果 Supervisor 可以 Handoff 给 RefundAgent,那么整个 Run 的 final_output 可能是两种不同类型之一。SDK 因为无法静态知道所有可能的最后 Agent 类型,所以 final_output 的类型边界不能简单理解为 Supervisor 的输出类型。(openai.github.io)

工程上有三种处理方法:

  • 所有可能成为最后 Agent 的 Agent 使用统一输出协议;
  • Handoff Agent 输出面向用户的文本,不在 Run 层强行统一结构;
  • 由 Supervisor 使用 Agents as tools 调用专家,从而让 Supervisor 独占最终输出类型。

4. 完整运行记录

如果需要审计或诊断,不能只保存 final_outputRunResult 还提供:

  • new_items:包含消息、工具、Handoff 和审批等丰富运行项目;
  • raw_responses:底层模型响应;
  • last_agent:最后运行的 Agent;
  • to_input_list():用于下一轮的输入列表;
  • to_state():用于暂停和恢复的运行状态。

官方结果文档明确区分了这些结果面:final_output 适合展示给用户,new_items 适合日志和审计,last_agent 适合决定下一轮通常由谁继续处理,to_state() 适合可恢复运行。(openai.github.io)


八、下一轮应该回到 Supervisor 还是继续由专家处理

假设第一轮发生了:

TriageRefundTriage \rightarrow Refund

用户随后说:

那退款大概什么时候到账?

如果系统每一轮都从 Triage 开始,路由 Agent 可能再次分类;如果系统直接复用上一轮的 Refund,则可以保留更准确的局部上下文。

SDK 的 last_agent 表示一次 Run 中最后运行的 Agent,通常可以将它作为下一轮的起始 Agent。(openai.github.io)

一个手动续接的示例:

from agents import Agent, Runner

refund_agent = Agent(
    name="Refund agent",
    instructions="持续处理退款相关问题,直接面向用户回答。",
)

result = Runner.run_sync(
    refund_agent,
    "我申请的退款还没有到账。",
    max_turns=4,
)

print(result.final_output)

next_result = Runner.run_sync(
    result.last_agent,
    "那退款大概什么时候到账?",
    max_turns=4,
)

print(next_result.final_output)

不过,last_agent 只是“通常适合继续处理”的运行结果,不是业务授权。以下情况仍应重新经过 Supervisor 或策略层:

  • 用户切换了完全不同的主题;
  • 当前专家只被授权处理一次性任务;
  • 权限、租户或用户身份发生变化;
  • 任务状态已经过期;
  • 上一 Agent 的建议需要重新审核;
  • 当前 Agent 不能处理新的操作。

因此可以把下一轮选择写成:

Anext={last_agent,若主题连续且权限有效Supervisor,若主题变化或需要重新路由Fallback,若 Agent 不可用A_{next} = \begin{cases} last\_agent, & \text{若主题连续且权限有效} \\ Supervisor, & \text{若主题变化或需要重新路由} \\ Fallback, & \text{若 Agent 不可用} \end{cases}

Session 只负责帮助保存和加载对话历史,不等同于路由策略。SDK 的 Session 可以自动读取运行前的历史,并在运行后存储新消息;不同 Session ID 对应不同对话。(openai.github.io)


九、死循环是如何形成的

多 Agent Handoff 的死循环通常不是 Python 层面的 while True,而是模型在状态空间中持续选择某条循环边。

设 Agent 图为:

G=(V,E)G=(V,E)

其中:

  • VV:Agent 集合;
  • EE:允许的 Handoff 边。

例如:

SupervisorBillingSupervisor \rightarrow Billing

BillingSupervisorBilling \rightarrow Supervisor

如果每次模型调用后,输入状态都没有产生足够变化,那么就可能出现:

(S,x)(B,x)(S,x)(B,x)(S, x) \rightarrow (B, x) \rightarrow (S, x) \rightarrow (B, x) \rightarrow \cdots

这里的 xx 表示用户问题和业务状态几乎没有变化。图中有环并不必然错误,但环必须具有单调进展条件

定义一个进度函数:

μ(s)N\mu(s) \in \mathbb{N}

其中 ss 是运行状态,μ\mu 表示还剩多少未完成工作。要保证终止,至少需要:

s 非终止状态,μ(snext)<μ(s)\forall s \text{ 非终止状态},\quad \mu(s_{next}) < \mu(s)

例如:

  • 已完成的路由不再重复执行;
  • 已询问过的澄清问题不再重复询问;
  • 已经失败的工具切换到回退路径;
  • 已经尝试过某个 Agent 后不再无条件重试;
  • 每次循环都增加已处理步骤或消耗有限预算。

如果只是把 max_turns 设置得很大,而没有定义进度函数,那么只是把死循环变成高成本循环。

一个典型反例

Supervisor:这个问题应该交给 Billing。
Billing:我无法处理,需要 Supervisor 判断。
Supervisor:这个问题应该交给 Billing。
Billing:我无法处理,需要 Supervisor 判断。

其状态变化是:

步骤 当前 Agent 决策 业务状态
0 Supervisor Handoff → Billing 未变化
1 Billing Handoff → Supervisor 未变化
2 Supervisor Handoff → Billing 未变化
3 Billing Handoff → Supervisor 未变化

由于业务状态不变,且没有“已尝试 Billing”的记录,系统没有理由自行停止。

解决循环的三种方式

1. 破坏回边

Billing 不再 Handoff 回 Supervisor,而是直接:

  • 请求用户补充信息;
  • 返回无法处理的最终答案;
  • 调用一个固定的回退 Agent;
  • 产生结构化失败结果。

2. 记录路由历史

在本地上下文或共享状态中记录:

@dataclass
class RoutingState:
    visited_agents: set[str]
    handoff_count: int

路由前检查:

if "billing_agent" in state.visited_agents:
    # 不再重复转交,进入回退路径
    ...

这不是防止所有错误的充分条件,因为同一个 Agent 可能在状态确实变化后再次处理;更精确的判定应使用:

key=(agent_name,task_fingerprint,state_version)key = (agent\_name, task\_fingerprint, state\_version)

只有当三者都没有变化时,才视为重复转交。

3. 设置有限预算

try:
    result = Runner.run_sync(
        triage_agent,
        user_input,
        max_turns=8,
    )
except Exception as exc:
    # 记录 request_id、最后 Agent、已发生的 Handoff 和异常类型
    print(f"run failed: {type(exc).__name__}: {exc}")

当达到 max_turns 时,SDK 会抛出 MaxTurnsExceeded;也可以通过错误处理器将其转成受控的最终输出,而不是让请求直接失败。官方文档给出了针对 "max_turns" 的错误处理器机制。(openai.github.io)

需要注意:最大轮数是最后一道保险,不是路由正确性的证明。它可以保护成本和延迟,但不能说明系统已经完成了任务。


十、怎样区分“合理重试”和“死循环”

重试与死循环的区别,不在于是否重复调用,而在于状态是否发生了可验证变化。

设一次尝试的状态指纹为:

Ft=hash(current_agent,normalized_task,relevant_state_version,tool_attempts)F_t = hash( current\_agent, normalized\_task, relevant\_state\_version, tool\_attempts )

如果连续两次满足:

Ft+1=FtF_{t+1}=F_t

并且动作仍然相同,例如再次 Handoff 到同一个 Agent,那么这更接近死循环,而不是有效重试。

合理重试通常至少改变一个因素:

  • 重试次数增加;
  • 使用备用模型;
  • 使用备用工具;
  • 输入中加入错误信息;
  • 缩小任务范围;
  • 更新业务状态;
  • 转人工;
  • 改变路由目标。

例如:

第 1 次:Billing Agent 调用订单服务,超时
第 2 次:Billing Agent 使用只读缓存查询
第 3 次:Fallback Agent 告知用户系统暂时无法确认

这是有限的故障恢复路径。

相反:

第 1 次:Billing Agent 调用订单服务,超时
第 2 次:Billing Agent 再次调用同一个服务
第 3 次:Billing Agent 再次调用同一个服务

如果没有退避、次数上限、工具切换或状态变化,这只是重复消耗。


十一、Handoff 的授权不能只依赖模型选择

Handoff 工具可以被模型调用,并不意味着模型选择就等于业务授权。

例如,模型通过 Handoff 参数传入:

{
  "reason": "high_value_refund",
  "priority": "urgent"
}

此时系统仍然必须检查:

  • 用户是否有权申请该退款;
  • 当前租户是否允许该操作;
  • 金额是否超过自动处理阈值;
  • 是否需要人工审批;
  • 是否已经存在相同退款请求。

SDK 文档特别指出,is_enabled 会在准备可用 Handoff 时执行,因此不能依赖它检查模型稍后才生成的参数值;如果授权依赖 Handoff 参数,应在 on_handoff 开始处检查,并在产生副作用前失败。Handoff 也不适用函数工具的输入 Guardrail。(openai.github.io)

因此,正确顺序是:

模型选择 Handoff
        ↓
解析并校验 input_type
        ↓
on_handoff 中执行授权检查
        ↓
授权通过后再写入状态或触发副作用
        ↓
接收 Agent 开始处理

不要在 Handoff 之后才检查授权,因为接收 Agent 可能已经看到“已升级”“已批准”之类的提示,也可能继续调用有副作用的工具。


十二、共享状态与 Handoff 上下文的边界

Handoff 传递的是对话控制流,不应承担所有共享状态管理职责。

推荐将系统状态拆成三类:

1. 对话历史

回答“模型之前说了什么、用户提供了什么、工具返回了什么”。

可由:

  • Session
  • to_input_list()
  • 服务端 conversation_id
  • previous_response_id

管理。

2. 业务状态

回答“订单当前是什么状态、退款是否已创建、审批是否完成”。

应由数据库或领域服务管理,并具有:

  • 所有权;
  • 版本号;
  • 幂等键;
  • 冲突检测;
  • 事务边界。

3. 运行状态

回答“当前由谁处理、已经 Handoff 几次、是否暂停、是否等待审批”。

可记录:

{
  "run_id": "run_123",
  "current_agent": "refund_agent",
  "handoff_path": [
    "triage_agent",
    "refund_agent"
  ],
  "turn_count": 3,
  "state_version": 7,
  "pending_approval": false
}

Handoff 只负责把控制权从一个 Agent 转给另一个 Agent;它不会自动解决两个 Agent 同时修改退款状态时的并发冲突。

例如:

Refund Agent A 读取 refund_status = pending
Refund Agent B 读取 refund_status = pending
A 写入 approved
B 写入 rejected

如果没有版本检查,最终状态取决于写入顺序,而不是业务规则。

因此,Agent 之间共享的数据更新应使用类似:

UPDATErefundSETstatus=:new_status,version=version+1WHERErefund_id=:idANDversion=:expected_versionUPDATE refund SET status = :new\_status, version = version + 1 WHERE refund\_id = :id AND version = :expected\_version

更新行数为 0 时,说明发生了版本冲突,需要重新读取状态并重新决策,而不是让模型凭旧上下文继续执行。


十三、并发执行时,Supervisor 仍然要负责合并语义

多个 Agent 并行运行适合互不依赖的子任务,例如:

  • 三个检索 Agent 分别查询不同数据源;
  • 三个评审 Agent 独立检查同一份文本;
  • 多个候选方案分别估算成本。

但并行结果不能简单地“拼接字符串”。Supervisor 或代码编排层必须定义:

Merge(r1,r2,,rn)Merge(r_1,r_2,\ldots,r_n)

并明确:

  • 哪些字段可以合并;
  • 哪些字段必须唯一;
  • 冲突时谁优先;
  • 缺失结果是否允许继续;
  • 一个专家失败时是否整体失败;
  • 是否需要再次调用裁决 Agent。

如果并行专家直接共享可变状态,而没有锁、版本或事件顺序,Handoff 只会让问题更难追踪:控制流是串行切换的,状态写入却可能是并发的。


十四、诊断:不要只看最终答案

多 Agent 故障通常隐藏在最终输出之前。至少需要记录以下事件:

run_started
agent_started
model_called
tool_called
tool_finished
handoff_requested
handoff_accepted
agent_finished
run_completed
run_failed

OpenAI Agents SDK 内置 Tracing,可以记录模型生成、工具调用、Handoff、Guardrail 和自定义事件,用于查看和监控 Agent 工作流。Tracing 默认启用,也可以全局或单次运行关闭。(openai.github.io)

一个有效的 Handoff 日志至少应包含:

{
  "run_id": "run_123",
  "turn": 4,
  "from_agent": "triage_agent",
  "to_agent": "refund_agent",
  "reason": "duplicate_charge",
  "input_fingerprint": "fp_abc",
  "state_version": 12,
  "history_items_forwarded": 9
}

诊断死循环时,按以下顺序检查:

  1. current_agent 是否在两个或多个 Agent 间反复变化;
  2. Handoff 目标是否总是相同;
  3. 输入指纹是否没有变化;
  4. 业务状态版本是否没有变化;
  5. 是否重复执行同一工具;
  6. max_turns 是否被关闭或设置过大;
  7. 是否将错误文本追加到上下文,却没有改变下一步策略;
  8. 最后一个 Agent 是否真的产生了最终输出。

new_itemsfinal_output 更适合做这类分析,因为它保留了消息、工具调用、Handoff 和审批等运行项目。(openai.github.io)


十五、一个可验证的路由设计

可以把 Supervisor 的路由约束写成以下状态机:

stateDiagram-v2
    [*] --> Triage
    Triage --> Billing: billing_intent
    Triage --> Refund: refund_intent
    Triage --> Clarify: insufficient_information
    Triage --> Fallback: unsupported_intent

    Billing --> Done: final_answer
    Refund --> Done: final_answer
    Clarify --> Done: ask_user
    Fallback --> Done: controlled_failure

    Billing --> Fallback: tool_failure
    Refund --> HumanApproval: high_risk_action
    HumanApproval --> Refund: approved
    HumanApproval --> Done: rejected

    Triage --> Fallback: repeated_route
    Refund --> Fallback: max_turns

这个状态机有三个重要性质:

1. 终止状态明确

DoneFallbackHumanApproval 都不再无限 Handoff。即使任务失败,也会以受控状态结束。

2. 高风险操作不会直接等价于 Handoff 成功

Refund → HumanApproval 只是进入审批状态,不代表退款已经执行。审批结果必须经过业务状态验证。

3. 重复路由有专门出口

Triage → Fallback: repeated_route 防止 Supervisor 在相同任务和状态下重复选择相同路径。

模型仍然可以参与 Triage 的判断,但状态机负责限制允许的边。这样做的意义是:

LLM 决定语义选择LLM \text{ 决定语义选择}

代码 决定合法边界和终止条件代码 \text{ 决定合法边界和终止条件}

这比完全让模型自由决定所有控制流更容易测试、评估和恢复。官方编排文档也区分了由 LLM 决策的编排方式与由代码控制的确定性编排方式,并建议在需要可预测速度、成本和性能时使用代码编排或结构化输出。(openai.github.io)


十六、生产中的取舍

适合使用 Handoff 的情况

  • 用户请求需要由某个专业 Agent 直接接管;
  • 专家应直接面向用户回答;
  • 不需要 Supervisor 汇总多个专家结果;
  • 路由之后的对话具有明显的领域连续性;
  • Agent 之间的最终输出协议可以接受变化。

适合使用 Agents as tools 的情况

  • Supervisor 必须保留最终回答权;
  • 需要调用多个专家并比较结果;
  • 专家只负责检索、计算、审查或分类;
  • 需要统一最终输出结构;
  • 需要由一个地方执行最终 Guardrail 或业务决策。

适合使用纯代码编排的情况

  • 合规、审批、支付、退款等高风险流程;
  • 状态机边界明确;
  • 失败和回退路径必须可预测;
  • 需要严格控制成本、延迟和副作用;
  • 任务步骤适合写成显式工作流。

实际系统可以混用:

代码决定:
  是否允许进入退款流程
  是否需要人工审批
  最大尝试次数
  是否允许回退

LLM 决定:
  用户意图
  文本摘要
  工具参数草稿
  对多个候选结果的解释

不要让模型独自决定“是否已经完成支付”“是否已经得到授权”“是否可以无限重试”。这些是业务状态和运行时策略,不是自然语言判断。


结语:把 Handoff 看成控制流跳转,而不是消息传递

理解 Supervisor 与 Handoff 的关键,可以压缩成四个判断:

  1. 谁拥有控制权:Agents as tools 保留控制权,Handoff 转移控制权;
  2. 谁能看到什么:本地 context 不自动进入模型,Handoff 历史决定接收 Agent 的可见信息;
  3. 谁产生最终返回:Handoff 后通常由最后一个 Agent 产生 final_output
  4. 什么时候停止:最终输出、审批暂停、失败回退和 max_turns 都必须是明确的终止或恢复路径。

一个可靠的多 Agent 系统不是“Agent 越多越智能”,而是能够证明:

每次转移都有合法目标\text{每次转移都有合法目标}

每次转移都携带足够且受控的上下文\text{每次转移都携带足够且受控的上下文}

每个副作用都有授权和幂等边界\text{每个副作用都有授权和幂等边界}

每条循环路径都有可验证的进展或预算\text{每条循环路径都有可验证的进展或预算}

当这些条件成立时,Supervisor 才真正是控制器,Handoff 才是可审计的控制流转移,而不是把多个模型调用连接起来后祈祷它们自行收敛。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。