结论先行:最小 ReAct Agent 不需要框架。只要有模型接口、动作解析、工具注册表、执行器、trace 和停止条件,就能跑出完整的 Thought -> Action -> Observation -> Final 循环。
这一篇用单文件 Rust 实现。它不接真实 LLM API,而是用 MockModel 模拟模型输出。这样可以先验证状态机和边界,再把模型层替换成真实 provider。
运行目标
示例要完成的任务是:
查出 huala 的文章数量,再加 2
模型会依次提出两个动作:
lookup: huala.post_count
add: 8,2
Agent 执行工具后,把 Observation 写回 trace,直到模型输出 Final。
flowchart TD
Main[main] --> Run[run_agent]
Run --> Next[Model::next]
Next --> Parse[parse_model_output]
Parse -->|Thought| PushThought[trace.push]
Parse -->|Final| PushFinal[trace.push]
Parse -->|解析失败| PushParserError[trace.push]
Parse -->|Action| PushAction[trace.push]
PushAction --> Exec[execute_action]
Exec --> Get[ToolRegistry::get]
Get -->|找到工具| ToolRun[Tool::run]
Get -->|未知工具| PushObservation[trace.push]
ToolRun --> PushObservation[trace.push]
PushThought --> Run
PushObservation --> Run
PushParserError --> Run
PushFinal --> Print[print_trace]
Run -->|达到上限| Print
完整代码
use std::collections::HashMap;
#[derive(Debug, Clone)]
enum Step {
Thought(String),
Action(Action),
Observation(Observation),
Final(String),
}
#[derive(Debug, Clone)]
struct Action {
tool: String,
input: String,
}
#[derive(Debug, Clone)]
struct Observation {
tool: String,
ok: bool,
output: String,
}
trait Model {
fn next(&mut self, goal: &str, trace: &[Step]) -> String;
}
trait Tool {
fn name(&self) -> &'static str;
fn run(&self, input: &str) -> Observation;
}
struct ToolRegistry {
tools: HashMap<String, Box<dyn Tool>>,
}
impl ToolRegistry {
fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
fn register<T: Tool + 'static>(&mut self, tool: T) {
self.tools.insert(tool.name().to_string(), Box::new(tool));
}
fn get(&self, name: &str) -> Option<&dyn Tool> {
self.tools.get(name).map(|tool| tool.as_ref())
}
}
struct LookupTool {
data: HashMap<String, String>,
}
impl LookupTool {
fn new() -> Self {
let mut data = HashMap::new();
data.insert("huala.post_count".to_string(), "8".to_string());
Self { data }
}
}
impl Tool for LookupTool {
fn name(&self) -> &'static str {
"lookup"
}
fn run(&self, input: &str) -> Observation {
match self.data.get(input.trim()) {
Some(value) => Observation {
tool: self.name().to_string(),
ok: true,
output: value.clone(),
},
None => Observation {
tool: self.name().to_string(),
ok: false,
output: format!("missing key: {}", input),
},
}
}
}
struct AddTool;
impl Tool for AddTool {
fn name(&self) -> &'static str {
"add"
}
fn run(&self, input: &str) -> Observation {
let mut parts = input.split(',').map(|part| part.trim().parse::<i64>());
let left = parts.next();
let right = parts.next();
let extra = parts.next();
match (left, right, extra) {
(Some(Ok(a)), Some(Ok(b)), None) => Observation {
tool: self.name().to_string(),
ok: true,
output: (a + b).to_string(),
},
_ => Observation {
tool: self.name().to_string(),
ok: false,
output: "expected input like `8,2`".to_string(),
},
}
}
}
struct MockModel;
impl Model for MockModel {
fn next(&mut self, goal: &str, trace: &[Step]) -> String {
let observations: Vec<&Observation> = trace
.iter()
.filter_map(|step| match step {
Step::Observation(observation) => Some(observation),
_ => None,
})
.collect();
match observations.as_slice() {
[] => format!(
"Thought: I need to look up the post count before answering `{}`.\nAction: lookup: huala.post_count",
goal
),
[first] if first.ok => format!(
"Thought: The lookup returned {}. I should add 2.\nAction: add: {},2",
first.output, first.output
),
[_, second] if second.ok => format!(
"Thought: The addition result is {}.\nFinal: huala 的文章数量加 2 等于 {}。",
second.output, second.output
),
[last, ..] => format!(
"Thought: The last tool call failed with `{}`.\nFinal: 无法完成任务:{}",
last.output, last.output
),
}
}
}
fn parse_model_output(output: &str) -> Result<Vec<Step>, String> {
let mut steps = Vec::new();
for line in output.lines().map(str::trim).filter(|line| !line.is_empty()) {
if let Some(value) = line.strip_prefix("Thought:") {
steps.push(Step::Thought(value.trim().to_string()));
continue;
}
if let Some(value) = line.strip_prefix("Action:") {
let action = parse_action(value.trim())?;
steps.push(Step::Action(action));
continue;
}
if let Some(value) = line.strip_prefix("Final:") {
steps.push(Step::Final(value.trim().to_string()));
continue;
}
return Err(format!("unrecognized model line: {}", line));
}
if steps.is_empty() {
return Err("model returned no parseable steps".to_string());
}
Ok(steps)
}
fn parse_action(raw: &str) -> Result<Action, String> {
let Some((tool, input)) = raw.split_once(':') else {
return Err(format!("invalid action: {}", raw));
};
let tool = tool.trim();
let input = input.trim();
if tool.is_empty() || input.is_empty() {
return Err(format!("invalid action: {}", raw));
}
Ok(Action {
tool: tool.to_string(),
input: input.to_string(),
})
}
fn execute_action(action: &Action, registry: &ToolRegistry) -> Observation {
if action.input.len() > 1024 {
return Observation {
tool: action.tool.clone(),
ok: false,
output: "tool input is too large".to_string(),
};
}
match registry.get(&action.tool) {
Some(tool) => tool.run(&action.input),
None => Observation {
tool: action.tool.clone(),
ok: false,
output: format!("unknown tool: {}", action.tool),
},
}
}
fn run_agent<M: Model>(
model: &mut M,
registry: &ToolRegistry,
goal: &str,
max_steps: usize,
) -> Vec<Step> {
let mut trace = Vec::new();
let mut error_count = 0usize;
for _ in 0..max_steps {
let output = model.next(goal, &trace);
println!("MODEL\n{}\n", output);
let parsed = match parse_model_output(&output) {
Ok(steps) => steps,
Err(error) => {
trace.push(Step::Observation(Observation {
tool: "parser".to_string(),
ok: false,
output: error,
}));
error_count += 1;
if error_count >= 3 {
trace.push(Step::Final("模型输出连续解析失败。".to_string()));
break;
}
continue;
}
};
let mut reached_final = false;
for step in parsed {
match step {
Step::Action(action) => {
trace.push(Step::Action(action.clone()));
let observation = execute_action(&action, registry);
if !observation.ok {
error_count += 1;
}
trace.push(Step::Observation(observation));
}
Step::Final(answer) => {
trace.push(Step::Final(answer));
reached_final = true;
break;
}
Step::Thought(thought) => {
trace.push(Step::Thought(thought));
}
Step::Observation(_) => {
error_count += 1;
}
}
}
if reached_final || error_count >= 3 {
break;
}
}
if !matches!(trace.last(), Some(Step::Final(_))) {
trace.push(Step::Final("达到最大步数,任务未完成。".to_string()));
}
trace
}
fn print_trace(trace: &[Step]) {
println!("TRACE");
for (index, step) in trace.iter().enumerate() {
match step {
Step::Thought(value) => println!("{index}. Thought: {value}"),
Step::Action(action) => {
println!("{index}. Action: {}({})", action.tool, action.input);
}
Step::Observation(observation) => println!(
"{index}. Observation: {} ok={} output={}",
observation.tool, observation.ok, observation.output
),
Step::Final(answer) => println!("{index}. Final: {answer}"),
}
}
}
fn main() {
let goal = "查出 huala 的文章数量,再加 2";
let mut registry = ToolRegistry::new();
registry.register(LookupTool::new());
registry.register(AddTool);
let mut model = MockModel;
let trace = run_agent(&mut model, ®istry, goal, 8);
print_trace(&trace);
}
代码结构
这个实现的核心边界是:
Model:只负责根据 goal 和 trace 产生下一段模型输出。parse_model_output:只负责把文本解析成结构化步骤。ToolRegistry:只保存宿主允许调用的工具。execute_action:只负责校验和执行动作。trace:保存事实历史,连接下一轮模型调用。
MockModel 不是 Agent 逻辑。它只是把真实模型替换成可预测输出,方便验证状态机。以后接真实 LLM 时,应该只替换 Model trait 的实现,而不是重写主循环。
预期输出
运行后会看到模型每轮输出和最终 trace。关键链路应该类似:
0. Thought: I need to look up the post count before answering `查出 huala 的文章数量,再加 2`.
1. Action: lookup(huala.post_count)
2. Observation: lookup ok=true output=8
3. Thought: The lookup returned 8. I should add 2.
4. Action: add(8,2)
5. Observation: add ok=true output=10
6. Thought: The addition result is 10.
7. Final: huala 的文章数量加 2 等于 10。
这说明 Agent 不是一次性生成答案,而是让外部工具结果逐步改变上下文。
边界条件
这个最小实现已经包含几个必要防线:
max_steps防止无限循环。error_count防止连续解析或执行错误。- 未知工具会变成失败 Observation。
- 工具输入有长度上限。
- 解析失败不会直接 panic。
但它还没有解决真实系统必须面对的问题:
- 没有 JSON schema,动作格式仍然脆弱。
- 没有权限模型,无法区分只读工具和写工具。
- 没有超时、取消和并发控制。
- 没有上下文截断,trace 会持续增长。
- 没有工具输出隔离,Observation 里的提示注入仍可能影响下一轮模型。
- 没有幂等键,写工具不能安全重试。
扩展方向
接真实 LLM API 时,不应该让 API 调用散落在主循环里。更稳的方式是新增一个实现 Model 的 provider:
struct ApiModel {
api_key: String,
}
impl Model for ApiModel {
fn next(&mut self, goal: &str, trace: &[Step]) -> String {
todo!("把 goal 和 trace 转成请求,调用模型 API,再返回模型文本或结构化响应")
}
}
真实系统还应该把 Action 改成结构化参数,而不是 tool: input 字符串。比如:
struct Action {
tool: String,
args_json: String,
call_id: String,
}
call_id 很重要。它让 Observation 可以准确对应到某一次工具调用,也方便重试、审计和恢复。
设计漏洞
这个示例最明显的漏洞是 Parser 过于乐观。它依赖固定前缀 Thought:、Action:、Final:,真实模型只要输出格式漂移就会失败。生产系统应该使用模型原生工具调用、JSON schema 或严格结构化输出。
第二个漏洞是工具输出没有隔离。LookupTool 返回的只是数字,所以看不出问题。如果工具返回网页、邮件或命令输出,其中可能包含恶意指令。Observation 必须被标记成不可信数据,并在提示词和执行器里维持这个边界。
第三个漏洞是没有副作用等级。lookup 和 add 都是只读纯工具,所以可以简单执行。只要换成写文件、发请求或改数据库,就必须增加审批、幂等、审计和回滚策略。
维护成本
这个最小 Agent 的好处是边界清楚。替换模型、增加工具、改解析器、加权限策略,都有明确落点。
真正的维护成本会出现在工具数量增长以后:工具 schema、权限策略、错误分类、trace 压缩和用户可见日志都会变成共享基础设施。如果一开始把这些逻辑写死在主循环里,后面每加一个工具都会复制一套不完整策略。
所以第三篇的核心结论是:最小实现可以很短,但边界不能省。ReAct 的工程价值不在循环本身,而在循环中每一次动作都可校验、可记录、可停止、可替换。