← 返回全部文章
Engineering

模型在流,文字为什么还在跳?

Evanchen By Evanchen
蓝色阶梯线表示突发到达的模型输出,绿色连续曲线表示浏览器按待渲染积压逐帧追赶,终态处两者立即合拢。

模型在流,屏幕上的文字却不一定在流。

模型可能持续返回原生增量,但这些增量穿过 Provider、Agent Runtime、事件缓冲和 WebSocket 后,浏览器收到的往往已经是一批一批的数据。如果 Web UI 在每次收到消息时把整批文字一次性塞进 React,用户看到的就是周期性的跳动:静止一会儿,突然多出一段,再静止一会儿。

这不是模型停止了生成,也不一定是网络变慢了。问题在于,我们把两只不同的时钟当成了一只。

流式输出有两只时钟:事件何时到达,以及文字何时进入下一次屏幕绘制。

requestAnimationFrame 不等于平滑

mosoo 的服务端不会为每个微小增量单独发送一条 WebSocket 消息。Viewer 事件会先被压缩并合并,最多等待 150ms;达到 4KB、64 个事件,遇到首个增量或 Run 终态时则会提前发送。这些边界直接写在 delivery buffer 中,首个增量和终态的快速路径也有独立的判断

这是一笔必要的系统账:合并事件可以减少消息数、序列化次数和下游状态更新。代价是,浏览器看到的输入节奏不再等于模型的生成节奏。

旧实现已经用 requestAnimationFrame 合并渲染,但它会在下一帧交付队列里的全部文字。rAF 只回答了“什么时候提交”,没有回答“这一帧提交多少”。当一次 WebSocket 消息已经包含约 150ms 的输出时,下一帧整批提交仍然是一跳。

关键不是固定打字速度,而是待渲染积压

最直接的方案是做一个打字机:每隔若干毫秒显示一个字符。但固定速度无法同时适配慢模型、快模型、短尾巴和突然到来的大批次。

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 会直接交付整批内容。此时继续以最高速度播放,也可能让屏幕落后真实状态数秒。过载时停止美化,是性能策略的一部分。

待渲染文本进入逐帧预算器;活跃流继续平滑,END 事件立即冲刷,积压超过 4096
时整批交付,requestAnimationFrame 暂停时由 50ms Timer
接管。

平滑只存在于“仍在生成且积压可控”的窗口。正确性、终态和新鲜度拥有更高优先级。

还有几条边界同样重要:

WebSocket 收到事件后,Scheduler 每帧只向 Live State 提交一个批次;关闭连接时则显式冲刷。调用边界保持在 Session Stream Socket 内

低开销不是一个魔法数字

这次改动没有引入动画库,也没有给每个字符创建 Timer。它复用了浏览器的帧时钟,在一个队列中完成预算、切分和批量状态更新:

  1. 字素只在入队时拆分一次。
  2. 每帧按真实经过时间计算一个整数预算。
  3. 一帧最多触发一次批量 State 更新。
  4. 只平滑用户可见的文本增量;结构事件本身不做逐字动画。
  5. 终态和超大 backlog 都有直接退出路径。

这是一组结构上的低开销约束,不是未经测量的性能承诺。PR #450 的验证重点是时间语义:用可控时钟覆盖了 60Hz / 120Hz 一致性、Emoji 边界、终态冲刷、重连快照、严格顺序、隐藏页兜底和超大 backlog。这些 Case 可以在测试文件中逐项查看。这次没有生产环境的 CPU 或长任务基准,因此我们不把“更平滑”写成“已经量化的更快”。

一条可以复用的原则

如果你正在模型原生输出之上构建 Web UI,可以先把问题拆成四条规则:

  1. 首个增量绕过传输缓冲,保护首字延迟。
  2. 生成中段按 backlog 调整逐帧速度,而不是固定打字速度。
  3. 终态和快照立即冲刷;连接关闭或切换时显式冲刷或清理队列。
  4. 积压失控时停止动画,新鲜度比平滑更重要。

传输层负责减少系统负担,渲染层负责连续的感知,而事件语义决定两者何时让路。

这就是 mosoo PR #450 背后的核心:没有修改模型如何输出,也没有伪造更多 Token;只是让浏览器用一个有界、可退出的显示时钟,忠实地追上模型原生输出。


← 更多文章 Learn More →