红循环调试法:先写一条失败的命令,再让 AI 修 Bug

清风徐来AI 前沿📡 觉醒AI2026-09-19340 阅读💛 166 收藏

一句话结论

让 AI 修 Bug 前先写一条「红循环」命令:快、确定性、精确对准用户症状、且只在症状消失时变绿——没有它,智能体只会修补最近的绿色(try/except 包住失败断言返回 True),Bug 还在生产环境里。

案例开场

上周二我看着 Hermes 把一个失败的断言包进 try/except 然后返回 True。

测试套件 40 秒变绿。那个 Bug——for page in range(1, total // page_size) 在 400 条数据整除 100 时丢掉最后一页——还在生产环境。

智能体做了放任它先打补丁时一定会做的事:为最近的绿色优化,不为原因优化。

我现在在每个调试会话顶部写的规则:在拥有一条「对准确切症状失败、且只在症状消失时通过」的命令之前,不许修。我称之为红循环。没有它,你是在多走几步地瞎猜。

铁律

Hermes 内置一个系统化调试技能,第一行不客气:没有根因调查,禁止修复。多数人读成「打字前先想想」——不是这个意思。思考廉价且通常错误。这个技能的意思是:在你能够按命令失败之前,不许提补丁。

红循环是一条满足四个性质的单一命令:快——秒级不是分钟级,你会跑 20 次。

确定性——同样输入同样失败;如果 Bug 是间歇性的,先提高复现率再立论,50% 的闪断可调试,1% 的不可。

精确——断言用户的症状,不是邻近崩溃;「进程退出码 1」不是循环,「第 4 页缺第 400 条」是循环。

可变绿——Bug 真消失时这条命令通过;说不出绿色长什么样,就还不理解这个 Bug。

没有这条命令,你不是在调试,你是在编辑。

先建循环,再读代码

我常见的失败模式:智能体打开堆栈里点名的文件,从它理解的第一个函数形成理论,然后打补丁。跳过这步。「坏了」之后第一个工具调用应该是复现它的命令,不是读文件。

按顺序试,见红就停。第一,接缝处的失败测试:pytest tests/test_pager.py::test_last_page_when_total_divides_evenly -v。如果这个测试不存在,先写它。已经通过的测试不是红循环,是邻近测试。

第二,带 fixture 输入的 CLI:python tools/fetch_pages.py —fixture tests/fixtures/items-400.json —page-size 100,期望 400 条,上周只打印 300。

第三,对运行中服务器的 curl:curl -sS 后接本机接口,用 jq 取 items 长度——总数 400 时期望 100,实际 0。

第四,一次性试验台:启动系统最小切片调用失败路径,放 /tmp 或 scratch/ 目录防止误合入,回归测试存在后删掉。

第五,已知好版本和坏版本之间的 git bisect run。写一个 repro 脚本断言 fetch_all(total=400, page_size=100) 长度为 400,然后 git bisect start、标记 bad HEAD、标记 good v1.4.0、git bisect run 脚本。

bisect 会自己检出、运行、标记,你得到一个 SHA,不是一种感觉。

不要从无头浏览器脚本或模糊测试开手。那些是后续的真工具。第一个红必须是最便宜的红。

收紧到「删任何一块都会变绿」

命令变红后,缩小复现。每次删一块输入、调用方、配置或数据,删后重跑。只保留对失败承重的部分。

删掉任何剩余部分都能让循环变绿时,收工。那个最小化复现基本就是回归测试,近乎逐字。

我在「智能体会话」上浪费过数小时,Bug 在一个 12 行的辅助函数里。会话是布景。

一个假设,一次探针

有了红之后,先写下 3–5 个可证伪假设再测任何一个。按可能性与推翻成本排序。每个假设必须给出预测:如果 X 是原因,那么改变或观察 Y 应该让 Z 发生。不产生预测的不是假设,是预感。

我把这些记在一个允许智能体追加的文件里,不记在聊天里。

以分页 Bug 为例:H1「页数取整 vs 向上取整」——预测 n_pages = total // page_size 对 400/100 是 4,四轮循环仍会通过,无法区分好坏——结果:探针前废弃,没有区分性预测。

H2「range(1, total // page_size) 右开区间」——预测 fetch_all(400, 100) 请求第 1、2、3 页而永远不请求第 4 页——探针:每次请求打 print——结果:确认,请求 1、2、3,没有 4。

H3「空末页被当作迭代结束」——预测 400/100 场景中空批次 break 的日志会触发——结果:否决,break 从未触发,H2 已解释一切。

H1 是智能体最爱那种假设:听着技术,什么也没解释。写下预测逼我在花一个回合之前发现它没用。

每次探针只改一个变量。如果你加了日志又改了条件又调了超时,你无法说清哪个改动起了作用。下一个 Bug 会教你:超时是承重的,条件是迷信。

加日志时,每行临时代码打唯一前缀标签:print 加 [DEBUG-a4f2] 前缀和 flush=True。清理时一次搜索搞定。我发过带着 [DEBUG] 打印上线的版本,因为它们长得像其他日志。前缀就是差别。

一个你能跑的完整例子

这是我反复在智能体代码里见到的 Bug:一个函数把 token 预算分给 N 个工人,整数除法丢掉余数。total 小于 n 时每个工人分 0,任务静默空转——看着像智能体卡死,其实是取整。

把这段放进 budget.py:

def split_budget(total: int, n: int) -> list[int]: if n <= 0: return [] share = total // n return [share] * n

智能体式「修复」是 share = max(1, total // n)。那会超分配:split_budget(3, 4) 变成 [1,1,1,1],你发明了 token;split_budget(0, 3) 变成 [1,1,1],零预算成了三。

别打补丁。先写红循环:

repro_budget.py

from budget import split_budget

CASES = [(10, 4), (3, 4), (0, 3), (7, 1), (100, 7)]

def check(total: int, n: int) -> None: parts = split_budget(total, n) assert len(parts) == n, f”len {parts} != {n}” assert sum(parts) == total, f”sum {parts} != {total}” assert all(p >= 0 for p in parts), parts assert max(parts) - min(parts) <= 1, parts

if name == “main”: failed = 0 for total, n in CASES: try: check(total, n) print(f”ok total={total} n={n} -> {split_budget(total, n)}”) except AssertionError as e: failed += 1 print(f”FAIL total={total} n={n} -> {split_budget(total, n)} {e}”) raise SystemExit(failed)

运行 python3 repro_budget.py,你会看到 (10, 4) 失败(和是 8 不是 10)、(3, 4) 失败(和是 0 不是 3)、(100, 7) 失败(和是 98 不是 100)。

(0, 3) 和 (7, 1) 通过——这很有用:循环是特异的,不是「一切都着火」。

现在你才被允许形成假设。H1「n <= 0 提前返回参与其中」——预测删掉该分支后 (10, 4) 仍失败——探针确实如此——杀掉 H1。

H2「整数除法丢余数」——预测 sum(split_budget(10, 4)) 等于 8——探针得 8——确认。

H3「零份额是第二个缺陷」——预测 (3, 4) 与 H2 同因——divmod(3, 4) 是 (0, 3),分配余数可同时修复两者——确认。

一个修法:

def split_budget(total: int, n: int) -> list[int]: if n <= 0: raise ValueError(f”n must be > 0, got {n}”) if total < 0: raise ValueError(f”total must be >= 0, got {total}”) share, rem = divmod(total, n) return [share + (1 if i < rem else 0) for i in range(n)]

重跑 python3 repro_budget.py,五案例全 ok。然后把试验台升级为正式测试:用 pytest.mark.parametrize 铺五个案例,断言长度等于 n、总和等于 total、最大最小差不超过 1;再加一个 n 为 0 时抛 ValueError 的测试。

n <= 0 静默返回空列表是藏在第一个缺陷后面的第二个缺陷。

红循环让它显形,因为 len(parts) == n 是不变量,不是感觉。

max(1, …) 补丁会让 (3, 4) 看着修好、让 (0, 3) 新错。循环两个都能抓,因为它断言总和,不断言「没人拿零」的感觉。

给智能体的提示词模板

在调试会话开头贴这段,按需改命令,别跳过「不许打补丁」那行:

Debug this. Do not patch anything until the red loop is green-capable and currently red.(调试这个。在红循环可变绿且当前为红之前,不许补任何东西。

)Symptom 填一句用户原话症状;Red loop 填确切命令;Expected 填绿色长什么样;Do not touch production files in this turn(本轮不碰生产文件);把假设写进 debug-log.md,测一个,报告探针与结果。

如果装了系统化调试技能,加一句:遵循系统化调试技能,只执行阶段一,阶段四前停止。

多组件故障时,委托调查而不是委托修复。给子智能体错误、文件路径和确切命令,不给编辑权限。把它的总结当声明对待,自己重跑循环。

三次规则

三个补丁都失败了就停。那不是「试第四次」,那是「架构才是 Bug」。

模式是:每个修复都暴露不同位置的共享状态,或每个修复都要求不断膨胀的「小重构」。

你已不是在调试函数,是在跟设计谈判。

对智能体把这句话说出口。我看着 Hermes 欢快地发出第 6 号修复,因为没人告诉它上限是 3。

不要做的事

不要先打开堆栈文件,先复现。不要接受已通过的测试当红循环。

不要「就试一下」max(1, …) 或更宽的 except——那些是症状补丁。

不要加无 [DEBUG-xxxx] 标签的日志。不要一次探针测两个假设。

不要让子智能体修,让它查,你来验证。不要因为试验台已通过就跳过回归测试——试验台会被删,测试不会。

不要为「解锁 CI」禁用失败测试——那是 try/except 返回 True 的加长版。

今晚可以练的项目

拿一个你这个月修过的 Bug,在一次性分支上:git revert 修复或检出父 SHA;写一条对原症状失败的红循环命令,给自己计时,超过 15 分钟说明原修复规格不足;

缩小复现到「删任何剩余部分都会变绿」;在文件里写三个假设,用不改生产代码的探针杀掉两个;把修复作为单次改动重新应用,确认循环变绿、套件仍过。

如果第二步写出的测试抓不住原 Bug,你原来的「修复」只是一次巧合的绿色。这就是本文的全部要点。

智能体擅长编辑,不擅长知道编辑是否重要。红循环就是分辨的办法。

AI 编程调试操作参考

用 AI 编程工具修 Bug 时按这个流程设置会话:开工先写复现命令,让智能体运行确认当前为红,再允许它动代码——把「红循环可变绿且当前为红之前不许补任何东西」写进调试提示词第一行。

排障过程用假设清单管理智能体:把 3–5 个可证伪假设写进 debug-log.md 文件,让智能体一次只测一个,每个假设必须带预测和探针结果,防止它用技术感词汇掩盖空转。

修完后做两步验证:重跑复现命令确认变绿,再跑全量测试套件确认没有改坏别处;把复现脚本升级为 parametrize 回归测试合入仓库,防止同一个 Bug 复发。

成本判断记一条:三个补丁都失败就停下换架构方向,继续堆补丁只会让 AI 智能体在错误设计上越走越远——它擅长快速编辑代码,不擅长检查编辑是否解决了实际问题;把「上限三次」写进调试提示词,管理成本比事后返工低得多。

原文信息

  • 作者:Michael Gannotti(@MichaelGannotti),智能体工程作者
  • 原文标题:No Fix Without a Red Loop: Debugging With Hermes Without Guessing 原文地址:
  • 发布日期:2026-09-08

文章评论(0

暂无评论,快来抢沙发~