模型在流,文字为什么还在跳?
模型在流,屏幕上的文字却不一定在流。
模型可能持续返回原生增量,但这些增量穿过 Provider、Agent Runtime、事件缓冲和 WebSocket 后,浏览器收到的往往已经是一批一批的数据。如果 Web UI 在每次收到消息时把整批文字一次性塞进 React,用户看到的就是周期性的跳动:静止一会儿,突然多出一段,再静止一会儿。
这不是模型停止了生成,也不一定是网络变慢了。问题在于,我们把两只不同的时钟当成了一只。
流式输出有两只时钟:事件何时到达,以及文字何时进入下一次屏幕绘制。
requestAnimationFrame 不等于平滑
mosoo 的服务端不会为每个微小增量单独发送一条 WebSocket 消息。Viewer 事件会先被压缩并合并,最多等待 150ms;达到 4KB、64 个事件,遇到首个增量或 Run 终态时则会提前发送。这些边界直接写在 delivery buffer 中,首个增量和终态的快速路径也有独立的判断。
这是一笔必要的系统账:合并事件可以减少消息数、序列化次数和下游状态更新。代价是,浏览器看到的输入节奏不再等于模型的生成节奏。
旧实现已经用 requestAnimationFrame 合并渲染,但它会在下一帧交付队列里的全部文字。rAF 只回答了“什么时候提交”,没有回答“这一帧提交多少”。当一次 WebSocket 消息已经包含约 150ms 的输出时,下一帧整批提交仍然是一跳。
关键不是固定打字速度,而是待渲染积压
最直接的方案是做一个打字机:每隔若干毫秒显示一个字符。但固定速度无法同时适配慢模型、快模型、短尾巴和突然到来的大批次。
- 速度太慢,模型已经结束,用户还在看几秒前的文字。
- 速度太快,传输批次依然会变成肉眼可见的跳跃。
- 每个字符一个 Timer,还会制造大量回调和状态更新。
mosoo 采用了另一种控制量:已经收到、但尚未渲染的字素数量。
pending = received - rendered
rate = clamp(pending / 0.25s, 20, 800)
budget += rate * elapsedSeconds
emit = floor(budget)
积压越多,下一帧追得越快;积压越少,速度越缓。时间常数 τ = 250ms 让一次约 150ms 的传输批次在下一批到达前仍处于消化过程,于是相邻的阶梯被连成连续流。20–800 字素/秒的上下限分别避免短尾巴永远磨蹭,以及快到已经无法被阅读的动画。
浏览器不是重放模型的每个 Token,而是用第二只时钟追踪第一只时钟。每帧预算取决于 backlog,而不是一个固定的打字速度。
预算使用真实经过时间 Δt,而不是假设每帧恰好 16ms。因此 60Hz、120Hz 和被节流的页面,在相同墙上时间内会得到接近的可见速度。小于一个字素的预算不会丢失,而是作为小数余量带到下一帧。完整预算计算和队列切分都集中在同一个 Scheduler 中。
可切分的单位是字素,不是 UTF-16 下标
如果按 JavaScript 字符串下标切分,可能把代理对、组合字符或 ZWJ Emoji 从中间切开,短暂渲染出 � 或半个 Emoji。
mosoo 在事件进入队列时用 Intl.Segmenter 预先拆成 grapheme clusters;不支持时退化到 code point 迭代。这段逻辑只执行一次,逐帧预算复用拆分结果。这既守住了文本正确性,也避免每帧重复扫描同一段字符串。
平滑必须知道何时停止
一个只会“慢慢显示”的 Scheduler 并不完整。动画必须服从事件语义。
当队列里出现 Message End、Reasoning End、Run 终态或重连后的 State / Messages Snapshot 时,mosoo 会把此前的积压立即交付。服务端已经证明这一段结束,就没有理由继续播放过时动画。这些事件在代码中是明确的 pacing barriers。
同样地,当积压超过 4096 个字素时,Scheduler 会直接交付整批内容。此时继续以最高速度播放,也可能让屏幕落后真实状态数秒。过载时停止美化,是性能策略的一部分。

平滑只存在于“仍在生成且积压可控”的窗口。正确性、终态和新鲜度拥有更高优先级。
还有几条边界同样重要:
- 所有事件保持严格 FIFO,后来的工具或状态事件不能越过仍在显示的文字。
- 单帧最多交付 512 个事件,避免病理性事件风暴制造一次巨大的 React Commit。
- 页面隐藏导致
requestAnimationFrame暂停时,50ms 的 Timer 兜底继续排空队列。 - Socket 关闭时,活动 Session 会
flushNow;已不再活动的 Session 队列会被清掉,避免内容串到另一个 Session。
WebSocket 收到事件后,Scheduler 每帧只向 Live State 提交一个批次;关闭连接时则显式冲刷。调用边界保持在 Session Stream Socket 内。
低开销不是一个魔法数字
这次改动没有引入动画库,也没有给每个字符创建 Timer。它复用了浏览器的帧时钟,在一个队列中完成预算、切分和批量状态更新:
- 字素只在入队时拆分一次。
- 每帧按真实经过时间计算一个整数预算。
- 一帧最多触发一次批量 State 更新。
- 只平滑用户可见的文本增量;结构事件本身不做逐字动画。
- 终态和超大 backlog 都有直接退出路径。
这是一组结构上的低开销约束,不是未经测量的性能承诺。PR #450 的验证重点是时间语义:用可控时钟覆盖了 60Hz / 120Hz 一致性、Emoji 边界、终态冲刷、重连快照、严格顺序、隐藏页兜底和超大 backlog。这些 Case 可以在测试文件中逐项查看。这次没有生产环境的 CPU 或长任务基准,因此我们不把“更平滑”写成“已经量化的更快”。
一条可以复用的原则
如果你正在模型原生输出之上构建 Web UI,可以先把问题拆成四条规则:
- 首个增量绕过传输缓冲,保护首字延迟。
- 生成中段按 backlog 调整逐帧速度,而不是固定打字速度。
- 终态和快照立即冲刷;连接关闭或切换时显式冲刷或清理队列。
- 积压失控时停止动画,新鲜度比平滑更重要。
传输层负责减少系统负担,渲染层负责连续的感知,而事件语义决定两者何时让路。
这就是 mosoo PR #450 背后的核心:没有修改模型如何输出,也没有伪造更多 Token;只是让浏览器用一个有界、可退出的显示时钟,忠实地追上模型原生输出。