Reflexion 自我反思与纠错循环:基于 Evaluator 的代码自愈闭环

本节目标

为什么单纯让 LLM「检查一下自己的答案」效果很差?

研究发现,在没有外部环境反馈(External Grounding)的情况下,直接让大模型对自己的输出进行自我纠错(Intrinsic Self-Correction),它往往要么盲目自信地回答「检查无误」,要么把原本正确的答案改错。

真正有效的 Reflexion(语言强化反思) 依赖两个核心机制:

  1. 确定性的外部评估器(Deterministic Evaluator):用真实的编译器、单元测试运行器、Schema 校验器或 SQL 执行器去跑模型的产出,拿到真实的报错堆栈与失败用例。
  2. 结构化的语言反思(Verbal Reflection):在发起下一次重写之前,先让模型针对「刚才为什么会挂、根本原因是什么、下一版必须避开什么坑」写一段简短的教训总结,存入反思记忆数组(reflections),并在后续每一次尝试中持续挂载。
┌──────────────┐    生成候选代码     ┌───────────────────────┐
│ Actor (生成器)│ ─────────────────► │ Evaluator (单测/编译器)│
└──────▲───────┘                    └───────────┬───────────┘
       │                                        │ (测试全部通过 → 交付!)
       │ 携带累积教训 reflections[]              ▼ (测试失败 + 错误堆栈)
       │                            ┌───────────────────────┐
       └─────────────────────────── │ Reflector (反思总结器) │
                                    └───────────────────────┘

手写 Reflexion 代码自愈闭环引擎

export class ReflexionCodeAgent {
  constructor({ llm, evaluator, maxTrials = 4 }) {
    this.llm = llm
    this.evaluator = evaluator // async (code) => { passed: boolean, diagnostics: string }
    this.maxTrials = maxTrials
  }

  async generateAndHeal({ taskSpec, functionSignature }) {
    const reflections = [] // 跨轮次累积的踩坑教训(Episodic Memory)
    let lastCode = ''
    let lastDiagnostics = ''

    for (let trial = 1; trial <= this.maxTrials; trial++) {
      // 1. Actor 阶段:结合任务说明与历史反思教训生成代码
      const actorPrompt = [
        `## 任务目标\n${taskSpec}`,
        `## 必须遵守的函数签名\n\`\`\`javascript\n${functionSignature}\n\`\`\``,
        reflections.length > 0
          ? `## 前序尝试踩坑反思(本轮严禁重犯!)\n${reflections.map((r, i) => `${i + 1}. ${r}`).join('\n')}`
          : '',
        lastCode
          ? `## 上一版未通过测试的代码\n\`\`\`javascript\n${lastCode}\n\`\`\`\n## 真实测试报错堆栈\n${lastDiagnostics}`
          : '',
        '请输出完整可运行的 JavaScript 实现代码。'
      ].filter(Boolean).join('\n\n')

      const actorRes = await this.llm.chat({
        messages: [
          { role: 'system', content: '你是一位严谨的 JavaScript 工程师,请根据规格与测试反馈编写无 Bug 的代码。' },
          { role: 'user', content: actorPrompt }
        ]
      })

      lastCode = this.#extractCode(actorRes.message.content)

      // 2. Evaluator 阶段:在真实单测沙箱中运行并收集断言结果
      const evalResult = await this.evaluator(lastCode)
      if (evalResult.passed) {
        return {
          status: 'healed_and_passed',
          trials: trial,
          code: lastCode,
          reflections
        }
      }

      lastDiagnostics = evalResult.diagnostics

      // 3. Reflector 阶段:若未达到最后一轮,提炼一条高浓度的根因教训追加到记忆池
      if (trial < this.maxTrials) {
        const reflectRes = await this.llm.chat({
          messages: [
            {
              role: 'system',
              content: '你是代码缺陷诊断专家。请用两句话精准指出代码未通过测试的根本原因(Root Cause)以及下一版修复的具体改法,不要直接写长代码。'
            },
            {
              role: 'user',
              content: `任务:${taskSpec}\n失败代码:\n${lastCode}\n测试报错:\n${lastDiagnostics}`
            }
          ]
        })
        reflections.push(reflectRes.message.content.trim())
      }
    }

    return {
      status: 'failed_after_max_trials',
      trials: this.maxTrials,
      code: lastCode,
      lastDiagnostics,
      reflections
    }
  }

  #extractCode(raw = '') {
    const m = /```(?:javascript|js)?\s*([\s\S]*?)```/i.exec(raw)
    return m ? m[1].trim() : raw.trim()
  }
}

课后练习

练习 1:在 ReflexionCodeAgent 中,为什么我们要把历次失败提炼出的 reflections 数组持续保留在 Prompt 中,而不是只保留最新一次的报错信息?

答案:如果只保留上一轮的报错,模型常会出现「跷跷板效应(Oscillation)」——第 2 轮修好了边界 A 却改坏了边界 B,第 3 轮修边界 B 时因为忘了第 1 轮的教训又改回了第 1 轮的错误写法。累积最近几轮的 reflections 相当于给模型提供了明确的负样本约束集。

练习 2:为什么在 Reflector 阶段我们要求模型「用两句话精准指出根因和具体改法,不要直接写长代码」?

答案:浓缩的语言级反思(Verbal Reflection)能够迫使模型先从报错堆栈中抽象出逻辑层面的 Bug 原因(如「未处理空数组输入」或「混淆了浅拷贝与深拷贝」),既节省了宝贵的上下文 Token,又比直接贴大段残缺代码更能指导 Actor 写出干净的新实现。