测试挂了不用喊人:我给 CI 装了个会自己修 bug 的循环,AI 修完我还得改回来一处
"自愈 CI"这个词现在有点被用滥了,营销号嘴里它近乎魔法。我决定用最笨的办法验证一下到底几斤几两:自己搭一个最小闭环,挂了就喂给大模型,模型给 patch,打上重跑,全程没人碰键盘。结果比想象的有意思——AI 确实把测试修绿了,但它顺手改掉了一行不该改的代码。

场景设定
一个 27 行的折扣计算模块加 4 个 pytest 用例。我模拟同事的一次坏提交:把"打八折"的公式写成了"打二折"(price * pct / 100,20% 折扣算出来只剩 20 块),顺手还把合计金额的 round(total, 2) 换成了 int(total)。
这里有个细节:模块里还有一条业务规则——折扣超过 50% 必须拦截返回 0,等人工审批。我特意在给大模型的提示词里强调"这是需求,保留"。这是故意的,真实项目里 AI 最容易干的就是把看不懂的业务逻辑当 bug 清理掉。
循环长什么样
整个循环四步,没有任何魔法:
[1/4] CI 第一轮:pytest
退出码: 1
| E assert 20.0 == 80.0
| FAILED test_app.py::test_apply_discount_zero - assert 0.0 == 100.0
| FAILED test_app.py::test_batch_total - assert 17 == 157.5
| ========================= 3 failed, 1 passed ==========
[2/4] 把失败输出喂给 GLM 要修复 patch
[3/4] 应用 patch 后重跑
退出码: 0
| ============== 4 passed in 0.07s ================
[4/4] 结论: 自愈成功
提示词就一句话:你是修复CI的工程师,只输出一个 JSON 对象,key 是 fixed_code,值是修复后的完整文件。强制 JSON 是为了后续解析不折腾——顺便说一句,这一步踩过坑的都知道,模型最爱干的事就是给你包一层 markdown 围栏,得自己剥。
AI 修了什么
GLM-5.3-flash 返回的修复版干了三件事:
第一,把折扣公式改对了:price * (100 - pct) / 100.0。测试断言从 20.0 == 80.0 的失败变通过,这是主 bug,修得没毛病。
第二,把 int(total) 改回了 total = 0.0 浮点累加。测试全绿。
第三,原样保留了折扣超过 50% 返回 0 的审批逻辑。我盯着这行看了好几秒——提示词里那句"这是需求,保留"它听进去了。
然后我把它的 patch 改掉了一处
问题出在第二件事上。它删掉 int() 的时候,把原来的 round(total, 2) 也弄没了,返回的是未经舍入的浮点数。4 个测试用例全过,因为测试没覆盖 0.005 这种边界金额。但如果下游有个支付网关对金额格式敏感,这就是一颗延迟引爆的雷。
测试全绿不等于修复正确。测试只守住了写下来的预期,没写下来的语义——比如"金额必须舍入到分"——AI 不会替你补。这也是我给这个循环加第四步的原因:patch 可以自动打,但必须生成 diff 推给人过目,人点头才进主分支。全自动自愈和带人审的自愈,可靠性是两个物种。
成本和边界
这次修复一次调用过,输入是测试输出加源码大概 1.5K token,成本可以忽略。但这依赖一个前提:bug 是"函数写得不对"级别的。如果测试挂的原因是环境问题、依赖冲突、或者测试本身写错了,同样的循环会进入修不好的死循环——所以工程上必须给循环加熔断:同一测试连续 3 轮修不过,停止自动修复,直接告警喊人。
另外说个反直觉的观察:整个闭环里最难写的不是提示词,是失败输出的裁剪。pytest 全量输出塞进去又贵又干扰,只截 FAILED 段加最后的 summary 反而效果最好。喂给模型什么,比怎么问它更重要。
要不要在真项目里用
我的建议是可以用,但把"自愈"两个字抠掉。它真正靠谱的定位是:夜间跑一轮,天亮时你收获几份带 diff 的修复建议,喝着咖啡点两下合并。修复这个动作值得信任,但"无人值守地信任"还早。
这个最小闭环核心代码不到 100 行,一个 urllib 调用加两个 subprocess,今晚你就能给自己项目装一个。