ReAct(Reasoning and Acting)是一种让大语言模型在推理过程中交替执行思考与行动的模式。与直接输出答案不同,模型会先写出Thought,再给出Action和Action Input,由外部环境执行后返回Observation,模型继续基于结果推理,直到得出最终结论。这种范式让语言模型突破静态知识限制,能够查询实时数据、操作数据库或调用第三方API。Node.js凭借异步I/O和丰富的包生态,非常适合作为ReAct执行器的宿主环境。本文会展示如何在不依赖重型框架的前提下,手写一个可运行的ReAct循环。

实现ReAct的关键在于设计一个可控的循环:把模型的输出当作指令流,执行其中声明的工具调用,再把工具结果反馈给模型。Node.js的异步函数可以方便地处理网络请求、文件读写等操作,而且JavaScript对象结构天然适合描述工具参数和返回结果。下面从核心机制、代码实现、完整示例和优化方向几个层面展开。
ReAct的核心机制:为什么推理和行动要交替进行
传统的CoT(Chain of Thought)只让模型在内部生成推理步骤,无法与外部环境交互。一旦问题需要实时数据,模型就只能靠训练时记住的信息硬猜。ReAct把推理过程拆成Thought、Action、Action Input和Observation四个部分:模型先写一段思考,说明它打算做什么;然后声明要调用的工具名称和参数;执行器运行工具后把结果作为Observation返回;模型再基于Observation继续思考。这个循环反复进行,直到模型输出Final Answer。
交替推理与行动的价值在于可验证性。每一步Action都会产生真实的Observation,模型无法凭空编造事实。例如查询天气、检索数据库、调用计算器,都能让最终答案建立在可靠信息之上。对于开发者来说,ReAct也便于调试,因为整个推理轨迹是可读的文本,可以清楚看到模型在哪一步判断失误或工具选择错误。
在Node.js环境中实现ReAct,核心就是用一个异步循环不断调用大模型API、解析输出、执行工具。Node.js的单线程事件循环并不妨碍并发I/O,工具执行期间可以同时处理其他任务。而且JavaScript的JSON处理能力让参数序列化和反序列化非常简洁。
设计工具注册表与提示词模板
工具注册表是ReAct执行器与外部世界交互的桥梁。每个工具至少需要三样信息:名称、描述、执行函数。描述会写入系统提示词,帮助模型判断何时调用该工具。参数可以用简单的对象结构声明,不必引入复杂的Schema校验库。
const tools = {
getWeather: {
name: 'getWeather',
description: '获取指定城市的实时天气信息',
parameters: { city: '城市名称,例如北京' },
execute: async function (args) {
const { city } = args;
// 这里替换为真实的天气API请求
return `城市${city}今天晴朗,气温25摄氏度`;
}
},
searchWeb: {
name: 'searchWeb',
description: '搜索互联网上的信息',
parameters: { query: '搜索关键词' },
execute: async function (args) {
const { query } = args;
return `关于${query}的搜索结果:...`;
}
}
};
系统提示词需要明确告诉模型输出格式。由于大多数大模型对文本格式比较敏感,提示词中应该给出清晰的字段标记和示例。下面是一段可用的提示词模板,它要求模型在每一步都遵循Thought、Action、Action Input或Final Answer的格式。
const systemPrompt = ` 你是一个能够使用工具的智能助手。请严格按照以下格式输出: Thought: 你的思考过程 Action: 要调用的工具名称,必须是 [getWeather, searchWeb] 中的一个 Action Input: 传递给工具的JSON参数 Observation: 工具返回的结果 当你已经得到最终答案时,请输出: Thought: 我现在可以回答用户了 Final Answer: 最终答案 `;
提示词中列出的可用工具名称必须与注册表保持一致,否则模型可能生成不存在的Action。可以把工具名和描述动态拼接到提示词中,例如遍历tools对象生成一行行说明。这样新增工具时无需手动修改提示词。
解析模型输出与执行循环
模型返回的通常是一段纯文本,而不是结构化JSON。因此需要编写一个解析函数,从文本中提取Action、Action Input或Final Answer。下面的实现使用正则表达式进行宽松匹配,即便模型在格式上稍有偏差也能工作。
function parseAction(text) {
const actionMatch = text.match(/Action:\s*([^\n]+)/);
const inputMatch = text.match(/Action Input:\s*([^\n]+)/);
const finalMatch = text.match(/Final Answer:\s*([\s\S]+)/);
if (finalMatch) {
return { type: 'final', answer: finalMatch[1].trim() };
}
if (actionMatch) {
let input = {};
try {
input = JSON.parse(inputMatch ? inputMatch[1].trim() : '{}');
} catch (e) {
input = { raw: inputMatch ? inputMatch[1].trim() : '' };
}
return { type: 'action', action: actionMatch[1].trim(), input: input };
}
return { type: 'unknown', raw: text };
}
主循环负责串联调用模型、解析结果、执行工具、追加Observation这几个步骤。每一轮结束后,新的Observation会以用户消息的形式追加到消息数组里,模型会在下一轮看到这些反馈。为了安全起见,必须设置最大迭代次数,防止模型反复调用工具却不给出最终答案。
async function runReAct(userQuestion) {
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userQuestion }
];
let currentText = '';
const maxSteps = 5;
for (let step = 0; step < maxSteps; step++) {
currentText = await callLLM(messages);
messages.push({ role: 'assistant', content: currentText });
const parsed = parseAction(currentText);
if (parsed.type === 'final') {
return parsed.answer;
}
if (parsed.type === 'action') {
const tool = tools[parsed.action];
if (!tool) {
messages.push({ role: 'user', content: `Observation: 工具${parsed.action}不存在` });
continue;
}
try {
const result = await tool.execute(parsed.input);
messages.push({ role: 'user', content: `Observation: ${result}` });
} catch (error) {
messages.push({ role: 'user', content: `Observation: 工具执行出错 ${error.message}` });
}
} else {
messages.push({ role: 'user', content: 'Observation: 无法解析输出,请重新按格式输出' });
}
}
return '抱歉,未能在限定步数内完成推理。';
}
异常处理同样重要。如果工具执行抛出错误,不应让整个循环崩溃,而是把错误信息作为Observation返回给模型。模型看到错误后可能会调整参数重新调用,或者改用其他工具。最大步数建议根据业务复杂度设置,通常3到8步即可覆盖大多数多步推理场景。
完整示例:查询城市天气
下面的callLLM函数封装了对OpenAI兼容接口的调用。实际开发中可以替换为任何大模型服务,只要请求和响应结构类似即可。注意API密钥应通过环境变量注入,避免硬编码在源码中。
async function callLLM(messages) {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: messages,
temperature: 0
})
});
const data = await response.json();
return data.choices[0].message.content;
}
假设用户提问:北京今天天气如何?模型输出的轨迹大致如下。可以看到模型先思考需要调用哪个工具,然后给出Action和Action Input,执行器返回Observation后,模型再生成Final Answer。
Question: 北京今天天气如何?
Thought: 用户想知道北京今天的天气,我需要调用 getWeather 工具。
Action: getWeather
Action Input: {"city": "北京"}
Observation: 城市北京今天晴朗,气温25摄氏度
Thought: 我已经拿到了北京天气信息,可以回答用户了。
Final Answer: 北京今天晴朗,气温25摄氏度。
这段轨迹清晰地展示了ReAct的交替过程。实际运行时,Observation是由工具执行函数返回的真实结果,而不是模型自己生成的。如果工具返回了错误信息,模型会看到错误并尝试修正。这种闭环机制让整个系统具备了一定的自我纠错能力。
可能遇到的问题与优化建议
模型输出格式不稳定是ReAct落地的常见障碍。某些模型可能漏掉Action Input,或者把Final Answer和Action写在同一行。除了用正则宽松匹配外,还可以在提示词中加入few-shot示例,让模型模仿标准输出。如果使用的模型支持JSON mode或function calling,建议优先使用这些原生能力,它们能大幅降低解析难度。
上下文长度是另一个需要注意的问题。每轮循环都会向消息数组追加assistant和user消息,如果步数较多或工具返回内容很长,token消耗会迅速增加。可以通过裁剪历史消息、只保留最近几轮,或者对Observation做摘要来缓解。对于生产环境,还可以引入流式输出,让用户在模型推理过程中看到中间Thought,提升体验。
此外,工具执行可能存在副作用,例如写数据库或发送请求。在Auto模式下让模型自行决定调用哪些工具具有一定风险,必要时需要加入人工确认环节。整体来看,手写ReAct循环适合理解原理和轻量场景;如果项目复杂度较高,可以考虑迁移到LangChain.js等框架,它们已经封装了更完善的工具管理和错误恢复机制,但核心逻辑与本文实现并无本质区别。

