03 · Hands-on Practice
上手实操:跑通一个最小 Reflexion 循环
02 章建立了机制:标量 reward 做不了 credit assignment,语言反思能定位"哪一步错、下次怎么改",反思文本拼回下一轮 context 完成 in-context 更新。这一章把那套机制落成 80 行能直接 python3 跑起来的代码——先读懂一个完整 worked example,再补两处关键留白,最后独立扩展一个带边界条件的任务。
本章你将建立的 schema
- 一套纯标准库、零 API key 即可运行的 Reflexion 骨架(
reflexion.py),真实使用时只换 LLM 那一层 - 把代码里的
act / evaluate / reflect / memory.add / 主循环逐一对回 01 章的 Actor、Evaluator、Self-Reflection、trajectory、episodic memory - 两个决策点的填法:memory 怎么注入 prompt(长期记忆怎么用)、反思 prompt 怎么写(第一人称 + credit assignment)
- 判断一个改动是否"动到了机制":换 Evaluator、换任务边界条件,会怎样改变循环的可靠性
3.1环境准备
整章代码只依赖 Python 3.10+ 标准库——用到的 dataclass、typing.Protocol、内置 exec 都在标准库里,无需 pip install 任何东西。版本下限是 3.10:骨架用了 list[str]、tuple[bool, str] 这类内置泛型写法,以及 X | None 风格,3.9 及以下会报语法错。
真实的 Reflexion 需要一个真 LLM 当 Actor 和 Self-Reflection。但循环逻辑本身——生成、评估、反思、写记忆、重试——和模型是谁无关。所以这里用一个 ScriptedLLM 把模型输出"录播"成一个列表:它按调用顺序吐出预设字符串,让整个循环在没有任何网络和密钥的情况下确定性地跑通。验证完逻辑,把 ScriptedLLM 换成一个调用 OpenAI 或 Anthropic 的封装(同样实现 complete(prompt) -> str),其余一行不用改——这正是骨架把 LLM 抽象成 Protocol 的目的。
把 3.2 节的两段代码依次粘进同一个文件 reflexion.py(骨架在前,worked example 的 mock 在后),然后:
python3 reflexion.py
没有任何输出之外的依赖。预期输出见 3.2 节末尾。
换真实 API 时,Actor 与 Self-Reflection 可以共用同一个客户端实例(论文里 M_a、M_e、M_sr 常是同一个 base model 的不同 prompt),Evaluator 这里是真跑测试、不调模型——这点在 02 章 reward 来源里讲过:编程任务的 Evaluator 是单元测试,不是 LLM judge。
Task → code:str(即 trajectory)→ (passed:bool, error:str) → reflection:str → 写进 memory → 拼回下一轮 prompt。
注意:红色回边是唯一跨 trial 携带信息的通道——权重不变,变的只是这条边上累积的反思文本。
3.2Worked example:median 任务从失败到通过
任务设定对应论文的 HumanEval 编程场景:让 LLM 写一个函数通过给定单元测试,Evaluator 就是跑测试。下面这套骨架是 03 与 05 两章共用的同一份 reflexion.py——类名和方法签名固定,后面所有练习都在它上面改。先读完整骨架。
"""reflexion.py — 最小可运行的 Reflexion 循环(对应 Shinn et al. 2023, Algorithm 1)"""
from dataclasses import dataclass, field
from typing import Protocol
# ── LLM 接口:换成真实 API 即可接 GPT-4 / Claude ──────────────
class LLM(Protocol):
def complete(self, prompt: str) -> str: ...
# ── 任务定义 ────────────────────────────────────────────────
@dataclass
class Task:
spec: str # 自然语言任务描述
entry_point: str # 待实现的函数名
tests: list[str] # 充当 Evaluator 判据的 assert 语句
# ── Actor:生成代码(产出 = trajectory)──────────────────────
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)
# ── Evaluator:跑测试,返回 (是否通过, 错误信息) ────────────────
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}"
# ── Self-Reflection:把失败转成第一人称语言教训 ──────────────────
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)
# ── 情景记忆:Ω 滑动窗口(论文 Ω=1-3) ─────────────────────────
@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 条
# ── 主循环:Algorithm 1 ──────────────────────────────────────
def reflexion(task: Task, actor: Actor, evaluator: Evaluator,
reflector: SelfReflection, memory: EpisodicMemory,
max_trials: int = 3) -> tuple[str, bool, int]:
code = ""
for t in range(max_trials):
code = actor.act(task, memory.reflections) # 生成 trajectory
passed, error = evaluator.evaluate(code, task) # 打分(reward)
if passed:
return code, True, t # 停止条件:通过
sr = reflector.reflect(code, error, task) # 反思
memory.add(sr) # 写入长期记忆
return code, False, max_trials # 用尽 trials
第二段是 worked example 的驱动代码:用 ScriptedLLM 把 Actor 的演化"录"成三次输出——trial 0 的 buggy 代码、一条反思文本、trial 1 的 fixed 代码。把它接在骨架后面,同一个文件即可运行。
class ScriptedLLM:
"""按调用顺序返回预设输出,模拟 Actor 从 buggy → fixed 的演化。
真实使用时替换为 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", # 偶数长度
],
)
# 三次 complete 调用:trial0 代码 → 反思文本 → trial1 代码
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, trials = reflexion(task, Actor(llm), Evaluator(), SelfReflection(llm), EpisodicMemory())
print(f"passed={ok} trials_used={trials}")
print(code)
运行 python3 reflexion.py,预期输出:
passed=True trials_used=1
def median(nums):
s = sorted(nums)
n = len(s)
if n % 2:
return s[n//2]
return (s[n//2 - 1] + s[n//2]) / 2
trials_used=1 的含义要看准:第 0 次尝试(index 0)失败,反思后第 1 次尝试(index 1)通过,所以返回的索引是 1。trial 0 的代码 return s[len(s)//2] 在偶数长度上取了单个中间元素,median([1,2,3,4]) 返回 3 而非 2.5,被第二条 assert 拦下;反思定位了这个具体缺陷;trial 1 补上了偶数分支。
逐行映射回 01 章概念
骨架里每个动作都对应 01 章一个词汇。读代码时按这张对照表落锚:
actor.act() 这是 Actor——生成 text+action 的 LLM 策略。它的产出 code:str 就是这次尝试的 trajectory(短期记忆);编程任务里整条轨迹塌缩成一段代码。注意 act 的第二个参数 reflections:Actor 内部本质是 CoT/ReAct(详见 agent-reasoning-patterns),Reflexion 没有换模型,只是在它的 prompt 前面多塞了一段记忆。
evaluator.evaluate() 这是 Evaluator——给 trajectory 打分。返回的 passed:bool 就是 reward(这里是 pass/fail 的二值标量),error:str 是给反思用的诊断材料。编程任务的 reward 来自真跑测试,不是 LLM 自评。
reflector.reflect() 这是 Self-Reflection——把标量 reward + trajectory + error 转成自然语言教训(reflective text)。这一步做的是 credit assignment:标量"失败"无法说清哪一步错,这段文本能。它的输出以第一人称写出该改什么,这是论文的设计选择。
memory.add() 这是写入 episodic memory(长期记忆)。EpisodicMemory 用 capacity 实现 Ω 滑动窗口(论文 Ω=1-3):只保留最近几条反思,适配上下文长度上限——它是个截断的 list,不是向量库。
主循环 for t 这是论文 Algorithm 1:生成 → 评估 → (通过则停)→ 反思 → 写记忆 → 下一轮。下一个 trial 开始时,actor.act 把 memory.reflections 读进 prompt——这就是 verbal reinforcement 的字面含义:"学习"通过把上一轮教训拼进上下文发生,模型权重始终冻结。
如果把 ScriptedLLM 的第二个元素(那条反思)删掉,只留 trial 0 和 trial 1 两段代码,complete 的调用顺序会怎样错位?trials_used 还会是 1 吗?
展开答案
会错位。主循环每个 trial 调一次 act、失败后调一次 reflect,两者都从同一个 ScriptedLLM 取下一个输出。trial 0:act 取出原本的 buggy 代码,reflect 取出本该是 trial 1 的 fixed 代码当成"反思文本"。trial 1:act 取到空字符串(列表已空),exec("") 不定义 median,assert 抛 NameError,失败;reflect 也取到空串。三个 trial 用尽,返回 passed=False, trials_used=3。这恰好暴露了 mock 的本质:ScriptedLLM 是按"调用次数"而非"角色"喂数据的,Actor 和 Self-Reflection 共享同一个输出队列——换成真实 LLM 时这个耦合就消失了,因为真实模型对每个 prompt 实时生成。
3.3Partial:补全两个关键决策点
骨架里有两处不是"随便填填"的样板代码,而是 Reflexion 成败所系的设计决策。下面把这两处挖空成 # TODO,框架其余部分给出。先自己写,再对参考答案。
留白 A:Actor 怎么把长期记忆注入 prompt
这一处决定"长期记忆怎么用"。reflections 是 EpisodicMemory 里累积的反思文本列表;Actor 必须把它们拼进 prompt,而且要让模型清楚这是"过去的教训、避免重犯",否则模型可能把它当噪声忽略,或反过来误读成任务要求。
class Actor:
def __init__(self, llm: LLM):
self.llm = llm
def act(self, task: Task, reflections: list[str]) -> str:
# TODO-A1:把 reflections 列表拼成一段可读的 memory 文本
memory_block = ...
# TODO-A2:仅当有反思时,才把 memory 段落带标题地插进 prompt
prompt = (
f"实现函数 {task.entry_point}。\n任务:{task.spec}\n"
+ ...
+ "\n只输出函数定义代码。"
)
return self.llm.complete(prompt)
展开参考答案 + 为什么这么填
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只输出函数定义代码。"
)
TODO-A1:把每条反思加 - 前缀、换行拼接,渲染成项目符号清单。这不只是好看——逐条分隔让模型把多条教训当成并列的独立约束,而不是糊成一段连续文本里相互稀释。
TODO-A2:用 if reflections 守卫,空记忆时整段不出现。第一次尝试(trial 0)记忆为空,prompt 里不该凭空冒出"过去的教训"这种误导性标题。标题文案"过去尝试的教训(避免重犯)"是 prompt 工程的关键:它给反思文本一个明确角色——这是历史反馈、不是任务的一部分。决策路径是:记忆是否非空 → 拼成带角色标签的清单 → 放在任务描述之后、输出指令之前。位置也有讲究:放在任务之后让模型先理解要做什么,放在输出指令之前让教训成为"最后看到的约束"。
这一处直接对应 02 章的 memory injection:长期记忆不是被检索召回的知识库,而是逐轮拼进上下文的一段文本——它能起作用,全靠 Actor 这几行把它放对位置、给对标签。
留白 B:Self-Reflection 的 prompt 怎么写
这一处决定反思的质量。reward 只是个标量"失败",信息量为零;真正驱动改进的是这段 prompt 引导出的 reflective text。它必须做两件事:第一人称写出教训(论文的设计选择),并强制模型做 credit assignment——不是泛泛说"我错了",而是定位"哪一步错、下次具体怎么改"。
class SelfReflection:
def __init__(self, llm: LLM):
self.llm = llm
def reflect(self, code: str, error: str, task: Task) -> str:
# TODO-B:写一个 prompt,喂入 任务 / 代码 / 错误,
# 引导模型用第一人称、定位哪一步错、给出下次的具体改法
prompt = ...
return self.llm.complete(prompt)
展开参考答案 + 为什么这么填
prompt = (
f"任务:{task.spec}\n我写的代码:\n{code}\n"
f"测试失败:{error}\n"
"用第一人称写一条简短教训:我哪一步错了、下次具体怎么改。"
)
prompt 把三样东西摆齐:任务规格(对标准)、自己写的代码(被诊断对象)、具体错误(reward 之外的诊断信号)。最后一句指令是核心——它同时锁死了两个要求:
第一人称("我哪一步错了"):这是论文明确的设计选择,反思文本以第一人称写出该改什么。第一人称让教训读起来像 agent 对自己的指令,下一轮拼进 prompt 时更像"自我提醒"而非"旁观评价"。注意这是被生成的程序内容,出现在代码字符串里合规——讲解它时仍用第三人称。
定位 + 具体改法("哪一步错、下次具体怎么改"):这逼模型做 credit assignment。对比两条反思——"我的代码有 bug"(无定位,下一轮 Actor 不知道改哪)对上"我只取了中间单个元素,没处理偶数长度;下次对偶数长度取中间两数平均"(定位到具体步骤 + 可执行的修法)。后者才是 02 章说的高维带定位的纠错信号,标量 reward 永远表达不了。决策路径:给齐三份材料 → 用指令同时约束人称和粒度 → 产出可被下一轮直接照做的教训。
顺带提一个边界:这里把 error 字符串(如 AssertionError 或 NameError: ...)喂给反思,而不是只喂"失败"二字。02 章讲过 reward 是标量,但 Evaluator 顺手带回的 error 是免费的诊断材料——把它交给反思能显著提升 credit assignment 的准确度。这不违背"reward 是标量":停止判定靠 passed 这个标量,定位靠 error 这段文本。
A 和 B 一进一出,卡住了同一条命脉:反思文本是 Reflexion 唯一跨 trial 流动的信息。B 决定这段文本含不含可执行的定位信息,A 决定它有没有被放回模型能用到的位置。任一处写砸,循环就退化成"盲目重试"——而 02 章消融已经量化过:盲目 refinement(52%)反而低于不 refine(60%),起作用的从来是反思这一步的 credit assignment。
3.4Open:独立扩展一个带边界条件的任务
前两节给了完整轮子。这一节合上参考、自己动手。任务有两条独立路线,任选其一(或都做):
- 路线一(换任务):把
median换成一个带边界条件的新函数——边界条件是关键,因为它制造了 trial 0 容易漏、反思容易定位的缺陷。例如chunk(lst, size):把列表按size切块,最后一块允许不足size,空列表返回[]。给至少 3 条 assert(含一条空输入 + 一条size>len的边界);用ScriptedLLM录一段 trial 0 漏掉边界 → 反思 → trial 1 修复的演化。 - 路线二(换 Evaluator):把
Evaluator从"跑单元测试"换成"LLM judge"——新类实现同样的evaluate(code, task) -> (passed, error)签名,但passed由一个 LLM 读代码后回PASS/FAIL决定(mock 里用ScriptedLLM录判决)。
实现里必须显式用到 01 章 ≥3 个概念(在代码注释里点名,如 # Actor / # Evaluator / # episodic memory / trajectory),并在收尾段落讨论 02 章某一个权衡。路线二天然牵出 reward 来源那个权衡:LLM judge 比单元测试更通用,但可靠性更低——这正是论文 MBPP 翻车的根因(自生成判据 16.3% 假阳性),细节留到 04 章。
路线二里,如果 LLM judge 误判一个错代码为 PASS(假阳性),循环会怎样?这和 judge 误判一个对代码为 FAIL(假阴性)相比,哪种更危险?
展开答案
假阳性致命、假阴性可容忍——这是 Reflexion 的非对称失败模式。假阴性时,对的代码被判 FAIL,循环继续反思重试,顶多浪费几轮 trial,最终仍可能收敛。假阳性时,错的代码被判 PASS,主循环立刻 return code, True, t 提前提交——错误答案不可恢复,后面再没有机会纠正。论文里 MBPP 是唯一一次失利(77.1% < GPT-4 的 80.1%),根因正是自生成测试 16.3% 的假阳性率(HumanEval 仅 1.4%)。是 reward 源错了,不是反思错了。所以换 Evaluator 时,宁可让判据偏严(多几个假阴性),也别让它偏松。
展开参考实现(路线一:chunk 边界任务)+ 决策说明
# 复用 reflexion.py 的 Task / Actor / Evaluator / SelfReflection /
# EpisodicMemory / reflexion / ScriptedLLM —— 一行不改,只换任务数据。
# Task = 一个带边界条件的新规格(空列表 + size>len 两个边界)
task = Task(
spec="把列表按 size 切成多个子列表;最后一块允许不足 size;空列表返回 []",
entry_point="chunk",
tests=[
"assert chunk([1,2,3,4,5], 2) == [[1,2],[3,4],[5]]", # 不足 size 的尾块
"assert chunk([], 3) == []", # 空输入边界
"assert chunk([1,2], 5) == [[1,2]]", # size > len 边界
],
)
# Actor(由 ScriptedLLM 驱动):trial0 漏掉尾块 → 反思 → trial1 修复
# 第二个元素是 reflective text(第一人称,论文设计)= 跨 trial 的 episodic memory
llm = ScriptedLLM([
# trial0:用 len(lst)-size+1 当上界,丢掉不足 size 的尾块,空列表也没覆盖
"def chunk(lst, size):\n return [lst[i:i+size] for i in range(0, len(lst) - size + 1, size)]",
# reflection:credit assignment —— 定位到"上界算错",给出可执行改法
"我用 len(lst)-size+1 当上界,丢掉了不足 size 的最后一块,空列表也没覆盖;"
"下次直接 range(0, len(lst), size)。",
# trial1:按反思修复
"def chunk(lst, size):\n return [lst[i:i+size] for i in range(0, len(lst), size)]",
])
# 主循环 = Algorithm 1:Actor 产 trajectory(code)→ Evaluator 打分 →
# 失败则 Self-Reflection 反思 → 写入 EpisodicMemory → 下一轮
code, ok, trials = reflexion(task, Actor(llm), Evaluator(),
SelfReflection(llm), EpisodicMemory())
print(f"passed={ok} trials_used={trials}")
print(code)
passed=True trials_used=1
def chunk(lst, size):
return [lst[i:i+size] for i in range(0, len(lst), size)]
用到的 01 章概念(注释已点名):Actor(产出 trajectory)、Evaluator(跑 assert 打分)、episodic memory + trajectory(反思写入长期记忆、code 即短期记忆)。
用到的 02 章权衡:边界条件是 Reflexion 的甜区。trial 0 的初始解在主路径上看着对,只在边界(空输入、size>len)崩——这种"初始正确率不高、且失败可被测试精确定位"的任务,正是 02 章说反思最划算的场景。边界 assert 让 Evaluator 给出的 error 精确指向问题,Self-Reflection 才能完成有效的 credit assignment。反过来,若任务本来就几乎全对(初始正确率高),反思反而可能把对的改错——这条阈值留到 04 章。
展开参考实现(路线二:LLM-judge Evaluator)+ 决策说明
# 复用骨架,只替换 Evaluator —— 同样的 evaluate(code, task) -> (passed, error) 签名,
# 但 passed 由一个 LLM 判定,而非真跑测试。这把"客观 reward"换成"模型 reward"。
class LLMJudgeEvaluator:
"""Evaluator 变体:reward 来自 LLM judge,不再跑单元测试。
签名与原 Evaluator 完全一致,可直接塞进同一个 reflexion() 主循环。"""
def __init__(self, llm: LLM):
self.llm = llm
def evaluate(self, code: str, task: Task) -> tuple[bool, str]:
verdict = self.llm.complete(
f"判定下面代码是否满足任务,只回 PASS 或 FAIL:\n{code}\n任务:{task.spec}"
).strip()
passed = verdict == "PASS"
return passed, "" if passed else "judge 判定:代码未满足规格"
task = Task(
spec="把列表按 size 切成多个子列表;最后一块允许不足 size;空列表返回 []",
entry_point="chunk",
tests=[], # LLM judge 路线不依赖 assert,tests 留空
)
# 两个独立的 ScriptedLLM:一个驱动 Actor,一个驱动 judge —— 职责分离
actor_llm = ScriptedLLM([
"def chunk(lst, size):\n return [lst[i:i+size] for i in range(0, len(lst) - size + 1, size)]",
"def chunk(lst, size):\n return [lst[i:i+size] for i in range(0, len(lst), size)]",
])
judge_llm = ScriptedLLM(["FAIL", "PASS"]) # judge:trial0 不过、trial1 通过
reflect_llm = ScriptedLLM([
"我漏了不足 size 的尾块和空列表;下次用 range(0, len(lst), size)。"
])
code, ok, trials = reflexion(
task, Actor(actor_llm), LLMJudgeEvaluator(judge_llm),
SelfReflection(reflect_llm), EpisodicMemory(),
)
print(f"passed={ok} trials_used={trials}")
print(code)
passed=True trials_used=1
def chunk(lst, size):
return [lst[i:i+size] for i in range(0, len(lst), size)]
关键设计:LLMJudgeEvaluator 与原 Evaluator 签名逐字相同——这正是骨架把组件解耦的回报:换 reward 来源不动主循环一行。Actor、judge、Self-Reflection 用三个独立的 ScriptedLLM,避免上面 worked example 里"共享输出队列"的耦合,也更贴近真实部署(三个角色可以是三次独立 API 调用)。
用到的 02 章权衡 —— reward 可靠性:LLM judge 比单元测试通用得多(任何自然语言规格都能判,不必先写出 assert),代价是可靠性骤降。单元测试要么过要么不过,客观;LLM judge 会假阳性——把错代码判成 PASS,触发主循环提前 return 提交错误答案。论文 reward 来源那张表里,编程任务坚持用自生成测试而非 judge,正是这个原因;而 MBPP 唯一失利就栽在判据假阳性上。结论很直接:Reflexion 的天花板由 Evaluator 的可靠性决定,不由反思能力决定——没有可信的对错信号,反思越积极越危险。这条依赖关系是 04 章第一个失败模式。
本章自测
trials_used=1到底意味着第几次尝试通过?为什么返回的不是 2?- worked example 里 Actor 和 Self-Reflection 传入的是同一个
ScriptedLLM实例。这个共享在真实 LLM 下会不会造成问题?为什么? - 留白 A 里那个
if reflections守卫去掉会怎样?第一次尝试的 prompt 会变成什么样,可能误导模型吗? - 留白 B 的 prompt 若改成只说"反思一下哪里错了"、不要求"下次具体怎么改",反思文本的质量会怎样退化?它还能让下一轮 Actor 改对吗?
- 路线二把 Evaluator 换成 LLM judge 后,哪一种误判(假阳性 / 假阴性)会让循环不可恢复?对应论文哪一次失利?
展开参考答案
1. 第 1 次尝试(index 1)通过;返回的是通过那一轮的循环索引 t。trial 0(index 0)失败、反思,trial 1(index 1)通过即 return code, True, t,此时 t==1。它数的是"通过发生在第几个 trial",从 0 起。
2. 真实 LLM 下没问题。ScriptedLLM 是按调用次数顺序吐预设输出,所以 Actor 和 Self-Reflection 共享一个队列会"抢"彼此的输出——这是 mock 的人工产物。真实 LLM 对每个 prompt 实时独立生成,不存在共享队列,Actor 调一次生成代码、Self-Reflection 调一次生成反思,互不干扰。
3. 去掉守卫后,trial 0(记忆为空)的 prompt 里会凭空出现"过去尝试的教训(避免重犯):"这个标题,后面跟一段空的 memory_block。模型可能被这个空标题困惑,甚至幻觉出并不存在的"过去尝试"。守卫保证记忆为空时整段不出现——空记忆就该是"没有历史",而非"历史为空字符串"。
4. 会退化成无定位的泛泛之谈,如"我的代码有问题"。下一轮 Actor 读到这种反思,不知道该改哪一步,大概率重蹈覆辙或乱改。"下次具体怎么改"这句逼模型完成 credit assignment——把"失败"翻译成可执行的修法。缺了它,反思就退回到接近盲目重试的状态,而消融实验已证明盲目重试比不重试还差。
5. 假阳性(错代码被判 PASS)不可恢复:主循环立刻提交错误答案、不再有反思机会。假阴性(对代码被判 FAIL)可容忍:循环继续反思重试。对应论文唯一一次失利——MBPP-Python 77.1% < GPT-4 的 80.1%,根因是自生成测试 16.3% 的假阳性率。
给主循环加一道"是否值得反思"的护栏
当前主循环失败后无条件反思。但 04 章会讲一个反直觉事实:对初始就高置信、本来对的答案做反思,反而可能把对的改错(correct→incorrect 翻转)。试着在 reflexion() 里加一个护栏:只有当失败"够明确"(比如 error 非空、且不是因为代码根本没跑起来的 SyntaxError)时才反思,否则换一种策略(如直接重采样、或提前放弃)。想清楚:这个护栏该放在主循环哪一步?它和 max_trials 早停是什么关系?——这正是 05 章 capstone 要展开的扩展方向之一,先在脑子里设计好接口。
Further reading
- Shinn et al. 2023 — Reflexion 论文(Algorithm 1 即本章主循环):arxiv.org/abs/2303.11366
- 官方实现(把 mock 换成真实 LLM 后的参考):github.com/noahshinn/reflexion
- Prompt Engineering Guide · Reflexion(prompt 写法参考):promptingguide.ai/techniques/reflexion
- 本教程兄弟篇 · ReAct/CoT(Actor 内部推理):agent-reasoning-patterns