Reflexion 自我反思与纠错循环:基于 Evaluator 的代码自愈闭环
本节目标
- 掌握论文《Reflexion: Language Agents with Verbal Reinforcement Learning》中的三元架构(Actor 行动者、Evaluator 评估器、Self-Reflection 反思器)
- 理解为什么「把外部确定性校验器(编译器/单测/Linter)接入反思环」比单纯让模型「自我检查一下」有效十倍
- 用原生 JavaScript 实现一个带**反思记忆池(Episodic Reflection Memory)**的代码生成与单测自愈闭环
为什么单纯让 LLM「检查一下自己的答案」效果很差?
研究发现,在没有外部环境反馈(External Grounding)的情况下,直接让大模型对自己的输出进行自我纠错(Intrinsic Self-Correction),它往往要么盲目自信地回答「检查无误」,要么把原本正确的答案改错。
真正有效的 Reflexion(语言强化反思) 依赖两个核心机制:
- 确定性的外部评估器(Deterministic Evaluator):用真实的编译器、单元测试运行器、Schema 校验器或 SQL 执行器去跑模型的产出,拿到真实的报错堆栈与失败用例。
- 结构化的语言反思(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 写出干净的新实现。