目录
[一、Self-Correction 到底解决什么问题?](#一、Self-Correction 到底解决什么问题?)
[二、先别急着让 LLM 判断:验证要分层](#二、先别急着让 LLM 判断:验证要分层)
[1. 代码任务的静态验证到底做了什么?](#1. 代码任务的静态验证到底做了什么?)
[2. 为什么还需要 Review?](#2. 为什么还需要 Review?)
[(一)真正省 Token 的地方:条件边,而不是 Prompt](#(一)真正省 Token 的地方:条件边,而不是 Prompt)
[(二)Diagnose 不是"再问一次",而是把错误变成可执行修复方案](#(二)Diagnose 不是“再问一次”,而是把错误变成可执行修复方案)
[(四)State 为什么看起来比普通 Agent 更"重"?](#(四)State 为什么看起来比普通 Agent 更“重”?)
[(五)LangGraph 图结构:五个节点足够,但边必须设计对](#(五)LangGraph 图结构:五个节点足够,但边必须设计对)
[演示 1:代码自修复------缺一个 docstring,也能触发完整修复链](#演示 1:代码自修复——缺一个 docstring,也能触发完整修复链)
[演示 2:JSON 修正------为什么两层验证缺一不可](#演示 2:JSON 修正——为什么两层验证缺一不可)
[演示 4:快速失败为什么不仅省钱,还减少误导](#演示 4:快速失败为什么不仅省钱,还减少误导)
[坑 1:没有 max_retries,错误会把 Agent 变成死循环](#坑 1:没有 max_retries,错误会把 Agent 变成死循环)
[坑 2:每次重试策略完全相同](#坑 2:每次重试策略完全相同)
[坑 3:验证器太弱,或者太强](#坑 3:验证器太弱,或者太强)
[坑 4:每轮从零生成,把已经正确的部分也改坏](#坑 4:每轮从零生成,把已经正确的部分也改坏)
[坑 5:所有判断都交给 LLM](#坑 5:所有判断都交给 LLM)
[(一)从 Demo 走向生产,应该继续补哪些能力?](#(一)从 Demo 走向生产,应该继续补哪些能力?)
[1. 把验证器接到成熟工程工具](#1. 把验证器接到成熟工程工具)
[2. 执行模型生成代码时,隔离级别必须更高](#2. 执行模型生成代码时,隔离级别必须更高)
[3. 不要只看"错误数量",还要看"错误是否真的变少"](#3. 不要只看“错误数量”,还要看“错误是否真的变少”)
[4. Human Handoff 必须带上下文](#4. Human Handoff 必须带上下文)
[5. 能前置的约束仍然应该前置](#5. 能前置的约束仍然应该前置)
[(二)一份可直接套用的 Self-Correction 设计检查表](#(二)一份可直接套用的 Self-Correction 设计检查表)
[六、总结:Self-Correction 的核心,是把"改 Bug"从行为变成系统](#六、总结:Self-Correction 的核心,是把“改 Bug”从行为变成系统)
干货分享,感谢您的阅读!
这是「LangGraph Agent Engineering Mastery」系列 Stage 4 推理 Agent · 第 4 篇。
这篇文章不讨论"让模型多想一会儿",而是讨论一个更工程化的问题:当 AI 的输出有明确验收标准时,怎样让它像开发者一样,执行、验证、定位问题、定向修复,并在该停的时候停下来。
对于经常写代码的你,这条链路一定非常熟悉:写完 → 运行 → 报错 → 看错误 → 修改 → 再运行 → 通过。
真正值得关注的不是"AI 能不能改 Bug",而是:这套 debug 循环能不能被做成一个可控、可追踪、不会无限烧 Token 的 Agent 图。

查看本次教学的Demo,你会看到它如何把验证拆成"确定性检查 + LLM 语义审查"两层,如何通过条件边跳过不必要的模型调用,以及为什么 max_retries、重复错误早停和历史最优回滚是 Self-Correction 能进入工程场景的关键。
一、Self-Correction 到底解决什么问题?
Reflection 和 Self-Correction 经常被放在一起讲,但它们解决的不是同一类问题。
-
Reflection 更像"编辑审稿":这段回答够不够清楚?结构好不好?解释是否充分?它面对的是带主观判断的"好不好"。
-
Self-Correction 更像"CI + Debug":代码能不能执行?JSON 能不能解析?字段是否齐全?测试是否通过?它面对的是可以被验收规则明确判定的"对不对"。
这一区分很重要。因为一旦问题可以被程序验证,就不应该把所有判断都交给 LLM。
| 维度 | 一次性生成 | Self-Correction |
|---|---|---|
| 错误检测 | 依赖人工发现 | 静态检查 + 必要时 LLM 语义审查 |
| 修复动作 | 人工重写 | 根因诊断后做定向最小修改 |
| 重试策略 | 通常没有 | 依据错误类型选择并升级策略 |
| 成本控制 | 每次失败都重新问模型 | 可短路、可跳过、可提前停止 |
| 终止机制 | 容易失控 | max_retries + 重复错误早停 |
| 适用任务 | 任意生成 | 有客观验收标准的代码 / JSON / 结构化数据 |
Self-Correction 不承诺"第一次就对",它承诺的是"失败以后知道该怎么修,并且知道什么时候不该继续修"。
二、先别急着让 LLM 判断:验证要分层
最容易犯的设计错误,是把验证写成:
"请判断上一步输出是否正确,如果不正确请指出问题。"
这当然能工作,但它把昂贵的模型调用当成了万能 if。对于 JSON 少一个引号、Python 语法错误、缺少必填字段、测试用例失败这类问题,这种做法既慢又贵,而且模型还可能误判。

我们的 Demo 做了一个非常关键的拆分:
-
StaticCheck:零 Token 的确定性验证 。能通过程序直接判定的,先用
ast、json.loads、真实测试用例等工具处理。 -
Review:LLM 语义审查。只有静态层已经通过,才让模型判断"结构虽然合法,但内容是否真正满足任务"。
这使整个流程更接近成熟工程里的"便宜检查前置"原则:先跑成本低、结论确定的检查,再调用成本高、判断更柔性的能力。
(一)整体代码展示说明
本次教学整体代码如下:
python
"""Demo 04: Self-Correction --- 真实 LLM 生成、确定性验证、根因诊断、定向修复。
演示 Self-Correction 推理模式(全链路真实 LLM 调用):
1. Execute 节点 :真实 LLM 生成输出;重试时携带「上次输出 + 错误清单 + 修复策略」
做定向最小修改,而不是从零重写(更快收敛、更省 Token)
2. StaticCheck :零 Token 的确定性验证 ------ JSON 用 json.loads 解析并校验字段;
代码用 ast 解析 + 子进程真实跑测试用例,再做记忆化/输入校验/文档串检查
3. Review 节点 :真实 LLM 语义审查,只在静态验证通过后才执行
4. Diagnose 节点 :真实 LLM 根因分析,输出结构化修复方案(root_cause / strategy /
patch_hint / fixable),判定不可自修复时立刻转人工,不再空耗重试
5. Output 节点 :输出最终结果;若始终未通过,回退到历史「错误最少」的最优版本
图链路的四处效率优化(对比朴素的 execute → validate(LLM) → fix 三角循环):
- 快速失败短路:静态验证已发现硬错误时,直接跳过 LLM 语义审查(省一次调用)
- 重试预算前置:重试次数耗尽时不再进入诊断节点(省一次调用)
- 重复错误早停:同一错误签名连续出现说明修复无效,升级策略并提前转人工
- 最优版本回滚:记录每轮错误数,避免「越修越差」时输出最后一版
验收标准(Spec)由验证器持有,是否写进提示词由 disclose_requirements 控制:
约束前置到提示词能减少重试(工程最佳实践),而演示里刻意不披露,用来还原
真实工程中「需求没写全、验收标准比提示词严格」的常态 ------ 这正是 Self-Correction
的价值所在:Agent 只能从验证反馈里学,把结果一步步拉回正确。
本 Demo 通过 shared.get_llm() 调用 .env 配置的真实在线模型
(fallback_to_mock=False,需联网)。LLM 输出不可用时回退到启发式保底逻辑
(也用于 mock/离线环境,保证流程可运行)。
运行方式:
python stages/stage4_reasoning/04_self_correction/main.py
"""
from __future__ import annotations
import ast
import json
import operator
import re
import subprocess
import sys
import time
from functools import partial
from pathlib import Path
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
from shared import get_llm, get_logger, log_step, log_success, log_warning
logger = get_logger("demo.04_04_self_correction")
MAX_RETRIES = 3
# 同一错误签名连续出现多少次后判定「修复无效」,提前终止并转人工
REPEAT_ERROR_LIMIT = 2
# 子进程跑候选代码的超时(同时兜住死循环 / 指数级递归)
CODE_TEST_TIMEOUT = 15
_TIMEOUT_FAILURE = f"执行超时(>{CODE_TEST_TIMEOUT}s),可能存在死循环或指数级递归"
# 任务约束(Spec):Self-Correction 的验证必须有客观依据,否则无法判定「正确」
DEFAULT_CODE_SPEC: dict[str, Any] = {
"entry": "fibonacci",
"signature": "fibonacci(n: int) -> int",
"cases": [
("fibonacci(0)", 0),
("fibonacci(1)", 1),
("fibonacci(10)", 55),
("fibonacci(30)", 832040),
],
"requirements": [
"使用记忆化/缓存,避免朴素递归的指数级重复计算",
"对非法输入(负数、非整数)抛出 ValueError",
"为入口函数编写文档字符串",
],
}
DEFAULT_JSON_SPEC: dict[str, Any] = {
"required_fields": ["name", "age", "role"],
"field_types": {"name": "str", "age": "int", "role": "str"},
}
# ============================================================
# 真实 LLM(通过 shared.get_llm 获取 .env 配置的在线模型)
# ============================================================
_LLM = None
def _get_correction_llm():
"""获取真实在线 LLM 实例(模块内复用,fallback_to_mock=False 确保真实调用)。"""
global _LLM
if _LLM is None:
_LLM = get_llm(fallback_to_mock=False)
return _LLM
def _invoke_llm(messages: list[BaseMessage]) -> str:
"""调用 LLM 并返回纯文本内容。"""
response = _get_correction_llm().invoke(messages)
return str(response.content).strip()
def _parse_llm_json(text: str) -> dict | None:
"""从 LLM 输出中解析 JSON(容忍 Markdown 代码块包裹等格式噪音)。"""
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.S).strip()
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
match = re.search(r"\{.*\}", text, re.S)
if match:
try:
parsed = json.loads(match.group())
return parsed if isinstance(parsed, dict) else None
except json.JSONDecodeError:
return None
return None
def _strip_code_fence(text: str) -> str:
"""去掉 Markdown 代码块围栏,保留正文。"""
match = re.search(r"```(?:python|json)?\s*(.*?)```", text, re.S)
return match.group(1).strip() if match else text.strip()
def _trace_event(node: str, detail: str) -> dict:
"""构造一条执行轨迹事件(全程可追踪的基础)。"""
return {"node": node, "detail": detail, "ts": time.strftime("%H:%M:%S")}
def _error_signature(errors: list[dict]) -> str:
"""错误签名:用于识别「同样的错误又犯了一遍」。"""
return "|".join(sorted(f"{e['type']}" for e in errors))
# ============================================================
# Self-Correction State
# ============================================================
class SelfCorrectionState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
task: str
task_kind: str # code / json / text,决定验证方式与生成提示词
spec: dict # 任务约束(测试用例、必填字段),验证的客观依据
current_output: str
current_errors: list[dict] # [{type, msg}]
is_valid: bool
retry_count: int
max_retries: int
error_history: list[dict] # [{attempt, errors, fix_strategy, root_cause, source}]
fix_strategy: str
patch_hint: str # LLM 给出的具体修改点,回传给 Execute 做定向修复
repeat_error_count: int # 同一错误签名连续出现次数
needs_human: bool # 不可自修复 / 修复无效 → 转人工
best_output: str # 历史最优版本(错误最少)
best_error_count: int
llm_calls: Annotated[int, operator.add] # 真实 LLM 调用次数
saved_llm_calls: Annotated[int, operator.add] # 链路优化省下的 LLM 调用次数
execution_trace: Annotated[list[dict], operator.add]
def detect_task_kind(task: str) -> str:
"""识别任务类型,决定采用哪种确定性验证方式。"""
lowered = task.lower()
if "json" in lowered:
return "json"
if "代码" in task or "函数" in task or "code" in lowered:
return "code"
return "text"
def default_spec(task_kind: str) -> dict:
"""返回任务类型对应的默认约束。"""
if task_kind == "code":
return dict(DEFAULT_CODE_SPEC)
if task_kind == "json":
return dict(DEFAULT_JSON_SPEC)
return {}
# ============================================================
# 保底执行(LLM 输出不可用时使用,也用于 mock/离线环境)
# ============================================================
def fallback_execution(task: str, task_kind: str, retry_count: int) -> str:
"""保底候选输出:随重试次数逐步逼近正确答案,保证离线环境下流程可收敛。"""
if task_kind == "code":
if retry_count == 0:
return (
"def fibonacci(n):\n"
" if n <= 1:\n"
" return n\n"
" return fibonacci(n - 1) + fibonacci(n - 2)"
)
if retry_count == 1:
return (
"from functools import lru_cache\n\n"
"@lru_cache(maxsize=None)\n"
"def fibonacci(n):\n"
" if n < 0:\n"
" raise ValueError('n must be non-negative')\n"
" if n <= 1:\n"
" return n\n"
" return fibonacci(n - 1) + fibonacci(n - 2)"
)
return (
"from functools import lru_cache\n\n"
"@lru_cache(maxsize=None)\n"
"def fibonacci(n: int) -> int:\n"
' """计算第 n 个斐波那契数(带记忆化缓存)。"""\n'
" if not isinstance(n, int) or n < 0:\n"
" raise ValueError('n must be a non-negative integer')\n"
" if n <= 1:\n"
" return n\n"
" return fibonacci(n - 1) + fibonacci(n - 2)"
)
if task_kind == "json":
if retry_count == 0:
return '{name: "Alice", age: 30}'
if retry_count == 1:
return '{"name": "Alice", "age": 30}'
return '{"name": "Alice", "age": 30, "role": "engineer"}'
if retry_count == 0:
return f"初步结果(可能有误): {task} 的简单回答"
if retry_count == 1:
return f"改进结果: 针对 '{task}' 的较详细回答,增加了背景信息"
return f"最终结果: 针对 '{task}' 的完整回答,包含分析、示例和结论"
def _is_usable_candidate(text: str, task_kind: str) -> bool:
"""判断 LLM 输出是否像一份可验证的候选结果。
区分「LLM 给了有 bug 的结果」(应进入验证-修复循环)和「LLM 完全没按格式作答」
(mock / 离线环境,应走保底逻辑,否则循环里全是无意义的格式错误)。
"""
if not text:
return False
if task_kind == "code":
return "def " in text or "lambda" in text
if task_kind == "json":
return "{" in text and "}" in text
return len(text) >= 10
# ============================================================
# Execute:真实 LLM 生成 / 定向修复
# ============================================================
_EXECUTE_SYSTEM_PROMPTS = {
"code": """你是一位资深 Python 工程师,负责编写可直接运行的 Python 代码。
输出要求:
1. 只输出 Python 代码(可用 ```python 包裹),不要输出任何解释文字
2. 代码必须能被直接执行,只使用标准库
3. 必须按要求定义入口函数,函数名与签名严格一致""",
"json": """你是一位数据接口工程师,负责生成严格合法的 JSON 数据。
输出要求:
1. 只输出一个 JSON 对象(可用 ```json 包裹),不要输出任何解释文字
2. 所有 key 必须使用双引号,字段类型严格符合要求
3. 必填字段一个都不能少""",
"text": """你是一位严谨的分析写作者。
输出要求:
1. 直接输出正文,内容具体、有条理、有结论
2. 不要输出「好的」「以下是」之类的寒暄""",
}
def _spec_requirements_text(task_kind: str, spec: dict) -> str:
"""把任务约束渲染成提示词片段。
spec["disclose_requirements"] 控制披露程度:
- True (默认,工程最佳实践):约束前置到提示词,首轮就尽量做对,省掉重试
- False:只给最基本的接口约定,完整验收标准由验证器持有。这模拟真实工程里
「需求没写全、验收标准比提示词严格」的常态,也正是 Self-Correction 的用武之地
"""
disclose = spec.get("disclose_requirements", True)
if task_kind == "code":
signature = f"入口函数签名: {spec.get('signature', '')}"
if not disclose:
return signature
cases = "\n".join(f" - {expr} == {expected!r}" for expr, expected in spec.get("cases", []))
requirements = "\n".join(f" - {r}" for r in spec.get("requirements", []))
return f"{signature}\n必须通过的测试用例:\n{cases}\n质量要求:\n{requirements}"
if task_kind == "json":
if not disclose:
return "要求: 输出一个描述用户的 JSON 对象。"
fields = spec.get("field_types") or {f: "any" for f in spec.get("required_fields", [])}
field_lines = "\n".join(f" - {name}: {ftype}" for name, ftype in fields.items())
return f"必填字段与类型:\n{field_lines}"
return "要求: 内容完整、有分析、有示例、有结论。"
def generate_output(
task: str,
task_kind: str,
spec: dict,
retry_count: int,
previous_output: str,
errors: list[dict],
fix_strategy: str,
patch_hint: str,
) -> tuple[str, str]:
"""调用真实 LLM 生成候选输出,返回 (输出, 来源标记 llm/fallback)。
首次执行从零生成;重试时改为「定向修复」------把上次输出、错误清单和修复方案
一起给 LLM,要求做最小必要修改。相比每轮从零重写,收敛更快也更省 Token。
"""
system_prompt = _EXECUTE_SYSTEM_PROMPTS.get(task_kind, _EXECUTE_SYSTEM_PROMPTS["text"])
requirements = _spec_requirements_text(task_kind, spec)
if retry_count == 0 or not previous_output:
user_prompt = f"任务: {task}\n{requirements}\n\n请给出结果。"
else:
error_text = "\n".join(f" - [{e['type']}] {e['msg']}" for e in errors) or " - (无明细)"
user_prompt = (
f"任务: {task}\n{requirements}\n\n"
f"上一版输出:\n{previous_output}\n\n"
f"验证发现的问题:\n{error_text}\n"
f"修复策略: {fix_strategy}\n"
f"具体修改点: {patch_hint or '(无)'}\n\n"
f"请在上一版基础上做最小必要修改,修掉全部问题后输出完整结果。"
)
raw = _invoke_llm([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
])
candidate = _strip_code_fence(raw) if task_kind in ("code", "json") else raw
if not _is_usable_candidate(candidate, task_kind):
log_warning(logger, "LLM 输出不是可验证的候选结果,使用保底执行逻辑")
return fallback_execution(task, task_kind, retry_count), "fallback"
return candidate, "llm"
# ============================================================
# StaticCheck:零 Token 的确定性验证
# ============================================================
def _run_code_cases(code: str, cases: list[tuple[str, Any]]) -> list[str]:
"""在子进程中真实执行候选代码与测试用例,返回失败描述列表。
子进程隔离 + 超时,既能兜住死循环/指数级递归,也避免候选代码污染当前进程。
生产环境应进一步使用容器/沙箱隔离,这里以子进程演示「真的把代码跑一遍」。
"""
harness = (
"\n\nimport json as _json\n"
f"_CASES = {cases!r}\n"
"_failures = []\n"
"for _expr, _expected in _CASES:\n"
" try:\n"
" _actual = eval(_expr)\n"
" except Exception as _e:\n"
" _failures.append('%s 抛出 %s: %s' % (_expr, type(_e).__name__, _e))\n"
" continue\n"
" if _actual != _expected:\n"
" _failures.append('%s 期望 %r,实际 %r' % (_expr, _expected, _actual))\n"
"print('__TEST_RESULT__' + _json.dumps(_failures, ensure_ascii=False))\n"
)
try:
completed = subprocess.run(
[sys.executable, "-c", code + harness],
capture_output=True,
text=True,
timeout=CODE_TEST_TIMEOUT,
)
except subprocess.TimeoutExpired:
return [_TIMEOUT_FAILURE]
for line in completed.stdout.splitlines():
if line.startswith("__TEST_RESULT__"):
return json.loads(line[len("__TEST_RESULT__"):])
stderr_tail = completed.stderr.strip().splitlines()
return [f"代码执行失败: {stderr_tail[-1] if stderr_tail else '无输出'}"]
def _find_entry_function(tree: ast.Module, entry: str) -> ast.FunctionDef | None:
"""在 AST 中查找入口函数定义。"""
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == entry:
return node
return None
def _has_cache_decorator(func: ast.FunctionDef) -> bool:
"""检查函数是否带记忆化装饰器(lru_cache / cache)。"""
for decorator in func.decorator_list:
target = decorator.func if isinstance(decorator, ast.Call) else decorator
name = getattr(target, "attr", None) or getattr(target, "id", None)
if name in ("lru_cache", "cache"):
return True
return False
def _is_self_recursive(func: ast.FunctionDef) -> bool:
"""检查函数是否直接递归调用自身。"""
return any(
isinstance(node, ast.Call)
and getattr(node.func, "id", None) == func.name
for node in ast.walk(func)
)
def _raises_input_error(func: ast.FunctionDef) -> bool:
"""检查函数是否对非法输入抛出 ValueError / TypeError。"""
for node in ast.walk(func):
if isinstance(node, ast.Raise) and node.exc is not None:
target = node.exc.func if isinstance(node.exc, ast.Call) else node.exc
if getattr(target, "id", None) in ("ValueError", "TypeError"):
return True
return False
def validate_code(code: str, spec: dict) -> list[dict]:
"""确定性验证代码:语法解析 → 真实跑测试 → 记忆化/输入校验/文档串检查。"""
try:
tree = ast.parse(code)
except SyntaxError as e:
return [{"type": "syntax", "msg": f"Python 语法错误 第 {e.lineno} 行: {e.msg}"}]
entry = spec.get("entry", "")
func = _find_entry_function(tree, entry)
if func is None:
return [{"type": "logic", "msg": f"未定义入口函数 {entry}"}]
errors: list[dict] = []
for failure in _run_code_cases(code, spec.get("cases", [])):
# 超时归为性能问题(触发 optimize_performance),其余归为逻辑错误
error_type = "performance" if failure == _TIMEOUT_FAILURE else "logic"
errors.append({"type": error_type, "msg": f"测试未通过: {failure}"})
if _is_self_recursive(func) and not _has_cache_decorator(func):
errors.append({"type": "performance", "msg": "递归实现缺少记忆化缓存,存在指数级重复计算"})
if not _raises_input_error(func):
errors.append({"type": "robustness", "msg": "缺少非法输入校验(未抛出 ValueError/TypeError)"})
if not ast.get_docstring(func):
errors.append({"type": "documentation", "msg": f"函数 {entry} 缺少文档字符串"})
return errors
def validate_json(text: str, spec: dict) -> list[dict]:
"""确定性验证 JSON:解析 → 结构 → 必填字段 → 字段类型。"""
try:
data = json.loads(text)
except json.JSONDecodeError as e:
return [{"type": "syntax", "msg": f"JSON 解析失败 第 {e.lineno} 行第 {e.colno} 列: {e.msg}"}]
if not isinstance(data, dict):
return [{"type": "schema", "msg": f"顶层结构应为对象,实际为 {type(data).__name__}"}]
errors: list[dict] = []
for field in spec.get("required_fields", []):
if field not in data:
errors.append({"type": "completeness", "msg": f"缺少必填字段 {field}"})
type_map = {"str": str, "int": int, "float": float, "bool": bool, "list": list, "dict": dict}
for field, expected in (spec.get("field_types") or {}).items():
expected_type = type_map.get(expected)
if expected_type and field in data and not isinstance(data[field], expected_type):
errors.append({
"type": "schema",
"msg": f"字段 {field} 类型应为 {expected},实际为 {type(data[field]).__name__}",
})
return errors
def static_validate(output: str, task_kind: str, spec: dict) -> list[dict]:
"""静态验证入口:零 LLM 调用,确定性给出硬错误。"""
if task_kind == "code":
return validate_code(output, spec)
if task_kind == "json":
return validate_json(output, spec)
if len(output.strip()) < 10:
return [{"type": "completeness", "msg": "输出内容过短,基本为空"}]
return []
# ============================================================
# Review:真实 LLM 语义审查(仅在静态验证通过后执行)
# ============================================================
_REVIEW_SYSTEM_PROMPT = """你是一位严格的结果审查员,判断输出是否真正满足任务要求。
只输出一个 JSON 对象(不要输出任何其他文字):
{"is_valid": true/false, "errors": [{"type": "<类型>", "msg": "<具体问题>"}]}
审查要求:
1. type 只能取: logic / completeness / quality / robustness / documentation
2. 只审查语义层面的正确性与完整性,语法和格式已由静态验证保证
3. 确实没有问题时 is_valid 为 true 且 errors 为空数组,不要为了挑错而挑错"""
_VALID_ERROR_TYPES = {
"syntax", "logic", "performance", "robustness", "schema", "completeness", "quality", "documentation",
}
def _sanitize_errors(raw: Any) -> list[dict]:
"""规整 LLM 返回的错误列表,过滤非法类型与空描述。"""
errors: list[dict] = []
if not isinstance(raw, list):
return errors
for item in raw:
if not isinstance(item, dict):
continue
error_type = str(item.get("type", "")).strip().lower()
msg = str(item.get("msg", "")).strip()
if not msg:
continue
errors.append({
"type": error_type if error_type in _VALID_ERROR_TYPES else "quality",
"msg": msg,
})
return errors
def _fallback_review(task_kind: str, retry_count: int) -> list[dict]:
"""LLM 审查结果不可用时的保底判定(离线环境下保证流程可收敛)。"""
if task_kind == "text" and retry_count == 0:
return [{"type": "quality", "msg": "回答过于简单,缺少分析深度与结论"}]
return []
def semantic_review(task: str, task_kind: str, output: str, retry_count: int) -> tuple[list[dict], str]:
"""调用真实 LLM 做语义审查,返回 (错误列表, 来源标记 llm/fallback)。"""
raw = _invoke_llm([
SystemMessage(content=_REVIEW_SYSTEM_PROMPT),
HumanMessage(content=(
f"任务: {task}\n"
f"这是第 {retry_count + 1} 次尝试的输出,静态验证(语法/测试/结构)已通过。\n"
f"待审查输出:\n{output}\n\n"
f"请输出审查 JSON。"
)),
])
decision = _parse_llm_json(raw)
if decision is None or "is_valid" not in decision:
log_warning(logger, "LLM 审查输出无法解析为 JSON,使用保底判定")
return _fallback_review(task_kind, retry_count), "fallback"
errors = _sanitize_errors(decision.get("errors"))
if decision.get("is_valid") is True:
return [], "llm"
if not errors:
errors = [{"type": "quality", "msg": "审查判定不通过但未给出明细"}]
return errors, "llm"
# ============================================================
# Diagnose:真实 LLM 根因分析 + 修复策略
# ============================================================
# 错误类型 → 修复策略的优先级映射(越靠前越先修,先修硬错误再修质量问题)
_STRATEGY_BY_ERROR_TYPE: list[tuple[str, str]] = [
("syntax", "fix_syntax"),
("logic", "fix_logic"),
("performance", "optimize_performance"),
("robustness", "add_validation"),
("schema", "fix_schema"),
("completeness", "add_missing_fields"),
("documentation", "add_documentation"),
]
def determine_fix_strategy(errors: list[dict], retry_count: int) -> str:
"""根据错误类型确定修复策略(保底方案,也是 LLM 诊断失败时的兜底)。"""
if not errors:
return "none"
error_types = {e["type"] for e in errors}
for error_type, strategy in _STRATEGY_BY_ERROR_TYPE:
if error_type in error_types:
return strategy
return f"general_improvement_attempt_{retry_count + 1}"
_DIAGNOSE_SYSTEM_PROMPT = """你是一位资深调试专家,负责分析失败根因并给出最小修复方案。
只输出一个 JSON 对象(不要输出任何其他文字):
{"root_cause": "<根本原因,而非复述错误>",
"strategy": "<简短英文蛇形命名,如 fix_syntax / optimize_performance>",
"patch_hint": "<具体到可直接落实的修改点>",
"fixable": true/false}
诊断要求:
1. root_cause 要指出为什么会错,不要重复错误描述
2. patch_hint 要具体(改哪里、改成什么),避免「优化一下」这类空话
3. 只有当任务约束本身无法满足(需求矛盾、缺少必要信息)时,fixable 才为 false"""
def diagnose_failure(
task: str,
output: str,
errors: list[dict],
error_history: list[dict],
retry_count: int,
) -> tuple[dict, str]:
"""调用真实 LLM 做根因分析,返回 (诊断结果, 来源标记 llm/fallback)。"""
error_text = "\n".join(f"- [{e['type']}] {e['msg']}" for e in errors)
history_text = "\n".join(
f"- 第 {h['attempt']} 次: 策略 {h['fix_strategy']},问题 {_error_signature(h.get('errors', []))}"
for h in error_history
) or "(无历史)"
raw = _invoke_llm([
SystemMessage(content=_DIAGNOSE_SYSTEM_PROMPT),
HumanMessage(content=(
f"任务: {task}\n"
f"这是第 {retry_count + 1} 次尝试后的诊断。\n"
f"当前输出:\n{output}\n\n"
f"验证发现的问题:\n{error_text}\n\n"
f"历史修复记录:\n{history_text}\n\n"
f"请输出诊断 JSON。"
)),
])
decision = _parse_llm_json(raw)
fallback = {
"root_cause": "; ".join(e["msg"] for e in errors),
"strategy": determine_fix_strategy(errors, retry_count),
"patch_hint": "逐条修掉验证发现的问题",
"fixable": True,
}
if decision is None:
log_warning(logger, "LLM 诊断输出无法解析为 JSON,使用启发式保底诊断")
return fallback, "fallback"
strategy = str(decision.get("strategy", "")).strip() or fallback["strategy"]
return {
"root_cause": str(decision.get("root_cause", "")).strip() or fallback["root_cause"],
"strategy": strategy,
"patch_hint": str(decision.get("patch_hint", "")).strip() or fallback["patch_hint"],
"fixable": decision.get("fixable", True) is not False,
}, "llm"
# ============================================================
# 节点实现
# ============================================================
def execute_node(state: SelfCorrectionState) -> dict:
"""Execute 节点:真实 LLM 生成候选输出(重试时做定向修复)。"""
task = state["task"]
retry_count = state["retry_count"]
task_kind = state.get("task_kind") or detect_task_kind(task)
spec = state.get("spec") or default_spec(task_kind)
if retry_count == 0:
log_step(logger, "Execute", f"首次执行任务(真实 LLM,{task_kind}): '{task[:40]}'")
print(f"\n [执行] 首次执行任务: {task}")
else:
log_step(logger, "Execute", f"第 {retry_count + 1} 次执行,定向修复(策略: {state.get('fix_strategy')})")
print(f"\n [重新执行] 第 {retry_count + 1} 次尝试 (修复策略: {state.get('fix_strategy')})")
output, source = generate_output(
task=task,
task_kind=task_kind,
spec=spec,
retry_count=retry_count,
previous_output=state.get("current_output", ""),
errors=state.get("current_errors", []),
fix_strategy=state.get("fix_strategy", ""),
patch_hint=state.get("patch_hint", ""),
)
print(f" [输出] ({source}) {output[:100]}{'...' if len(output) > 100 else ''}")
return {
"task_kind": task_kind,
"spec": spec,
"current_output": output,
"llm_calls": 1,
"execution_trace": [
_trace_event("execute", f"第 {retry_count + 1} 次生成({source},{len(output)} 字)")
],
}
def static_check_node(state: SelfCorrectionState, enable_fast_path: bool = True) -> dict:
"""StaticCheck 节点:零 Token 的确定性验证(语法、测试、结构)。"""
output = state["current_output"]
task_kind = state["task_kind"]
spec = state.get("spec", {})
retry_count = state["retry_count"]
log_step(logger, "StaticCheck", f"确定性验证(第 {retry_count + 1} 次,{task_kind})")
print("\n [静态验证] 语法 / 测试 / 结构检查(零 LLM 调用)...")
errors = static_validate(output, task_kind, spec)
if errors:
for err in errors:
print(f" [错误] ✗ [{err['type']}] {err['msg']}")
else:
print(" [静态验证] ✓ 通过")
update: dict = {
"current_errors": errors,
"is_valid": False, # 最终结论由语义审查给出
"execution_trace": [
_trace_event("static_check", f"发现 {len(errors)} 个硬错误" if errors else "静态验证通过")
],
}
# 快速失败短路:已有硬错误时语义审查必然不通过,跳过它省下一次 LLM 调用
if errors and enable_fast_path:
log_step(logger, "FastPath", f"静态已发现 {len(errors)} 个硬错误,跳过 LLM 语义审查")
print(" [短路] 静态验证已发现硬错误,跳过 LLM 语义审查(省 1 次调用)")
update["saved_llm_calls"] = 1
return update
def review_node(state: SelfCorrectionState) -> dict:
"""Review 节点:真实 LLM 语义审查,与静态错误合并给出最终结论。"""
task = state["task"]
task_kind = state["task_kind"]
output = state["current_output"]
retry_count = state["retry_count"]
static_errors = state.get("current_errors", [])
log_step(logger, "Review", f"语义审查(真实 LLM,第 {retry_count + 1} 次)")
print("\n [语义审查] LLM 检查输出是否真正满足任务要求...")
semantic_errors, source = semantic_review(task, task_kind, output, retry_count)
errors = static_errors + semantic_errors
if semantic_errors:
for err in semantic_errors:
print(f" [错误] ✗ [{err['type']}] {err['msg']} (审查来源: {source})")
else:
print(f" [语义审查] ✓ 未发现语义问题 (审查来源: {source})")
is_valid = not errors
best_output = state.get("best_output", "")
best_error_count = state.get("best_error_count", sys.maxsize)
update: dict = {
"current_errors": errors,
"is_valid": is_valid,
"llm_calls": 1,
"execution_trace": [
_trace_event("review", f"语义审查{'通过' if is_valid else f'发现 {len(errors)} 个问题'}({source})")
],
}
# 最优版本回滚:记住错误最少的一版,避免「越修越差」时输出最后一版
if not best_output or len(errors) < best_error_count:
update["best_output"] = output
update["best_error_count"] = len(errors)
return update
def diagnose_node(state: SelfCorrectionState) -> dict:
"""Diagnose 节点:真实 LLM 根因分析,产出修复策略并递增重试计数。"""
task = state["task"]
output = state["current_output"]
errors = state.get("current_errors", [])
retry_count = state["retry_count"]
error_history = list(state.get("error_history", []))
log_step(logger, "Diagnose", f"根因分析(真实 LLM,第 {retry_count + 1} 次失败)")
print("\n [诊断] LLM 分析根本原因并给出修复方案...")
diagnosis, source = diagnose_failure(task, output, errors, error_history, retry_count)
strategy = diagnosis["strategy"]
# 重复错误检测:同样的错误又犯一遍,说明上一轮修复无效 → 升级策略
signature = _error_signature(errors)
previous_signature = _error_signature(error_history[-1].get("errors", [])) if error_history else ""
repeat_count = state.get("repeat_error_count", 0)
if signature and signature == previous_signature:
repeat_count += 1
strategy = f"{strategy}_escalated_{repeat_count}"
log_warning(logger, f"检测到重复错误(第 {repeat_count} 次),升级修复策略: {strategy}")
print(f" [诊断] ⚠ 同类错误重复出现,策略升级为 {strategy}")
else:
repeat_count = 0
needs_human = not diagnosis["fixable"] or repeat_count >= REPEAT_ERROR_LIMIT
print(f" [根因] {diagnosis['root_cause'][:100]} (来源: {source})")
print(f" [策略] {strategy}")
print(f" [修改点] {diagnosis['patch_hint'][:100]}")
if needs_human:
print(" [早停] 判定无法自修复,提前终止重试并转人工")
error_history.append({
"attempt": retry_count + 1,
"errors": errors,
"fix_strategy": strategy,
"root_cause": diagnosis["root_cause"],
"source": source,
})
return {
"error_history": error_history,
"fix_strategy": strategy,
"patch_hint": diagnosis["patch_hint"],
"repeat_error_count": repeat_count,
"needs_human": needs_human,
"retry_count": retry_count + (0 if needs_human else 1),
"llm_calls": 1,
"execution_trace": [_trace_event("diagnose", f"根因诊断 → {strategy}({source})")],
}
def output_node(state: SelfCorrectionState) -> dict:
"""Output 节点:输出最终结果;未通过时回退到历史最优版本。"""
output = state["current_output"]
retry_count = state["retry_count"]
error_history = state.get("error_history", [])
is_valid = state["is_valid"]
needs_human = state.get("needs_human", False)
rolled_back = False
if not is_valid:
best_output = state.get("best_output", "")
best_error_count = state.get("best_error_count", sys.maxsize)
if best_output and best_output != output and best_error_count < len(state.get("current_errors", [])):
output = best_output
rolled_back = True
if is_valid:
log_success(logger, f"任务完成,共 {retry_count + 1} 次尝试")
status = f"✓ 验证通过(第 {retry_count + 1} 次尝试成功)"
elif needs_human:
log_warning(logger, "判定无法自修复,提前终止并转人工")
status = "⚠ 无法自修复,已提前终止并转人工处理"
else:
log_warning(logger, f"达到最大重试次数 {state['max_retries']},输出最优结果")
status = "⚠ 达到最大重试次数,输出历史最优结果"
print(f"\n [完成] {status}")
if rolled_back:
print(" [回滚] 最后一版错误更多,已回退到历史最优版本")
summary = f"{status}\n\n最终输出:\n{output}\n\n"
if error_history:
summary += "--- 修复历程 ---\n"
for record in error_history:
errors_str = "; ".join(e["msg"] for e in record.get("errors", []))
summary += f"第 {record['attempt']} 次: [{record['fix_strategy']}] {errors_str}\n"
update: dict = {
"current_output": output,
"messages": [AIMessage(content=summary)],
"execution_trace": [_trace_event("output", status)],
}
# 重试预算耗尽时不再进入诊断节点,省下一次 LLM 调用
if not is_valid and not needs_human and retry_count >= state["max_retries"]:
update["saved_llm_calls"] = 1
return update
# ============================================================
# 条件边(链路优化的关键:让昂贵节点尽量不被执行)
# ============================================================
def _has_retry_budget(state: SelfCorrectionState) -> bool:
"""是否还有重试预算。"""
return state["retry_count"] < state["max_retries"]
def make_static_check_router(enable_fast_path: bool):
"""构造静态验证后的路由。
enable_fast_path=True 时,静态验证已发现硬错误就直接进入诊断,
跳过必然不通过的 LLM 语义审查;关闭后退化为「每轮都调 LLM 审查」的朴素链路。
"""
def route(state: SelfCorrectionState) -> str:
errors = state.get("current_errors", [])
if not errors or not enable_fast_path:
return "review"
return "diagnose" if _has_retry_budget(state) else "output"
return route
def route_after_review(state: SelfCorrectionState) -> str:
"""语义审查后的路由:通过则输出;否则在有重试预算时才进入诊断。"""
if state["is_valid"]:
return "output"
if not _has_retry_budget(state):
log_warning(logger, f"达到最大重试次数 {state['max_retries']},跳过诊断直接输出")
return "output"
return "diagnose"
def route_after_diagnose(state: SelfCorrectionState) -> str:
"""诊断后的路由:可自修复则重试,否则提前终止转人工。"""
if state.get("needs_human"):
return "output"
return "execute"
# ============================================================
# 构建 Self-Correction Graph
# ============================================================
def build_self_correction_graph(
max_retries: int = MAX_RETRIES,
enable_fast_path: bool = True,
):
"""构建 Self-Correction Agent 图。
图结构(分层验证 + 快速失败短路):
START → execute → static_check ─(有硬错误)─→ diagnose ─(可修复)─→ execute
│ │
│(静态通过) └─(不可修复/无预算)─→ output → END
↓
review ─(通过)─→ output
└─(不通过且有预算)─→ diagnose
Args:
max_retries: 最大重试次数,防止无限修复循环
enable_fast_path: 是否启用「静态失败即短路」优化;关闭后退化为朴素链路,
用于对比两种链路的 LLM 调用开销
"""
graph = StateGraph(SelfCorrectionState)
graph.add_node("execute", execute_node)
graph.add_node("static_check", partial(static_check_node, enable_fast_path=enable_fast_path))
graph.add_node("review", review_node)
graph.add_node("diagnose", diagnose_node)
graph.add_node("output", output_node)
graph.add_edge(START, "execute")
graph.add_edge("execute", "static_check")
graph.add_conditional_edges(
"static_check",
make_static_check_router(enable_fast_path),
{"review": "review", "diagnose": "diagnose", "output": "output"},
)
graph.add_conditional_edges(
"review",
route_after_review,
{"diagnose": "diagnose", "output": "output"},
)
graph.add_conditional_edges(
"diagnose",
route_after_diagnose,
{"execute": "execute", "output": "output"},
)
graph.add_edge("output", END)
return graph
def _initial_state(task: str, max_retries: int, disclose_requirements: bool = True) -> dict:
"""构造初始状态。
disclose_requirements=False 时,完整验收标准只由验证器持有(提示词不披露),
用于演示「验证器比提示词更严格」时 Self-Correction 循环如何把结果拉回正确。
"""
task_kind = detect_task_kind(task)
spec = {**default_spec(task_kind), "disclose_requirements": disclose_requirements}
return {
"messages": [HumanMessage(content=task)],
"task": task,
"task_kind": task_kind,
"spec": spec,
"current_output": "",
"current_errors": [],
"is_valid": False,
"retry_count": 0,
"max_retries": max_retries,
"error_history": [],
"fix_strategy": "",
"patch_hint": "",
"repeat_error_count": 0,
"needs_human": False,
"best_output": "",
"best_error_count": sys.maxsize,
}
def _print_execution_trace(trace: list[dict]) -> None:
"""打印全程执行轨迹(可追踪性演示)。"""
print("\n --- 执行轨迹(全程可追踪) ---")
for i, event in enumerate(trace, 1):
print(f" {i:02d}. [{event['ts']}] {event['node']:<13} {event['detail']}")
def _print_cost_summary(result: dict) -> None:
"""打印链路效率统计。"""
llm_calls = result.get("llm_calls", 0)
saved = result.get("saved_llm_calls", 0)
print(f" LLM 调用: {llm_calls} 次;链路优化省下: {saved} 次")
# ============================================================
# 运行演示
# ============================================================
def demo_code_correction():
"""演示 1:代码自修复(真实 LLM 生成 + 子进程真实跑测试用例验证)。"""
print("\n--- 演示 1: 代码自修复(确定性验证:真的把代码跑一遍) ---\n")
graph = build_self_correction_graph(max_retries=3)
app = graph.compile()
task = "编写一个高质量的代码实现斐波那契数列计算"
print(f" 任务: {task}")
print(" 验收标准(测试用例 + 缓存/输入校验/文档串)只由验证器持有,提示词不披露")
result = app.invoke(_initial_state(task, max_retries=3, disclose_requirements=False))
print(f"\n {'─' * 50}")
print(f" 总尝试次数: {result['retry_count'] + 1}")
print(f" 最终验证: {'通过' if result['is_valid'] else '未通过'}")
_print_cost_summary(result)
_print_execution_trace(result["execution_trace"])
return result
def demo_json_correction():
"""演示 2:JSON 结构修正(解析 + 必填字段 + 类型校验)。"""
print("\n--- 演示 2: JSON 格式修正 ---\n")
graph = build_self_correction_graph(max_retries=3)
app = graph.compile()
task = "生成一个 JSON 格式的用户数据"
print(f" 任务: {task}")
print(" 必填字段与类型只由验证器持有,提示词不披露")
result = app.invoke(_initial_state(task, max_retries=3, disclose_requirements=False))
print(f"\n 总尝试次数: {result['retry_count'] + 1}")
print(f" 最终验证: {'通过' if result['is_valid'] else '未通过'}")
_print_cost_summary(result)
return result
def demo_retry_limit():
"""演示 3:重试次数限制(预算耗尽时跳过诊断,不再空耗 LLM)。"""
print("\n--- 演示 3: 重试次数限制 ---\n")
graph = build_self_correction_graph(max_retries=1)
app = graph.compile()
task = "编写完美的代码(max_retries=1,限制为 1 次重试)"
print(f" 任务: {task}")
print(" max_retries = 1")
result = app.invoke(_initial_state(task, max_retries=1, disclose_requirements=False))
print(f"\n 总尝试次数: {result['retry_count'] + 1}")
print(f" 重试限制触发: {'是' if not result['is_valid'] else '否'}")
_print_cost_summary(result)
return result
def demo_fast_path_efficiency():
"""演示 4:链路效率对比 ------ 快速失败短路 vs 朴素链路。"""
print("\n--- 演示 4: 图链路效率对比(快速失败短路) ---\n")
task = "编写一个高质量的代码实现斐波那契数列计算"
print(f" 任务: {task}(同一任务跑两种链路)")
print("\n [A] 朴素链路: execute → 每轮都调 LLM 审查 → diagnose")
naive = build_self_correction_graph(max_retries=3, enable_fast_path=False).compile()
naive_result = naive.invoke(_initial_state(task, max_retries=3, disclose_requirements=False))
print("\n [B] 优化链路: execute → 静态验证失败即短路 → diagnose")
fast = build_self_correction_graph(max_retries=3, enable_fast_path=True).compile()
fast_result = fast.invoke(_initial_state(task, max_retries=3, disclose_requirements=False))
naive_calls = naive_result.get("llm_calls", 0)
fast_calls = fast_result.get("llm_calls", 0)
short_circuits = fast_result.get("saved_llm_calls", 0)
print(f"\n {'─' * 50}")
print(f" 朴素链路 LLM 调用: {naive_calls} 次(每轮都做语义审查)")
print(f" 优化链路 LLM 调用: {fast_calls} 次(短路触发 {short_circuits} 次)")
if short_circuits:
print(f" 同等结论下省去 {short_circuits} 次必然不通过的语义审查")
else:
print(" 本次首轮即通过静态验证,短路未触发(输出质量高时优化自然不生效)")
print(f" 两者最终结论一致: {naive_result['is_valid'] == fast_result['is_valid']}")
return {"naive": naive_result, "fast": fast_result, "short_circuits": short_circuits}
def run_demo() -> dict:
"""运行 Self-Correction 全部演示。"""
print("=" * 60)
print(" Demo 04: Self-Correction --- 错误检测与自动修复(真实 LLM)")
print("=" * 60)
code_result = demo_code_correction()
json_result = demo_json_correction()
limit_result = demo_retry_limit()
efficiency_result = demo_fast_path_efficiency()
print()
print("=" * 60)
print(" 关键概念回顾")
print("=" * 60)
print(" 1. Execute : 真实 LLM 生成;重试时带错误清单做定向最小修改")
print(" 2. StaticCheck : 零 Token 确定性验证(AST 解析 + 子进程真跑测试)")
print(" 3. Review : 真实 LLM 语义审查,仅在静态验证通过后执行")
print(" 4. Diagnose : 真实 LLM 根因分析,输出策略与具体修改点")
print(" 5. 快速失败短路: 静态验证失败直接跳过语义审查,省一次 LLM 调用")
print(" 6. 重试预算前置: 预算耗尽不进诊断节点,省一次 LLM 调用")
print(" 7. 重复错误早停: 同一错误签名重复出现 → 升级策略 → 转人工")
print(" 8. 最优版本回滚: 记录错误最少的版本,避免越修越差")
print()
return {
"code_result": code_result,
"json_result": json_result,
"limit_result": limit_result,
"efficiency_result": efficiency_result,
}
if __name__ == "__main__":
run_demo()
(二)重要提示事项
1. 代码任务的静态验证到底做了什么?
源码里的 validate_code 并不是只做一次 ast.parse。它把代码任务拆成了几层验收:
-
先验证 Python 语法;
-
检查入口函数是否存在;
-
将候选代码放进子进程,真实执行测试用例;
-
超时被识别为
performance问题; -
对递归实现检查是否有缓存;
-
检查非法输入是否抛出
ValueError/TypeError; -
检查入口函数是否包含 docstring。
对应源码如下,保持原样:
python
def validate_code(code: str, spec: dict) -> list[dict]:
"""确定性验证代码:语法解析 → 真实跑测试 → 记忆化/输入校验/文档串检查。"""
try:
tree = ast.parse(code)
except SyntaxError as e:
return [{"type": "syntax", "msg": f"Python 语法错误 第 {e.lineno} 行: {e.msg}"}]
entry = spec.get("entry", "")
func = _find_entry_function(tree, entry)
if func is None:
return [{"type": "logic", "msg": f"未定义入口函数 {entry}"}]
errors: list[dict] = []
for failure in _run_code_cases(code, spec.get("cases", [])):
# 超时归为性能问题(触发 optimize_performance),其余归为逻辑错误
error_type = "performance" if failure == _TIMEOUT_FAILURE else "logic"
errors.append({"type": error_type, "msg": f"测试未通过: {failure}"})
if _is_self_recursive(func) and not _has_cache_decorator(func):
errors.append({"type": "performance", "msg": "递归实现缺少记忆化缓存,存在指数级重复计算"})
if not _raises_input_error(func):
errors.append({"type": "robustness", "msg": "缺少非法输入校验(未抛出 ValueError/TypeError)"})
if not ast.get_docstring(func):
errors.append({"type": "documentation", "msg": f"函数 {entry} 缺少文档字符串"})
return errors
这里最值得学习的不是某个 AST API,而是"验证器必须拥有客观依据"。Self-Correction 只有在"错在哪里"能被稳定检测时,修复循环才有收敛基础。
2. 为什么还需要 Review?
如果 StaticCheck 已经这么严格,为什么还保留 LLM Review?
因为程序能判断"结构合法",却未必能判断"内容有意义"。例如 JSON 字段都齐了、类型也正确,但所有值都是 ""、0、默认占位符。从 schema 角度它可能完全合法,从任务语义角度却没有完成要求。
这就是 Review 存在的边界:它不是替代静态验证,而是补上静态验证看不到的语义层。
三、核心代码详细说明
(一)真正省 Token 的地方:条件边,而不是 Prompt
Self-Correction 的成本优化,不主要来自"把 Prompt 写短一点",而来自让不该执行的节点根本不执行。
Demo中的路由逻辑很明确:StaticCheck 已经发现硬错误时,如果启用了 enable_fast_path,就直接进入 Diagnose;只有静态检查通过,才进入 Review。Review 不通过时,也只有在仍有重试预算的情况下才做 Diagnose。
python
def make_static_check_router(enable_fast_path: bool):
"""构造静态验证后的路由。
enable_fast_path=True 时,静态验证已发现硬错误就直接进入诊断,
跳过必然不通过的 LLM 语义审查;关闭后退化为「每轮都调 LLM 审查」的朴素链路。
"""
def route(state: SelfCorrectionState) -> str:
errors = state.get("current_errors", [])
if not errors or not enable_fast_path:
return "review"
return "diagnose" if _has_retry_budget(state) else "output"
return route
def route_after_review(state: SelfCorrectionState) -> str:
"""语义审查后的路由:通过则输出;否则在有重试预算时才进入诊断。"""
if state["is_valid"]:
return "output"
if not _has_retry_budget(state):
log_warning(logger, f"达到最大重试次数 {state['max_retries']},跳过诊断直接输出")
return "output"
return "diagnose"
这两条判断分别挡住两类浪费:
-
静态失败时跳过 Review:已经知道结果不合格,再问一次"语义是否合格"没有价值;
-
重试预算耗尽时跳过 Diagnose:即使诊断出了修复策略,也已经没有下一轮 Execute 可以使用它。
换句话说,条件边不只是控制流程,它同时承担了调用成本治理。
(二)Diagnose 不是"再问一次",而是把错误变成可执行修复方案
一个低质量的自修复 Agent,失败后只会把错误重新塞回 Prompt:
"上次错了,请修正。"
这样的反馈信息太弱,模型很容易再次生成相近答案。当前实现增加了真正的 Diagnose 节点,让 LLM 输出四个结构化信息:
-
root_cause:为什么错,而不是复述报错; -
strategy:本轮采用什么修复策略; -
patch_hint:具体改哪里、怎么改; -
fixable:是否还能自动修复。
然后 Execute 在下一轮拿到"上一版输出 + 当前错误 + 修复策略 + patch_hint",要求基于上一版做最小必要修改,而不是从零重新生成。
这件事非常重要:Self-Correction 的目标不是"多生成几次",而是让每次重试都尽可能拥有新的信息增量。
(三)错误分类决定修复方向:先修什么,也是一种工程策略

当前代码里有一份明确的错误类型 → 修复策略映射,并且顺序本身就是优先级:
python
# 错误类型 → 修复策略的优先级映射(越靠前越先修,先修硬错误再修质量问题)
_STRATEGY_BY_ERROR_TYPE: list[tuple[str, str]] = [
("syntax", "fix_syntax"),
("logic", "fix_logic"),
("performance", "optimize_performance"),
("robustness", "add_validation"),
("schema", "fix_schema"),
("completeness", "add_missing_fields"),
("documentation", "add_documentation"),
]
def determine_fix_strategy(errors: list[dict], retry_count: int) -> str:
"""根据错误类型确定修复策略(保底方案,也是 LLM 诊断失败时的兜底)。"""
if not errors:
return "none"
error_types = {e["type"] for e in errors}
for error_type, strategy in _STRATEGY_BY_ERROR_TYPE:
if error_type in error_types:
return strategy
return f"general_improvement_attempt_{retry_count + 1}"
这和后端系统按异常类型分类处理是同一种思路:不是所有失败都值得用同一种重试方法。
更进一步,代码还会记录错误签名。当同类错误连续出现时,说明上一轮策略没有产生实质进展,于是策略会被升级;连续无效达到阈值后,needs_human 被置为真,流程提前转人工,而不是继续空耗预算。
这使"重试"从一个简单计数器,变成了有反馈的重试策略。
(四)State 为什么看起来比普通 Agent 更"重"?
Self-Correction 要做到可追踪、可回滚、可早停,就不能只存一条 messages。当前 State 里额外保存了错误、策略、历史最佳版本和调用成本等信息:
python
class SelfCorrectionState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
task: str
task_kind: str # code / json / text,决定验证方式与生成提示词
spec: dict # 任务约束(测试用例、必填字段),验证的客观依据
current_output: str
current_errors: list[dict] # [{type, msg}]
is_valid: bool
retry_count: int
max_retries: int
error_history: list[dict] # [{attempt, errors, fix_strategy, root_cause, source}]
fix_strategy: str
patch_hint: str # LLM 给出的具体修改点,回传给 Execute 做定向修复
repeat_error_count: int # 同一错误签名连续出现次数
needs_human: bool # 不可自修复 / 修复无效 → 转人工
best_output: str # 历史最优版本(错误最少)
best_error_count: int
llm_calls: Annotated[int, operator.add] # 真实 LLM 调用次数
saved_llm_calls: Annotated[int, operator.add] # 链路优化省下的 LLM 调用次数
execution_trace: Annotated[list[dict], operator.add]
可以把这些字段分成四组来理解:
|--------|----------------------------------------------------------------------------------------|---------------------|
| 目的 | 关键字段 | 作用 |
| 当前判断 | current_output / current_errors / is_valid | 描述这一轮结果是否合格 |
| 修复上下文 | fix_strategy / patch_hint / retry_count | 为下一轮 Execute 提供增量信息 |
| 防止空转 | repeat_error_count / needs_human / max_retries | 判断是否继续自动修复 |
| 可回滚与观测 | best_output / best_error_count / error_history / execution_trace / llm_calls | 避免越修越差,同时保留完整链路证据 |
如果一个自修复系统没有这些状态,往往会出现两个问题:要么每轮像失忆一样从头生成,要么只会机械重试,无法判断自己到底有没有变好。
(五)LangGraph 图结构:五个节点足够,但边必须设计对

图本身并不复杂:Execute、StaticCheck、Review、Diagnose、Output 五个节点,核心差异都落在条件边上。
python
graph.add_edge(START, "execute")
graph.add_edge("execute", "static_check")
graph.add_conditional_edges(
"static_check",
make_static_check_router(enable_fast_path),
{"review": "review", "diagnose": "diagnose", "output": "output"},
)
graph.add_conditional_edges(
"review",
route_after_review,
{"diagnose": "diagnose", "output": "output"},
)
graph.add_conditional_edges(
"diagnose",
route_after_diagnose,
{"execute": "execute", "output": "output"},
)
graph.add_edge("output", END)
这张图可以从三条路径来读:
-
理想路径:Execute → StaticCheck → Review → Output。首轮静态与语义都通过,一次结束。
-
硬错误路径:Execute → StaticCheck → Diagnose → Execute。静态层发现问题,直接短路,不进入 Review。
-
语义错误路径:Execute → StaticCheck → Review → Diagnose → Execute。结构合法,但内容层面未完成任务,再进入诊断。
当 max_retries 用尽,或 Diagnose 判断不可修复、重复错误达到阈值时,流程都会走向 Output,而不是无限循环。
四、主要运行和演示说明
(一)运行方式
运行命令保持原文一致:
python
source .venv/bin/activate
python stages/stage4_reasoning/04_self_correction/main.py
Demo 使用真实在线模型调用,静态验证环节则在本地完成。演示里还特意通过 disclose_requirements=False 隐藏一部分验收标准:模型第一次并不知道验证器手里全部的测试与质量要求,这更接近真实工程中"需求描述不完整,但验收规则更严格"的情况。
(二)主要演示说明
演示 1:代码自修复------缺一个 docstring,也能触发完整修复链

下面这段输出非常适合观察 Self-Correction 的"信息是怎样逐轮增加的"。原始运行输出保持不变:
--- 演示 1: 代码自修复(确定性验证:真的把代码跑一遍) ---
任务: 编写一个高质量的代码实现斐波那契数列计算
验收标准(测试用例 + 缓存/输入校验/文档串)只由验证器持有,提示词不披露
[执行] 首次执行任务: 编写一个高质量的代码实现斐波那契数列计算
[输出] (llm) def fibonacci(n: int) -> int:
if n < 0:
raise ValueError("n must be non-negative")
i...
[静态验证] 语法 / 测试 / 结构检查(零 LLM 调用)...
[错误] ✗ [documentation] 函数 fibonacci 缺少文档字符串
[短路] 静态验证已发现硬错误,跳过 LLM 语义审查(省 1 次调用)
[诊断] LLM 分析根本原因并给出修复方案...
[根因] 函数未提供文档字符串,导致无法满足高质量代码对可读性和自文档化的要求 (来源: llm)
[策略] add_docstring
[修改点] 在函数定义后第一行添加文档字符串:"""Calculate the n-th Fibonacci number..."""
[重新执行] 第 2 次尝试 (修复策略: add_docstring)
[输出] (llm) def fibonacci(n: int) -> int:
"""Calculate the n-th Fibonacci number for non-negative n."""
...
[静态验证] 语法 / 测试 / 结构检查(零 LLM 调用)...
[静态验证] ✓ 通过
[语义审查] LLM 检查输出是否真正满足任务要求...
[语义审查] ✓ 未发现语义问题 (审查来源: llm)
[完成] ✓ 验证通过(第 2 次尝试成功)
总尝试次数: 2 | LLM 调用: 4 次;链路优化省下: 1 次
--- 执行轨迹(全程可追踪) ---
01. [15:04:09] execute 第 1 次生成(llm,209 字)
02. [15:04:09] static_check 发现 1 个硬错误
03. [15:04:10] diagnose 根因诊断 → add_docstring(llm)
04. [15:04:11] execute 第 2 次生成(llm,275 字)
05. [15:04:11] static_check 静态验证通过
06. [15:04:11] review 语义审查通过(llm)
07. [15:04:11] output ✓ 验证通过(第 2 次尝试成功)
这条链路里有四个关键动作:
-
第一次生成其实已经接近正确:函数能够计算,并且有非法输入校验,只漏了 docstring。
-
StaticCheck 直接定罪 :
ast.get_docstring()已经能给出确定结论,不需要 LLM 再判断一次。 -
Diagnose 给出 patch_hint:错误从"缺少文档字符串"被转换成"在函数定义后第一行补什么"。
-
第二次不是重写,而是定向修复:Execute 带着上一版与修改点继续,最终静态和语义都通过。
真正的工程价值在第 2、3、4 步:验证结果是确定的,修复目标是具体的,下一轮是增量修改。
演示 2:JSON 修正------为什么两层验证缺一不可
JSON 演示把 StaticCheck 和 Review 的职责边界展示得更明显:
--- 演示 2: JSON 格式修正 ---
[输出] (llm) { "username": ..., "email": ... }
[静态验证] ✗ [completeness] 缺少必填字段 name / age / role → 短路,省 1 次调用
[诊断] 策略: add_missing_fields
[重新执行] 第 2 次尝试
[静态验证] ✓ 通过
[语义审查] ✗ [completeness] 所有字段均为空值或默认占位符,未体现真实用户信息
[诊断] 策略: generate_realistic_data
[重新执行] 第 3 次尝试
[静态验证] ✓ 通过 → [语义审查] ✓ 通过
总尝试次数: 3 | LLM 调用: 7 次;链路优化省下: 1 次
第一轮是典型的结构问题 :缺少 name / age / role,程序完全可以判定,因此静态失败后直接短路。
第二轮则变成了语义问题 :字段齐全、结构正确,但内容全是空值或默认占位符。此时 json.loads 和字段类型检查都不会报错,只有语义 Review 能识别"这份 JSON 虽然合法,但没有真正生成用户数据"。
所以不要把"确定性验证"和"LLM 评审"看成二选一。正确的做法是:让每一层只处理自己最擅长、最可靠的那部分问题。
演示 4:快速失败为什么不仅省钱,还减少误导

同一个任务分别跑朴素链路和快速失败链路,原始输出如下:
--- 演示 4: 图链路效率对比(快速失败短路) ---
[A] 朴素链路: execute → 每轮都调 LLM 审查 → diagnose
[静态验证] ✗ [documentation] 函数 fibonacci 缺少文档字符串
[语义审查] ✓ 未发现语义问题 (审查来源: llm) ← 花了钱,还给了个误导性结论
[B] 优化链路: execute → 静态验证失败即短路 → diagnose
[短路] 静态验证已发现硬错误,跳过 LLM 语义审查(省 1 次调用)
朴素链路 LLM 调用: 5 次(每轮都做语义审查)
优化链路 LLM 调用: 4 次(短路触发 1 次)
两者最终结论一致: True
朴素链路的问题不仅是多花了一次 LLM 调用。更麻烦的是:Review 只看语义层,它可能给出"未发现语义问题"这样的结论,而静态层此时明明已经失败。如果日志或观测系统没有把这两类结论区分清楚,就容易让人误解为"结果已经没问题"。
快速失败的价值因此有两层:
-
成本层:少一次必然无效的 LLM 调用;
-
认知层:避免在确定性失败已经成立时,生成一个局部正确但整体误导的判断。
(三)五个最常见的坑,分别会把系统拖向哪里?
坑 1:没有 max_retries,错误会把 Agent 变成死循环
只要验证条件存在一个无法满足的约束,或模型始终修不动,Execute → Validate → Fix 就会不断回环。解决办法不是"相信模型最终会修好",而是把重试次数当成预算:预算耗尽必须退出;框架层再用 recursion limit 做最后兜底。
坑 2:每次重试策略完全相同
如果第 1 次和第 3 次收到的提示基本一样,模型大概率给出相近答案。当前实现用错误类型决定策略,并在相同错误连续出现时升级策略,就是为了阻止"原地打转"。
坑 3:验证器太弱,或者太强
太弱会把明显错误放过去;太强会让任务永远无法通过。Self-Correction 的验证标准应该优先覆盖可客观判定、与任务完成直接相关的条件。主观质量问题不要无限塞进硬验证器里,否则系统会把"追求更好"误当成"仍然错误"。
坑 4:每轮从零生成,把已经正确的部分也改坏
修复应该尽量增量化。把上一版、错误列表、策略和 patch_hint 一起带回 Execute,要求做最小必要修改;同时用 best_output / best_error_count 记录历史最优,最终失败时还有回滚路径。
坑 5:所有判断都交给 LLM
这是最常见、也最容易被忽略的成本问题。JSON 能不能解析、Python 能不能编译、测试是否通过,这些都属于机器可以直接判定的事实。把它们交给 LLM,相当于用一个概率模型去完成本来可以确定完成的工作。
五、线上注意事项与设计检查
(一)从 Demo 走向生产,应该继续补哪些能力?
当前实现已经把核心闭环跑通,但生产环境还需要继续加强边界与工具链。
1. 把验证器接到成熟工程工具
代码任务可以把 Demo 里的 AST 与简单测试扩展为 pytest、linter、类型检查等成熟工具;JSON 可以用 JSON Schema;其他结构化任务也应该优先寻找确定性校验器。原则不变:客观正确性交给工具,语义完整性交给模型。
2. 执行模型生成代码时,隔离级别必须更高
Demo 使用子进程和超时,已经能阻止部分死循环和指数级递归,但"执行模型生成的代码"本质上仍是在执行不可信输入。生产环境需要更严格的容器或沙箱、文件系统隔离、网络限制和资源限制。
3. 不要只看"错误数量",还要看"错误是否真的变少"
best_output 是一个很好的基础。进一步可以要求每轮新结果在错误数、严重级别或关键验收项上有明确改善,否则不接受新版本,回退到历史最优再调整策略。
4. Human Handoff 必须带上下文
转人工不是简单地返回一句"修复失败"。真正有用的人工接管应该至少携带:最终候选结果、错误历史、每轮策略、根因诊断、重试次数和执行轨迹。这样人接手时不需要重新排查一遍。
5. 能前置的约束仍然应该前置
Self-Correction 是兜底能力,不是故意把需求写模糊的理由。如果验收标准可以在生成前明确提供,就应该尽量提供,让首轮成功率更高。只有那些无法完全前置、或现实中不可避免存在缺口的要求,才需要依赖后续修复闭环。
(二)一份可直接套用的 Self-Correction 设计检查表
在你自己实现类似 Agent 时,可以用下面这组问题快速自检:
-
任务是否存在客观验收标准? 如果没有,可能更适合 Reflection,而不是 Self-Correction。
-
哪些检查可以零 Token 完成? 语法、schema、测试、编译、字段类型应尽量前置。
-
静态失败时能否短路? 不要让注定没有价值的 LLM 节点继续执行。
-
错误是否被分类? 不同错误要进入不同修复策略。
-
诊断是否给出 patch_hint? 不要只说"哪里错了",还要告诉下一轮"具体怎么改"。
-
重试是否基于上一版做最小修改? 避免每次重新生成导致正确内容丢失。
-
有没有重复错误检测? 同类错误连续出现应该升级策略或转人工。
-
有没有硬性重试上限?
max_retries是预算,不是建议。 -
有没有历史最优版本? 防止最后一轮反而比上一轮更差。
-
观测数据是否完整? 至少保留错误历史、执行轨迹、LLM 调用数和短路节省量。
六、总结:Self-Correction 的核心,是把"改 Bug"从行为变成系统
Self-Correction 看起来像"让 AI 自己检查自己",但真正决定它是否可靠的,并不是模型有没有自省能力,而是整个系统有没有明确的工程约束:
执行后有客观验证,验证后有错误分类,分类后有定向诊断,诊断后做最小修复;每轮都有预算、可以短路、可以早停、可以回滚,也可以转人工。
这套设计把一次性生成模型,变成了一个会根据验证反馈不断逼近正确结果的执行系统。
到这里,Stage 4 已经覆盖了 ReAct、Planning、Reflection、Self-Correction 四种常见推理形态。它们虽然做法不同,但大多还是沿一条路径推进。下一步如果希望 Agent 同时探索多个方案、比较后再继续,就会进入另一个典型方向:Tree of Thought。
下一篇:《Tree of Thought:让 AI 多想几个方案再决定》