作者通过自建claude-code-router解决远程SSH环境下Claude Code流式事件过于碎片化导致的延迟问题,详细记录排查和修复过程。
我的机器上,Claude Code 并不直连模型提供商。它先经过 claude-code-router(ccr)——一个自托管代理,让同一个 CLI 可以指向官方 Anthropic API 或第三方提供商——而我通过 VS Code 扩展配合 Remote-SSH 观察整个过程。前一篇文章《Two Clocks, Neither Lying》解释了为什么那条路径曾让一个审批提示悬停了一小时:每个 token 一个流事件,扩展消化不了这么快,CLI 自己的控制消息在六千个 delta 后面排队等着。本文讲的是修复方案——结果发现是依次发生的两个问题:写一个把事件合并成块的中间件,然后让这个中间件真正跑起来。前者花了一个晚上。后者花了五次尝试。
故障有三个地址。提供商的粒度——那是他们的,不是我能改的。扩展——闭源的,不是我能打的。还有夹在中间的路由器:我的容器,我的 compose 文件,我的规则。官方 Anthropic API 已经以合并好的块流式返回,扩展处理起来没问题——所以目标从来不是让扩展更快。而是让我的流看起来像扩展已经能消化的那种。
这听起来是件小事:一个转换器,在出口处把 delta 重新分块。但约束每个其他决策的规则比"合并事件"更棘手。中间件必须在不改变流含义的前提下合并。一个破坏语义的合并器比卡住更糟——卡住损失一小时,撒谎的流损失整个会话。
规则,一句话:只有当相邻的 content_block_delta 事件携带相同块索引和相同 delta 类型的字符串载荷时,才合并它们——text 并入 text,thinking 并入 thinking,部分 JSON 并入部分 JSON——一旦出现任何其他事件就立即刷新。
每个条款都有其存在的理由,缺了就会破坏什么。相同索引:一条 assistant 消息会交错多个块——文本,然后一个工具调用,然后更多文本——块边界是客户端区分它们的依据;跨索引变更合并会把两个块融合成一个从未存在的块。任何其他事件都刷新:content_block_start、content_block_stop、message_delta、message_stop 都携带协议含义,所以窗口清空,它们不受影响、按序通过。中间件刻意对对话一无所知——它只合并可证明可互换的东西,其余一切遵从。这份无知就是安全属性。
窗口是唯一的旋钮:窗口越长,合并得越多,延迟也越多。CCR_SSE_COALESCE_MS 全局设置它,零是禁用中间件的Kill开关。后来它生长出了按类型覆盖——CCR_SSE_COALESCE_THINKING_MS、_TEXT_MS、_INPUT_JSON_MS——因为一个数字服务不了两类人:thinking delta 可以等,text 是人类看着出现的东西。某类型的值为零或更小不代表"该类型关闭";而是回退到全局——零宽窗口没有计时器来刷新它,会把数据搁置到流结束。Keep-alive ping 默认丢弃(CCR_SSE_DROP_PINGS=0 恢复它们)——不是为了那几个字节,而是因为一个 ping 落在 thinking 序列中间恰好会在流最密集的地方刷新窗口;让合并跨 keep-alive 继续比保留 ping 更有价值。
合并 body 会使传输层相信的两件事失效。Content-length:合并后的 body 不再是任何人声明的长度,尊重过时 header 的客户端会截断或卡住——SSE 以 chunked 方式传输,所以 header 直接移除。Compression:中间件在进口处请求 accept-encoding: identity,如果响应实际以压缩形式回来,它就绕过自己——让流原样通过,而不是解压、合并、再编码一个它无法担保的 body。两者的底层规则:合并你完全理解的,拒绝你不完全理解的。
原来的 bug 是慢速读者把管道堵了。天真的合并器只是把那种病害平移了一层:积极缓冲,中间件就成了气球,越长越大直到别的东西卡住。所以压力要传递过去——当下游消费者发出停止信号,队列暂停;发出继续信号,交付恢复。中间件可以平滑流的形状。它废除不了它的经济学。
设计完成了,剩下的问题看起来很小:把文件弄进路由器的进程。五种配置试完才有一个成功,每个失败都各有其教训。
第一次尝试打补丁 globalThis.fetch——那个著名的门。没有反应。路由器的网关不调用 fetch;它依赖 undici,通过 getGlobalDispatcher() 分发。打实际在用的那个 API,不是那个品牌认知度高的。
第二次尝试移到调度层——拦截 undici 的 headers/data/completion handlers——它生效了,恰好在一个进程中。容器运行的不止一个 node 进程:一个核心服务器,和它生成的网关。部分提供商流量(原来是 DeepSeek 的)由核心服务器获取,核心服务器从未加载过那个补丁——只有网关加载了。正确的层,错误的进程:部署到一个进程的补丁对其兄弟进程毫无作用,当目标是"容器"时,部署单位是进程树。
第三次尝试让加载变得普遍:NODE_OPTIONS 加 --require,这样容器中每个 node 进程在诞生时就加载该模块。模块加载了。什么都没发生。--require 加载一个文件;不调用其中的任何东西——我的模块导出了一个 install() 然后礼貌地等待一个永远不会来的调用者。加载不等于运行。修复方案是让模块在加载时自动安装自己,幂等地,这样被加载就是被部署。
第四次尝试瞄准一个已经存在的文件:网关的 preload。也是陷阱——核心服务器每次启动时通过 writeFileSync 从内嵌副本重新创建该文件,所以对它的编辑寿命只有一次重启。不要打补丁生成的 artifact。带来你自己的文件,并让它被邀请。
第五种配置是现在正在运行的:
NODE_OPTIONS: "--require /data/.claude-code-router/sse-coalesce.cjs"
我的文件,保存在 ops 仓库中,以只读方式 bind mount 进容器,路径是核心不知道的——这正是没有任何东西覆盖它的原因——加载进每个 node 进程,在落地的瞬间安装自己。仓库是唯一权威;容器只是它运行的地方。
这个放置方式还藏着一个陷阱,是中间件本身第一次需要修改时才发现的:bind mount 的内容不属于 compose 的哈希范围,所以编辑文件不会触动运行中的容器——况且 --require 只在进程诞生时加载。改了不等于重载了。每次修改中间件都意味着 docker compose up -d --force-recreate,否则那个修改什么都不是。
接触真实流量之前,中间件经过了九个单元测试——合并本身、content-length 移除、背压传递、compression bypass、拒绝跨块索引合并、ping 丢弃等。线上验证通过 DeepSeek 运行,因为智谱的五小时配额上限([1308])在检查中途打断了计划——一次意外同时证明了中间件不关心它在为哪个提供商挡事。一个 200 token 的响应作为 85 个事件进入,作为 11 个事件离开,由中间件自己的 stats log 计数——它记录每次合并,并在 256 KB 处截断,所以可观测性不会变成它自己的事故。
一个卫生方面的改动顺便搭车:路由器的请求日志一直在捕获完整 body——156 MB——那天晚上捕获从全量变成了仅错误,把文件 vacuum 到 12 MB。所以前文中的粒度数字是一个夜晚的记录,不是重新运行的结果。
部署之后,中间件杀掉了小时级别的挂起——但留下了一点残留。第二天下午我的手机和扩展再次出现分歧,只是规模小了:通知按时发出了,扩展又花了三分钟才展示 Claude 在问的问题。Transcript 判定谁说了真话,和之前一样——问题进入 transcript 的秒数和通知发出的秒数相同。三分钟全是显示队列的代价:这一轮最后两个响应经过了合并器,仍然作为 534 个事件到达,估计每个渲染耗时 250 到 400 毫秒。最后这个数字值得停下来想想。它是扩展的每事件成本,通过除法得出,是这个故事里所有其他东西都在推向的天花板。
stats log 也解释了合并器为什么没有合并更多。一整天下来,40 毫秒窗口只买到了三到五倍的缩减:提供商每 25 到 50 毫秒发出一个 delta,这么窄的窗口只能抓住一两个就过期了。那个旋钮在任何人都不知道流节拍的时候就被设好了。
所以旋钮学会了节拍,按类型:全局 200 毫秒,thinking 500 毫秒——占 99% 的流量,那里没人盯着流畅度——text 120 毫秒,那里延迟是人类能感知的东西。跑在真实流量上:719 事件进,31 出。然后 337 进,14 出。二十三倍起步,胜过我设的十五倍下限。天花板本身在上游,未被动摇——一个在长会话中每个事件花四分之一秒的渲染器——所以管道我这边的整个游戏是看有多少事件需要渲染。少即是战略。在长会话中 /compact 也是。该渲染器现在归档在上游,anthropics/claude-code#86854,上述每事件成本数字被记录在那里,供谁来修它。
中间件是 16 KB 代码。写它是这份工作的短半程。长半程是学会代码必须放在哪里才算部署完成——放在实际在用的 API 中,放在每个有关进程的进程中,被调用而非仅仅被加载,放在一个不会被任何东西重生成的文件中,修改时用 force 重新创建。一个修复在写完的时候不存在。它在跑起来的时候才存在。
不过故事还没完。第二天早上,同一台机器差点在负载 38.7 之下宕机——一个看似独立的事件,结果是这件事二十分钟后的余波。那是《Twenty-Hour Fuse》。