Skip to content

ReAct(一):Reason + Act 的 Agent 基本模型

Published: at 12:30 AM

结论先行:ReAct 不是前端框架 React,也不是一句“请一步一步思考”的提示词。它是一种 Agent 运行契约:模型在上下文中交替产生推理、动作和最终答案,宿主 Agent 负责执行动作,把外部世界返回的观察结果再写回上下文。

最小形式可以写成:

Thought -> Action -> Observation -> Thought -> Action -> Observation -> ... -> Final

也可以画成流程图:

flowchart TD
  Goal[用户目标] --> Trace[Trace]
  Trace --> Model[Model]
  Model -->|Thought| Trace
  Model -->|Final| Done[返回最终答案]
  Model -->|Action 意图| Agent[Agent 校验和路由]
  Agent --> Tool[Tool]
  Tool --> Observation[Observation]
  Observation --> Trace

这条链路的关键不是模型“会不会思考”,而是系统把能力边界拆清楚:

所以 ReAct 的本质是一个带外部副作用边界的状态循环。

基本建模

先把 ReAct 当成事件流,而不是当成 prompt 技巧。

#[derive(Debug, Clone)]
enum ReActEvent {
    Thought(String),
    Action(Action),
    Observation(Observation),
    Final(String),
}

#[derive(Debug, Clone)]
struct Action {
    tool: String,
    input: String,
}

#[derive(Debug, Clone)]
struct Observation {
    tool: String,
    output: String,
}

这个模型刻意没有把 Action 写成任意字符串。因为工程实现里真正危险的不是模型写错一句话,而是模型把“想要做什么”和“宿主实际执行什么”混在一起。

Action 必须被结构化描述,至少包含:

第一篇先只保留 toolinput,后面再扩展到工具注册表、动作校验和失败处理。

数据流

一次 ReAct 循环可以建模成下面这样:

用户目标

Agent 构造上下文

模型输出 Thought / Action / Final

如果是 Action,Agent 查找工具并执行

工具返回 Observation

Agent 把 Observation 追加到上下文

继续请求模型,直到 Final 或触发停止条件

这条数据流里有两个稳定边界。

第一个边界在模型和 Agent 之间。模型输出的是意图,不是权限。它可以说“调用搜索工具查资料”,但不能直接访问网络、文件系统或数据库。

第二个边界在 Agent 和工具之间。Agent 不应该把模型原文直接交给任意工具执行,而要先解析、校验、路由和记录 trace。工具返回的也不应该是自由散落的日志,而应该被包装成 Observation。

用 Rust 写成接口,大概是:

trait Model {
    fn next(&mut self, trace: &[ReActEvent]) -> ReActEvent;
}

trait Tool {
    fn name(&self) -> &'static str;
    fn run(&self, input: &str) -> Observation;
}

这里 trace 是模型可见历史。模型每次根据完整上下文决定下一步。工具不读取模型内部状态,只接收被 Agent 认可的输入。

运行契约

ReAct 的运行契约可以压缩成三条规则:

模型负责选择下一步
Agent 负责执行下一步
Trace 负责连接每一步

所以 Agent 主循环不是“调用一次 LLM 然后返回”,而是:

fn run<M: Model>(model: &mut M, max_steps: usize) -> Vec<ReActEvent> {
    let mut trace = Vec::new();

    for _ in 0..max_steps {
        let event = model.next(&trace);
        let is_done = matches!(event, ReActEvent::Final(_));

        trace.push(event);

        if is_done {
            break;
        }
    }

    trace
}

这段代码还没有执行工具,故意保持不完整。它只说明一个事实:ReAct 必须有循环边界。没有 max_steps 的 Agent 不是自动化能力,而是潜在的无限循环。

当加入工具以后,状态变化会多一步:

Model -> Action
Agent -> Tool
Tool -> Observation
Agent -> Trace
Trace -> Model

Observation 不是模型自己编出来的内容,而是宿主系统执行动作后的外部反馈。它可以是搜索结果、数据库查询结果、命令输出、API 响应,也可以是错误。

一个小例子

用户目标:

计算 huala 的文章数量再加 2

模型可能输出:

Thought: 我需要先查 huala 的文章数量。
Action: lookup("huala.post_count")
Observation: 8
Thought: 现在把 8 加 2。
Action: add("8,2")
Observation: 10
Final: huala 的文章数量加 2 等于 10。

这里最容易误解的是 Thought。在工程系统里,不一定要把完整推理暴露给用户,也不一定要保存所有中间思维。真正必须保存的是可审计的执行链路:模型请求了什么动作,Agent 是否允许,工具返回了什么结果,最终答案依赖哪些 Observation。

因此,更稳定的 trace 通常是:

UserGoal
Action
Observation
Action
Observation
Final

Thought 可以作为调试信息、摘要信息或模型内部状态,但不应该成为唯一的系统状态来源。

设计漏洞

ReAct 的简洁模型隐藏了几个直接风险。

第一,模型输出不是可靠协议。只要 Action 还依赖自然语言解析,就会出现格式漂移、字段缺失和歧义调用。工程实现应该尽量使用结构化输出或工具调用协议。

第二,Observation 会污染后续上下文。如果工具返回了提示注入内容,模型下一轮可能把它当成系统指令执行。Observation 必须被标记为工具结果,而不是提升为开发者指令。

第三,循环会放大错误。一次错误解析可能导致错误工具调用,错误 Observation 又会诱导下一轮错误动作。必须有最大步数、错误计数和可终止状态。

第四,Trace 会不断膨胀。每次 Observation 都进入上下文,长任务会迅速消耗 token。实际系统需要摘要、截断和结构化存储策略。

维护成本

ReAct 的长期维护成本不在代码量,而在边界一致性。

如果模型层开始直接拼接工具结果,Agent 层开始理解具体业务语义,工具层开始读取全局对话状态,系统会很快失去可测试性。后续排查问题时,你无法判断错误来自模型判断、动作解析、工具副作用,还是上下文污染。

所以第一篇的核心结论是:先把 ReAct 建成一个事件循环模型,再谈提示词、工具数量和具体框架。没有清晰边界的 Agent,只是一个会反复调用模型的脚本。

参考文献

  1. ReAct: Synergizing Reasoning and Acting in Language Models
  2. ReAct project