为什么 AI 应用必须 Streaming
传统 Web 接口大多遵循一个简单模式:前端发请求,后端处理,返回完整 JSON,前端一次性渲染结果。
AI 应用不能总是这样做。模型生成一个长回答可能需要几秒、几十秒,甚至更久。如果前端一直等到完整结果返回,用户看到的就是一个长时间不动的加载状态。
Streaming 的价值不只是“看起来更快”。它改变了 AI 应用的交互模型:用户能看到生成正在发生,能在中途停止,能更早判断方向是否正确,也能在失败时保留已经产生的内容。
非 Streaming 的问题
非 Streaming 接口通常长这样:
这段代码非常容易理解,但一旦回答变长,就会暴露几个问题。
首先,用户等待时间不可见。请求可能正在排队、模型可能正在生成、网络可能卡住,UI 都只能显示一个 loading。
其次,用户不能及时停止。等完整结果返回再发现方向不对,Token 已经消耗了,时间也浪费了。
再次,失败体验很差。如果 20 秒后连接断开,前端可能什么内容都拿不到。模型其实已经生成了很多,但用户看到的是“失败,请重试”。
最后,产品很难建立信任。AI 应用的回答不是普通接口数据,用户需要边看边判断它是否理解了问题。长时间空白会让用户怀疑系统卡死。
Streaming 解决的是反馈回路
Streaming 指服务端把生成结果拆成多个片段,边生成边传给前端。前端收到一个片段,就把它追加到 assistant message 上。
用户不再面对一个黑箱等待,而是看到回答逐步出现。这会显著改变体验:
- 首 token 到达后,用户知道系统已经开始工作。
- 方向不对时,用户可以立刻停止。
- 长回答更容易阅读,因为内容逐步出现。
- 即使中途失败,也可能保留已有内容。
- 前端能根据事件展示工具调用、引用和阶段进度。
Streaming 的核心不是“更炫的打字机效果”,而是缩短用户和模型之间的反馈回路。
首 Token 时间比总耗时更影响体感
AI 应用有两个常见指标:
- Time To First Token:从发起请求到收到第一个输出片段。
- Time To Last Token:从发起请求到完整输出结束。
非 Streaming 只暴露总耗时。Streaming 让用户在首 token 到达后就开始阅读。
假设一个回答总共需要 18 秒。如果第 2 秒出现第一段内容,用户的感受是“系统在工作,我可以先看”。如果 18 秒后一次性出现完整内容,用户的感受更接近“页面卡了很久”。
对前端来说,这意味着 loading 设计也要变。AI Chat 不应该只有一个全局 spinner,而应该表达更细的阶段:
- 正在提交用户消息。
- 正在等待模型开始输出。
- 正在接收回答。
- 正在调用工具。
- 正在收尾保存。
Streaming 支持中途停止
AI 生成是有成本的。回答越长,输出 Token 越多,用户等待越久。
Streaming 让“停止生成”变成一个真实能力。用户看到模型误解了问题,可以立即点击停止。前端通过 AbortController 取消请求,后端也应该中断上游模型调用或尽快停止向下游写入。
停止不是异常,而是 AI Chat 的一等交互。UI 应该把当前 assistant message 标记为 cancelled 或 partial,并提供继续、重试或复制已有内容的入口。
Streaming 让错误恢复更可控
没有 Streaming 时,请求失败往往意味着没有结果。即使模型已经生成了内容,只要完整响应没有正常返回,前端也很难拿到。
有 Streaming 时,前端可以在每个 delta 到达时更新本地状态。中途失败时,用户至少能看到已生成部分。
工程上可以把一次流式响应拆成事件:
如果失败发生在 delta 之后,前端可以保留 message content,并把状态改成 failed 或 partial。用户重试时,不需要丢掉所有上下文。
SSE、ReadableStream 和 WebSocket 怎么选
AI Chat 常见的 Streaming 方案有三类。
SSE 适合服务端向浏览器单向推送事件。它的语义简单,天然支持事件名和 data 字段,非常适合模型 delta、done、error 这类事件。
ReadableStream 是浏览器读取 fetch 响应体的底层能力。即使后端不是标准 SSE,只要返回一个流式响应,前端也可以通过 response.body.getReader() 逐块读取。
WebSocket 是双向长连接,适合多人协作、实时游戏、复杂双向控制。如果只是用户发起一次请求、服务端持续返回模型输出,WebSocket 往往不是第一选择。
可以粗略这样判断:
多数 AI Chat 前端可以从 fetch + ReadableStream 或 SSE 事件格式开始,不必过早引入 WebSocket。
前端需要解析的是事件,不只是字符串
很多 Demo 会让服务端直接输出文本片段,前端收到后拼接:
这适合最小演示,但真实应用最好传结构化事件。因为前端除了文本,还需要知道:
- 本次 run id。
- assistant message id。
- delta 文本。
- done 信号。
- 错误类型。
- 工具调用状态。
- 引用来源。
- token 用量。
- finish reason。
一个事件协议可以像这样:
这种协议会让前端状态管理更稳定,也更方便日志追踪。
Streaming 改变后端边界
前端要实现 Streaming,后端也必须配合。
一个流式 Chat API 不能像普通接口那样等模型结束后再 return json。它需要尽早写响应头,然后把上游模型事件转发给前端。
后端要负责:
- 设置正确的响应头。
- 处理客户端断开连接。
- 转发模型 delta。
- 把错误包装成前端可理解的事件。
- 在完成或取消时保存最终状态。
- 避免代理、网关或部署平台缓冲响应。
前端工程师不一定负责后端实现,但必须理解这条链路。否则你很难判断“为什么本地能流式输出,上线后却一次性返回”。
常见坑
坑一:把 Streaming 当成打字机动画。
打字机动画可以把完整文本逐字显示,但它不缩短等待时间,也不能中途停止模型。真正的 Streaming 是服务端边生成边传输。
坑二:只处理文本,不处理 done。
没有明确的 done 事件,前端不知道什么时候关闭生成状态、启用输入框、显示复制按钮或保存最终内容。
坑三:没有处理 UTF-8 分包。
网络 chunk 不等于完整字符,也不等于完整事件。前端读取二进制流时要用 TextDecoder 的 streaming 模式,避免中文或 emoji 被截断。
坑四:代理层缓冲响应。
有些服务、网关或中间件会缓存响应,导致后端虽然在写流,浏览器却等到最后才收到。上线前一定要在真实部署环境验证。
坑五:频繁 setState 导致渲染抖动。
模型输出可能非常密集。每个小 delta 都触发全量消息列表重渲染,会让页面卡顿。前端需要合并更新或限制渲染频率。
坑六:忽略取消后的落库状态。
用户停止生成后,数据库里应该记录这次 run 已取消,以及 assistant message 是否保留部分内容。否则刷新页面后状态会对不上。
工程设计建议
第一,优先设计事件协议。
不要让前后端只约定“返回一段流式文本”。至少要有 start、delta、done、error,后续再扩展 usage、tool_call 和 citation。
第二,把首 token 作为体验指标。
优化 AI Chat 时,不只看完整回答耗时。首 token 时间、delta 间隔和停止响应速度都应该被观察。
第三,前端要把流读取和 UI 渲染解耦。
流读取负责解析事件,状态层负责合并消息,组件层负责渲染。不要把 reader.read()、Markdown 渲染和按钮状态全写在一个组件里。
第四,把取消看成正常路径。
Abort、cancelled event、partial message、重试入口,都应该在设计时出现,而不是等用户抱怨“停不下来”再补。
第五,在部署环境测试 Streaming。
本地开发服务器、生产网关、边缘函数、Node 服务、反向代理的缓冲行为可能不同。只在本地验证是不够的。
小结
AI 应用必须 Streaming,不是因为逐字出现更酷,而是因为模型生成天然耗时、可中断、可能失败,并且用户需要尽早建立判断。
Streaming 让 AI Chat 从“等待一个完整响应”变成“管理一个正在发生的生成过程”。前端工程师要理解 SSE、ReadableStream、事件协议、取消、错误恢复和渲染节流,才能做出真正稳定的 AI 交互体验。