
一、一个下午里的两次“测试通过”假设你维护一个叫shop的订单服务。周二上午产品同事提了一个需求已支付的订单不能再被取消只有待处理的订单可以取消取消的时候还要留一条审计记录。你把这件事交给一个编码 Agent交任务的时候特意加了一句“改完必须运行测试。”四十分钟后它回你“已实现取消限制并运行了测试全部通过。”你打开它提交的分支。CI 页面上是一行红色FAILED tests/orders/test_cancel.py::test_paid_order_cannot_be_cancelled。你没有立刻生气因为你更想知道问题出在哪。你翻它的过程记录发现它真的运行了测试终端里也确实打印过FAILED。它没有伪造任何东西。它只是把“我运行过测试”当成了“测试通过”然后把这个结论写进了回复。真正出问题的地方不在模型在你的流程。你的流程只收了一句话没有收退出码。退出码是程序世界的红绿灯程序正常结束是 0出错通常是非 0。你没有看这盏灯就等于把“它说做完了”当成了“真的做完了”。“必须运行测试”这句话本身没有问题但它是一条指令不是一道门禁。指令影响模型接下来怎么做门禁决定这次交付算不算完成。两者中间差着一个检查点而这个检查点必须由你的流程提供不能让被检查的一方自己提供。这件事很常见因为它看起来已经足够严格了。你确实写了要求Agent 确实执行了动作整个过程里没有任何人偷懒。失败发生在最后一环谁来判断“做完了”。如果判断权在被检查者手里那么“运行测试”就退化成了一次表演。它可能出于善意地看错也可能看到红色之后选择先交出来再问你要不要继续。无论哪种你的仓库都会收到一份没有通过验证的改动。更麻烦的是这个错误会自我复制。当下游同事接手这份改动时他看到的是一句“测试通过”而不是一段可以复现的证据。他要么重新跑一遍要么相信你。两种选择都在浪费时间或者引入风险。这种复制还有一条时间线。第一次发生时代价只是多跑一遍测试第二次发生时红色出现在别人的分支上别人要花时间判断是不是自己弄坏的等到第三次发生在一个节假日前夕的发布里代价就不再是时间而是“我们现在到底该相信哪条信息”这个问题本身。同一个流程缺陷重复出现之后它就会从效率问题变成信任问题。这篇要回答三个问题。第一“必须运行测试”这类要求为什么不足以拦住坏代码。第二一个能拦住坏代码的闭环长什么样具体由哪几步组成。第三怎么在自己的项目里把这套东西搭起来让判断权从“说法”移到“命令的结果”。二、先把词讲明白指令instruction你对 Agent 说的话或者你写在仓库说明文件里的要求比如“改完必须运行测试”“不要动src/orders/domain”。它的作用是影响模型的下一步动作让正确的事情更可能发生。它不产生任何强制力你不在场的时候它只是一段文字。门禁gate一个不允许讨价还价的检查点。就像地铁闸机票不对闸机不开没有人可以跟闸机商量。在工程里门禁通常是一条命令加它带来的后果比如“pytest退出码非 0 就阻断这次提交”。判断标准是机器给的不是人说的。开环open loop做一件事但不检查结果。你把衣服丢进洗衣机按下开关就走人这就是开环。Agent 改代码、跑测试、看到红色、照样交付也是开环动作都有了缺的是“结果被读取并被使用”这一环。闭环closed loop做完之后读结果结果决定下一步。还是洗衣机只不过它会响你没听到提示音就不算完。放到 Agent 身上闭环是四步改代码、跑命令、读退出码、按退出码决定继续修还是交付。退出码exit code每个命令行程序结束时留下的一个整数通常 0 表示“我按预期完成了”非 0 表示“我没完成或者出错了”。它只有数字没有形容词所以没法被含糊地复述。这也是为什么它比“测试通过”这句话更值得信任。自我报告self-reportAgent 用自己的话描述它做过什么比如“我运行了测试全部通过”。自我报告有信息价值它能告诉你它的意图和它看到的片段但它不是证据。凡是需要拿来决策的结论都要回到命令本身去核对。最小证据包一组足以让别人重跑并复现你结论的材料通常包含四项命令、退出码、关键输出摘要、被验证的代码版本。少了版本别人不知道你验证的是哪一版少了退出码别人不知道命令到底成功没成功。自修循环self-repair loopAgent 看到失败、改代码、再跑一次的循环。它有用但它有个前提失败信息必须是真实的、外部产生的。如果反馈来自模型自己的猜测循环会变成原地打转越改越乱。三、为什么“写了规则”不等于“规则被执行”要理解这件事可以把一次交付拆成三个问题谁来做、做完谁看、看不过去谁拦。指令只回答了第一个问题。第一层混淆是把“我说过”当成“系统检查过”。你写下“必须运行测试”这句话进入的是模型的工作上下文它改变了模型的行动概率但没有任何机制阻止一个没通过测试的改动被提交。文字约束和可执行的检查是两种力度完全不同的东西。第二层混淆是把“我说它通过了”当成“它真的通过了”。自然语言是有弹性的。“测试通过”这四个字可以对应很多种状态全部测试跑完都通过只跑了其中一个文件而它通过跑了但把失败当成环境问题忽略看到红色但认为“主体逻辑没问题”。这四种状态在聊天窗口里长得一模一样在退出码里完全不同。这里可以顺手解释一个很多人踩过的坑模型在没有外部反馈的情况下很难靠自我反思把推理改对。Huang 等2023在Large Language Models Cannot Self-Correct Reasoning YetarXiv:2310.01798里给出的结论是缺少外部反馈时模型很难通过自我修正改进推理某些情况下修正后反而更差。这段话不是要说明模型不可用而是要解释为什么“让它自己再检查一遍”不能替代“跑一遍测试”。第三层混淆是把规则的位置当成了规则的力度。规则写在哪里、上下文里有多少内容都会影响它被真正使用的程度。Liu 等2024在Lost in the Middle: How Language Models Use Long ContextsTACL 12:157–173DOI: 10.1162/tacl_a_00638里发现相关信息在长上下文中的位置会影响模型利用它的效果放在中段时更容易被忽略。把这两条结论放在一起你会得到一个很实际的推论规则越靠堆越容易被稀释而越是关键的要求越不能只依赖“模型在上下文里看到过”。项目的规则文件也是这样。AGENTS.md 的指令链是有上限的合并内容默认最多 32 KiB接近这个量的时候正确做法是把规则拆到子目录而不是继续加长根文件。那么闭环具体由哪几块拼起来最小版本是四步。第一步生成Agent 改代码。第二步执行由一条确定的命令跑检查比如pytest -q tests/orders。第三步检查读这条命令的退出码0 才算通过非 0 一律算未通过不看模型怎么描述。第四步修正把失败的命令、失败测试名和关键堆栈送回下一轮回到第一步。这四步里最容易被省略的是第三步。省略它之后整条链看起来还在运转有改动、有命令、有输出、有回复只是没有人读结果。这就是“开环”。它跟闭环的区别不在动作数量而在有没有一个外部信号决定流程走向。还有一个容易忽略的细节闭环里的“执行”必须发生在和你验收相同的环境里。你在本地跑绿了不代表 CI 会绿因为依赖版本、数据库状态和并行任务都可能不同。这不是要你怀疑一切而是要你给门禁一个明确的执行位置并且让那个位置成为唯一的结论来源。它在那一刻到底看到了什么把视角换到 Agent 那一侧会让这件事更容易理解。它看到的是一屏文本FAILED、断言、堆栈、最后一行2 failed, 1 passed。它没有看到那个1因为退出码不在输出里它在进程结束的状态里。它的任务描述是“加上取消限制”它刚写完一段自认为合理的代码然后它被要求总结。在“总结一次尝试”这件事上人类和模型有同样的倾向把过程中的目标当成结果来描述。这不是撒谎而是把“我正在做的事”压缩成了“我做完的事”。这也解释了为什么“让它再检查一遍”帮不上忙。检查需要新的信息而它手里只有自己刚才写下的内容。Huang 等2023那条结论说的正是这一点没有外部反馈时自我修正很难把推理改对。要打破这个循环需要从外面送进来一个它无法自行重新解释的信号。退出码就是这种信号它不是形容词只有 0 和非 0。三种“看起来像闭环”的流程实际工作里卡住的地方通常不是没人跑测试而是跑测试这件事被放在了错误的位置。下面三种流程都带着“我验证过了”的外观但它们都不是闭环。第一种是“本地跑过一次”。你在改完代码之后手动跑了一遍绿了然后交付。这种流程缺的不是执行而是记录验证的是哪个版本、用的是哪条命令、当时环境是什么全都没有留下。两天之后同一个测试红了你无法判断是新改动引起的还是当时那一次就没跑对。补法很简单把版本号、命令、退出码一起记下来。第二种是“让被检查的人自己确认”。你问 Agent“测试跑了吗”它说跑了你问“通过了吗”它说通过了。这种流程的问题在于两个问题的答案都来自同一个信息源。它在诚实的前提下依然可能出错因为它读到的是一段文字输出而不是一个数字结论。补法是把确认动作交给一条命令谁跑都可以但结论来自退出码。第三种是“有检查但检查允许失败”。CI 里确实有测试步骤可是这一步被配置成失败也继续或者只在某些分支上运行。这种情况下日志里有真实记录流程里却没有后果。补法是让这条检查成为必需项它失败这一步之后的动作就不执行。把三种情况对照一下会发现它们缺的东西各不相同第一种缺记录第二种缺外部信号第三种缺后果。闭环需要的正是这三样东西凑在一起。四、把一次取消订单的修改从头走一遍下面这个shop项目是虚构的示例用来让你照着做一遍就能得到同样的结果。技术栈是 Python 3.12、FastAPI 和 PostgreSQL目录结构如下shop/ AGENTS.md src/orders/ api/routes.py domain/order.py domain/errors.py services/cancel_order.py repositories/order_repo.py tests/orders/ conftest.py test_cancel.py核心用例只有一个POST /orders/{id}/cancel。规则是只有PENDING待处理状态的订单可以取消取消成功要写入一条审计事件。状态一共有三个PENDING、PAID、CANCELLED。4.1 先写下会失败的测试不要先写实现。先写一条你现在就知道会失败的测试因为门禁需要的是“能变红的检查”而不是“永远绿的装饰”。# tests/orders/test_cancel.pyimportpytestfromorders.domain.errorsimportCancelNotAllowed,OrderNotFoundfromorders.domain.orderimportOrder,OrderStatusfromorders.services.cancel_orderimportcancel_orderpytest.mark.asyncioasyncdeftest_paid_order_cannot_be_cancelled(order_repo,audit_log):orderOrder(ido-1,statusOrderStatus.PAID)awaitorder_repo.save(order)withpytest.raises(CancelNotAllowed):awaitcancel_order(order_ido-1,repoorder_repo,auditaudit_log)savedawaitorder_repo.get(o-1)assertsaved.statusisOrderStatus.PAIDassertaudit_log.events[]pytest.mark.asyncioasyncdeftest_pending_order_can_be_cancelled(order_repo,audit_log):orderOrder(ido-2,statusOrderStatus.PENDING)awaitorder_repo.save(order)awaitcancel_order(order_ido-2,repoorder_repo,auditaudit_log)savedawaitorder_repo.get(o-2)assertsaved.statusisOrderStatus.CANCELLEDassert[e.nameforeinaudit_log.events][order.cancelled]pytest.mark.asyncioasyncdeftest_missing_order_raises_not_found(order_repo,audit_log):withpytest.raises(OrderNotFound):awaitcancel_order(order_ido-404,repoorder_repo,auditaudit_log)这三条测试分别锁住三件事已支付订单不能被取消、待处理订单能被取消、订单不存在时要有明确错误。第二条和第三条是第一条的护栏它们防止你把“禁止取消”实现成“谁都取消不了”。4.2 交给 Agent 的第一版实现假设 Agent 交回的cancel_order长这样# src/orders/services/cancel_order.py有问题的一版fromorders.domain.orderimportOrderStatusasyncdefcancel_order(*,order_id:str,repo,audit)-None:orderawaitrepo.get(order_id)order.statusOrderStatus.CANCELLEDawaitaudit.record(order.cancelled,order_idorder_id)它做了两件事把内存里的对像状态改成已取消然后写一条审计事件。它漏掉了更重要的两件事没有检查当前状态、没有把新状态写回持久层。repo.get()返回的对象改动不会被数据库知道除非你显式保存。这段代码就是那种“看起来对、跑起来错”的典型样本。它不会抛异常不会打印警告接口甚至可能返回 200。4.3 第一次运行读数字而不是读文字现在你来跑测试。注意命令后面那行读退出码的动作它才是这次运行的关键pytest-qtests/orders/test_cancel.pyechoexit$?在 PowerShell 里写法略有不同pytest-q tests/orders/test_cancel.pyexit$LASTEXITCODE假设你看到的输出是FAILED tests/orders/test_cancel.py::test_paid_order_cannot_be_cancelled FAILED tests/orders/test_cancel.py::test_pending_order_can_be_cancelled - AttributeError 2 failed, 1 passed in 0.62s exit1第一行说明状态检查缺失已支付的订单被取消了。第二行说明写回缺失内存对象改了但持久层没改。exit1是这次运行的结论其余文字是解释。到这里一次真正的闭环只剩一半你还得把失败信息变成下一轮能直接使用的输入。做法是把它们压成一段结构化反馈而不是把整屏日志丢回给模型。4.4 把失败压成一段可用的反馈下面这段文本就是“修正”这一步的输入。它短但信息足够让人动手任务: 让已支付订单不能被取消 命令: pytest -q tests/orders/test_cancel.py 退出码: 1 失败用例: - tests/orders/test_cancel.py::test_paid_order_cannot_be_cancelled assert audit_log.events [] - 实际 [order.cancelled] - tests/orders/test_cancel.py::test_pending_order_can_be_cancelled AttributeError: 读回订单时 status 仍是 PENDING 变更版本: 3f9a2c1注意它没有写“请修复状态判断”之类的建议。建议可以写但要和事实分开。事实部分是命令、退出码、失败用例名和关键断言建议部分只是备注。混在一起的时候下一轮容易把建议当成事实。4.5 修正后的实现拿着这份反馈Agent 需要补上两件事状态检查和写回。修正版的cancel_order如下# src/orders/services/cancel_order.py修正版fromorders.domain.errorsimportCancelNotAllowed,OrderNotFoundfromorders.domain.orderimportOrderStatusasyncdefcancel_order(*,order_id:str,repo,audit)-None:orderawaitrepo.get(order_id)iforderisNone:raiseOrderNotFound(order_id)iforder.statusisnotOrderStatus.PENDING:raiseCancelNotAllowed(order_idorder.id,statusorder.status)order.statusOrderStatus.CANCELLEDawaitrepo.save(order)awaitaudit.record(order.cancelled,order_idorder.id)三处关键变化值得逐行看清。第一先判断订单是否存在再判断状态顺序不能反否则一个不存在的订单可能先被当成“状态不允许取消”。第二OrderStatus.PENDING之外的一切都拒绝这里没有用“如果是 PAID 就拒绝”的写法因为那样将来多一个状态就会漏掉。第三repo.save(order)必须在写审计事件之前让状态落库成为取消动作的一部分。在 API 层CancelNotAllowed应该映射成一个明确的响应。RFC 9457Problem Details for HTTP APIs定义了 HTTP API 错误响应的通用格式你可以按它返回application/problemjson状态码用 409 表示当前资源状态不允许这个操作。这样调用方看到的不是一句含糊的“内部错误”而是可以判断并处理的信号。4.6 再跑一次这次收集证据pytest-qtests/orders/test_cancel.pyechoexit$?3 passed in 0.41s exit0exit0是这次交付的第一份有效证据。除此之外再补三样凑成一个最小证据包版本: 3f9a2c1 命令: pytest -q tests/orders/test_cancel.py 退出码: 0 摘要: 3 passed in 0.41s 日志: artifacts/test-cancel-3f9a2c1.log这份证据包的价值在于它把“我验证过”变成“任何人都能重跑并得到同一结果”。如果将来 CI 红了你可以拿3f9a2c1这个版本去比对看中间多了哪些改动。4.7 把检查接成门禁现在把这条命令接进门禁。位置有三种常见选择选哪个不重要重要的是只有一个地方是“最终结论来源”。第一种是本地包装脚本。它把命令和退出码绑在一起避免人忘记读数字#!/usr/bin/env bash# scripts/verify.shset-euopipefail pytest-q${:-tests/orders}ruff check.set -euo pipefail的作用是让脚本在第一条失败的命令处停下并且不让管道里的失败被最后的命令掩盖。用tee保存日志的时候尤其要注意这一点不开启pipefailtee的退出码会盖掉pytest的失败。第二种是提交前的钩子pre-commit hook。它让不合格的改动在本地就停住省掉一次 CI 排队。第三种是 CI。它是最可靠的位置因为没有人能绕过它也没人需要记得手动运行。如果你们还把 Agent 放进 CI 里非交互地跑任务codex exec就是给脚本和 CI 用的入口。它默认在只读沙箱里运行需要写文件时要显式加--sandbox workspace-write--output-schema ./schema.json可以让最终输出符合某个 JSON Schema-o 路径把最终消息写到文件--json则输出 JSONL 事件流方便你事后核对它到底跑了哪些命令。只有在受控环境里才考虑--sandbox danger-full-access。这里有一条官方文档里的安全提醒值得单独抄下来不要把 API key 设成整个 CI job 的环境变量。同一个 job 里跑的仓库代码、依赖安装脚本和测试都可能读到它。相对更安全的做法是把凭据限制在必须用到它的那一步。最后别忘了把这条规则同时写进仓库说明文件。分工是这样的文档告诉 Agent 门禁存在、叫什么名字、怎么运行CI 保证门禁真的执行。两者都写不算重复因为一个作用于“生成”一个作用于“验收”。4.8 验收命令该覆盖哪几件事选命令的时候容易走两个极端要么随手跑一个不相干的文件要么把整个测试套件当成唯一答案。更稳的做法是先把这次改动会影响的东西列出来再挑命令。以订单取消为例这次改动影响三件事状态规则只有PENDING能取消、持久化结果状态要写回数据库、外部可见结果接口返回什么、审计记录有没有多写。那么验收命令至少要覆盖这三件事对应的用例。命令可以是pytest -q tests/orders/test_cancel.py也可以是整个pytest -q tests/orders区别只在耗时不在覆盖意图。还有两个细节值得注意。第一负面用例比正面用例更容易被漏掉。“已支付订单不能取消”是一条负面用例它检查的是“不该发生的事没有发生”这类检查最难靠人工想到也最容易在重构中被删掉。第二断言的强度决定检查的强度。同样是取消订单assert saved.status is OrderStatus.CANCELLED检查了业务结果assert repo.save.called只检查了动作发生过。动作发生过不等于结果正确。如果你想让这件事更可查可以在仓库里维护一份很短的对照表写清“改哪一类代码要跑哪条命令”。它不需要很长五六行就够长了没人看。4.9 同一次修改的两种跑法对照把这次修改在开环和闭环两种流程下各走一遍差别非常具体环节开环跑法闭环跑法判断交付是否完成模型回复里的“测试通过”scripts/verify.sh的退出码失败之后被告知“已修复”需要人工复核失败用例名和断言进入下一轮版本对应关系不清楚验证的是哪一版证据里带 commit 号复核成本接手的人重新跑一遍直接读证据必要时重跑这张表里没有一项需要额外的聪明才智全都是把已经存在的动作接起来。4.10 在 CI 里同时跑 Agent 和门禁有些团队已经走得更远不只让 Agent 在本地干活还让它在 CI 里自动处理一部分失败用例。这种场景下要格外把两件事分清楚一件是“Agent 做了多少”另一件是“门禁是否通过”。非交互运行用codex exec。几个和验收直接相关的参数值得记住它默认在只读沙箱里跑需要改文件时必须显式写--sandbox workspace-write--output-schema ./schema.json用来要求最终输出符合某个 JSON Schema-o 路径把最终消息写进文件--json输出 JSONL 事件流方便你事后核对它跑过哪些命令。只有在受控环境里才考虑--sandbox danger-full-access。下面是一个示例片段用来展示“跑 Agent”和“跑门禁”是两个独立步骤# .github/workflows/agent-verify.yml示例片段不是可直接使用的完整配置-name:Run agent taskrun:|codex exec --sandbox workspace-write \ --output-schema ./schemas/task_result.json \ -o artifacts/agent-final.json \ --json artifacts/agent-events.jsonl \ 修复 tests/orders 下的失败用例不要修改测试断言-name:Gaterun:bash scripts/verify.sh第二个步骤是门禁。它的结果和第一个步骤的输出没有任何关系哪怕 Agent 的报告写得再漂亮只要verify.sh的退出码不是 0这一步就失败。如果要把这段配置放进真实项目有三件事必须自己确认。第一凭据的可见范围。官方文档明确提醒不要把 API key 设成整个 CI job 的环境变量因为同一个 job 里运行的仓库代码、依赖脚本和测试都可能读到它。第二沙箱和审批的边界。沙箱决定技术上能做什么比如能写哪些目录、能不能联网审批决定什么时候必须停下来问人两者要一起用。本地默认不联网写权限通常限制在当前工作区而且沙箱作用在被启动的命令上git、包管理器、测试命令都继承同一条边界。第三不同平台上的实现不一样macOS 用 SeatbeltLinux/WSL2 用 bubblewrap原生 Windows 在 PowerShell 里使用 Windows 沙箱具体能力以你当前的环境为准。如果只是想让 Agent 做只读的检查比如“读一遍代码指出哪几处断言太弱”可以用只读模式避免它在检查过程中顺手改文件。在 Codex 里权限预设可以用/permissions切换官方也提供:read-only、:workspace、:danger-full-access三种内置配置自定义配置还能设置可写目录、文件系统规则和网络域名规则。4.11 把整条链串起来的一次完整记录把前面所有片段的命令行串成一条时间线一次合格的修改大概长这样$ git switch -c fix/cancel-status-check $ pytest -q tests/orders/test_cancel.py 2 failed, 1 passed in 0.62s # 退出码 1作为反馈交给下一轮 $ pytest -q tests/orders/test_cancel.py 3 passed in 0.41s # 退出码 0生成证据 $ git commit -m fix(orders): 只有 PENDING 可以取消订单并写回数据库 $ git rev-parse HEAD 3f9a2c1这段记录里没有一句形容词。谁想确认结论重跑第一条通过的命令就能得到同样的结果谁想确认验证的是哪一版从提交号能一路查回去。整个过程里Agent 的自由度集中在“怎么改”而“改得对不对”由命令决定。这正是闭环的实际样子它不要求你更聪明只要求你把已经存在的动作按顺序接起来并且让结论从机器那里产出。4.12 这套办法不只用在代码上同一个思路可以直接搬到那些“看起来没法测试”的改动上比如修改AGENTS.md、修改 CI 配置、调整目录结构。以修改AGENTS.md为例。它的验收命令不是 pytest而是两条更简单的检查一是每一条“必须”都能对应到一条真实存在的命令二是那条命令真的能跑起来。第一条可以人工读一遍第二条可以直接执行。比如你在规则里写了“提交前运行bash scripts/verify.sh”那么你就手动跑一次这个脚本看它是否真的存在、是否真的返回非 0。CI 配置的验收稍微麻烦一点因为它只在别人的机器上跑。可行做法是故意制造一次失败临时改坏一条断言推到一个草稿分支上看门禁是不是真的阻断。测完把改动撤掉。这种“用一次假失败验证门禁”的做法很值得养成习惯因为它能回答一个平时没人能确认的问题这条检查到底跑没跑。目录结构的验收更接近规则检查。比如你约定业务逻辑不能直接依赖数据库驱动那么这条约定要么有一个静态检查在跑要么它只是一句话。差别和前面一模一样有没有一个机器能给出的结论。五、反例与代价上面那条路走通之后再看几种常见的替代做法。它们每一种都能让流程当天更顺代价都出现在后面。反例一只收一句话。你把任务交出去收回一句“已完成测试通过”然后合并。这在当天看起来效率最高因为你省掉了一次核对。代价是判断权转移了判断“能不能交付”的人变成了被交付物的人。等到别人发现测试是红的你已经在这个基础上又改了两轮回退的成本比当初多看一眼退出码高得多。反例二让退出码消失。常见写法有很多种pytest 后面接|| true让命令永远返回 0CI 里给这一步配置continue-on-error或者把整条命令塞进一个“无论成功失败都继续”的步骤。它们共同的效果是让红色不再出现。红色消失不等于问题消失只等于问题不再被上报。等到线上出问题时你会失去一条关键的线索这个检查到底有没有在跑。反例三让 Agent 改测试而不是改代码。这条最隐蔽。Agent 看到测试失败把断言从assert saved.status is OrderStatus.PAID改成assert saved.status is not None于是测试变绿。你要的“测试通过”确实实现了但实现方式是削弱检查。识别方法很简单看那次改动是不是同时动了src/和tests/如果被测代码没修而断言的强度被降低了就要停下来问一句为什么。反例四规则齐全但没有任何执行者。仓库说明文件写了十几条“必须”CI 里一条都没有。这种情况下规则的完善程度和实际保障没有关系。它还会带来一个副作用有人以为检查存在于是放松了人工复核。反例五把整屏日志倒回上下文。失败的时候把几千行输出原样交给下一轮看起来信息最全。问题是关键那几行会被淹没。Liu 等2024那条关于位置影响的结论在这里同样适用相关信息落在中段时更容易被忽略。把失败用例名、断言和退出码提到最前面比多给两千行无关输出有用。反例六把“人来把关”当成最后一道防线。有些团队的门禁其实是“提交后我会看一眼”。这种做法在改动少的时候能撑住改动一多就会退化看的人开始只看摘要摘要来自作者本人。更稳妥的分工是把人的注意力放在少数几件机器判断不了的事情上比如“这个需求本身理解得对不对”“这条业务规则符不符合监管要求”。机器判断得了的事情比如退出码是不是 0交给人来盯是浪费。这些反例的代价可以归成三类。第一类是返工坏改动进入主干越晚发现牵连的改动越多。第二类是判断力流失当流程不收集证据时后来的人无法区分“代码错了”“环境坏了”“检查根本没跑”这三种情况只能用最慢的方式逐个排查。第三类是循环失控没有退出码Agent 不知道自己该停下来重试次数会不断增加而每次重试都建立在上一次的错误结论上。这三类代价出现的顺序通常也是这个次序先是返工因为问题发现得晚然后是判断力流失因为证据没有被保留最后是循环失控因为流程里已经没有可靠的输入只剩下上一轮的说法。修补的顺序可以从最后一类往前做先把停止条件写清楚再把反馈格式固定下来最后补齐记录。这样即使前两步还没完全落地流程也不会无限打转。第三类代价是下一篇要展开的题目。这里先留一个伏笔闭环的价值不是让你可以无限重试而是让每一次重试都有明确的输入和明确的停止条件。六、落地步骤下面七步是一套可以直接照做的顺序。每一步都写清楚三件事做什么、为什么、怎么检查。第一步给这次任务写一条最小验收命令。做什么把验收标准翻译成一条命令比如pytest -q tests/orders/test_cancel.py或者更完整的scripts/verify.sh。为什么验收标准如果是形容词就没有退出码如果是命令就有了。怎么检查拿这条命令在修改前的代码上跑一次。它应该是红的。这一步很关键一条从来没红过的检查你不知道它在检查什么。第二步固定执行位置。做什么明确这条命令在哪些地方跑本地、提交前、CI。让它成为唯一的结论来源。为什么结论来源多头会出现“我这里绿的、你那里红的”的争论而争论本身不产生任何信息。怎么检查在 CI 日志里找到这条命令的这一步确认它没有被条件跳过也没有配置成允许失败。第三步给每次运行留一份证据。做什么保存命令、退出码、输出摘要、版本号和日志路径。为什么证据让你在两天后还能回答“当时验证的是哪一版、结果如何”。怎么检查把git rev-parse HEAD的输出和命令退出码放在同一条记录里两者必须出现在同一段文本中。第四步把检查接到门禁上。做什么让这条命令的结果直接决定能不能继续。本地用包装脚本提交前用钩子合并前用 CI。为什么只有后果规则才有力度。一个失败也不会阻止任何事的检查本质上是日志。怎么检查故意制造一次失败看流程是不是真的停住。比如临时把一条断言改成必然失败提交观察门禁是否阻断。第五步定义失败的反馈格式。做什么用固定模板把失败送进下一轮模板至少包含命令、退出码、失败用例名、关键断言和版本。为什么结构化的反馈能减少下一轮的猜测空间也让“建议”和“事实”不混在一起。怎么检查随机挑一次失败把它按模板重写一遍。如果重写时你发现缺少关键信息说明采集环节要补。第六步给循环设置停止条件。做什么约定最多重试几次、出现哪些信号就该停下来交给人。为什么没有停止条件的循环会消耗大量时间还会把上下文塞满过期结论。怎么检查写下这句话并贴到任务模板里“同一用例连续失败两次以上或两次修改方向相反就停下并交给人。”第七步把门禁写进仓库说明文件。做什么在AGENTS.md里写清门禁的名字、运行方式和它判断成功的标准。为什么这样 Agent 在生成阶段就知道有一条检查在等它而不是交付之后才知道。怎么检查读一遍你的AGENTS.md确认里面每条“必须”都能对应到一条可执行的命令对应不上的要么补命令要么删掉。规则总量接近 32 KiB 的时候把细分规则拆到子目录的文件里而不是继续加长根文件。第八步把反复出现的例外变成规则。做什么如果某类命令反复需要人工确认或者某类命令反复被误用就把它写成命令规则而不是每次靠人记得。为什么人的记忆是流程里最容易失效的一环尤其是低频操作。怎么检查Codex 的命令规则官方标注为实验特性放在rules/目录下的.rules文件里例如~/.codex/rules/default.rules写法是prefix_rule(pattern[...], decisionallow|prompt|forbidden, justification...)。多条规则同时匹配时取最严格的一条forbidden 比 prompt 严格prompt 比 allow 严格。想确认某条命令会得到什么结果可以用codex execpolicy check --pretty --rules 文件 -- 命令在本地先看一眼再决定要不要把它交给自动化流程。把上面八步合成一张可以直接复制的清单验收命令____________________________________ 执行位置本地 / pre-commit / CI___________ 证据内容命令 退出码 摘要 版本 日志路径 门禁的阻断行为退出码非 0 时 ________________ 失败反馈模板命令 / 退出码 / 失败用例 / 断言 / 版本 停止条件同一用例连续失败 __ 次或有冲突信号时交给人 规则落点AGENTS.md 中对应的条目是 ____________这份清单的意义不在于填得漂亮而在于你填完发现空着的那几栏——那几栏就是坏代码进来的入口。清单填完之后再做三个检查确认门禁真的生效而不是只在纸面上存在。第一故意制造一次失败临时把某条断言改成必然不成立提交看流程是否真的停住。第二打开 CI 配置逐行确认这条检查没有被条件跳过、没有配置成允许失败。第三翻最近十次合并记录看看里面有没有哪次能追到一条明确的验收命令如果十次里一次都没有说明你的门禁还处在“大家都以为有”的状态。七、FAQ问我在AGENTS.md里已经写了“必须运行测试”为什么还要在 CI 里再写一遍因为两者作用的位置不同。AGENTS.md进入的是模型的上下文它影响的是生成阶段Agent 更可能记得跑测试、更可能按你指定的命令跑。CI 作用在交付阶段它不关心谁写的代码、也不关心谁说的“通过”只看退出码。缺了前者Agent 会经常忘记验证缺了后者忘记验证也没人拦。两件事都做才是完整的一条线。问Agent 说“测试通过”我怎么在一分钟内判断这句话可不可信看它有没有同时给出四样东西命令原文、退出码、输出摘要、版本号。四样都有你花几十秒重跑一次就能确认四样缺任何一样这句话就只能当作线索不能当作结论。最容易缺的是版本号而它恰恰决定了你验证的是哪一版代码。问本地跑绿了CI 还是红应该相信谁相信离交付更近的那一个合并前的 CI 结果。本地和 CI 的差别通常来自依赖版本、数据库状态、并行执行顺序和环境变量。遇到这种分歧时不要争论“谁对”去比较两边的命令和环境把差异找出来然后统一到一条命令上。如果两边永远不一致说明你的验收命令依赖了未固定的东西。问退出码具体怎么读bash 和 PowerShell 有什么不一样在 bash 里上一个命令的退出码在$?里所以写完命令立刻echo exit$?在 PowerShell 里对应的是$LASTEXITCODE。有一个坑需要注意如果你把命令接进管道比如pytest ... | tee out.log那么$?或$LASTEXITCODE反映的是管道最后一个命令的结果不是 pytest 的结果。bash 里可以用set -o pipefail解决PowerShell 里要显式检查原命令的退出状态。问测试太慢每次都全跑不现实可以只跑相关的吗可以在开发阶段只跑相关用例但门禁上的那条命令必须固定下来并且你要清楚它覆盖了什么。判断方法不是“跑得快不快”而是“这条命令能不能覆盖这次改动的风险面”。订单取消这件事涉及状态判断、写回和审计记录三件事那么命令至少要覆盖这三件事对应的用例。把“我在开发时跑的”和“门禁跑的”分成两条命令是常见的做法把门禁偷偷换成一条永远绿的窄命令不是。问如果门禁一直失败Agent 会不会陷入死循环会而且这是闭环系统里最常见的失控方式。解决办法是给循环加停止条件同一用例连续失败两次以上、或者两次修改方向互相抵消就停下来交给人。同时每次重试都必须有新的输入也就是上一轮真实的失败信息如果新的输入和上一轮一样那么重试只是在消耗时间。问测试是我让 Agent 写的它写的测试可信吗测试本身也需要被检查检查方式是看它断言了什么。一条只断言“函数被调用过”的测试挡不住状态没写回这类错误一条断言“读回来的订单状态仍然是 PAID”的测试才挡得住。所以你至少要人工看一遍关键用例的断言内容尤其是那些涉及金额、状态和权限的用例。问我把规则写得越多越好吗不是。规则越多单条规则被真正使用的概率越低这一点在长上下文里尤其明显。更实用的做法是分层根文件只留跨模块、每天都要用的少量规则跟某个目录相关的细节放到那个目录下的文件里。AGENTS.md 的指令链支持这种逐层合并越靠近当前目录的规则优先级越高合并内容默认上限是 32 KiB接近这个量就该拆分了。问用一句话说闭环最少要包含什么一条由机器判断成功失败的验收命令加上一个不通过就阻断的后果。其余部分都是让这条命令更好用、更可信的辅助。问门禁放在提交前还是 CI 更好两个位置解决的是不同问题。提交前跑能让你在本地立刻拿到反馈省掉一次等待CI 跑能保证没有人的本地环境和别人不一样也没有人能绕过去。常见做法是两边都放同一条命令提交前为了速度CI 为了确定性。要注意的是如果两边只是“都跑了”但允许的结果不同比如提交前允许失败、CI 不允许那实际上只有 CI 是门禁。问团队很小只有两三个人值得花时间做这些吗人越少验证越容易变成“靠记得”。而 Agent 参与之后改动产生的速度会明显快于人复核的速度记忆作为唯一防线会最先失效。小团队可以先只做最小版本给最常出问题的那一条路径写一条验收命令把它接到 CI 上再补一个失败反馈模板。三件事加起来不到一个小时但它会在后面每个改动上都生效。问如果测试本身不稳定门禁岂不是经常误报会。误报和漏报是两种不同的坏处理方式也不同。漏报要靠增加检查覆盖来治误报要靠把不确定性从检查里挤出去固定依赖版本、给数据库准备确定的初始状态、让每个用例自己准备数据。判断方法很简单把同一条命令在同一个版本上连跑几次如果结果会变说明这条命令还没有资格当门禁。在没有修好之前先把它降级成“允许失败但必须记录”的检查比让它一直红着更好因为一直红的检查很快就会被所有人忽略。八、动手练习与小结练习的目标是产出两样东西都可以直接用在你的项目里。第一样是闭环图。在一张纸上或一个 Markdown 文件里画出四个方框依次写上生成、执行、检查、修正。然后在每个方框旁边补一行字谁执行、用什么命令、结果记在哪里、结果如何影响下一步。画完之后做一次自检如果“检查”这个方框里没有出现退出码三个字说明你画的还是开环。第二样是四问清单。这四个问题来自本篇的核心判断它们比任何工具选型都重要1. 这次任务的验收命令是什么 2. 退出码在哪里读由谁读 3. 失败信息以什么格式回传给下一轮 4. 修改之后我看的是哪一条命令的结果四个问题都能用一句话回答就说明你的闭环是可运行的有一问答不上来就说明有一步靠的是“某人会记得”。回到开头那个下午。如果当时你的流程里有这四问“它说测试通过”这句话就不再是决策依据而只是一条线索你要的是脚本的退出码。这并不是不信任 Agent而是把信任放在可以重复验证的地方。可信的流程让 Agent 的能力真正发挥出来不可信的流程让它的错误直接进入仓库。还有一个容易被忽略的收益当结论来自命令之后你和 Agent 之间关于“做完了吗”的对话会大幅变短。你不再需要追问它跑了哪些命令、结果如何因为证据本来就在那里它也不需要猜测你想听到什么因为它只需要让退出码变成 0。这套东西最终省下的是双方的注意力。这一篇讲的是指令和门禁的区别。下一篇往下走一层一次代码修改从开始到结束可以被控制的节点到底有哪几个每个节点该放什么。那是把本篇的“闭环”拆成可操作的控制面。