跳到主要内容

数据截至 (上游 commit 3309bf4e416f)

02 — ReActAgent / ToolCallAgent:把模型说的话变成真的动作

这章讲什么: OpenManus 的心脏。先看 ReActAgent 怎么把一步切成两半, 再逐行拆 ToolCallAgentthink()act(),最后看 Manus 及其兄弟智能体 分别改了哪几行就变成了另一个角色。


1. ReActAgent:38 行的分工声明

ReAct = Reasoning + Acting(推理与行动交替),是 agent 领域的经典范式: 每一步先让模型「想」,再把想出来的动作「做」掉,把结果再喂回去。

OpenManus 把这件事写成了一个近乎空壳的抽象类(app/agent/react.py:11-38):

async def step(self) -> str:
should_act = await self.think()
if not should_act:
return "Thinking complete - no action needed"
return await self.act()

think() 返回 bool——「这一步还需要动手吗」。它是整条链上最重要的一个布尔值: 返回 False 时这一步就只「想」不「做」,但循环仍然继续。


2. 四层继承链:每层只加一件事

BaseAgent 循环 + 状态 + 记忆 + 卡死检测 (base.py)
│ 加:把一步切成 think/act
ReActAgent 两个抽象方法 (react.py)
│ 加:think=问模型要工具,act=执行工具
ToolCallAgent 工具表 · tool_choice · 特殊工具 (toolcall.py)
│ 加:换一套工具表 + 换一套提示词
Manus / SWEAgent / MCPAgent / DataAnalysis / BrowserAgent

这条链的价值: 越往下越具体,而每一层只承担一个新职责。 要造新智能体,通常只需要在最后一层改两个字段。


3. think():问模型「下一步调哪个工具」

3.1 流程图

think()

├─① 若有 next_step_prompt → 作为 user 消息追加进记忆
├─② llm.ask_tool(记忆, 系统提示, 全部工具 schema, tool_choice)
├─③ 捕获 RetryError:若 __cause__ 是 TokenLimitExceeded → 置 FINISHED,返回 False
├─④ 把模型回复(含 tool_calls)包成 assistant 消息写回记忆
└─⑤ 按 tool_choice 模式决定返回 True/False

3.2 ① 每一步都注入一次提示词

app/agent/toolcall.py:41-43:

if self.next_step_prompt:
user_msg = Message.user_message(self.next_step_prompt)
self.messages += [user_msg]

注意这是「追加」不是「替换」。 跑 20 步就会有 20 条一模一样的 user 提示留在记忆里。 好处是模型不容易忘记规则;代价是 token 浪费,而且和 §5 的卡死警告叠加时提示会越滚越长。

3.3 ② 工具 schema 怎么给出去

self.available_tools.to_params()(app/agent/toolcall.py:54)会把工具表里每个工具 转成 OpenAI function calling 的 JSON(细节见 03 章)。 每一步都把全量工具 schema 重新发一遍——没有工具筛选、没有分组、没有缓存。

3.4 ⑤ 三种 tool_choice 模式

ToolChoice 枚举见 app/schema.py:20-25,分支逻辑在 app/agent/toolcall.py:96-121:

模式模型行为think() 返回备注
NONE不许调工具有文字则 True有工具调用会打日志警告
AUTO(默认)自己决定有工具调用或有文字则 TrueToolCallAgent 的默认值
REQUIRED必须调工具True没调工具留给 act() 抛错

3.5 ③ token 超限的特殊处理

app/agent/toolcall.py:60-73 这段值得单看:

if hasattr(e, "__cause__") and isinstance(e.__cause__, TokenLimitExceeded):
...
self.state = AgentState.FINISHED
return False

为什么要检查 __cause__?因为 llm.ask_tool 上挂了 tenacity 的重试装饰器, 重试耗尽后抛出的是 RetryError,原始异常挂在 __cause__ 上。 token 超限时智能体不是崩溃,而是体面地把状态设成 FINISHED 并留一条说明消息。 (重试与 token 限额的细节见 05 章。)


4. act():逐个执行工具并回写结果

4.1 流程图

act()

├─ 没有 tool_calls?
│ REQUIRED 模式 → 抛 ValueError
│ 否则 → 直接返回最后一条消息的文本

└─ for 每个 tool_call:
├─ execute_tool() 执行 → 得到字符串 observation
├─ 按 max_observe 截断
├─ 包成 tool 消息(带 tool_call_id + 工具名)写回记忆
└─ 若结果带 base64 截图 → 另外攒一条带图的 user 消息
最后把攒下的图片消息一次性写进记忆

4.2 结果截断

app/agent/toolcall.py:148-149:

if self.max_observe:
result = result[: self.max_observe]

Manusmax_observe 设成 10000 字符(app/agent/manus.py:50), DataAnalysis 设成 15000(app/agent/data_analysis.py:26)。 这是唯一的上下文防爆机制——简单粗暴地砍字符串。

4.3 图片怎么进上下文

工具返回的 ToolResult 若带 base64_image,会被暂存到 _current_base64_image (app/agent/toolcall.py:194-197),然后在 act() 里变成一条独立的 user 消息 (app/agent/toolcall.py:162-168):

image_messages.append(
Message.user_message(
content=f"Image returned by {command.function.name}:",
base64_image=self._current_base64_image,
)
)

为什么不直接塞进 tool 消息? 因为 OpenAI 的 tool 角色消息不支持多模态内容块, 必须另起一条 user 消息承载图片。这是个很实际的兼容处理。

4.4 execute_tool:三层容错

app/agent/toolcall.py:174-216 把每种失败都变成字符串返回给模型,而不是抛异常:

失败情形返回给模型的内容
工具名不在表里Error: Unknown tool '<name>'
参数不是合法 JSONError: Error parsing arguments for <name>: Invalid JSON format
工具执行抛异常Error: ⚠️ Tool '<name>' encountered a problem: <msg>
执行成功Observed output of cmd `<name>` executed:\n<result>

这是 agent 设计里非常关键的一条纪律:错误也是观察。 把错误信息喂回模型, 模型有机会自己改参数重试;抛异常则直接中断整个任务。


5. 特殊工具:agent 怎么自己决定收工

5.1 机制

special_tool_names 是一个名字列表(app/agent/toolcall.py:31,默认只有 terminate)。 每次工具执行完都会走一遍 _handle_special_tool(app/agent/toolcall.py:218-226):

if not self._is_special_tool(name):
return
if self._should_finish_execution(name=name, result=result, **kwargs):
self.state = AgentState.FINISHED

于是「收工」这件事被拆成三个可替换的钩子:

钩子默认行为谁改过它
special_tool_names["terminate"]MCPAgent 也设成 ["terminate"]
_is_special_tool忽略大小写比名字
_should_finish_executionTrueMCPAgent 改成「名字等于 terminate 才收工」

MCPAgent 的覆盖在 app/agent/mcp.py:169-172——因为它的工具全是远程来的, 名字可能撞上,所以多加一道判断。

5.2 terminate 工具本身

Terminateexecute 只是返回一句话(app/tool/terminate.py:23-25), 真正让循环停下来的是上面那个副作用。工具本身不做任何事,这一点第一次读容易迷惑。


6. 智能体家族:每个只改了几行

智能体工具表关键差异文件
ManusPythonExecuteStrReplaceEditorAskHumanTerminate启动时自动连 MCP 服务器,默认挂 Browser Useapp/agent/manus.py:41-64
SWEAgentBashStrReplaceEditorTerminatenext_step_prompt 设成空串,只靠系统提示app/agent/swe.py:10-25
MCPAgent完全等于某台 MCP 服务器的工具每 5 步刷新一次工具清单app/agent/mcp.py:12-37
BrowserAgent继承 MCPAgent,连 uvx browser-use --cli-mcp只干浏览器的活app/agent/browser.py:83-114
DataAnalysisNormalPythonExecuteVisualizationPrepareDataVisualizationTerminate观察上限提到 15000app/agent/data_analysis.py:12-37
SandboxManus四个 Daytona 沙箱工具 + AskHumanTerminate工具全在远程云沙箱里跑app/agent/sandbox_agent.py:21-111

这张表就是这套架构的论证: 想造新智能体,改 available_tools + 两段提示词就够了。


7. Manus 的两条初始化路径

Manus 有个异步工厂 create()(app/agent/manus.py:75-81),因为「连 MCP 服务器」 是异步的,而 Pydantic 的 __init__ 不能 await。

但仓库里有两种用法,行为不同:

用法谁在用MCP 什么时候连上
await Manus.create()main.py:17实例化后立刻连
Manus()run_flow.py:12第一次 think() 时懒加载(app/agent/manus.py:197-203)

懒加载那条路径靠的是 think() 里的 if not self._initialized。 它保证了忘记用工厂方法也不会「无工具可用」,但代价是第一步会额外阻塞在建连接上


8. 代码地图

主题文件路径符号名
ReAct 分工app/agent/react.pyReActAgentReActAgent.step
想:问模型要工具app/agent/toolcall.pyToolCallAgent.think
做:执行并回写app/agent/toolcall.pyToolCallAgent.act
单个工具执行与容错app/agent/toolcall.pyToolCallAgent.execute_tool
特殊工具 → 收工app/agent/toolcall.pyToolCallAgent._handle_special_tool_should_finish_execution
工具资源清理app/agent/toolcall.pyToolCallAgent.cleanup
通用智能体app/agent/manus.pyManusManus.createManus.think
编程智能体app/agent/swe.pySWEAgent
浏览器智能体app/agent/browser.pyBrowserAgentBrowserContextHelper
数据分析智能体app/agent/data_analysis.pyDataAnalysis
收工工具app/tool/terminate.pyTerminate
tool_choice 枚举app/schema.pyToolChoice