☰
为什么写了“必须运行测试”,Agent 还是会交出坏代码?
2026/10/3 2:34:31 网站建设 项目流程

一、一个下午里的两次“测试通过”

假设你维护一个叫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-report):Agent 用自己的话描述它做过什么,比如“我运行了测试,全部通过”。自我报告有信息价值,它能告诉你它的意图和它看到的片段,但它不是证据。凡是需要拿来决策的结论,都要回到命令本身去核对。

最小证据包:一组足以让别人重跑并复现你结论的材料,通常包含四项:命令、退出码、关键输出摘要、被验证的代码版本。少了版本,别人不知道你验证的是哪一版;少了退出码,别人不知道命令到底成功没成功。

自修循环(self-repair loop):Agent 看到失败、改代码、再跑一次的循环。它有用,但它有个前提:失败信息必须是真实的、外部产生的。如果反馈来自模型自己的猜测,循环会变成原地打转,越改越乱。

三、为什么“写了规则”不等于“规则被执行”

要理解这件事,可以把一次交付拆成三个问题:谁来做、做完谁看、看不过去谁拦。指令只回答了第一个问题。

第一层混淆是把“我说过”当成“系统检查过”。你写下“必须运行测试”,这句话进入的是模型的工作上下文,它改变了模型的行动概率,但没有任何机制阻止一个没通过测试的改动被提交。文字约束和可执行的检查,是两种力度完全不同的东西。

第二层混淆是把“我说它通过了”当成“它真的通过了”。自然语言是有弹性的。“测试通过”这四个字可以对应很多种状态:全部测试跑完都通过;只跑了其中一个文件而它通过;跑了但把失败当成环境问题忽略;看到红色但认为“主体逻辑没问题”。这四种状态在聊天窗口里长得一模一样,在退出码里完全不同。

这里可以顺手解释一个很多人踩过的坑:模型在没有外部反馈的情况下,很难靠自我反思把推理改对。Huang 等(2023)在Large Language Models Cannot Self-Correct Reasoning Yet(arXiv:2310.01798)里给出的结论是,缺少外部反馈时,模型很难通过自我修正改进推理,某些情况下修正后反而更差。这段话不是要说明模型不可用,而是要解释为什么“让它自己再检查一遍”不能替代“跑一遍测试”。

第三层混淆是把规则的位置当成了规则的力度。规则写在哪里、上下文里有多少内容,都会影响它被真正使用的程度。Liu 等(2024)在Lost in the Middle: How Language Models Use Long Contexts(TACL 12:157–173,DOI: 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_order@pytest.mark.asyncioasyncdeftest_paid_order_cannot_be_cancelled(order_repo,audit_log):order=Order(id="o-1",status=OrderStatus.PAID)awaitorder_repo.save(order)withpytest.raises(CancelNotAllowed):awaitcancel_order(order_id="o-1",repo=order_repo,audit=audit_log)saved=awaitorder_repo.get("o-1")assertsaved.statusisOrderStatus.PAIDassertaudit_log.events==[]@pytest.mark.asyncioasyncdeftest_pending_order_can_be_cancelled(order_repo,audit_log):order=Order(id="o-2",status=OrderStatus.PENDING)awaitorder_repo.save(order)awaitcancel_order(order_id="o-2",repo=order_repo,audit=audit_log)saved=awaitorder_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_id="o-404",repo=order_repo,audit=audit_log)

这三条测试分别锁住三件事:已支付订单不能被取消、待处理订单能被取消、订单不存在时要有明确错误。第二条和第三条是第一条的护栏,它们防止你把“禁止取消”实现成“谁都取消不了”。

4.2 交给 Agent 的第一版实现

假设 Agent 交回的cancel_order长这样:

# src/orders/services/cancel_order.py(有问题的一版)fromorders.domain.orderimportOrderStatusasyncdefcancel_order(*,order_id:str,repo,audit)->None:order=awaitrepo.get(order_id)order.status=OrderStatus.CANCELLEDawaitaudit.record("order.cancelled",order_id=order_id)

它做了两件事:把内存里的对像状态改成已取消,然后写一条审计事件。它漏掉了更重要的两件事:没有检查当前状态、没有把新状态写回持久层。repo.get()返回的对象改动不会被数据库知道,除非你显式保存。

这段代码就是那种“看起来对、跑起来错”的典型样本。它不会抛异常,不会打印警告,接口甚至可能返回 200。

4.3 第一次运行,读数字而不是读文字

现在你来跑测试。注意命令后面那行读退出码的动作,它才是这次运行的关键:

pytest-qtests/orders/test_cancel.pyecho"exit=$?"

在 PowerShell 里写法略有不同:

pytest-q tests/orders/test_cancel.py"exit=$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 exit=1

第一行说明状态检查缺失:已支付的订单被取消了。第二行说明写回缺失:内存对象改了但持久层没改。exit=1是这次运行的结论,其余文字是解释。

到这里,一次真正的闭环只剩一半:你还得把失败信息变成下一轮能直接使用的输入。做法是把它们压成一段结构化反馈,而不是把整屏日志丢回给模型。

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:order=awaitrepo.get(order_id)iforderisNone:raiseOrderNotFound(order_id)iforder.statusisnotOrderStatus.PENDING:raiseCancelNotAllowed(order_id=order.id,status=order.status)order.status=OrderStatus.CANCELLEDawaitrepo.save(order)awaitaudit.record("order.cancelled",order_id=order.id)

三处关键变化值得逐行看清。第一,先判断订单是否存在,再判断状态,顺序不能反,否则一个不存在的订单可能先被当成“状态不允许取消”。第二,OrderStatus.PENDING之外的一切都拒绝,这里没有用“如果是 PAID 就拒绝”的写法,因为那样将来多一个状态就会漏掉。第三,repo.save(order)必须在写审计事件之前,让状态落库成为取消动作的一部分。

在 API 层,CancelNotAllowed应该映射成一个明确的响应。RFC 9457(Problem Details for HTTP APIs)定义了 HTTP API 错误响应的通用格式,你可以按它返回application/problem+json,状态码用 409 表示当前资源状态不允许这个操作。这样调用方看到的不是一句含糊的“内部错误”,而是可以判断并处理的信号。

4.6 再跑一次,这次收集证据

pytest-qtests/orders/test_cancel.pyecho"exit=$?"
3 passed in 0.41s exit=0

exit=0是这次交付的第一份有效证据。除此之外再补三样,凑成一个最小证据包:

版本: 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保存日志的时候尤其要注意这一点:不开启pipefail,tee的退出码会盖掉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 用 Seatbelt,Linux/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,让命令永远返回 0;CI 里给这一步配置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=[...], decision="allow|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。这套东西最终省下的是双方的注意力。

这一篇讲的是指令和门禁的区别。下一篇往下走一层:一次代码修改从开始到结束,可以被控制的节点到底有哪几个,每个节点该放什么。那是把本篇的“闭环”拆成可操作的控制面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询