长会话里打字越来越卡?Hermes 桌面端把每次按键的开销砍到了三分之一


下午三点,你把一个跑了两天的会话往上翻,想找早上那几条结论。翻到顶之后,你回到输入框,开始打字——然后你停了一下:字是一个一个蹦出来的,删掉一个字符要等半秒,光标跟不上手指。你换了台机器,还是卡;你以为是模型在后台跑,可模型明明在等你说完话。

真正的问题在输入框本身。Hermes 桌面端 10 月 26 日合入的一项性能改动(PR #144903)把这件事说清楚了:历史加载得越多,打字越慢,因为每一次按键都会触发整段对话的重新渲染。修完之后,同一个 300 轮会话里,每次按键的主线程开销从 33.9 毫秒降到 9.6 毫秒。

一次按键,为什么会让整段对话重算一遍

Hermes 桌面端的聊天界面建在 assistant-ui 这个组件库上。输入框里的文字不是老老实实待在自己的小盒子里——它是 store(共享状态仓库)里的一个值,而界面里很多地方会订阅它:

  • 发送按钮该亮还是该灰;
  • 输入框里只有问号时要不要弹帮助提示;
  • 这行字是斜杠命令(/model 之类)还是普通消息;
  • 输入框该显示占位符还是真实内容;
  • 多行文本的换行布局。

问题在于,这些订阅方的检查不是“只检查输入框”,而是跟着 store 的写入一起,把已经挂在页面上的每一条消息的选择器都重新过一遍。会话越长、挂载的轮次越多,这一遍就越贵。PR 里的注释写得很直白:短会话里每次按键约 0.5 毫秒,长会话里能到 19 毫秒,而这只是其中一环。

于是就有了那个很反直觉的体验:不是越用越慢,是同一个会话里你往回翻得越多,打字越慢。因为翻回去意味着更多历史被真正挂载到 DOM 上。

改法:把“每次按键都写”换成“只在必要的时候写”

修法不是给输入框加个节流阀就完事,而是把“什么时候必须写进 store”这个判断做成了显式逻辑。新增的 composer-store-writer.ts 里有一个函数叫 composerTextEdges(text),它把上面那五件订阅方真正关心的事压成一个签名:

text.length === 0                          // 空 / 非空
trimmed.length > 0                         // 有实际内容
text === '?'                               // 单独一个问号
trimmed.length > 0 && !SLASH_COMMAND_RE    // 普通消息(非斜杠命令)
text.trimEnd().includes('\n')              // 多行

只要这次按键没有让这个签名发生变化,订阅方看到的仍是同一个状态,那么这次写入就可以等。等多久?代码里定了一个常量 TYPING_STORE_IDLE_MS = 180——打字停顿 180 毫秒后,尾部的文字才真正落进 store(草稿持久化和运行时镜像从那里读)。

剩下的路径也被补齐了,否则延迟写会引入新的 bug:

  • flush():在任何需要 store 是“当前值”的读取之前,立刻把待写文字落盘;
  • cancel():组件卸载时丢掉待写文字,不留脏数据;
  • observed():store 被外部(运行时订阅)改动时,把它认作新的基线,避免旧值覆盖新值。

另外那个“粘住的提示条”(sticky prompt,长会话里贴在顶部的输入回显)也不再每帧重新给每条消息算样式,改成注册一次 CSS 变量,滚动时不再重排。

实测数字

PR 里给的是一组可复现的测量:300 轮 / 1800 条消息的会话,先用 2500 帧滚轮把整段历史读一遍(所有轮次挂载,约 4.2 万个 DOM 节点),然后敲 60 个字符。

指标 改动前 改动后
每次按键的主线程耗时(历史已读满) 33.9 ms 9.6 ms
每次按键的主线程耗时(刚打开会话) 12.1 ms 6.0 ms
每次按键时 store 刷新的耗时 18.0 ms 1.3 ms

作者也把边界写清楚了,这点值得照抄:这些是共享机器上的单次测量,另一次“改动前”的对照跑出了每次按键 49.3 毫秒;在这台 20 核机器上,从按下到画面更新本来就在一帧左右,所以 33.9 和 9.6 的差别主要落在慢机器上——主线程越紧张,输入延迟越明显。换句话说,这不是一个“所有人体感都翻三倍”的改动,而是“你的笔记本不用再替历史付打字税”。

你该做什么

这次改动目前只在 main 分支上:v0.22.5 是 10 月 23 日发布的,而 PR 是 10 月 26 日合入的,所以哪怕是当天的 canary 标签也还不包含它。三条路:

  1. 等下一个版本发布后 hermes update 升级——最省事;
  2. 跟 main 走(hermes update 可以切换通道,具体见 升级通道说明);
  3. 现在就想缓解,先把会话拆短:长会话的输入开销跟挂载的历史成正比,开新会话或做一次 会话边界整理 比什么都直接。

如果你本来就觉得“桌面端比 CLI 卡”,可以顺手看一下 启动延迟那件事——启动慢和输入慢是两个独立的问题,这次只解决了后者。

这件事的真正含义

大多数人把“agent 卡”归因到模型:token 太多、上下文太长、provider 在排队。这次的问题提醒我们,界面层也会长出自己的性能债,而且它长得特别隐蔽——它不在日志里,不在 trace 里,只在你的手指和屏幕之间。

判断方法很简单:下次觉得卡,先看卡的是“打字”还是“回复”。打字卡、回复正常,那是渲染问题;两边都卡,才去看模型和网络。把这两件事分开,排查时间能省一半。