手写 ReAct(Reasoning + Acting)引擎:思考-行动-观察状态机

本节目标

为什么「先出声思考,再动手调工具」如此重要?

由于自回归语言模型(Autoregressive LLM)是按 Token 从左到右逐个采样预测的,如果你要求模型直接输出工具调用的 JSON 参数,模型没有「打草稿」的计算空间(Scratchpad),很容易在面对多步条件判断时选错工具或填错参数。

ReAct(Reason + Act) 范式的精髓在于:强制模型在每一步行动(Action)之前,先根据上一轮的环境观察(Observation)生成一段显式的推理链(Thought):

生产环境中 ReAct 最常见的病态:原地打转(Looping)

在实际线上运行中,ReAct Agent 最常遇到的故障是:模型连续两次用完全相同的参数调用同一个工具(例如搜索某个不存在的文件名,返回空列表后,下一轮它不甘心又用一模一样的参数再搜一次)。如果不在引擎层拦截,它会一直重复到 maxSteps 耗尽。

手写带死循环自愈拦截的 ReAct 状态机引擎

下面的代码实现了完整的 ReAct 循环,并内置了基于签名哈希(toolName + canonicalArgs)的重复动作检测器:一旦发现模型试图重复执行相同调用,立即在本地注入一条警告提示强制其换思路:

export class ReActEngine {
  constructor({ llm, registry, maxSteps = 10, onStepEvent }) {
    this.llm = llm
    this.registry = registry
    this.maxSteps = maxSteps
    this.onStepEvent = onStepEvent
  }

  #buildActionSignature(name, rawArgs) {
    try {
      const obj = typeof rawArgs === 'string' ? JSON.parse(rawArgs || '{}') : rawArgs
      const sortedKeys = Object.keys(obj).sort()
      const normalized = sortedKeys.map((k) => `${k}:${JSON.stringify(obj[k])}`).join('|')
      return `${name}(${normalized})`
    } catch {
      return `${name}(${String(rawArgs).trim()})`
    }
  }

  async solve(question) {
    const systemPrompt = [
      '你是一个严格遵循 ReAct(思考-行动-观察)范式的智能体。',
      '每次调用工具前,请先在 content 中简要写出你的【思考 Thought】:目前已知什么、下一步为什么要调该工具。',
      '如果某个工具返回空结果或报错,严禁使用完全相同的参数重复调用,请更换查询词或换用其他工具。'
    ].join('\n')

    const messages = [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: question }
    ]

    // 记录已执行过的 (工具名 + 归一化参数) 签名及其返回结果
    const executedSignatures = new Map()
    const trajectory = []

    for (let step = 1; step <= this.maxSteps; step++) {
      const response = await this.llm.chat({
        messages,
        tools: this.registry.toOpenAiTools()
      })

      const msg = response.message
      messages.push(msg)

      const thought = (msg.content || '').trim()
      if (thought) {
        this.onStepEvent?.({ type: 'thought', step, content: thought })
      }

      // 若无工具调用,说明推理收敛,直接输出最终答案
      if (!msg.tool_calls || msg.tool_calls.length === 0) {
        this.onStepEvent?.({ type: 'final_answer', step, content: thought })
        return { ok: true, steps: step, answer: thought, trajectory }
      }

      const toolMessages = []
      for (const tc of msg.tool_calls) {
        const sig = this.#buildActionSignature(tc.function.name, tc.function.arguments)

        // 死循环拦截:若之前已用完全相同的参数调用过该工具,直接拦截并注入换思路指令
        if (executedSignatures.has(sig)) {
          const prevResult = executedSignatures.get(sig)
          const warning = JSON.stringify({
            ok: false,
            loopDetected: true,
            warning: `禁止重复调用!你在第 ${prevResult.step} 步已执行过完全相同的调用 ${sig},当时结果为:${prevResult.summary}。请立即更换参数或基于现有信息直接回答。`
          })
          this.onStepEvent?.({ type: 'loop_blocked', step, signature: sig })
          toolMessages.push({
            role: 'tool',
            tool_call_id: tc.id,
            name: tc.function.name,
            content: warning
          })
          continue
        }

        // 正常执行工具并记录签名
        this.onStepEvent?.({ type: 'action', step, tool: tc.function.name, args: tc.function.arguments })
        const toolMsg = await this.registry.invokeOne(tc)
        executedSignatures.set(sig, {
          step,
          summary: toolMsg.content.slice(0, 160)
        })
        this.onStepEvent?.({ type: 'observation', step, tool: tc.function.name, output: toolMsg.content })
        toolMessages.push(toolMsg)
      }

      messages.push(...toolMessages)
    }

    return {
      ok: false,
      steps: this.maxSteps,
      answer: '达到最大推理步数上限,任务未能在预算内收敛。',
      trajectory
    }
  }
}

课后练习

练习 1:在 #buildActionSignature(name, rawArgs) 方法中,为什么要先将参数 JSON 解析后再按属性名 Object.keys(obj).sort() 排序生成签名,而不是直接拼接原始 rawArgs 字符串?

答案:JSON 对象的键顺序是不敏感的。模型第一次可能输出 {"a":1,"b":2},第二次输出 {"b":2,"a":1},两者在语义上完全等价;只有先对键名做字典序归一化(Canonicalization),才能 100% 准确识别出等价的重复调用。

练习 2:当检测到重复动作(executedSignatures.has(sig))时,为什么我们不是直接抛异常终止整个 Agent,而是构造一条带 loopDetected: true 和上一次结果摘要的 role: 'tool' 消息返还给模型?

答案:模型重复调用往往是因为上下文较长导致它「忘了」几步前已经查过该参数;在 tool 响应里明确提醒它「你在第 N 步已查过且结果是什么」,模型在下一步就能立即跳出死胡同,改用新参数或直接汇总答案。