Steven
Steven1 分钟阅读

如何流式呈现实时报告而不闪烁

会议结束后,GeekBye 的摘要现在会实时逐步填入,而不再让你盯着一个转圈图标发呆。要让流式 UI 感觉平稳而非抖动,我们不得不把闪烁解决两次 —— 一次是为结构化字段,一次是为 markdown —— 而第二次的解法,是一个我们早已拥有的渲染器。

工程
前端
流式传输
GeekBye 发布
如何流式呈现实时报告而不闪烁

在这次发布之前,结束一场 GeekBye 会议意味着盯着一个转圈图标。应用会把你的整段文字记录发去分析,等待整份摘要 —— 评分、要点、行动项,样样齐全 —— 然后一次性砸到屏幕上。功能是有的,但它感觉很慢,因为在它工作时你只能盯着一片空白。

GeekBye v1.6.13–v1.6.15 把它变成了一份实时流入、随生成而逐字段呈现的报告。有意思的部分不是流式传输本身 —— 而是我们为了不让流式传输看起来抖动所必须做的一切。平稳的流式传输和卡顿的流式传输是同一个功能,只是执行方式截然不同。

闪烁,第一幕:别让每个数据块都去猛摇 React

摘要不是一个整块 —— 它是一组结构化字段(总体评分、要点、行动项等等),由后端通过 server-sent events 逐个发出。渲染它的天真做法,是每来一个事件就更新一次 React state。

这么做,你就会得到闪烁。每个字段的到达都触发它自己的重新渲染;本来显示"空"的组件被挂载、又被卸载、再作为"已加载"重新挂载;布局在每个数据包上都抽搐一下。报告以最糟糕的方式在你眼前自我组装 —— 看得见地、神经质地。

解法是一套两部分的纪律。第一,把进来的字段累积在一个 ref(一个不触发渲染的可变容器)里,每个数据块以一份全新副本向 React state 发布一次,让树是刻意地更新,而不是在每个微小事件上更新。第二,无论在流式传输中还是已完成,都始终渲染一棵稳定的组件树,为尚未到达的字段留下行内占位符:

// accumulate in a ref, publish once per chunk
partialRef.current = { ...partialRef.current, [field]: value }
setPartial({ ...partialRef.current })

// one tree, never swapped; missing fields show a placeholder in place
const report = isStreaming ? partial : saved
<Score>{report.overallScore ?? '…'}</Score>

显示评分的组件从不卸载 —— 在数字落地之前它只显示 ,然后才显示数字。什么都不抽搐。而完整组装好的对象最终仍会缓存到本地数据库,因此流式 UI 与持久存储可以共存而不打架。

闪烁,第二幕:那个我们早已拥有的渲染器

第二种闪烁更微妙,藏在 markdown 本身里。报告渲染的是丰富的 markdown —— 标题、粗体、列表、代码块。但在流式传输中途到达的 markdown,在任何一个瞬间都是半成品。一份到目前为止只有一个圆点的列表。一个已经打开却尚未闭合的代码围栏。一个还没找到搭档的粗体标记。

传统的 markdown 渲染器会在每个 token 上重新解析整个字符串,而那半成品状态每次都会解析成不同的东西 —— 于是列表闪烁,代码块忽开忽合,布局随着 token 完成而跳动。这与前面是同一种神经质的组装,只是低了一层。

解法几乎近在眼前到令人有点难为情:我们早已在实时 AI 聊天里用着一个流式感知的 markdown 渲染器。 一个专为容忍不完整 token 而造的渲染器 —— 稳定地渲染半成品 markdown,只在 token 完成时才让它落定,而不是每次从头重新解析。报告只需用上同一个即可。这个一模一样的问题我们几个月前就为聊天解决过了;给报告的"修复",是认清我们已经拥有这个工具,并把它对准第二个界面。复用胜过重建。

附赠:富文本复制是一个剪贴板格式问题

一旦报告好看了,人们就想把它粘贴出去 —— 贴进文档、贴进邮件 —— 并保留格式和截图。本能的做法是把报告序列化成一串 markdown 字符串,然后指望目标端把它渲染出来。它通常不会。

真正的答案是,剪贴板可以同时持有多种表示。所以我们写入两份:一份格式完好、截图以内联图片嵌入的 HTML 版本,以及一份供不接收 HTML 的目标端使用的纯文本回退。粘贴进富文本编辑器,你得到带图片的带格式报告;粘贴进纯文本框,你得到干净的文本。"富文本复制"从来不是一个序列化问题 —— 它是一个挑对剪贴板格式的问题。

同一次发布还给报告加上了聊天风格的 Me / Them 标签,把两位说话者对齐到相反的两侧,于是一段文字记录读起来就像它本来所是的那场对话;并且把你自己的截图移到属于它的那一侧以作呼应。(这个时间窗里的一次发布,v1.6.14,是一次纯粹的重新构建 —— 没什么故事可讲,坦白说出来才诚实。)

流式 UI 教给我们的三件事

  1. 缓冲流入的字段,每个数据块只发布一次。 让每个 server-sent event 各自驱动一次 state 更新,正是闪烁的根源。一个 ref 累加器,加上每个数据块单次发布,注入一棵永不卸载的树,就能把神经质的组装变成平稳的填入。
  2. 任何会流式传输的东西都需要一个流式感知的渲染器。 一个逐 token 重新解析整个字符串的渲染器,会在半成品 markdown 上闪烁。用一个为不完整输入而造的 —— 而如果你已经为另一个界面拥有了一个,那就在再造第二个之前先复用它。
  3. 富文本复制关乎剪贴板格式,而非序列化。 写入内嵌图片的 text/html以及一份 text/plain 回退。剪贴板本就被设计来承载两者;用起来。

这是终将汇成 GeekBye v2 的可靠性与打磨故事的第五章。上一章请见耳机的那个 bug(v1.6.12);至于同样的 server-sent-events 思路后来如何变成一整套回退传输,请见当防火墙拦住 WebSocket 时的实时转录(v2.0.8);而整条弧线,请见把软件打磨到完美地交付的解剖学