先说一件刚发生的事。
我们修掉了一处缺陷:四种不同的「没有」被压成一个开关,读数里分不出「设计如此」和「出错了」。修完,接着做另一件事。那个新字段落地的第二天,一位读者在评论里,指出了同一处缺陷——并且写下了该怎么改。
不是同一个问题复活。是同一类问题,在我们以为自己已经学会的地方,又长了一遍。
这篇只讲这一件事:记录里没有格子放「不知道」的时候,「不知道」会读成「知道」——而且读成的是那个字段里最强的那句断言。
## 一、四种「没有」,一个开关
先说背景。
AI 写一条记录,我们要留下它「写之前什么样、写之后什么样」的快照。将来要撤销,得知道退回到哪里。
问题出在「抓不到之后那份快照」这件事上。它至少有四种原因:
- 根本没装配抓取器——设计上的可选
- 实体解析不到,回退数据也是空的
- 实体在,但那行查不到——异常
- 抓取过程抛错——错误
前两种是设计选择的结果,后两种是真出了事。
而我们当时只有一个布尔字段。四种原因,一个开关。
于是有人问「这批快照为什么是空的」时,答不上来:分不出是设计如此,还是坏了。更糟的是方向——那个开关默认是 false,读起来就是「快照完好」。
「没有」不是一个值,是四种不同的值。压成一个布尔,就是让最强的那种读法,替所有情况作答。
后来我们把它拆成四个成因,分别落列、分别可读。拆的时候还发现:最初拟的四个名字里,有两个根本不属于这一类——一个在另一条路径上(那是「写之前」那张表),另一个是机制而不是成因。所以最后落地的四个,和一开始想的四个并不一样。
## 二、第二天,在我们刚写完的地方
修完这处,接着做另一件事:撤销之前,记下「这一次我打算比对哪些成员」。
承诺在这里是有意义的——撤销前是承诺,撤销后是实况。两者对差,才能说出「我承诺比对的某个成员,其实比不了」。落地的做法是:在组根行上放一个列,装这次的差集;这次没有差集,就把列写空。
看起来没问题。然后有人在评论里写下这段:
> 一个按原因分列的字段放在成员行上,会被最后一次撤销尝试覆写,所以第二次尝试会擦掉第一次的「失约」。追加式能让它们分开。
他是对的。而且比他说得还糟一点。
那一列在没有差集时会被写成空;而那个「空」同时表示两件事——「最近一次比对没有差集」,和「这一行从来没有被撤销过」。所以:
- 第一次撤销发现「承诺了却比不到」——记下了
- 第二次撤销一切正常——把第一次的记录写成了空
- 事后读这一行:空。读不出「曾经有过一次失约」,也读不出「它被清空过」
一次成功的重试,抹掉了前一次的失败记录。
要说清楚的是:这个清空是有意的。理由写在当时的设计记录里——「陈旧的差集不该活过产生它的那次比对」。这个理由本身不算错。错的是那个「空」没有第二个意思可用。
然后我们照他说的改了。
改成每一次走到比对的尝试各占一行,不再共用一个格子。两个读数在写的时候就分开:这一行是「查出了差集」,还是「查过、没有差集」。于是:
- 后一次尝试不再抹掉前一次——它写自己那一行
- 空历史(没有任何一次尝试走到过承诺)与 「查过、干净」 不再是同一个读数
- 那条旧规则——「陈旧的差集不该活过产生它的那次比对」——被撤回了。它错在用同一个格子表达两件事,不是错在「不该留陈旧的东西」
那句旧列没删,作为遗留条目继续读,标注「写于何时不可考」——删掉它,恰好会抹掉这一整段争议的证据。
## 三、同一套系统里,有一处做对了
上面两处都是「没有格子放不知道」。但同一套系统里,有一处留了格子。
撤销写到外部系统的时候,我们只能把请求发出去,终态在对方手里。对方返回 2xx,只证明「请求到了」,不证明「对方撤了」。所以那一条不写「已撤销」,写「补偿已请求,结果未知」——一个专门的读法。
效果很直接:没人会把它误读成完成。界面照实写「结果在目标系统」,不必粉饰。
所以这件事不是做不到。是有没有意识到要给「不知道」留位置。留了,它就不塌;没留,它就归到最强的那种读法上。
## 四、为什么难
难在这个默认行为是静默的。
每一个字段都在替你做决定,而且都用最省事的那种读法:
- 空值,默认读作「无此项」
- 空集合,默认读作「没有差异」
- false,默认读作「未标记」,进而读作「正常」
这三种读法在绝大多数情况下是对的。问题在剩下的那一小部分:当「没有」和「不知道」共用同一个值,你就得到了一个会撒谎的字段——而且它平时不吭声。
更麻烦的是:修一处不会让这一类消失——包括修它自己的时候。
说一件就在那次修复里发生的事。改完之后,「没有差集」有了自己的读法。但那个判据函数返回空,其实有两种情形:真的全比过了,和压根没有可承诺的东西(那时连「比对」都无从定义)。第一种写「查过、干净」是对的;第二种要是也写「查过、干净」,就等于替一次从未发生的检查作证。
我们差点就这么写了。发现之后改成:什么都没承诺,就不落行。
所以这一类缺陷的入口在每一个「没有」上——你修一个,它就在你写修复代码的那一行里等你。
## 五、一个你可以自己做的检验
不必问「我的记录全不全」,先问一个更基础的问题:
把你系统里每一个表示「没有 / 未知 / 空」的值列出来,逐个问:它读成什么?
凡是同时承载两种含义的,就是一处潜在的谎报。典型的三类:
| 写法 | 它同时意味着 |
|---|---|
| 空值 | 「没有这一项」/「查过,结果为空」/「没查过」 |
| 空集合 | 「确实没有」/「没能读出来」 |
| false / 缺省值 | 「未标记」/「标记为否」 |
判据很简单:这个值是空的时候,读者还能不能说清「为什么空」?说不出来,就是这个字段在替他猜。
## 结语
这类问题不好找,因为它不报错。系统照常运行,界面照常显示,只是有那么一格在说它不是的话。
而它最容易出现的地方,恰恰是你刚刚修完的地方——因为那一刻,你正忙着「已经知道该怎么做」了。
交代一下我们自己的状态:这个模式我们现在认识了,也修了两处——第二处的形状是读者给的。但我们没有做过一次全盘体检:那三类「没有」在我们的记录层里到底有多少处、分别读成什么,我们没有清单。
所以这篇不收在一个结论上。你拿上面那个检验去照自己的系统,多半能找到几处;我们这边,只知道还没找到完。