AutoGen Core Runtime:从消息队列到多智能体协作
本文示例按 AutoGen 0.7.5 API 编写;AutoGen 后续版本若调整 API,请以对应版本的官方文档为准。
一、Runtime 是什么
Runtime 可以理解为 AutoGen 的"消息引擎",主要负责 Agent 的生命周期管理和 Agent 之间的消息传递。
本文先围绕三个最核心的概念理解 Runtime:
| 概念 | 作用 |
|---|---|
Agent |
一个"消息处理者",通过 on_message 或 @message_handler 处理消息 |
AgentId(type, key) |
Agent 的逻辑地址 (如 AgentId("echo", "default")) |
AgentRuntime |
邮局:投递、路由、注册、订阅 |
AgentRuntime 是一个 Protocol(接口),因此可以有多个运行时实现:
SingleThreadedAgentRuntime:进程内单线程版本,适合开发和教学(本文主角)。GrpcWorkerAgentRuntime+ Host:gRPC 分布式版本,Agent 可分布在多进程、多机器或不同语言实现的 worker 中。
二、SingleThreadedAgentRuntime 的内部结构
注意:本节主要分析
SingleThreadedAgentRuntime的当前源码实现。_message_queue、Envelope 类型、_process_next()等以下划线开头的成员属于内部实现,不是稳定公共 API,不同 AutoGen 版本可能发生变化。
它的核心数据结构可以概括为:
python
_message_queue: Queue[Envelope] # 邮箱:存储待分发消息
_agent_factories: {type: factory} # 注册表:类型的"蓝图"
_instantiated_agents: {AgentId, Agent} # 活着的 agent 实例(懒加载)
_subscription_manager # 订阅表:topic → [AgentId]
消息收发链路中最主要的三类"信封"(Envelope)会在队列里流转------它们可以理解为消息的"快递单":
SendMessageEnvelope:点对点,带一个future(等响应的票据)PublishMessageEnvelope:广播到话题ResponseMessageEnvelope:把点对点响应"回填"给等待者
整体架构图

关键点:消息必须先入队(enqueue),由消息循环取出后再执行 。调用
send_message会先把消息放入 Runtime 的队列;调用方在等待响应时会挂起,而真正的处理由后台消息循环完成。
三、消息循环:RunContext 与 process_next
runtime.start() 会创建一个 RunContext,它在后台任务里反复做一件事:从队列取出下一条消息,交给 process_next() 处理。
python
# RunContext._run(简化)
while True:
await self._runtime._process_next() # 取出并分发一条消息
process_next() 是分发器:按信封类型把工作交给对应处理函数,每个信封 spawn 一个独立的 asyncio 任务并发执行(之后 await asyncio.sleep(0) 让出事件循环)。
四、两条消息通道
点对点 send_message ------ 有响应的"私聊"
点对点不经过订阅 :Runtime 拿到 AgentId 后直接投递,handler 的返回值原样作为响应送回调用方。
广播 publish_message ------ 无响应的"群发"

广播的核心是订阅关系 。一条订阅的本质是"topic → 哪个 agent " AutoGen 使用基于类型的订阅,将 topic_type 与 agent_type 关联起来。例如:
python
await runtime.add_subscription(
TypeSubscription(topic_type="default", agent_type="echo")
)
当 Runtime 收到发布到 default 话题的消息时,会根据这条订阅将消息交给 echo 类型的 Agent。对于 TopicId(type="default", source="default"),Runtime 会把目标地址解析为 AgentId(type="echo", key="default");Agent 的 key 默认来自 Topic 的 source。
五、消息怎么路由到"处理函数"
Runtime 只负责"把消息送到哪个 Agent";Agent 内部再按消息类型 路由到对应方法。这就是 RoutedAgent + @message_handler:
python
class MyAgent(RoutedAgent):
@message_handler
async def on_greeting(self, message: Greeting, ctx: MessageContext) -> GreetingReply:
return GreetingReply(...) # message为Greeting类型,将agent收到的该类型消息路由到这个函数
ctx: MessageContext 里带着这次消息的"元信息":sender(谁发的)、topic_id(来自哪个话题)、is_rpc(是不是点对点)、message_id。一个 Agent 可以注册多个不同类型的处理函数,Runtime 和框架负责自动路由。
例子 A:最小代码跑通点对点 + 广播
python
import asyncio
from dataclasses import dataclass
from autogen_core import (
AgentId, DefaultTopicId, MessageContext,
RoutedAgent, SingleThreadedAgentRuntime,
default_subscription, message_handler,
)
@dataclass
class Greeting: text: str
@dataclass
class GreetingReply: text: str
@dataclass
class News: content: str
# --- 点对点:被"私聊"的 agent ---
class EchoAgent(RoutedAgent):
def __init__(self) -> None:
super().__init__("echo")
@message_handler
async def on_greeting(self, message: Greeting, ctx: MessageContext) -> GreetingReply:
print(f"[{self.id}] 收到点对点: {message.text} 来自 {ctx.sender}")
return GreetingReply(text=f"你好!我是 {self.id},收到:{message.text}")
# --- 广播:订阅 default 话题的"读者" ---
@default_subscription
class NewsReader(RoutedAgent):
def __init__(self) -> None:
super().__init__("reader")
@message_handler
async def on_news(self, message: News, ctx: MessageContext) -> None:
print(f"[{self.id}] 收到广播(topic={ctx.topic_id}): {message.content}")
async def main() -> None:
runtime = SingleThreadedAgentRuntime()
# 注册 Agent 类型(工厂注册,Agent 收到第一条消息才真正实例化)
await EchoAgent.register(runtime, "echo", lambda: EchoAgent())
await NewsReader.register(runtime, "reader1", lambda: NewsReader())
await NewsReader.register(runtime, "reader2", lambda: NewsReader())
runtime.start() # 启动消息循环
try:
# 向指定的Agent发送消息(点对点),并等待响应
reply = await runtime.send_message(
Greeting("hello"), AgentId("echo", "default")
)
print(f"[main] 点对点响应: {reply.text}")
# 向默认话题广播消息(订阅了 default 话题的 Agent 都会收到)
await runtime.publish_message(
News("AutoGen 新版本发布!"), DefaultTopicId()
)
await runtime.stop_when_idle() # 等消息处理完再优雅停机
finally:
await runtime.close()
# 普通 .py 脚本必须使用 asyncio.run;在 Jupyter 单元格中才写 `await main()`。
if __name__ == "__main__":
asyncio.run(main())
输出结果:
text
[echo/default] 收到点对点: hello 来自 None
[main] 点对点响应: 你好!我是 echo/default,收到:hello
[reader1/default] 收到广播(topic=default/default): AutoGen 新版本发布!
[reader2/default] 收到广播(topic=default/default): AutoGen 新版本发布!
广播接收者由独立任务并发处理,因此
reader1/reader2两行的先后顺序不应当作为程序正确性的判断依据。
六、Agent 生命周期:懒实例化
Runtime 对生命周期的管理可以概括为"懒创建、常驻缓存、随 Runtime 关闭统一清理":
- 注册 :
register_factory(type, factory)只登记创建 Agent 实例的工厂函数,此刻不创建任何 Agent。 - 实例化 :Agent 收到第一条消息 时,Runtime 才调
_get_agent现场调用工厂创建它。 - 注入身份 :创建发生在
AgentInstantiationContext作用域内,所以 Agent 的构造函数里就能拿到"自己是谁、在哪个 Runtime"。 - 常驻 :一个
AgentId通常对应一个缓存的 Agent 实例,之后继续复用;不要把它理解为每条消息都会创建新实例。 - 关闭:Runtime 关闭时会对存活实例执行统一的清理流程。具体生命周期细节以当前版本 API 为准。
七、六大常见多智能体设计模式
因为一切都是消息,一些常见的多智能体"模式"本质上是消息流向的套路:
| 模式 | 思想 | 用消息怎么表达 |
|---|---|---|
| 顺序工作 | 流水线,上一步输出是下一步输入 | 每次 send_message 将结果发送给固定的下一个 Agent |
| 群聊 | 一组 Agent 共享上下文并轮流发言 | 广播到共享话题 / 组内点名 |
| 移交(Handoff) | 会话控制权显式转交、逐跳传递 | Handoff 消息 + 指定接收 agent |
| 混合代理(Mixture of Agents) | 分层 worker 各自独立作答,把上一层全部结果喂给下一层精炼,最后聚合出唯一答案 | 编排者 send WorkerTask、await 收齐 WorkerTaskResult 后再进下一层 |
| 多智能体辩论 | 多个求解器各自作答、参考邻居后迭代,最后多数表决 | 见下方例子 B(复刻官方) |
| 反思 | 生成 → 评审 → 改进的闭环,评审者点头才停 | 生成者把 Draft publish 给评审者,评审者回带 approved 的 Review;不通过就按意见修订重发,通过才 publish 定稿(最多 N 轮) |
注意:autogen-agentchat 里的 Team(RoundRobinGroupChat 等)就是把这些消息流套路封装成了组件,见第八节。
六个模式各自的"消息剧本"
这 6 种模式没有一种需要"写在代码里的调度器"------它们都能翻译成**"谁 send/publish 一条什么消息、谁处理这条消息"**。下面每个模式配一张小图。
读图约定(6 张图通用):
Runtime= 消息引擎(邮局) :持有消息队列与订阅表,负责分发 。所有消息都要先投给 Runtime ,再由它按目标地址(send)或订阅表(publish)送给对应 agent;agent 之间从不直接"私聊"。[名字]= 一个 agent(角色);((话题))= 一个共享话题(订阅路由用的"信箱")。-->|消息类型|= send:投给 Runtime → Runtime 分发给指定 agent;需要返回时,返回值也经 Runtime 回填给发送者。==>|消息类型|= publish:投给 Runtime → Runtime 广播给订阅了该话题的 agent(通常跳过发布者自己)。-.->= 订阅/回填这类"分发关系",本身不是一条新消息。
① 顺序工作(Pipeline)------流水线

- 消息类型 :整条流水线靠四种带类型的消息 流转------
Input(原始输入)、清洗结果、解析结果、最终结果。消息的类型就是它的内容,命名只是示意;每段产出一种新类型的消息,就代表上一个阶段做完了。 - 怎么处理和发布 :main 先投下
Input;A 收到后清洗,把结果包成一条清洗结果消息投回 Runtime ;Runtime 按"谁声明能接收该类型"把它交给下一段(B)------B 收到的是内容本身,而不是"叫我干活"的指令。B 干完投解析结果、C 干完投最终结果,Runtime 一路送回 main。 - 谁都不直接"叫"对方:每个 agent 只做两件事------接收自己声明能处理的消息类型 → 处理完把结果包成一条新类型的消息投回 Runtime。下一棒由谁来接,看的是消息类型,而不是发送者的点名。
② 群聊(Group Chat)------GroupChatManager 主持的"点名广播"

- 消息类型 :两种------
GroupChatMessage(广播用,body 里包一层发言,如UserMessage)与RequestToSpeak(点名用,没有正文,只是一句"该你说了")。 - 角色与订阅 :一个 GroupChatManager 主持 + N 个参与者。Manager 自己不"聊",只保存全部历史并用 LLM 选下一位发言人。广播话题
group_chat被全员(参与者 + Manager)订阅 ;此外每个参与者还有自己的专属话题 、只被自己订阅------Manager 把RequestToSpeakpublish 到"下一位"的专属话题,就只有那一位收到。 - 怎么处理和发布 :A 发言 = publish 一条
GroupChatMessage;Manager 收到后追加历史、用 LLM 点名下一位 B,于是往 B 的专属话题 publishRequestToSpeak;B 收到就干活,再 publish 一条GroupChatMessage......循环到满足终止条件(发言含APPROVE等终止词,或到达最大轮数)。若不想让 LLM 自由点名,固定轮流即可------那是第八节RoundRobinGroupChat(Team 版)封装的同一个套路。
③ 移交(Handoff)------谁更合适谁接手,一路移交下去

- 消息类型 :
UserTask= 任务 + 整段对话历史 (移交的"行李");AgentResponse= 最终解决后回给客户的结果消息(可带reply_to_topic_type指下次找谁);开场还有一条UserLogin(建会话,图略)。"下一个该给谁"由 delegate 工具 决定------它不做实事,只是被 LLM 调用后返回目标 agent 的话题名。 - 怎么处理和发布 :一位客户想退掉刚买的商品,话被包成
UserTask发出,前台客服 A 先接待;A 判断退款该由售后专员 B 处理,于是调 delegate 工具拿到 B 的话题,把到目前为止的全部历史 再包成一条新的UserTask、publish 到 B 的专属话题------历史跟着消息走,B 接过来就是"接着同一段对话干"(官方示例会附一句 "Transferred to B. Adopt persona immediately." 让 B 换成对口的身份)。B 处理中客户不满、升级投诉要主管拍板,B 觉得值班主管 C 更能解决 ,就照做一遍再移交给 C;C 拍板解决后 publishAgentResponse回 User 话题,Runtime 把结果送回客户。 - 移交的本质 = 换一个 agent 接着同一段历史干活 ,且可以一路移交多次 :每个 agent 只决定"下一步交给谁",办不完就再往下移交,直到有人能收尾。注意每一跳都要把完整历史重新随
UserTask发一遍 ------消息重复是特性不是缺陷。每个 agent 只订阅自己的话题,所以发给谁的话题,接棒的就必然是那个 agent。第八节 AgentChat 的Swarm是同一思想的高层封装:由 LLM 直接产出带target的HandoffMessage,运行时替你把移交跑完。
④ 混合代理(Mixture of Agents)------分层 worker 逐层精炼

- 消息类型 (官方协议,4 种):
UserTask(入口)、WorkerTask{task, previous_results}(派给 worker 的活,其中previous_results携带上一层全部结果 )、WorkerTaskResult(worker 交回的答案)、FinalResult(唯一最终答案)。worker 之间不直接说话,结果一律先回到编排者手上。 - 怎么处理和发布 :编排者把同一份
WorkerTask用send同时派给第 0 层的 K 个 worker(官方用asyncio.gather收齐 K 份返回值);各 worker 独立作答、各自把WorkerTaskResult回填给编排者。接着编排者把上一层 K 份结果整体塞进下一层的previous_results,连同原任务再派给第 1 层------每一层 worker 的系统提示都是"把给出的这些答案批判性地融合 成一份更高质量的回答"。如此逐层推进;到最后一层收齐后,编排者自己用 LLM 做最终聚合 ,产出唯一FinalResult送回用户。 - 角色分工与原理 (MoA 出自论文 Mixture-of-Agents Enhances Large Language Model Capabilities ,arXiv:2406.04692):各层 worker 是 proposer(提案者) ,独立给出参考回答;最终把全部结果融成唯一答案的是 aggregator(聚合器) ------官方实现里由编排者扮演(后续层 worker 拿到上一批结果后,也会先"小聚合"一遍再输出)。MoA 的经验依据是 LLM 的协作性 :让一个模型参考别的模型写的回答------哪怕那回答并不更好------它往往能答得更好。于是每多一层 ≈ 在"更多视角的合集"上再精炼一次,多视角 + 逐层融合就是它有效的根源。
- 效果 = 前馈神经网络的"宽度":层数、每层 worker 数都是可调的旋钮。实现细节:用的是
send(handler 返回值回填)而非publish------编排者必须 await 收齐一层 才能进下一层;且同一worker类型会按layer/序号实例化,编排者每轮只是换一批AgentId再派一次。
延伸:SMoA------给 MoA 的"全连接"减负 :想进一步了解 ④ 的变体,可读它的稀疏版 Sparse Mixture-of-Agents (SMoA,arXiv:2411.03284)。原始 MoA 是全连接 的------下一层的每个 worker 都要读上一层全部 K 份结果:规模一大既费 token,论文还指出"过密"的交流会损伤输出的多样性。SMoA 借鉴稀疏混合专家(SMoE)做三处瘦身:① 应答筛选(Response Selection) ------每个 agent 不再读全部上层结果,而是筛出一个子集来看,把"谁参考谁"变稀疏;② 提前停止(Early Stopping) ------检测到各家答案趋于一致就提前收束,不必跑满所有层/轮;③ 角色差异化------给每个 agent 分配互不相同的角色提示,像 SMoE 里"专家各管一摊"那样维持发散思维,弥补交流变少带来的多样性损失。论文报告:性能与完整 MoA 相当,计算成本却显著更低,且更稳定、更易扩展。
阅读材料:
- 论文原文(英)SMoA: Improving Multi-agent Large Language Models with Sparse Mixture-of-Agents (Dawei Li 等,2024)------arXiv:2411.03284,中文翻译见 alphaxiv 镜像;
- 中文解读《SMoA:基于稀疏混合架构的大语言模型协同优化框架》(阿里云开发者社区,2024-11)------developer.aliyun.com/article/163...。
⑤ 多智能体辩论(Debate)------主持人引导的多轮互听

- 消息类型 :整场共 5 种------
Question(辩题,入口)、SolverRequest(主持人"开始第 r 轮"的信号)、IntermediateSolverResponse(某辩手当轮的立场/草稿)、FinalSolverResponse(终稿)、Answer(聚合器裁决后的定案)。全部经 Runtime 的订阅表分发。 - 怎么处理和发布(对照现实辩论) :先把辩题交给主持人(聚合器) ------它把
SolverRequestpublish 到"发言台",等于出题并宣布开辩;每个辩手(求解器)都订阅发言台 ,听到题目就亮出自己立场(publish 一条中间稿)。发言台上每条稿子会广播给在场其他人 :你听到别的辩手的立场后,可以参考、修正、反驳 ,再带着新信息亮一轮------每轮作答由一条SolverRequest("请作答"信号)触发:主持人发它开场,求解器攒够别人的参考后也可以发一条给自己进入下一轮,轮数上限可设。最后一轮每位辩手只发终稿 ;主持人把 N 份终稿收齐后裁决(多数表决或自评),产出Answer交回调用方。 - 消息传递的实现要点 = "谁能听到谁"由订阅决定 :辩手们订阅同一个发言台 → 人人可旁听全场 ;如果只让彼此订阅相邻,就变成只能听到邻居(下方例子 B 正是"4 个求解器、每人听两个邻居"的稀疏环实例)。
publish默认跳过发布者自己,所以自己说的不会被自己重复收到。
⑥ 反思(Reflection)------稿子交给评审者,打回重写到点头为止

- 消息类型 :4 种成对出现------
Task(原始任务,进)、Draft(第 n 稿作品,G→R)、Review{review, approved}(评审意见 + 放行与否的布尔,R→G)、Result(终稿,作品连同全部评审一起打包 ,G→调用方)。官方"写码"示例把它们命名为CodeWritingTask / CodeReviewTask / CodeReviewResult / CodeWritingResult,两个角色叫 CoderAgent(生成者)与 ReviewerAgent(评审者)------把代码换成作文、方案,这套消息类型照抄即可。 - 怎么处理和发布 :用 publish + 两人共享同一个 default 话题 (Broadcast 风格)。Task、Draft、Review 发上话题后两人都会收到广播 ,但每个 agent 只处理匹配自己
@message_handler的类型 、其余一律忽略------所以"谁接什么"由订阅 + 类型匹配自然决定,不用点名,也不会发给无关者。这与 ②⑤ 那种"广播给在场所有人、人人都会回应"的公开论坛不同:虽同为广播,但每条消息只有声明能处理它的那一个 agent 会真正反应------当只有两人结对时,publish 的效果就等于把消息只递给对方。一轮的剧本是:调用方 publishTask→ G 用 LLM 写第 1 稿、publishDraft→ R 评审、把Review{review, approved}publish 回 G → G 看 approved:假 就把整段会话(题目 + 自己的每版草稿 + R 的每条意见)喂回 LLM 修订,重新走上面两条边出第 n+1 稿;真 就定稿,publishResult给调用方。 - 为什么不空转 / 何时收尾 :停止条件是双保险------评审者点头(approved=true) 或 达到最大轮数上限 。每一轮 G 都带着历史重写,R 下一轮也会看到自己上一轮的
review并核对意见是否真被采纳 (官方提示语 "Previous feedback ... see if it was addressed"),所以迭代朝"被放行"收敛,而不是闭眼重写;Result同时装着成品与一路评审意见,过程可追溯。注意这是"外置评审者"的反思;若让 G 自己评自己,属于自反思(self-reflection),那是另一种套路。
这 6 张图里,②③ 这类常规套路在第八节已有对应的 Team 封装(群聊 → RoundRobinGroupChat、移交 → Swarm);④ 的分层 MoA 与 ⑤(稀疏环 + 多数表决)没有现成的内置 Team------④ 要在 Runtime 上自己编排多层 worker,⑤ 需像下方例子 B 那样手工搭;⑥ 的"生成者---评审者"结对最省事是塞进一个双人 GroupChat 走 ② 的剧本,或照本段在 Runtime 上手动 publish 往返。
例子 B:复刻官方设计模式 Multi-Agent Debate
下面直接采用 AutoGen 官方"Multi-Agent Debate"设计模式示例的思路来演示如何在 Runtime 上手工实现一个"非内置"的协作模式 :它不是简单的轮流发言,而是一群求解器互相"辩论"、最后由聚合器定案。
出处 :本示例参考 AutoGen 官方"Multi-Agent Debate"设计模式;"求解器按稀疏拓扑相连"的思想来自论文 Improving Multi-Agent Debate with Sparse Communication Topology (arXiv:2406.11776)。需要真实 LLM,运行前请先准备好可用的模型接口------要配哪些密钥和变量,见下文第 1 步的"运行环境与配置"说明。
参与者分两类角色:
- MathSolver(求解器) :4 个,负责独立解一道数学题;拓扑上连成一个稀疏环 A--B--C--D ,每个求解器只和 2 个邻居交换答案。
- MathAggregator(聚合器) :1 个,接收用户题目、分发任务,收集所有求解器的终稿 后多数表决出最终答案。

每一轮"辩论"是这样的消息流:
为保持示例简洁,下面的实现假设 Runtime 生命周期内只处理一道题。如果要并发处理多个问题,需要给消息增加
request_id,并按request_id隔离求解器历史、轮次和聚合器缓冲区。
- main 把题目
Question发到 default 话题,聚合器收到。 - 聚合器把题目包装成
SolverRequest,广播到 default 话题;所有求解器都订阅了 default,所以 4 个一起开工。 - 每个求解器调 LLM 得出形如
{{42}}的答案,把中间结果IntermediateSolverResponse发布到"属于自己的那个 topic" (topic_type = 自己的名字)。 - 邻居通过
TypeSubscription订阅了这个 topic,所以能收到。求解器攒够 2 个邻居的回答 后,把它们拼进提示词,再向自己 发一个SolverRequest重新解题(进入下一轮)。 - 到达
max_round后不再发中间结果,改发FinalSolverResponse到 default 话题。 - 聚合器收齐 4 份终稿,多数表决 ,把
Answer发回 default 话题。
下面的代码就是把上面 6 步消息流"落"成可运行程序的过程。动手前先记一个总纲------设计一个协作模式 = 定角色(agent 类)+ 定消息(消息类型)+ 定拓扑(订阅表),三个要素在 AutoGen 里各有明确的落点:
| 协作模式里的设计要素 | AutoGen 的落点 | 你要写的代码 |
|---|---|---|
| 有哪些角色、各自什么职责 | RoutedAgent 子类 + @message_handler |
每个 agent 类能处理哪些消息、如何处理 |
| 角色之间"聊什么" | @dataclass 消息类型 |
消息的字段 = 协作中流转的信息 |
| 谁听谁的(拓扑) | TypeSubscription 订阅表 |
在 main() 里把"话题 → agent 类型"逐一绑定 |
于是实现可以严格按"消息 → 角色 → 拓扑"的顺序分五步展开:第 1 步是"给所有角色备好同一套推理环境"(各角色复用的基础设施),后面四步才是这个协作模式真正的血肉。
第 1 步 · 导入基础包、创建模型客户端 ------ 给所有 agent 备好"推理引擎"
这段代码解决两件事:
- 导包 :
autogen_core提供运行时、话题、路由、订阅等核心概念;autogen_core.models提供发给 LLM 的SystemMessage/UserMessage/AssistantMessage等消息类型,以及统一的客户端抽象ChatCompletionClient;autogen_ext.models.openai提供具体实现OpenAIChatCompletionClient。 create_model_client()创建一个"所有 agent 共用"的客户端 :后面MathSolver构造时接收的是抽象的ChatCompletionClient而不是自行 new------既能统一模型配置,也便于在测试时替换成别的实现。传入的model_info只是声明模型支持哪些能力,避免框架做过多的能力探测。
python
import asyncio
import os
import re
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List
from dotenv import load_dotenv
from autogen_core import (
DefaultTopicId,
MessageContext,
RoutedAgent,
SingleThreadedAgentRuntime,
TypeSubscription,
default_subscription,
message_handler,
)
from autogen_core.models import (
AssistantMessage,
ChatCompletionClient,
LLMMessage,
SystemMessage,
UserMessage,
)
from autogen_ext.models.openai import OpenAIChatCompletionClient
_NUMBER = r"-?\d+(?:,\d{3})*(?:\.\d+)?"
def parse_numeric_answer(content: str) -> str:
"""优先解析 {{42}},并兼容常见的 LLM/GSM8K 输出格式。"""
patterns = (
rf"\{{\{{({_NUMBER})\}}\}}",
rf"####\s*({_NUMBER})",
rf"(?:answer|total|result|equals?|equal to)\D+({_NUMBER})(?!.*{_NUMBER})",
)
for pattern in patterns:
matches = list(re.finditer(pattern, content, flags=re.IGNORECASE | re.DOTALL))
if matches:
return matches[-1].group(1).replace(",", "")
numbers = list(re.finditer(rf"(?<![\w.])({_NUMBER})(?![\w.])", content))
if numbers:
return numbers[-1].group(1).replace(",", "")
raise ValueError("模型输出中没有可解析的数值答案。")
def create_model_client() -> OpenAIChatCompletionClient:
"""创建 OpenAI 兼容接口客户端。"""
# 兼容两种运行方式:从代码所在目录运行,或从其父目录运行。
for env_file in (Path.cwd() / ".env", Path.cwd().parent / ".env"):
if env_file.exists():
load_dotenv(env_file, override=False)
model = os.getenv("OPENAI_MODEL", "qwen3.7-plus")
api_key = os.environ["OPENAI_API_KEY"]
base_url = os.getenv("OPENAI_API_BASE_URL")
client_kwargs = {
"model": model,
"api_key": api_key,
# 本文只发送纯文本消息,不声明未使用的高级能力。
"model_info": {
"vision": False,
"function_calling": False,
"json_output": False,
"structured_output": False,
"family": "unknown",
},
}
if base_url:
client_kwargs["base_url"] = base_url
return OpenAIChatCompletionClient(**client_kwargs)
运行环境与配置 :这一段调用的是真实的大模型接口(不是离线模拟),运行前请先准备好两样东西:
- 一个"OpenAI 兼容"的服务:可以是 OpenAI 官方,也可以是阿里云百炼这类国内兼容服务,或任何自己搭的兼容网关------只要它接受 OpenAI 的调用协议即可。
- 一个
.env配置文件 :放在代码文件同一级目录或其父目录,create_model_client()会按"当前目录 → 上一级目录"的顺序找到并加载它。文件里主要是下面两个变量:
变量 必填? 作用 OPENAI_API_KEY✅ 必填 访问接口用的密钥(在服务商控制台申请),缺失会直接报错 OPENAI_API_BASE_URL⚪ 选填 接第三方兼容服务时填它的地址;留空则走 OpenAI 官方默认地址
第 2 步 · 定义消息协议
每个 agent 能收发哪些消息、消息带哪些字段,就是这个协作模式的"接口契约"。这里的 5 个 @dataclass 正好覆盖上一节从提问到出答案的整条消息流,各司其职:
Question/Answer------ 对外的入口与出口 :main用Question把题目交进来;聚合器最终把Answer发回 default 话题。SolverRequest------ 一次"请你解题"的驱动信号,同一类型被用在两个场景:聚合器广播给全体求解器开工、以及求解器到下一轮把邻居意见"问自己"。IntermediateSolverResponse------ 求解器之间交换的本轮草稿 ,携带本轮answer(解析出的数值)与round(第几轮)。正是round字段让接收方判断"这段草稿属于哪一轮、攒够没有"。模型应优先输出{{42}},但生产代码最好保留对#### 42或结尾数字等常见格式的兜底解析。FinalSolverResponse------ 收敛信号:终轮不再扩散草稿,只把答案交给聚合器。
python
# ---------------- 消息协议 ----------------
@dataclass
class Question: # 用户的题目 → 发给聚合器
content: str
@dataclass
class Answer: # 聚合器多数表决后的最终答案
content: str
@dataclass
class SolverRequest: # "请解这道题"(聚合器 → 全体 / 自己 → 自己)
content: str
question: str
@dataclass
class IntermediateSolverResponse: # 求解器之间交换的"本轮草稿"
content: str
question: str
answer: str
round: int
@dataclass
class FinalSolverResponse: # 终轮:只把答案交给聚合器
answer: str
第 3 步 · 实现 MathSolver ------ 一个"既会解题、又会参考邻居"的 agent
MathSolver 用两个 handler 把"自己解"和"听邻居"两件事拆开,互不干扰:
handle_request(SolverRequest)------ 收到"请求题"就调用一次LLM来解决问题作为本轮答案,如果到达了最大轮次就发FinalSolverResponse停止求解,否则将本轮答案作为草稿发布到属于自己的话题 (topic_type就是自己的名字),通知订阅了自己的邻居来处理草稿handle_response(IntermediateSolverResponse)------ 收到邻居草稿,先按round攒进_buffer(用ctx.sender做 key 去重,避免同一条消息被重复计数把数凑满),这一轮收集完成所有邻居草稿后就把邻居们的话拼进提示词、点对点发给"自己" ,从而再次触发handle_request进入下一轮。
构造函数的参数就是求解器的"配置面":topic_type 决定草稿发到哪个话题、num_neighbors 决定等几个邻居才进下一轮、max_round 决定辩论几轮后收手。
注意,没有任何 agent 负责"调度轮次" :要不要进入下一轮,由每个求解器自己数缓冲区决定;求解器之间的"同步"只靠消息的类型、round 字段和 publish/send 完成。这正是把一个协作模式表达成消息后,代码自然得到的样子。
python
# ---------------- 求解器 ----------------
@default_subscription # 订阅 default 话题 → 能收到聚合器广播的 SolverRequest
class MathSolver(RoutedAgent):
def __init__(self, model_client: ChatCompletionClient, topic_type: str,
num_neighbors: int, max_round: int) -> None:
super().__init__("A solver.")
self._topic_type = topic_type # 自己"广播草稿"用的话题类型
self._model_client = model_client
self._num_neighbors = num_neighbors # 等几个邻居回答完,才进入下一轮
self._history: List[LLMMessage] = []
# 每一轮按发送者去重,避免重复消息把计数提前凑满。
self._buffer: Dict[int, Dict[str, IntermediateSolverResponse]] = {}
self._system_messages = [SystemMessage(
content=(
"You are a helpful assistant with expertise in mathematics and reasoning. "
"Your task is to assist in solving a math reasoning problem by providing "
"a clear and detailed solution. Limit your output within 100 words, "
"and your final answer should be a single numerical number wrapped in double braces, "
"at the end of your response. For example, 'The answer is {{42}}.'"
)
)]
self._round = 0
self._max_round = max_round
@message_handler
async def handle_request(self, message: SolverRequest, ctx: MessageContext) -> None:
# 把"题目 + 上一轮自己的解"追加进上下文,让 LLM 作答。
self._history.append(UserMessage(content=message.content, source="user"))
model_result = await self._model_client.create(self._system_messages + self._history)
assert isinstance(model_result.content, str)
self._history.append(AssistantMessage(content=model_result.content, source=self.metadata["type"]))
print(f"{'-' * 80}\nSolver {self.id} round {self._round}:\n{model_result.content}")
# 优先取 {{42}};若模型漏掉双大括号,也兼容常见的最终数字格式。
answer = parse_numeric_answer(model_result.content)
self._round += 1
if self._round >= self._max_round:
# 到达终轮:把最终答案发到 default 话题,聚合器会收集。
await self.publish_message(FinalSolverResponse(answer=answer), topic_id=DefaultTopicId())
else:
# 否则:把草稿发到"属于自己"的 topic,等邻居订阅接收。
await self.publish_message(
IntermediateSolverResponse(
content=model_result.content,
question=message.question,
answer=answer,
round=self._round,
),
topic_id=DefaultTopicId(type=self._topic_type),
)
@message_handler
async def handle_response(self, message: IntermediateSolverResponse, ctx: MessageContext) -> None:
# 收到邻居的草稿,先按 round 攒到自己的缓冲区。
sender_key = str(ctx.sender)
self._buffer.setdefault(message.round, {})[sender_key] = message
if len(self._buffer[message.round]) >= self._num_neighbors:
# 邻居这轮都答完了 → 先取出并清空本轮缓冲,再"问自己一次"。
responses = self._buffer.pop(message.round)
prompt = "These are the solutions to the problem from other agents:\n"
for resp in responses.values():
prompt += f"One agent solution: {resp.content}\n"
prompt += (
"Using the solutions from other agents as additional information, "
"can you provide your answer to the math problem? "
f"The original math problem is {message.question}. "
"Your final answer should be a single numerical number wrapped in double braces, "
"for example {{42}}, at the end of your response."
)
# 点对点发给自己:触发 handle_request 进入下一轮。
await self.send_message(SolverRequest(content=prompt, question=message.question), self.id)
第 4 步 · 实现 MathAggregator ------ 扮演协作的"入口"与"收口"
聚合器是全系统唯一知道"题目出给谁、最终答什么"的角色,两个 handler 正好对应一次协作的起点与终点:
handle_question(Question)------ 入口 :收到用户的题目后,把它包装成SolverRequest广播到 default 话题。一条publish_message就驱动所有订阅了 default 的求解器开工。handle_final_solver_response(FinalSolverResponse)------ 收口 :把每份终稿收进_buffer,收集完所有求解器答案后就对这些答案做多数表决(用Counter统计票数;平票时显式抛错而不是"猜"),再把唯一胜出的答案作为Answer发回 default 话题,并清空缓冲区准备迎接下一题。
注意聚合器里不存在"轮次推进":它不需要知道求解器内部辩论了几轮,只要结果到位就定案。"轮次、状态"这类信息被分散到求解器的消息与缓冲区里,而不是集中在一个调度器身上------这正是用 Core Runtime 设计协作模式,与写一段单体顺序程序最大的不同。
python
# ---------------- 聚合器 ----------------
@default_subscription # 也订阅 default 话题:收问题、收终稿、发答案
class MathAggregator(RoutedAgent):
def __init__(self, num_solvers: int) -> None:
super().__init__("Math Aggregator")
self._num_solvers = num_solvers
self._buffer: List[FinalSolverResponse] = []
@message_handler
async def handle_question(self, message: Question, ctx: MessageContext) -> None:
# 收到题目 → 广播 SolverRequest,让 4 个求解器开工。
prompt = (
f"Can you solve the following math problem?\n{message.content}\n"
"Explain your reasoning. Your final answer should be a single numerical number, "
"at the end of your response, wrapped in double braces, for example {{42}}."
)
await self.publish_message(
SolverRequest(content=prompt, question=message.content), topic_id=DefaultTopicId()
)
@message_handler
async def handle_final_solver_response(self, message: FinalSolverResponse, ctx: MessageContext) -> None:
self._buffer.append(message)
if len(self._buffer) == self._num_solvers:
# 4 个求解器都交了终稿 → 多数表决。
answers = [resp.answer for resp in self._buffer]
counts = Counter(answers)
top_count = max(counts.values())
candidates = [
answer for answer, count in counts.items()
if count == top_count
]
if len(candidates) != 1:
raise ValueError(f"无法唯一决策,出现平票:{candidates}")
majority_answer = candidates[0]
await self.publish_message(
Answer(content=majority_answer), topic_id=DefaultTopicId()
)
self._buffer.clear()
print(f"{'-' * 80}\nAggregator {self.id} publishes final answer:\n{majority_answer}")
第 5 步 · 注册角色、织出拓扑、跑起来 ------ 把"设计图纸"变成活程序
前面几段只是把"零件"造了出来------消息、求解器、聚合器------它们目前还只是定义,运行时并不知道系统里实际有哪些角色、谁该听谁。这一段做的是最后的"组装",一共三件事:
-
登记角色 :把 4 个求解器(分别叫 A、B、C、D)和 1 个聚合器登记进运行时,让它知道系统里有这 5 个角色可用。注意此刻并没有真正创建它们------一个角色要等收到第一条消息时才会被"造"出来(前面说的懒创建);四个求解器虽然出自同一个"类",但各有独立身份,互不干扰。
-
把"谁听谁的"写死成一张表 :每个求解器只把自己的草稿讲给两个邻居听------A 讲给 B、D,B 讲给 A、C,C 讲给 B、D,D 讲给 C、A,四条边收拢成一个环。写进运行时的就是这 8 条订阅关系。从此消息往哪送、谁在听谁,完全由这张表决定,代码里再没有任何"该发给谁"的调度逻辑。
-
开跑并收尾:启动运行时的消息引擎,把用户的题目作为第一条消息投进去,整场"辩论"从这里开始。运行时会自动处理到没有新消息为止再安全停下;收尾时把运行时和模型连接依次关掉。
运行效果:每个求解器一共作答 3 次------第一次独立解题,后两次都会参考邻居的解法再作答,最后一次的答案就是它交出的终稿。4 份终稿到齐后,聚合器做多数表决,把出现次数最多的那个数字作为最终答案打印出来。示例里的题是一道小学算术题,标准答案是 72。
python
async def main() -> None:
runtime = SingleThreadedAgentRuntime()
model_client = create_model_client()
# 注册 4 个求解器(agent_type 直接用名字)+ 1 个聚合器。
for name in ["MathSolverA", "MathSolverB", "MathSolverC", "MathSolverD"]:
await MathSolver.register(
runtime,
name,
lambda n=name: MathSolver(
model_client=model_client, topic_type=n,
num_neighbors=2, max_round=3,
),
)
await MathAggregator.register(runtime, "MathAggregator", lambda: MathAggregator(num_solvers=4))
# 关键:拓扑 = 8 条 TypeSubscription(环:A 连 B、D;B 连 A、C;C 连 B、D;D 连 C、A)。
topology = {
"MathSolverA": ["MathSolverB", "MathSolverD"],
"MathSolverB": ["MathSolverA", "MathSolverC"],
"MathSolverC": ["MathSolverB", "MathSolverD"],
"MathSolverD": ["MathSolverA", "MathSolverC"],
}
for topic, neighbors in topology.items():
for neighbor in neighbors:
await runtime.add_subscription(TypeSubscription(topic, neighbor))
runtime.start()
# GSM8K 的一道原题(答案 72)。
question = (
"Natalia sold clips to 48 of her friends in April, and then she sold "
"half as many clips in May. How many clips did Natalia sell altogether "
"in April and May?"
)
await runtime.publish_message(Question(content=question), topic_id=DefaultTopicId())
await runtime.stop_when_idle() # 等整场辩论处理完再优雅停机
await runtime.close()
await model_client.close()
asyncio.run(main())
一次成功运行的典型输出如下(具体解题文字会随模型、温度和服务商而变化,这里只保留关键结果):
text
--------------------------------------------------------------------------------
Solver MathSolverA/default round 0:
... April: 48, May: 24, total: 72 ... {{72}}
--------------------------------------------------------------------------------
Solver MathSolverB/default round 0:
... total = 72 ... {{72}}
...
--------------------------------------------------------------------------------
Solver MathSolverC/default round 2:
... final answer: {{72}}
--------------------------------------------------------------------------------
Solver MathSolverD/default round 2:
... final answer: {{72}}
--------------------------------------------------------------------------------
Aggregator MathAggregator/default publishes final answer:
72
完整流程中共有 4 个求解器,每个求解器作答 3 轮,因此会产生 12 次模型调用;最后聚合器收集 4 份终稿并通过多数表决得到 72。模型偶尔会漏掉 {{72}} 的格式,代码中的兜底解析会尝试识别 #### 72、answer is 72 或结尾数字等常见形式。
这一段值得停下来品一下------没有任何 agent 拥有"全局调度器":
- "谁进入下一轮"不是由谁指挥的,而是每个求解器数自己的缓冲区 :攒满
num_neighbors条邻居草稿,就发一条SolverRequest给自己,自己触发自己。 - "谁听谁的"完全由 8 条
TypeSubscription表达------这就是第二节说的用订阅表替代状态机的典型例子。 - 求解器也会收到自己并不关心的消息(例如别的求解器发到
default的终稿,以及聚合器发布的Answer)。因为没有对应 handler,这些消息会走on_unhandled_message路径并产生日志。若不希望如此,应为最终答案使用独立 Topic,或为Answer增加显式 handler。 - 聚合器广播
Answer用的是publish_message(无返回值),示例里答案靠 print 看到;要"把答案取回给调用方",更地道的做法是把聚合器的"求答案"设计成一个send_message+ 返回Answer的 handler。 - 这里的多数表决已显式处理平票;生产环境还应考虑答案归一化、模型输出异常和超时。
八、高一层 API:Team ------ 把模式打包成组件
手写消息流很有教育意义,但常用套路不必每次重造。autogen-agentchat 把一些已沉淀好的交互模式 封装成了 Team :你只需挑一个 Team 类型、放入 agent 即可。在没有显式传入其他运行时的示例中,Team 会替你管理底层运行环境,使用者通常不需要直接操作 Runtime。 不过要留意:在本文所针对的 AutoGen 版本中,没有一个专门预置的 Team 直接对应"稀疏环 + 多数表决"这一完整模式;如果需要这种拓扑,仍然需要使用 Core Runtime 自定义消息类型、订阅关系和聚合逻辑。Team 打包的是更常规的套路,比如"轮流发言"的群聊:下面这个"评审式辩论"用 RoundRobinGroupChat 几行就能跑起来,结束条件交给 TerminationCondition 声明式描述:
python
import asyncio
import os
from pathlib import Path
from dotenv import load_dotenv
from autogen_agentchat.agents import AssistantAgent
from autogen_agentchat.conditions import FunctionalTermination, MaxMessageTermination
from autogen_agentchat.messages import TextMessage
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_ext.models.openai import OpenAIChatCompletionClient
async def main() -> None:
for env_file in (Path.cwd() / ".env", Path.cwd().parent / ".env"):
if env_file.exists():
load_dotenv(env_file, override=False)
model = OpenAIChatCompletionClient(
model=os.getenv("OPENAI_MODEL", "qwen3.5-plus"),
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.getenv("OPENAI_API_BASE_URL"),
# 本文只发送纯文本消息,不声明未使用的高级能力。
model_info={
"vision": False,
"function_calling": False,
"json_output": False,
"structured_output": False,
"family": "unknown",
},
)
# 一种"轮流发言"的群聊式辩论/评审
alice = AssistantAgent(name="alice", model_client=model,
system_message="你是正方,主张 AI 会取代程序员。")
bob = AssistantAgent(name="bob", model_client=model,
system_message="你是反方,认为 AI 不会取代程序员。")
judge = AssistantAgent(name="judge", model_client=model,
system_message="你是裁判,当一方说服你时输出 APPROVE 结束辩论。")
def judge_approved(messages) -> bool:
# 只检查 judge 的可见 TextMessage,不检查 qwen 等推理模型产生的 ThoughtEvent。
return any(
isinstance(msg, TextMessage)
and msg.source == "judge"
and "APPROVE" in msg.content
for msg in messages
)
team = RoundRobinGroupChat(
[alice, bob, judge],
# 只统计可见对话消息;ThoughtEvent 可能额外出现在 result.messages 中。
# 最多运行 4 条可见消息,避免无限对话。
termination_condition=FunctionalTermination(judge_approved) | MaxMessageTermination(4),
)
try:
result = await team.run(task="开始辩论:AI 会取代程序员吗?")
for msg in result.messages: # 整场对话都在这里
print(msg)
finally:
await model.close()
asyncio.run(main())
一次成功运行时,终端中通常可以看到如下可见消息顺序(为便于阅读,省略了消息对象中的 id、时间戳等字段):
text
source='user' content='开始辩论:AI 会取代程序员吗?'
source='alice' content='正方:AI 会取代程序员,因为......'
source='bob' content='反方:AI 不会完全取代程序员,因为......'
source='judge' content='综合双方观点后,APPROVE'
当 judge 的可见 TextMessage 中出现 APPROVE 时,FunctionalTermination 被触发,Team 结束运行。如果使用带推理过程的模型,result.messages 中还可能在上述消息前后出现 ThoughtEvent;这些事件不应被当作额外的发言轮次。若裁判没有输出 APPROVE,则最多继续到 MaxMessageTermination(4) 所允许的 4 条可见消息。
两个例子放到一起看,就能画出 Team 的能力边界:
| 例子 B:官方 Multi-Agent Debate | 例子 C:群聊式评审(Team) | |
|---|---|---|
| 交互模式 | 稀疏环 + 聚合器多数表决 | 轮流发言 |
| 有没有内置 Team | 没有,需在 Runtime 手写 | 有,RoundRobinGroupChat |
| 你要写的 | 消息类型 + 订阅拓扑 + 缓冲区逻辑 | 几个 Agent 的 system_message + 终止条件 |
| 底层 Runtime | 需要自行组织和控制 | 默认由 Team 管理,必要时按 API 配置 |
除了 RoundRobinGroupChat,autogen_agentchat.teams 还提供 SelectorGroupChat(模型选下一位)、Swarm(移交)、MagenticOneGroupChat(编排器)等。
参考文献
注:第三方中文镜像可能存在同步延迟。涉及 API、源码和版本行为时,应优先以 AutoGen 官方文档和对应版本源码为准。
- AutoGen 官方核心用户指南(中文镜像) ------ Runtime、Agent、消息与话题等概念的总索引,正文各节的权威依据: www.aidoczh.com/autogen/sta...
- Datawhale《Hello Agents》第 6 章 · 框架开发实践 ------ 从"自己动手搭一个 Agent 框架"的角度读 AutoGen 等框架的补充材料: github.com/datawhalech...
- AutoGen 官方设计模式 · Multi-Agent Debate 源码 notebook (例子 B 的复刻对象):
python/docs/src/user-guide/core-user-guide/design-patterns/multi-agent-debate.ipynb - 论文 Improving Multi-Agent Debate with Sparse Communication Topology(稀疏通信拓扑) : arxiv.org/abs/2406.11...
附:源码对照速查(方便延伸阅读)
| 你想看 | 打开 |
|---|---|
AgentRuntime 接口 |
autogen_core/_agent_runtime.py |
| 队列 + RunContext + process_next | autogen_core/_single_threaded_agent_runtime.py(L99、L671) |
| 订阅三要素 | autogen_core/_subscription.py、_type_subscription.py |
| 生命周期(懒实例化) | autogen_core/_single_threaded_agent_runtime.py(L886、L942、L976) |
| 按类型路由 handler | autogen_core/_routed_agent.py(on_message_impl) |
| Team 封装 | autogen-agentchat/src/autogen_agentchat/teams/ |