两个钟,都没说谎

14 阅读7分钟

8 月 14 日那晚,Claude Code 在帮我规划一趟旅行。一份八天的行程——不是写代码,就是在规划旅行,跑在 VS Code 插件里,连着我会话所在的那台远程机器。刚过午夜,手机震了一下:这一轮结束了,Claude 在等我批准它的计划。震动来自 claude-code-notify,一个我自己写的 hook,为的是不让一轮很长的对话、或者一个正在等我输入的时刻,悄无声息地过去——在它出现之前,没有任何东西会通知我。我看到消息,切到那个窗口。会话标签页上写着,这一轮还在执行中。

两个指示都是对的,它们之间那一个小时的落差,就是那个 bug。只是花了点时间,才看明白一个会话怎么可能同时"结束了"又"没结束"。

是慢,不是丢

插件没有冻住,也不是空的。它时不时会往上多挤出来一点文字——而这些文字到屏幕上的时候,已经是旧的了。它给我看的不是"什么都没有"。它给我看的是过去,而且是慢慢给我看的。

这一个区分,在我动手做任何事之前,就替我完成了大半的诊断工作。一次握手丢失,丢的是东西:事件会不见,会话看起来被截断了,丢的那部分永远不会再出现。一个队列做的事正好相反:所有东西都会到,按顺序到,只是晚到。我看到的不是投递失败——是一张投递时刻表。事件没有丢;它们堆在 CLI 和屏幕之间的某个地方,而堆栈最前面的那个东西,走得很慢。

那个卡住的会话,自己还知道什么

那个卡住的会话,进程本身还活着——是睡着了,不是在空转。CPU 占用可以忽略不计,网络连接数是零,子进程数也是零。一轮在等模型响应的对话,会开着一个 socket;一轮在跑命令的对话,会有子进程。这一轮两样都没有。它不是在等网络,也不是在等某个 shell。它在等一个本地的东西,就像一个程序在等一个管道。

这个会话自己的记录说的是同一件事,只是更精确。Claude Code 把每个会话都记成一份 JSON 行文件,那天晚上的最后一行,是一条完整的助手消息——用量算清楚了,停止原因也记下来了——以一次工具调用收尾:请求我批准它刚起草的计划。那一行之后,什么都没有了。没有结果,没有收尾。CLI 已经把自己的问题问完了,却从来没听到这一轮结束的信号;它一直以为这个会话还在半空中飞着。就连那个 tool-call id 都带着一点小小的自白——它的格式是第三方提供商的格式,不是 Anthropic 的,证明了这次请求走的是哪条路。

一份带时间戳的不在场证明

在那台机器上,我是通过 claude-code-router 来跑 Claude Code 的——ccr,一个自托管的路由器,让我能用同一套工作流程,既连官方 Anthropic 订阅,也连第三方提供商。那天晚上它接的是智谱 GLM 的 coding plan。路由器会记录它转发的每一个请求,所以"模型那边是不是真的处理完了"这个问题,有一个直接的答案:这一轮的请求 1.7 秒就完成了——HTTP 200,没有重试,没有额度限流;在前后那一个小时里,186 个请求,全部 200。CLI 上游的一切,早在我开始追问之前,就已经全部结束了。

这就把卡顿钉死在了一个具体的区间里:CLI 之后,屏幕之前。

只剩一个变量

剩下的嫌疑名单很短,一张矩阵一遍就把它排除掉了。终端 + ccr:没问题。插件 + 官方 API:没问题。插件 + ccr:卡住。这两个能跑通的组合,唯一的共同点就是都避开了那个出问题的组合——所以变量既不是路由器,也不是插件。是插件在消费路由器转发出来的东西时,那条流本身长什么样。

路由器那天晚上的日志,完整记录了响应体,每一份响应体,都用两个数字讲清了这个故事:

日志请求编号输出 token 数SSE 增量事件数
7496,4006,314
7453,8223,803
7462,8772,720

几乎是一个 token 对应一个流式事件,每个大约 135 字节——一次响应就是 855 KB 的 server-sent events——而且开着 thinking 模式的时候,大概 99% 都是 thinking 的增量。官方 Anthropic API 不是这么流式传输的:它会把很多个 token 合并进每一个增量事件,事件速率因此低了一到两个数量级。同样的对话,同样的渲染器——事件数量却天差地别。(那些响应体现在已经不在了;我后来把记录级别调成了只记失败请求,所以这张表只是某一夜的记录,不是今天在我机器上还能重跑出来的测量结果。)

一个瓶口很小的队列

现在来看机制本身。对每一个流式事件,插件要做三件事:解析这个事件的 JSON,把消息通过 Remote-SSH 隧道 post 进 webview,再重新渲染一遍对话——是整个对话,在一个长会话里,这一步并不便宜。单个事件不慢。慢的是六千个事件叠在一起,而提供商那边生产事件的速度,比插件能烧掉它们的速度更快。

CLI 和插件之间,架着一根普通的 stdout 管道,默认 64 KB。当读的一方跟不上,管道就会被填满,然后写的一方就会被阻塞:CLI 下一次要写的东西,会一直卡着,直到插件把积压排掉一些。CLI 接下来想说的所有话——包括一些很小的控制消息,比如"我需要你批准这个计划"——都得排在成千上万个等着轮到渲染器处理的小增量后面。一个凌晨 12:28 就已经准备好的审批提示,一小时之后才到屏幕上,不是因为任何东西崩溃了,而是因为它排进了一支很长、走得很慢的队伍的最后面。

这也是为什么我的手机是对的。claude-code-notify 挂在 CLI 自己的 hook 上——挂的是会话生命周期——不是挂在插件的渲染通道上。CLI 按时把消息写完,按时发出审批请求;hook 也按时触发,直接发到 Telegram,从来没在渲染队列里站过一秒钟。这两个钟,争的根本不是同一个量。一个在读会话本身。另一个在读一个队列。

两个钟都没说谎

通知汇报的是会话本身:消息结束了,正在等批准——在 12:28 这个时间点上,是对的。插件汇报的是它的渲染队列:积压了几千个事件,走得很慢——这也是对的,只是在说另一件事。看起来像是矛盾的东西,其实是两个钟在测量两根不同的管道,它们之间那一个小时,只是排空所需要的时间。

不过,在我这套设置更上游的地方,确实存在一个真实的设计问题:一个这么慢的渲染器,本不该有能力卡住协议层的通道——流式事件完全可以为了显示而批量处理,同时不让控制消息排在它们后面等。逐 token 的颗粒度把这个问题暴露了出来,但这个耦合本身,是插件那边的事。我已经把它报到上游了——anthropics/claude-code#86854——附上了事件颗粒度的数据。

最终的修复,根本不在插件那一侧——是一个小小的中间件,在这条流抵达那根管道之前,先把那些增量重新合并成大块。而真正把它装载进去能跑起来,花了五次尝试,其中四次都失败了。这是下一篇要讲的事。

这个修复本身,就是下一篇的主题——《合拢这道流》