news 2026/8/17 18:27:55

合拢这道流

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
合拢这道流

在我的机器上,Claude Code 不会直接跟模型提供商对话。它经过 claude-code-router(ccr)——一个自托管的代理,让同一个 CLI 既能连官方 Anthropic API,也能连第三方提供商——而我透过 VS Code 插件、隔着 Remote-SSH,看着这一切发生。上一篇《两个钟,都没说谎》,理清了为什么这条路径曾经让一个审批提示卡了整整一个小时:每个 token 一个流式事件,插件烧不动这个速度,CLI 自己的控制消息排在六千个增量后面动弹不得。这一篇讲的是修复本身,结果是接连两个问题——写一个把这些事件重新合并成大块的中间件,然后想办法让这个中间件真的跑起来。第一个问题花了一个晚上。第二个问题试了五次。

我能管得着的那一处

这个故障牵涉三个地方。提供商那边的颗粒度——是他们的,轮不到我改。插件——闭源,也轮不到我去打补丁。夹在两者中间的,是这个路由器:我的容器,我的 compose 文件,我定的规则。官方 Anthropic API 本来就是合并成大块再流式传输的,插件处理起来毫无压力——所以目标从来不是让插件变快。是让我这边的流,看起来跟插件早就消化得动的那种流一样。

听起来是件小事:一个在数据出去的路上把增量重新分块的转换器。但真正统摄其他所有决定的那条约束,比"合并事件"这四个字狠得多。这个中间件必须在合并的同时,不改变这条流本来的意思。一个会篡改语义的合并器,比卡住还糟——卡住损失一个小时,说谎的流损失的是整个会话。

合并,但不改口

规则一句话就能说完:只有当连续的content_block_delta事件,携带的是同一个 block index、同一种 delta 类型的字符串负载时——text 并进 text,thinking 并进 thinking,partial JSON 并进 partial JSON——才把它们合并;一旦出现任何其他事件,立刻把攒着的东西冲出去。

每一个从句的存在,都是因为少了它就会出错。同一个 index:一条助手消息里会交错出现多个 block——先是 text,然后是一次工具调用,然后又是 text——block 的边界正是客户端用来区分它们的依据;跨着 index 变化去合并,就是把两个 block 硬融成一个从来不存在过的东西。任何其他事件都会冲刷content_block_startcontent_block_stopmessage_deltamessage_stop都带着协议层面的含义,所以窗口会被清空,它们原样按顺序透传过去。这个中间件刻意对对话内容一无所知——它只合并那些可以证明彼此可以互换的东西,其余一律让路。这份"无知",正是它的安全属性。

唯一能调的旋钮是窗口长度:窗口越长,合并得越多,也拖得越久。CCR_SSE_COALESCE_MS是全局设置,设成零就是彻底关掉这个中间件的总闸。后来又加了按类型覆盖的选项——CCR_SSE_COALESCE_THINKING_MS_TEXT_MS_INPUT_JSON_MS——因为一个数字伺候不了两种不同的场景:thinking 的增量等得起,text 却是人正盯着看它一个字一个字冒出来的东西。某个类型的值设成零或负数,并不代表"这个类型关掉合并";而是回退到全局设置——一个宽度为零的窗口没有计时器能把它冲出去,数据会一直卡着,直到整条流结束。Keep-alive 的心跳包默认会被丢弃(CCR_SSE_DROP_PINGS=0可以恢复它们)——不是因为在乎那几个字节,而是因为一个心跳包如果落在 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 的负载均值下差点扛不住——一起看起来毫不相干的事故,结果却是这一次的余波,只是往下游流了二十个小时。这是《二十小时的引信》要讲的事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/17 18:25:09

SpringBoot整合ActiveMQ实战:从入门到生产级消息队列应用

1. 项目概述与核心价值消息队列这玩意儿,在咱们搞后端开发的人手里,就像个万能胶,哪儿需要解耦、哪儿需要削峰、哪儿需要异步,把它掏出来准没错。我最早接触ActiveMQ,还是在一个老旧的ERP系统重构项目里,那…

作者头像 李华
网站建设 2026/8/17 18:23:03

企业 AI Native,到底怎么落地?

1. 停在试点,不是能力问题,是路径问题 MIT 把 300 多个企业 AI 试点翻了个底朝天,结论就一句话:95% 没产生可衡量的财务回报。关掉 AI,业务照转。 你不是缺一个更强的模型,你是缺一条把它长进业务的路径。 …

作者头像 李华