首页
学习
活动
专区
圈层
工具
发布

AI coding别让模型自己判“通过”,让测试决定它能不能继续

让 Agent 自我纠错,听上去很简单:写完代码,跑测试;失败就读错误信息,改一改,再跑一次;绿色通过后交给人审阅。链路几乎人人都会画:`plan -> implement -> test -> review -> repair -> merge`。 真正跑起来,麻烦才出现。测试失败时,是实现错了,还是命令与环境有问题?测试通过是否等于需求满足?评审 Agent 的“建议优化”该不该再开一轮?模型连续两次改不对,是继续烧 Token,还是把证据交给人?它会不会为了绿灯而删除断言? 因此,自我纠错不是追加一句“请反思并修复”的提示词,而是**由代码掌控、由外部证据驱动、具有明确收敛条件的工作流**。本文用 LangGraph 给出最小闭环:`plan -> implement -> test -> review -> repair(max 2) -> human merge`。三条纪律很简单:测试输出必须进入状态;修复次数必须有上限;自动验证再绿,合并仍由人或明确策略负责。 ## 一、先把“自我纠错”说准确:它不是模型自言自语 很多 Demo 中的自我纠错是这样的:模型写一段代码,然后问自己“这段代码有问题吗?”,接着再写一遍。这样的循环有时会得到更好的结果,但它的可靠性非常脆弱。 原因并不神秘。实现者和评审者共享同样的上下文、同样的错误假设,甚至是同一种模型偏好。第一个模型误解了需求,第二个模型很可能只是把这种误解表达得更流畅。它们一起说“看起来没问题”,不是独立验证。 真正的自我纠错至少需要三种不同性质的反馈。 第一种是**执行反馈**:测试、编译、类型检查、静态扫描、接口校验、运行时断言。它们不关心模型有没有“想明白”,只关心约束是否被满足。 第二种是**语义反馈**:审阅 Agent 或人类根据需求、变更范围、反例和架构约束提出的问题。它不能替代测试,但能发现“测试没覆盖、却做偏了”的问题。 第三种是**控制反馈**:预算、重试次数、权限范围、风险等级和任务状态。它决定系统是否还有资格继续自动尝试。 所以,一条可靠的闭环并不是: ```text 模型 -> 模型 -> 模型 ``` 而是: ```text 模型提出改动 -> 外部系统产生证据 -> 规则决定是否继续 -> 模型只在受限范围内修复 ``` ![AI coding 可靠闭环架构.png](https://developer.qcloudimg.com/http-save/yehe-7660620/3a0fdd9cf9e5317c5d51341d379b3a91.png) 这也是为什么 LangGraph 的 evaluator-optimizer 模式值得借鉴,但不能被照搬。官方示例展示了生成者和评估者之间的条件回路:评估未通过,就带着结构化反馈回到生成节点。用于软件工程时,“评估者”必须把测试和扫描结果纳入判断,不能只由另一个 LLM 凭直觉打分。 ## 二、先设计状态,再设计 Agent:状态才是闭环的记忆 很多 Agent 工作流一开始就设计 prompt:规划 prompt 怎么写、编码 prompt 怎么写、评审 prompt 怎么写。可一旦流程超过两三步,真正决定可靠性的往往不是 prompt,而是状态里保存了什么。 在一个编码闭环中,至少要保存以下信息: - 原始任务和验收条件,而不是只保存一段逐轮膨胀的聊天历史。 - 当前 worktree、允许改动路径和测试命令。 - 规划产物、实现摘要、变更文件列表和关联 commit/diff。 - 原始测试输出、退出码、执行时长和环境信息。 - 审阅结论及其引用的具体证据。 - 修复次数、已用预算、升级原因和最终人工决定。 这些字段会带来一个重要变化:当流程重试时,模型不需要“回忆”上一次发生了什么。它直接读取状态中明确保存的测试失败信息、评审意见和剩余预算。对人类也是一样:任务被升级时,接手者无需翻阅漫长对话,而能看到一个结构化的失败包。 下面给出一个简化状态。它省略了模型供应商、认证与持久化细节,但保留了闭环真正需要的控制信息。 ```python from __future__ import annotations from typing import Literal, TypedDict class TestReport(TypedDict): passed: bool exit_code: int command: str output: str class ReviewResult(TypedDict): verdict: Literal["accept", "repair", "escalate"] feedback: str evidence: list[str] class CodingState(TypedDict, total=False): task: str acceptance_criteria: list[str] worktree: str allowed_paths: list[str] test_command: str plan: list[str] implementation_summary: str changed_files: list[str] test_report: TestReport review: ReviewResult repair_count: int max_repairs: int escalation_reason: str human_handoff: str ``` 这里最关键的字段是 `test_report`、`repair_count` 和 `human_handoff`。 `test_report` 让测试不再只是终端里闪过的一段文本,而是后续路由的正式输入。`repair_count` 是成本与收敛的硬边界,不应该由模型“感觉差不多了”来决定。`human_handoff` 则承认一个事实:**系统的目标不是永不求助,而是在机器无法可靠判断时,把人需要的信息准备好。** ## 三、闭环图:让每个节点只做一件可以被检查的事 在这个最小设计里,节点职责应该刻意单一。 - `plan`:把任务翻译为可执行步骤和验收重点,不写代码。 - `implement`:在允许路径和 worktree 中实现当前计划或修复意见。 - `test`:运行被允许的确定性验证命令,原样保存结果。 - `review`:结合需求、diff 摘要和测试报告给出结构化结论。 - `repair`:只整理下一轮实现需要的反馈,并消耗一次修复预算。 - `human_merge`:不自动合并,而是输出审批/合并所需的交接包。 注意,`repair` 不是一个自由发挥的“再想想”节点。它的职责是把失败证据转换成下一轮实现的受限输入,例如“`tests/auth/test_refresh.py::test_expired_token` 仍然失败;不要修改测试;只允许改 `src/auth/**`;这是第 2 次也是最后一次自动修复”。 ![受控自我纠错闭环:测试和预算决定是否继续.png](https://developer.qcloudimg.com/http-save/yehe-7660620/15ede104e4cf9bffff843b75a18e639b.png) 这个图有两个看似保守、实际上非常重要的选择。 第一,即使测试通过,也要经过 `review`。测试只能说明被执行的约束没有失败,不能保证没有偏离需求、引入维护风险或漏掉关键负向路径。 第二,即使 review 认为可以接受,也要经过 `human merge`。这里的“human merge”并不意味着每次都要人类逐行审阅;它可以是受分支保护、代码所有者规则、变更窗口或显式批准驱动的门禁。重点是:合并是一个有责任归属的动作,不应被一句“任务完成”自动触发。 ## 四、最小实现:用 LangGraph 把控制流写进代码 下面是一份框架无关的 LangGraph 骨架。它故意把调用具体模型的部分抽象为 `CodingAdapter`:你可以接入 Copilot SDK、Claude Code、OpenAI、内部模型或本地编码器,而不改变控制流。这样也避免把密钥、模型调用和安全策略混进图逻辑里。 安装依赖: ```bash pip install langgraph ``` 核心图代码如下。`run_tests` 使用 `shlex.split` 和命令白名单,避免为了运行测试而开放任意 shell;实际系统还应在容器中运行,并把允许命令、环境变量和网络策略收进 Task 契约。 ```python from __future__ import annotations import shlex import subprocess from pathlib import Path from typing import Literal, Protocol from langgraph.graph import END, START, StateGraph class CodingAdapter(Protocol): """将任意编码模型接入受控图的最小接口。""" def make_plan(self, task: str, criteria: list[str]) -> list[str]: ... def implement( self, *, task: str, plan: list[str], feedback: str, worktree: str, allowed_paths: list[str], ) -> tuple[str, list[str]]: ... def review( self, *, task: str, criteria: list[str], changed_files: list[str], test_output: str, ) -> ReviewResult: ... def ensure_changed_files_are_allowed( changed_files: list[str], allowed_paths: list[str] ) -> None: """真实系统应基于 Git diff 校验,而不是信任模型的自报。""" for file_name in changed_files: if not any(Path(file_name).match(pattern) for pattern in allowed_paths): raise PermissionError(f"changed file is outside task scope: {file_name}") def build_graph(adapter: CodingAdapter): def plan(state: CodingState) -> dict: steps = adapter.make_plan( state["task"], state["acceptance_criteria"] ) return {"plan": steps} def implement(state: CodingState) -> dict: feedback = state.get("review", {}).get("feedback", "") summary, changed_files = adapter.implement( task=state["task"], plan=state["plan"], feedback=feedback, worktree=state["worktree"], allowed_paths=state["allowed_paths"], ) ensure_changed_files_are_allowed(changed_files, state["allowed_paths"]) return { "implementation_summary": summary, "changed_files": changed_files, } def run_tests(state: CodingState) -> dict: command = shlex.split(state["test_command"]) if not command or command[0] not in {"pytest", "npm", "pnpm"}: raise PermissionError("test command is not in the task allowlist") completed = subprocess.run( command, cwd=state["worktree"], text=True, capture_output=True, timeout=300, check=False, ) output = (completed.stdout + "\n" + completed.stderr).strip() return { "test_report": { "passed": completed.returncode == 0, "exit_code": completed.returncode, "command": state["test_command"], "output": output[-12000:], } } def review(state: CodingState) -> dict: report = state["test_report"] result = adapter.review( task=state["task"], criteria=state["acceptance_criteria"], changed_files=state.get("changed_files", []), test_output=report["output"], ) return {"review": result} def repair(state: CodingState) -> dict: return {"repair_count": state.get("repair_count", 0) + 1} def human_merge(state: CodingState) -> dict: report = state.get("test_report", {}) review_result = state.get("review", {}) handoff = ( f"task={state['task']}\n" f"changed_files={state.get('changed_files', [])}\n" f"test_command={report.get('command')}\n" f"test_exit_code={report.get('exit_code')}\n" f"review={review_result.get('verdict')}\n" f"feedback={review_result.get('feedback', '')}\n" f"repair_count={state.get('repair_count', 0)}\n" f"escalation_reason={state.get('escalation_reason', '')}" ) return {"human_handoff": handoff} def after_review( state: CodingState, ) -> Literal["repair", "human_merge"]: report = state["test_report"] result = state["review"] if report["passed"] and result["verdict"] == "accept": return "human_merge" if result["verdict"] == "escalate": state["escalation_reason"] = "review requires human judgment" return "human_merge" if state.get("repair_count", 0) >= state["max_repairs"]: state["escalation_reason"] = "automatic repair budget exhausted" return "human_merge" return "repair" graph = StateGraph(CodingState) graph.add_node("plan", plan) graph.add_node("implement", implement) graph.add_node("test", run_tests) graph.add_node("review", review) graph.add_node("repair", repair) graph.add_node("human_merge", human_merge) graph.add_edge(START, "plan") graph.add_edge("plan", "implement") graph.add_edge("implement", "test") graph.add_edge("test", "review") graph.add_conditional_edges( "review", after_review, {"repair": "repair", "human_merge": "human_merge"}, ) graph.add_edge("repair", "implement") graph.add_edge("human_merge", END) return graph.compile() ``` 这段代码有一个值得指出的细节:示例里 `after_review` 为了简洁直接更新了 `state` 中的 `escalation_reason`。在生产代码里,更推荐让节点显式返回状态更新,再由 `Command` 或后续节点完成路由,避免在路由函数中产生隐式副作用。这里的重点是控制策略,而不是某一行 API 写法。 也要看到 `ensure_changed_files_are_allowed` 的局限。它暂时校验的是 Agent 返回的文件列表,真实系统必须通过 `git diff --name-only` 或受控文件系统事件获取事实,而不是信任模型的自报。**模型说“我只改了测试”,和系统证明“它只改了测试”,是两件不同的事。** ## 五、路由规则:测试失败不等于立即修,测试通过也不等于立即合并 上面这条图最容易被写错的地方,是把路由简化为:测试通过就结束,测试失败就回到实现。这样确实能跑,但会埋下很多现实问题。 测试失败可能来自至少四类原因:实现错误、测试环境错误、测试本身过期、任务契约缺失。只有第一类适合自动修复。第二类需要环境恢复或重试;第三类需要测试所有者确认;第四类应直接升级需求澄清。如果所有失败都回灌给编码 Agent,它为了让绿灯亮起来,可能会修改测试、跳过断言、扩大改动范围,最终把“验证失败”变成“验证失效”。 同样,测试通过也有至少三种含义:实现正确;测试覆盖不够;Agent 改错了地方但没有触发已有测试。Review 节点存在的意义,就是把“绿灯”放回任务契约和变更范围中解释。 因此,建议把审阅结果限制为三个结构化判定: - `accept`:测试证据与任务验收一致,未发现需要人裁决的风险。 - `repair`:存在具体、局部、可自动修复的问题,反馈应指向文件、失败命令或需求条目。 - `escalate`:涉及需求冲突、权限边界、架构取舍、生产风险或重复失败,需要人类决定。 任何不属于这三类的长篇评语,都不应该直接驱动控制流。它可以附在证据里供人阅读,但不能让系统进入“模型说再优化一下”的无穷循环。 下面是一条更完整的失败处理路径。它的核心不是多重重试,而是先确定“失败属于谁”。 ![测试失败后,先分类,再决定重试或升级.png](https://developer.qcloudimg.com/http-save/yehe-7660620/6eb78eeb5df4aadc7641ce6fa27ea698.png) 这也是 `max 2` 的真正价值。它不是宣称两次一定能修好,而是把系统从“持续尝试”变成“有限探索”。两次失败通常已经足够暴露:要么当前实现策略不对,要么测试/环境/需求需要人类介入。继续增加轮数,只会提高成本并让变更范围不断漂移。 ## 六、把 Adapter 接到真实模型前,先把三个护栏建好 上面的 `CodingAdapter` 看起来留了一个“待实现”接口。很多人会迫不及待地把它接上最强的编码模型。顺序应该反过来:先让护栏生效,再让模型获得写能力。 第一个护栏是**worktree 与路径校验**。每个 Task 在独立 worktree 中运行;实现完成后,系统从 Git 读取真实 diff,拒绝所有超出 `allowed_paths` 的文件。对于公共配置、锁文件、数据库迁移和 CI 脚本,默认不允许子 Agent 改,除非 Task 契约显式扩大范围。 第二个护栏是**命令与环境白名单**。测试节点不能接收模型拼出的任意 shell 字符串。命令应从任务模板或已审批配置中选择,使用参数化执行,并在超时、CPU、内存、网络和凭证上施加约束。`pytest -q tests/auth` 与 `rm -rf /` 都是 shell 文本,风险不在模型会不会说谎,而在执行器有没有把文本当权限。 第三个护栏是**证据不可被实现者覆盖**。测试输出、扫描报告、Git diff、审批日志应该由执行器写入专门存储,编码 Agent 只能读取摘要,不能伪造或覆盖原始记录。否则一个 Agent 既写代码又写“测试通过”的报告,闭环仍然只是自我认证。 在这三个护栏之外,才是模型的提示词工程:给规划 Agent 足够的目标与约束,给实现 Agent 失败输出和最小必要上下文,给评审 Agent 一份明确的反例清单。提示词很重要,但它属于能力层;护栏决定的是责任边界。 ## 七、验证状态机,并逐步走向生产 Agent 工作流的测试经常停在“每个节点函数能运行”。这还不够。一个节点都正确,不代表整张图的路由正确。 至少应该测试以下几类行为: - 测试通过且 review 为 `accept` 时,图必须进入 `human_merge`,不能再次调用实现节点。 - 测试失败、review 为 `repair` 且预算剩余时,图只能增加一次 `repair_count` 后回到实现。 - 连续失败达到上限时,图必须生成 `human_handoff`,不能无上限循环。 - review 为 `escalate` 时,即使测试通过,也必须进入人工门禁。 - Agent 声称改动文件在允许范围内、但 Git diff 显示越界时,执行器必须拒绝该任务。 下面是一段伪测试,展示应该验证什么。真实项目中可以为 `CodingAdapter` 提供固定输出的 fake,实现确定性的状态机测试;不需要每次单元测试都调用真实模型。 ```python def test_failed_task_stops_after_two_repairs(fake_adapter, initial_state): fake_adapter.review_result = { "verdict": "repair", "feedback": "test still fails", "evidence": ["pytest exit code 1"], } graph = build_graph(fake_adapter) result = graph.invoke( { **initial_state, "max_repairs": 2, "repair_count": 0, } ) assert result["repair_count"] == 2 assert result["escalation_reason"] == "automatic repair budget exhausted" assert "test_exit_code=1" in result["human_handoff"] ``` 这类测试非常朴素,却比“让模型跑十次看看会不会成功”更接近工程质量。模型输出有随机性,状态机规则不应该有随机性。把两者分开,你才能在升级模型、修改提示词或增加角色时,知道究竟是能力变化还是控制逻辑被破坏。 ### 增加能力时,别破坏收敛性 有了最小图之后,下一步自然会想加更多节点:代码库探索、需求澄清、风险分析、SAST、性能基准、多个实现候选、自动开 Pull Request、异步回调。它们都可以加入,但有一条底线:**每增加一个节点,都要重新回答它如何影响状态、权限、验证和终止条件。** 例如加入并行探索节点时,不要让三个 Agent 各自写代码。更合理的是让它们并行生成只读的调用链、风险清单和测试建议,然后由规划节点把这些证据收敛为一个有所有权的实现任务。 加入自动开 PR 时,不要把它接在 `review -> accept` 后面就自动执行。开 PR 是外部副作用,应先检查分支保护、提交信息、验证报告、代码所有者和变更窗口,再签发一次性的受限凭证。 加入长期运行时,不要只把状态塞进内存。Task 应有持久化 ID,原始测试工件应可检索,外部动作需要幂等键,恢复后的图必须知道哪些节点已完成、哪些操作不能重放。 加入更多模型时,也不要把“不同模型互评”误认为独立验证。模型多样性可以降低部分相关性偏差,但编译、测试、静态分析和人工业务确认仍然是更可靠的锚点。 LangGraph 的图模型能让这些扩展以节点与边的形式逐步显式化。但框架不替你决定哪些动作危险、哪些测试足够、什么时候值得打断自动化。那些仍然是你的工程与业务责任。 ## 结语:最好的自我纠错,不是循环更久,而是更早拿到证据、更早知道该停 一个会写代码的 Agent 很容易做出来;一个能在失败后自我修复的 Agent 也并不遥远。真正困难的是让它在修复过程中不越界、不掩盖失败、不无限消耗资源,并能在不确定性超过边界时把任务清楚地交回人类。 这正是这条 LangGraph 闭环的价值: ```text plan -> implement -> test -> review -> repair(max 2) -> human merge ``` 它把模型放在最擅长的位置:规划、生成、解释和提出修复。它把代码放在最该掌控的位置:状态更新、命令执行、权限校验、预算收敛和流程路由。它把人放在真正需要负责的位置:合并、风险取舍、需求裁决和高影响动作。 请记住最后这句话: > **不要让模型决定“我已经修好了”;让测试和任务契约决定它能否继续,让明确的门禁决定它能否交付。** 当这条原则被写进图里,多 Agent 的“自我纠错”才不再是一段漂亮的提示词,而成为一条可以被测试、审计和持续改进的工程流水线。 ### 参考资料 - [LangGraph:Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents) - [LangGraph:Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api) - [LangGraph:Thinking in LangGraph](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph) - [GitHub Docs:About custom agents in Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/copilot-cli/about-custom-agents)

下一篇
举报
领券