十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

合拢这道流

合拢这道流 在我的机器上Claude Code 不会直接跟模型提供商对话。它经过 claude-code-routerccr——一个自托管的代理让同一个 CLI 既能连官方 Anthropic API也能连第三方提供商——而我透过 VS Code 插件、隔着 Remote-SSH看着这一切发生。上一篇《两个钟都没说谎》理清了为什么这条路径曾经让一个审批提示卡了整整一个小时每个 token 一个流式事件插件烧不动这个速度CLI 自己的控制消息排在六千个增量后面动弹不得。这一篇讲的是修复本身结果是接连两个问题——写一个把这些事件重新合并成大块的中间件然后想办法让这个中间件真的跑起来。第一个问题花了一个晚上。第二个问题试了五次。我能管得着的那一处这个故障牵涉三个地方。提供商那边的颗粒度——是他们的轮不到我改。插件——闭源也轮不到我去打补丁。夹在两者中间的是这个路由器我的容器我的 compose 文件我定的规则。官方 Anthropic API 本来就是合并成大块再流式传输的插件处理起来毫无压力——所以目标从来不是让插件变快。是让我这边的流看起来跟插件早就消化得动的那种流一样。听起来是件小事一个在数据出去的路上把增量重新分块的转换器。但真正统摄其他所有决定的那条约束比合并事件这四个字狠得多。这个中间件必须在合并的同时不改变这条流本来的意思。一个会篡改语义的合并器比卡住还糟——卡住损失一个小时说谎的流损失的是整个会话。合并但不改口规则一句话就能说完只有当连续的content_block_delta事件携带的是同一个 block index、同一种 delta 类型的字符串负载时——text 并进 textthinking 并进 thinkingpartial JSON 并进 partial JSON——才把它们合并一旦出现任何其他事件立刻把攒着的东西冲出去。每一个从句的存在都是因为少了它就会出错。同一个 index一条助手消息里会交错出现多个 block——先是 text然后是一次工具调用然后又是 text——block 的边界正是客户端用来区分它们的依据跨着 index 变化去合并就是把两个 block 硬融成一个从来不存在过的东西。任何其他事件都会冲刷content_block_start、content_block_stop、message_delta、message_stop都带着协议层面的含义所以窗口会被清空它们原样按顺序透传过去。这个中间件刻意对对话内容一无所知——它只合并那些可以证明彼此可以互换的东西其余一律让路。这份无知正是它的安全属性。唯一能调的旋钮是窗口长度窗口越长合并得越多也拖得越久。CCR_SSE_COALESCE_MS是全局设置设成零就是彻底关掉这个中间件的总闸。后来又加了按类型覆盖的选项——CCR_SSE_COALESCE_THINKING_MS、_TEXT_MS、_INPUT_JSON_MS——因为一个数字伺候不了两种不同的场景thinking 的增量等得起text 却是人正盯着看它一个字一个字冒出来的东西。某个类型的值设成零或负数并不代表这个类型关掉合并而是回退到全局设置——一个宽度为零的窗口没有计时器能把它冲出去数据会一直卡着直到整条流结束。Keep-alive 的心跳包默认会被丢弃CCR_SSE_DROP_PINGS0可以恢复它们——不是因为在乎那几个字节而是因为一个心跳包如果落在 thinking 正密集输出的当口会把窗口冲刷掉偏偏冲在流最密的地方让合并跨过心跳包继续下去比保留那几个心跳包更值。两条不能过期的传输层事实合并响应体会让传输层原本相信的两件事失效。Content-length合并之后的响应体长度已经不是任何人当初声明的那个数字了一个死守着过期 header 的客户端要么截断要么卡死——SSE 本来就是分块传输的所以这个 header 干脆整个拿掉。压缩中间件在请求方向要求accept-encoding: identity如果响应还是压缩着回来的它就直接绕过自己——原样放行这条流而不是去解压、合并、再重新编码一个它没法为内容背书的响应体。两条规则背后是同一条底线只合并你完全理解的东西理解不了的一律让路。别拿气球换卡顿最初的那个 bug是一个读得太慢的消费者把管道堵住了。一个天真的合并器只是把这个病往上挪了一层只要贪心地攒数据中间件自己就会变成一个不断膨胀的气球直到别的地方被撑爆为止。所以压力必须原样传下去——下游消费者发信号说停队列就暂停发信号说走投递就恢复。中间件能把这条流的形状抹平。它没法废除它背后的经济学。装载了不等于跑起来了设计定了之后剩下的问题看起来不大把这个文件塞进路由器的进程里去。四种配置失败了第五种才成每一次失败都是自己的一堂课。第一次尝试打的是globalThis.fetch的补丁——那扇最有名的门。毫无反应。路由器的网关根本不调用 fetch它require的是 undici通过getGlobalDispatcher()派发请求。要打补丁得打在真正在用的那个 API 上不是打在名气最大的那个上。第二次尝试挪到了 dispatcher 这一层——拦截 undici 的 headers/data/completion 处理器——成功了但只在一个进程里成功。容器里跑的不止一个 node 进程一个核心服务加上它自己派生出来的网关进程。结果发现有些提供商的流量是 DeepSeek 的是核心服务在抓取的而核心服务从来没加载过这个补丁——只有网关加载了。层选对了进程选错了补丁部署进一个进程对它的兄弟进程毫无作用当目标是整个容器的时候部署的最小单位其实是整棵进程树。第三次尝试让加载这件事变得普遍用NODE_OPTIONS配--require让容器里每一个 node 进程一出生就加载这个模块。模块确实加载了。什么都没发生。--require只负责加载一个文件它不会调用文件里的任何东西——我这个模块导出了一个install()礼貌地等着一个永远不会来的调用者。加载了不等于跑起来了。修复办法是让模块在被加载的那一刻就自己安装自己而且要做成幂等的这样被加载这件事本身就是被部署。第四次尝试瞄准了一个本来就存在的文件网关的 preload 文件。这也是个陷阱——核心服务每次启动都会用内嵌的副本通过writeFileSync把这个文件重新生成一遍所以对它的任何修改寿命都只有一次重启那么长。永远不要去改一个会被自动生成的产物。带上自己的文件想办法让它被请进来。第五种配置就是现在正在跑的这个NODE_OPTIONS: --require /data/.claude-code-router/sse-coalesce.cjs我自己的文件存在运维仓库里以只读方式 bind mount 进容器挂在一个核心服务根本不知道的路径下——这正是它永远不会被覆盖的原因——被加载进每一个 node 进程一落地就自己安装自己。仓库才是唯一的权威来源容器只是它运行的地方。这个安放位置还带着另一个陷阱是第一次要改这个中间件本身时才发现的bind mount 挂进去的内容不在 compose 计算哈希值的范围之内所以改了文件正在跑的容器根本感觉不到——反正--require也只在进程出生那一刻加载一次。改了不等于重新加载了。往后每次改中间件都得配一句docker compose up -d --force-recreate不然这次修改压根不算数。数一数出来了多少在真实流量上线之前这个中间件先拿到了九个单元测试——合并逻辑本身、content-length 的移除、背压的透传、压缩的绕过、拒绝跨 block index 合并、丢弃心跳包都在里面。真实环境的验证跑的是 DeepSeek因为智谱那边五小时的额度上限[1308]在验证中途把计划打断了——这个意外也顺带证明了一件事这个中间件根本不在乎自己前面站的是哪个提供商。一个 200 token 的响应进来时是 85 个事件出去时变成 11 个数字来自中间件自己的统计日志——这份日志会记录每一次合并并且在 256 KB 时自我截断这样可观测性本身不会又变成一场新的事故。顺带做了一次清理路由器的请求日志一直在完整记录响应体——已经攒了 156 MB——那天晚上把记录级别从all改成了errors又清理了一遍文件降到了 12 MB。这也是为什么上一篇里那些颗粒度数字只是某一夜的记录不是随时能重跑出来的测量。还是那三分钟部署之后中间件消灭了那种小时级别的卡顿——但留下了一点残余。第二天下午我的手机和插件又对不上了只是小了一号通知准时到了插件却又花了三分钟才把 Claude 提的问题显示出来。跟上次一样是会话记录裁定了谁说的是真话——问题落进记录的那一刻跟通知发出去的那一刻是同一秒。这三分钟全部耗在显示队列上这一轮对话的最后两个响应都已经经过了合并器处理到达时依然是 534 个事件估算下来每个事件渲染要花 250 到 400 毫秒。最后这个数字值得停下来看一眼。这是插件处理每个事件的成本用除法算出来的也是这整个故事里其他一切都在顶着的那道天花板。统计日志还解释了为什么合并器没能合并得更狠。一整天下来四十毫秒的窗口只换来三到五倍的事件数下降提供商每 25 到 50 毫秒才吐一个增量这么窄的窗口过期之前只能逮住一两个。这个旋钮当初设置的时候根本没人知道这条流的节奏。于是这个旋钮学会了节奏按类型分开设全局两百毫秒thinking 给五百——占流量的百分之九十九反正没人盯着它的流畅度——text 给一百二因为延迟是人真正能感觉到的东西。真实流量上719 个事件进31 个出。然后是 337 进14 出。二十三倍以上比我自己定的十五倍下限还要好。天花板本身在更上游我碰不到——一个长会话里每个事件要花四分之一秒去渲染的那个渲染器——所以我这一侧管道里唯一能玩的游戏就是压低要渲染的事件数量。少就是策略。长会话里/compact也是同一个策略。那个渲染器现在已经报到上游了在 anthropics/claude-code#86854上面那些单事件成本的数字就记在那里留给将来修它的人。这个中间件一共十六 KB 代码。写它是这份工作里比较短的那一半。长的那一半是弄明白代码要放在什么位置才算得上真的部署了——放在真正在用的那个 API 上放进每一个真正相关的进程里是被调用而不只是被加载放在一个不会被自动重新生成的文件里每次改动都强制重建。一个修复被写下来的那一刻它还不存在。它开始存在是在它真正跑起来的那一刻。不过事情还没有结束。第二天早上同一台机器又在 38.7 的负载均值下差点扛不住——一起看起来毫不相干的事故结果却是这一次的余波只是往下游流了二十个小时。这是《二十小时的引信》要讲的事。
返回列表