数据截至 (上游 commit 1f738cdeb7f5)
高层编排模式:把 agent 编进工作流
30 秒导读: 前面几章讲了单个 agent 怎么跑(第 01 章)、怎 么长手脚(第 02 章),以及底层那台"类型路由的 Pregel 图引擎"怎么转(第 03 章)。本章讲的是最上面那层:框架怎么把"多个 agent 协作"这件事,变成五种开箱即用的拓扑(顺序、并行、去中心路由、编排者主导、Magentic 自规划)。核心洞察只有一句——这五种模式不是各写一套引擎,而是各自在同一台图引擎上"生成一张特定形状的图"。理解了这句,五个 Builder 就都通了。
本章覆盖两块内容:
- 桥接层——让 agent 和 workflow 能互相包裹的三个适配器(
AgentExecutor、WorkflowAgent、WorkflowExecutor)。 - 编排包——
agent_framework_orchestrations里的五个高层 Builder,以及压轴的 Magentic 台账机制。
1. 先搞清楚一件事:两个世界要打通
框架里有两个"世界",词汇不一样:
| 世界 | 基本单位 | 怎么调用 | 输出 |
|---|---|---|---|
| Agent 世界 | Agent / 任何 SupportsAgentRun | agent.run(messages) | AgentResponse |
| Workflow 世界 | Executor(图里的节点) | 引擎按边把消息路由给它 | 靠 ctx.send_message / ctx.yield_output |
编排的本质,就是把 agent 塞进 workflow 的图里当节点跑。但 agent 的接口(run)和节点的接口(收消息、发消息)对不上。所以框架先造了一层适配器,把两个世界的接口互相翻译。
桥接层一共三个适配器,方向各不同:
桥接层三件套(谁包谁)
Agent ──包成──► AgentExecutor (agent 当图里一个节点)
└ 收 AgentExecutorRequest,发 AgentExecutorResponse
Workflow ──包成──► WorkflowAgent (整张图反过来当一个 agent)
└ 对外暴露 .run(),内部把 workflow 事件翻成 AgentResponse
Workflow ──包成──► WorkflowExecutor (子工作流当父图里一个节点)
└ 图套图,支持嵌套
先把这三个适配器讲透,后面五个 Builder 才有地基。
2. 桥接件一:AgentExecutor —— 把 agent 包成节点
它解决的小问题: 图引擎只认 Executor。你有一个 agent,想让它在图里当一个节点,谁来收发消息、谁来维护对话上下文?
思路: 写一个 Executor 子类,内部持有 agent;收到消息就攒进缓存,该回复时调 agent.run(),把结果打包成一个标准信封发给下游。这个包装类就是 AgentExecutor(python/packages/core/agent_framework/_workflows/_agent_executor.py:119,class AgentExecutor)。
2.1 两个标准信封
整个编排包的节点之间,传的都是这两个 dataclass:
| 信封 | 方向 | 关键字段 | 源码 |
|---|---|---|---|
AgentExecutorRequest | 发给 agent 节点 | messages、should_respond(是否要它真的回复) | _agent_executor.py:32(class AgentExecutorRequest) |
AgentExecutorResponse | agent 节点发出 | executor_id、agent_response、full_conversation(到此为止的完整对话) | _agent_executor.py:46(class AgentExecutorResponse) |
should_respond=False 是个关键设计:它让编排者可以只把消息灌进某个 agent 的上下文缓存、但不让它现在开口(见 run handler,_agent_executor.py:197)。后面群聊/交接的"广播同步"全靠这个开关。
full_conversation 也不是摆设。它保证下游 agent 拿到的是完整对话历史而不是只有上一个 agent 的最后一句——否则链条越长,前面的用户提问越容易丢。
2.2 无缝链接:三种输入都能接
AgentExecutor 定义了一组 handler,靠输入类型自动分派(这正是第 03 章讲的类型路由):
| 收到的类型 | 走哪个 handler | 行为 |
|---|---|---|
AgentExecutorRequest | run | 标准路径,攒缓存后按需回复 |
AgentExecutorResponse | from_response | 上一个 agent 的输出直接喂进来,继续对话 |
str | from_str | 裸字符串当新用户输入 |
Message / list[Message] | from_message / from_messages | 单条/多条消息 |
from_response(_agent_executor.py:213)里藏着一个上下文策略开关 context_mode:
# 示意,非源码:from_response 里怎么决定"把多少历史喂给下一个 agent"
if context_mode == "full": # 默认:全量历史都带上
cache.extend(prior.full_conversation)
elif context_mode == "last_agent": # 只带上一个 agent 的回复
cache.extend(prior.agent_response.messages)
else: # custom:用户给的过滤函数说了算
cache.extend(context_filter(prior.full_conversation))
一个容易踩的坑(源码里专门警告了): 如果你写自定义 executor,想改写 agent 的输出文本,别直接
send_message一个裸str——那会命中下游的from_str,把完整对话历史丢光。要用AgentExecutorResponse.with_text(...)(_agent_executor.py:62),它保持信封类型不变,于是走from_response,历史得以保留。这个坑在from_str的 docstring(_agent_executor.py:244)里被明确点名。
3. 桥接件二:WorkflowAgent —— 把整张图反过来当 agent
它解决的小问题: 你辛辛苦苦编排了一张多 agent 的图,现在想把它当成一个普通 agent 塞进别人的系统(或者再嵌进另一张图)。可是图的接口是"跑起来吐一串事件",不是 run() -> AgentResponse。
思路: 反向包装。WorkflowAgent(python/packages/core/agent_framework/_workflows/_agent.py:52,class WorkflowAgent)继承 BaseAgent,对外长得就是个 agent——有 .run();内部把 workflow 跑出来的事件流,翻译回 AgentResponse / AgentResponseUpdate。
它在构造时会做一个类型校验:workflow 的起始节点必须能吃 list[Message],否则拒绝包装(_agent.py:117)——因为 agent 的输入就是消息列表,图的入口得对得上。
翻译规则很清晰,只放行两类事件(_agent.py:_convert_workflow_events_to_agent_response,起于 :483):
| workflow 事件类型 | 翻成什么 |
|---|---|
output(终态输出) | 追加进 AgentResponse.messages |
request_info(要人介入) | 翻成一个"函数审批请求"内容,交给上层处理 |
其它(生命周期、诊断、编排内部事件如 group_chat/handoff_sent/magentic_orchestrator) | 一律丢弃 |
这就是"图套 agent 套图"能无限嵌套的原因:每一层只暴露干净的 AgentResponse,内部噪音全被这层滤掉。
4. 桥接件三:WorkflowExecutor —— 子工作流嵌套
它解决的小问题: 上面 WorkflowAgent 是"图 → agent"。但如果我想直接把一张子图当成父图里的一个节点(不经过 agent 这层皮),怎么办?
思路: WorkflowExecutor(python/packages/core/agent_framework/_workflows/_workflow_executor.py:106,class WorkflowExecutor)把一整张 workflow 包成一个 Executor。父图给它一条消息,它就在内部跑完子图,再把子图的输出转发回父图。
两个要点:
- 输出转发有开关(
allow_direct_output,_workflow_executor.py:525):默认把子图输出当普通 消息send_message给父图的下游节点;开成True则直接yield_output,让子图的输出就是父图的输出。 - 请求/响应会跨层协调:子图中途需要外部输入(比如人在环路),
WorkflowExecutor会把请求包成SubWorkflowRequestMessage冒泡给父图,父图应答后再喂回子图恢复执行(_workflow_executor.py:_process_workflow_result,起于:537)。每次子图调用都有独立的ExecutionContext做隔离,支持并发多次调用。
一句话记住三件套的分工:
AgentExecutor : agent → 节点 (最常用,五个 Builder 的地基)
WorkflowAgent : 图 → agent (对外封装 / 无限嵌套)
WorkflowExecutor : 图 → 节点 (图套图,子工作流)
5. 编排包全景:五个 Builder,五种拓扑
有了 AgentExecutor 这块地基,agent_framework_orchestrations 包提供了五 个高层 Builder。它们的共同套路是:
participants=[agent1, agent2, ...]
│
▼
XxxBuilder.build()
│
├─ 1. 把每个 agent 包成 AgentExecutor(或其特化子类)
├─ 2. 造若干"内部节点"(分发器/聚合器/编排者)
├─ 3. 按这个模式的拓扑,在 WorkflowBuilder 上连边
▼
一张 Workflow(回到第 03 章那台图引擎)
关键认知:五个 Builder 本身不含执行逻辑,它们只是"图的生成器"。真正跑的还是第 03 章那台超步引擎。区别只在连边的形状:
| Builder | 拓扑一句话 | 谁决定 下一个谁说话 | 中心化? |
|---|---|---|---|
SequentialBuilder | 链:A→B→C | 固定顺序 | —— |
ConcurrentBuilder | 扇出并行再扇入 | 全体并行,无先后 | —— |
HandoffBuilder | 网状:agent 自己交接 | agent 自己(调交接工具) | 去中心 |
GroupChatBuilder | 星形:编排者居中 | 编排者(选择函数/agent) | 中心化 |
MagenticBuilder | 星形 + 自规划循环 | manager(进度台账) | 中心化 |
拓扑对比图(方向统一从左到右 / 居中):
Sequential: IN → A → B → C → OUT (一条链)
Concurrent: ┌→ A ┐
IN → ┤ B ├ → 聚合器 → OUT (扇出/扇入)
└→ C ┘
Handoff: A ⇄ B (全连通网,agent 自己跳)
⇅ ╳ ⇅
C ⇄ D
GroupChat / ┌─────编排者─────┐ (星形:所有话都过中心)
Magentic: A B C
└──────┴────────┘
下面逐个拆。每个都按"要解决什么 → 拓扑 → 关键源码 → 巧妙点"讲。
6. SequentialBuilder —— 链式,最简单的那个
要解决什么: 让几个 agent 按固定顺序接力,共享同一条对话。典型场景:草稿 agent → 审校 agent → 摘要 agent。
拓扑: 一条直链。开头加一个内部节点 _InputToConversation(_sequential.py:49)负责把各种输入(str / Message / list)归一化成 list[Message],然后逐个 add_edge 串起来。
连边逻辑短到可以直接看(_sequential.py:267):
# 示意,非源码:build() 尾部就是一个 for 循环把参与者串成链
prior = input_conv
for p in participants: # participants 已被包成 AgentExecutor
builder.add_edge(prior, p)
prior = p
默认输出: 只有最后一个参与者的 yield_output 会被当成 workflow 的终态输出(default_output_from=[participants[-1]],_sequential.py:258)。
巧妙点 / 可配置项:
chain_only_agent_responses=True→ 把上面讲的context_mode设成"last_agent",链上只传上一个 agent 的回复而非全量历史(_sequential.py:203)。.with_request_info(agents=[...])→ 开人在环路:每个 agent 说完暂停,发request_info事件让人审阅、可注入引导(_sequential.py:156)。开了这个的 agent 会被包成AgentApprovalExecutor而非普通AgentExecutor。
7. ConcurrentBuilder —— 扇出并行,再聚合
要解决什么: 同一个问题,让多个 agent 同时从不同角度回答,最后汇总。典型场景:多专家并行会诊。
拓扑: 分发器 → 扇出到所有 agent → 扇入聚合器。两个内部节点:
_DispatchToAllParticipants(_concurrent.py:55):把输入原样广播给所有参与者(靠图的扇出边,不点名目标)。_AggregateAgentConversations(_concurrent.py:83):等所有 agent 都回复后,从每个 agent 各取最后一条 assistant 消息,拼成一个AgentResponse(aggregatehandler,_concurrent.py:96)。
连边就是一次扇出加一次扇入(_concurrent.py:430):
# 示意,非源码:并行拓扑的两条关键连边
builder.add_fan_out_edges(dispatcher, participants) # 一散多
builder.add_fan_in_edges(participants, aggregator) # 多聚一
扇入边天生就是第 03 章讲的"barrier 屏障":聚合器要等齐所有上游才触发。这就是"并行后汇总"的语义来源,不用 Builder 自己写等待逻辑。
巧妙点: 聚合器可换。.with_aggregator(cb)(_concurrent.py:269)接受一个 Executor,或一个普通回调 (results) -> Any;回调会被 _CallbackAggregator(_concurrent.py:139)包起来,同步回调自动丢进线程池跑,避免阻塞事件循环。
8. HandoffBuilder —— 去中心化,agent 自己交接
要解决什么: 客服式路由。分诊 agent 判断后把对话交给退款 agent 或账单 agent,由后者自己再决定要不要转交。没有中央调度,谁接棒谁自己说了算。
这也是本章第一个"谁下一个说话"由 agent 自身决定的模式。它和群聊的分野,源码 docstring 说得很干脆(_handoff.py:20):
Group Chat : centralized orchestration of multiple agents(中心编排者拍板)
Handoff : decentralized routing by agents themselves(agent 自己调工具跳转)