如何打破 AI 编程 Agent 的修复死循环
你可能见过这一幕:用 AI 编程 Agent 开发的应用出现了问题。你让 Agent 修复,它声称已“修复”,但错误依然存在,甚至又冒出了第二个 bug。
于是你告诉它“还是不行”,Agent 再次尝试。二十分钟后,代码越改越乱,额度消耗殆尽,却无法回到正常状态。
人们常搜索“AI 让 bug 越修越多”或“Agent 陷入修复循环”等表述。这不是某款产品的怪癖,而是结构性失效。Lovable、Replit、Cursor、Claude Code、Base44 等工具均会出现这一模式。
本教程将解释循环发生的原因,并提供一套具体步骤,帮助你在上述任一工具中打破该循环。
内容概要:
你将学到什么
如何尽早识别 AI 修复循环
为什么模糊的重试会让后续尝试更糟
一套五步流程:停止、回滚、重述、隔离、验证
如何撰写信息充足的修复提示词,确保 Agent 成功解决
前置条件
你无需是资深工程师即可跟随本教程,但应具备以下条件:
一个正在使用 AI 编程 Agent(如 Cursor、Claude Code、Replit Agent、Lovable 或类似工具)构建的应用
版本历史、检查点或 Git 访问权限,以便撤销错误变更
自行运行或预览应用的方式(浏览器预览、本地服务器或已部署 URL)
修复循环的表现
抛开产品品牌,所有场景中的循环模式都相同:
运行中的应用出现异常。
你让 agent 去修,只发一句简短的话("坏了"、"再试一次",或者点一下"Try to Fix"按钮)。
agent 给出一个听起来很有把握的改动。
原问题还在,或者旁边的地方又坏了。
你用同样模糊的反馈再试。
每次失败的尝试都留在会话上下文里,于是下一次尝试是在一堆噪音上推理的。
不同工具的界面表现形式不同:有的真的有个重试按钮,有的一直卡在"Thinking",有的悄悄把你已经确认过的改动回滚了。表面千差万别,机制却是一样的。
为什么会出现死循环
大致按以下顺序,四种因素叠加在一起。
1. 会话越长,上下文越差
每条消息、每个 diff、每句"不对,不是这个"都会增加 agent 需要承载的 token。会话刚开始时,agent 对你应用的认知还比较清晰;聊了二十轮之后,它是在基于之前所有内容的模糊平均值在工作,包括那些走错的弯路。
2. 模糊的重试只添噪音,不给信息
"还是不行。""再试一次。""不是这样。"在你看来这是反馈,但对 agent 来说,这些指令几乎不含新信息,没说哪里还有问题、涉及哪个文件、"正确"长什么样。
于是 agent 通常只在上一次猜测的基础上稍作改动。这就是为什么死循环常常在两个几乎一样的错误修法之间来回打转,而不是逐渐收敛。
3. Agent 看不到你眼中的应用
你看到的是渲染出来的页面,实际点着操作流程走;agent 面对的只是代码,加上你对所见现象的文字描述。
用文字描述一个 UI bug 必然有损耗。当描述和真实 UI 对不上时,agent 就会去优化一个看似合理、实则修错了的问题。
4. 失败的尝试会毒害下一次尝试
正是这一点把一次错误变成了死循环。第二次尝试并不是从零开始,它的上下文里已经装着第一次的错误 diff、你气急败坏的纠正,以及 agent 对自己"修了什么"的解释。第三次又继承了这一切噪音。这个循环是在不断滚雪球,而不是随机碰运气。
综合起来就是:会话越长,模型的精确度越低,而这时你的提示恰恰越来越模糊——正处在 agent 最需要干净、具体信号的时刻。
如何打破这个循环
这些步骤都不需要切换工具,它们直击机制本身。
第一步:停止向循环供料
绝不要在短时间内连续发送"再试一次"或"还是不行"。如果第一次重试失败,问题在于 Agent 不需要盲目再尝试一次,而是你的指令缺乏足够信息来改变其输出。第三次低信息量的提示只会增加更多噪音。
当你意识到自己又要重复同样的抱怨时,停下来,转向第二步。
第二步:回退到上一个可用状态
在再次尝试之前,先撤销失败的修复(如果循环已运行多次,则撤销最近几次修复)。不要在一个损坏的基础上叠加新的尝试。这正是从一个 Bug 演变成三个 Bug 的根源。
使用工具提供的相应功能:
Git:
git checkout -- path/to/file或git restore,或者重置到你信任的提交Cursor / 类似 IDE:文件的本地历史或时间线
Replit、Lovable 及类似构建工具:检查点或版本历史,然后恢复到上一个可用状态
只有当应用回到你认可的状态时,再输入新的修复请求。
第三步:从零开始,精准界定问题
这是杠杆率最高的操作。如果工具允许,优先使用新对话或干净的线程。编写包含以下三点的提示:
确切的文件或组件名称(使用实际路径,而非视觉描述)
哪里出了问题,描述为具体可观察的事实
"正确"的样子是什么,同样具体陈述
弱提示:
还是坏的。修复提交按钮。
强提示:
在
CheckoutForm.tsx中,提交按钮调用handleSubmit,但当请求失败时,加载状态从未重置。提交失败后按钮保持禁用状态,且不显示错误消息。它应该重新启用,并在按钮下方显示服务器错误字符串。
第二个提示为 Agent 提供了文件、行为表现和成功条件。这就足以使其在不猜测的情况下行动。
第四步:每次只改变一件事
不要在 Bug 修复提示中捆绑"顺便把页眉也修好"。这会给 Agent 两个问题,却只有一个上下文预算。对问题 A 的干净修复可能会悄悄重新引入问题 B。
提交修复,验证结果,然后针对下一个问题另开一个工单。
第 5 步:在真实应用中验证,而非轻信 Agent 的说法
Agent 常常自信满满却判断失误,因为它们检查的是自己的推理逻辑,而非你正在运行的产品。
在关闭当前循环之前:
重新加载预览或强制刷新页面
点击遍历那个失败的实际用户流程
如果 Bug 涉及数据或 API,检查数据库记录、网络标签页或日志
确认你在第 3 步中定义的验收条件
直到这一步,才算真正解决该问题。
完整演练:重复扣款
以下用一个真实的 Bug 演示这个循环。你有一个由 AI Agent 编写的小型结算接口。用户反馈网络连接慢时会被扣款两次。下面的内容只需 Node 20 且无额外依赖即可运行。
Agent 生成的代码:
// checkout.js
export function createCheckout({ chargeCard, saveOrder }) {
async function handleCheckout(req) {
const { cartId, amount } = req.body;
const charge = await chargeCard(amount);
const order = await saveOrder({ cartId, chargeId: charge.id, amount });
return { status: 201, body: { orderId: order.id } };
}
return { handleCheckout };
}
当浏览器超时,客户再次点击“支付”时,服务器会第二次运行 handleCheckout,导致再次扣款。
循环过程
你告诉 Agent:“用户被重复扣款了,请修复。”
尝试 1。 Agent 在扣款前检查是否已存在订单:
const existing = await findOrder(cartId);
if (existing) {
return { status: 200, body: { orderId: existing.id } };
}
你通过间隔几秒点击两次“支付”来测试,似乎有效。但如果两个请求同时到达,这就失效了,因为当其中一个请求检查时,另一个请求尚未保存订单。你反馈:“有时仍会重复扣款。”
尝试 2。 Agent 在首次点击后禁用支付按钮。这是客户端改动,无法阻止超时重试或来自第二个浏览器标签页的请求。你反馈:“问题依然存在。”
第三次尝试。 Agent 给扣款逻辑包上 try/catch,出错时返回一条友好的提示。现在 bug 变安静了,但卡还是被扣了两次。到这一步就该停手了。
第 1、2 步:停下来,回滚代码
别再发第四条"还是不行"。先用 Git 恢复原始的 checkout.js(git restore checkout.js),这样你调试的只是一个 bug,而不是四个。
第 3 步:在要求修复之前,先写一个会失败的测试
你能给 Agent 的最有用的东西,就是一个"因为正确的原因而失败"的测试。下面是三个测试:前两个描述这个 bug,第三个保护正常行为:
// checkout.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { createCheckout } from "./checkout.js";
import { createFakes } from "./fakes.js";
const req = (key) => ({
headers: { "idempotency-key": key },
body: { cartId: "cart_1", amount: 4900 },
});
test("a retried request charges the card only once", async () => {
const fakes = createFakes();
const { handleCheckout } = createCheckout(fakes);
const first = await handleCheckout(req("key-1"));
const retry = await handleCheckout(req("key-1")); // client timed out and retried
assert.equal(fakes.charges.length, 1);
assert.equal(fakes.orders.length, 1);
assert.deepEqual(retry.body, first.body);
});
test("two requests sent at the same moment still charge once", async () => {
const fakes = createFakes();
const { handleCheckout } = createCheckout(fakes);
await Promise.all([handleCheckout(req("key-2")), handleCheckout(req("key-2"))]);
assert.equal(fakes.charges.length, 1);
});
test("different keys are different purchases", async () => {
const fakes = createFakes();
const { handleCheckout } = createCheckout(fakes);
await handleCheckout(req("key-3"));
await handleCheckout(req("key-4"));
assert.equal(fakes.charges.length, 2);
});
这些测试用轻量的内存 fake 模拟支付服务商和数据库,运行只需几毫秒:
// fakes.js
export function createFakes() {
const charges = [];
const orders = [];
return {
charges,
orders,
async chargeCard(amount) {
await new Promise((r) => setTimeout(r, 10)); // simulate a slow provider
const charge = { id: `ch_${charges.length + 1}`, amount };
charges.push(charge);
return charge;
},
async saveOrder(data) {
const order = { id: `ord_${orders.length + 1}`, ...data };
orders.push(order);
return order;
},
};
}
运行 node --test。前两个测试会失败,这正是 Bug 存在的证据:
not ok 1 - a retried request charges the card only once
not ok 2 - two requests sent at the same moment still charge once
ok 3 - different keys are different purchases
注意,第二个测试恰好描述了第一次尝试所遗漏的场景。Agent 无法察觉到该情况,但测试可以。
步骤 3 & 4:给 Agent 一个精准的提示词,且仅做一处修改
开启一个新会话并粘贴以下内容:
在
checkout.js中,handleCheckout每次被调用都会扣款,导致客户端重试时用户被重复扣款。请使用Idempotency-Key请求头使其具备幂等性:相同的 Key 必须产生一次扣款和一笔订单,即便两个携带相同 Key 的请求同时到达也是如此,且必须返回相同的响应体。未携带 Key 的请求应返回状态码 400。不要修改fakes.js或测试。运行node --test并展示输出结果。
该提示词明确了文件和函数,阐述了错误行为与正确行为,涵盖了并发场景,并要求只做一处修改。
修复方案
// checkout.js
export function createCheckout({ chargeCard, saveOrder }) {
const requests = new Map(); // 幂等键 -> 响应 Promise
async function process(req) {
const { cartId, amount } = req.body;
const charge = await chargeCard(amount);
const order = await saveOrder({ cartId, chargeId: charge.id, amount });
return { status: 201, body: { orderId: order.id } };
}
async function handleCheckout(req) {
const key = req.headers["idempotency-key"];
if (!key) {
return { status: 400, body: { error: "缺少 Idempotency-Key 请求头" } };
}
if (!requests.has(key)) {
const promise = process(req);
requests.set(key, promise);
promise.catch(() => requests.delete(key)); // 失败请求允许重试
}
return requests.get(key);
}
return { handleCheckout };
}
核心在于 Map 存储的是 Promise,而非最终结果。相同键的第二个请求会拿到同一个正在执行的 Promise,从而等待首次扣款完成,而不是发起新扣款。正是这一步补上了并发场景下第一次尝试的疏漏。
第 5 步:验证
ok 1 - 重试请求只扣款一次
ok 2 - 同一时刻的两个请求也只扣款一次
ok 3 - 不同键对应不同交易
# tests 3
# pass 3
# fail 0
也要实测真实场景:用 curl 发送两次相同请求并携带相同的 Idempotency-Key,确认支付服务商后台只记录了一笔扣款。
注意:此版本将键保存在内存中,仅能保护单个服务器进程。生产环境应将键存入数据库或 Redis 并设置过期时间。测试无需改动,可让 agent 作为独立需求执行此修改。
本例说明
修复代码仅 12 行。打破循环的关键不在于更巧妙的提示词,而在于失败的测试。它将"偶尔仍扣两次款"转化为 agent 可读的结果,并抓住了所有模糊重试都未覆盖的边界场景。
可复用的检查清单
复制以下清单应对下次事故:
[ ] 我仅重试一次失败请求即停止
[ ] 我回滚到已知正常状态
[ ] 我指定了具体文件或组件
[ ] 我将错误行为描述为可观测事实
[ ] 我把正确行为描述成了可观察的事实
[ ] 我只要求修改一处
[ ] 我自己在真实界面 / 数据 / 日志中验证过了
结语
AI coding agent 很擅长做出应用的第一版,但迭代就难多了——因为一个高质量的修复请求需要足够的精确度,一句气急败坏的“还是不行”提供不了,而且 agent 看不到你的屏幕,无法像你一样了解情况。
陷入修复循环并不代表你在深层次上“用错了工具”,它只是在提示你:prompt 需要比“再试一次”更多的信息。
下次当你连续修了三次还没修好时,停下来,回退,然后从头重新描述问题,明确范围。一开始感觉更慢,但总比陷入死循环快。
如果你想看一份针对具体工具的简明实操版指南,我在 Vibe Coder Daily 上也发过一篇实战文章。