2026-04-24

Question

你要为一个 Web 应用接入“流式 AI 对话”能力。用户发送问题后,前端需要像 ChatGPT 一样边生成边展示,并且支持:

  • 实时展示 token 流
  • 用户可中途停止回答
  • 一轮对话结束后,把消息可靠地落盘
  • 页面刷新后还能恢复这轮会话

请从前端工程视角回答:

  • 你会怎么设计这套交互流程?
  • 前端状态怎么组织?
  • 流式返回用什么方式接(比如 SSE / WebSocket / fetch stream)?为什么?
  • 如何处理“停止生成”和“落盘一致性”这两个容易出问题的点?
  • 如果生成过程中网络断了,你会怎么兜底?

Your Answer

我会这样设计我的交互流程。用户可以在输入框进行输入,敲击回车后发送上下文给大模型。大模型收到输入以后,开始生成返回,使用流式传输把信息分批传给前端,直到最后完成整体输入以后,告知前端消息已发送完成。如果用户主动终止了生成,就立刻告诉前端生成停止。生成过程当中,如果用户提交了新的问题,则先加到队列当中,在当前问题生成完成以后再处理。

前端需要记录返回的结果,同时要记录返回结果的状态,是传输中、传输完成还是错误终止。如果收到了新的流式数据,把结果进行拼接,同时更新结果状态。

当服务端收到用户的输入之后,便开始生成结果。如果没有被打断,则在生成完成以后,把这一轮对话存进数据库。如果用户打断了对话,则由前端告诉服务端用户在什么时刻停止了对话,把已经渲染的内容回传给服务端,用于保存为历史对话。这样的设计是在用户哪怕断网或者切换 APP 以后依旧可以由服务端完成对话的落库。

Review

这次回答已经抓住了几个关键方向,说明你并不是停留在“能把流式文本显示出来”的层面。

优点:

  • 能描述从用户发送问题到模型流式返回的基本交互流程
  • 知道需要维护流式结果及其状态
  • 注意到了“停止生成”是一个独立事件,不只是 UI 上停一下
  • 提到了后续问题排队,说明有会话顺序和并发控制意识
  • 已经开始关注“生成结束后要可靠落库”这个工程问题

还需要补强的点:

  • 状态粒度还不够细,至少要拆开消息状态、请求状态、持久化状态
  • 没有说明协议选择依据,SSE、fetch stream、WebSocket 的取舍需要讲清楚
  • “停止生成后由前端回传已渲染内容给服务端保存”这个设计不够稳,服务端更适合做最终事实源
  • 没有明确关键 id,如 conversationId、messageId、requestId,后续恢复和幂等落库会依赖这些标识
  • “页面刷新可恢复”需要区分恢复已完成历史和恢复进行中的生成,这两类方案复杂度不同
  • “生成完成”和“已落盘成功”是两个状态,不能混在一起

Improved Answer

我会把这套能力当成一个 带状态机的对话系统 来设计,而不是一个简单的“发请求 + 拼字符串”功能。

先说整体交互流程。

用户发送消息后,前端先立即写入一条 user message,同时创建一条空的 assistant message 作为占位,并为这次生成分配 requestId。整个会话用 conversationId 标识,每条消息有自己的 messageId。随后前端发起流式请求,把本轮上下文和这些标识传给服务端。前端边接收流式 chunk,边把内容 append 到当前 assistant message 上,UI 持续增量渲染。

在协议选择上,如果这是典型的 服务端单向推送模型输出,我会优先考虑 SSEfetch + ReadableStream。原因是大模型文本生成天然是单向流输出,这两种方式更贴合 HTTP 语义,实现也更轻。WebSocket 更适合双向高频实时交互,例如协作编辑、语音、多人在线状态同步;如果只是做文本流式回答,WebSocket 往往会显得更重。

前端状态我会至少拆成三层:

  • 消息状态streaming / completed / stopped / failed
  • 请求状态idle / requesting / aborting / reconnecting
  • 持久化状态not_started / persisting / persisted / persist_failed

这样可以避免“回答已经显示完了,但数据库还没写成功”这种状态混淆。

“停止生成”是最容易翻车的地方之一。我会这样处理:

  1. 前端点击停止时,先通过 AbortController 立即中止本地流读取,让 UI 立刻停下来
  2. 同时向服务端发送一个 abort(requestId) 事件或接口调用
  3. 服务端停止模型生成,并以 服务端已经接收到的累计内容 作为最终保存依据
  4. 服务端返回这条 assistant message 的最终状态,例如 stopped,以及最终持久化结果

这里我会明确:服务端是最终事实源,前端不是。因为前端可能丢 chunk、页面可能刷新、网络可能中断,如果让前端把“我看到的内容”回传后决定最终入库,容易造成用户看到的内容和数据库保存内容不一致。

关于“落盘一致性”,我会要求服务端基于 requestIdmessageId 做幂等写入。也就是说,同一轮生成不管由于重试、断线重连还是前端重复提交触发了多少次保存请求,最终都只更新同一条 assistant message,而不是新插入多条记录。这样可以避免页面刷新后出现重复消息。

页面刷新和网络异常的恢复我会分两种情况处理:

第一种,已完成的历史消息恢复。页面重新加载时,前端先根据 conversationId 拉取服务端历史消息,直接恢复完整会话。

第二种,进行中的生成恢复。如果服务端记录这条消息仍处于 streamingaborting 中间态,前端需要根据能力选择:

  • 如果后端支持按 requestId 重新订阅流,则继续接续展示
  • 如果不支持续流,至少把这条消息标记为“上次生成中断,可重新生成”,避免用户看到一个无状态的半截回答

最后,我认为这题里最容易翻车的地方有三个:

  • 停止生成时前后端看到的内容不一致
  • 生成完成了,但持久化失败,UI 却误以为这轮已经彻底结束
  • 页面刷新、断网或重试后重复插入 assistant 消息,导致会话历史错乱

所以这道题的关键不是“把字流出来”,而是 设计一套前后端状态一致、控制权明确、异常可恢复的流式对话系统