
AI 编程助手修 bug 越修越乱?一套可复用的破环流程
AI 编程助手修 bug 越修越乱?一套可复用的破环流程
用 AI 编程助手改 bug 时,反复说「还是不行」往往会让情况更糟:上下文被污染、错误被叠加,一个 bug 变成三个。这篇教程拆解这种「修复死循环」的成因,并给出一套五步流程——停手、回滚、重述、隔离、验证,配一个重复扣款的完整示例,说明如何用失败测试和精确提示词把问题一次说清。
用 AI 编程助手写应用的人,多半都经历过同一个场景:运行中的应用出了问题,你让助手去修,它说修好了,可 bug 还在,或者旁边又冒出一个新问题。你回一句「还是不行」,它再试一次。二十分钟后,代码更乱、额度更少,而你连回到能跑的状态的路都找不到了。有人把这种现象描述成「AI 越修越糟」或者「助手卡在修复循环里」。它不是某一款产品的怪癖——Lovable、Replit、Cursor、Claude Code、Base44 以及同类工具都会掉进同一个模式,因为问题出在结构上,而不是出在某个模型的脾气上。
这篇教程讲两件事:这个循环为什么会发生,以及一套可以在上述任何工具上使用的具体操作顺序,用来把它打断。适合正在用 AI 助手做应用、并且已经遇到过一次以上「修不动」的人阅读。
准备工作
不需要是专业工程师也能照着做,但下面三样东西最好先具备,否则后面的步骤会缺胳膊少腿:
- 一个正在用 AI 编程助手开发的应用,工具可以是 Cursor、Claude Code、Replit Agent、Lovable 或同类产品。
- 能访问版本历史、检查点(checkpoint)或 Git,这样才有办法撤销一次糟糕的改动。没有回滚能力,整个流程的第一步就走不下去。
- 你自己能运行或预览这个应用的方式:浏览器预览、本地服务器,或者一个已部署的地址。验证必须由你来做,不能只听助手说。
另外,示例部分用 Node 20 就能跑,不需要安装任何第三方依赖。如果你的项目不是 JavaScript,流程本身照样适用,只是把命令和文件名换成你项目里的对应物。
修复循环长什么样
把各家产品的界面差异剥掉,循环的形状到处都一样:
- 运行中的应用出了问题。
- 你用一句很短的话让助手去修,比如「坏了」「再试一次」,或者直接点一下界面上的「Try to Fix」。
- 助手产出一个听起来很有把握的改动。
- 原来的问题还在,或者隔壁的功能被弄坏了。
- 你用同样含糊的反馈再试一次。
每一次失败的尝试都留在对话上下文里,于是下一次尝试是在一堆噪声之上做推理。不同工具把这个过程暴露成不同的界面:有的给你一个实打实的重试按钮,有的卡在「Thinking」上转圈,有的会悄悄回滚一个你已经确认过的改动。表面不同,机制相同。
循环为什么会发生
四股力量大致按下面的顺序叠加,把一次小失误滚成一个死循环。
上下文随会话变长而退化
每一条消息、每一个 diff、每一句「不是这个」,都会往助手必须持有的上下文里加 token。会话刚开始时,助手对你这个应用的模型相对清晰;二十轮对话之后,它是在一个被抹平的平均印象上工作,而这个平均印象里包含了所有走错的路。
含糊的重试只增加噪声,不增加信息
「还是坏的」「再试一次」「不是这个」——对你来说这像是反馈,对助手来说,这是几乎没有新信号的指令。它们没有说明哪里还坏着、涉及哪个文件、什么才算「对了」。于是助手通常只是把上一次的猜测稍微变个形。这就是为什么循环往往在两个几乎相同的错误修法之间来回跳,而不是收敛到正确答案。
助手看不到你看到的那个应用
你盯着渲染出来的页面,点着流程走。助手是在代码和你对所见现象的文字描述上做推理。用文字描述一个界面 bug 是有损的。当描述和真实界面对不上时,助手会去优化一个「看起来合理」的修法,而它修的是错误的问题。
失败的尝试会污染下一次尝试
这是把一次错误变成循环的关键。第二次尝试不是从零开始,它的起点是一个已经装着第一次错误 diff、你的烦躁纠正、以及助手解释「我以为我修好了什么」的上下文窗口。第三次尝试继承了所有这些噪声。循环是复利式的,不是随机的。
把四者合起来看:长会话降低了精度,而恰好在这时你的提示词变得更含糊,也正是助手最需要一个干净、具体信号的时候。
操作步骤:五步打断循环
下面每一步都不需要换工具,它们针对的是机制本身。
第一步:停止继续喂这个循环
永远不要连续两次发送「再试一次」或「还是坏的」。如果第一次重试失败了,问题不是助手需要再来一次盲试,而是你的指令没有携带足够的信息去改变它的答案。第三次低信息量的提示只会再加一层噪声。当你发现自己正准备把同一句抱怨再打一遍时,停手,进入第二步。
第二步:回滚到最后一个已知可用的状态
在再次尝试之前,先撤销失败的修复(如果循环已经跑了几轮,就撤销最近几次)。不要把新的尝试叠在一个已经坏掉的改动上,那是一个 bug 变成三个 bug 的标准路径。用你手头工具提供的能力:
- Git:
git checkout -- path/to/file、git restore,或者直接 reset 到一个你信任的 commit。 - Cursor 及类似 IDE:使用该文件的本地历史或时间线。
- Replit、Lovable 及类似的构建器:打开检查点或版本历史,恢复到最后一个正常状态。
只有当应用回到一个你认得出来的状态之后,才允许输入新的修复请求。
第三步:从零重述问题,并给出精确范围
这是杠杆最大的一步。工具允许的话,优先开一个全新的对话或干净的线程。写提示词时必须包含三样东西:
- 确切的文件或组件名,用字面路径,不要用视觉描述。
- 哪里坏了,写成一条具体可观察的事实。
- 「对了」是什么样,同样写得一样具体。
弱提示词长这样:
还是坏的。修一下提交按钮。
强提示词长这样:
在
CheckoutForm.tsx里,提交按钮调用handleSubmit,但请求失败时 loading 状态永远不会复位。提交失败后按钮一直处于禁用状态,也不显示任何错误信息。正确行为是:按钮重新可用,并在按钮下方显示服务器返回的错误字符串。
第二段提示给了助手一个文件、一个行为、一个成功条件。这足够它动手,而不必靠猜。
第四步:一次只改一件事
不要把「顺便把页头也修一下」塞进一个修 bug 的提示里。那等于给助手两个问题、一份上下文预算。对问题 A 的干净修复,可能悄悄把问题 B 又带回来。先交付这个修复,验证它,然后再为下一个问题单独开一个请求。
第五步:对着真实应用验证,而不是对着助手的说法验证
助手在「自己的修复有没有生效」这件事上经常自信且错误,因为它检查的是自己的推理,不是你在跑的产品。在关闭这个循环之前:
- 重新加载预览,或者对页面做一次硬刷新。
- 把刚才失败的真实用户流程点一遍。
- 如果 bug 涉及数据或 API,去看数据库里的那一行、网络面板或日志。
- 确认你在第三步写下的成功条件确实成立。
只有到这一步,才可以把这次事故当作已关闭。
一个完整示例:重复扣款
下面用一个真实类型的 bug 把循环走一遍。你有一个由 AI 助手写的小型结账接口,用户反馈网络慢的时候会被扣两次款。所有代码用 Node 20 就能跑,无依赖。
助手最初写出来的代码:
// 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,于是卡片被再扣一次。
循环是怎么发生的
你告诉助手:「用户被扣了两次款,修一下。」
尝试一。助手在扣款前先查有没有已存在的订单:
const existing = await findOrder(cartId);
if (existing) {
return { status: 200, body: { orderId: existing.id } };
}你隔几秒点两次「支付」来测试,通过了。但当两个请求同时到达时它不成立,因为两边互相检查的时候,谁都还没保存订单。你反馈:「有时候还是扣两次。」
尝试二。助手在第一次点击后禁用「支付」按钮。这是客户端改动,挡不住一个超时请求的重发,也挡不住第二个浏览器标签页。你反馈:「还是会发生。」
尝试三。助手把扣款包进 try/catch,出错时返回一句友好的提示。现在这个 bug 更安静了,但卡片还是被扣了两次。
这就是该停手的点。
第一步与第二步:停手并回滚
不要再发第四句「还是坏的」。从 Git 恢复原始的 checkout.js:
git restore checkout.js这样你调试的是一个 bug,而不是四个。
第三步:在要求修复之前,先写一个会失败的测试
你能给助手的最有用的一句话,是一个因为正确原因而失败的测试。下面三个测试里,前两个描述这个 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);
});测试用小的内存假对象替代支付服务和数据库,所以毫秒级就能跑完:
// 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注意第二个测试描述的正是尝试一漏掉的那个场景。助手看不到这个场景,但测试可以。
第三步与第四步:给助手一个精确提示,只改一处
开一个全新的对话,把下面这段贴进去:
在
checkout.js里,handleCheckout每次被调用都会扣款,所以客户端重试会让用户被扣两次。请用Idempotency-Key请求头把它改成幂等的:同一个 key 必须只产生一次扣款和一笔订单,包括两个相同 key 的请求同时到达的情况,并且必须返回相同的响应体。没有 key 的请求应返回 400。不要改fakes.js,也不要改测试。运行node --test并把输出给我看。
这段提示点名了文件和函数,说明了错误行为和正确行为,包含了并发场景,并且只要求一处改动。
修复后的实现思路是:在 createCheckout 内部维护一个从幂等 key 到「响应 Promise」的映射,把真正的处理逻辑抽成一个 process 函数;handleCheckout 先取 key,没有 key 就返回 400,有 key 就查映射——命中则直接复用那个 Promise,未命中则把 process(req) 的 Promise 存进映射再返回。关键在于存的是 Promise 而不是结果:两个同时到达的请求会在第一个 Promise 尚未完成时就命中同一个条目,因此只会扣一次款。测试跑完后三个用例应当全部通过。
第五步:对着真实应用验证
测试通过只是必要条件,不是充分条件。回到真实环境:重新加载预览,走一遍真实的支付流程,在慢网络下重复提交,然后去支付服务商后台和数据库里确认只有一条扣款记录、一笔订单。只有这些都对了,这次事故才算关闭。
注意事项
- 这套流程不要求换工具,但它要求你有回滚能力。没有版本历史、检查点或 Git 的项目,第一步就执行不了,所以先把回滚手段准备好。
- 「开新对话」这一步在多数工具里是可行的,但并非所有工具都提供干净的线程;如果做不到,至少要在提示里明确声明忽略之前的尝试,并把文件、现象、成功条件重新写全。
- 测试里的假对象是为了让测试跑得快、跑得稳,它不能替代真实支付服务商的行为验证。涉及金额、并发、超时的逻辑,最终仍要在接近生产的环境里确认。
- 幂等键的生成、存储位置和过期策略因系统而异,示例只演示了服务端如何消费这个 key,没有规定客户端该怎么生成它。
- 各工具的功能、额度、版本与可用地区会变化,具体以官网当前信息为准。