让智能体边想边做:从 0 理解 ReActAgent 的工作方式

为什么还需要 ReAct?

上一篇里,我们已经有了一个最小可用的 SimpleAgent。 它能理解用户输入,也能结合上下文生成回答。

但很快,小王就遇到了一个很现实的问题:

老板:"这个智能体现在能聊天了,但如果用户问'今天天气怎么样'、'最新新闻是什么'、'帮我算一个表达式',它还是不太行。能不能让它真的做点事?"

这句话点中了纯 LLM Agent 的三个典型短板:

  1. 知识不是实时的模型训练数据有截止时间,不知道今天刚发生的事。
  2. 遇到未知内容可能幻觉它不一定会诚实地说"不知道",有时会编一个看起来像真的答案。
  3. 它不能直接行动 它会生成文本,但不会自己查搜索、调用 API、访问数据库、执行运算。

也就是说,SimpleAgent 更像一个"会说话的助手",但还不像一个"会做事的智能体"。

ReAct 正是为这个问题设计的。


第一章:ReAct 到底是什么?

1.1 ReAct 的全称

ReAct 来自这两个词:

text 复制代码
Reasoning + Acting

也就是:

  • Reasoning:先思考
  • Acting:再行动

它不是让模型拿到问题后直接吐最终答案,而是让模型先做中间推理,再调用工具,再根据结果继续思考,直到答案足够可靠。

1.2 ReAct 的核心闭环

ReAct 最经典的结构是:

flowchart LR Q["用户问题"] --> T["Thought\n分析当前问题"] T --> A["Action\n执行一个动作"] A --> O["Observation\n获得动作结果"] O --> T T --> F["Finish\n输出最终答案"]

这里面最重要的是三种状态:

  • Thought 当前知道什么?还缺什么?下一步最合理的动作是什么?
  • Action 调用一个工具,或者宣布任务结束。
  • Observation 工具返回的结果,它会进入下一轮推理。

1.3 一个最直观的例子

假设用户问:

华为最新发布的手机是什么型号?售价多少?

纯 LLM 容易直接"猜"一个答案。 而 ReAct 会更像这样:

text 复制代码
第 1 轮
Thought: 我需要先确认华为最近发布了什么手机
Action: Search[华为最新发布手机型号]
Observation: 华为发布了 Mate 70 系列,包括 Mate 70、Mate 70 Pro、Mate 70 Pro+

第 2 轮
Thought: 已经知道型号了,现在需要查售价
Action: Search[华为 Mate 70 售价]
Observation: Mate 70 起售价 5499 元,Mate 70 Pro 起售价 6499 元,Mate 70 Pro+ 起售价 8499 元

第 3 轮
Thought: 信息已经足够,可以回答用户了
Action: Finish[华为最新发布的是 Mate 70 系列,......]

ReAct 的价值,不只是"多查了一次",而是它把"思考 -> 执行 -> 根据结果继续调整"的过程显式化了。


第二章:什么时候适合用 ReAct?

ReAct 特别适合这些任务:

  • 实时信息问答
  • 检索增强问答
  • 带计算能力的助手
  • 需要多步工具调用的任务
  • 用户目标明确,但路径不固定的场景

例如:

  • "今天北京天气怎么样?"
  • "帮我查一下某家公司最新融资消息"
  • "计算 (15 + 27) * 3 - 8 的结果"
  • "先搜索一个技术方案,再基于结果给建议"

不太适合直接上 ReAct 的情况也有:

  • 纯文案生成
  • 不需要任何工具
  • 路径很固定
  • 对延迟特别敏感

你可以这样简单判断:

flowchart LR S["当前任务"] --> Q1{"需要外部工具或实时信息吗?"} Q1 -- "不需要" --> R1["普通 Agent 或直接 LLM 即可"] Q1 -- "需要" --> Q2{"中间路径是否不固定?"} Q2 -- "是" --> R2["适合 ReAct"] Q2 -- "否" --> R3["也可以用固定工作流"]

第三章:把读者当小白,我们先从零搭一个能跑的项目

如果你看到文章里的 @hello-agents/shared 会困惑,这是正常的。 因为那是当前项目里已经封装好的共享模块,但如果你自己是第一次做,当然不知道里面是什么。

所以这一篇我们不偷懒,直接从零开始搭一个最小项目。

3.1 项目结构

建议你先创建这样一个目录:

text 复制代码
react-agent-demo/
├── package.json
├── tsconfig.json
├── .env
└── src/
    ├── shared/
    │   └── llm.ts
    ├── react/
    │   ├── prompt.ts
    │   ├── parser.ts
    │   ├── tools.ts
    │   └── agent.ts
    └── index.ts

3.2 package.json

先初始化项目:

json 复制代码
{
  "name": "react-agent-demo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "ts-node src/index.ts",
    "build": "tsc"
  },
  "dependencies": {
    "dotenv": "^16.6.1",
    "openai": "^4.104.0"
  },
  "devDependencies": {
    "@types/node": "^20.10.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.3.0"
  }
}

然后安装依赖:

bash 复制代码
npm install

3.3 tsconfig.json

json 复制代码
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "moduleResolution": "node",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "types": ["node"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

3.4 .env

先准备模型配置:

bash 复制代码
LLM_API_KEY="你的模型 API Key"
LLM_MODEL_ID="gpt-4o-mini"
LLM_BASE_URL="https://api.openai.com/v1"

如果你不是用 OpenAI,而是别的兼容 OpenAI API 的服务,只要改这三个变量就行。


第四章:先写共享层 llm.ts

这就是之前文章里提到但没展开的"共享代码"。

我们先创建 src/shared/llm.ts 这个文件,内容如下:

typescript 复制代码
// src/shared/llm.ts
import "dotenv/config";
import OpenAI from "openai";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";

export interface LLMOptions {
  model?: string;
  apiKey?: string;
  baseUrl?: string;
  timeout?: number;
}

export class HelloAgentsLLM {
  private readonly client: OpenAI;
  private readonly model: string;

  constructor(options: LLMOptions = {}) {
    const apiKey = options.apiKey ?? process.env.LLM_API_KEY;
    const baseUrl = options.baseUrl ?? process.env.LLM_BASE_URL;

    if (!apiKey) {
      throw new Error("缺少 LLM_API_KEY,请检查 .env 配置");
    }

    this.model = options.model ?? process.env.LLM_MODEL_ID ?? "gpt-4o-mini";

    this.client = new OpenAI({
      apiKey,
      baseURL: baseUrl,
      timeout: options.timeout ?? 60_000,
    });
  }

  async think(
    messages: ChatCompletionMessageParam[],
    temperature = 0
  ): Promise<string> {
    const response = await this.client.chat.completions.create({
      model: this.model,
      messages,
      temperature,
    });

    return response.choices[0]?.message?.content ?? "";
  }
}

4.1 这段代码到底是干什么的?

如果你是小白,可以把它简单理解成:

  • HelloAgentsLLM 就是我们自己的"大模型调用器"
  • 它负责从 .env 里读取配置
  • 帮我们统一调用 OpenAI 兼容接口
  • 以后所有 Agent 都通过它来访问模型

也就是说,后面文章里凡是出现 HelloAgentsLLM,你现在已经知道它是什么了,不再是一个神秘黑盒。


第五章:设计 Prompt 协议

ReAct 能不能稳定跑,提示词设计比很多人想象中更重要

因为模型必须知道:

  • 当前有哪些工具
  • 输出必须长什么样
  • 每次只能执行一步
  • 什么情况下可以结束

创建 src/react/prompt.ts

typescript 复制代码
// src/react/prompt.ts
export const REACT_PROMPT_TEMPLATE = `
你是一个具备推理和行动能力的 AI 助手。你可以通过思考分析问题,然后调用合适的工具来获取信息,最终给出准确的答案。

## 可用工具
{tools}

## 工作流程
请严格按照以下格式进行回应,每次只能执行一个步骤:

Thought: 分析当前问题,思考需要什么信息或采取什么行动。
Action: 选择一个行动,格式必须是以下之一:
- \`ToolName[参数]\` - 调用指定工具
- \`Finish[最终答案]\` - 当你有足够信息给出最终答案时

## 重要规则
1. 每次回应必须包含 Thought 和 Action 两部分
2. 工具调用格式必须严格遵循:工具名[参数]
3. 只有当你确信有足够信息回答问题时,才使用 Finish
4. 如果工具返回的信息不够,请继续推理并调用工具

## 当前任务
Question: {question}

## 执行历史
{history}

现在开始你的推理和行动:
`;

5.1 为什么这段 Prompt 很关键?

这不是随便写一段提示词,而是在告诉模型:

  • 你现在不是普通聊天模式
  • 你必须按 ReAct 协议输出
  • 你不能跳步
  • 你可以使用工具

如果这一层写得不清楚,后面的代码再完整,也会经常跑偏。


第六章:实现输出解析器

有了 Prompt,还不够。 因为模型输出的是字符串,程序需要把它解析成结构化数据。

创建 src/react/parser.ts

typescript 复制代码
// src/react/parser.ts
export interface ParsedOutput {
  thought: string | null;
  action: string | null;
}

export interface ParsedAction {
  toolName: string;
  toolInput: string;
}

export function parseOutput(text: string): ParsedOutput {
  const thoughtMatch = text.match(
    /Thought:\s*([\s\S]*?)(?=\nAction:|$)/i
  );

  const actionMatch = text.match(
    /Action:\s*(Finish\[[\s\S]*?\]|\w+\[[^\n\]]*\])/i
  );

  return {
    thought: thoughtMatch?.[1]?.trim() || null,
    action: actionMatch?.[1]?.trim() || null,
  };
}

export function parseAction(actionText: string): ParsedAction | null {
  const match = actionText.match(/(\w+)\[([^\n]*)\]/);
  if (!match) {
    return null;
  }

  return {
    toolName: match[1],
    toolInput: match[2].trim(),
  };
}

export function isFinishAction(action: string): boolean {
  return action.startsWith("Finish");
}

6.1 小白最容易忽略的一点

很多人第一次做 Agent 会以为:

"LLM 都能输出了,我直接把字符串拿来用不就好了?"

但其实不行。因为你的程序必须知道:

  • 当前输出是不是合法的 Thought
  • 当前输出是不是合法的 Action
  • 这轮到底该结束还是继续

所以解析器不是"锦上添花",而是 ReAct 的关键基础设施。


第七章:实现工具系统

ReAct 的第二个核心,就是工具。

创建 src/react/tools.ts

typescript 复制代码
// src/react/tools.ts
export interface Tool {
  name: string;
  description: string;
  execute: (input: string) => Promise<string>;
}

export class ToolRegistry {
  private tools: Map<string, Tool> = new Map();

  register(tool: Tool): void {
    this.tools.set(tool.name, tool);
    console.log(`✅ 工具 '${tool.name}' 已注册`);
  }

  getToolsDescription(): string {
    const descriptions: string[] = [];

    this.tools.forEach((tool) => {
      descriptions.push(`- ${tool.name}: ${tool.description}`);
    });

    return descriptions.join("\n") || "暂无可用工具";
  }

  async execute(toolName: string, input: string): Promise<string> {
    const tool = this.tools.get(toolName);

    if (!tool) {
      return `错误:未找到工具 '${toolName}'`;
    }

    try {
      return await tool.execute(input);
    } catch (error) {
      return `工具执行失败:${error instanceof Error ? error.message : String(error)}`;
    }
  }

  hasTool(name: string): boolean {
    return this.tools.has(name);
  }
}

7.1 为什么这里不用复杂设计?

因为我们现在是教学版实现,目标是先把 ReAct 跑起来。这个版本已经足够表达 3 件重要的事情:

  1. 工具有名字和描述
  2. 模型需要先知道有哪些工具
  3. 程序要能根据工具名真正去执行

第八章:实现 ReActAgent 主循环

现在终于来到核心部分:ReActAgent 本体。

创建 src/react/agent.ts

typescript 复制代码
// src/react/agent.ts
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { HelloAgentsLLM } from "../shared/llm";
import { ToolRegistry } from "./tools";
import { parseOutput, parseAction, isFinishAction } from "./parser";
import { REACT_PROMPT_TEMPLATE } from "./prompt";

export interface ReActAgentConfig {
  name: string;
  maxSteps: number;
}

export class ReActAgent {
  private readonly llm: HelloAgentsLLM;
  private readonly tools: ToolRegistry;
  private readonly config: ReActAgentConfig;
  private history: string[] = [];

  constructor(
    llm: HelloAgentsLLM,
    tools: ToolRegistry,
    config: Partial<ReActAgentConfig> = {}
  ) {
    this.llm = llm;
    this.tools = tools;
    this.config = {
      name: "ReActAgent",
      maxSteps: 5,
      ...config,
    };
  }

  async run(question: string): Promise<string> {
    this.history = [];

    console.log(`\n🤖 ${this.config.name} 开始处理问题: ${question}\n`);

    for (let step = 0; step < this.config.maxSteps; step++) {
      console.log(`--- 第 ${step + 1}/${this.config.maxSteps} 步 ---`);

      const prompt = this.buildPrompt(question);
      const messages: ChatCompletionMessageParam[] = [
        { role: "user", content: prompt },
      ];

      const response = await this.llm.think(messages, 0);

      if (!response) {
        console.log("❌ LLM 未返回有效响应");
        break;
      }

      const { thought, action } = parseOutput(response);

      if (thought) {
        console.log(`💭 Thought: ${thought}`);
      }

      if (!action) {
        console.log("❌ 无法解析 Action");
        break;
      }

      console.log(`🎬 Action: ${action}`);

      if (isFinishAction(action)) {
        const parsed = parseAction(action);
        const answer = parsed?.toolInput || response;
        console.log(`\n✅ 任务完成,最终答案: ${answer}`);
        return answer;
      }

      const parsedAction = parseAction(action);
      if (!parsedAction) {
        console.log("❌ 无法解析工具调用");
        break;
      }

      const observation = await this.tools.execute(
        parsedAction.toolName,
        parsedAction.toolInput
      );

      console.log(`👁️ Observation: ${observation}`);

      this.history.push(`Thought: ${thought || ""}`);
      this.history.push(`Action: ${action}`);
      this.history.push(`Observation: ${observation}`);
    }

    return "抱歉,我无法在限定步数内完成这个任务。";
  }

  private buildPrompt(question: string): string {
    const historyStr = this.history.length > 0
      ? this.history.join("\n")
      : "无";

    return REACT_PROMPT_TEMPLATE
      .replace("{tools}", this.tools.getToolsDescription())
      .replace("{question}", question)
      .replace("{history}", historyStr);
  }

  getHistory(): string[] {
    return [...this.history];
  }
}

8.1 这段代码到底在做什么?

你可以把它理解成一个严格循环:

  1. 构建 Prompt
  2. 调用 LLM
  3. 解析 ThoughtAction
  4. 如果是 Finish,直接结束
  5. 如果是工具调用,就执行工具
  6. Observation 写进历史
  7. 进入下一轮

这就是 ReAct 的本质。

8.2 为什么一定要有 maxSteps

为了防止死循环。

如果模型:

  • 一直不 Finish
  • 总是重复调用同一个工具
  • 一直拿不到足够信息

那系统不能无限跑下去。 maxSteps 就是最基础的安全阀。


第九章:写两个最简单的工具

为了让读者不依赖额外服务,我们先写两个能本地跑的工具:

  • Calculator
  • MockSearch

直接把它们写在 src/index.ts 里也可以。

typescript 复制代码
// src/index.ts
import "dotenv/config";
import { HelloAgentsLLM } from "./shared/llm";
import { ReActAgent } from "./react/agent";
import { ToolRegistry, Tool } from "./react/tools";

class CalculatorTool implements Tool {
  name = "Calculator";
  description = "数学计算工具,支持基本表达式计算";

  async execute(expression: string): Promise<string> {
    try {
      const sanitized = expression.replace(/[^0-9+\-*/().]/g, "");
      const result = Function(`"use strict"; return (${sanitized})`)();
      return `计算结果:${result}`;
    } catch (error) {
      return `计算错误:${error instanceof Error ? error.message : String(error)}`;
    }
  }
}

class MockSearchTool implements Tool {
  name = "Search";
  description = "模拟搜索工具,用于演示查询实时信息";

  async execute(query: string): Promise<string> {
    const knowledgeBase: Record<string, string> = {
      "华为最新发布手机型号": "华为最新发布的是 Mate 70 系列。",
      "华为 Mate 70 售价": "Mate 70 起售价 5499 元,Mate 70 Pro 起售价 6499 元。",
      "北京今天天气": "北京今天晴,最高温 24 度,最低温 12 度。",
    };

    for (const key of Object.keys(knowledgeBase)) {
      if (query.includes(key)) {
        return knowledgeBase[key];
      }
    }

    return `没有找到与"${query}"相关的信息。`;
  }
}

async function main() {
  const llm = new HelloAgentsLLM();

  const tools = new ToolRegistry();
  tools.register(new CalculatorTool());
  tools.register(new MockSearchTool());

  const agent = new ReActAgent(llm, tools, {
    name: "ReAct 演示助手",
    maxSteps: 5,
  });

  const questions = [
    "计算 (15 + 27) * 3 - 8 的结果",
    "华为最新发布的手机是什么型号?售价多少?",
  ];

  for (const question of questions) {
    console.log("=".repeat(60));
    const answer = await agent.run(question);
    console.log(`📝 最终回答: ${answer}\n`);
  }
}

main().catch(console.error);

9.1 为什么先用 MockSearch

因为如果一上来就接真实搜索 API,新手很容易被:

  • API Key
  • 网络问题
  • 第三方 SDK
  • 返回格式解析

这些问题打断学习节奏。

先用本地模拟工具,更容易把注意力放在 ReAct 主循环本身。

等跑通之后,再升级成真实搜索工具就很自然了。


第十章:如果你想换成真实搜索工具,可以怎么做?

当你本地跑通之后,可以把 MockSearchTool 换成真实搜索工具。

例如:

typescript 复制代码
class SearchTool implements Tool {
  name = "Search";
  description = "网页搜索引擎,用于查询实时信息、新闻、事实等";

  async execute(query: string): Promise<string> {
    const { getJson } = await import("serpapi");

    const results = await getJson({
      engine: "google",
      q: query,
      api_key: process.env.SERPAPI_API_KEY,
      hl: "zh-cn",
      gl: "cn",
    });

    if (results.answer_box?.answer) {
      return results.answer_box.answer;
    }

    if (results.organic_results?.length > 0) {
      return results.organic_results
        .slice(0, 3)
        .map((r: any, i: number) => `[${i + 1}] ${r.title}\n${r.snippet}`)
        .join("\n\n");
    }

    return `未找到关于 '${query}' 的相关信息`;
  }
}

然后在 .env 里增加:

bash 复制代码
SERPAPI_API_KEY="你的 serpapi key"

这时候你的 ReActAgent 就从"教学版"变成了"可联网版"。


第十一章:完整运行流程长什么样?

你执行:

bash 复制代码
npm run dev

终端里会看到类似过程:

text 复制代码
--- 第 1/5 步 ---
Thought: 我需要先计算表达式
Action: Calculator[(15 + 27) * 3 - 8]
Observation: 计算结果:118

--- 第 2/5 步 ---
Thought: 已经拿到结果,可以直接回答
Action: Finish[(15 + 27) * 3 - 8 的结果是 118]

这时候你就已经真正跑通了一个最小可用的 ReActAgent。


总结

这一篇真正重要的,不只是"ReAct 有个 Thought-Action-Observation 循环",而是你要理解它把 Agent 往前推进了一步:

它让智能体从"只会回答问题",变成"会先想一下,再做一下,再根据结果继续想"的系统。

你现在应该已经掌握这些关键点:

  1. ReAct 解决的是纯 LLM 无法可靠获取外部信息和执行动作的问题。
  2. 它的核心机制是 Thought -> Action -> Observation
  3. 一个从零实现的 ReActAgent 至少需要 llm.ts、Prompt、解析器、工具系统和主循环。
  4. 你现在可以自己从零搭一个最小 ReActAgent。

如果说第一篇让我们理解了什么是 Agent,那么这一篇就是第一次真正让 Agent "动起来"。


下篇预告

ReAct 已经让智能体具备了"边想边做"的能力。但它还有一个明显问题:

它通常是"做完就结束",不太会回头检查自己做得好不好。

所以下一篇,我们会进入另一个非常重要的范式:ReflectionAgent

也就是让智能体学会:

flowchart LR E["执行"] --> R["反思"] R --> F["优化"] F --> R

到那时,Agent 就不只是"能做事",而是开始具备"自我改进"的能力。


参考资料

相关推荐
AIData搭子3 小时前
高并发场景下,如何让你的向量语义检索快人一步?
人工智能
AI攻城狮3 小时前
Vibe Coding 时代:为什么你不应该盲目启用 AI 编码插件
人工智能·云原生·aigc
两万五千个小时3 小时前
Claude Code 源码:Agent 工具 — 多 Agent 的路由与定义机制
人工智能·程序员·架构
袋鱼不重3 小时前
Hermes Agent 安装与实战:从安装到与 OpenClaw 全方位对比
前端·后端·ai编程
汉秋3 小时前
iOS 自定义 UICollectionView 拼图布局 + 布局切换动画实践
前端
江南月3 小时前
让智能体学会自我改进:从 0 理解 ReflectionAgent 的迭代优化
前端·人工智能
尽欢i3 小时前
前端响应式布局新宠:vw 和 clamp (),你了解吗?
前端·css
沸点小助手3 小时前
「 AI 整活大赛,正式开擂 & 最近一次面试被问麻了吗」沸点获奖名单公示|本周互动话题上新🎊
前端·人工智能·后端
网络工程小王3 小时前
【大模型基础部署】(学习笔记)
人工智能·深度学习·机器学习