代码生成这件事,很多人第一反应是"让大模型直接写"。但真把它放进工程流程里,你会发现一个尴尬的现实:模型一次生成的代码,能跑通的比例远没有想象中高。语法错误、边界条件漏判、依赖版本对不上、单元测试跑不过——这些问题在一次性生成里几乎必然出现。我最初做代码生成工具时也踩过这个坑,后来才意识到,真正有价值的不是"生成",而是"生成之后能不能自己发现问题并改对"。
这就是"自我修正"代码生成 Agent 要解决的核心问题。它不是一个更聪明的模型,而是一套让模型能够循环迭代的编排结构:生成代码、执行验证、读取错误、定位原因、重新生成,直到通过或者达到重试上限。LangGraph 恰好是干这件事的合适工具,因为它把 Agent 的执行过程建模成一张有状态的有向图,节点之间可以带条件跳转,天然适合"生成—验证—修正"这种带环路的流程。
这篇内容适合已经了解大模型基本调用、想进一步做 Agent 编排的开发者,也适合正在评估 LangGraph 是否值得投入的工程同学。我会从为什么需要自我修正讲起,把 LangGraph 的图结构、状态设计、验证节点、重试策略、终止条件这些关键环节拆开讲清楚,并给出可以直接复现的实现思路和我在实际调试中踩过的坑。
1. 为什么"一次性生成"在真实项目里站不住脚
1.1 代码生成的真实失败率比 Demo 高得多
在演示场景里,我们通常让模型写一个斐波那契函数、一个排序算法,这类题目训练数据里出现频率极高,模型几乎背下来了,所以看起来"很准"。但一旦换成业务代码——比如解析某种特定格式的日志、调用一个内部 SDK、处理带时区的时间计算——准确率会明显下滑。
我做过一个粗略统计,在中等复杂度的函数级生成任务上,不考虑任何修正机制,首次生成能直接通过单元测试的比例大概在三到五成之间。这个数字会随任务复杂度、上下文长度、语言生态变化,但量级上不会差太多。也就是说,如果你把生成结果直接塞进代码库,一半以上的概率是要出问题的。
失败的类型也很有规律,大致可以归成几类:
- 语法与类型错误:括号不匹配、类型标注冲突、泛型写错,这类错误编译器或解释器会直接报出来。
- 运行时错误:空指针、越界、除零、资源未释放,这类要跑起来才暴露。
- 逻辑错误:边界条件漏判、循环终止条件写反、状态更新顺序错误,这类最隐蔽,测试用例覆盖不到就发现不了。
- 依赖与环境错误:引用了不存在的包、版本 API 不兼容、导入路径错误。
前两类是"可自动发现"的,因为工具链会给你明确的错误信息。后两类里,逻辑错误需要靠测试用例兜底,依赖错误需要靠环境校验。自我修正 Agent 的价值,恰恰在于它能把这些"可发现的错误"变成下一轮生成的输入。
1.2 自我修正的本质是"把错误信息喂回去"
很多人把自我修正想得很玄,觉得是模型"反思"了。其实机制非常朴素:把上一轮生成的代码和它产生的错误信息,一起作为新的上下文,让模型基于这些具体反馈重新生成。模型不需要真的"理解"自己错在哪,它只需要看到"第 12 行 NameError: name 'x' is not defined"这样的信息,就有很大概率在下一轮把变量定义补上。
这跟人类程序员调试的过程其实是一样的。你写了一段代码,运行报错,你看着报错信息改,改完再跑。区别只是人类能凭经验快速定位,而模型需要你把错误信息完整、准确地提供给它。所以自我修正 Agent 的设计重点,不在于让模型变聪明,而在于把验证环节做扎实,把错误信息提取干净,把重试逻辑控制好。
这里有个反直觉的结论:验证节点的质量,比生成节点的模型能力更决定最终成功率。我试过用同一个模型,只把验证从"简单跑一下"升级成"跑测试 + 捕获完整堆栈 + 提取关键行号",最终通过率提升了将近一倍。原因很简单,模型拿到的反馈越精确,它修正的方向就越准。
1.3 为什么用图结构而不是简单的 while 循环
你可能会想,自我修正不就是个循环吗?写个 while 不就行了。在小规模场景下确实可以,但一旦流程复杂起来,纯循环会迅速失控。
考虑这些需求:生成后先做静态检查,静态检查不过就直接重试,不用跑测试;静态检查过了再跑单元测试;测试失败要区分是"代码错"还是"测试用例本身写错了";连续失败三次要换策略,比如让模型先解释错误再改;某些错误类型要触发人工介入而不是无限重试。这些分支和状态,用 while 循环加一堆 if-else 会写得非常难维护。
LangGraph 的思路是把每个处理步骤定义成一个节点,节点之间用边连接,边上可以挂条件判断函数。整个执行过程有一个共享的 State 对象在节点间传递。这样"生成—检查—测试—修正"就变成了一张清晰的图,每个节点的职责单一,分支逻辑集中在条件边上,重试和终止通过图的走向来控制。可读性和可扩展性都比裸循环好得多。
2. LangGraph 的图模型:把 Agent 拆成节点和边
2.1 State 是整个流程的"共享内存"
LangGraph 里最核心的概念是 State。它是一个在整张图里流转的数据结构,通常用 TypedDict 或者 Pydantic 模型定义。每个节点函数接收当前 State,返回对 State 的更新,框架负责把更新合并回去。
对代码生成 Agent 来说,State 里至少要放这些东西:
from typing import TypedDict, List, Optional class CodeGenState(TypedDict): task: str # 原始任务描述 code: Optional[str] # 当前生成的代码 error: Optional[str] # 最近一次的错误信息 error_type: Optional[str] # 错误分类:syntax/runtime/logic/dependency attempts: int # 已尝试次数 max_attempts: int # 最大尝试次数 history: List[dict] # 每轮的代码与错误记录 passed: bool # 是否已通过验证这里有几个设计细节值得说。history字段很关键,它记录了每一轮的代码和错误,一方面可以用于最终分析,另一方面在重试时可以把"之前试过什么、错在哪"作为上下文给模型,避免它在同一个坑里反复摔。error_type用于驱动条件分支,不同类型的错误走不同的修正策略。attempts和max_attempts是终止条件的依据,没有这个,图可能永远转下去。
提示:State 字段不要设计得太宽泛。我见过有人把整个对话历史、所有中间产物都塞进 State,结果每次节点更新都要序列化一大坨数据,调试时根本看不清哪个字段在变。按职责拆分,只放流程真正需要的。
2.2 节点函数的写法与职责边界
每个节点就是一个普通函数,签名是def node(state: CodeGenState) -> dict。返回值是"要更新的字段",不是完整 State。这个约定很重要,它让每个节点只关心自己改了什么。
生成节点的典型写法:
def generate_node(state: CodeGenState) -> dict: prompt = build_prompt( task=state["task"], previous_code=state.get("code"), previous_error=state.get("error"), history=state.get("history", []) ) new_code = call_llm(prompt) return { "code": new_code, "attempts": state["attempts"] + 1 }注意这里把previous_code和previous_error都传给了 prompt 构造函数。第一轮它们为空,模型只看到任务描述;后续轮次模型能看到上一版代码和具体错误,这就是"修正"的信息来源。
验证节点则负责跑检查并填充error和error_type:
def validate_node(state: CodeGenState) -> dict: code = state["code"] syntax_ok, syntax_err = check_syntax(code) if not syntax_ok: return {"error": syntax_err, "error_type": "syntax", "passed": False} test_ok, test_err = run_tests(code) if not test_ok: return {"error": test_err, "error_type": "runtime", "passed": False} return {"error": None, "error_type": None, "passed": True}节点职责单一的好处是,你想换验证方式(比如从跑测试换成静态分析),只改这一个函数,图结构不用动。
2.3 条件边决定"下一步去哪"
条件边是 LangGraph 区别于普通工作流引擎的地方。它接收当前 State,返回下一个节点的名字。自我修正 Agent 的核心分支逻辑就写在这里:
def route_after_validate(state: CodeGenState) -> str: if state["passed"]: return "done" if state["attempts"] >= state["max_attempts"]: return "give_up" if state["error_type"] == "syntax": return "generate" # 语法错误直接重生成 if state["error_type"] == "runtime": return "analyze" # 运行时错误先分析再改 return "generate"这段逻辑体现了前面说的"不同错误走不同策略"。语法错误通常比较机械,直接重生成就行;运行时错误往往需要模型先理解错误原因,所以插一个分析节点,让它输出一段推理再改代码,成功率会更高。这种差异化处理,是纯 while 循环很难优雅表达的。
3. 验证环节:自我修正 Agent 的成败关键
3.1 静态检查与动态测试要分层
验证不能只有一层。我的做法是分三层,逐层加严,任何一层不过就立即返回,不浪费后面的计算。
第一层是语法与导入检查。用目标语言的解析器做 AST 解析,Python 可以用ast.parse,JavaScript 可以用 acorn 之类。这一步能抓出绝大多数低级错误,而且几乎零成本。导入检查可以尝试解析 import 语句,看模块是否存在。
第二层是静态类型与风格检查。Python 用 mypy 或 pyright,JS 用 tsc,能抓出类型不匹配、未定义变量这类问题。这一步比语法检查慢,但比跑测试快。
第三层是单元测试。这是最接近真实行为的验证。测试用例从哪来?两个来源:一是任务本身附带的测试,二是让模型根据任务描述自己生成测试。后者要小心,模型可能生成"迎合自己代码"的测试,所以最好两者结合,以人工或任务给定的测试为准。
def check_syntax(code: str): try: ast.parse(code) return True, None except SyntaxError as e: return False, f"SyntaxError at line {e.lineno}: {e.msg}"注意错误信息里我特意保留了行号。行号对模型定位问题帮助极大,比一句笼统的"语法错误"有用得多。
3.2 错误信息要"提纯"再喂给模型
原始的错误堆栈往往很长,包含大量框架内部调用。如果原样喂给模型,它会淹没在噪音里。我一般做三步提纯:
- 截取关键帧:只保留与生成代码相关的堆栈帧,过滤掉标准库和第三方库的内部调用。
- 提取错误类型与消息:把
NameError: name 'x' is not defined这样的核心信息单独拎出来。 - 附上出错行附近的代码:给出错误行上下各几行,让模型有上下文。
def purify_error(raw_traceback: str, code: str) -> str: lines = raw_traceback.strip().split("\n") # 取最后一行作为错误摘要 summary = lines[-1] # 提取行号 line_no = extract_line_number(raw_traceback) context = get_code_context(code, line_no, radius=3) return f"错误摘要: {summary}\n出错位置附近代码:\n{context}"实测下来,提纯后的错误信息能让模型一次修正成功的概率明显提升。原因不复杂,信息密度高了,模型不用在无关内容上"分心"。
3.3 测试用例本身也可能是错的
这是很多人忽略的坑。当测试失败时,有两种可能:代码错了,或者测试错了。如果不加区分,模型可能会去"改代码迎合错误的测试",越改越离谱。
我的处理方式是在验证节点里加一个判断:如果连续两轮都是同一个测试失败,且模型声称"代码逻辑正确",就触发一个"测试审查"分支,让另一个模型实例(或者同一模型换个 prompt)来判断到底是代码问题还是测试问题。这个分支不常触发,但一旦触发,能避免大量无效重试。
注意:不要让生成代码的模型同时判断测试对错,它有动机偏袒自己的代码。用一个独立的、prompt 中立的调用来做这个判断,结论更可靠。
4. 重试策略与终止条件的设计
4.1 无脑重试是最差的选择
如果每次失败都原样重试,模型很可能生成几乎一样的代码,因为输入没变,输出分布也没变。我早期就犯过这个错,看着 Agent 转了三圈,生成的代码一字不差,白白烧 token。
有效的重试必须让每一轮的输入有差异。差异可以来自几个方面:
- 错误信息:这是最基本的,每轮错误不同,输入自然不同。
- 历史记录:把之前失败的尝试和原因列出来,明确告诉模型"这些方向试过了,别再重复"。
- 策略切换:第一轮直接改,第二轮要求模型先写出修改计划再改,第三轮要求它换个实现思路。
- 温度调整:适当提高采样温度,增加输出的多样性。
def build_prompt(task, previous_code, previous_error, history): parts = [f"任务: {task}"] if previous_code: parts.append(f"上一版代码:\n{previous_code}") if previous_error: parts.append(f"运行报错:\n{previous_error}") if history: tried = "\n".join( f"- 第{i+1}次尝试失败: {h['error'][:100]}" for i, h in enumerate(history) ) parts.append(f"已尝试过的方向(避免重复):\n{tried}") parts.append("请给出修正后的完整代码。") return "\n\n".join(parts)4.2 终止条件要设多重保险
图必须有明确的出口,否则会无限循环。我一般设三重保险:
- 成功终止:验证通过,走
done节点。 - 次数终止:达到
max_attempts,走give_up节点,把最后一版代码和所有错误记录返回给调用方。 - 无进展终止:如果连续两轮的错误信息高度相似(比如相似度超过阈值),说明模型卡住了,直接终止,不再浪费。
第三重保险特别有用。我遇到过模型在某个边界条件上反复失败,错误信息几乎一样,但每次都差一点点。这时候继续重试就是浪费,不如早点交回人工。
def is_stuck(history, threshold=0.9): if len(history) < 2: return False last = history[-1]["error"] prev = history[-2]["error"] return similarity(last, prev) > thresholdmax_attempts设多少合适?我的经验是 3 到 5 之间。低于 3,很多本来能修好的问题没机会修;高于 5,边际收益急剧下降,而且 token 成本线性上升。具体值要看任务难度和模型能力,可以先设 3,观察通过率分布再调。
4.3 失败也要"优雅地失败"
give_up节点不是简单地抛异常。它应该做几件事:把最后一版代码、完整的尝试历史、每轮的错误信息打包返回;标注清楚"这是未通过验证的代码,请人工检查";如果可能,给出一个"最接近成功"的版本。
这一点在工程上很重要。Agent 不可能 100% 成功,关键是失败时能不能给人类接手提供足够的信息。我见过一些实现,失败就返回一个空结果,调用方完全不知道发生了什么,排查起来非常痛苦。
5. 把图组装起来:从节点到可运行的 Agent
5.1 图的构建与编译
有了节点和条件边,组装图本身很直接:
from langgraph.graph import StateGraph, END def build_graph(): graph = StateGraph(CodeGenState) graph.add_node("generate", generate_node) graph.add_node("validate", validate_node) graph.add_node("analyze", analyze_node) graph.add_node("done", done_node) graph.add_node("give_up", give_up_node) graph.set_entry_point("generate") graph.add_edge("generate", "validate") graph.add_conditional_edges( "validate", route_after_validate, { "generate": "generate", "analyze": "analyze", "done": "done", "give_up": "give_up" } ) graph.add_edge("analyze", "generate") graph.add_edge("done", END) graph.add_edge("give_up", END) return graph.compile()编译后的图可以直接invoke,传入初始 State。整个执行过程框架会帮你调度,你只需要关心每个节点的逻辑。
这里有个细节:analyze节点执行完回到generate,形成一个小环。这个环和validate直接回generate的环是两条不同的路径,对应"需要分析"和"不需要分析"两种修正策略。图结构把这种差异表达得很清楚。
5.2 初始 State 的构造
调用时要给全初始字段,尤其是attempts要从 0 开始,history要初始化成空列表:
initial_state = { "task": "写一个函数,解析形如 '2024-01-15T10:30:00+08:00' 的时间字符串,返回 UTC 时间戳", "code": None, "error": None, "error_type": None, "attempts": 0, "max_attempts": 4, "history": [], "passed": False } result = app.invoke(initial_state)任务描述写得越具体,首次生成的质量越高。上面这个例子明确了输入格式、输出类型,比"写个时间解析函数"要好得多。这是 prompt 工程的基本功,在 Agent 场景里同样适用。
5.3 流式观察执行过程
调试 Agent 时,一次性invoke看不到中间过程,很不方便。LangGraph 支持流式执行,可以逐节点观察 State 的变化:
for event in app.stream(initial_state): for node_name, state_update in event.items(): print(f"节点 {node_name} 输出: {state_update}")我调试时几乎都用stream,能清楚看到每一轮生成了什么代码、报了什么错、走了哪条分支。定位问题时,这比看最终结果有用得多。
6. 实测中踩过的坑与调优经验
6.1 上下文膨胀导致后期轮次变慢变贵
每轮都把完整历史塞进 prompt,到第三四轮时上下文会非常长,不仅慢,还贵,而且模型可能被过长的历史干扰。我的做法是只保留最近两轮的错误摘要,更早的只留一行"第 N 次尝试失败于 X 类型错误"。这样既保留了"别重复"的信息,又控制了长度。
6.2 模型倾向于"最小改动"而非"重新思考"
当错误比较隐蔽时,模型往往只改一行,结果还是错。这时候可以在 prompt 里明确要求:"如果连续两次失败,请重新审视整体实现思路,而不是只做局部修改。"加这句话之后,卡死的情况明显减少。
6.3 验证环境的隔离不能省
跑测试一定要在隔离环境里,不能让生成的代码直接访问生产资源、文件系统或网络。我一般用容器或子进程加资源限制来跑,超时直接杀掉。这既是安全考虑,也是稳定性考虑——生成的代码可能死循环,不隔离会把整个 Agent 拖死。
6.4 错误分类不要过度依赖模型判断
我一开始想让模型自己判断错误类型,结果它经常分错。后来改成用规则判断:能解析出SyntaxError就是语法错误,能解析出NameError/TypeError就是运行时错误,测试断言失败就是逻辑错误。规则判断又快又准,模型只在规则覆盖不到时才介入。
6.5 记录每一轮的完整数据用于复盘
history里我不仅存错误,还存了每轮的代码、耗时、token 消耗。跑一段时间后分析这些数据,能看出模型在哪些类型的任务上容易卡住,从而有针对性地优化 prompt 或调整策略。这个习惯帮我发现了好几个之前没意识到的问题模式。
7. 这套结构还能往哪些方向扩展
自我修正的框架搭好之后,扩展点其实很多。比如把单文件生成扩展成多文件项目生成,验证环节加上跨文件依赖检查;比如引入"代码审查"节点,让另一个模型实例对通过的代码做质量评估,不达标也触发修正;再比如把人工反馈也作为一种"错误信息"接入,让人在关键节点介入。
还有一个我觉得很有价值的方向:把修正过程中积累的"错误—修复"对沉淀下来,作为后续类似任务的参考示例。这相当于给 Agent 加了一层经验记忆,遇到相似错误时可以直接参考历史修复方案,减少重试次数。LangGraph 的 State 机制天然支持把这类信息在流程中传递和持久化,实现起来并不复杂。
我在实际项目里用这套结构处理过日志解析、数据清洗、API 封装这几类任务,首次通过率大概在四成左右,加上自我修正后能到八成以上,剩下的两成走人工。这个数字不算完美,但相比一次性生成已经是质的差别。关键是把验证做扎实、把错误信息提纯、把重试策略设计好,剩下的就是根据具体任务不断调优了。