为什么 AI 应用必须 Streaming

传统 Web 接口大多遵循一个简单模式:前端发请求,后端处理,返回完整 JSON,前端一次性渲染结果。

AI 应用不能总是这样做。模型生成一个长回答可能需要几秒、几十秒,甚至更久。如果前端一直等到完整结果返回,用户看到的就是一个长时间不动的加载状态。

Streaming 的价值不只是“看起来更快”。它改变了 AI 应用的交互模型:用户能看到生成正在发生,能在中途停止,能更早判断方向是否正确,也能在失败时保留已经产生的内容。

非 Streaming 的问题

非 Streaming 接口通常长这样:

const res = await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ messages }),
});

const data = await res.json();
setAssistantMessage(data.content);

这段代码非常容易理解,但一旦回答变长,就会暴露几个问题。

首先,用户等待时间不可见。请求可能正在排队、模型可能正在生成、网络可能卡住,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 取消请求,后端也应该中断上游模型调用或尽快停止向下游写入。

const controller = new AbortController();

await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify(payload),
  signal: controller.signal,
});

controller.abort();

停止不是异常,而是 AI Chat 的一等交互。UI 应该把当前 assistant message 标记为 cancelled 或 partial,并提供继续、重试或复制已有内容的入口。

Streaming 让错误恢复更可控

没有 Streaming 时,请求失败往往意味着没有结果。即使模型已经生成了内容,只要完整响应没有正常返回,前端也很难拿到。

有 Streaming 时,前端可以在每个 delta 到达时更新本地状态。中途失败时,用户至少能看到已生成部分。

工程上可以把一次流式响应拆成事件:

event: start
data: {"runId":"run_1","messageId":"msg_2"}

event: delta
data: {"text":"Streaming 的价值"}

event: delta
data: {"text":"不只是速度。"}

event: done
data: {"finishReason":"stop"}

如果失败发生在 delta 之后,前端可以保留 message content,并把状态改成 failed 或 partial。用户重试时,不需要丢掉所有上下文。

SSE、ReadableStream 和 WebSocket 怎么选

AI Chat 常见的 Streaming 方案有三类。

SSE 适合服务端向浏览器单向推送事件。它的语义简单,天然支持事件名和 data 字段,非常适合模型 delta、done、error 这类事件。

ReadableStream 是浏览器读取 fetch 响应体的底层能力。即使后端不是标准 SSE,只要返回一个流式响应,前端也可以通过 response.body.getReader() 逐块读取。

WebSocket 是双向长连接,适合多人协作、实时游戏、复杂双向控制。如果只是用户发起一次请求、服务端持续返回模型输出,WebSocket 往往不是第一选择。

可以粗略这样判断:

方案适合场景注意点
SSEChat delta、任务进度、单向事件流浏览器 EventSource 默认只支持 GET,POST 场景常用 fetch 读取 SSE 格式
ReadableStream自定义流协议、fetch POST streaming需要自己处理解码、分包和解析
WebSocket双向实时协作、多客户端同步连接管理和鉴权复杂度更高

多数 AI Chat 前端可以从 fetch + ReadableStream 或 SSE 事件格式开始,不必过早引入 WebSocket。

前端需要解析的是事件,不只是字符串

很多 Demo 会让服务端直接输出文本片段,前端收到后拼接:

assistant.content += chunkText;

这适合最小演示,但真实应用最好传结构化事件。因为前端除了文本,还需要知道:

  • 本次 run id。
  • assistant message id。
  • delta 文本。
  • done 信号。
  • 错误类型。
  • 工具调用状态。
  • 引用来源。
  • token 用量。
  • finish reason。

一个事件协议可以像这样:

type StreamEvent =
  | { type: 'start'; runId: string; messageId: string }
  | { type: 'delta'; messageId: string; text: string }
  | { type: 'tool_call'; name: string; args: unknown }
  | { type: 'usage'; inputTokens: number; outputTokens: number }
  | { type: 'done'; finishReason: 'stop' | 'length' | 'cancelled' }
  | { type: 'error'; message: string; retryable: boolean };

这种协议会让前端状态管理更稳定,也更方便日志追踪。

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 交互体验。