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

四种含义,两个名字

一个场景的路由有四种可能,而旧形状只给了其中两种起了名字,于是健康的那一行渲染成红色故障、正常的那一行挂着一句永久的红字。一句永远在的告警不是告警。

永远在红的那一列

我们的运维后台有一页,列出 agent 能路由到的每个场景,每个场景旁边写着它实际走哪条线。原生方言透传,其余的一切在送往上游厂商之前经过翻译层。

那一页本来有用,而它几乎没法读,因为一个没人仔细看过的形状。场景声明了原生暴露,但这个场景可用的那些模型对方言有分歧,于是一行就足以把整个场景放回翻译。这是一个缺陷:它是静默的,它降级了运营以为这个场景有的一个能力,而且没有人被告知。

那一页把这个缺陷显示出来了。它对「本来就要翻译」的场景也显示同样的东西——那是绝大多数场景——而对「场景就是按配置在做事」这个最普通的情况,它渲染出一句红色故障。于是对真正的缺陷和普通情况,这一页说的是同一句话、同一种警告语气,而运营分辨它们唯一的办法是知道哪些场景「本该」是原生的。

两个字段,四种含义

之所以会错,是因为那个状态被建成了一对字段而不是一个值:

  • 方言,一个字符串,没有单一原生方言可发布时为空;
  • 一句话,解释为什么。

仔细读,这一对有四种组合。一,原生且一致,没问题。二,声明原生但可选集合有分歧,这是缺陷。三,因为场景自己要求而翻译,这是普通情况,也是这一列的默认值。四,因为场景想要原生却得不到而翻译,这是缺陷,而且和第二个是不同的缺陷。还有:目录读不出来所以翻译,这根本不是关于这个场景的判决。

四种含义。两个名字——因为空方言同时代表后面三种,而那句话是一段挂在「并不说明自己是哪一种」的取值上的自由文本。后台只能猜,而且两个方向都猜错了:读失败的目录看起来像一个回落到翻译的场景,而本来就要翻译的场景看起来像一个正在回落的场景。

这个形状很容易被喜欢。空字符串加一句话很灵活,什么都装得下。问题正在这里:一个什么都装得下的形状,没法告诉读者它此刻装的是四种里的哪一种,于是每个消费方都得自己推断,而推断就是两个方向都错掉的来源。

四个状态,其中一个是空

修法是把状态建成一个取值,而不是一对取值。四个具名状态,渲染方对它们分派:

native     暴露是 native 且可选集合一致同意
by_design  场景自己要翻译;不是故障,也不是新闻,这一列的默认值
fell_back  要 native 而在翻译;唯一该报警的一个
""         服务端答不出;读作「不知道」,不读作任何结论

这份清单里有三处值得单独拿出来。

那个空字符串是一个状态,不是状态的缺席。一个没人填过的判决什么都不主张,所以一次读失败的目录会被渲染成「无法判断这个场景路由到哪」。如果未知的情况靠「落到普通情况」来表示,那么一次读失败就和健康场景无法区分——那是我们正在修的那个 bug,换了顶帽子。

零值是未知。这个选择在做实事:它意味着一个构造了判决却没有填它的调用方,拿到的正是那个什么都不主张的状态。反过来,把一个正面状态设成零值,意味着每一条忘记设置这个字段的路径都会报出一个健康场景,而忘记设置字段是字段出错的最常见方式。

而这个集合是闭的。不存在第五个能在没有被讨论过渲染方式的情况下抵达的状态,于是渲染方的 switch 是穷尽的,编译器会强制这一点。

不变量是渲染,不是那句解释

服务端本来就分对了这些区别;问题出在它发出了什么,以及页面怎么解释它。接口契约现在写着:渲染方必须把四个取值分开对待,不许从别的字段反推一个出来,并且带着旧形状错在哪里的理由。页面改成对状态做 switch,两个安静的分支、一个警告的分支,未识别的取值落到安静那一侧。

最后这一段是把契约变成机制的那一块。写成「有解释就红色显示」的渲染器,形状就是错的,因为「有解释」恰恰是 by_design 看起来的样子。而写成对一个闭集做 switch 的渲染器,形状就是对的,因为每个状态恰好有一种渲染,而一个没人预料到的状态渲染成「什么都没有」,而不是渲染成故障。源码里的注释说,不安全的那种写法对以后加的任何状态都会再来一次,而它是对的:不安全的写法不是对今天这四个值来说是错的,它是对第五个值来说是错的。

那一页也不再按「有没有解释」上色了。那枚写着「翻译中」的徽章只对一个状态渲染。

钉住这条规则的那条测试

有意思的测试不是检查状态有没有回来的那些,是检查页面拿它怎么办的那些。

一条断言「场景自己就要翻译」回来的是 by_design 而不是故障,理由写在旁边:那是这一列的默认值、绝大多数行都在那里,把它标红等于把几乎整列都标红。另一条断言一致同意的时候这两格必须安静,理由是:一句永远在的告警会被学会忽略。还有一条断言目录读失败回来时没有状态,于是页面显示它自己那句「无法判断」,而不是一个判决。

有一对测试是刻意做成反例的。一个场景没有公布原生方言,而目录仍然说它支持搜索;另一个场景没有公布任何东西,而目录也什么都没说。在旧模型里,这两者到达客户端时是同一个 nil,于是客户端把两个都收窄了,而其中一个真正能用的搜索被关掉了。任何地方都不报错。这两条测试之所以成对存在,正是因为每一条都是另一条的例外;删掉任何一条,另一条都会带着错误的行为继续通过。

小结

把状态建成一个闭集的具名取值,而不是「空字段 + 一句自由文本」。 四种含义藏在两个名字后面,这不是一点小歧义,而是四件需要读者去猜的事,而猜会在两个方向上都错,这比只猜错一个方向更糟。

让未知成为一个显式状态,并让它成为零值。 没人填过的判决应该什么都不主张,而不是主张健康。于是读失败在构造上就是正确的,而不是靠评审正确的,而且每一条忘记设置字段的路径都落在那个不指控任何人的答案上。

一句永远在的告警不是告警。普通情况必须渲染成沉默,故障必须是唯一渲染成红色的东西。如果报警的那个状态恰好是这一列的默认值,给它上色等于教会所有人忽略这一列,而唯一重要的那一行就在这个列里。

把渲染方写成对一个闭集的 switch,让未知落到安静那一侧。 渲染器的形状就是不变量;一个「有解释就标红」的渲染器今天只是碰巧对,因为有解释恰恰是普通情况的样子。

并且保留成对的反例。两条必须互相不同的测试,比两条一致的测试值钱,因为它们钉住了两个状态之间的边界,而单个测试只会把那个边界模糊成一个。

文中提到的产品

继续阅读