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

在五条线路里同时蒸发的那张图

目录说这台模型看得见图,于是 app 发了图,中继把它静悄悄丢掉,端上、服务端、日志三处都不报错。那个交集被两个地方各自手算了一遍,相隔一行。

四条中继,一个布尔值

我们的 agent 栈里有一张目录,说明每台模型能做什么;有四条中继,负责把请求送到模型那里去。一次请求只走其中一条,由 provider 决定,而 provider 是每次调用时定的。

目录说支持图像。客户端信了,从设备上读出像素,发了一个 image 块。中继接过那个块,而远端没有图。

用户看到的是模型的第一条 thinking,然后是它的回答:没有图附上,上传大概失败了。而诊断显示的恰恰相反:读图那一步成功了,报的是 succeeded,字节就在内存里。端上没有错,服务端没有错,日志里没有错。那张图被正确地读了、被正确地发送了,然后被正确地丢掉了。

丢在中继里。原生中继原样透传字节。翻译中继把一种厂商的消息格式转成另一种,它转文本、转工具调用、转用量统计。它不转图像,因为目标格式里没有放图像的地方。当进来的 image 块遇到一个没有对应分支的转换器时,它走的是那个「什么都不产生」的分支。

真正的问题是同一个交集被算了两次

目录的正确性不是审美问题。它有定义:一个被告知客户端的能力,必须是「模型声明的能力」与「它将实际走的那条线承载得了的能力」的交集。而这个交集被在同一个文件里、相隔一行的地方,手算了两次。

一个函数拿目录声明的能力位和线路求交。紧接着的下一个函数读同一行目录,原封不动地交给客户端。于是同一行目录带着同一个问题的两个不同答案,而客户端看到的是第二个。

两个函数单独看都不算错。第一个是正确的求交,只是没有任何东西去问它。第二个是透传,也没有任何东西去质疑它。缺的是「它们是同一个问题的两个答案」这个观察,而这种东西只要没被写下来,就会活很久。

这个故事的结构性部分是:为什么它必须在服务端算。一次请求会走哪条线取决于 provider,而 provider 是刻意不下发给客户端的。所以客户端在原理上算不出这个交集,它没有被允许算。只有服务端同时握着这个问题的两半输入。

同一个 bug 的第二种形状

同一次改动里还有第二个实例,而它更有教益,因为它是同一个错误、符号相反。

一个能力位在某条 adapter 的结果映射里被漏掉了。承载图像的那个结果字段被一个结构体字面量转换吞掉,而喂给它的 match 是穷举的:每一个分支都在,一个不缺。就代码本身而言没有任何问题。那张图从来没到过那家 provider,因为决定它会不会到的那一位在更早一层就已经丢了,而等这段代码运行时,已经没有什么可丢的了。

教训是:一个 match 的完整性不是一条路径的完整性。一个分支齐全的 switch 证明你处理了每一个到达了它的 case;它对那个 case 有没有到达只字不提。

关于指标还有一个推论。有一阵子,请求里图像块的数量在那条线路上读作 0,因为它只数 content 下面的图,而那家 provider 把图放在 result 里。于是那条漏在唯一本该显示它的数字上是看不见的。当一个计数是从你预期的形状算出来、而不是从你发出的形状算出来时,这个指标保护不了任何东西。

四条传输,四个答案

修法是一张闭表:中继一行,每个能力位一格,交集从那张表里算,而不是在每个调用点手工拼。四条中继,而它们真的不一样。

两条原生中继透传。翻译中继留住工具,丢掉图像和托管搜索,而这个丢失不是偶然:那家厂商的托管搜索是用工具表达的,而翻译会把它压成一个对面永远执行不了的工具声明,于是模型反复调用它、反复什么都得不到。OpenAI 兼容适配器留住图像而丢掉工具,因为它的请求类型里有一个图像部件,而压根没有 tools 字段。

一个布尔值不可能对四条中继都正确。不存在某个「supports vision」的值,既对四条都为真、又在只有线路承载得了时才为真。所以答案是四行,而每一格的论证就住在那一格旁边,而不是住在某份文档里。

为什么编译器救不了你,以及什么能救

这一段最值得记住,因为它属于那种会被重新发现的东西。

加第四个能力位,什么都不会坏。两个方向都是静默的。Go 的复合字面量允许省略字段并取零值,于是新的一格在真正支持它的那些中继上读作 false,一个确实能用的能力从所有客户端上凭空消失,没有一个错误。而发布目录的那个函数把每一格按名字显式写出来,于是在那里漏写一个字段同样读作 false。那个函数刻意没有写成「直接拷贝过去」,因为那种兜底赋值会把没求交的新位原样透传,于是客户端被告知一个送不到的能力——那正是这次事故的形状。

两个方向都不会编译失败,而它们的症状相反:漏写是「能力凭空消失」,兜底透传是「能力凭空出现」。而第二个的修法(直接拷贝过去)恰恰是第一个的来源。两者都不会编译失败。

接住它们的是一条钉住能力结构体字段数的测试,以及源文件里那句解释——它存在的意义就是把作者带回那张表旁边。字段的形状一变它就红,而那一刻正是作者在加一位、也就是唯一可能用得上一句解释的时刻。

边界另一侧那道守卫不是可选项。中继自己的映射必须保持拒绝它搬不动的块类型,而拒绝必须是一个点名的拒绝,不是一次静默跳过。两侧的线上类型是不同团队写的字符串和枚举,再怎么小心也不会成为双射,所以 default 分支必须说不。

现在答案各自住在哪里

三个地方,各干一件事。

交集本身是服务端读路径上的一个函数,走四条条件、按顺序返回两半:要么是要发布的方言,要么是在不发布时的那句理由。它返回两半,是因为曾经只返回一半,而只返回一半意味着只加进其中一半的一个条件会得到一个没有解释的答案——那是唯一一个消费方渲染不出来的形状。

每条线路的能力主张是那张闭表,每一格的论证就写在那一格旁边。这些主张不是从做翻译的代码推导出来的,而是被断言的;把字段数钉死的那条测试,就是让这个断言保持诚实的东西。

拒绝在翻译层里,在它那个 default 分支上,并且点名它搬不动的那个块,而不是把它丢掉。

小结

一个被告知客户端的能力,是模型声明的与线路承载得了的之间的交集。 这是这个词的定义,不是一种美好性质,而我们发布过一张违反它一个字段的目录,而且比任何人注意到的时间都长。故障在两个方向上都静默:读取成功、发送成功、字节到达,而那个块被丢进一个没有它分支的转换器里。

在握着两半的那一侧算一次那个交集,只发布结果。算不了它的那一侧就是不应该被告知答案的那一侧;而在我们的架构里走哪条中继是每次调用决定的、永远不下发,所以这是一个结构性性质而不是一个选择。

按线路的答案不是一个布尔值。 四条中继、四行,而某一格对一条线路是假、对另一条是真,这不是不一致,这就是把关于世界的事实如实表达出来。那张表刻意是闭的;新能力走原生透传,不去打开翻译层。

搞清楚如果某个改动破坏了这个你会拿到什么编译错误,因为你不会拿到任何一个。给能力结构体加一个字段,两个方向都编译通过;不求交直接拷贝模型,也编译通过。一个丢掉了能用的功能,另一个承诺了一个送不到的功能,而一条钉住结构体形状的测试是你和两者之间唯一的东西。

继续阅读