流式输出:AI 原生应用的默认交互范式

一句话结论:流式输出不是 UX 上的锦上添花——它是 LLM 应用的身份基石。但真正的难点从来不是”把 chunk 拼起来显示”,而是分布式状态一致性。搞不定这件事,你的停止按钮、刷新恢复、聊天持久化全是 bug。


本质:LLM 天生就该流式

LLM 不是一次性产出整段文字。它 autoregressive 地工作:给定一串 token,预测下一个最可能的 token,追加到序列尾部,循环往复。

换句话说,模型本身就在增量地”吐”内容。那你把内容增量地交给用户,就是最自然的选择。

反过来,让用户盯着白屏等 5 秒、10 秒甚至 30 秒,等完整答复再一口气显示——这等于把”慢”暴露给用户,没有任何缓冲。用户会觉得应用坏了。

流式输出把体验翻转了:文字立刻开始出现,感知延迟大幅降低。更关键的是,它把”黑盒查询”变成了”实时协作”——用户可以边看边判断方向对不对,发现模型跑偏就立刻打断,省下浪费的 token。

这种可中断、可纠偏的交互模式,正是 AI 原生应用和传统 Web 应用的分水岭。


核心难点:三层状态机

聊天 UI 看起来简单——接个流,往 div 里塞文本就行。生产环境远不是这么回事。

首先你得给每个实体分配稳定 ID:conversationId(会话)、messageId(每条消息)、requestId(每次生成尝试)。用户发消息时,前端立刻插入一条 user message,再创建一个空的 assistant message 占位,然后打开流式连接往里填 chunk。

真正容易踩的坑在这里:把”屏幕上的字”和”数据库里的数据”当成一回事。

正确的做法是把状态拆成三层,各管各的:

状态 管什么
消息状态 streaming / completed / stopped / failed 用户眼前看到什么
请求状态 idle / requesting / aborting / reconnecting 网络正在干什么
持久化状态 not_started / persisting / persisted / persist_failed 后端已落盘什么

这三层各自独立变化。一条消息可能流式输出已完成(completed),但持久化还在进行(persisting)。页面刷新可能同时打断消息渲染和网络请求,但数据库里已经落盘了一半。

分开管理,就不会出现那个经典 bug:”屏幕上答案好了,一刷新数据库里啥也没有。”


协议选择:别用 WebSocket 干单向的活

对于单向文本流式传输——服务端把模型输出推给客户端——SSE(Server-Sent Events)fetch + ReadableStream 几乎总是正确答案。

它们基于标准 HTTP 语义,简单直接,与 LLM 的生成模型天然映射。

WebSocket 是为双向、高频、实时场景设计的——协同编辑、语音流、多人状态同步。拿它做单向文本流,纯属自找复杂度,没有收益。


停止、持久化、恢复:三个最难做对的交互

停止

用户点了停止,前端用 AbortController 立刻中断本地流消费——UI 瞬间冻结。然后发 abort(requestId) 给服务端。服务端停止推理,并把已累积的所有内容作为最终答案持久化

这里的关键原则:服务端是事实的唯一来源,永远不是客户端。

客户端可能丢了 chunk,页面可能在流中刷新,网络可能断过。如果让客户端去保存”我在屏幕上看到的”,必然产生显示和数据库之间的偏移。让服务端说了算。

持久化

必须幂等。用 messageIdrequestId 作为写入键——重试、重连、重复提交,永远更新同一条记录,绝不复写。

恢复

两条路径。已完成的历史对话,页面加载时用 conversationId 拉回来——很简单。

真正麻烦的是中断的进行中生成。服务端可能还持有 streamingaborting 的中间态。前端应该用 requestId 重新订阅流,或者展示部分消息附带”生成已中断——点击重新生成”的入口。

绝对不要让用户对着一个无声的半截答案,束手无策。


一句话总结

流式输出的文本流本身是简单的。难的是:定义清晰的身份边界(conversationId / messageId / requestId),把显示状态和持久化状态彻底分开,把服务端视为唯一真相来源,以及把停止、刷新、重连这些交互当成一等状态转换来设计——而不是事后打补丁。

这本质上是一道分布式系统题,不是前端题。