
如何流式呈现实时报告而不闪烁
会议结束后,GeekBye 的摘要现在会实时逐步填入,而不再让你盯着一个转圈图标发呆。要让流式 UI 感觉平稳而非抖动,我们不得不把闪烁解决两次 —— 一次是为结构化字段,一次是为 markdown —— 而第二次的解法,是一个我们早已拥有的渲染器。
在这次发布之前,结束一场 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 教给我们的三件事
- 缓冲流入的字段,每个数据块只发布一次。 让每个 server-sent event 各自驱动一次 state 更新,正是闪烁的根源。一个 ref 累加器,加上每个数据块单次发布,注入一棵永不卸载的树,就能把神经质的组装变成平稳的填入。
- 任何会流式传输的东西都需要一个流式感知的渲染器。 一个逐 token 重新解析整个字符串的渲染器,会在半成品 markdown 上闪烁。用一个为不完整输入而造的 —— 而如果你已经为另一个界面拥有了一个,那就在再造第二个之前先复用它。
- 富文本复制关乎剪贴板格式,而非序列化。 写入内嵌图片的
text/html,以及一份text/plain回退。剪贴板本就被设计来承载两者;用起来。
这是终将汇成 GeekBye v2 的可靠性与打磨故事的第五章。上一章请见耳机的那个 bug(v1.6.12);至于同样的 server-sent-events 思路后来如何变成一整套回退传输,请见当防火墙拦住 WebSocket 时的实时转录(v2.0.8);而整条弧线,请见把软件打磨到完美地交付的解剖学。