ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于LangGraph的自我修正代码生成Agent实战

基于LangGraph的自我修正代码生成Agent实战 代码生成这件事很多人第一反应是让大模型直接写。但真把它放进工程流程里你会发现一个尴尬的现实模型一次生成的代码能跑通的比例远没有想象中高。语法错误、边界条件漏判、依赖版本对不上、单元测试跑不过——这些问题在一次性生成里几乎必然出现。我最初做代码生成工具时也踩过这个坑后来才意识到真正有价值的不是生成而是生成之后能不能自己发现问题并改对。这就是自我修正代码生成 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( taskstate[task], previous_codestate.get(code), previous_errorstate.get(error), historystate.get(history, []) ) new_code call_llm(prompt) return { code: new_code, attempts: state[attempts] 1 }注意这里把previous_code和previous_error都传给了 prompt 构造函数。第一轮它们为空模型只看到任务描述后续轮次模型能看到上一版代码和具体错误这就是修正的信息来源。验证节点则负责跑检查并填充error和error_typedef 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.parseJavaScript 可以用 acorn 之类。这一步能抓出绝大多数低级错误而且几乎零成本。导入检查可以尝试解析 import 语句看模块是否存在。第二层是静态类型与风格检查。Python 用 mypy 或 pyrightJS 用 tsc能抓出类型不匹配、未定义变量这类问题。这一步比语法检查慢但比跑测试快。第三层是单元测试。这是最接近真实行为的验证。测试用例从哪来两个来源一是任务本身附带的测试二是让模型根据任务描述自己生成测试。后者要小心模型可能生成迎合自己代码的测试所以最好两者结合以人工或任务给定的测试为准。def check_syntax(code: str): try: ast.parse(code) return True, None except SyntaxError as e: return False, fSyntaxError 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, radius3) 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- 第{i1}次尝试失败: {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, threshold0.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. 把图组装起来从节点到可运行的 Agent5.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:0008: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 封装这几类任务首次通过率大概在四成左右加上自我修正后能到八成以上剩下的两成走人工。这个数字不算完美但相比一次性生成已经是质的差别。关键是把验证做扎实、把错误信息提纯、把重试策略设计好剩下的就是根据具体任务不断调优了。
返回列表