结论先行: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
这条链路的关键不是模型“会不会思考”,而是系统把能力边界拆清楚:
- 模型只提出下一步动作。
- Agent 校验动作是否合法。
- 工具由 Agent 执行,而不是模型执行。
- 工具结果以 Observation 的形式进入下一轮上下文。
- Final 只能基于已有上下文、工具结果和任务目标生成。
所以 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 必须被结构化描述,至少包含:
- 工具名:要调用哪个能力。
- 输入:传给工具的参数。
- 调用约束:是否允许写入、是否需要审批、是否有超时和重试。
第一篇先只保留 tool 和 input,后面再扩展到工具注册表、动作校验和失败处理。
数据流
一次 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,只是一个会反复调用模型的脚本。