技术9/4/2026·约 5 分钟

那个正确的修复,把功能关掉了

加上上文让排序缺陷变得更糟,五个看似合理的根因全错。真因是一个未做平滑的估计比它自己的基线还低 3.46 bit——而修它的办法悄悄关掉了上下文。

当上文让结果更糟

我们的键盘给候选排序。一位用户报障:他拍了照,然后打了 zhanshi(展示)。没有上文时,「展示」排第一;带上他打的上文之后,同一个词排到了第 9,前面是 战、战术、转世,还有另外几个。

带上文的排序和用户截图逐字吻合——这一点才是它可处理的原因。我们能在办公桌上复现一个用户的误操作,这种事并不常见,值得心存感激。

一个「你一加信息就翻转」的排序不是调参问题。评分里的某处把上下文当成了惩罚。于是我们去找根因,并且以几种有教育意义的方式搞错了。

读代码给了我们什么

候选转储里第一件跳出来的东西是一个写死的分支。无上文的单字候选,语言分被设成 0.0;而多字词走正常路径,拿到真实的(负的)对数概率。两者落进同一张候选列表、比较同一个数字,于是语言项悄悄退化成了长度惩罚——它只会移动多字词。

那是个真缺陷。它是对这个症状的一个站得住脚的解释。它也不是这个 bug 的原因,而我们是用很贵的方式知道这一点的:

统一基准——让解码器也在无上文时跳过语言项——那 11 条失败用例纹丝不动。top1、top3、top5、mrr 全部一模一样。

诱人的结论是「这个缺陷和我们的用例不相交」。而那个结论也是错的,后来的更正证明了这一点:这些用例看不见这个缺陷,因为它们的期望答案恰好各自占据了当时首屏仅有的一格完整候选。零变化不是「缺陷不存在」的证据,它是「测试观测不到它」的证据。

那次重排了一切、却什么也没量到的实验

第二个假设是评分函数本身。我们把语言项换成互信息——正是针对这种形状的标准解法(一个常见词纯粹靠词频取胜时)。

beforeafter
top10.36360.3636
top50.81820.8182
mrr0.58380.5884

11 条用例全部重排。一条从第 5 挪到第 4。这是一个改写了整个排序、却买不到任何可测收益的改动,值得点清楚名字,因为拿到一个空结果之后,本能是换个常数再试一次:

一个重排一切、却移动不了任何指标的改动是一个发现,不是一次调参失误。

它告诉你的是:排序根本不是这个指标敏感的东西。我们当时在调一个贡献度已经小到无关紧要的项,而那一族里的每个实验都会返回同一个数字。

两种病,一张脸

第三个假设是语料,而它对了一半——这是最有用的那种错。

我们的语言模型训练自一个大型匿名中文讨论论坛存档。而我们键盘的用户写的是工程中文。在那个语料里,「的时候」后面接 战士 确实比接 展示 更可能——模型没有坏,它在回答另一个问题。

我们能量化我们差多远,这一步让这个假设从「合理」变成「可检验」:三条失败用例需要条件概率摆动 1.6 到 5.2 bit,而在领域内真出现过的 bigram 与没出现过的,差距轻松超过 10 bit。所以差距是可关闭的,语料是对的杠杆。

然后我们换成了一个对话语料,三条一条也没修好。 值得留下的原因是下面这个。三条里有两条在那个语料里压根没有 bigram——那些词连前两万个高频短语都进不去——所以模型回退到 unigram,纯按词频排。它们需要的是覆盖。只有一条是「有 bigram 但估得不准」,而它需要更好的估计。

换语料改善的是估计,而它造不出覆盖。两种病,一个症状,没有任何单一修法能同时覆盖——这正是那个合理论证产出一个干净空结果的完整原因。

真因,以及那一行修法

真因是算术,而且一直明明白白摆在那里。

转移分是一个没有平滑的极大似然估计:P(词|前词) = 支持度 / count(前词)。在五千八百万字里只出现一次的支持度,算出来的不是「这个搭配少见」,而是噪声——它比该词自己的边缘概率还低 3.46 bit。

于是上下文在主动把候选往下推:

lexical语言模型排位
展示,无上文11.08−11.091
展示,有上文11.08−14.559
战士,有上文11.76−13.781

那个没有任何上下文证据的竞争者回退到自己的基线,赢过了那个只有一次目击证据的。用户截图就是这张表。

修法是一条规则,写在唯一能同时看见两个量的函数里:

上文只能细化基线,绝不能让候选更糟。取 max(条件, 边缘)。

它不是一个凑出来的系数,也不是一个权重。它是对「上文是用来干什么」的一句陈述。一个带噪的估计应当被忽略,而不是被相信;而「忽略它」必须意味着「回退」,不意味着「扣分」。带着出厂语料、不改任何数据上线它,展示从第 9 回到第 1。

我们在同一次变更里抬高了门禁地板——top1 从 0.36 到 0.545,mrr 从 0.58 到 0.702——并且把那一行改回去,门禁立刻变红。这类修法唯一的保护方式,就是让地板描述修好之后的世界,而不是修好之前的世界。

那个把功能关掉的修复

两天后,完整混合语料建好了,修法还在原地——而门禁读数和出厂模型一模一样。每个数字都相同。上下文什么也没在做。

规则本身仍然正确。它被用在了两个不在同一尺度上的量上:

边缘 = log2( c(词)   / c(最高频词) )     ← 分母 3560 万,而 unigram 总量是 7.52 亿
条件 = log2( c(前词, 词) / c(前词) )

边缘用最高频词当分母,而 unigram 总量是 7.52 亿,于是每个边缘估计被整体抬高约 4.4 bit。而 c(前词, 词) ≤ c(词) 恒成立——这里的前词正是 的,也就是那个最高频词——所以条件分在任何情况下都追不上。于是 max 每次都退回边缘分,那个刚刚被修好的机制就那样静默地失明了。

没有崩溃。门禁是绿的。那个功能只是关着,戴着修复的名字。

真正的修法把两项放到同一个分母上并做插值:

unigram = log2( (1-λ) · c(w) / N )                          N = unigram 计数总和
bigram  = log2( λ·c(prev,w)/c(prev) + (1-λ)·c(w)/N )        λ = 0.9

插值形式可证 ≥ unigram 形式,于是上文只能抬高一个候选,永远不能把它压到它本来所在的位置之下——最初那条规则活了下来,但现在是打分函数的一条数学性质,而不是压在上面的防御性钳位。

在完整语料上实测,收益只落在真有证据的那一侧:

无上文有上文抬升
的 → 响应−17.97−13.09+4.88
时候 → 展示−18.01−14.55+3.46
时候 → 战士(语料里没有)−20.85−20.85+0.00

而上下文那道门禁此前读作 0.636 / 0.818 / 0.756,之后变成 0.636 / 0.909 / 0.768——无上下文那一半刻意不动。

小结

不要相信一个靠读代码找到的根因。这场追查里的每个假设都有源码支持,而最自信的两个要么是错的,要么观测不到。候选表不是评分函数,它是评分函数的一个投影;一个表看不见的缺陷,并不因此而不存在。

仔细读一个空结果,两个方向都要读。一个重排一切却移动不了指标的改动,已经告诉你排序不是那个指标在意的;而一个看不见缺陷的测试,不是「缺陷不存在」的证据——在下那个结论之前,先问这个测试对什么失明。

把「我们估得不准」和「它压根就不在那儿」分开。从外面看两者一模一样——都表现为排序错了——而它们需要的是相反的解法。换任何东西之前先量化:量出错误答案需要移动多远才能变成正确答案,正是把一个合理的假设变成可检验假设的那一步。

并且在第二份数据上验证你的修复。一条原则上正确的规则,可能因为它比较的两个量不在同一尺度而在实践中完全失明,而症状是门禁永远读出完全相同的数字。验证修复的方式,是确认机制确实在做事,而不是确认数字没有动。

文中提到的产品

继续阅读