数据截至 (上游 commit 11cdf466d042)
MGX 与 RoleZero:TeamLeader 调度的动态命令智能体
30 秒导读: 前四章讲的是「第一代」MetaGPT——角色沿着写死的 SOP 流水线(需求→PRD→设计→代码)一棒接一棒跑。本章讲第二代:
Team默认use_mgx=True,换上MGXEnv环境和一个叫 Mike 的TeamLeader。此后每个角色不再走固定剧本,而是每一轮都让 LLM 现场决定「这一步调哪个工具、传什么参数」。这些角色全都继承同一个基类RoleZero——一个"能动态思考和行动"的智能体骨架。
本章是第 4 章「经典 SOP 流水线」的对照面。读之前建议先读过第 1 章 Role 循环、第 3 章 Environment 消息总线,本章会大量复用它们的概念(_observe/_think/_act、publish_message、Message 路由)。
1. 这是什么(零基础也能懂)
一句话定义: MGX(MetaGPT 的第二代多智能体模式)是"一个 LLM 队长 + 一群通用工具型智能体"的团队;每个成员靠逐轮生成工具命令干活,而不是照着固定流程走。
它解决的问题。 第一代 SOP 流水线的强项是"确定性"——步骤写死,产物齐整。但它也僵:每个角色只会做剧本里那一步,遇到剧本没覆盖的需求(改个 bug、爬个网页、临时查资料)就抓瞎。MGX 想要的是通用性:同一套角色,既能写 2048 小游戏,也能解 GitHub issue、做数据分析,靠的是"给角色一批工具,让它自己看着办"。
两代对比(先建立心智模型):
| 维度 | 第一代 SOP(第 4 章) | 第二代 MGX(本章) |
|---|---|---|
| 每一步怎么定 | 写死的 Action 序列 | 每轮 LLM 现场选工具命令 |
| 角色 | 专职(ProductManager 只写 PRD) | 通用(RoleZero 子类,配一批工具) |
| 谁来调度 | Environment 按订阅广播 | TeamLeader「Mike」居中收发、派活 |
| 停不停 | 跑完 n_round | 角色自己发 end 命令收工 |
| 适合 | 结构化、可预测的软件生成 | 开放任务、需要临场应变 |
用起来什么样。 用户几乎无感——入口还是那句 generate_repo("write a 2048 game")。差别在内部:software_company.py:45 雇的第一个人就是 TeamLeader(),后面跟着 ProductManager / Architect / Engineer2 / DataAnalyst(metagpt/software_company.py:45-53)。这些成员大多是 RoleZero 的子类。
一句话直觉/类比。 把它想成一个真实的项目组:第一代像工厂流水线(每个工位固定动作);第二代像一个有项目经理(Mike)的敏捷小组——你把需求丢给经理,经理拆活、点名派给合适的人,每个人拿到活后自己决定用什么工具、怎么下手,干完汇报,经理再决定下一步。
2. 顶层全景(它大概怎么转)
MGX 有两个主角:消息怎么流(MGXEnv + TeamLeader),和单个角色怎么想-做(RoleZero)。先看全景,再逐个拆。
怎么读下面这张图: 左边是"消息中转"——所有消息都先经过队长 Mike;右边是"一个角色收到活之后的内循环"。数字是一次任务的大致时序。
用户需求 "写个 2048"
│
▼
┌─────────────────────────────────────────────┐
│ MGXEnv.publish_message (mgx_env.py:24) │
│ 规则:除队长自己发的,一切消息都追加 send_to=Mike │
└───────────────┬─────────────────────────────┘
│ ① 需求先到 Mike
▼
┌──────────────────────┐
│ TeamLeader「Mike」 │ ② _think:LLM 生成命令
│ (RoleZero 子类) │ Plan.append_task(...) 拆活
│ │ publish_team_message(→Alex)
└──────────┬───────────┘
│ ③ 派活:发一条 UserMessage 给 Alex,并把自己挂起
▼
┌──────────────────────────────────────┐
│ Engineer2「Alex」= RoleZero 子类 │
│ ┌── _react 每轮重新 observe ────────┐ │
│ │ _think: 拼 system_prompt │ │ ④ 角色内循环
│ │ = 角色说明 + 工具schema + 计划状态 │ │
│ │ → LLM 吐一段 JSON 命令 │ │
│ │ _act: parse_commands → 逐个执行 │ │
│ │ Editor.write / Terminal.run / ... │ │
│ └── 直到发出 end 命令 ───────────────┘ │
└───────────────┬──────────────────────┘
│ ⑤ 干完汇报,消息又经 MGXEnv 回到 Mike
▼
Mike 标记任务完成 → 派下一个 → …
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
MGXEnv | 消息总线的 MGX 版:强制一切消息经队长中转 | metagpt/environment/mgx/mgx_env.py:11 |
TeamLeader(Mike) | 拆需求成计划、点名派活、跟踪进度 | metagpt/roles/di/team_leader.py:23 |
RoleZero | 动态智能体基类:think→act→react 的通用引擎 | metagpt/roles/di/role_zero.py:55 |
Engineer2/DataAnalyst/SWEAgent | RoleZero 的具体职业子类,各配一批工具 | metagpt/roles/di/engineer2.py:32 等 |
Planner/Plan/Task | 计划数据模型 + 更新/评审逻辑 | metagpt/strategy/planner.py:58、metagpt/schema.py:496 |
ToolRegistry + BM25ToolRecommender | 工具注册表 + 每轮召回"该给 LLM 看哪些工具" | metagpt/tools/tool_registry.py:91、tool_recommend.py:195 |
exp_cache 经验池 | 缓存/复用历史决策,省 LLM 调用 | metagpt/exp_pool/decorator.py:29 |
主线走一遍(高层): 需求 → MGXEnv 转给 Mike → Mike Plan.append_task 拆活 + publish_team_message 点名 → 被点到的 RoleZero 子类进入 _react 内循环:每轮 recommend_tools 挑工具、_think 让 LLM 出命令、_act 执行 → 角色发 end 收工汇报 → 消息回 Mike → Mike 推进下一任务。
3. 核心原理(逐个机制,由浅入深)
3.1 消息都要经过队长:MGXEnv 的中转规则
要解决的小问题: 一群通用角色若各自乱发消息,很快就退化成"谁都听不懂谁"。MGX 的答案简单粗暴——设一个中枢(队长 Mike),几乎所有消息都先过他一遍。
思路。 MGXEnv.publish_message 接管了发消息这件事(mgx_env.py:24)。它按 发信人身份分几种情况处理,最关键的是最后那条兜底规则:任何"普通消息"都会被追加收件人 Mike,于是队长永远知情。
# 示意,非源码。重点看:普通消息一律加上收件人 Mike
def publish_message(self, message, user_defined_recipient="", publicer=""):
tl = self.get_role("Mike") # 队长
if 用户直接 @某角色:
直接投递 # 私聊,绕过队长
elif publicer == 队长身份:
直接投递 # 队长处理过的消息,放行
else:
message.send_to.add(tl.name) # ← 兜底:普通消息都抄送队长
self._publish_message(message)
真实实现见 mgx_env.py:24-62;兜底那句在 mgx_env.py:56-58(message.send_to.add(tl.name))。
两个巧妙细节:
- 公开群聊模式。
is_public_chat = True(mgx_env.py:16)时,_publish_message会给消息加MESSAGE_ROUTE_TO_ALL(mgx_env.py:18-22),等于"发到群里所有人可见"。 - 把路由信息塞进正文。 LLM API 只认
content字段,不认send_to。所以move_message_info_to_content(mgx_env.py:73)把发件人/收件人写进正文,变成"[Message] from Alice to Mike: ...",这样 LLM 读 content 就知道"谁对谁说的"(mgx_env.py:88)。
3.2 队长怎么派活:TeamLeader 也是个 RoleZero
要解决的小问题: 谁把"写个 2048"拆成一串带负责人的任务,并在合适的时候把某个任务"喊"给某个角色?
关键认知:队长自己也是 RoleZero 子类(team_leader.py:23 class TeamLeader(RoleZero))。也就是说,他"拆活"和"派活"用的也是"让 LLM 生成工具命令"这一套,只不过他的工具是 ["Plan", "RoleZero", "TeamLeader"](team_leader.py:31)——即操作计划、以及一个特有命令 publish_team_message。
派活 = 一次工具调用。 LLM 决定"该让 Alex 干了",就生成命令 TeamLeader.publish_team_message(content=..., send_to="Alex")。它的实现有两个要点(team_leader.py:75-86):
- 派活即把自己挂起:
self._set_state(-1)——发完就停,等被点名的人回话(team_leader.py:80)。这保证同一时刻只有一个角色在动。 - 消息用
UserMessage发出:对收件人而言,队长转来的活"像用户请求"(team_leader.py:84-85)。
docstring 里有句很实在的叮嘱:派活时别漏掉任何必要信息(路径、链接、语言、框架、约束),"因为你是他们唯一的信息来源"(
team_leader.py:76-78)。这暴露了中枢式调度的软肋:下游只看得到队长转述的内容。
队长还有个特点:max_react_loop = 3(team_leader.py:29)——他每次只反应一两下(拆活/派活)就停,把舞台让给干活的人。相比之下 Engineer2 是 40(engineer2.py:55),DataAnalyst 继承默认 50(role_zero.py:71)。
3.3 RoleZero 的心跳:think → act,外裹 react
这是本章的心脏。RoleZero 把第 1 章的 _think/_act 循环,改造成"每轮让 LLM 自选命令"的动态引擎。
_think 干一件事:把一大堆上下文拼成 prompt,让 LLM 吐一段 JSON 命令。 拼进去的料有(role_zero.py:198-265):
| 拼进 prompt 的部分 | 来源 | 行号 |
|---|---|---|
| 检测响应语言(首轮) | DETECT_LANGUAGE_PROMPT | role_zero.py:210-211 |
| 相关经验示例 | _retrieve_experience() | role_zero.py:213 |
| 计划状态 + 当前任务 | get_plan_status(planner) | role_zero.py:216 |
| 可用工具的 schema | tool_recommender.recommend_tools() | role_zero.py:219-220 |
| 角色说明 + 任务类型描述 | system_prompt.format(...) | role_zero.py:224-230 |
| 最近观察(浏览器/编辑器/图片) | parse_browser_actions 等 | role_zero.py:241-244 |
拼好后一次 LLM 调用产出 self.command_rsp——一段 JSON 命令文本(role_zero.py:254)。
_act 干一件事:把那段 JSON 解析成命令列表,逐个执行。 见 role_zero.py:280-301:
# 示意,非源码。_act 的主干
async def _act(self):
commands, ok, rsp = await parse_commands(self.command_rsp, ...) # JSON → [{command_name, args}, ...]
if not ok:
记下错误,交回 LLM 下轮修正
return
outputs = await self._run_commands(commands) # 逐个查 tool_execution_map 并调用
return AIMessage("我干完了,请标记任务完成。Outputs: ...")
_run_commands(role_zero.py:385-415)是真正的执行器:对每条命令,先看是不是"特殊命令",否则去 tool_execution_map(命令名→真实 Python 函数的字典)里查出函数并调用。这张映射表是 RoleZero 的"手"——在 set_tool_execution(role_zero.py:118-171)里建好,把 "Editor.write"、"Browser.click" 这类字符串命令对应到 self.editor.write、self.browser.click 等真实方法。子类通过 _update_tool_execution 往里加自己的工具(如 Engineer2._update_tool_execution 加 write_new_code,engineer2.py:75)。
_react 是外壳:每轮都重新观察。 与第一代最大的行为差异写在注释里(role_zero.py:303-336):
_react 循环(role_zero.py:314-336):
while 动作数 < max_react_loop:
await self._observe() # ← Diff 2:每轮重新观察,能吸收新到的消息
has_todo = await self._think()
if not has_todo: break
await self._act()
# 到达上限时,问人类"要不要继续?"(role_zero.py:328-335)
"每轮 re-observe" 是关键:传统 Role 的 react 是"想一次做一次到底",而 RoleZero 在循环体里再次 _observe,于是干活途中队长/用户发来的新消息能被即时吸收。这让动态智能体能"边干边听"。
3.4 命令的两类特权:special 与 exclusive
LLM 生成的命令不是一律平等,RoleZero 给两类命令开了后门:
- special tool commands(
role_zero.py:77):Plan.finish_current_task、end、Terminal.run_command、RoleZero.ask_human这几个需要特殊处理,不走普通tool_execution_map,而进_run_special_command(role_zero.py:420-447)。例如end会触发收尾:先确保回复过人类,再让 LLM 生成一段任务总结(role_zero.py:474-491)。这就是"角色自己决定收工"的开关。 - exclusive tool commands(
role_zero.py:80-85):像Editor.edit_file_by_replace这种"一轮里出现多次会互相打架"的命令。parse_commands会只保留第一个(role_zero_utils.py:137-143),避免 LLM 在一轮里重复改同一文件。
3.5 省 LLM 调用:quick_think 路由
要解决的小问题: 不是每句话都值得走"拼一大坨 prompt + 生成命令"的重流程。用户随口问一句"你好""这段代码啥意思",没必要惊动整个计划机器。
思路:先分类,再决定走轻还是走重。 _quick_think(role_zero.py:342-383)在正式 think-act 前先跑一次轻量分类:
_quick_think 路由(role_zero.py:342-383):
只对"来自用户"的消息触发(role_zero.py:345-347,省掉角色间的自问自答)
│
▼
LLM 分类 intent ──► QUICK / AMBIGUOUS ─► 直接一次 LLM 作答,不进计划循环
├─► SEARCH ───────────► 走 SearchEnhancedQA 联网搜
└─► TASK ─────────────► 返回空,交给正式 _react 处理
有个自我纠错的小机关:若被判成 QUICK 的回答里却含 command_name(说明其实是个要动手的 TASK),就把它改回 TASK,退给正式流程(role_zero.py:366-369)。
3.6 该给 LLM 看哪些工具:注册表 + BM25 召回
要解决的小问题: 系统里注册了几十上百个工具,但一次 prompt 塞不下所有 schema(贵、还稀释注意力)。得每轮挑最相关的几个给 LLM。
注册: 任何类/函数加 @register_tool(...) 装饰器就进全局 TOOL_REGISTRY(tool_registry.py:91-118)。装饰器会用 inspect 抓源码、把函数签名转成 JSON schema 存好(tool_registry.py:97-115)。RoleZero、TeamLeader、Plan 这些本身也是被 @register_tool 注册的——所以"操作计划"能变成 LLM 可调的命令。
召回 + 排序两段式(tool_recommend.py:77-109):
recommend_tools:
① recall(召回) ── BM25 用「当前任务指令」当查询,从工具库里检索 topk 个候选
② rank(排序) ── 再让 LLM 从候选里选出最终 topk 个
BM25ToolRecommender.recall_tools(tool_recommend.py:216-228)把每个工具的 name + tags + description 当文档,用经典 BM25(一种按词频/逆文档频率打分的关键词检索算法)算相关度,取分数最高的几个。注意一个务实设定:当 force=True 或没有上下文时,直接返回用户指定的全部工具、跳过召回(tool_recommend.py:96-99)——RoleZero 初始化时正是 BM25ToolRecommender(tools=self.tools, force=True)(role_zero.py:109-110)。
3.7 Planner:动态模式下计划怎么被驱动
第 1 章只给了"plan_and_act"这个模式名(RoleReactMode.PLAN_AND_ACT),这里展开它在动态模式下怎么被驱动。计划的数据模型很朴素(metagpt/schema.py):
| 类型 | 是什么 | 关键字段/方法 | 行号 |
|---|---|---|---|
Task | 一个任务 | instruction、task_type、assignee、is_finished、update_task_result | schema.py:457 |
TaskResult | 一次执行的产物 | code、result、is_success | schema.py:480 |
Plan | 有依赖关系的任务序列 | append_task、finish_current_task、_topological_sort | schema.py:496 |
Plan 会对任务做拓扑排序(按 dependent_task_ids 排先后,schema.py:505-522),并始终指向"第一个未完成任务"作为 current_task(schema.py:640-660)。
两种驱动方式,MGX 用前者:
- 动态命令驱动(RoleZero/MGX)。 计划的增删改直接由 LLM 生成的命令触发:
Plan.append_task、Plan.replace_task、Plan.reset_task都注册成了工具(schema.py:488-495),映射进tool_execution_map(role_zero.py:122-124)。所以"改计划"和"写代码"一样,都是一条命令。RoleZero 里的Planner是被auto_run=True悄悄初始化的(role_zero.py:114)。 - 经典 plan_and_act 驱动(DataInterpreter)。
Planner.update_plan / process_task_result / ask_review(planner.py:77/102/119)是老一套:先WritePlan出计划、ask_review让人类确认、跑完任务再process_task_result决定确认/重做/改计划。DataInterpreter用的就是这条路(data_interpreter.py:45react_mode="plan_and_act")。
注入人类先验:TaskType.guidance。 get_plan_status 组装计划状态时,会按当前任务的 task_type 取出对应的 guidance 文本拼进 prompt(planner.py:178-189)。TaskType 枚举(strategy/task_type.py:22)为 EDA、数据预处理、特征工程、模型训练等预设了各自的写码指导——注释说得直白:"识别任务类型,就能注入人类先验(guidance)来帮助解题"(task_type.py:23)。
3.8 经验池:llm_cached_aask + exp_cache
要解决的小问题: 同样的处境反复出现时,能不能不每次都重新问 LLM?
RoleZero 生成命令的那次 LLM 调用被包了一层缓存:llm_cached_aask 上挂 @exp_cache(...)(role_zero.py:267-274)。exp_cache 装饰器(exp_pool/decorator.py:29-94)的逻辑是:先按 req 查历史经验,若有"完美经验"就直接返回(省掉 LLM 调用),否则真正调用并把结果评分后存回经验池。配套的 RoleZeroContextBuilder/RoleZeroSerializer 负责把经验拼进请求、以及把长请求裁剪成便于存储的精华(role_zero.py:16-17、267-273)。开关由 config.exp_pool.enabled/enable_read/enable_write 控制(decorator.py:44-46)。
4. 深入实现:三个职业子类怎么各 显神通
RoleZero 是骨架,真正的"职业"靠子类填 tools 和 _update_tool_execution。
Engineer2「Alex」——写代码/建站/部署(engineer2.py:32)。工具清单包含 Editor、Terminal:run_command、Browser、git_create_pull、CodeReview、Deployer 等(engineer2.py:39-51)。它的特色是每轮 _think 前先 _format_instruction 把当前终端目录、编辑器打开的文件注入 prompt(engineer2.py:62-73),让 LLM 知道"我现在站在哪"。核心命令 write_new_code(engineer2.py:112-142)是"独占命令",单独跑一次 LLM 专门产出整份代码文件。
DataAnalyst「David」——数据分析/爬虫/文档 QA(data_analyst.py:26)。它把 DataInterpreter(第一代数据解释器,本节末尾会与它对照)的"写码-执行-反思"循环收进一条命令 write_and_exec_code(data_analyst.py:61-125):
write_and_exec_code(data_analyst.py:93-119):
counter=0
while 未成功 and counter<3:
写代码(counter>0 时启用 reflection 反思上次报错)
在 notebook 里执行
成功 → 记 TaskResult、更新任务;失败 → 带着报错重来
这就是"数据解释器"的精髓:代码在真实 notebook 里跑,失败了带着 traceback 让 LLM 自我修正(data_analyst.py:96-119)。DataAnalyst 还有第二个工具推荐器 custom_tool_recommender,专门为"写代码"这步召回细粒度库工具(data_analyst.py:41-52)。
SWEAgent「Swen」——解 GitHub issue(swe_agent.py:17)。工具极简:Bash、Browser、git_create_pull(swe_agent.py:22-27)。它每轮把 bash 的 state 命令输出(当前仓库状态)注入 prompt(swe_agent.py:46-54),并在评测模式下用 git diff --cached 抽出补丁存进 output_diff(swe_agent.py:62-80)——这是它跑 SWE-bench 的接口。
对照 DataInterpreter(第一代)。 同名角色 David,但 DataInterpreter 直接继承 Role 而非 RoleZero(data_interpreter.py:36),走 plan_and_act 固定模式;而 DataAnalyst 走 RoleZero 的动态命令模式。二者共用 WriteAnalysisCode/ExecuteNbCode 这套写码-执行底座,但谁来决定下一步不同:前者是 SOP,后者是 LLM 逐轮选命令。