Suxiong

不到100行代码,实现ReAct:让 Agent 学会边想边做

1. 从一个任务说起

给 LLM 一个指令:「读取 package.json 并告诉我它的内容」。

LLM 本身不能访问文件系统。它只能生成文本。要完成这个任务,它需要:

  1. 判断应该调用「读文件」工具
  2. 拿到文件内容
  3. 基于内容生成总结

这不是单次推理能完成的事。它需要一个循环:思考 → 行动 → 观察结果 → 再思考

这就是 ReAct。

2. ReAct 是什么

ReAct 来自 2022 年的论文 "ReAct: Synergizing Reasoning and Acting in Language Models"(Yao et al.)。名字是 Reasoning + Acting 的缩写。

核心机制是三个阶段的循环:

┌─────────────────────────────────────┐
│                                     │
│   Reason ──→ Act ──→ Observe ──┐    │
│     ↑                          │    │
│     └──────────────────────────┘    │
│                                     │
│   循环直到模型认为可以给出最终回答     │
└─────────────────────────────────────┘
  • Reason:模型分析当前信息,决定下一步该做什么
  • Act:调用外部工具(搜索、读文件、执行代码等)
  • Observe:将工具返回的结果交回模型,作为下一轮推理的输入

每轮循环,模型都能基于新的观察修正自己的判断。这和人类解决问题的方式一致——不是一次想清楚所有事情,而是边做边调整。

3. 为什么需要 ReAct

理解 ReAct 的价值,需要跟其他范式做对比。

纯 Chain-of-Thought(CoT)

CoT 让模型在回答前先「想一想」,通过分步推理提升准确性。但它的全部信息来源只有 prompt 中的上下文。模型无法获取外部数据,遇到知识盲区时只能编造——这就是幻觉的根源。

纯 Tool-use

直接让模型调用工具,拿到结果就结束。问题在于:模型没有中间推理步骤,无法根据第一次工具调用的结果动态决定下一步。它更接近一个函数路由器,而不是一个能解决复杂问题的 agent。

Plan-and-Execute

先让模型生成一个完整计划,然后逐步执行。这对确定性高的任务有效,但面对需要根据中间结果调整策略的场景,预先生成的计划往往不够灵活。

ReAct 的位置

ReAct 在推理和行动之间交替进行。每次行动后,模型都能看到真实的外部反馈,然后据此调整下一步。这提供了两个关键能力:

  1. 推理有事实支撑——通过工具获取真实数据,而不是依赖参数化记忆
  2. 行动有推理指导——每次工具调用都经过思考,而不是盲目执行

4. ReAct 循环的工作机制

以「读取 package.json 并总结」为例,完整的 ReAct 循环如下:

用户: 请读取 package.json 并告诉我它的内容

第 1 轮
├─ Reason: 用户需要 package.json 的内容,我应该调用 read_file 工具
├─ Act:    调用 read_file({ path: "package.json" })
└─ Observe: 工具返回 → { "name": "agent", "version": "1.0.0", ... }

第 2 轮
├─ Reason: 已经拿到文件内容,可以直接总结
└─ 最终回答: 这是一个名为 agent 的项目,版本 1.0.0,依赖了 @anthropic-ai/sdk ...

关键的控制流逻辑:

while (迭代次数 < 上限) {
  response = callLLM(messages)

  if (response.stop_reason === "end_turn") {
    return response.text    // 模型认为可以最终回答,退出循环
  }

  if (response.stop_reason === "tool_use") {
    results = executeTools(response.tool_calls)
    messages.append(assistant: response)
    messages.append(user: results)  // 将工具结果作为新的"观察"输入
  }
}

终止条件有两个:

  1. 模型主动终止stop_reason === "end_turn",模型认为信息已足够,给出最终文本回答
  2. 迭代次数达到上限:防止模型陷入无限循环的安全机制

5. 代码实现

以下是一个基于 Anthropic SDK 的完整 ReAct Agent 实现,共三个文件。

5.1 工具抽象层:tool.ts

先解决一个基础问题:如何让 agent 知道有哪些工具可用,以及如何调用它们。

import type { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/messages";

export abstract class Tool {
  abstract readonly name: string;
  abstract readonly description: string;
  abstract readonly input_schema: AnthropicTool["input_schema"];

  abstract execute(args: Record<string, unknown>): Promise<unknown>;

  toSchema(): AnthropicTool {
    return {
      name: this.name,
      description: this.description,
      input_schema: this.input_schema,
    };
  }
}

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

  register(tool: Tool) {
    this.tools.set(tool.name, tool);
  }

  async execute(name: string, args: Record<string, unknown>) {
    const tool = this.tools.get(name);
    if (!tool) {
      throw new Error(`Tool "${name}" not found`);
    }
    return tool.execute(args);
  }

  getToolDefinition(): AnthropicTool[] {
    return Array.from(this.tools.values()).map((tool) => tool.toSchema());
  }
}

设计要点:

  • Tool 抽象类:每个工具必须声明名称、描述、输入参数的 JSON Schema,以及执行逻辑。toSchema() 方法将工具转为 Anthropic API 要求的格式。
  • ToolRegistry:工具注册表。负责管理所有可用工具,提供按名称查找和执行的能力,以及批量导出工具定义供 LLM 使用。

这层抽象的价值在于:agent 的主循环不需要知道具体工具的实现细节,只依赖 ToolRegistry 的接口。新增工具只需继承 Tool 并注册,不需要修改循环逻辑。

5.2 ReAct 主循环:agent-loop.ts

这是 agent 的核心。

import type Anthropic from "@anthropic-ai/sdk";
import type {
  ContentBlock,
  MessageParam,
  ToolUseBlock,
} from "@anthropic-ai/sdk/resources/messages/messages";
import type { ToolRegistry } from "./tool";

export interface AgentLoopOptions {
  provider: Anthropic;
  tools: ToolRegistry;
  buildMessages: (
    history: MessageParam[],
    userMessage: string,
  ) => MessageParam[];
  system?: string;
  model?: string;
  maxIterations?: number;
}

interface ToolResult {
  type: "tool_result";
  tool_use_id: string;
  content: string;
}

export class AgentLoop {
  private provider: Anthropic;
  private tools: ToolRegistry;
  private buildMessages: AgentLoopOptions["buildMessages"];
  private system?: string;
  private model: string;
  private maxIterations: number;

  constructor(options: AgentLoopOptions) {
    this.provider = options.provider;
    this.tools = options.tools;
    this.buildMessages = options.buildMessages;
    this.system = options.system;
    this.model = options.model ?? "claude-opus-4-6";
    this.maxIterations = options.maxIterations ?? 10;
  }

  async run(
    userMessage: string,
    history: MessageParam[] = [],
  ): Promise<string> {
    const messages = this.buildMessages(history, userMessage);

    for (let i = 0; i < this.maxIterations; i++) {
      const response = await this.callModel(messages);

      // Reason 完成 → 返回最终文本
      if (response.stop_reason === "end_turn") {
        return this.extractText(response.content);
      }

      // Act + Observe → 执行工具调用,将结果回传模型
      if (response.stop_reason === "tool_use") {
        messages.push({ role: "assistant", content: response.content });
        const toolResults = await this.executeToolCalls(response.content);
        messages.push({ role: "user", content: toolResults });
      }
    }

    return "Reached maximum iterations without final response.";
  }

  private callModel(messages: MessageParam[]) {
    return this.provider.messages.create({
      model: this.model,
      max_tokens: 4096,
      system: this.system,
      messages,
      tools: this.tools.getToolDefinition(),
    });
  }

  private extractText(blocks: ContentBlock[]): string {
    const textBlock = blocks.find((b) => b.type === "text");
    return textBlock?.type === "text" ? textBlock.text : "";
  }

  private executeToolCalls(blocks: ContentBlock[]): Promise<ToolResult[]> {
    const toolUseBlocks = blocks.filter(
      (b): b is ToolUseBlock => b.type === "tool_use",
    );
    return Promise.all(toolUseBlocks.map((block) => this.invokeTool(block)));
  }

  private async invokeTool(block: ToolUseBlock): Promise<ToolResult> {
    try {
      const raw = await this.tools.execute(
        block.name,
        block.input as Record<string, unknown>,
      );
      return {
        type: "tool_result",
        tool_use_id: block.id,
        content: typeof raw === "string" ? raw : JSON.stringify(raw),
      };
    } catch (error) {
      return {
        type: "tool_result",
        tool_use_id: block.id,
        content: `Error: ${error}`,
      };
    }
  }
}

逐段拆解 run() 方法——它就是 ReAct 循环的直接映射:

1. 构建初始消息

const messages = this.buildMessages(history, userMessage);

buildMessages 是外部注入的函数,负责将历史对话和当前用户输入组装成 LLM 需要的消息格式。这个设计将消息构建策略与循环逻辑解耦——不同场景可以有不同的消息拼装方式(比如是否注入 system prompt、是否裁剪历史等)。

2. 循环体:Reason → Act → Observe

for (let i = 0; i < this.maxIterations; i++) {
  const response = await this.callModel(messages);

每轮循环调用一次 LLM。maxIterations 是安全阀,默认 10 轮。

3. 终止判断

if (response.stop_reason === "end_turn") {
  return this.extractText(response.content);
}

stop_reason === "end_turn" 表示模型认为不需要再调用工具,直接给出了文本回答。这就是 ReAct 循环的正常出口。

4. 工具执行与结果回传

if (response.stop_reason === "tool_use") {
  messages.push({ role: "assistant", content: response.content });
  const toolResults = await this.executeToolCalls(response.content);
  messages.push({ role: "user", content: toolResults });
}

stop_reason === "tool_use" 表示模型决定调用工具。这里做了三件事:

  • 将模型的响应(包含工具调用请求)追加到消息列表,角色为 assistant
  • 并行执行所有工具调用,收集结果
  • 将工具结果以 user 角色追加到消息列表——这就是 Observe 阶段

工具结果以 user 角色发送,是因为 Anthropic API 的消息协议要求 assistant 和 user 消息交替出现。从语义上看,工具结果确实是「外部世界对 agent 的反馈」。

5. 错误安全

private async invokeTool(block: ToolUseBlock): Promise<ToolResult> {
  try {
    const raw = await this.tools.execute(block.name, block.input as Record<string, unknown>);
    return { type: "tool_result", tool_use_id: block.id, content: ... };
  } catch (error) {
    return { type: "tool_result", tool_use_id: block.id, content: `Error: ${error}` };
  }
}

工具执行失败不会中断整个循环。错误信息被包装成正常的 tool_result 返回给模型,让模型自行决定如何处理——重试、换一个工具、或直接告诉用户出了什么问题。这比 throw 到外层更合理,因为模型通常能够理解错误并做出有意义的响应。

5.3 组装与运行:index.ts

import { Anthropic } from "@anthropic-ai/sdk";
import { Tool, ToolRegistry } from "./tool";
import { AgentLoop } from "./agent-loop";
import type { MessageParam } from "@anthropic-ai/sdk/resources";

class ReadFileTool extends Tool {
  readonly name = "read_file";
  readonly description = "Read a file from the file system";
  readonly input_schema = {
    type: "object" as const,
    properties: {
      path: { type: "string", description: "The path to the file to read" },
    },
    required: ["path"],
  };

  async execute(args: Record<string, unknown>): Promise<string> {
    const { readFile } = await import("node:fs/promises");
    return await readFile(args.path as string, "utf-8");
  }
}

const anthropic = new Anthropic({
  apiKey: process.env.API_KEY,
  baseURL: process.env.BASE_URL,
});

const tools = new ToolRegistry();
tools.register(new ReadFileTool());

const agent = new AgentLoop({
  provider: anthropic,
  tools,
  system: "You are a helpful assistant with file access.",
  buildMessages: (history: MessageParam[], userMessage: string) => [
    ...history,
    { role: "user" as const, content: userMessage },
  ],
  model: "claude-opus-4-6",
  maxIterations: 10,
});

const reply = await agent.run("请读取 package.json 并告诉我它的内容");
console.log(reply);

整个 agent 的组装过程:

  1. 定义一个具体工具(ReadFileTool
  2. 创建工具注册表并注册工具
  3. 初始化 AgentLoop,注入 provider、工具、消息构建策略
  4. 调用 agent.run() 启动 ReAct 循环

新增工具只需要三步:继承 Tool、实现 execute、调用 registry.register()。主循环不需要任何改动。

6. 局限性与实践建议

ReAct 不是银弹。以下是实际使用中需要注意的问题。

迭代次数与成本

每轮循环都是一次完整的 LLM 调用,且消息列表会累积之前所有的 assistant 响应和工具结果。这意味着:

  • Token 消耗随轮次递增——第 N 轮的 input tokens 包含前 N-1 轮的所有内容
  • 必须设置 maxIterations 上限,否则模型可能在无效循环中持续消耗 token
  • 对于复杂任务,10 轮循环的总 token 量可能远超单次调用

工具选择错误

模型可能调用错误的工具,或传入错误的参数。上面的实现通过 try/catch 将错误信息回传给模型,让它自行修正。这在大多数情况下有效,但如果模型连续犯同类错误,会白白消耗迭代次数。

实践中的应对方式:

  • 工具描述要足够清晰,减少歧义
  • 对关键参数做校验,返回有意义的错误信息而不是堆栈跟踪
  • 考虑对同一工具的连续失败次数做限制

上下文窗口

随着循环次数增加,消息列表会逐渐逼近模型的上下文窗口限制。如果工具返回大量数据(比如读取一个大文件),可能很快耗尽上下文空间。

可能的策略:对工具返回结果做截断或摘要、定期压缩历史消息、或在接近上下文上限时强制终止循环。

何时考虑更复杂的架构

ReAct 适合需要 1-10 轮工具调用就能完成的任务。当任务更复杂时,可能需要:

  • Plan-and-Execute:任务可以预先分解为确定的子步骤时,先规划再执行效率更高
  • Multi-Agent:不同子任务需要不同的工具集或不同的 system prompt 时,拆分为多个专用 agent 更清晰
  • 分层架构:外层 agent 负责规划和任务分发,内层 agent 各自用 ReAct 循环执行具体子任务

7. 总结

ReAct 的核心是一个简单的循环:让 LLM 交替进行推理和行动,每次行动后基于真实的外部反馈调整下一步。

它的实现同样简单——一个 for 循环,根据 stop_reason 判断是继续调用工具还是返回最终结果。全部核心逻辑不到 100 行代码。

这种简洁性既是优势也是边界。对于大多数需要 LLM 与外部工具交互的场景,ReAct 是最直接的起点。当它不够用时,再考虑更复杂的架构也不迟。

参考

On this page