流式输出:AI 原生应用的默认交互范式
流式输出: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,页面可能在流中刷新,网络可能断过。如果让客户端去保存”我在屏幕上看到的”,必然产生显示和数据库之间的偏移。让服务端说了算。
持久化
必须幂等。用 messageId 或 requestId 作为写入键——重试、重连、重复提交,永远更新同一条记录,绝不复写。
恢复
两条路径。已完成的历史对话,页面加载时用 conversationId 拉回来——很简单。
真正麻烦的是中断的进行中生成。服务端可能还持有 streaming 或 aborting 的中间态。前端应该用 requestId 重新订阅流,或者展示部分消息附带”生成已中断——点击重新生成”的入口。
绝对不要让用户对着一个无声的半截答案,束手无策。
一句话总结
流式输出的文本流本身是简单的。难的是:定义清晰的身份边界(conversationId / messageId / requestId),把显示状态和持久化状态彻底分开,把服务端视为唯一真相来源,以及把停止、刷新、重连这些交互当成一等状态转换来设计——而不是事后打补丁。
这本质上是一道分布式系统题,不是前端题。