AB
AiBoss
チュートリアル

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,流程本身照样适用,只是把命令和文件名换成你项目里的对应物。

修复循环长什么样

把各家产品的界面差异剥掉,循环的形状到处都一样:

  1. 运行中的应用出了问题。
  2. 你用一句很短的话让助手去修,比如「坏了」「再试一次」,或者直接点一下界面上的「Try to Fix」。
  3. 助手产出一个听起来很有把握的改动。
  4. 原来的问题还在,或者隔壁的功能被弄坏了。
  5. 你用同样含糊的反馈再试一次。

每一次失败的尝试都留在对话上下文里,于是下一次尝试是在一堆噪声之上做推理。不同工具把这个过程暴露成不同的界面:有的给你一个实打实的重试按钮,有的卡在「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,没有规定客户端该怎么生成它。
  • 各工具的功能、额度、版本与可用地区会变化,具体以官网当前信息为准。