Chapter 05

综合实战:给内部代码助手加一个自我修正的反思循环

01 章把 Reflexion 拆成 Actor / Evaluator / Self-Reflection 三组件,钉死了"trajectory 是短期记忆、反思文本是长期记忆"。02 章讲透了机制:为何语言能替代权重、reward 来源分任务、反思 ≠ 重试。03 章把 reflexion.py 跑通了。04 章列出机制被违反时的失败模式。本章不再逐节讲机制,而是把前四章塞进一个真实需求:给一个内部代码助手加上"在 CI 测试反馈下自我修正"的能力。它逼出的不是"某步怎么写",而是判别——同一个决策点,这里该用 Reflexion 还是别的模式、Evaluator 该信谁、反思该在哪一层发生。

本章你将建立的 schema

  • 把"学习发生在上下文、不在权重里"从一句话本质落到一张可运行的 CI 反思循环上
  • 三个跨章判别决策——用不用 Reflexion、Evaluator 信谁、反思在哪一层——每个都没有放之四海的默认答案
  • 选型判别:一棵决策树分清 Reflexion / 纯 ReAct / Self-Refine / RLHF 各自的主场
  • 用可观测行为(首轮过就不反思、反思没推进就早停、反思文本能定位到哪一步错)验收设计,而不是"看起来对"
要让 agent 自我改进 能改权重 + 有训练数据/算力? 是 RLHF / 微调 改权重 · 全局持久 否 有可靠验证信号? (测试 / 环境 / oracle) 否 纯 ReAct 无 grounding 别自评 是 要跨多次尝试 累积教训? 否 Self-Refine 同 session 改一版 是 Reflexion 本项目主线
图 5.1自我改进模式选型决策树:三道菱形把需求筛到四个终点。 注意:四个终点不是优劣排序,而是分界轴不同——Q1 分"改不改权重",Q2 分"有没有外部判对错的信号",Q3 分"学习只在本次 session 还是跨多次尝试持久化"。CI 代码助手三道都走右/是分支,落在朱红的 Reflexion;但只要 Q2 答"否"(无可靠 Evaluator),就该退回纯 ReAct——硬上反思反而更糟。

5.1项目背景:CI 反馈下的代码助手

一个内部代码助手现在的工作方式是:工程师给一句自然语言需求("写一个解析 ISO 时间戳的函数"),助手调一次 LLM 生成代码,直接贴给工程师。问题是一次成的概率不高——生成的函数经常在边界条件上挂掉,工程师得自己跑测试、自己改。

团队已经有一套现成的资产:每个需求都附带一组单元测试(CI 流水线里跑的那组)。于是有人提议:既然测试能自动判对错,为什么不让助手自己跑测试、看到失败信息后自己改一版,改到通过再贴给人?这正是 Reflexion 的设定——把 LLM 写代码当 Actor,把跑 CI 测试当 Evaluator,失败时让模型反思"哪一步错了、下次怎么改"。

需求画像

验证信号:每个任务自带单元测试,跑一遍就是 pass/fail——这是一个可靠、机器可判的 Evaluator,不是"让模型自己评自己"。这一条几乎把"该用 Reflexion"写定了。

初始正确率:助手当前一次成的比例偏低(边界条件常挂),属于反思能净获益的区间(初始正确率越低、反思边际收益越大)。

可重试:写代码这个动作天然可重做——失败了再生成一版没有副作用,不像"已经把钱转出去了"那种不可逆动作。

成本约束:每多一轮就多一次 LLM 调用 + 一次测试,单轮 10–30s。所以要有早停,不能无限反思下去。

这套画像把"用 Reflexion"几乎写死了:可靠 Evaluator + 初始正确率低 + 动作可重试 + 任务自带 ground-truth 判据。但"选了 Reflexion"只是起点——真正的工程判断在下面三个决策点上,每一个都对应前面某一章,且都没有"标准答案",只有"对这个 CI 场景"的答案。

5.2设计任务:三个判别决策

下面每一行都是一次判别:给出备选、给出这个场景下的选择、给出"为什么不选另一条"。每个决策点回链到前面定义过它的章节——判别题考的从来不是记住名词,是对取舍的推理。

表 5.1 · CI 代码助手的三个跨章判别决策
决策点备选这个场景选哪个 / 为什么
① 用 Reflexion 还是纯 ReAct
01 三组件 · 02 反思≠重试 vs agent-reasoning-patterns
纯 ReAct(单条轨迹推理+行动)/ Reflexion(外套 评估→反思→重试) 选 Reflexion。判据是两条:动作能否重试(写代码可以,重生成无副作用)+ 有无可靠 verifier(CI 测试就是)。两条都满足,套上反思循环就能把失败信号变成下一轮的教训。若任一条不满足——比如动作不可逆、或没有任何判对错的信号——就该退回纯 ReAct 的单轨迹推理,因为 Reflexion 的增益全部来自那个能判对错的 Evaluator;没有它,反思就是在没有 grounding 的情况下自说自话。
② Evaluator 用自生成测试还是 ground-truth
02 reward 来源 vs 04 Evaluator 依赖
让 LLM 自己生成单元测试当判据 / 用 CI 里已有的 ground-truth 测试 选 ground-truth 测试。团队的 CI 测试是人写、可信的判据。自生成测试看着省事,但 MBPP 那次失利就是栽在它身上——自生成测试有 16.3% 假阳性,会把错代码误判通过、提前提交。Reflexion 的失败非对称:假阴性还能继续反思,假阳性致命(贴了错答案不可恢复)。CI 场景既然已有 ground-truth,没有任何理由去赌一个高假阳性的判据。自生成测试只在完全没有现成测试时才作为退路。
③ 同 session 改一版还是跨 trial 累积
02 备选方案表(Self-Refine vs Reflexion)
Self-Refine(同一 session 内自评自改,不持久化)/ Reflexion(跨 trial、反思写进 episodic memory) 选 Reflexion。分界轴是有没有外部 reward + 教训要不要跨尝试留存。Self-Refine 靠模型自评(无外部信号),且改完即弃、不积累。这里有 CI 这个外部 reward,且希望"上一次踩过的边界坑"能写进记忆、喂给下一轮,避免重犯同一个错。若这个助手只需"对单次输出润色一遍、不要历史教训",那 Self-Refine 更轻——但 CI 场景要的是带外部判据的、跨尝试的纠错,正是 Reflexion 与 Self-Refine 的分界处(也是两者最容易被混淆的地方)。

三行有一个共同点值得停下来看:每个决策的关键都不是"哪个更先进",而是"这个场景满不满足那条分界判据"。① 看动作可重试 + 有 verifier;② 看判据可不可信(假阳性代价);③ 看要不要外部 reward + 跨尝试留存。把"Reflexion 最新所以最好"当默认,是这道题最常见的错——判别的核心就是认出该用哪条轴去分。

想一想

决策 ② 选了 ground-truth 测试。假设有一天换了个新任务,团队还没来得及写测试,只能让 LLM 自己生成测试当判据。这时该把 max_trials 调大还是调小?

展开答案(先停 10 秒再点)

该调小,甚至退回不反思。自生成测试有 16.3% 假阳性——trial 越多,越可能在某一轮"恰好"骗过这个不可靠判据、提前提交一个其实错的答案。判据越不可信,越不该给它更多"蒙混过关"的机会。

更稳的做法:自生成测试时把判据调严(比如要求连续两轮都通过、或人工抽检通过的样本再提交),而不是放更多 trial。这正呼应"假阳性致命"——判据的可信度,比尝试次数更决定 Reflexion 到底帮不帮得上忙。

5.3选型判别:Reflexion / ReAct / Self-Refine / RLHF

决策 ① 和 ③ 合起来,其实是在一棵更大的决策树上定位。这棵树(章首图 5.1)把四个最容易混淆的"自我改进"模式按分界轴排开。把判别路径走一遍,就能说清"为什么是 Reflexion,而不是它旁边那三个"。

三道判别各自在问什么

  • Q1 能改权重 + 有训练数据/算力? 这是 RLHF / 微调与其余三者的分水岭。改权重得到的是全局、持久的改进(这个模型以后所有任务都受益),但代价是要数据、要算力、要训练流程。CI 助手是一个轻量内部工具,没有这个预算,也不需要全局改——它只要在当前这个任务上改对。所以走"否"。SCoRe(2024-09)那条前沿正是把反思能力训进权重,但那是另一条重得多的路。
  • Q2 有可靠验证信号? 这是 Reflexion / Self-Refine 与纯 ReAct 的分水岭,也是本教程反复强调的那条红线。有测试/环境 reward/oracle 才谈得上"反思";没有就只能让模型凭空自评——Huang 等人 ICLR 2024 已证伪:无 grounding 的内在自我纠错净降准确率。CI 测试是可靠信号,走"是"。
  • Q3 要跨多次尝试累积教训? 这是 Reflexion 与 Self-Refine 的分水岭——也是最细的一刀。两者都在 session 内自我改进,区别在 Reflexion 把反思写进 episodic memory、跨 trial 持久化("这次的教训喂给下一轮"),Self-Refine 改完即弃、不留历史。CI 助手希望"上次踩的边界坑别再踩",要跨尝试留存,走"是",落到 Reflexion。
洞察 · 判别不是追新

资深信号不是"知道 Reflexion 所以到处用",而是认得出哪道分界轴在当前场景起决定作用。CI 助手落在 Reflexion,是因为它三道分界都恰好走到那个终点:不改权重、有可靠判据、要跨尝试留教训。把任意一条改掉——动作不可逆、没有测试、或只需润色单次输出——最优解立刻滑到旁边某个模式。Reflexion 不是更好的 ReAct,它是"ReAct 当 Actor、外面套了评估和跨 trial 记忆"的组合体;用不用那层外壳,由这三道分界决定。

5.4自己实现(先别看参考实现)

把 §5.2 的决策落成代码,在 03 章那套 reflexion.py 骨架上扩展。先合上参考实现,自己写一版——判别题的价值在于你做了选择、并能说出为什么;直接看答案等于把这章读成了配置清单。

任务:给主循环加早停 + 反思护栏

03 章的 reflexion() 主循环有两个隐患,正是 04 章点名的失败模式。本章要在不改三组件类的前提下,给主循环加两道护栏:

同时让主循环返回每个 trial 的可观测日志(这一轮过没过、有没有反思、为什么没反思),好让下面的 checklist 能用行为来验。

验收 checklist(用可观测行为验,不靠"看起来对")

  • 首轮过就不反思:喂一个第 0 轮就通过的任务,循环只跑 1 个 trial,且那个 trial 的"是否反思"标记为否——绝不在已通过的解上调用 Self-Reflection。
  • 失败→反思→通过:喂 03 章那个 median 例子(首轮漏了偶数长度),循环在第 0 轮失败后反思、第 1 轮通过;日志显示第 0 轮 reflected=True、末轮通过且不再反思。
  • 反思没推进就早停:喂一个"每轮都返回同样错代码、报同一个错误"的 mock,max_trials 设 5,循环应在远小于 5 的轮数停下,并在日志里记下"反思未推进、提前停止",而不是傻跑满 5 轮。
  • 反思文本能定位到哪一步:打印失败那轮产生的反思文本,它应当是第一人称 + 指出哪一步错 + 下次怎么改(credit assignment),而不是"我错了,我会改进"这种没有定位的空话。
验收陷阱

"早停生效"不能靠"循环跑完了"来证明——要主动喂一个永远不会进步的 mock(每轮同样的错代码),看它是否在 max_trials 之前停下。一个没有早停的实现,在正常路径(很快通过)下看起来完全正常,只会在"任务太难、反思一直推不动"时白烧满额度才暴露。验收的是不收敛路径的行为,不是 happy path。

参考实现(写完自己版本再展开)

下面整段基于 03 章的 reflexion.py——三组件类(Actor / Evaluator / SelfReflection / EpisodicMemory)原样不动,只替换主循环 reflexion(),新增一个 TrialLog 记录可观测行为。纯标准库,可直接 python3 reflexion.py 运行。

reflexion.py(三组件部分 · 与 03 章同源,原样保留) Python
"""reflexion.py — Reflexion 循环 + capstone 扩展(早停 + 反思护栏)"""
from dataclasses import dataclass, field
from typing import Protocol

class LLM(Protocol):
    def complete(self, prompt: str) -> str: ...

@dataclass
class Task:
    spec: str
    entry_point: str
    tests: list[str]

class Actor:
    def __init__(self, llm: LLM):
        self.llm = llm
    def act(self, task: Task, reflections: list[str]) -> str:
        memory_block = "\n".join(f"- {r}" for r in reflections)
        prompt = (
            f"实现函数 {task.entry_point}。\n任务:{task.spec}\n"
            + (f"\n过去尝试的教训(避免重犯):\n{memory_block}\n" if reflections else "")
            + "\n只输出函数定义代码。"
        )
        return self.llm.complete(prompt)

class Evaluator:
    def evaluate(self, code: str, task: Task) -> tuple[bool, str]:
        ns: dict = {}
        try:
            exec(code, ns)                  # 定义函数
            for assertion in task.tests:    # 逐条跑测试
                exec(assertion, ns)
            return True, ""
        except Exception as e:
            return False, f"{type(e).__name__}: {e}"

class SelfReflection:
    def __init__(self, llm: LLM):
        self.llm = llm
    def reflect(self, code: str, error: str, task: Task) -> str:
        prompt = (
            f"任务:{task.spec}\n我写的代码:\n{code}\n"
            f"测试失败:{error}\n"
            "用第一人称写一条简短教训:我哪一步错了、下次具体怎么改。"
        )
        return self.llm.complete(prompt)

@dataclass
class EpisodicMemory:
    capacity: int = 3
    reflections: list[str] = field(default_factory=list)
    def add(self, sr: str) -> None:
        self.reflections.append(sr)
        self.reflections = self.reflections[-self.capacity:]   # 只留最近 capacity 条

① 扩展点:带早停 + 护栏的主循环。两道护栏都直接对应 04 章的失败模式——注释里标了堵的是哪一条:

reflexion.py(扩展的主循环) Python
@dataclass
class TrialLog:
    """每个 trial 的可观测记录,供验收 checklist 检查行为。"""
    index: int
    passed: bool
    error: str
    reflected: bool          # 这一轮到底有没有触发反思
    skip_reason: str = ""    # 没反思时,护栏给出的原因

def reflexion(task: Task, actor: Actor, evaluator: Evaluator,
              reflector: SelfReflection, memory: EpisodicMemory,
              max_trials: int = 3,
              patience: int = 2) -> tuple[str, bool, list[TrialLog]]:
    """两道护栏:
       1) 反思护栏: Evaluator 判通过就立刻停、绝不在已通过的解上"再反思一版"
          —— 堵 04 章 reflection-hallucination 的 correct→incorrect 翻转。
       2) 早停: 连续 patience 次"同一个错误信号" → 反思没在推进, 提前停,
          不耗满 max_trials —— 堵 04 章 convergence 的 plateau / 白烧成本。
    """
    code = ""
    logs: list[TrialLog] = []
    last_error = None
    repeat = 0
    for t in range(max_trials):
        code = actor.act(task, memory.reflections)          # 生成 trajectory
        passed, error = evaluator.evaluate(code, task)       # 打分(reward)
        if passed:
            # 护栏 1: 通过即停, 不在正确解上反思(避免把对的改错)
            logs.append(TrialLog(t, True, "", reflected=False,
                                 skip_reason="已通过,无需反思"))
            return code, True, logs
        # 早停判定: 同一个错误信号重复 → 反思没在推进
        repeat = repeat + 1 if error == last_error else 0
        last_error = error
        if repeat >= patience - 1 and t < max_trials - 1:
            logs.append(TrialLog(t, False, error, reflected=False,
                                 skip_reason=f"连续 {repeat + 1} 次相同错误,反思未推进,提前停止"))
            return code, False, logs
        sr = reflector.reflect(code, error, task)            # 反思: 做 credit assignment
        memory.add(sr)                                       # 写入长期记忆
        logs.append(TrialLog(t, False, error, reflected=True))
    return code, False, logs                                 # 用尽 trials

② 三个 mock 验收(对应 checklist 三条可观测行为)。ScriptedLLM 与 03 章同源;真实使用时换成 OpenAI/Anthropic 封装即可:

reflexion.py(验收演示,可直接跑) Python
class ScriptedLLM:
    """按调用顺序返回预设输出。真实使用时替换为 OpenAI / Anthropic 客户端封装。"""
    def __init__(self, outputs: list[str]):
        self._outputs = list(outputs)
    def complete(self, prompt: str) -> str:
        return self._outputs.pop(0) if self._outputs else ""

task = Task(
    spec="返回数字列表的中位数(偶数长度取中间两数平均)",
    entry_point="median",
    tests=[
        "assert median([1, 3, 2]) == 2",
        "assert median([1, 2, 3, 4]) == 2.5",     # 偶数长度
    ],
)

# 演示 1 — 失败→反思→通过(checklist 第 2 条)
llm = ScriptedLLM([
    "def median(nums):\n    s = sorted(nums)\n    return s[len(s)//2]",
    "我只取了中间单个元素,没处理偶数长度;下次对偶数长度取中间两数的平均。",
    "def median(nums):\n    s = sorted(nums)\n    n = len(s)\n    if n % 2:\n        return s[n//2]\n    return (s[n//2 - 1] + s[n//2]) / 2",
])
code, ok, logs = reflexion(task, Actor(llm), Evaluator(), SelfReflection(llm), EpisodicMemory())
print(f"[1] passed={ok} trials_run={len(logs)}")
assert ok and logs[0].reflected and not logs[-1].reflected   # 首轮反思过、末轮通过不再反思

# 演示 2 — 反思没推进就早停(checklist 第 3 条)
same_buggy = "def median(nums):\n    return nums[0]"          # 永远错且报同一个 AssertionError
llm2 = ScriptedLLM([same_buggy, "教训1", same_buggy, "教训2", same_buggy, "教训3"])
_, ok2, logs2 = reflexion(task, Actor(llm2), Evaluator(),
                          SelfReflection(llm2), EpisodicMemory(),
                          max_trials=5, patience=2)
print(f"[2] passed={ok2} trials_run={len(logs2)} (max_trials=5)")
assert not ok2 and len(logs2) < 5 and "提前停止" in logs2[-1].skip_reason   # 远未跑满就停

# 演示 3 — 首轮通过,护栏阻止反思(checklist 第 1 条)
llm3 = ScriptedLLM([
    "def median(nums):\n    s = sorted(nums)\n    n = len(s)\n    if n % 2:\n        return s[n//2]\n    return (s[n//2-1]+s[n//2])/2",
])
_, ok3, logs3 = reflexion(task, Actor(llm3), Evaluator(), SelfReflection(llm3), EpisodicMemory())
print(f"[3] passed={ok3} trials_run={len(logs3)} reflected={logs3[0].reflected}")
assert ok3 and len(logs3) == 1 and not logs3[0].reflected     # 首轮就过、绝不反思

print("ALL ASSERTIONS PASSED")

运行 python3 reflexion.py 的预期输出:

预期输出 Text
[1] passed=True trials_run=2
[2] passed=False trials_run=2 (max_trials=5)
[3] passed=True trials_run=1 reflected=False
ALL ASSERTIONS PASSED

每个决策"为什么选 X 不选 Y"小结:

  • 护栏选"通过即停"不选"再反思一版":03 章原循环里,passed 后直接 return 已经隐含了这条——但本章把它显式记进日志(reflected=False + 原因),是为了能验证护栏真在挡。在对的答案上继续反思会触发 correct→incorrect 翻转(论文复现里 GSM8K 8.8%、Llama-2 31% 的翻转率),所以"对了就别动它"。
  • 早停按"相同错误信号"判,不按固定轮数砍:固定轮数(比如永远只跑 2 轮)会误伤"前两轮没成、第三轮本能成"的任务。按错误是否在变判:错误一直不变,说明反思没在做有效的 credit assignment、陷在 local minima,再跑也是白烧;错误在变,说明还在推进,就继续。这比"一刀切轮数"更贴合"反思有没有用"这个真实信号。
  • 用 error == last_error 而非语义相似度:这是最小可运行的判据(纯标准库)。真实系统里可换成"测试通过数有没有增加"这种更强的进度信号——但分界轴不变:进度停了就停反思。
  • 不动三组件类:两道护栏都是主循环层的策略,不是组件能力的改变。Actor/Evaluator/Self-Reflection 仍各司其职——这正呼应 01 章"三组件 + 一个把它们接起来的循环",护栏改的是"循环怎么停",不是"组件怎么干活"。
亲手画一张图

合上教程,在纸上或 Excalidraw 里画出这个 CI 反思循环——只画 3 个核心节点:Actor(生成代码)、Evaluator(跑 CI 测试出 pass/fail)、Self-Reflection(失败时产出教训)。再画出连接它们的环,以及那条"反思文本写进 memory、回喂给下一轮 Actor"的回边。画完回到 章首图 5.1 与 01 章三组件图对照——你画的回边,是从 Evaluator 直接连回 Actor(那只是"重试"),还是经过 Self-Reflection 和 memory 再回 Actor?后者才是 Reflexion;少了中间那步,就退化成 04 章说的盲目 retry(52% < 不 refine 的 60%)。这条回边走不走 Self-Reflection,正是"反思 ≠ 重试"的全部。

5.5反思问题

  1. 这三个决策里,哪个你做得最不确定?回看是哪一章帮你定下来的——是 01 的概念、02 的机制、还是 04 的失败模式?把"卡住→回看哪章→怎么定的"这条路径写下来。判别能力的标志,是能讲清这条回看路径,而不是记住结论。
  2. (场景变形) 如果这个 CI 助手要从"写函数"升级成"改一个跨多文件的 bug"——需要多步探索代码库、读多个文件、再改。这时该把 Actor 从单步换成 ReAct 多步吗?换了之后,Evaluator 和 Self-Reflection 要不要跟着改?哪一个组件的工作量变化最大?
反思参考(先自己想完再展开)
  1. 没有标准答案——重点是能定位到具体章节。常见的"最难"是决策 ③(Self-Refine vs Reflexion):它最细,要同时认清"有没有外部 reward"和"教训要不要跨 trial 留存"两条轴,且 Self-Refine 与 Reflexion 在文献里最容易被混为一谈。能讲清"CI 有外部 reward + 要跨尝试留教训 ⇒ Reflexion,而非只润色单次输出的 Self-Refine",就说明这个判别是推理出来的、不是背的。
  2. 应该把 Actor 换成 ReAct 多步——单步生成搞不定"读多个文件再改"。换之后:Self-Reflection 的工作量变化最大。单步任务里,trajectory 就是一段代码,反思只需定位"这段代码哪行错";多步任务里,trajectory 变成一长串"读文件 A → 推断 → 改文件 B → 跑测试"的动作序列,反思要做的 credit assignment 难得多——要在一长串动作里定位是哪一步(哪次读错了文件、哪次改错了地方)导致最终失败,这正是 Reflexion 比标量 reward 强的地方,但也对反思质量提出更高要求。Evaluator 变化最小:仍是跑那组测试出 pass/fail,判据本身没变。这也说明:Actor 内部换成 ReAct,Reflexion 的外层循环结构不变——Reflexion 始终是"把某种 Actor 套进 评估→反思→重试",01 章那个组合关系在这里再次成立。
进阶挑战 · 刚好够不着

给护栏加一道"是否值得反思"的前置判断

04 章的 RA 阈值说:反思只在初始正确率 <20–30% 时净获益,用在本来就高置信的答案上反而有害(HotpotQA 80.3→76.2)。现在的护栏是"失败了才反思",但它无法区分"差一点点就对(高置信)"和"完全跑偏(低置信)"。给主循环再加一道前置判断:在反思之前,先估一个"这次失败到底值不值得反思"的信号,不值得就直接停或换策略,而不是无脑反思。可用的信号有哪些?写出两种,并说明各自的假阳性/假阴性风险。

提示(卡住再展开)

可用信号(写代码场景):① 测试通过比例——10 条测试过了 9 条(差一点点)vs 过了 1 条(跑偏),前者更值得再反思一轮,后者可能任务本身超纲、该停。② 错误类型——AssertionError(逻辑差一点)比 SyntaxError 反复出现(模型根本没理解任务)更值得反思。③ 反思后通过数有没有单调上升——上升说明在推进,停滞/下降说明该停。风险面:用"通过比例高才反思"会有假阴性——某些任务前期通过数低但一次顿悟就全过,被提前砍掉;用"通过比例"当唯一信号也可能被假阳性测试污染(测试本身判错)。核心仍是 04 章那条:反思的价值由判据可靠度 + 初始正确率共同决定,前置判断就是把这两者显式量化进停止策略。