该停下来的信号:怎么判断一次改动是「结构问题」而不是「bug 问题」
你不写代码,所以看不见「结构」。但你能数出一个数:同一件事,这次被改在了几个地方(本篇管每一个这样的地方叫一个「落点」)。数到 3,就该停——这篇给你那份信号清单、停下来之后能直接说的话,以及哪些情况其实是真的 bug、照常修就行
📅 更新于 2026-09-16
0030 秒速览:三句话,然后决定你从哪一节开始
先说清这篇解决的是什么。它不教你怎么写代码,也不给你一套新工具。它只补一个判断:眼前这一次改动(就像「点了没反应」这种没按你想要的样子表现的问题,也就是平常说的 bug),是该继续修,还是该停下来回头看结构。
你以前没有这个判断,所以你只有一个动作——继续修。而「继续修」这条路有个特点:它永远不会自己告诉你该停了。 这篇就是把那个「该停了」的信号,从感觉变成一个你能数出来的数。
如果只记三句话
① 问题不在 bug 本身,在「同一件事被存在了几个地方」。一次改动,同一件事只落在 1 处,那是普通 bug;落在 3 处以上,那是结构问题——这时候接着修,只会引出下一个问题。
② 该停下这件事,只能由你判断,AI 判断不了。它每次只看得见它读到的那几个文件,手里没有一张「这个系统长什么样」的图。所以它的每一次修复都是局部最优(只看眼前那几行,改得对;放进整体,经常有害)。这不是它不努力,是它拿不到那张图。
③ 停下来 ≠ 重写(重写 = 丢掉旧的、按新思路再写一遍)。停下来的第一个动作小得多:把「同一件事的 3 个落点」收成 1 个。收完,这件事就回到「改一处就够」了。
如果你只有 5 分钟:看这三节
01 · 起手动作(60 秒,立刻能测出你手上这个项目有没有这个问题)→ 04 · 五条信号(对照你现在正卡着的那一次改动)→ 05 · 反面(防止你从此看什么都像结构问题)。
这篇假设你手上有什么
诚实说在前面:这篇需要一个你自己能跑起来的项目,哪怕很烂。没有项目的话,它对你就是一份说明书,读了也用不上。 具体要什么、缺了怎么办,下一节第一段就说清。
01起手动作:读完 60 秒内做的那一件事
上面那三句话你未必信。没关系,你可以现在就测一次——用你自己上次那次「改完还是不对」的经历测,不用信我。
第一个动作:把这句话发给 AI,然后只数一个数
打开你和 AI 的上一次对话。就是那一轮:你说了哪里不对,它改完,你还是觉得不对(或者它说改好了,回头又坏了)。把下面这段原样发过去:
先别动代码。只回答三个问题:
1. 刚才那次改动,你一共动了几个文件?一个一个列出来。
2. 每个文件里你改的,是「同一件事」的哪一份?
3. 这件事在项目里现在一共存在于几个地方?
不确定的地方就说「不确定」,不要猜。
然后你只做一件事:数第 3 问的答案。
第 3 问才是判据,第 1、2 问是为了让你能验证它的答案。AI 说「动了 3 个文件」可能只是它手脚大(那是另一个问题,见 08 误判二);只有第 3 问答出「这件事现在有 3 处」,你手上才真的是结构问题。
做这个动作之前,你手上必须有什么
两样,缺一不可。这一栏比上面那一栏重要——它让你在花时间之前就能判断这篇对你有没有用:
不用好。能打开、能点、能看见它现在哪里不对,就够了。半途停下的也算——这篇恰恰是给那种项目写的。
不是假设的场景,是你和 AI 之间真实发生过的那一轮。因为它留下了痕迹:AI 当时确实动过那些文件,你现在去问,它答得出。你拿一个编出来的场景去问,它只会编一个同样编出来的答案——数出来的数没有意义。
不是你的问题,也不是这篇的问题:这份判断需要一次真实的改动当素材。没有素材,它读起来就是一份说明书,读完还是用不上。
缺的是项目,不是这篇。先去把某一个旧项目弄到「能打开、能点」的状态(那是另一件事,不在这篇的范围内),再回来做上面那个动作。
想现在就用上:把同一个问题提前问一次
上面那个动作是回头测。你也可以在让 AI 动手之前先问一遍——只要 30 秒,而且比事后追问更值:
这件事你打算改哪几个文件?先列出来,先别动手。
如果同一件事在项目里存在多处,把它们一并列出来。
这么做的好处是它给你一份预期。等它真的改完,你看到实际改动范围,超没超你一眼就知道——不用读代码,你只需要对比两份清单。这一步解决的是「它顺手改了我没让它碰的东西」,和这篇要解决的结构问题是两件事,但两件都值得做。
02「结构问题」是什么:一个你能数出来的数
先破掉一个误会:它跟代码好不好看没关系
你大概听过「代码乱」「代码丑」「屎山」这类说法。它们听着像同一件事,其实不是。「丑」是审美,「结构问题」是算术。
一份代码可以写得很丑,但同一件事只有一处——那它在改动上是健康的,不是结构问题。反过来,一份代码看起来很整齐(每个文件都短短的很干净),但同一条规则存在四个地方——那它就是结构问题。判据跟美丑无关,只跟「同一件事有几处」有关。
这个区别对你很实际:因为你看不见美丑(你不读代码),但你能数得清处数。这篇从头到尾都在用你能拿到的那一个量。
正向的定义:同一件事被存在了不止一个地方
一句话:同一件事在项目里存在几处,决定了你改一次要付出几倍的力。
当它存在 1 处,「改对」只需要改一次。当它存在 3 处,「改对」就变成了「三处都记得改」——而「记得」不是一种能力,是一种运气。你之前那些「改完还是不对」,绝大多数不是改错了,是漏了一处。
这件事在软件工程里有个名字,也有出处。Martin Fowler 在《重构》第一版(1999 年)第 3 章「代码里的坏味道」里,把它叫做霰弹式修改。(《重构》是一本书的书名,指的是「改变代码的内部组织方式,但外部行为一个字不变」这类做法;「坏味道」= 那些一看就知道不对劲、但还不能算错的征兆。这两个词 06 节还会细讲。)他的原话大意是:每次你想做一种改动,都不得不对很多个地方各做一点小修改;当改动散得到处都是的时候,它们很难被找全,而漏掉重要的一处太容易了。
他在同一节里还给了反面:一个地方因为好几种互不相干的原因被反复修改,叫发散式变化。这一节的收束句值得抄下来——理想状态下,一种常见的改动,应该只对应一个地方。
上面引的是《重构》第一版(1999)。第二版(2018)把这一章改了大约四分之三(Fowler 自己说的原话是「我猜四分之三变了,但那是感觉不是实测」),两个名字和含义保留,措辞有差异,所以别把这段话当成他的最新表述。
为什么阈值是「3」,而不是「2」
同一个来源里还有一个经验法则,叫三次法则(Rule of Three)。Fowler 说这是 Don Roberts 给他的,原话大意是:第一次做某件事,你直接做;第二次遇到类似的事,你会为重复皱一下眉,但还是照做一遍;第三次再遇到类似的事,你就该动手整理了。
为什么 2 不够、3 才算:两处相像,可能只是相像。 两个不同页面上的按钮恰好用了同一个颜色,那不是「同一件事」。第三处出现,说明有一条规则被抄了三份——它是个模式,不是巧合。
真正的判据是「这是不是同一件事」,3 只是个提醒你抬头看一眼的数字。别把它当成硬性标准:同一件事出现 2 次、但它已经在悄悄不一致了(一处的规则已经和另一处不一样),那第 2 次就该停。反过来,3 个地方改的是 3 件不同的事,那 3 是噪音,不是信号。
它和「技术债」「代码烂」是什么关系
那些词是笼统的说法,你没法拿它们做决定——你没法数「有多少债」。这篇给的是一个能数的量:同一件事现在有几个落点。能数出来,才能比较、才能判断该不该停。你以前的困境不是缺判断力,是缺一个可数的东西。
03为什么「再修一次」只会让事情变多
这一节解释的是你最想搞明白的那件事:为什么不管怎么修,问题都会涌现出来。
先把你走过的那个循环写出来
如果你有过这种感觉——发现一个 bug,让 AI 去改,它改完,问题没解决,反而多出来两个;再改,又多出来——这不是你运气差。这是一个稳定的循环,走进去的人都会走到同一个地方:
关键在后面那一步:「加新的」这个动作本身,就在给系统添东西。 你不是在失败,你是在按这套循环的规则正确地运行。所以问题不在意志力,也不在「底子太差」。
机制一:改症状,不改根因 → 错从别的地方冒出来
同一件事存在 3 处,AI 只改了它读到的那 1 处。另外 2 处还按老规矩运行。于是你看到的现象是「修好了 A,B 又不对了」——那个 B 不是新问题,它是同一件事的另一份,一直就在那里,只是你上次没走到那条路上。
机制二:一次改太多处 → 出了新问题,你也不知道是哪一处引起的
很多 AI 会顺手把另外两处也一起改了,有时还顺手改了点别的。这时候它「改好了」,但出了问题你无法定位:你没有「一次只改一件事」这个对照。 三个地方同时变,错在哪一处,只能靠猜。
机制三:它只看得见它读到的文件 → 每次都是局部最优
这是三条里最根本的一条。
AI 手里没有一张「这个系统长什么样」的图。它每次开工,只读到被打开的那几个文件,然后在这几个文件的范围里给出一个最优的修改。只看眼前那几行,它改得是对的;把这几行放回整个系统里,这个修改经常是有害的。
一是越界:你只让它「包一层判断」,它顺手把周边代码也重整了一遍,还加了一条你从没要求过的行为。二是级联扩大:一个小的报错让它觉得「底下那个约定写错了」,于是改了那个约定;为了让所有用到这个约定的地方都对上,它把每一个相关文件都改了——一个文件的修补,变成二十个文件的整体改动。这两种形状本站另一篇已经收过成套的一手证据(issue = 用户在公开代码仓库里提的问题记录;见《用 skill 维护老项目》§08 与 §03 的护栏样例),这里只用来解释你遇到的那一类。
更麻烦的是它分不清两件事:「完成你交代的任务」和「因为它发现了更好的做法、所以重塑这个项目」。 在它那边,继续做看起来就等于进展。一个有经验的开发者知道什么时候不该写代码——这个判断目前还不在它那一侧。所以「该停了」只能由你说。这句话不是推给你,是它真的给不了。
于是「越修越多」不是意外,是算术
把三条合起来看:每修一次,如果落点没有减少,你就多了一处「下次必须记得的地方」。 下一次改动要动的处数只会增加,不会自己减少。这就是为什么修到后来,你的信心比代码先垮——因为你无法预测下一步会不会更好。信心来自「我能预测下一步会发生什么」,而落点数不清,就没法预测。
有一条实证能说明「在乱的地方继续修」这条路成本是往上走的(口径是观察性统计,不是因果证明):2022 年的一项研究统计了低质量代码与高质量代码的对比——低质量代码里的缺陷数量是高质代码的 15 倍以上;在低质量代码里解决问题,平均要多花 124% 的时间,最长处理周期长 9 倍(Tornhill & Borg, Code Red, arXiv:2203.04374)。
所以这一节要给你的不是安慰,是一个算术结论:你不是在修复,你是在给下一次的「必须记得」名单上加一行。
04五条信号:该停下来的那一刻长什么样
这五条有一个共同点:都是你能亲眼看见的,都不需要你读代码。 按「最容易先撞上」的顺序排——第一条是主信号,后面四条是它的各种变形。
信号 1(主信号)· 同一件事落在 3 处以上
你看见什么:AI 说「我改了 order.ts、cart.ts、checkout.ts」——三个文件(.ts 结尾的是代码文件,一个文件大致相当于一页纸,里面装着一部分代码),而这三个文件里改的是同一条规则,比如「满 199 免运费」。
它说明什么:不是「三个文件」多,是同一条规则有三份。规则本身只有一条,项目里却存了三份,所以你每改一次规则,都要记得三处。
这一刻你该做什么:用 01 节那三问,把第 3 问的数字拿到手。数字 ≥3,停。
别搞错的地方:改了 3 个文件、但改的是 3 件不同的事(比如三个页面各自的文案),不算信号。单位永远是「同一件事」。
信号 2 · 同一处坏了第二次
你看见什么:上个月修过的地方,这个月又坏了;或者你从另一个入口进去,发现它还是坏的。
它说明什么:bug 修好了就不该回来。回来只有两种可能——上次修的是症状(另外几处没动),或者你从另一个入口进去碰到的是同一件事的另一份。两种情况都是结构问题。
重要的数字:第二次就够了。不用等第三次。同一处坏第二次,是这五条里最快能确认的一条,因为它不需要你问 AI 任何问题——你记得,你上次就修过这里。
信号 3 · 修 A 引出 B
你看见什么:你说「按钮点了没反应」,它改完,按钮好了,表单提交坏了。
它说明什么:不是它手笨,是 A 和 B 共用着同一处东西,而那一处只有一份、被两件事同时拉扯。你改那一处,代价由两处一起承担。这个现象在软件工程里被专门研究过,有个名字叫「引发修复的改动」——确实有一类改动,它本身会引出后面的修复(Śliwerski / Zimmermann / Zeller, MSR 2005)。
这一刻你该做什么:先别让它「再修一次 B」。修 B 会让共用那一处继续被拉扯,下一次坏的是 C。先问:「A 和 B 是不是共用同一处东西?如果是,那一处现在有几份?」
信号 4 · 你要把同一条规则交代第二遍
你看见什么:「别忘了订单页也要加」「上次说的那个,这里也适用」。你说过一次,又得说第二次。
它说明什么:你说第二遍的那一刻,就证明了那条规则在项目里没有唯一的存放处——它散着,所以每一次都得重新告知一遍。这条是所有信号里最早出现的,也是最容易被忽略的,因为它不疼:只是多说一句话而已。但它是在替项目记住一件事,而那份记忆不在项目里,在你脑子里。
信号 5 · 你说不清「这个功能在哪几个地方」
你看见什么:有人问「这个功能在代码里在哪」,你答不上来,只能说「AI 知道」。
它说明什么:你答不上来,AI 也只能靠猜(它只读得到被打开的文件)。这一条是其他四条共同的源头:那个「系统长什么样」从来没有存在于任何地方——不在你脑子里,也不在任何文件里。这就是 03 节「机制三」里说的盲区的来源。
这一刻你该做什么:注意,这一条的解法不是「读懂代码」。这个动作对你不成立,也不需要成立。可做的动作是问 AI 要一段文字答案:
这件事(比如「运费怎么算」)现在在项目里存在于几处?
每一处在哪个文件、大概负责什么?
用中文回答,不要贴代码。
你能判断这段文字对不对——那是你的项目,你知道运费是怎么回事。你判断不了的是代码写得好不好,那不需要你判断。
五条信号速查
读第二遍的时候直接查这张表。第三列是这一刻该做的事,和上面的正文不重复:
| 信号 | 你亲眼看见什么 | 这一刻该做什么 |
|---|---|---|
| 同一件事落在 3 处以上 | 它改了三个文件,改的是同一条规则 | 问 01 节那三句,拿到处数;≥3 就停 |
| 同一处坏了第二次 | 修过的地方又坏了 | 不用问任何人,直接按结构问题处理 |
| 修 A 引出 B | 按钮好了,表单坏了 | 先别修 B。问 A 和 B 是否共用同一处 |
| 同一条规则要交代第二遍 | 「别忘了那边也要加」 | 先记下来,别立刻动手;这是最早的信号 |
| 你说不清功能在几处 | 你只能答「AI 知道」 | 让它用中文文字回答,不贴代码 |
05反面:哪些是真的 bug,照常修就行
这一节和前面同样重要。这篇的目的不是让你从此看什么都像结构问题。 过度反应和反应不足一样贵,而且过度反应更容易发生——因为它感觉更专业、更有掌控感。你会开始说「这里得重构一下」,而其实只需要改一个错别字。
判据只有一句:下一次同类的事来了,你还要不要走同一遍
第二件同类的事来了,你还是得改这几个地方。那就说明这几处是同一件事的多份,而不是碰巧都要改。
修完就结束了,不会再有第二件同类的事。那你改的那一处就是它唯一的家,没有第二份。
这一问的好处是它不需要你懂代码,只需要你回顾:「这种事我以前遇到过吗?以后还会遇到吗?」 这个你答得了。
五类「照常修就行」
路径写错了、名字拼错了、少写了一步等待、某个依赖没装(依赖 = 别人写好的、你的项目要用到的东西)。改一处就好,那就是 bug。
比如页面上要多显示一项内容,服务器那边就要多返回这一项。这是两件不同的事各改一次,不是同一件事的两份。 这一类你最容易误判成结构问题——因为「动了三个文件」和信号 1 长得一模一样。区分法:问它们是不是同一条规则。不是,就放行。
那一条记录本身是脏的,或者某个文件是你自己手动改坏的。错的是数据,不是代码的组织方式。 改数据,或者删掉那条脏数据重来。
换一台机器就不行、升级之后打不开、别人电脑上正常你这里不正常。这类问题的落点在配置(那些填写项和设置),不在代码怎么组织。
比如只有数量刚好是 0 的时候才出错。修好之后你怎么试都不再出现——那它就是一次性的 bug。
另一个方向的警告:别急着「合并重复」
你已经受过一次「重复」的苦,所以很自然会产生一个冲动:一看到两处长得像,就立刻让 AI 把它们合并成一处。这个动作会做错,而且做错了比重复更难拆。
Sandi Metz 在 2016 年写过一篇《错误的抽象》,核心两句:重复远比错误的抽象便宜;而一旦你造出了错误的抽象,最快的出路是往回走——把合并出来的东西再拆回给每个用到它的地方(「调用方」),重新容忍重复。她还给了失灵的信号:合并出来的那份东西上,每次用它要传进去的东西(「参数」)越加越多、里面「如果是 A 就…否则就…」的分岔(「条件分支」)越加越多,就说明它已经不再代表「同一件事」了。
所以当 AI 说「这里有两处重复,我把它提取成一个公共函数吧」(公共函数 = 把几份一样的写法收成一份,让几处都去用它),而你手上还没有第三处证据的时候,你可以直接说:
先别合并。等第三次真的出现再说。
它不是「永远别合并」。当三处都出现了、而且你确认它们是同一件事,那就该合并——那正是 06 节要做的事。这条警告针对的是「看到两处像就动手」这个冲动。
还有一个时机问题:别在「今晚就要用」的时候停
如果你现在正需要它工作——今晚就要用、明天要给人看——那就先打补丁让它能用,把「收落点」排到能用之后。
但有一个条件:延期要写下来。 不写下来的延期,会变成永久的「以后再说」,而「以后」不会自己到来。写一句话就够了:「运费规则现在散在三个文件里,等这版用完要收成一处。」——这句话写在哪儿都行,只要是你下次会看到的地方。
06停下来之后:把 3 个落点收成 1 个
「停下来」这三个字,在你以前的经历里很可能等于「重写」——因为过去你停下来之后的结果,就是项目烂尾。所以你会本能地抗拒停。但停下来不等于重写,这两件事之间差得很远。
先分清「重构」和「重写」
| 重构 | 重写 | |
|---|---|---|
| 做什么 | 改变代码的内部组织方式 | 丢掉旧的,按新思路再写一遍 |
| 外部行为变不变 | 一个字都不变 | 全部重新定义 |
| 中途能不能停下来用 | 能,每一步之后它都还是能用的 | 不能,写完之前它是坏的 |
分界线只有一句话:「你能不能在每个中间状态都发布?」(中间状态 = 整理到一半、还没全部做完的那个时候;发布 = 把新版本放出去给人用)能,是重构;不能,是重写。(本站另一篇《用 skill 维护老项目》§05 的「三组最容易搞混的词」把这条讲得更细,可以对着看。)
但对你要补的只有一件:你不需要现在决定要不要重写。 你要做的第一步比这两者都小——小到不构成一个「项目」,只是一次改动顺手做的事。所以它不需要排期,也不需要信心。
第一步:收落点。验收标准是「行为一件都没变」
Kent Beck 在《Tidy First?》(2023)里给过一个很有用的区分:把改动分成结构改动和行为改动。他的说法是:结构改动大体上是可以回退的,行为改动往往不可逆,而把这两者分开,本身就有价值。
这条给你的直接好处是——它给了你一个能验收的标准。 你不需要判断代码写得好不好(那本来就不是你的位置),你只需要判断一件事:
收完之后,你去点一遍,所有功能都跟之前一模一样。有一个功能的表现变了,那就不是结构改动,是改错了。
这就是「结构改动允许改变的行为数 = 0」的意思。它是你能百分之百验证的一件事,而且只有你能验证——因为你最清楚这个工具该有什么表现。
三步做完,以及每步该说的话
要一份文字答案:这件事现在在哪几处、每处的写法差在哪。这一步的产出是你能核对的一段话,不是代码。
很多情况下你会在这里发现一件更要紧的事:那几处已经不一致了。 见 07 节的例子——你之前遇到的 bug,往往就是这条不一致。
先别改。只回答:这件事在项目里现在存在几处?
每一处在哪个文件、写法差在哪?用中文说,不要贴代码。
选哪一处留着?选最符合你真实意图的那一处(如果它们已经不一致,你才知道哪个是对的)。其余几处不再自己算,改成去问留下来的那一处。
把这几处收成一处。留下的那一份要改得能覆盖现在所有情况。
要求:外部行为一个字都不变——所有功能的表现必须和现在完全一样。
在确认之前,让两边同时存在、结果必须一致。你去点一遍(购物车、结算页、订单页,每个入口都走一次),确认行为一个字没变,再让它把旧的删掉。
现在这件事在项目里只剩一处了吗?
如果你不确定,就说「不确定」,不要猜。
最后那一句不是客套。「不确定」对你是好消息——它让你知道哪里还没被验证;「大概是吧」才是坏消息,因为它让没验证的东西看起来像验证过了。
下次直接用的话(都收在这里)
① 数落点(判断用)——就是 01 节那三问,它是全篇最该记住的一段,不在这里重复。
② 让它先回答再动手(收落点第一步)
先别改。只回答:这件事在项目里现在存在几处?
每一处在哪个文件、写法差在哪?用中文说,不要贴代码。
③ 收落点,并要求行为不变
把这几处收成一处。要求:外部行为一个字都不变——
所有功能的表现必须和现在完全一样。改完告诉我点哪里可以验证。
④ 防止它顺手扩大范围(和结构问题无关,但一起挡掉更省事)
只改我点名的文件。如果你觉得别的文件也该改,先列出来问我,不要直接动手。
最后这两句是本站《用 skill 维护老项目》§03 给的护栏原话(它放在 CLAUDE.md 里长期生效——那是给 AI 的一页常驻说明,每次开工它都会先读到),这里照抄,因为实测有效。
为什么不用专门排一个「整理时间」
一个很有用的提醒,来自 Fowler 在《重构》里谈「什么时候该重构」那一节。他大意说:几乎在所有情况下,我都反对专门腾出时间来做重构。 你不是决定去重构——你是因为要做别的事,而重构能帮你做成那件事,所以顺手做。
对你的意义很直接:不要给「整理」排期。 排期会把它变成一个没有截止日期的大工程,而那正是它一直没发生的原因。正确的时机只有一个:下一次你本来就要改这件事的时候。 那时候你本来就要动它,收落点只是顺手——而且不做的话,你这次又要改三处。
07最小例子:一条运费规则散在三个文件里
这是本篇唯一的完整走查。它演示的就是最要紧的那件事:结构问题一开始长得完全不像结构问题。
事情的经过(注意每一次都「修好了」)
你让 AI 给一个小店后台加一条规则:满 199 免运费。
第一次
AI 改了算总价的地方(order.ts),加了一句「满 199 就免运费」。你试了一下,下单,运费是 0。好了。
两周后
你发现购物车页面上显示的运费不对——明明够了 199,它还收你运费。AI 找到购物车那块代码(cart.ts),也加了一句同样的规则。你试了,好了。
再过一周
结算页的运费还是不对。AI 改了结算那块(checkout.ts)。你试了,好了。
三次都是「改一处、好一处」。 所以它长得完全不像结构问题——没有一次改动落在 3 处,每一次都干净利落。你甚至会觉得这个项目挺健康的。
现在把 01 节那句话发给它
你问:「这件事在项目里现在一共有几个地方在算?」答案是 3。
然后你让它把这三处贴出来对比。你会看到类似这样的东西:
order.ts 满 199 免运费 → if (total >= 199)
cart.ts 满 199 免运费 → if (total >= 199)
checkout.ts 满 199 免运费 → if (total >= 200)
第三处写的是 200。可能当初是有人图个整数好看,也可能纯属手误。无论哪种,这就是你之前那些 bug 的来源——同一条规则,两个地方说 199,一个地方说 200。
所以「三处都记得改」从来不是一个可靠的方案:就算你每次都记得,它们也会自己走散。 这不是记性问题,是重复本身的问题。
收落点:留下最符合你意图的那一份
你确认「满 199 免运费」才是你要的(那两处是对的,200 那处是错的)。于是:
order.ts 里那一份,并把它写清楚门槛值 199,规则只在这一个地方定义一次。
购物车页和结算页不再各写一遍规则,而是去要同一个答案。外部行为必须一个字都不变。
购物车、结算页、订单页,每个入口走一次,确认运费显示和改之前完全一样。注意:改之前那一处 200 是错的,统一到 199 之后,那个页面在「刚好 199」的情况下会变。 这是你要的行为变化——所以它不是「行为没变」,是你主动决定要变的。剩下所有不该变的地方,一处都不能变。
收完之后,什么变了
你要说:「把运费门槛改成 299。注意购物车、结算页、订单页都要改。」
——后面那半句,是你在替项目记住一件事。你成了那份记忆的存放处,而你会忘。
你说:「把运费门槛改成 299。」
——项目自己记得。你不需要记住任何东西,这就叫「这件事只有一处」。
这篇文章的其他部分你都可以读,但这一条只有你能做:挑一个你自己的项目,回想最近一次「改了又坏」或者「改完还是不对」的事,把 01 节那三句话发给 AI,把第 3 问的数字记下来。
如果数字是 3 或更大,你就亲眼看到了自己那十几个项目的失败模式长什么样——而且它和你以前以为的(「我底子太差」「模型能力不够」)很可能不是一回事。
08三个最容易误判的场面
这三处是「反应不足」和「过度反应」各自的高发区。每一条都给一个区分法,区分法都不需要你读代码。
误判一:把「代码丑」当成结构问题
你的感觉:「这一堆乱糟糟的,肯定有问题。」
为什么错:丑和落点数是两件事。一份很难看的代码可能同一件事只有一处,改起来很顺;一份看起来很整齐的代码可能同一条规则存在四处。
区分法:别问「它好不好看」,问「同一件事有几处」。前者你判断不了也不该判断,后者你数得清。
误判二:把「AI 顺手多改了几个文件」当成结构问题
你的感觉:「它动了 5 个文件,肯定是结构问题。」
为什么错:AI 常常顺手改格式、加注释、动一个你根本没提的文件。那叫越界,是范围问题,不是结构问题。它说明的是「你没有把范围框住」,不是「你的代码有结构问题」。
区分法:看这几个文件改的是不是同一件事。不是同一件事 → 那是范围问题,用 06 节第四句话(「只改我点名的文件」)挡回去就行。这也正是为什么 01 节那三问里的第 2 问(每一处改的是「同一件事」的哪一份)不能省。
误判三:把「难修」当成结构问题
你的感觉:「这个 bug 找了好久才修好,说明结构有问题。」
为什么错:难修的意思是找不着;结构问题的意思是找着了,但改一处不够。这两件事的解法完全不同:前者是定位问题(多看几处、把现象描述得更准),后者才是本篇讲的事。
区分法:问自己「找到了以后,改一处就够了吗?」找到了、改一处就够 → 那只是难找,不是结构问题。
三个场面对照
| 你看到的现象 | 是结构问题吗 | 判据 |
|---|---|---|
| 代码很乱很难看 | 不一定 | 丑是审美,跟你无关;数落点才算 |
| 它改了 5 个文件 | 不一定 | 看这 5 处是不是同一件事;不是就是越界 |
| 这个 bug 很难找到原因 | 不是 | 难修 = 找不着;结构问题 = 找着了但改一处不够 |
| 同一件事落在 3 处 | 是 | 主信号,直接停 |
| 同一处坏了第二次 | 是 | 第二次就够,不用等第三次 |
09术语速查:这几个词别混
都是本篇出现过的词。第三列回答的是「什么时候它会帮到你 / 别把它当成什么用」——如果你只需要记一件事,记着别把它们互换着说。
| 词 | 在这里指什么 | 为什么重要 · 别当什么用 |
|---|---|---|
| 落点 | 同一件事在项目里被存放的某一处 | 判断的单位是「同一件事有几个落点」;别数成「改了几个文件」 |
| 结构问题 | 同一件事散落在 3 处以上,改一次要改多处 | 是算术不是审美;别拿它当「代码丑」的同义词 |
| bug | 程序没按你想要的样子表现 | bug 描述现象,不描述原因——落点散开引起的错,表现出来也叫 bug |
| 局部最优 | 只看眼前几个文件改得对,放进整体有害 | 这是 AI 的默认状态,不是它不认真;别指望它自己跳出来 |
| 重构 | 改内部组织方式,外部行为一个字不变 | 验收标准是「点一遍,表现全一样」;别和重写混着说 |
| 重写 | 丢掉旧的,按新思路再写一遍 | 中途不能用,是它和重构的分界线;别用「重构」安慰自己做重写 |
| 霰弹式修改 | 一次改动逼你在很多地方各改一点 | 这是 Fowler 给主信号起的名字,不是你发明的感觉 |
| 发散式变化 | 一个地方因为好几种不相干的原因被反复改 | 主信号的反面,指同一个文件;判断时别和霰弹式弄反 |
| 三次法则 | 第一次直接做,第二次皱眉还是做,第三次才整理 | 它是「提醒你抬头」的闹钟,不是硬标准 |
| 公共函数 | 把几份一样的写法收成一份,几处都去用它 | 收落点就是造一个它;但对只有两处相像的,先别造 |
10场景索引:你现在的处境 → 该看哪一节
不用从头读。找到你现在那句话,直接跳:
| 你现在的处境 | 先去哪 | 为什么 |
|---|---|---|
| 我刚让 AI 改完,感觉它动了一堆东西 | 01 → 08 误判二 | 先数落点;如果只是越界,别误当结构问题 |
| 同一个地方坏了第二次 | 04 信号 2 → 06 | 最快能确认的一条,不用问任何人 |
| 修好 A 之后 B 坏了 | 04 信号 3 → 03 | 先弄清 A 和 B 是否共用同一处,别再修 B |
| 我分不清这是 bug 还是结构问题 | 05 | 那一节给的就是这条分界线 |
| 我确认是结构问题了,但不知道跟它说什么 | 06 的四句话 | 直接复制,不用自己组织语言 |
| 我想干脆推倒重写 | 06 的「重构 vs 重写」 | 先确认它是不是真的不能「每个中间状态都发布」 |
| 我今晚就要用,现在不能停 | 05 最后一节 | 先打补丁,但要写下来延期,别让它变成永久 |
| 我连一个能跑起来的项目都没有 | 01 的警告框 | 这篇今天对你用不上,先去把某个项目弄到能跑 |
11来源与证据边界
一手来源
- Martin Fowler《重构:改善既有代码的设计》第一版(1999)第 3 章「代码里的坏味道」——霰弹式修改与发散式变化的定义与收束句、第 2 章「什么时候该重构」里的三次法则(Fowler 注明是 Don Roberts 给他的)、以及「反对专门腾出时间做重构」那段。
版本提示:引的是一手第一版。第二版(2018)把原理与坏味道两章大改,Fowler 自己在 refactoring-2nd-changes 里说「我猜四分之三变了,但那是感觉不是实测」。两版保留了两个名字与含义,措辞有差异。
另一个必须说的:refactoring.com 上并没有坏味道目录(那里的 catalog 收的是重构手法),所以这两个 smell 没有「官方在线定义页」,只能引书。网上常见的 refactoring.guru 页面是第三方整理,不要把它当成 Fowler 官方。 - Kent Beck《Tidy First?》(O'Reilly, 2023)——结构改动与行为改动的区分。原话见 Beck 自己的通讯:Distinctions with a Difference(「结构改动大体上可逆」「行为改动往往不可逆」「把行为改动和结构改动分开,本身创造价值」)。
口径提醒:网上流传的「两者绝不能混在同一次提交里」没能在 Beck 原文里核到,那些都是书评人的转述。本篇写成「Beck 的主张」,没打引号当原话。 - Sandi Metz《The Wrong Abstraction》(2016)——sandimetz.com/blog/2016/1/20/the-wrong-abstraction(「重复远比错误的抽象便宜」「最快的出路是往回走」,以及参数与分支越加越多这个失灵信号)。
归属提醒:「第三次才抽象」是三次法则(Fowler 转述 Don Roberts),不是 Metz 说的。两条依据独立,本篇分开写。 - Tornhill & Borg《Code Red: The Business Impact of Code Quality》(2022)——arXiv:2203.04374:低质量代码的缺陷数量是高质代码的 15 倍以上;在低质量代码里解决问题平均多花 124% 时间、最长处理周期长 9 倍。
证据边界:这是观察性统计,支持相关推断,不是因果证明。 它说明的只是「低质量与高成本同时出现」,不能证明「因为质量低所以慢」。 - Śliwerski, Zimmermann, Zeller《When Do Changes Induce Fixes?》(MSR 2005)——st.cs.uni-saarland.de:提出「引发修复的改动」这一概念,用来支撑 04 节信号 3。
- 本站另一篇《用 skill 维护老项目》(ai-legacy-code-maintenance.html)——本篇从它那里借了三样:重构与重写的分界线(§05「三组最容易搞混的词」)、护栏原话「只改我点名的文件」(§03 的
CLAUDE.md样例)、以及 AI 越界与级联扩大的成套一手 issue 证据(§08)。两篇不重复:那一篇讲的是「装哪几个 skill、按什么阶段调用」,本篇讲的是「在我还没决定要不要动手之前,先判断这是不是结构问题」——正好是那一篇 §09 没有覆盖的一格。
本篇没有核到的东西
- 没有权威一手来源把「反复修同一处 / 修一个引出一个」命名为某个专门术语。 网上流传的说法多半是低质量二手内容,所以本篇没有引它,改用了 MSR 2005 的「引发修复的改动」这个有出版来源的概念来代替。
- 「3」这个阈值没有精确的实验来源。 它来自三次法则这个经验法则,加上实践中反复观察到的现象。本篇把它当「提醒你抬头的闹钟」而不是定律,理由写在 02 节。
时效性
这篇讲的是原理,不引版本号、价格、或者会变的费率,所以它不容易过时。唯一两处版本敏感的地方已经在上面标出来了(《重构》第一版 vs 第二版、Beck 那句话的转述问题)。
99一句话总结
判断一次改动是结构问题还是 bug 问题,不看代码好不好,只看一个你能数出来的数:同一件事现在散落在几处。数到 3,就该停——因为再修一次只会给「下次必须记得的地方」再加一行。停下来的动作不是重写,是把那 3 处收成 1 处,验收标准是「点一遍,所有功能的表现一件都没变」。
最后一件事:这五条信号你不需要一次全记住。 下次再遇到「改完还是不对」,你只做 01 节那一个动作——把三句话发给它,数第 3 问的答案。数一次,比读十篇都管用。