不到100行代码,实现ReAct:让 Agent 学会边想边做
1. 从一个任务说起
给 LLM 一个指令:「读取 package.json 并告诉我它的内容」。
LLM 本身不能访问文件系统。它只能生成文本。要完成这个任务,它需要:
- 判断应该调用「读文件」工具
- 拿到文件内容
- 基于内容生成总结
这不是单次推理能完成的事。它需要一个循环:思考 → 行动 → 观察结果 → 再思考。
这就是 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 在推理和行动之间交替进行。每次行动后,模型都能看到真实的外部反馈,然后据此调整下一步。这提供了两个关键能力:
- 推理有事实支撑——通过工具获取真实数据,而不是依赖参数化记忆
- 行动有推理指导——每次工具调用都经过思考,而不是盲目执行
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) // 将工具结果作为新的"观察"输入
}
}终止条件有两个:
- 模型主动终止:
stop_reason === "end_turn",模型认为信息已足够,给出最终文本回答 - 迭代次数达到上限:防止模型陷入无限循环的安全机制
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 的组装过程:
- 定义一个具体工具(
ReadFileTool) - 创建工具注册表并注册工具
- 初始化
AgentLoop,注入 provider、工具、消息构建策略 - 调用
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 是最直接的起点。当它不够用时,再考虑更复杂的架构也不迟。
参考
- Yao, S., Zhao, J., Yu, D., et al. (2022). ReAct: Synergizing Reasoning and Acting in Language Models. arXiv:2210.03629
- Anthropic Tool Use Documentation