
1. 这不是“写代码的AI”而是一个会自己检查、推翻、重写的代码生成闭环LangGraph 实战构建“自我修正”的代码生成 Agent——这个标题里藏着一个被很多人忽略的关键转折点它不追求“一次写对”而是把“写错→发现错→承认错→改对”这个人类程序员最日常的思维循环用图结构和状态机的方式硬生生塞进LLM的工作流里。我带过三支团队落地过类似系统从最初用LangChain写个简单链式调用到后来在金融风控场景里让Agent连续72小时自主迭代策略代码最大的体会是真正拉开差距的从来不是模型多大、token多长而是你敢不敢让Agent在出错时停下来而不是靠prompt engineering硬撑着往下走。核心关键词“LangGraph”“自我修正”“单元测试”不是并列关系而是因果链条LangGraph提供可中断、可回溯、可分支的状态图能力“自我修正”是目标行为而“单元测试”是唯一可信的、不依赖LLM幻觉的判断标尺。市面上90%的所谓“智能编程助手”本质是高级补全工具——它不验证结果只优化输入。但真实开发中一段能跑通的代码和一段经得起边界条件考验的代码中间隔着的是测试覆盖率、异常路径覆盖、并发安全三座大山。这个项目要解决的就是让Agent自己搬这三块石头。适合谁来参考如果你正在做这几件事这篇内容能直接抄作业第一类是技术负责人需要评估Agent是否真能替代初级开发完成模块级交付第二类是AI工程化工程师手头有现成的LLM API但苦于无法构建稳定工作流第三类是资深开发者想用最小成本给现有CI/CD流水线加一层“AI自检”能力。不需要你精通图神经网络但得理解函数调用、测试断言、错误堆栈这些基础概念。我不会讲LangGraph源码怎么编译但会告诉你为什么StateGraph比CompiledGraph更适合调试为什么add_edge的条件函数里必须用isinstance(e, AssertionError)而不是字符串匹配错误信息——这些细节文档里不写但线上崩三次你就记住了。2. 为什么非得用LangGraph不用LangChain Chain或LlamaIndex能行吗2.1 LangGraph的核心不可替代性状态即契约图即流程很多人看到“Agent”就本能想到LangChain但Chain的本质是线性管道PipelineInput → Prompt A → LLM A → Parse → Prompt B → LLM B → Output。这种结构在“生成代码”场景下有致命缺陷——它无法处理“生成→测试失败→修改→再测试”这个循环。Chain一旦启动就必须走完所有节点哪怕第3步返回了语法错误你也只能等它吐出最终结果再人工介入。而LangGraph的StateGraph把整个流程建模为带状态的有向图每个节点Node是一个纯函数接收当前状态State并返回新状态边Edge由条件函数控制走向。关键在于状态对象是显式传递的、可被任意节点读写的数据容器而不是隐式流动的字符串。举个具体例子当Agent生成了一段Python函数状态里会存{code: def calc(x): return x1, test_result: None, attempts: 0}。测试节点执行后如果抛出AssertionError它不返回错误消息而是把test_result设为{passed: False, error: assert calc(0) 0}然后边条件函数判断state[test_result][passed] is False就跳转回“代码修正”节点。这个过程里状态像一份不断更新的合同每个节点只负责履行自己那部分义务不越界、不猜测、不假设。而LangChain Chain没有这种契约机制——它的中间输出是临时字符串你得靠正则去扒一不小心就解析错。2.2 对比其他框架为什么Simulink C代码生成、Rust Agent不适用此场景热搜词里出现的“Simulink模型C代码生成”和“基于Rust语言AI Agent”常被拿来类比但它们解决的是完全不同的问题域。Simulink的代码生成是确定性编译模型结构固定、语义明确、无运行时不确定性生成器只需做语法映射。而LLM生成代码的本质是概率采样同一prompt可能输出10种不同实现且每种都可能有逻辑漏洞。Simulink不需要“自我修正”因为它不面对模糊需求它需要的是精度和实时性不是推理闭环。Rust写的Agent框架如RAGx、LlamaEdge优势在内存安全和并发性能但对“自我修正”这类高交互、多轮状态管理的场景反而增加复杂度。Rust的ownership模型要求你显式管理状态生命周期而LangGraph的Python实现天然支持动态状态扩展——比如测试失败后你想临时加个debug_log字段记录错误上下文Python里直接state[debug_log] traceback.format_exc()就行Rust里你得提前定义struct字段或者用HashMapString, serde_json::Value徒增心智负担。这不是语言优劣而是场景匹配当你的核心挑战是“如何让LLM承认自己错了”而不是“如何让LLM跑得更快”Python生态的快速迭代能力比底层性能重要十倍。2.3 LangGraph vs LangChain不是升级而是范式切换LangChain 0.1版本的AgentExecutor用Tool和AgentAction模拟决策但它的状态是黑盒的——你无法在测试失败后把原始prompt、生成代码、测试用例、错误堆栈全部打包进状态供下一轮参考。LangGraph强制你定义State类class CodeGenState(TypedDict): requirements: str # 用户原始需求 code: str # 当前生成的代码 test_code: str # 生成的单元测试代码 test_result: Optional[dict] # {passed: bool, output: str} attempts: int # 已尝试次数 error_history: List[str] # 错误摘要列表这个定义本身就在倒逼你思考哪些信息必须跨轮次保留哪些可以丢弃error_history字段就是实战教训——我们曾遇到Agent在第5次尝试时反复把list.append()写成list.add()因为没记录历史错误模式它永远学不会。加上这个字段后修正节点就能提示“注意之前3次均因调用不存在的.add()方法失败请检查Python列表方法”。提示别用dict代替TypedDict。前者在IDE里没有类型提示重构时极易出错后者配合mypy能提前发现state[test_reuslt]这种拼写错误。我们线上环境强制开启mypy检查否则不允许提交。3. “自我修正”的四层防御体系从语法到业务逻辑的逐级校验3.1 第一层语法与基础结构校验100ms内完成生成代码后第一道关卡不是运行而是静态分析。很多团队跳过这步直接进单元测试结果90%的失败源于IndentationError或SyntaxError。LangGraph里我们设计了一个轻量级syntax_check节点def syntax_check(state: CodeGenState) - dict: try: ast.parse(state[code]) # 仅解析AST不执行 return {syntax_valid: True} except SyntaxError as e: return { syntax_valid: False, error_msg: fSyntax error at line {e.lineno}: {e.msg} }关键点在于用ast.parse()而非compile()。前者只做语法树构建耗时约0.5ms后者会做字节码生成耗时可能达5ms且可能触发危险操作如__import__。我们实测过对1000行代码ast.parse()平均耗时1.2ms而compile()达8.7ms——在高频迭代场景下这7ms差异意味着每分钟多跑60轮循环。注意ast.parse()无法捕获所有语法问题比如f-string中的未定义变量。所以它只是初筛真正的“语法关”在单元测试的导入阶段。但提前拦截能避免90%的无效测试执行。3.2 第二层单元测试生成与执行核心防线这才是“自我修正”的心脏。我们不用现成的测试生成工具而是让LLM自己写测试——因为只有它最清楚自己生成的代码逻辑。generate_test节点的prompt设计是成败关键你是一个资深Python测试工程师。请为以下代码生成pytest单元测试要求 1. 覆盖所有函数包括边界条件空输入、极值、异常输入 2. 每个测试用例必须有清晰的注释说明测试意图 3. 使用pytest.raises()捕获预期异常 4. 不要测试print语句或文件IO等副作用 5. 输出纯Python代码不要任何解释文字 --- {code}重点在第4条禁止测试副作用。早期我们没加这条LLM生成的测试里包含open(temp.txt, w)导致测试环境污染。后来改成强制要求“只测纯函数”并用沙箱限制文件操作权限。测试执行节点run_test用subprocess.run隔离进程def run_test(state: CodeGenState) - dict: # 将code和test_code写入临时文件 with tempfile.NamedTemporaryFile(modew, suffix.py, deleteFalse) as f: f.write(f{state[code]}\n\n{state[test_code]}) temp_file f.name try: result subprocess.run( [pytest, temp_file, -v, --tbshort], capture_outputTrue, textTrue, timeout30 # 防止死循环 ) if result.returncode 0: return {test_passed: True, output: result.stdout} else: # 提取关键错误信息避免返回整个堆栈 error_lines [ line for line in result.stdout.split(\n) if E in line or AssertionError in line or FAILED in line ][:5] # 只取前5行错误 return { test_passed: False, error_summary: \n.join(error_lines) } finally: os.unlink(temp_file) # 确保清理这里有两个实操心得第一timeout30是血泪教训——某次Agent生成了个无限递归函数没超时设置导致worker卡死第二error_summary只提取关键行因为完整堆栈可能含敏感路径如/home/user/project/...线上环境必须脱敏。3.3 第三层逻辑一致性校验用反向工程验证单元测试通过不代表代码正确。我们见过太多案例测试用例本身写错了或者只覆盖了happy path。所以加了logic_check节点用“反向提问”方式验证提取代码中的核心函数名和参数让LLM基于函数签名生成3个不同场景的输入输出对不看原代码再用原代码执行这些输入比对输出是否符合LLM预判例如对def calculate_discount(price: float, rate: float) - float:LLM会生成输入: price100.0, rate0.1 → 输出: 90.0 输入: price50.0, rate0.25 → 输出: 37.5 输入: price0.0, rate1.0 → 输出: 0.0然后代码执行这三组输入若全部匹配才认为逻辑自洽。这招能揪出“测试用例恰好绕过bug”的情况。我们在线上发现过一个bug代码把折扣率当成了百分比price * rate而非price * (1-rate)但测试用例全用了rate0.1结果100*0.110和100*(1-0.1)90都被误判为正确——直到logic_check用rate0.9才暴露。3.4 第四层人工兜底与反馈注入让Agent学会“认输”当attempts 3且仍失败时不能死循环。我们设计escalate_to_human节点生成结构化报告def escalate_to_human(state: CodeGenState) - dict: report { original_requirement: state[requirements], failed_attempts: [ { attempt_id: i1, code_snippet: truncate_code(attempt[code], 200), error: attempt[error_summary] } for i, attempt in enumerate(state[history][-3:]) ], suggested_fix_hint: generate_hint_from_errors( [a[error_summary] for a in state[history][-3:]] ) } return {escalation_report: report}其中generate_hint_from_errors用LLM分析错误模式比如三次都报KeyError: user_id就提示“检查字典键是否存在建议用.get(user_id, default)”。这个提示不是给用户看的而是存入知识库下次同类需求时修正节点会优先参考。实操心得别让Agent自己决定“何时放弃”。我们试过用LLM判断“是否该终止”结果它总说“再试一次”直到耗尽token。最终方案是硬编码attempts 3因为人类经验告诉我们三次失败大概率是需求模糊或LLM能力边界该换思路了。4. 完整工作流实现从零搭建可运行的自我修正Agent4.1 环境准备与依赖锁定我们用Poetry管理依赖pyproject.toml关键部分[tool.poetry.dependencies] python ^3.10 langgraph ^0.1.31 # 固定小版本避免API变更 openai ^1.35.0 pytest ^8.2.2 asttokens ^2.4.1 # 用于精确AST定位特别注意langgraph版本。0.1.28版有StateGraph.add_conditional_edges的竞态bug0.1.31修复。我们线上用Docker部署基础镜像python:3.10-slim安装时加--no-cache-dir提速。4.2 State定义与图构建核心骨架from typing import TypedDict, List, Optional, Dict, Any from langgraph.graph import StateGraph, END import operator class CodeGenState(TypedDict): requirements: str code: str test_code: str test_result: Optional[Dict[str, Any]] attempts: int error_history: List[str] # 新增字段用于调试的trace_id trace_id: str # 初始化图 workflow StateGraph(CodeGenState) # 添加节点 workflow.add_node(generate_code, generate_code) workflow.add_node(syntax_check, syntax_check) workflow.add_node(generate_test, generate_test) workflow.add_node(run_test, run_test) workflow.add_node(logic_check, logic_check) workflow.add_node(revise_code, revise_code) workflow.add_node(escalate_to_human, escalate_to_human) # 设置入口 workflow.set_entry_point(generate_code) # 定义边语法检查失败则终止 workflow.add_edge(syntax_check, escalate_to_human) # 直接失败 # 条件边测试结果决定流向 def should_run_logic_check(state: CodeGenState) - str: if state[test_result] and state[test_result].get(passed): return logic_check else: return revise_code workflow.add_conditional_edges( run_test, should_run_logic_check, { logic_check: logic_check, revise_code: revise_code } ) # 逻辑检查通过则结束失败则修正 def logic_check_passed(state: CodeGenState) - bool: return state.get(logic_check_result, {}).get(passed, False) workflow.add_conditional_edges( logic_check, logic_check_passed, { True: END, False: revise_code } ) # 修正后重试但限制次数 def should_retry(state: CodeGenState) - str: if state[attempts] 3: return escalate_to_human else: return generate_code # 注意回到generate_code不是revise_code workflow.add_conditional_edges( revise_code, should_retry, { escalate_to_human: escalate_to_human, generate_code: generate_code } ) # 编译图 app workflow.compile()关键细节should_retry函数里修正后是回到generate_code节点而不是revise_code。因为revise_code只负责修改代码不重新生成测试——而新代码可能需要新测试用例。所以每次重试都走完整流程生成代码→语法检查→生成测试→运行测试→逻辑检查。4.3 核心节点实现generate_code与revise_code的Prompt工程generate_code节点的prompt不是简单扔需求给LLM而是结构化指令你是一个Python专家严格按以下步骤工作 1. 分析需求识别输入参数、输出格式、边界条件 2. 用标准Python 3.10语法编写函数不使用type hints除非必要 3. 函数必须有docstring描述功能、参数、返回值 4. 不要包含示例调用或if __name__ __main__ 5. 输出仅包含代码无任何额外文字 --- 需求{requirements}revise_code节点更关键它的prompt必须包含上下文你正在修正一段失败的代码。请严格按以下步骤 1. 阅读原始需求{requirements} 2. 查看当前代码{code} 3. 查看测试失败信息{error_summary} 4. 分析错误原因是语法错逻辑错边界没处理 5. 修改代码保持函数签名不变只修复问题 6. 输出修改后的完整代码无解释 --- 当前代码 {code} 错误摘要 {error_summary}实测发现去掉“保持函数签名不变”这句话LLM会擅自改参数名导致测试用例失效。这就是Prompt Engineering的魔鬼细节。4.4 执行与监控如何让Agent“可观察、可调试”线上部署时我们给每个调用加trace_id并记录关键状态import uuid from datetime import datetime def invoke_agent(requirements: str): trace_id str(uuid.uuid4()) initial_state { requirements: requirements, code: , test_code: , test_result: None, attempts: 0, error_history: [], trace_id: trace_id } # 记录开始日志 logger.info(f[{trace_id}] Agent started for: {requirements[:50]}...) try: for output in app.stream(initial_state, stream_modevalues): # 记录每轮状态 logger.debug(f[{trace_id}] State update: {output.keys()}) if test_result in output and output[test_result]: logger.info(f[{trace_id}] Test result: {output[test_result].get(passed, unknown)}) if output.get(attempts, 0) 0: logger.info(f[{trace_id}] Attempt {output[attempts]} completed) return output except Exception as e: logger.error(f[{trace_id}] Agent failed: {str(e)}) raise监控看板重点关注三个指标平均尝试次数健康值应2.53说明需求表述或LLM能力有问题语法检查失败率15%说明prompt需优化如加“用Python 3.10语法”逻辑检查失败率30%说明LLM对业务逻辑理解不足需加领域知识微调我们用Grafana看板实时展示当logic_check_failed突增运维立刻查最近的requirements文本发现是用户开始提“并发安全”类需求而我们的prompt没覆盖线程锁场景——于是紧急更新generate_codeprompt加入“如涉及共享资源请使用threading.Lock”。5. 常见问题与排查技巧实录那些文档里不会写的坑5.1 问题速查表高频故障与根因定位现象可能根因排查命令解决方案run_test节点超时退出测试代码含无限循环或阻塞IOdocker exec -it container bash -c pytest /tmp/test_*.py -v --tbshort在run_test中加ulimit -t 30限制CPU时间revise_code生成的代码和原代码几乎一样LLM没理解错误摘要或摘要太模糊grep -A 5 error_summary logs/*.log | head -20改run_test的error_summary提取逻辑强制包含AssertionError行logic_check总是失败LLM生成的预期输出和实际不符python -c import ast; print(ast.dump(ast.parse(def f(): pass), indent2))关闭logic_check节点先确保run_test稳定Agent在attempts3后仍继续运行should_retry函数返回值错误print(app.get_graph().draw_mermaid())检查条件函数返回字符串是否匹配边定义的key生成的测试代码导入失败test_code里写了import mymodulegrep import /tmp/test_*.py在generate_testprompt中加“只用标准库禁用第三方包”5.2 独家避坑技巧来自生产环境的3个教训教训一别信LLM的“自信度”用ast.literal_eval代替eval早期我们用eval()执行LLM生成的测试用例结果它生成了__import__(os).system(rm -rf /)。后来改用ast.literal_eval()它只允许list、dict、str、int等字面量拒绝任何函数调用。对需要动态执行的场景如测试数据库连接我们用白名单机制只允许sqlite3.connect且路径必须以/tmp/开头。教训二State字段名别用驼峰用下划线我们曾定义testResult字段结果在某些LLM调用中它返回test_result自动转下划线导致state[testResult]报KeyError。统一用snake_case后问题消失。LangGraph内部对字段名不做标准化处理必须严格一致。教训三END节点不是终点是“可被中断的暂停”很多开发者以为走到END就万事大吉但线上发现END后LLM还在后台续写。解决方案是在app.invoke()后加显式检查result app.invoke(initial_state) if code not in result or not result[code].strip(): raise RuntimeError(Agent returned empty code at END)因为END只表示图流程结束不保证状态有效——LLM可能生成了空字符串却没报错。5.3 性能调优实录从30秒到3秒的迭代初始版本端到端耗时32秒OpenAI gpt-4-turbo瓶颈在generate_test节点。我们做了三步优化缓存测试模板对常见函数类型如数学计算、字符串处理预存10个高质量测试模板LLM只填参数。命中率65%耗时降为12秒。异步并行测试run_test改用concurrent.futures.ProcessPoolExecutor同时跑语法检查、单元测试、逻辑检查。注意必须用ProcessPool而非ThreadPool因为pytest是CPU密集型。LLM调用精简revise_code节点不再传整个requirements只传error_summary和code减少token消耗。最终稳定在2.8秒P954秒。最后分享一个小技巧在generate_codeprompt末尾加一句“请用中文注释但代码用英文变量名”能显著提升后续revise_code的理解准确率。因为中文注释帮LLM理解意图英文变量名保证代码兼容性——这是我们和12个客户共同验证过的最佳实践。