详细分析Claude Code在VS Code远程SSH下因流式事件过于细碎导致session看似完成的bug,涉及事件合并中间件和队列机制。
那个 bug 在于,两个时钟各读各的,却都没说谎。
8月14日夜里,Claude Code 正在为我规划一趟旅行。八天行程——不是写代码,仅仅是旅行规划——运行在 VS Code 扩展里,对接的是我那些 session 所在的远程机器。刚过午夜,手机震了一下:轮次已结束,Claude 正在等待我批准它的计划。那下震动来自 claude-code-notify,这是我自写的一个钩子,用来在长时间轮次或等待我输入时不会漏掉通知——在它出现之前,根本没有任何通知。我看到消息后切换到窗口,session 标签页却显示轮次仍在执行。
两个指示器都是对的,而它们之间那一小时的差距就是那个 bug。只是花了点时间才看清一个 session 怎么可以同时是已结束和未结束的状态。
扩展没有卡死,也没有空转。它时不时往屏幕上推一点新文本——只是那些文本已经是旧的,等到达屏幕时已经过时了。它不是什么都没显示,它是在缓慢地展示过去。
这个区别在我动手之前就完成了大部分诊断工作。丢失的握手会丢事件:事件缺失,session 看起来被截断了,丢失的部分永远不会出现。队列的行为则相反:所有内容都会到达,按顺序,只是晚了。我看到的不是交付失败,而是一个交付调度。事件没有丢失,它们堆在某处(CLI 和屏幕之间),而堆栈前端的东西移动得很慢。
那个卡住的 session 的进程仍然活着——在睡眠,没有空转。CPU 几乎为零,网络连接为零,子进程为零。等待模型的轮次有一个 socket 打开;运行命令的轮次有子进程。这个既没有前者也没有后者。它不是在等互联网,也不是在等 shell。它在等本地的东西,就像程序在等 pipe。
session 自己的 transcript 更精确地说明了这一点。Claude Code 将每个 session 作为一行行 JSON 的文件记录下来,那晚的最后一行是一个完整的 assistant message——usage 已计入,stop reason 已记录——以一个 tool call 结尾:请求我批准它起草的计划。在那行之后,什么都没有。没有结果,没有收尾。CLI 已经完成了它的 question 的产生,却从未听到轮次结束的通知;它仍然以为 session 还在飞行中。就连那个 tool call id 也带有一点自白——它的格式是第三方提供商的,而不是 Anthropic 的,证明了这个请求走的是哪条路。
我在那台机器上通过 claude-code-router 运行 Claude Code——ccr,一个自托管的路由器,让我可以把同一个工作流指向官方 Anthropic 订阅或第三方提供商。那晚它指向的是 Zhipu GLM 编程计划。路由器会记录它转发的每个请求,所以"模型侧实际上完成了吗?"这个问题有了一个直接答案:那个轮次的请求在 1.7 秒内完成——HTTP 200,没有重试,没有凭证节流——在其周围的那一小时内,186 个请求,全部 200。所有在 CLI 上游的东西在我开始问问题之前很久就已经完成了。
这就把卡顿锁定在了一个段:CLI 之后,屏幕之前。
剩下的嫌疑者名单很短,而一个矩阵一次就清空了它们。Terminal 加 ccr:正常。扩展加官方 API:正常。扩展加 ccr:卡住。两个能用的组合除了都避开了那个失败的组合之外没有任何共同点——所以变量不是路由器,也不是扩展。是流在扩展消费路由器转发的东西时的样子。
路由器的日志那晚捕获了响应体,每个响应体用两个数字讲述了故事:
每个 token 几乎一个流式事件,每个约 135 字节——单个响应 855 KB 的 server-sent events——开启思考模式后,大约 99% 都是 thinking deltas。官方 Anthropic API 不是这样流的:它将许多 token 合并到每个 delta 事件中,这使其事件率低一到两个数量级。同样的对话,同样的渲染器——事件计数却差异巨大。(那些响应体现在已经没了;我后来把捕获调低到仅在失败时开启,所以这个表是一晚的记录,不是你今天能在我的机器上重跑的测量数据。)
现在是机制。每个流式事件,扩展做三类工作:解析事件的 JSON、通过 Remote-SSH 隧道向 webview 发送消息、重新渲染整个对话——在长 session 上重新渲染整个对话并不便宜。每一件事本身都不慢。慢的是乘以六千个事件,而提供商那边产生它们的速度比扩展消耗它们的速度更快。
CLI 和扩展之间是一个普通的 stdout pipe,默认 64 KB。当读取方落后时,pipe 就会满,然后写入方阻塞:CLI 的下一次写入会停滞,直到扩展排掉一些积压。CLI 试图说的下一件事——包括小的控制消息,比如"我需要你批准这个计划"——都排在了成千上万个等待轮到渲染器的小 delta 后面。一条在凌晨 12:28 就绪的批准提示在一小时后才到达屏幕,不是因为任何东西崩溃了,而是因为它加入了一条很长、很慢的队伍的末尾。
这就是为什么我的手机是对的。claude-code-notify 挂在 CLI 自己的一系列钩子上——session 生命周期——而不是扩展的渲染通道上。CLI 完成了它的消息并按时提出了批准请求;钩子按时触发,直接发往 Telegram,从未站在渲染队列里。两个时钟并不是在争论同一个量。一个在读取 session。另一个在读取一个队列。
通知报告了 session:消息结束,等待批准——正确,时间是 12:28。扩展报告了它的渲染队列:数千事件深度,移动缓慢——也是正确的,不过说的是另一件事。看似矛盾的东西其实是两个时钟在测量两根不同的管道,而它们之间那一小时不过是排空时间。
不过,在我的设置上游有一个真正的设计问题。像这样慢的渲染器不应该能够阻塞协议通道:流式事件可以为显示而批量处理,而不需要让控制消息在它们后面等着。每个 token 的粒度暴露了这个问题,但耦合是扩展自己的。我已经向上游提交了——anthropics/claude-code#86854——带着事件粒度数据。
修复来的时候,并不是在扩展里——而是一个小型中间件,在流到达 pipe 之前将那些 delta 合并成块。实际加载它花了五次尝试,四次失败。那是下一篇文章的主题。
那个修复是下一篇文章的主题——《合并流》(Coalescing the Stream)。