1. 为什么“一次生成、坐等结果”的代码 Agent 不靠谱
先聊一个所有搞 Agent 的人都会撞上的现实:大模型写代码,一次跑通的概率远比你想象的低。即使是最强的模型,面对稍复杂的仓库结构、不够明确的错误信息、前后端联调需求,第一版代码基本都能跑,但功能往往差一口气——要么边界条件没处理,要么 API 参数写错,要么引入一个奇怪的不兼容。
我见过很多团队把大模型包装成一个“代码生成接口”——输入需求描述,输出代码,然后让开发自己去测试、去改。这样不是不能用,但本质上你只是把“从搜索引擎复制代码改成自己项目”这件事,交给了大模型,后面的验证和修复还得靠人。更关键的是,你没法让这个过程自动化起来。
最早我发现这个问题,是在一个内部工具项目里:我需要生成一段从 Excel 读取数据、做清洗、再导入数据库的脚本。模型一次性给出的代码看起来非常专业,类型标注齐全、注释得体,但运行时报错——库没装、API 已废弃、字段名拼错。正常开发中这种问题找出来也就几分钟,但如果目标是“让 Agent 自己干活”,它必须能够看到测试失败的反馈,再回头改自己的代码,直到通过为止。
这就引出了所谓“自我修正 Agent”的核心需求:把“写代码”从一次生成变成有反馈的闭环——生成 → 运行验证 → 读取错误 → 修复 → 再验证。这个闭环不是靠叠加提示词“请仔细思考”能解决的,而是需要一个能编排状态、控制流程、支持条件分支的框架。LangGraph 恰好是干这个的。
我选择 LangGraph 而不是直接手写while循环,主要原因有这么几个:第一,它的状态管理是结构化的,代码、错误信息、已完成步骤、尝试次数都能放进共享状态,不会因为函数调用层级复杂而丢失;第二,条件边(conditional edge)能根据验证结果决定下一步走“修复”还是“收工”,这个分支逻辑在原生代码里写容易乱,换成图结构一目了然;第三,LangGraph 原生支持检查点(checkpointer),Agent 中途崩了可以从上次状态接着跑,省掉一大堆工程轮子。
如果你目前只把 Agent 做成“调一次模型拿个结果”,那这篇文章可能会改变你这个习惯——至少对代码生成这个场景而言,缺少反馈闭环的 Agent 是不完整的产品。
2. 整体架构:状态、节点、条件边如何编排修正闭环
2.1 先放下“链式调用”的思维,Agent 是一张图
很多人写 Agent 容易陷入一种固定套路:先调一次模型做规划,再调一次模型写代码,再调一次模型总结结果——本质上是一条直线。这个思路只能叫“串联脚本”,不是真正的 Agent。真正能具备自我修正能力的结构,是一张允许走回头路的图。
LangGraph 把整条执行路径抽象成三个核心部件:
- 状态(State):全局共享的数据结构,所有节点都能读也能写。
- 节点(Node):执行单一职责的函数,比如“生成代码”“执行测试”“提炼错误”。
- 边和条件边(Edge / Conditional Edge):决定下一个节点是谁,普通边固定走向,条件边看状态动态跳转。
我设计的修正闭环是四步循环:Generate → Run Test → Parse Error → Fix Code,每一步对应一个节点,它们之间用条件边相连。
整体流程走下来是这样:
- 用户把任务描述传入“生成代码”节点,模型输出代码文本。
- 代码写入工作目录,触发“执行验证”节点——运行编译、静态检查或单元测试。
- 验证结果(包括报错信息、测试通过率等)写入状态,进入“分析反馈”节点。
- 分析节点把原始 stdout + stderr 整理成结构化的错误摘要,送进“修复代码”节点。
- 模型拿到“原代码 + 错误摘要 + 任务需求”后重写代码,再次回到第 2 步。
整个循环由一条条件边把关:如果验证通过了,跳向“完成”终点;如果没通过且尝试次数没超限,跳回“修复代码”;如果尝试次数超限,也要中断退出,避免无限循环烧钱。
2.2 状态设计:把“代码、需求、错误、历史”都装进同一个结构体
状态设计是最容易被低估的环节。很多人一开始只放一个code字段,跑着跑着发现不对——出错后要恢复历史版本、要区分哪条错误是上一次修复引入的、要看当前是第几次尝试,这些信息如果不在状态里,后面根本没法写逻辑。
我用 TypedDict 定义状态,大致是这样:
from typing import TypedDict, List, Optional class CodeGenState(TypedDict): requirement: str # 原始需求,全程不做修改 code: str # 当前代码 attempts: int # 当前尝试次数 max_attempts: int # 最大尝试次数,防止死循环 error_feedback: str # 最近一次的结构化错误摘要 raw_logs: List[str] # 每次验证的原始日志,便于追溯 generation_history: List[str] # 每次生成的代码快照 passed: bool # 最新一次验证是否通过这里有两个细节值得说明。generation_history是我在后来的迭代中加上去的——为什么?因为模型修复代码时经常把原来对的东西改错了,或者反复进入同一种错误模式。有历史快照的好处是,状态里可以直接把“上一版代码”作为修复提示的一部分给模型看,让它知道发生了什么变化,而不是只看到最终报错。另一个是raw_logs,一开始我觉得只保留error_feedback就够了,后来发现排查问题时原始日志不可替代,比如某些偶发性的网络超时,模型自己都判断不出是代码问题还是环境问题,只有看原始输出才明白。
2.3 节点划分:每个节点只干一件事,避免“大杂烩”函数
我最开始写的时候犯过一个错误:把所有逻辑塞进两个节点里,一个负责生成和修复,一个负责测试和解析。跑了两轮就发现,模型报错后重新生成代码时,会把“修复代码的正确意图”和“生成初始代码的创作意图”混在一起,导致节点又当翻译又当 debugger,效果很差。
划分职责时我遵循一个原则:一个节点对应一个模型调用或一个确定性工具,不在同一个节点里既思考又动手。
我最终的节点列表是:
| 节点名称 | 职责 | 是否调用模型 | 核心输出 |
|---|---|---|---|
generate_code | 根据需求生成第一版代码 | 是 | 代码文本 |
run_validation | 写文件并运行测试/编译 | 否 | 原始退出码和日志 |
analyze_error | 从日志中提炼错误要点 | 是 | 结构化错误摘要 |
fix_code | 结合错误摘要和原代码重写代码 | 是 | 修正后的代码 |
节点间最大的区别在于“会不会改变状态里的code”。run_validation永远不碰代码内容,它是一个纯粹的执行器——好处是,如果验证逻辑稳定,你可以先本地跑一遍,确认无误后再接到图里,减少 Agent 链路里的不确定因素。很多踩坑案例其实都不是模型的问题,而是测试工具本身的输出格式不稳定,导致“反馈信号”是脏的,后面再怎么修都白搭。
2.4 条件边:让循环“知道”什么时候该停
条件边是整张图里最关键的一环。它不调用大模型,只根据状态字段做简单的判断:
def should_continue(state: CodeGenState) -> str: if state["passed"]: return "finish" if state["attempts"] >= state["max_attempts"]: return "finish" # 也可以走一个专门的“超限失败”终点 return "fix_code"有人可能会问:为什么需要 max_attempts?模型不是越修越好吗?实测中并不总是这样——有相当的机率,模型在第三、第四次修复时会突然把整个逻辑推翻重写,甚至删除之前正确的代码。限定次数既是对成本的控制,也是质量兜底:与其让 Agent 无限试错,不如让它停下来,把已有进展和失败原因交给人处理。
条件边书写时还有一种容易踩的问题:返回值必须是目标节点的名称字符串,如果拼错了,LangGraph 不会在构造图时报错,而是在运行时抛异常。这个坑挺隐蔽的,建议节点名全部用常量集中管理,而不是裸写字符串。
3. 核心实现:从“报错日志”到“修复指令”的完整链路
3.1 验证节点:不只是跑一次命令那么简单
run_validation节点在很多人看来就是一个subprocess.run(),实则不然。代码生成场景有一个天然张力:你没法确定模型生成的代码需要什么解释器、什么依赖、以及用什么方式运行验证。如果验证方式本身不稳定,整个循环的反馈信号就是噪声。
我的做法是:先做一次“探测”——在验证代码前检查几件事:
- 生成的文件是什么类型?
.py、.js、.go,还是混合? - 有没有
requirements.txt或package.json? - 项目有没有测试文件?还是只有一段独立脚本?
根据探测结果选择验证策略,比如 Python 代码优先收集py_compile的编译检查结果,其次是执行内置的测试用例;如果有 pytest 文件就优先跑 pytest,没有的话就做一个简单的冒烟执行。探测逻辑看起来繁琐,但能显著提升信号质量。
一个最小可用的验证节点大致长这样:
import subprocess import tempfile from pathlib import Path def run_validation(state: CodeGenState) -> CodeGenState: # 把当前代码落盘到工作目录,方便编译/运行定位行号 with tempfile.TemporaryDirectory() as tmp: workdir = Path(tmp) code_file = workdir / "generated_code.py" code_file.write_text(state["code"]) result = subprocess.run( ["python", "-m", "py_compile", str(code_file)], capture_output=True, text=True, timeout=30 ) raw_output = result.stdout + result.stderr state["raw_logs"].append(raw_output) state["attempts"] += 1 if result.returncode == 0: # 再跑一个冒烟执行,验证不是只过语法关 smoke = subprocess.run( ["python", str(code_file)], capture_output=True, text=True, timeout=15 ) state["passed"] = smoke.returncode == 0 if smoke.returncode != 0: state["error_feedback"] = smoke.stdout + smoke.stderr else: state["passed"] = False state["error_feedback"] = raw_output return state这里有一个工程细节:py_compile只做语法检查,一次通过的代码往往运行时秒挂。所以必须加一层冒烟执行,哪怕没有正式的测试用例,至少验证“导入不报错、脚本能跑完”。如果项目本身带单元测试,直接把冒烟换成pytest -q,反馈信号的质量会再上一个台阶。
3.2 错误提炼节点:把“天书”翻译成模型看得懂的“处方”
跑完验证之后,手里是一堆原始日志。不建议直接把几百行 traceback 全量塞给修复节点——原因有两个:
一是上下文长度限制。多轮循环后,每条日志都推给模型,很快generation_history加上日志就会超出窗口,而且模型在超长上下文中更容易忽略真正关键的错误行。
二是错误主次不分。一份 traceback 里往往有“直接错误”和“根因错误”两层信息,模型要自己从原文里找根因,浪费一次推理不说,还容易挑到表面信息去修。
所以analyze_error节点的作用是:让模型把原始报错压缩成一种结构化的“错误处方”。我会强制模型按固定 JSON 格式输出:
{ "error_type": "AttributeError", "error_location": "line 23, in process_data", "root_cause": "DataFrame 列名不匹配,期望 col_a,实际只有 col_b", "suggestion": "读取文件后先重命名列,或改用列索引访问" }解析 JSON 时我做了兜底处理:如果模型输出里混入了评论或 Markdown 代码块,先正则提取 JSON 片段,解析失败则整段原始文本作为纯文本继续传入修复节点。这是因为实测中模型偶尔会不按 JSON schema 输出,硬解析直接崩掉不合算。
3.3 修复节点:比“重写代码”多一层对比信息
修复节点和初始生成节点共享同一个模型接入方式,但系统提示词差异很大。生成节点拿到的指令是“根据需求写代码”,而修复节点看到的是三样东西:原始需求、当前代码、错误处方,并且被要求“只针对错误处方中提到的点做最小化修改”。
最小化修改这个要求,是我反复强调的核心。模型默认倾向是“重写整个文件”——一旦重写,原来已经验证通过的那部分逻辑又被推翻,下一轮验证可能冒出新错误。要让模型遵守这个约束,除了提示词之外,关键技巧是把上一版代码也放进消息里:
您上一轮生成的代码为: {previous_code} 最新测试结果指出以下問題: {error_feedback} 请基于最小改动原则修复代码,尽量保留已经能工作的部分。previous_code从generation_history里取倒数第二个快照。实践证明,有了这个对比,模型“推倒重来”的概率大幅下降。
4. 循环控制与状态治理:防止 Agent 跑飞烧钱
4.1 最大尝试次数要配“自适应阈值”
我在第一节提过max_attempts,但想聊得更细。很多人直接写成固定 3 次或 5 次,实际用下来不够聪明——有些错误是“第一版就没抓住需求”,需要更多轮次;有些错误是纯粹的环境问题,跑多少次都没用。
我采用的方案是双阈值:硬性上限设为 6 次,前 3 次按正常修复逻辑跑;第 4 次开始时如果仍然失败,就会切换到一个“激进修复”参数——模型被要求可以考虑重构或者补充断言,但如果第 6 次还没过,直接停止。等于给了 Agent 两个阶段:先用保守方式修正,再允许放大范围重写,最后止损。
这个想法来自真实观察:代码 Agent 失败,通常不是一次能救回来的,但也不能无休止地自我怀疑。
def should_continue(state: CodeGenState) -> str: if state["passed"]: return "finish" if state["attempts"] >= state["max_attempts"]: return "finish_limited" # 走到一个专门的止损终点 return "fix_code"finish_limited终点节点里我会额外输出一句话给用户:当前代码已经过了几次尝试仍未通过验证,建议人工介入。这句话看似简单,但在工程协作里很重要——它让调用方知道这是非正常结束,而不是 Agent 成功完成了任务。
4.2 Token 预算管理比循环次数更现实
很多人忽略一个问题:LangGraph 控制的是“节点跳转”,但每次模型调用的 token 消耗是累计的。哪怕只跑了 3 轮,如果每轮的代码都在 800 行、错误日志又是全量传入,总 token 数也可以轻松破十万。成本飙升之外,还有一个被忽视的问题:长上下文的注意力稀释。模型在长上下文里做局部修改时,经常改错位置。
我的 Token 预算策略有三条:
- 传入修复节点的代码不是全量代码,而只传“相关代码片段”——办法是让 analyze_error 输出错误位置,然后用行号切片,取错误点上下 50 行。
- 传入的日志最多保留最近 2 轮的错误摘要,更早的只保留一行标记(“第 2 轮曾出现某个类型错误”)。
- 所有历史代码快照在状态里保留完整,但不直接进消息,由外部逻辑决定是否截断。
这样处理之后,每轮的 token 占用从“近乎全文”降到“局部上下文”,模型修复的准确率和成本同时得到优化——这是一个很少人提但在实际场景里非常有效的优化点。
4.3 状态治理:清理“脏”状态和并发的坑
LangGraph 图本身没有并发问题,因为它按顺序跑。但你要处理一个隐含场景:同一个 Agent 服务被多个用户调用,每个用户的 Session 内部状态不能串。这就要用到checkpointer和thread_id。
LangGraph 的检查点机制允许你把每次运行的状态快照持久化,之后用同一个thread_id恢复。我在服务化部署时是这么接入的:
from langgraph.checkpoint.memory import MemorySaver checkpointer = MemorySaver() app = builder.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": user_request_id}}MemorySaver适合低并发演示或单机小流量。多实例部署时,换成 Redis 或 Postgres 检查点,防止进程重启丢状态。这个切换不用改图逻辑,只需换 checkpointer 实现,LangGraph 在设计上留好了口子。
另外要提醒一个状态设计的坑:TypedDict里的字段如果不小心写成非必填,并发的请求又复用了同一个键,可能出现状态残留。我的习惯是每次新的运行开始,显式调用reset_state()或者直接新建一个 dict 来初始化,防止前一次的error_feedback或code残留污染下一次运行。
5. 真实落地中的踩坑记录与调优经验
5.1 报错日志太长导致上下文爆炸——我如何用“摘要裁剪”救回来
第一次完整跑通闭环后,我把一个真实需求的 6 轮修正日志打印出来,发现第 5 轮传给模型的内容里,仅 traceback 文本就占了 1.2 万 tokens。而真正定位问题只需要其中的 4、5 行关键信息。
于是我在analyze_error节点里新增了一步:除了让模型输出 error_type、error_location、root_cause、suggestion 这四件套,我还要求它输出一条 100 字以内的one_line_summary。后续进入 fix_code 时,上下文里只传 summary 和前一次的错误类型,完整日志只在调试开关打开时透传。这看起来像个小改动,实际效果非常明显——修复准确率不降反升。
这里有一个值得学习的基础逻辑:反馈的质量不是由体积决定的,而是由“信噪比”决定的。模型在长篇日志里找重点的能力远远弱于你帮它标好重点后的表现,这个结论我在多个模型上都验证过。
5.2 修复反而引入新 bug——引入回归检测机制
自我修正 Agent 有一个特别让人头疼的“劣化模式”:第一次生成代码 70 分,验证失败后模型自信地改了一版,直接掉到 40 分。为什么会这样?因为模型只看到了当前错误,不知道之前哪些地方是对的,改着改着就把原来的正确逻辑破坏了。
我在修复节点加了一个“回归提示”后,情况改善了一些,但没有根治。后来我意识到,关键是建立自动回归检测——每次修复后,不仅要跑最新的测试,还要重放前几轮的测试用例。
回归测试设计: - 第 1 轮生成的代码,保存当时的测试用例(如果没有,就用冒烟执行结果作为基线)。 - 第 2 轮修复后,除了跑本轮的新测试,还要确保第 1 轮的基线用例不失败。 - 凡是回归失败的,标记为 regression_error,作为错误摘要的优先级最高项。这样做的代价是验证时间变长,但换来的是发现问题后的“方向感”——模型知道自己不只是修一个点,还破坏了一个点,它下一次修复时会更收敛。
实际代码里我把它实现成一个regression_flag字段,当连续 3 轮都没有跑出新错误,而回归反复出现时,让 Agent 走finish_limited终止并请求人工介入。这个规则帮我们拦住了好几次模型“自我捣乱”的情况。
5.3 从“闷头跑”到“可视化调试”——LangGraph 提供的观测工具
刚开始跑图的时候完全是黑盒,你只知道最终结果不行,但不知道卡在哪一步。LangGraph 有一个很实用的功能:可以把整个执行过程的每一步状态流输出成文本日志。
我用的是 JSON 格式的状态转储,每次节点执行完,把attempts、passed、error_type、代码长度等关键字段打出来。这样排查问题的时候,不需要去猜,一眼就能看出循环是在哪个节点出状况:
=== Attempt 2 === generate_code: code_len=1243, passed=False run_validation: exit_code=1, error_type=ModuleNotFoundError analyze_error: root_cause=缺少 openpyxl 依赖 fix_code: reused_previous=True, code_len=1288这个习惯帮我省了非常多时间。尤其在条件边判断逻辑出错时——比如should_continue的返回值大小写写错——没有可视化日志的话,到处加 print 调试会非常痛苦。LangGraph 自带的可视化画图功能(app.get_graph().draw_ascii())也值得用,虽然输出简陋,但能直观看到节点之间的连接是否符合预期。
5.4 不是所有错误都值得自动修——人工兜底范围的设计
最后聊一个比较“产品化”的心得。代码生成 Agent 并不是所有错误都应该由模型自己修,有些错误修多少次也没用,比如:
- 依赖根本没装好(环境问题,Agent 内部网络又访问不了)
- 需求本身有歧义(模型反复修也没法猜中用户意图)
- 外部服务不稳定(测试失败是偶发性的,不是代码问题)
我在结构中加了一个“错误分类器”节点,在 analyze_error 之后跑一遍轻量的规则判断(不调用模型),识别出这几类问题就直接终止,不再进入fix_code。这个设计极大地提升了整体效率——原本 6 轮不仅烧 token,更重要的是用户等半天拿到一个错的答案;现在 1 轮就可以给出结论“这个问题不建议自动修复,原因如下……”。
人工兜底的另一个角度是保留“人工接管的入口”。当 Agent 走到finish_limited终点时,我把它的完整执行日志、代码快照、错误摘要打包成一个 JSON 结果返回给调用方。调用方可以决定是丢弃、还是让人修改后重新触发,或者作为“人工前置把关”的输入。这是工程落地时非常重要的一环——纯 Agent 的容错能力终究有限,但一个好的编排框架应该让“AI + 人”的组合比两者单独工作都强。
我的实践体会是,finish_limited从来不是失败,它是一个精心设计的安全出口。能在真实场景里控制住 Agent 的边界,比让它在“无限潜力”里继续烧钱重要得多。