← 文章

修了三次还在

约 3 分钟#复盘#工程

517 条故障记录里,46 条标题带「仍」字。每一步修的时候都对,合起来什么都没解决。

我数了一下自己的故障记录,517 条里有 46 条的标题带「仍」字:仍触发、仍重复、仍不生效、仍在凌晨发送。每个「仍」都意味着上一次修完,症状只是消失了一阵。

先说一个跟「仍」无关、但性质一样的。有一行代码按 60 条上限读聊天历史,写了很久。它实际每次只取 20 条。证据就在日志的同一行:selected=20 messages (max_messages=60, max_chars=12000)。没人发现,因为它从不报错。

让我最难受的是提示词前缀缓存。锚点公式锚在历史长度上,意思是取最近若干条、窗口起点按 20 条一档向上取整。上线之后用户反馈重启后命中率从 59% 掉到 48%。我去查日志,488 次调用每一次都写着「原始历史记录: 100 条消息」。上游给的是「最近约 100 条」的滑动列表,长度永远是 100,于是那个公式恒等于 ceil((100-60)/20)*20 = 40;列表每轮整体往后挪一条,下标 40 每轮指向的都是另一条消息,量化从头到尾都是空操作。

我在当时的记录里写的是「我上一条改错了」。错不在公式,在它锚住的那个量会被上游截断,而我没去验证它实际拿到多少。后来改成按时间锚,300 秒一档,同一份数据上前缀复用率 68/79,86%。

把那 46 条摊开看,原因翻来覆去就那么几种,下面五种占了大部分:

  • 取最短值。冷却设了 20 分钟,休眠时间却写成 min(动态间隔, 模型建议等待),7 分钟就醒。凡是兜底里「取更小、取更早」的写法,都在悄悄取消上游的约束。
  • 兜底优先。选「上一句助手消息」时写成优先取主动消息、没有再取普通消息。字面挑不出问题,实际效果是永远用那条更旧的主动消息,忽略更新的正常回复,角色于是接着上一轮的话往下说。
  • 只改调用点。同一个函数有三个调用方,报错的那个改了,另外两个没动,三天后从另一个入口报出同样的错。
  • 双写未收敛。状态同时写文件和内存,改的时候只动了文件那条。重启之后内存里的旧值又覆盖回去。
  • 只在一条路径修。主聊天修了,主动关怀没修;QQ 端修了,安卓端没修。一个后端,两条链路,两种行为。

这些修法在当时都说得过去:改动最小、风险最低、能马上验证,让我再来一次多半还是这么改。毛病出在修完那一步:症状没了,我就当问题解决了,没去想同一个 bug 还能从哪里进来。

后来我给自己加了一道收尾,修完必须答得上来:

下一次它还会从哪个入口进来?

答不出来,说明还没修完。答得出来,就把那个入口也堵上,或者至少在日志里留个能搜到的记号。这个问题比写测试生效得更早,很多 bug 在测试写出来之前就被它拦住了。

这道收尾我只用在三类 bug 上:会重复出现的、影响用户看得见的行为的、以及修完之后没人说得清为什么好了的。最后这一类最要命,因为下次没有东西可以复用。