Skip to content

ReAct(三):用 Rust 实现一个最小可运行 Agent

Published: at 12:50 AM

结论先行:最小 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, &registry, goal, 8);

    print_trace(&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 不是一次性生成答案,而是让外部工具结果逐步改变上下文。

边界条件

这个最小实现已经包含几个必要防线:

但它还没有解决真实系统必须面对的问题:

扩展方向

接真实 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 必须被标记成不可信数据,并在提示词和执行器里维持这个边界。

第三个漏洞是没有副作用等级。lookupadd 都是只读纯工具,所以可以简单执行。只要换成写文件、发请求或改数据库,就必须增加审批、幂等、审计和回滚策略。

维护成本

这个最小 Agent 的好处是边界清楚。替换模型、增加工具、改解析器、加权限策略,都有明确落点。

真正的维护成本会出现在工具数量增长以后:工具 schema、权限策略、错误分类、trace 压缩和用户可见日志都会变成共享基础设施。如果一开始把这些逻辑写死在主循环里,后面每加一个工具都会复制一套不完整策略。

所以第三篇的核心结论是:最小实现可以很短,但边界不能省。ReAct 的工程价值不在循环本身,而在循环中每一次动作都可校验、可记录、可停止、可替换。

参考文献

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