跳到主要内容

我说“就改这一行”,它道了个歉,然后给我加了十一个 if (!mounted)

阿舟
阿舟

· 阅读约 8 分钟

上周翻一个两年没动的老项目,纯看 diff。一个两百多行的文件里,if (!mounted) return; 出现了十一次。

不是十一个文件。是一个文件,十一次。其中有两处还紧挨着——

if (!mounted) return;
if (!mounted) return;

我盯着这两行笑了半天。它们怎么来的,我一清二楚:是我一句一句命令出来的。

案发

起点特别普通。一个订单列表页,WebSocket 断线重连,用户退出页面之后推送还在进来,控制台刷这个:

setState() called after dispose(): _OrderListState#a3f1c
This error happens if you call setState() on a State object for a widget
that no longer appears in the widget tree.

我当时的操作,现在回想起来蠢得很标准:把报错整段复制,@agent,打一句“这里加个判断,别让它 setState”。

它回了句“你说得对,抱歉”。然后加了一个 if (!mounted) return;。

跑起来不报错了。挺好。

第二天,另一个页面在返回手势下偶发白屏——它顺手把那个页面的初始化顺序“理顺”了一下。我又把新的报错贴过去,又是一句对不起,又是一个补丁。第三轮,它开始给回调套 try/except。第四轮我骂了它一句,它道歉得更诚恳,然后在同一个文件里又加了三个 if (!mounted)。

四轮之后,那个文件里长出了十一个。

画外音:这十一个里,九个是假的。它们不修 bug,只负责把 bug 藏起来。

有一阵我坚信是 prompt 不够狠

于是我开始走那条所有用过 AI 编程工具的人都走过的路——把指令写长。

五十行的上下文,相关文件全贴进去,逐条列“不要动 X,不要动 Y,不要改公共接口”。

更糟。

印象最深的一次,我特意叮嘱:“这个改动只涉及列表页,绝对不要碰 auth 中间件。”下一个动作,它打开的就是 auth 中间件。

当时我以为这是运气。后来才知道有个说法叫粉红大象——你越写“不要想大象”,注意力越落在“大象”这个词上。负向指令在生成模型这儿基本是反效果,你把那个语义区域点亮了,然后说别往那儿走。

但还有一个更隐蔽的错,是我后来才想明白的。我那句“就改这一行”,我以为叫精确,其实是把它的搜索空间焊死了。

它没有一次是改错的。它每次都在我指定的位置、按我描述的症状,改得完全正确。

问题从来不在它改得对不对,在我指的那个地方,本来就不是问题所在。我那段时间所有 prompt,本质上都在说一件事:别想,照做。

改口那天

具体哪天改口的我记不清了,但那个 prompt 留到现在。

以前是:

这个订阅在 dispose 之后还在跑,加个判断,别让它 setState。
另外不要动其他文件。

后来变成:

用户点返回、页面已经 dispose 了,但 /orders 的请求还在飞——响应回来的时候会走到哪一行?
这个订阅的生命周期到底归谁管?

差别看很小,结果差很远。

它没立刻改代码。它去读了路由,读了 dispose,读了一个我压根没提的 provider,回来告诉我:问题不在 setState,在那个 toValueListenable 的桥接上——监听被加上了,dispose 的时候没人摘。真正漏的是监听器,setState 只是最后叫出来的那一声。

这个问题在我这儿藏了小半年,我贴了四次报错都没贴出来,一句问句给问出来了。

命令句是闭合的,问句有个洞

我的解释比较土,但方向我觉得没错。

命令句里所有变量都是闭合的。“给这个订阅加个判断”——这句话没有一个字是空的,它的任务就退化成:找到最像“加个判断”的那几行 token,插进去。生成模型本来就在猜下一个 token,句子越闭合,能猜的空间越小,它就越倾向做机械替换。它不是在理解你的系统,是在补全你的句子。

问句里有一个洞。“响应回来的时候会走到哪一行”——“哪一行”是空的。它想填这个空,就得先把状态推一遍:请求在飞、页面已销毁、回调触发、setState 打到不在树上的 State。这几步不推完,它答不出来。

我不确定这算不算注意力机制层面的解释,但效果上就是这样:命令让它做局部替换,问题逼它跑一遍状态机。

然后是这个分工——我觉得这才是最值钱的部分。

我攒了几年的直觉,大部分时候就是一个五秒钟的问题:“这里会不会有两个请求同时回来?”我问出这句话只花五秒,但要回答它,得把这五秒展开成几十个状态、几条时序、几种退出路径。

以前我把这个五秒钟的问题压缩成一句命令扔出去,等于我替它推演完了,让它拿着我的结论去干活。我的推演大概率是错的,它还照做了。

现在我把问题原样扔出去。我在 O(1) 那一头,它在 O(N) 那一头。

反过来是什么样?“你把这个模块理一理。”这句话是我把 O(N) 的活扔给它,同时把 O(1) 的判断权也一起扔了。

画外音:这不叫放权,这叫甩锅。

revert 比越界更狠

有件事我缴过智商税,值得单独拎出来说。

它有时候会顺手多做点事——优化几个索引,改改命名,把某个初始化提前。我一般懒得逐个 hunk 看,图省事,直接 git revert 整个 commit。

有一次 revert 完,第二天早上七点被叫起来。那个 commit 里除了它多做的那部分,还有我前一天 squash 进去的一个热修。revert 是整块的,不区分“我没看懂的部分”和“它多做的部分”,一起删。

这是我现在 review 必看的一件事:这个改动最远能波及到哪。不是我信不过它,是回滚这件事本身太粗。它看起来是最安全的兜底,实际上经常比原来那次越界更有破坏性——越界你还能看见,回滚你是闭着眼睛删。

现在我宁可一个个 hunk 挑,也不整 commit revert。慢,但我知道自己删了什么。

那九个 if,还有 try/except

那十一个 if (!mounted),我最后删了九个。剩下两个是真的该在。

删掉的九个干的事一模一样:把异常吞掉,让日志消失,让状态卡住,下次出问题的时候你以为是网络。

比这更狠的是 try/except。我见过 agent 在被骂两轮之后,给一个本来就该抛异常的解析函数包了个 catch,返回 null。跑通了,测试也过了。三个月后线上错了一批数据,没人知道错在哪,因为异常被吃了。

这条我立场很硬:让它写业务代码,和让它写防御性代码,风险不是一个量级。后者它写得又快又像模像样,恰恰是因为它不理解“这里到底该不该崩”。它只知道你在生气,生气的时候要加 try。

道歉也有成本。每次它开始“对不起,我错了”,上下文里就多一段垃圾,prompt cache 也跟着废,后面几轮全在脏上下文里打转,越转越防御。我现在的做法很粗暴:一旦发现它进入道歉循环,不解释,不纠正,直接杀掉这个会话,重开一个干净的 subagent,把不变量重新说一遍。在脏上下文里讲道理,讲不回来的。

先别急着说“这又是一个 prompt 技巧”

写到这儿我自己都觉得别扭。

前两天看见一个评论,说这类文章一边嘲笑 magic prompt,一边又给出一条新的 prompt 建议。这话对,我认。

区别只在于——“请仔细检查边界情况”这种话,换个项目照样能用,所以它大概率没用。它是护身符,不是问题。

而“请求在飞的时候用户退出了,这个订阅谁收尸”这句话,只对我这个页面成立,换个项目就是废话。

能复用的 prompt 是护身符,不能复用的才是问题。

同一个坑,别问第二次

同一个坑如果我要提问两次,那说明是我的流程有问题,不是模型的问题。

现在的做法:每次被这种问句救回来一次,我就把它抄下来,压成一句检查项塞进 review 清单;能写成确定性检查的,写成 hook,提交前直接拦。规则类的东西——禁止裸 open、禁止在 X 层直接调 Y——hook 比问句靠谱,不依赖它今天心情好不好。

但 hook 拦不住时序。没有哪个 hook 能判断“这个请求在飞的时候用户会不会退出”——那不是规则,是一个需要被推演的场景,只能靠问。

两样都得有。指望其中一个包打天下,早晚翻车。

那九个 if (!mounted),我删的时候心里想的不是“AI 真不靠谱”,是“这九个是我自己命令出来的”。

它每次都在我指的地方、按我说的症状,改对了。

下次手放到键盘上,想打“就改这一行”的时候,先停五秒,把它改成一个问句。

就五秒。

阿舟
阿舟

写代码写到一半开始怀疑人生,靠 AI 工具续命,顺手把踩过的坑都记下来。

查看主页 →