ReAct:让 LLM Agent 从“会说”到“会做”

你可能听过 ReAct(Reason + Act),也可能在很多 Agent 框架里“被动使用过它”——模型先推理(Reason),再行动(Act),用工具结果(Observation)来纠错并继续推进。

这篇文章只做一件事:把 ReAct 讲清楚、讲到能落地。我会刻意用 ReAct 的表达方式来写:用 Thought / Action / Observation / Reflection 推进叙事,就像在读一段“可执行的工程推理”。(说明:这里的 Thought 是工程判断点,不展开冗长推演。)

ReAct Step 1:ReAct 到底解决什么问题

1
2
3
4
5
6
7
Thought: 为什么我需要 ReAct,而不是让模型一次性给“最终答案”?
Action: 列出单次生成在工程里的典型失败方式。
Observation:
- 问题复杂时,模型容易“讲得像对的”,但过程不可控、不可验证
- 一旦涉及外部事实(网页/DB/文件),模型需要 grounding,否则就是幻觉风险
- 真正的工程任务需要执行:查数据、改文件、跑命令、读日志,而不是只写说明
Reflection: ReAct 的价值是把“生成文本”变成“可执行的循环”,并用 Observation 把模型拉回现实。

ReAct Step 2:ReAct 的最小闭环长什么样

1
2
3
4
5
6
7
Thought: 我需要一个足够稳定的步骤格式,让系统能驱动工具调用。
Action: 规定输出协议(协议比 prompt 更重要)。
Observation: 最小闭环通常是:
- Thought(思考):决定下一步(建议只在内部记录/不对用户展示)
- Action(行动):选择工具或 NONE
- Observation(观察):工具结果
Reflection: ReAct 不是“让模型多想想”,而是给系统一个可解析、可编排的执行接口。

ReAct Step 3:实践(Ruby 实现最小可用 ReAct 循环)

这个例子刻意“笨一点”:不追求框架化,只把 ReAct 的最小闭环做出来,便于你迁移到任何语言/框架。

关键点很简单:

  • @scratchpad 记录每一步 Thought/Action/Observation,让模型“看见自己刚刚做过什么”
  • 每次调用 LLM 时,把 @scratchpad 塞回 prompt 里,形成多轮闭环
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
require "openai"

class MiniReAct
  def initialize(model: "gpt-4.1-mini")
    @client = OpenAI::Client.new
    @model = model
    @scratchpad = ""     # ReAct 轨迹(Thought/Action/Observation)
  end

  def append(text)
    @scratchpad << text
  end

  def step(user_input)
    prompt = <<~PROMPT

      You are a ReAct agent.

      Follow the format:
      Thought: <your reasoning>    # keep it short; internal notes
      Action: <tool name or NONE>
      Observation: <result of action>

      Current scratchpad:
      #{@scratchpad}

      User input:
      #{user_input}

      Continue the next step.
    PROMPT

    res = @client.chat(
      parameters: {
        model: @model,
        messages: [{ role: "user", content: prompt }]
      }
    )

    output = res["choices"][0]["message"]["content"]
    append(output + "\n")
    output
  end

  def run(user_input)
    3.times do |i|
      puts "\n=== STEP #{i + 1} ==="
      out = step(user_input)
      puts out

      # 如果模型说 Action: NONE → 停止
      break if out.include?("Action: NONE")
    end

    puts "\n=== FINAL SCRATCHPAD ==="
    puts @scratchpad
  end
end

# 使用示例
# agent = MiniReAct.new
# agent.run("What is 12 * 13?")
# agent.run("明天是周几?")
1
2
3
4
Thought: 这个“最小循环”到底让系统多了什么能力?
Action: 对比“单次回答”与“循环 + Observation”。
Observation: 你现在可以把外部信息(工具结果)写回轨迹,再让模型基于新事实继续规划。
Reflection: 这就是 ReAct 的核心:把不确定性留给下一轮,用 Observation 驱动纠错。

ReAct Step 4:实践进阶(加入工具调用:Action → 真执行)

更接近真实系统时,你会加入:

  • tools:把行动从“文本”变成“可执行的函数调用”
  • memory:把长期信息从“长文本”变成“结构化对象”

但 ReAct 的闭环不变:模型先产出 Action,你去执行,再把 Observation 写回去

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
require "openai"
require "json"

module Reactor
  class Agent
    attr_reader :scratchpad, :memory

    def initialize(model: "gpt-4.1-mini", tools: [])
      @client = OpenAI::Client.new
      @model = model
      @tools = tools
      @scratchpad = ""
      @memory = {}
    end

    # 写入 scratchpad
    def append(text)
      @scratchpad << text
    end

    # 执行一步
    def step(user_input)
      prompt = build_prompt(user_input)

      res = @client.chat(
        parameters: {
          model: @model,
          messages: [{ role: "user", content: prompt }]
        }
      )

      content = res["choices"][0]["message"]["content"]
      append(content + "\n")

      # 解析 Action
      action = content.match(/Action:\s*(.+)/)&.captures&.first&.strip
      if action && action != "NONE" && tool = @tools.find { |t| t.name == action }
        args_json = content.match(/Arguments:\s*(\{.*\})/)&.captures&.first
        args = args_json ? JSON.parse(args_json) : {}
        result = tool.call(args)
        append("Observation: #{result}\n")
      end

      content
    end

    # 循环执行
    def run(user_input, max_steps: 10)
      max_steps.times do |i|
        puts "\n=== STEP #{i + 1} ==="
        output = step(user_input)
        puts output
        break if output.include?("Answer:") || output.include?("Action: NONE")
      end

      puts "\n=== FINAL SCRATCHPAD ==="
      puts @scratchpad
    end

    private

    # 构建 prompt,包含 scratchpad + memory
    def build_prompt(user_input)
      <<~PROMPT
      You are a ReAct agent. Follow this extended format:

      Thought: <your reasoning>
      Action: <tool name or NONE>
      Arguments: <JSON arguments if tool>
      Observation: <result of action>
      Reflection: <self critique or check>
      Fix: <correction if needed>
      Plan: <next subgoal or step>
      Answer: <final answer when ready>

      Current memory:
      #{@memory.to_json}

      Current scratchpad:
      #{@scratchpad}

      User input:
      #{user_input}

      Continue the next step.
      PROMPT
    end
  end

  # 工具抽象
  class Tool
    attr_reader :name

    def initialize(name, &block)
      @name = name
      @block = block
    end

    def call(args)
      @block.call(args)
    end
  end
end
1
2
3
4
Thought: 为什么需要先写入模型输出,再执行工具?
Action: 把“可审计性”当成一等公民:先把 Action 记录下来再执行。
Observation: 线上排障时你能回答“模型当时想做什么、传了什么参数、工具返回了什么”。
Reflection: ReAct 的工程价值不止是准确率,更是可观测、可复现、可治理。

这段代码里,ReAct 的执行顺序很清晰:

  • LLM 输出一段计划(含 Action / Arguments)→ 你把它写入轨迹(scratchpad/trace)
  • 你执行工具 → 把 Observation 也写回轨迹
  • 下一轮规划时,模型看到的是“完整的已发生轨迹”,因此能稳定地连续推理

ReAct Step 5:踩坑点 / 常见误区(ReAct 很容易“跑偏”)

  • 把 Thought 当作“给用户看的解释”:不要。Thought 更像内部控制信号;对用户展示建议只给结论/依据/引用,避免暴露内部草稿与敏感信息。
  • 用正则解析 Action/Arguments 太脆弱:LLM 输出很容易偏格式。更稳的做法是让模型产出严格 JSON(或使用框架的结构化工具调用),并做 schema 校验。
  • Observation 不做截断/摘要:工具返回可能很大(网页、日志、SQL 结果集)。如果 Observation 不治理,上下文会被“结果噪声”淹没,模型会变笨。
  • 工具调用没做安全边界:需要 allowlist、超时、重试、权限隔离、以及 prompt injection 防护(尤其是 Web 工具)。
  • 循环停不下来:必须有 max_steps、明确的停止条件(Answer / Action: NONE)、以及失败兜底策略(降级为解释 + 下一步建议)。

ReAct Step 6:生产环境建议(让 ReAct “可控地跑”)

  • 优先用结构化工具调用:让 Action/Arguments 变成 schema(JSON Schema / typed tool calling),并做严格校验与拒绝策略。
  • 把轨迹做成结构化事件:每一步 {thought?, action, args, observation, timestamp},方便检索、评估与回放。
  • 对 Observation 做预算:限制 token/字节数,必要时摘要、分页、或只保留 top-k 片段。
  • 隔离“用户输出”和“内部轨迹”:线上日志脱敏,最小化保留;前端只展示可解释信息(引用、证据、关键步骤),不要直接 dump scratchpad。
  • 把失败当成常态设计:工具超时/限流/失败要有 retry/backoff 与降级路径(例如只给出可执行的下一步指引)。

ReAct Step 7:总结 / Takeaways

  • ReAct 的本质是闭环Thought → Action → Observation 让模型在外部事实与执行结果的约束下前进。
  • 工程上,协议比 prompt 更重要:Action/Arguments 必须可解析、可校验、可拒绝。
  • 落地时别只追求“能跑”:要考虑可观测性(轨迹/指标)、可控性(预算/停止条件)、以及安全边界(工具权限/注入防护/脱敏)。
Built with Hugo
Theme Stack designed by Jimmy