广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何用Node.js从零实现ReAct推理与行动模式?

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

如何用Node.js从零实现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等框架,它们已经封装了更完善的工具管理和错误恢复机制,但核心逻辑与本文实现并无本质区别。

广告位 · 文章底部(后台-模块管理替换此区域)