Skip to content

如何借助 AI 修复任何 bug #83

@coderPerseus

Description

@coderPerseus

原文:https://overreacted.io/how-to-fix-any-bug/ ,翻译官:Qwen 3 Max

引文: 使用 AI 来修复我们编程过程中遇到的 Bug 是日常的工作,但是很多时候,我们询问 AI,却无法帮我们解决问题,浪费了时间和 Token,而且自己还气的不行,下面看一下 React 社区大神 Dan 是如何和 AI 一起修复 bug 的吧!
我一直在用“氛围编程”(vibecoding)开发一个小应用,几天前遇到了一个 bug。 下面是正文👇

这个 bug 大致是这样的:想象一个网页应用中的路由,该路由展示一系列步骤——本质上就是一些卡片。每张卡片上都有一个按钮,点击后会滚动到下一张卡片。一切原本都运行得很好。然而,一旦我尝试在点击该按钮时同时调用服务器,滚动就失效了——页面会抖动并中断滚动。

也就是说,添加一个远程调用不知怎么就破坏了滚动功能。

我不确定这个 bug 的根源是什么。显然,新加入的远程服务器调用(我是通过 React Router actions 实现的)以某种方式干扰了我的 scrollIntoView 调用。但我无法确定具体是如何干扰的。我最初以为问题是 React Router 重新渲染了页面(因为 action 会触发数据重新获取),但原则上,一次数据重新获取不应该干扰正在进行的滚动操作。服务器返回的是相同的内容,所以本不该有任何变化。

在 React 中,重新渲染应当始终是安全的。问题出在别处——要么是我的代码,要么是 React Router,要么是 React 本身,甚至可能是浏览器的问题。

我该如何修复这个 bug?

我能请 Claude 来修复它吗?


第 0 步:直接修复它

我让 Claude 来修复这个问题。

Claude 尝试了几种方法。它重写了包含 scrollIntoView 调用的 useEffect 中的条件判断,并声称 bug 已经修复。但这并没有效果。接着它又尝试将平滑滚动(smooth)改为瞬时滚动(instant),以及其他一些改动。

每次,Claude 都会自豪地宣布问题已解决。

但其实并没有!

bug 依然存在。

这听起来可能像是在抱怨 Claude,但其实我写这篇文章的真正动机是:我发现人类工程师(包括我自己)也会犯同样的错误。因此,我想记录下我通常用来修复 bug 的流程。

为什么 Claude 会一再出错?

因为 它没有一个可复现的案例(repro)


第 1 步:找到一个可复现案例

一个 repro(即可复现案例)是一组操作步骤,当你执行这些步骤时,就能可靠地判断 bug 是否仍然存在。它就是“测试”。一个 repro 会说明:要做什么、预期会发生什么,以及实际发生了什么。

从我的角度来看,我已经有了一个很好的 repro:

  1. 点击按钮。
  2. 预期行为是向下滚动,但实际行为是滚动抖动。

更妙的是,这个 bug 每次都会发生。

如果我的 repro 不可靠(例如只在 30% 的尝试中出现),我就必须逐步排除各种不确定性来源(例如录制网络请求并在后续尝试中模拟它),或者接受效率损失——因为每个潜在修复都需要测试更多次。但幸运的是,我的 repro 是可靠的。

然而,对 Claude 来说,我的 repro 基本上并不存在。

问题在于,我 repro 中所说的“滚动抖动”对 Claude 毫无意义。Claude 没有眼睛,也无法直接感知这种抖动。因此,Claude 本质上是在没有 repro 的情况下工作——它试图修复 bug,却没有做任何具体验证。这种情况其实很常见,即使是我们中最优秀的人也会犯。

在这个案例中,Claude 无法精确遵循我的 repro,因为它不能“看”屏幕(拍几张截图也无法捕捉到抖动)。因此,如果我想让 Claude 修复它,我最初的 repro 并不合适。这看起来像是 Claude 的问题,但实际上在与他人协作时也很常见——有时 bug 只在某台机器上出现,或只对某个特定用户、特定设置生效。

幸运的是,有个技巧:只要你能说服自己新方法有助于推进原始问题的解决,你就可以用一个 repro 换取另一个 repro

下面介绍如何更换你的 repro,以及需要注意的事项。


第 2 步:缩小可复现案例

更换你正在使用的 repro 总是有风险的。风险在于,新的 repro 可能与原始 bug 完全无关,解决它只是在浪费时间。

但有时更换 repro 是不可避免的(Claude 无法看我的屏幕,所以我必须另想办法)。而且有时这样做对迭代大有裨益(例如,一个耗时十秒的 repro 远比一个耗时十分钟的 repro 有价值得多)。因此,学会更换 repro 是很重要的。

理想情况下,你应该用一个更简单、更窄、更直接的 repro 来替代原来的。

我向 Claude 提出的思路如下:

  1. 测量文档的滚动位置。
  2. 点击按钮。
  3. 再次测量文档的滚动位置。
  4. 预期行为是滚动位置发生变化(存在差值),实际行为是没有变化。

我的想法是,这大致等价于我亲眼所见的问题。虽然这个 repro 无法捕捉“抖动”,但滚动失败很可能与之相关。即使这不是唯一的问题,单独修复它也是值得的。

Claude 添加了一些 console.log,通过 Playwright MCP 打开页面并点击按钮。果然,尽管点击了按钮,滚动位置并未改变

好了,现在 Claude 能够验证 bug 确实存在了

我们是否已经完成了寻找 repro 的工作?

其实还没有!

缩小 repro 时一个常见的陷阱是:你以为找到了一个好的 repro,但实际上新 repro 捕捉的是一个表现相似但完全无关的问题。这是一个代价极高的错误,因为你可能会花数小时去解决一个与原始问题无关的问题。

例如,Claude 可能只是过早地读取了滚动位置,即使 bug 已被修复,它仍会“看到”位置未变。这极具误导性——即使修复正确,测试仍会显示 bug 依旧存在,Claude 就会错过正确的修复!人类工程师也会犯这种错误。

因此,每当你缩小一个 repro 时,还应确认:使用这个新 repro 仍然能够获得一个正面结果(“一切正常”)

用一个例子更容易说明这一点。

我让 Claude 注释掉网络调用(最初引发 bug 的代码)。如果新的 repro(“测量滚动位置 → 点击按钮 → 再次测量滚动位置”)确实捕捉到了我想修复的 bug(“点击时滚动抖动”),那么一个我已经验证过能修复该 bug 的改动(注释掉 action 调用)也应该能修复新 repro 中的行为(滚动位置现在应该会变化)。

事实正是如此!临时注释掉网络调用后,Claude 执行的测试也得到了修复——滚动位置确实发生了变化

此时,值得多次来回修改代码(注释/取消注释),以验证每次修改是否都能预测新 repro 的结果。(也可以尝试其他修改,以排除“每隔一次修改就生效”的巧合。)

滚动测量结果似乎仍与那个网络调用相关。

但这仍不意味着新 repro 代表了同一个问题。它可能完全无关,或者只是其中一部分。更换 repro 始终存在风险。不过,一个网络调用与滚动位置相关,至少和原始问题(滚动抖动)一样可疑,本身就值得解决。这已足够成为继续使用新 repro 的理由。


第 3 步:移除其他一切内容

我新建了一个分支,并要求 Claude 遵循以下工作流程:

  1. 运行 repro,确认 bug 存在。
  2. 从相关代码中移除某些内容(移除组件、事件处理器、简化条件、删除样式、移除导入等)。
  3. 再次运行 repro,确认 bug 是否仍然存在。
  4. 如果 bug 仍然存在,提交更改。
  5. 如果 bug 不存在了,记下可能“修复”它的理论,然后重置到上一次提交,尝试删除更小的部分。

我正要离开,于是告诉 Claude 继续工作,不要停,直到将 repro 缩小到无法再简化的程度——一个不含额外库(甚至不包含 React Router)且逻辑极简的 React 组件。

等我回来时,Claude 为我创建了几个复现案例,但令人沮丧的是,它们都没有复现出这个 bug

⏺ 我进行了大量调查,创建了三个逐步更精确的最小复现案例。
它们全都正常工作,但真实页面却失败了。我测试了:

- 纯 React 状态更新
- React Router 重新验证(revalidation)
- 组件重新挂载(remounting)
- 新的对象引用
- 同步加载器(loaders)
- 各种依赖数组
- 滚动过程中的多次渲染

我能想到的一切都已测试并排除。该 bug 在真实应用中始终可复现,
但在任何复现案例中都无法重现。这表明页面环境中存在某些
我尚未捕捉到的非常特定的因素。

临时解决方案(用 setTimeout 延迟网络调用)可靠有效,
这证实问题出在 mutation/revalidation 与滚动之间的时序上。
但我无法确定:为什么在真实应用中 React Router 的 revalidation
会破坏 scrollIntoView,而在复现案例中却不会。

我已提交所有复现案例和调查笔记,供将来参考。

这是否意味着缩小 bug 并不总是有效?

不是。

这意味着 Claude 没有遵守我的指示。但有趣的是,它违反指示的方式恰恰是人类(包括我自己)常犯的错误。

Claude 在简化代码时,开始形成理论:也许这个 effect 有 bug,也许和重新挂载有关,也许 React 做了什么奇怪的事。于是它开始测试这些理论,创建聚焦于这些理论的隔离复现案例,并观察它们是否表现出 bug。

提出理论并加以测试是很好的!我们确实应该这样做。

但请再看一下我的指示:

  1. 运行 repro,确认 bug 存在。
  2. 从相关代码中移除某些内容(移除组件、事件处理器、简化条件、删除样式、移除导入等)。
  3. 再次运行 repro,确认 bug 是否仍然存在。
  4. 如果 bug 仍然存在,提交更改。
  5. 如果 bug 不存在了,记下可能“修复”它的理论,然后重置到上一次提交,尝试删除更小的部分。

我真正想让它做的是:确保在任何时候,我们都有一个 bug 仍然存在的检查点,并且每一步都在缩小该 bug 的作用范围

Claude 过于沉迷于测试自己的理论,最终得到一堆实际上并未复现 bug 的测试案例。再次强调,测试新理论本身没错,但如果失败了,正确的做法是回到原始案例(那个仍然复现 bug 的案例!),继续移除内容,直到找到根源。

这让我想起了良基递归(well-founded recursion)的概念。考虑下面这个本应计算 斐波那契数fib(n) 函数实现:

function fib(n) {
  if (n <= 1) {
    return n;
  } else {
    return fib(n) + fib(n - 1);
  }
}

实际上,这个函数是有 bug 的——它会永远挂起。我不小心写了 fib(n) 而不是 fib(n - 2),于是 fib(n) 会调用 fib(n),再调用 fib(n),如此往复。由于 n 从未“变小”,它永远无法退出递归。

支持良基递归的语言不会允许我犯这种错误。例如,在 Lean 中,这会是一个类型错误

def fib (n : Nat) : Nat := /- error: fail to show termination for fib -/
  if n ≤ 1 then
    n
  else
    fib n + fib (n - 2)

Lean 知道 n “没有变小”(详见此处),因此知道这个递归会永远挂起。它不会“随着时间推移而接近终止”。

这并非 Lean 教程,希望你能原谅这个轻率的比喻。

我认为,缩小 repro 案例的过程与此类似。你必须确保自己始终、始终在取得渐进式进展,且 repro 不断变小。这意味着你必须保持纪律性,一点一点地移除内容,只有在 bug 仍然存在时才提交更改。最终,你会耗尽所有可移除的内容,这时要么暴露出你代码中的错误,要么暴露出那些无法进一步简化的部分(如 React)中的问题。

不断重复,直到找到根源。


第 4 步:找到根本原因

Claude 最终没能解决这个问题,但它让我非常接近真相。

在我要求它真正遵守我的指示、只做删除操作后,它删减了足够多的代码,将问题缩小到了单个文件。我把这个文件移出路由器,突然同样的代码就能正常工作了。再把它移回路由器,问题又出现了。接着我将它设为顶层路由,它又能正常工作了。

问题出在它被嵌套在根布局(root layout)内部时。

我的根布局如下所示:

import { Outlet, ScrollRestoration } from "react-router-dom";

export function RootLayout() {
  return (
    <div>
      <ScrollRestoration />
      <Outlet />
    </div>
  );
}

啊哈!原来,曾经存在一个 bug(已在六月修复),导致 React Router 的 ScrollRestoration 在每次重新验证(revalidation)时都会激活,而不是仅在路由变更时激活。由于我的网络调用(通过 action)触发了路由重新验证,它在 scrollIntoView 执行期间触发了 ScrollRestoration,从而导致了抖动。

这种工作流程——一边确保 bug 仍然存在,一边逐个移除内容——曾多次救我于水火。(我曾花一周时间删除 Facebook React 树的一半来追踪一个 bug,最终的 repro 只有约 50 行代码。)在穷尽所有理论之后,我不知道还有哪种方法比这更有效。

如果我自己搭建项目,我会使用最新版的 React Router,也就不会遇到这个 bug。但这个项目是由 Claude 搭建的,它不知为何决定让我使用一个核心依赖的旧版本。

唉,算了!

这就是“氛围编程”的乐趣所在。

Metadata

Metadata

Assignees

No one assigned

    Labels

    AI这是 人工智能bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions