跳转至

第 02 步:ReAct 与工具调用

这一节学什么

  • create_react_node(...) 的核心链路
  • action 在 ReAct 里是什么意思
  • 怎么把 tool 注入 Agent
  • actions / messages / done / final_answer / rounds 是怎么演化的

你会运行什么

示例代码:

  • examples/getting_started/step_02_react_with_tool.py

运行:

python examples/getting_started/step_02_react_with_tool.py

核心概念

这一节第一次进入 jianmu 最典型的主路径:

  • LLM 决定是否调用工具
  • ToolExecutor 执行工具
  • 工具观察结果回到 messages
  • 最终收敛到 done=True 和 final_answer

如果你之前只看过最小聊天,这一步是第一次看到 Agent 真正变成“可行动”的运行时。

先说什么是 action

这一节第一次出现 actions 字段。

你可以先把它理解成:

  • 模型当前这一步产出的“工具调用意图”

它还不是最终答案,而是:

  • “我下一步想调用哪个 tool”
  • “我准备传什么参数”

后面 ToolExecutor 会消费这些 actions,真正去执行工具。

所以在 ReAct 链路里,一个很重要的区别是:

  • Step 02 的最小聊天:模型直接生成回复
  • Step 03 的 ReAct:模型可能先生成 actions,再进入工具执行

为什么 ReActState 是这四个字段

这个示例的 State 是:

class ReActState(BaseModel):
    messages: Annotated[list[Message], operator.add] = Field(default_factory=list)
    actions: Annotated[list[ToolCall], Ephemeral(scope="step")] = Field(default_factory=list)
    done: Annotated[bool, Ephemeral(scope="run")] = False
    final_answer: Annotated[str | None, Ephemeral(scope="run")] = None
    rounds: Annotated[int, Ephemeral(scope="run")] = 0

这五个字段不是随便选的,它们正好对应 ReAct preset 的默认状态协议。

messages

messages 是对话历史,也是 LLM 每一轮看到的主要输入。

它会包含:

  • user message
  • assistant message
  • assistant tool call
  • tool observation
  • 最终回答

如果少了 messages,LLM 节点就没有稳定的对话入口,tool observation 也没有地方回流。

actions

actions 是 LLM 到 ToolExecutor 的中间交接字段。

LLM 节点从 assistant response 里解析 tool call,然后写到 actions。 ToolExecutor 再读取 actions,真正执行对应工具。

这里把它标成:

Ephemeral(scope="step")

意思是它只在当前 step 内有效。 工具执行完以后,下一次 step 开始前会被清空,避免旧 tool call 被重复执行。

如果少了 actions,模型即使产出了 tool call,ToolExecutor 也拿不到要执行的动作。 如果它不是 step-scoped,就可能在下一轮被重复消费。

done

done 是 ReAct loop 的完成信号。

create_react_node(...) 里有一个 completion check:

只要 state.done 为真,就认为 ReAct 完成。

如果少了 done,loop 不知道什么时候该停,只能跑到 max_iterations。 如果字段名改了,但没有同步配置 keys,completion check 也会找不到它。

final_answer

final_answer 是 ReAct 默认的最终文本结果槽。

在常见的文本回答路径里,LLM 节点会在完成时同时写:

  • done = True
  • final_answer = ...

这两个字段职责不同:

  • done 负责停机
  • final_answer 负责承载最终文本

如果你的链路最终结果不是文本,而是别的结构化产物,那么是否写 final_answer 要看具体节点协议; 但对标准 ReAct 聊天链路来说,它通常仍然会有这个字段。

如果字段名改了,但没有同步配置 keys,最终文本结果也会写不到你预期的位置。

rounds

rounds 用来记录 ReAct/LLM 交互轮次。

它不是控制链路必须依赖的最核心字段,但对理解运行过程很有用。 你可以通过它看到模型到底调用了几轮,而不是误把行为树 tick 次数当成 LLM 轮次。

如果想改字段名怎么办

可以改,但要同步告诉 ReAct preset 新的 state keys。

默认情况下,ReActConfig.keys 使用的是:

messages -> "messages"
actions -> "actions"
final_answer -> "final_answer"
done -> "done"
rounds -> "rounds"

如果你的 State 想改成别的名字,比如 chat_history、pending_tools、answer,就需要传入自定义 StateKeys。

否则会出现一种典型问题:

  • State 里有字段
  • 但节点还在按默认名字读写
  • 最后表现为工具不执行、final answer 不回写、loop 无法结束

入门阶段建议先保留这四个默认字段名。 等你理解了 ReAct 主路径,再考虑自定义 ReActConfig.keys。

如果确实要改,State 字段和 ReActConfig.keys 要成对修改。

例如你想把默认字段改成:

messages -> chat_history
actions -> pending_tool_calls
done -> completed
final_answer -> answer
rounds -> turns

可以这样写:

from jianmu.config import ReActConfig, StateKeys


class CustomReActState(BaseModel):
    chat_history: Annotated[list[Message], operator.add] = Field(default_factory=list)
    pending_tool_calls: Annotated[list[ToolCall], Ephemeral(scope="step")] = Field(default_factory=list)
    completed: Annotated[bool, Ephemeral(scope="run")] = False
    answer: Annotated[str | None, Ephemeral(scope="run")] = None
    turns: Annotated[int, Ephemeral(scope="run")] = 0


root = create_react_node(
    model_client=model_client,
    tools=[CalculatorTool()],
    config=ReActConfig(
        keys=StateKeys(
            messages="chat_history",
            actions="pending_tool_calls",
            done="completed",
            final_answer="answer",
            rounds="turns",
        )
    ),
)

这段配置的意思是:

  • LLM 节点从 chat_history 读写消息
  • LLM 节点把 tool call 写入 pending_tool_calls
  • ToolExecutor 从 pending_tool_calls 读取并执行工具
  • loop 用 completed 判断是否完成
  • LLM 的最终文本结果写到 answer
  • 轮次计数写到 turns

也就是说,StateKeys 不是额外状态,而是告诉内置节点“应该去哪个 State 字段读写”。

create_react_node(...) 在帮你装什么

create_react_node(...) 不是普通 helper,它其实在帮你快速装配一条标准 ReAct 主路径。

你可以先把它理解成至少做了 4 件事:

  1. 创建一个 LLM 节点,负责决定下一步是直接回答,还是先调工具
  2. 创建一个 ToolExecutor,负责执行模型产出的 actions
  3. 把 tool observation 回写到 messages
  4. 用 loop 控制整条链路,直到 done=True 或达到终止条件

也就是说,这一节不是简单地“给 LLM 加一个 tool 列表”,而是在第一次引入一条真正的 agent loop。

max_iterations 是什么

这个例子里显式传了:

ReActConfig(max_iterations=5)

它表示:

  • 这条 ReAct loop 最多运行多少轮

这是一个非常重要的边界,因为 ReAct 不是一次性调用,而是:

  • 规划
  • 调工具
  • 看 observation
  • 再继续规划

如果没有类似 max_iterations 的限制,这条链路就可能无限循环。

默认 ReAct prompt 在这里怎么工作

这个例子虽然没有显式传 system_prompt,但 create_react_node(...) 仍然会给这条链路补上默认 ReAct prompt。

你现在可以先记住新的语义:

  • 如果 system_prompt is None
  • 使用默认 ReAct prompt
  • 如果你显式传了 system_prompt
  • 就表示你接管这一层 prompt,不再自动追加默认 ReAct prompt

这一节先知道“默认 ReAct prompt 会不会进入主路径”就够了。 更完整的 prompt 装配关系,下一节再展开。

运行后观察什么

  • Tool trace 会展示 assistant 发出的 tool call,以及 tool observation
  • messages 里会追加 tool observation
  • rounds 会增长,表示 ReAct 循环轮次
  • 结束时 done 会变成 True
  • final_answer 不是一开始就有,而是多轮推理后收敛出来

这个例子里建议你特别看三样东西:

  • Tool trace
  • Rounds
  • Final answer

因为它们分别对应:

  • 工具调用与 observation 回流
  • 循环次数
  • 最终收敛结果

注意:示例 State 里仍然有 actions 字段,但它是 Ephemeral(scope="step") 的临时字段。 它用于在单个 step 内把 LLM 产出的 tool call 交给 ToolExecutor 消费。 运行结束后它通常已经被清空,所以这个示例改为从 messages 中打印工具调用轨迹。

为什么日志里 tick 次数比 rounds 多

运行这个示例时,你可能会看到类似:

[Tick 1] Root Status: RUNNING
[Tick 2] Root Status: RUNNING
Iteration 1 incomplete; continuing to next round
...
[Tick 7] Root Status: SUCCESS
Rounds: 2

这不是异常。

要先区分两个概念:

  • tick 是行为树调度次数
  • rounds 是 ReAct/LLM 交互轮次

create_react_node(...) 生成的不是一个单函数,而是一棵行为树:

LoopUntilSuccess(ReActAgent)
  Sequence(ReActLoop)
    AgentLLMNode
    ToolExecutor
    StateCondition(CheckCompletion)

所以一次 ReAct 运行通常会被拆成多个 tick:

  1. LLM 节点开始异步生成,root 处于 RUNNING
  2. LLM 返回 tool call,ToolExecutor 执行工具
  3. 当前轮还没有 final_answer,进入下一轮
  4. LLM 带着 tool observation 再生成最终答案,并把 done 置为 True
  5. CheckCompletion 看到 done=True,root 变成 SUCCESS

因此,Tick 7 不表示模型调用了 7 轮。 真正更接近“模型交互轮次”的是 Rounds: 2。

为什么日志里会说 Iteration incomplete

你还可能看到:

Iteration 1 incomplete; continuing to next round

这里的 incomplete 不是工具执行失败,也不是任务失败。

在 ReAct loop 里,如果某一轮还没有把 done 置为 True,CheckCompletion 会返回 failure。 外层 LoopUntilSuccess 会把这个 failure 当成“本轮还没完成”,然后继续下一轮。

所以这句话更准确的含义是:

第 1 轮还没有完成信号,继续下一轮。

如果最后 root status 是 SUCCESS,并且有 final_answer,说明整个 ReAct 链路是正常完成的。

这一步学完后应该会什么

你应该能回答:

  • action 在 ReAct 里是什么意思
  • create_react_node(...) 在主路径里帮你装了什么
  • 为什么 ReAct 需要 max_iterations
  • 为什么 ReAct 比最小聊天多了一层“规划 -> 工具执行 -> observation 回流”的机制

下一步

下一节回答一个常见问题:prompt 到底从哪里来。