AutoGen Core Runtime 实战:从消息路由到多智能体协作

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_typeagent_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 关闭统一清理":

  1. 注册register_factory(type, factory) 只登记创建 Agent 实例的工厂函数,此刻不创建任何 Agent
  2. 实例化 :Agent 收到第一条消息 时,Runtime 才调 _get_agent 现场调用工厂创建它。
  3. 注入身份 :创建发生在 AgentInstantiationContext 作用域内,所以 Agent 的构造函数里就能拿到"自己是谁、在哪个 Runtime"。
  4. 常驻 :一个 AgentId 通常对应一个缓存的 Agent 实例,之后继续复用;不要把它理解为每条消息都会创建新实例。
  5. 关闭:Runtime 关闭时会对存活实例执行统一的清理流程。具体生命周期细节以当前版本 API 为准。

七、六大常见多智能体设计模式

因为一切都是消息,一些常见的多智能体"模式"本质上是消息流向的套路

模式 思想 用消息怎么表达
顺序工作 流水线,上一步输出是下一步输入 每次 send_message 将结果发送给固定的下一个 Agent
群聊 一组 Agent 共享上下文并轮流发言 广播到共享话题 / 组内点名
移交(Handoff) 会话控制权显式转交、逐跳传递 Handoff 消息 + 指定接收 agent
混合代理(Mixture of Agents) 分层 worker 各自独立作答,把上一层全部结果喂给下一层精炼,最后聚合出唯一答案 编排者 send WorkerTaskawait 收齐 WorkerTaskResult 后再进下一层
多智能体辩论 多个求解器各自作答、参考邻居后迭代,最后多数表决 见下方例子 B(复刻官方)
反思 生成 → 评审 → 改进的闭环,评审者点头才停 生成者把 Draft publish 给评审者,评审者回带 approvedReview;不通过就按意见修订重发,通过才 publish 定稿(最多 N 轮)

注意:autogen-agentchat 里的 TeamRoundRobinGroupChat 等)就是把这些消息流套路封装成了组件,见第八节。

六个模式各自的"消息剧本"

这 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 把 RequestToSpeak publish 到"下一位"的专属话题,就只有那一位收到。
  • 怎么处理和发布 :A 发言 = publish 一条 GroupChatMessage;Manager 收到后追加历史、用 LLM 点名下一位 B,于是往 B 的专属话题 publish RequestToSpeak;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 拍板解决后 publish AgentResponse 回 User 话题,Runtime 把结果送回客户。
  • 移交的本质 = 换一个 agent 接着同一段历史干活 ,且可以一路移交多次 :每个 agent 只决定"下一步交给谁",办不完就再往下移交,直到有人能收尾。注意每一跳都要把完整历史重新随 UserTask 发一遍 ------消息重复是特性不是缺陷。每个 agent 只订阅自己的话题,所以发给谁的话题,接棒的就必然是那个 agent。第八节 AgentChat 的 Swarm 是同一思想的高层封装:由 LLM 直接产出带 targetHandoffMessage,运行时替你把移交跑完。
④ 混合代理(Mixture of Agents)------分层 worker 逐层精炼
  • 消息类型 (官方协议,4 种):UserTask(入口)、WorkerTask{task, previous_results}(派给 worker 的活,其中 previous_results 携带上一层全部结果 )、WorkerTaskResult(worker 交回的答案)、FinalResult(唯一最终答案)。worker 之间不直接说话,结果一律先回到编排者手上。
  • 怎么处理和发布 :编排者把同一份 WorkerTasksend 同时派给第 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 的订阅表分发。
  • 怎么处理和发布(对照现实辩论) :先把辩题交给主持人(聚合器) ------它把 SolverRequest publish 到"发言台",等于出题并宣布开辩;每个辩手(求解器)都订阅发言台 ,听到题目就亮出自己立场(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 的效果就等于把消息只递给对方。一轮的剧本是:调用方 publish Task → G 用 LLM 写第 1 稿、publish Draft → R 评审、把 Review{review, approved} publish 回 G → G 看 approved: 就把整段会话(题目 + 自己的每版草稿 + R 的每条意见)喂回 LLM 修订,重新走上面两条边出第 n+1 稿; 就定稿,publish Result 给调用方。
  • 为什么不空转 / 何时收尾 :停止条件是双保险------评审者点头(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 隔离求解器历史、轮次和聚合器缓冲区。

  1. main 把题目 Question 发到 default 话题,聚合器收到。
  2. 聚合器把题目包装成 SolverRequest广播到 default 话题;所有求解器都订阅了 default,所以 4 个一起开工。
  3. 每个求解器调 LLM 得出形如 {{42}} 的答案,把中间结果 IntermediateSolverResponse 发布到"属于自己的那个 topic"topic_type = 自己的名字)。
  4. 邻居通过 TypeSubscription 订阅了这个 topic,所以能收到。求解器攒够 2 个邻居的回答 后,把它们拼进提示词,再向自己 发一个 SolverRequest 重新解题(进入下一轮)。
  5. 到达 max_round 后不再发中间结果,改发 FinalSolverResponse 到 default 话题。
  6. 聚合器收齐 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 等消息类型,以及统一的客户端抽象 ChatCompletionClientautogen_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)

运行环境与配置 :这一段调用的是真实的大模型接口(不是离线模拟),运行前请先准备好两样东西:

  1. 一个"OpenAI 兼容"的服务:可以是 OpenAI 官方,也可以是阿里云百炼这类国内兼容服务,或任何自己搭的兼容网关------只要它接受 OpenAI 的调用协议即可。
  2. 一个 .env 配置文件 :放在代码文件同一级目录或其父目录,create_model_client() 会按"当前目录 → 上一级目录"的顺序找到并加载它。文件里主要是下面两个变量:
变量 必填? 作用
OPENAI_API_KEY ✅ 必填 访问接口用的密钥(在服务商控制台申请),缺失会直接报错
OPENAI_API_BASE_URL ⚪ 选填 接第三方兼容服务时填它的地址;留空则走 OpenAI 官方默认地址
第 2 步 · 定义消息协议

每个 agent 能收发哪些消息、消息带哪些字段,就是这个协作模式的"接口契约"。这里的 5 个 @dataclass 正好覆盖上一节从提问到出答案的整条消息流,各司其职:

  • Question / Answer ------ 对外的入口与出口mainQuestion 把题目交进来;聚合器最终把 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 步 · 注册角色、织出拓扑、跑起来 ------ 把"设计图纸"变成活程序

前面几段只是把"零件"造了出来------消息、求解器、聚合器------它们目前还只是定义,运行时并不知道系统里实际有哪些角色、谁该听谁。这一段做的是最后的"组装",一共三件事:

  1. 登记角色 :把 4 个求解器(分别叫 A、B、C、D)和 1 个聚合器登记进运行时,让它知道系统里有这 5 个角色可用。注意此刻并没有真正创建它们------一个角色要等收到第一条消息时才会被"造"出来(前面说的懒创建);四个求解器虽然出自同一个"类",但各有独立身份,互不干扰。

  2. 把"谁听谁的"写死成一张表 :每个求解器只把自己的草稿讲给两个邻居听------A 讲给 B、D,B 讲给 A、C,C 讲给 B、D,D 讲给 C、A,四条边收拢成一个环。写进运行时的就是这 8 条订阅关系。从此消息往哪送、谁在听谁,完全由这张表决定,代码里再没有任何"该发给谁"的调度逻辑。

  3. 开跑并收尾:启动运行时的消息引擎,把用户的题目作为第一条消息投进去,整场"辩论"从这里开始。运行时会自动处理到没有新消息为止再安全停下;收尾时把运行时和模型连接依次关掉。

运行效果:每个求解器一共作答 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}} 的格式,代码中的兜底解析会尝试识别 #### 72answer 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 配置

除了 RoundRobinGroupChatautogen_agentchat.teams 还提供 SelectorGroupChat(模型选下一位)、Swarm(移交)、MagenticOneGroupChat(编排器)等。

参考文献

注:第三方中文镜像可能存在同步延迟。涉及 API、源码和版本行为时,应优先以 AutoGen 官方文档和对应版本源码为准。

  1. AutoGen 官方核心用户指南(中文镜像) ------ Runtime、Agent、消息与话题等概念的总索引,正文各节的权威依据: www.aidoczh.com/autogen/sta...
  2. Datawhale《Hello Agents》第 6 章 · 框架开发实践 ------ 从"自己动手搭一个 Agent 框架"的角度读 AutoGen 等框架的补充材料: github.com/datawhalech...
  3. AutoGen 官方设计模式 · Multi-Agent Debate 源码 notebook (例子 B 的复刻对象): python/docs/src/user-guide/core-user-guide/design-patterns/multi-agent-debate.ipynb
  4. 论文 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.pyon_message_impl
Team 封装 autogen-agentchat/src/autogen_agentchat/teams/
相关推荐
龙亘川1 小时前
数智防控 全域闭环:智慧安防如何筑牢城市公共安全新底座?
人工智能·信息可视化·开源·智慧城市
武子康1 小时前
第一次用 SGLang:把本地模型接进聊天应用
人工智能·llm·agent
玩美数据-1 小时前
企业级在线调研与数据分析解决方案
大数据·人工智能·数据分析
用户5274675614211 小时前
Agent 跑满一天不等于多一个人:用四个指标算真实产能
人工智能
天远Date Lab1 小时前
零信任架构实战:基于天远行驶OCR证识别构建自动化高并发物流车队准入网关
人工智能·架构·自动化·ocr
厚皮龙1 小时前
CoT Monitoring 与 Activation Monitoring 总结
人工智能
QYR-分析1 小时前
高端精密制造赋能,管材旋锻机行业市场格局、痛点趋势与发展研判
人工智能·制造
Hody911 小时前
【XR硬件介绍】苹果N50智能眼镜技术解读:Vision Pro让位之后,“无屏AI眼镜”凭什么接棒
人工智能·xr
kaixin_啊啊1 小时前
PandaWiki 本地 AI 知识库实战:文档导入、智能问答与远程访问
linux·服务器·人工智能·windows·ai