lelo 写作

排查笔记 · 2026 年 7 月

我以为「快」是一个字段,
结果它是另一条通道

为了给自己那台小机器上的 API 网关接上「快模式」,我花了两天:在 body 里改字段、在响应里找回显、在深夜的一次断电中亲手作废了自己的证据。 最后发现加速根本不在我一直盯着的那一层——而真正难缠的敌人,是一个返回 200 的谎言。

先说结论,省得你和我一样绕两天:那个「快模式」的加速,不在请求体的任何一个字段里,它在传输层。 同一个账号、同一个模型、同一段 prompt,走 HTTP POST 加上所有传说中的加速字段,吞吐是 0.9712×——纹丝不动,误差范围内的纹丝不动。 换成官方客户端实际走的那条 WebSocket 通道,1.5485×

字段是敲门砖,通道才是门。我用了两天才明白这句话,中间的每一步弯路都挺有意思,所以记下来。

01 / 起点慢,而且慢得很稳定

背景很简单:我在一台海外小机器上给自己搭了个 API 网关,把几个模型统一成一套接口,本地的编码工具直接连它。用了一阵子之后我注意到一件事——它慢得非常稳定。 稳定在 51–57 tok/s 这个区间,像被谁焊死了一样。

而官方客户端在同样的账号下明显更快。这就有意思了:同一个后端、同一个账号、同一个模型,凭什么它快我慢?

02 / 第一个假设「加个字段就行了吧」

翻了一圈公开信息,看到请求体里有个 service_tier,写成 priority 据说就能进快车道。这听起来太合理了:一个字段,一个开关,改完重启,收工。

于是我做了第一组 A/B。同账号、固定 1 → 1024 token、cache 清零,反复跑:

路径吞吐相对基准
HTTP POST(普通)54.926 tok/s1.000×
HTTP POST + service_tier=priority53.343 tok/s0.9712×
官方客户端走的 WebSocket 通道85.048 tok/s1.5485×

第二行是那种让人愣三秒的结果。不是「提升不明显」,是压根没动,甚至比不加还低了一点点——完全落在噪声里。 那一刻我才被迫承认:我对这个功能的心智模型从一开始就是错的。它不是一个「你申请、服务端批准」的字段协议,它是一条物理上不同的路

带得走的判断

当一个「开关型」参数的 A/B 结果精确地趴在 1.0× 上时,别急着怀疑测法。先怀疑这个开关根本没接在你以为的那根线上。 真正生效的开关,哪怕实现得再烂,也不会烂得这么整齐。

03 / 转向不是 body,是 transport

换了个问法之后事情就顺了:不问「怎么让服务端给我加速」,改问「官方客户端到底在网络上做了什么我没做的事」。 答案是它压根没在发 HTTP POST,它开了一条 WebSocket。

接下来是我最喜欢的部分——把这条通道接进我自己的网关。我用的是一个开源的协议转换代理,它内部其实已经有 WebSocket 执行器了,只是默认路径不走那儿。 问题变成:怎么在不把自己钉死在某个版本上的前提下,让它在特定条件下切过去。

为什么不能写成插件

我第一反应当然是「用插件,别动源码」。这个方案被我自己否掉了,原因很具体: 插件能拿到的只有 payload,拿不到选中的那份凭据,也够不到原生传输层的切换点。 而这两样恰恰是决策的必要输入——你得知道这次请求最终用哪个身份、能不能走 WS,才能决定切不切。

用插件硬做,等于要在插件里重写一整套 OAuth 刷新 + WS 握手 + SSE 翻译。为了「不动源码」而重写三个子系统,维护面反而更大。 有时候最小侵入不等于零侵入。

所以我打了个窄补丁,插在唯一能同时看到「请求的模型别名 / 最终 payload / 选中的凭据 / 两个原生执行器」的那个调度点上。 三个条件同时满足才切通道,否则一律走原路:

// 三层契约,缺一层就静默退回慢速——这点后面咬了我一口
if aliasResolvesToFastVariant(req)      // ① 模型别名解析
   && payloadHasPriorityTier(req)         // ② payload 覆盖
   && selectedAuth.WebsocketsEnabled {    // ③ 该凭据允许 WS
    return websocketExecutor.ExecuteStream(ctx, req)
}
return httpExecutor.ExecuteStream(ctx, req)  // 原路径,一字未改

整个补丁 406 行,其中对上游源码只有 3 行插入,剩下全在两个新文件里。 这个形态不是一开始就想清楚的,是后来被上游一次重构打崩之后逼出来的——这事儿放在后面的弯路清单里讲。

真实环境交替跑 3×3:

模式三次采样均值
Standard51.429 / 56.651 / 56.74854.943
Fast85.786 / 83.406 / 85.83885.010

1.547×。和实验室里那个 1.5485× 对得上,这才敢说通了。

04 / 差点自己翻自己的案回显是不可信

切完流上生产,我按惯例发探针复验。四发请求,一发默认、三发显式带 service_tier=priority,然后看响应里回显的是什么。

四次全回 default

按直觉,这就该判死刑了:你要的是 priority,人家告诉你是 default,那不就是没生效吗?我差点就把已经验收完的东西重新开案。

但是——WebSocket 命中日志明明白白记着走了新通道,吞吐稳稳趴在 85 tok/s。两条硬证据在打回显的脸。 最后我给这件事定了性,还专门写进了自己的备忘:响应里的 service_tier 回显不可信,不得仅凭它判定功能失效。

当一个弱信号和两个强信号打架时,正确的动作不是重新调查,而是给这个弱信号盖章:以后不许再拿它当证据。

于是有了我现在用的验收铁三角,三条都过才算数:

  • 命中日志——通道确实切过去了,不是自我感觉;
  • 吞吐比——约 1.55×,低于这个数说明某层契约掉了;
  • 额度趋势——快模式消耗更多,如果额度消耗没变快,那多半也没真快。

关于代价,说句实话

快是有价的。我拿官方在每个响应头里给的额度百分比做了对账:同账号、同一个额度窗口内配对采样, 快模式每消耗 1% 额度换来的 token 大约是普通模式的 0.40×(7 个配对窗口的中位数,四分位区间 0.30–0.70)。 换句话说,大约 2.5 倍的额度换 1.55 倍的速度。

口径声明

这个数字我只当趋势看,不当精确倍率用。因为 cache 命中率和 workload 长度都混在里面,样本量也就 7 对。 把 0.40 反过来说成「精确 2.5 倍」是过度解读——它只够支撑「贵得挺明显,量级对得上官方定价」这个结论。

05 / 第二战场一个返回 200 的谎言

速度问题解决了,另一个更阴间的问题浮上来:偶发的 Connection closed mid-response。 客户端跑到一半崩掉,而服务端所有的日志都显示——一切正常。

最要命的一个样本长这样:

  • 21:23:40请求发出。
  • 21:23:50客户端收完了全部内容,包括带 stop_reason: end_turn 的那条 message_delta。内容是完整的,一个字不缺。
  • 21:24:15网关记账:HTTP 200,completion_tokens=1383,status=ok,正常 EOF。漂漂亮亮。
  • 21:26:43客户端报错:Connection closed mid-response。此时距离它收完内容已经过去了两分半。

所有中间层都说「我没问题」,而唯一的受害者是那个没等到一句结束语的客户端。 内容全到了,message_stop 这个协议终帧没到。客户端就一直举着话筒等对方说完,等到超时。

先排除自己

第一嫌疑人当然是我自己的协议翻译层——是不是它压根没生成终帧? 我去读了翻译器的源码,结果这条线索干净利落地断了:在结束分支里,message_deltamessage_stop拼进同一个字节块一次写出的。 物理上不可能只丢后半截。

翻译器无罪,丢失点在它下游的某一跳。

真正的放大器

然后我找到了那个把故障洗成成功的家伙。网关在读上游流的时候,遇到裸 EOF 直接判定「正常结束」,记 status=ok完全不校验流里到底有没有出现过终帧

而 HTTP 200 一旦写出去,就没法改成 5xx 了。所以这个链路是双层的:上游在终态边界上偶尔漏帧(这是个至今还 open 的 上游 issue), 而中间层把这个漏帧确定性地包装成了成功。第二层比第一层可恨得多——偶发故障只是麻烦,被伪装成成功的故障是会腐蚀你全部监控数据的。

06 / 补一帧一个极度保守的守门人

上游的 bug 我修不了,那就在自己这侧兜住。我写了个小 sidecar 夹在链路中间,专门守 SSE 流。 它的整个设计哲学就一句话:宁可放过,绝不误伤。

只有当以下条件全部满足时,它才补一帧结束语:

  • 生命周期完整:message_start 开过头,最终的 message_delta 带着 stop_reason
  • 干净的 EOF,不是网络错误;
  • 最后一条 SSE record 是完整的,不是半行;
  • 唯独缺 message_stop

任何一条不满足——网络错误、半条 record、生命周期异常——一律老老实实报 api_error,绝不盲目重试,更不伪造。 它也不看、不记任何正文内容,日志里只有四种动作:正常放行、补帧、拒绝不完整的流、客户端自己走了。

带得走的判断

给别人的 bug 打补丁时,补丁的触发条件要窄到近乎苛刻。 一个「顺手也修一修类似情况」的宽松补丁,最后一定会在某个你想不到的场景里把真故障吞掉——那时你就变成了自己刚刚痛骂过的那个「放大器」。

后来这东西又加了个 240 秒空转看门狗、单行 1MB 上限防 OOM、终帧之后禁止再注入任何东西。 17 个测试全绿才敢放上去。它现在安静地待在链路里,日志一天到晚只写 complete,偶尔写一次 repaired

07 / 弯路清单那些不写进 commit message 的

技术文章通常只讲那条最后走通的直线。但我这两天真正花掉的时间,绝大部分在下面这些地方。

弯路一 · 我自己把证据搞没了

凌晨抓断流复现,抓得正起劲,家里停电了。本机断网,服务端日志上只留下一个「请求开始」没有结束。

这个样本看起来简直是上游断流的完美实锤——只有我自己知道,是我家跳闸造成的。 我在备忘里郑重写下「该样本作废,不得混入证据」,然后重新抓。 调查里最危险的样本,是那种恰好符合你预期的假阳性。

弯路二 · 被一个无关的布尔值劝退

某个响应头里有个 has-credits=False,我一度当成「这个账号没有快模式权限」,差点整个方向放弃。 实际上它说的是「没有额外充值余额」,和功能权限半毛钱关系没有。 陌生系统里的字段名,望文生义的准确率比你想的低得多。

弯路三 · 上游一次「无害重构」把补丁全炸了

上游把一个 2359 行的大文件拆成了几个小文件。我那些散落在旧文件各处的补丁 hunk,全线失配

坏事变好事:我被逼着把补丁重构成「新文件为主 + 对上游只插 3 行」的形态。 现在上游再怎么重构,我要 rebase 的也只剩那一个小 hunk。这算是补丁架构的成人礼。

弯路四 · 被自己的安全带勒住

我给自动更新流水线加了个完整性门禁:脚本对不上校验和就拒绝执行。 然后我改了脚本、忘了刷新校验和文件,第二天被自己写的门禁拦得死死的。

同一批还有个更蠢的:门禁里写了「容器重启次数不为零就锁红回滚」,结果一次正常重启就把自己锁进死局。后来改成只告警不拦。 给自动化系统写守卫的时候,永远多想一步:这条规则在「一切正常但状态不干净」时会怎么样。

弯路五 · 探针探了个寂寞

想验证流量走没走某条路由,我去 GET 了一个状态接口——结果被 CDN 缓存命中,返回的是历史快照; 换 POST,被身份层 302 走了。全是假信号。 正解朴素得可笑:拿一个不带凭据的真实业务请求去打,看它是不是从源站吐出 401。 验证路由要用真实路径上的请求,旁路接口的答案不算数。

08 / 收尾知道什么时候停手

故事的最后有两个决定,我觉得比前面所有技术细节都更值得记。

第一个:验收做到「命中日志 + 1.55× 吞吐 + 额度趋势」三路证据齐了之后,我停了。 本来还可以再烧额度做更多组 A/B,把那个 0.40× 的置信区间收窄一点。但没必要——我要的结论已经足够稳, 继续测只是在为「更好看的数字」付真金白银。知道什么时候停手,也是工程能力的一部分。

第二个:我顺手把另一家客户端的类似加速模式也扒清楚了,wire 协议、beta 头、响应里怎么确认,全摸明白了。 然后决定不接

因为它还是 research preview,不是稳定契约,接进来就要同时耦合模型自动切换、网关透传和一个随时会变的 beta 头。 花了功夫逆出来,最后亲手放弃——这是整件事里我最冷静的一个决定。


如果只带走三句话,我希望是这三句:

  • A/B 精确等于 1.0× 的时候,先怀疑开关没接线,而不是怀疑测法。
  • 把一个 bug 洗成 200 OK 的中间层,比 bug 本身危险得多。
  • 给别人的 bug 打补丁,触发条件宁可窄到苛刻。

这篇的所有数字都来自我自己那台机器上的真实采样,脱敏处理过域名、主机和账号信息。 如果你也在自建网关上折腾类似的东西,欢迎来聊。

下一篇:能住精装房,为什么要住毛坯房? 全部文章