← 返回文章选择

MediAC 的重复气泡,要清的是哪份状态

重复气泡不该按文字去重:临时文本和正式消息怎么交接,异常路径怎么逐条查。

一条回复显示两次,先别急着按文字去重。流式页面里本来就可能同时有两份文本:正在接收的临时回复,和完成后进入消息列表的正式回复。两份都画出来,后端即使只生成了一次,页面也会多一个气泡。

要清的是临时状态

九月整理 frontend/src/App.tsx,正常完成路径把消息加入列表后,紧接着清理 streamConsult。这里清的是临时状态:

流式回复进入正式消息后,清空临时状态使临时气泡退出

文字版
token 到来 → 追加 streamConsult → 显示正在生成的气泡
正常完成 → 完整回答进入 messages
→ streamConsult 置空

渲染临时气泡的条件是 loading && streamConsult。正式消息进了列表,即使后面还在轮询报告、loading 没结束,临时内容一清空,正在生成的气泡也会退出。只在整个请求的 finally 里关 loading,表达不了这个更早的交接时点。

React 对这类临时内容有一个现成的模型。useOptimistic 文档说,乐观状态只在一次 Action 进行中渲染,Action 结束后,临时状态和真实状态在同一次渲染里收敛,不需要额外一步去“清”。useTransition 的 isPending 也是挂在 Action 完成上,而不是整条网络请求上。“进行中”的粒度本来就该比一次请求细,它应该跟着交接走。

另一种写法是把状态摊开成一个字段。Vercel AI SDK 的聊天界面文档用 submitted、streaming、ready、error 四个值表示一次请求走到哪,并保证最新消息在进入 ready 或 error 之前就已发布。渲染条件写在这一个字段上,交接时点就是它换值那一刻,不用靠 loading 和 streamConsult 两个变量去凑。

按字符串去重是错的方向

我觉得这个问题比“把一样的文字删一份”更适合拿来讲状态。用户完全可能连续收到两句相同的回复,文本相等不代表是同一条消息。反过来,同一次生成会在临时回复和正式消息里各留一份,文字一样,只有一份该显示。

要清的是这次生成的占位内容,依据应该来自流程完成,而不是比较字符串。React 状态快照可以帮助理解为什么页面会同时反映两份状态:一次渲染里读到的状态值不会变,设置状态只影响下一次渲染。同一帧里既有临时气泡又有正式消息,是两份状态各自为真,不是渲染出错。

用 id 认消息,不用文字

React 文档在选择状态结构一章里,把同一份数据存两处直接列为 bug 来源,给的办法是只留 id,不留副本。渲染列表一章也要求 key 唯一、不能变,要用数据里的稳定 ID,不要在渲染时现生成。正在流式的文本每一帧都在变,拿它当身份必然出错。

落到这里,我会让一条消息从开始流式起就有一个 id,临时回复和正式消息指向同一个 id,完成时用正式那份替换掉占位。前端生成 id 用浏览器自带的 crypto.randomUUID() 就够,不过它只在安全上下文(HTTPS)里可用,用局域网 IP 走 HTTP 调试时会拿不到。

正常路径之外,还没查完

当前这段代码能说明正常完成怎样处理,但没有历史 diff 可以逐行还原四月修复前的现场。正式消息进列表和临时内容清空是挨着的两步,两份状态一起收尾。断流、没有 done、保存后出错这几条路径还得分别检查,不能把这一处清空当成所有异常都解决了。

照着文档过一遍,我会把要单独验的路径列成这样:

  1. 流是怎么结束的。ReadableStreamDefaultReader.read() 在流正常关闭时返回 done: true,流出错时直接 reject。前者才轮到判断“done 事件来没来”,后者要按失败收尾,清掉临时状态并提示用户。
  2. 断在半条事件上。事件流规范规定,流结束时缓冲区里不完整的事件直接丢弃;连接断开后原生客户端默认还会重连。所以没有 done 的内容不该当成功用,清理时机也不能只看连接状态。
  3. 两路流交叉。React 讲取数据的那节提醒,多个请求谁先回没有保证,官方做法是只认最后一次请求,其余响应忽略。用户连发或重试时,先发的那路后到,可能把已经收尾的临时内容又写回去。
  4. 交接放在 Effect 里。同一页指出,用 Effect 跟着变化同步状态,会先用旧值渲染一次再纠正。临时内容要是靠 Effect 盯着请求状态去清,中间那一帧旧气泡还在。
  5. 开发环境重复请求。Strict Mode 下,React 在开发时会把组件挂载后立刻再挂一次,Effect 里发起的请求会发两次。本地稳定复现、线上不复现的重复气泡,先查这里。
  6. 追加写法。批量更新那一章说明,一次事件里多次改同一个状态要用 updater 函数。逐 token 追加如果写成 旧值 + 片段,可能丢内容或出错,这和状态交接是两条线,要分开查。

拿它讲一次前后端流程

如果用这个 demo 做 90 分钟讲解,我会留一段时间给重复气泡:

  1. 先看页面上的两份回复;
  2. 再找 messages 和 streamConsult;
  3. 最后沿 token、保存、done 回到后端。

听的人已经认识一次正常请求,再看状态什么时候交接,比先把所有技术名词讲一遍容易跟上。

这三步其实和 React “用状态响应输入”里的建模顺序对得上:先列出界面有哪几种视觉状态,再找触发它们的输入(包括网络响应到达这类机器输入),然后用状态表示、删掉不必要的状态、最后接上事件处理。临时气泡、正式消息是视觉状态,token、保存、done 是触发者。重复气泡正好是“没删掉不必要的状态”时会出的事,拿来当讲解的例子很顺。

留下几句话 读者来信

读到这里,有什么想法?欢迎留下你的看法。