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 各自的主场
- 用可观测行为(首轮过就不反思、反思没推进就早停、反思文本能定位到哪一步错)验收设计,而不是"看起来对"
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设计任务:三个判别决策
下面每一行都是一次判别:给出备选、给出这个场景下的选择、给出"为什么不选另一条"。每个决策点回链到前面定义过它的章节——判别题考的从来不是记住名词,是对取舍的推理。
| 决策点 | 备选 | 这个场景选哪个 / 为什么 |
|---|---|---|
| ① 用 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 章点名的失败模式。本章要在不改三组件类的前提下,给主循环加两道护栏:
- 反思护栏(堵 correct→incorrect 翻转):04 章那条"在已经对的答案上继续反思反而改错"。所以一旦 Evaluator 判通过,立刻停、绝不再反思一版。更进一步:通过即停,不给"对的解"任何被改坏的机会。
- 早停(堵收敛 plateau):04 章 WebShop 那种"trial 4 就平台期、反思不再推进"。所以当反思连续若干轮都没改变结果(同一个错误反复出现),就提前停,不耗满
max_trials白烧 LLM 调用。
同时让主循环返回每个 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 — 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 章的失败模式——注释里标了堵的是哪一条:
@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 封装即可:
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 的预期输出:
[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反思问题
- 这三个决策里,哪个你做得最不确定?回看是哪一章帮你定下来的——是 01 的概念、02 的机制、还是 04 的失败模式?把"卡住→回看哪章→怎么定的"这条路径写下来。判别能力的标志,是能讲清这条回看路径,而不是记住结论。
- (场景变形) 如果这个 CI 助手要从"写函数"升级成"改一个跨多文件的 bug"——需要多步探索代码库、读多个文件、再改。这时该把 Actor 从单步换成 ReAct 多步吗?换了之后,Evaluator 和 Self-Reflection 要不要跟着改?哪一个组件的工作量变化最大?
反思参考(先自己想完再展开)
- 没有标准答案——重点是能定位到具体章节。常见的"最难"是决策 ③(Self-Refine vs Reflexion):它最细,要同时认清"有没有外部 reward"和"教训要不要跨 trial 留存"两条轴,且 Self-Refine 与 Reflexion 在文献里最容易被混为一谈。能讲清"CI 有外部 reward + 要跨尝试留教训 ⇒ Reflexion,而非只润色单次输出的 Self-Refine",就说明这个判别是推理出来的、不是背的。
- 应该把 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 章那条:反思的价值由判据可靠度 + 初始正确率共同决定,前置判断就是把这两者显式量化进停止策略。