跳到主要内容

想-做循环与工具调用:把「LLM 说的话」变成「真的动作」

这章讲什么: 上一章的 BaseAgent 只定义了"反复走一步",但一步到底做什么没说。这一章就是答案——ReActAgent 把一步拆成"想"和"做",ToolCallAgent 把它落地成"问 LLM 该调哪个工具、然后真去调"。这是整个项目工程含量最高的一层。


1. ReAct:把「一步」拆成想 + 做

1.1 它要解决的小问题

"一步"其实包含两件事:先想清楚该干嘛,再动手去干。把这两件事揉在一起写会很乱,所以 ReActAgentapp/agent/react.py:11)把 step() 拆成两个抽象方法:

# 示意,非源码:ReActAgent 的 step 就是想 + 做
async def step(self):
should_act = await self.think() # 想:决定要不要行动、行动什么
if not should_act:
return "Thinking complete - no action needed"
return await self.act() # 做:执行想好的动作

真实实现见 app/agent/react.py:33ReActAgent.stepReAct = Reasoning(推理)+ Acting(行动)交替进行——这是现代 agent 最主流的范式。

1.2 think/act 仍是抽象的

ReActAgent 只声明 think() / act() 是抽象方法(app/agent/react.py:25:29),怎么想、怎么做还没说。填空的是它的子类 ToolCallAgent


2. ToolCallAgent:think = 向 LLM 要工具调用

ToolCallAgentapp/agent/toolcall.py:18)是所有实用 agent(ManusBrowserAgentMCPAgent…)的直接父类。

2.1 think() 干了什么

ToolCallAgent.thinkapp/agent/toolcall.py:39)的主线:

think():
1. 若有 next_step_prompt,作为 user 消息追加进 memory
2. 调 llm.ask_tool(
messages = 全部记忆,
system_msgs = 系统提示,
tools = 工具箱转成的 function 定义, ← available_tools.to_params()
tool_choice = auto / required / none
)
3. 把 LLM 返回的 tool_calls 存到 self.tool_calls
4. 把 LLM 的回答(含 tool_calls)作为 assistant 消息写回 memory
5. 返回「要不要 act」的布尔值

关键点:LLM 不直接执行任何东西。它只是被告知"你有这些工具"(通过 tools 参数),然后回一段结构化的 tool_calls——每个 tool_call 说"调哪个工具、参数是什么"。真正执行发生在 act。

llm.ask_toolapp/llm.py:644)是这层的"电话线":它把消息 + 工具定义发给 OpenAI 兼容接口,tool_choice 强制/建议模型返回工具调用,拿回一个 ChatCompletionMessage

2.2 tool_choice 三种模式

think() 会根据 tool_choicesapp/schema.py:20ToolChoice)分情况处理:

模式含义think 行为
none禁止用工具有 content 就当普通回答收下,返回
auto(默认)自主决定有工具调工具;没工具但有话说,也当有效输出
required必须用工具没返回工具调用会在 act 里报错

2.3 token 超限的优雅降级(一个坑的处理)

ask_tool 内部若因 token 超限抛出 TokenLimitExceededthink() 会捕获它(甚至是被 tenacity 的 RetryError 包着的),写一条"达到 token 上限"的 assistant 消息,然后把状态直接置 FINISHED 干净收尾,而不是让异常炸穿整个循环(app/agent/toolcall.py:60)。


3. ToolCallAgent:act = 真的执行工具

3.1 act() 主线

ToolCallAgent.actapp/agent/toolcall.py:131):

act():
若没有 tool_calls:
required 模式 → 报错
否则 → 直接返回最后一条消息内容(当作纯文本回答)
否则 逐个 tool_call:
result = execute_tool(command) # 真执行
若设了 max_observe,截断 result 长度
把 result 作为 tool 消息写回 memory(带 tool_call_id 关联)
返回所有结果拼接

每个工具结果都以 tool 角色、带上对应的 tool_call_id 写回记忆——这样下一轮 LLM 就能看到"我上次调的工具返回了什么",形成闭环。

3.2 execute_tool:稳健地跑一个工具

execute_toolapp/agent/toolcall.py:166)是单个工具调用的执行器,处理得很稳健:

execute_tool(command):
1. 校验命令格式、工具名是否在工具箱里
2. json.loads 解析参数字符串(失败 → 返回 JSON 错误,不崩)
3. available_tools.execute(name, args) ← 真正调用工具
4. _handle_special_tool(name, result) ← 处理 terminate 之类
5. 若结果带 base64_image,暂存起来给 tool 消息用(多模态)
6. 把结果包装成「Observed output of cmd ...」返回
异常 → 记日志并返回 Error 字符串(不让单个工具失败拖垮循环)

设计哲学:工具失败不抛异常终止 agent,而是把错误信息当成一次观察结果喂回 LLM,让它自己看到"这工具报错了"并调整——这正是 ReAct 的精髓。


4. terminate:特殊工具如何结束循环

还记得上一章 run() 的出口是 state == FINISHED 吗?谁来把状态置成 FINISHED? 答案是"特殊工具"机制。

4.1 机制

  • 每个 agent 有 special_tool_names,默认含 terminateapp/agent/toolcall.py:31)。
  • _handle_special_toolapp/agent/toolcall.py:210):执行完的工具若在特殊名单里,且 _should_finish_execution 返回 True,就把 self.state = FINISHED
  • 基类的 _should_finish_execution 永远返回 True(app/agent/toolcall.py:220)——即任何特殊工具一执行就结束。

4.2 terminate 工具本体

Terminateapp/tool/terminate.py:8)本身几乎什么都不做——只接受一个 status(success/failure)并返回一句话。它的"威力"不在执行体,而在于它的名字在特殊名单里,一旦被调用就触发状态收尾。

流程:LLM 决定「活干完了」→ 返回 tool_call: terminate(status=success)
→ act 执行 terminate → _handle_special_tool → state=FINISHED
→ 下一轮 while 判断 state==FINISHED → 跳出循环 → 结束

子类可覆盖收尾条件:例如 MCPAgent._should_finish_executionapp/agent/mcp.py:167)只在工具名恰好是 "terminate" 时才结束。


5. Manus:把这一切组装成成品

Manusapp/agent/manus.py:18)是 ToolCallAgent 的成品子类,主要做三件事:

  1. 配齐工具箱PythonExecute + BrowserUseTool + StrReplaceEditor + AskHuman + Terminateapp/agent/manus.py:34)。
  2. 接 MCPcreate() 工厂方法在实例化后连接配置里的 MCP 服务器(app/agent/manus.py:59),把远程工具动态加进工具箱(详见 §03)。
  3. 浏览器上下文注入think()app/agent/manus.py:140)会检查最近几步是否用了浏览器;用了的话,就把"当前网页 URL/标题/截图"格式化进下一步提示词,让 LLM "看得见"浏览器状态。

注意 Manuscreate() 异步工厂而非直接构造——因为连 MCP 是异步的,Pydantic 的同步 __init__ 里做不了。


6. 关键细节 / 坑

  • assistant 消息一定要在执行工具前写回think() 里先把带 tool_calls 的 assistant 消息 add_messageact() 才逐个执行并写 tool 消息。顺序反了会破坏"assistant 提出调用 → tool 返回结果"的配对,OpenAI 接口会报错。
  • max_observe 截断:工具结果可能极长(比如网页全文),Manusmax_observe=10000 截断,避免撑爆上下文。
  • stuck 提示是临时的Manus.think 会先备份 next_step_prompt、注入浏览器上下文、调用父类 think、再还原原提示(app/agent/manus.py:146:163),避免污染下一轮。

7. 代码地图

主题文件路径符号名
想-做拆分app/agent/react.pyReActAgent.step, ReActAgent.think, ReActAgent.act
think 实现app/agent/toolcall.pyToolCallAgent.think
act 实现app/agent/toolcall.pyToolCallAgent.act
单工具执行app/agent/toolcall.pyToolCallAgent.execute_tool
特殊工具收尾app/agent/toolcall.pyToolCallAgent._handle_special_tool, _should_finish_execution
LLM 工具调用app/llm.pyLLM.ask_tool
terminate 工具app/tool/terminate.pyTerminate
成品组装app/agent/manus.pyManus, Manus.create, Manus.think
tool_choice 枚举app/schema.pyToolChoice