数据截至 (上游 commit 3309bf4e416f)
02 — ReActAgent / ToolCallAgent:把模型说的话变成真的动作
这章讲什么: OpenManus 的心脏。先看
ReActAgent怎么把一步切成两半, 再逐行拆ToolCallAgent的think()与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(默认) | 自己决定 | 有工具调用或有文字则 True | ToolCallAgent 的默认值 |
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]
Manus 把 max_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 消息承载图片。这是个很实际的兼容处理。