前端实现 AI 对话流式输出:SSE 是最容易落地的方案

AI 对话页面里,回答通常不是等全部生成完再一次性显示,而是一段一段地输出。

用户看到文字不断出现,会觉得响应更快,也能更早判断答案是否有用。

这类效果通常叫流式输出。

流式输出解决的核心是用户等待时的心理感受,逐字效果好不好看反而是次要的。

模型完整生成可能要十几秒,但第一段内容如果 1 秒内出来,用户就会觉得系统在工作。他可以边看边判断方向对不对,必要时提前停止。这种体验比最后一次性吐出一大段更自然。

我们最早那版没做流式,产品同学反馈最多的一句话就是“点了之后像卡死了”。其实接口一直在跑,只是前端在 loading 转圈,用户感知不到任何进展。后来上了流式,投诉直接少了一大半,连接口本身的耗时都没变——变的只是“等待被看见了”。

还有一个容易被忽略的指标:首 token 时间(TTFT,time to first token)。后端那边很多优化(比如预热、prompt 裁剪、缓存命中)最终都是为了把这个数压下去。前端做流式,就是要把这个第一段内容尽快、尽稳地呈现出来。我会专门埋一个点,记录从发起请求到收到第一个 delta 的间隔,这个数比总耗时更能反映用户的真实感受。

为什么不用普通请求

普通接口一般是这样:

  1. 前端发起请求
  2. 服务端处理完成
  3. 一次性返回完整结果

如果模型生成需要 20 秒,用户就要等 20 秒才看到内容。

流式输出则是服务端生成一点就推一点,前端边接收边渲染。

对 AI 对话来说,流式输出还有一个额外好处:可以更早暴露错误方向。

如果模型一开始就答偏了,用户可以立刻取消,而不是等完整答案生成完才发现浪费了一次调用。对成本敏感的应用,这一点也很实际。

SSE 的基本思路

SSE 全称是 Server-Sent Events。

它适合服务端向浏览器持续推送文本数据。

前端可以这样接收:

1const eventSource = new EventSource("/api/chat-stream");
2
3eventSource.onmessage = (event) => {
4  appendMessage(event.data);
5};
6
7eventSource.onerror = () => {
8  eventSource.close();
9};

服务端每推一段内容,浏览器就触发一次 message 事件。

EventSource 用起来确实省心,浏览器帮你把协议解析、自动重连、Last-Event-ID 续传都做了。它默认带一套断线重连机制:连接断开后会自动重试,重试间隔还能由服务端通过 retry: 字段控制。配合每条消息的 id: 字段,重连时浏览器会自动把上一次的 id 放进 Last-Event-ID 请求头,服务端就能从断点继续推。这套机制对“长时间订阅型”的场景很香。

但它有几个硬伤,是我后来弃用它的原因:

  • 只能 GET,没法带请求体。AI 对话的 prompt、历史消息、模型参数动辄几 KB,全塞进 query string 既不优雅也有长度上限。
  • 不能自定义请求头。很多鉴权方案要在 header 里放 Authorization: Bearer xxxEventSource 给不了,只能退而求其次用 cookie 或 query 传 token,安全性打折。
  • 连接数受限。HTTP/1.1 下浏览器对同一域名的并发连接有上限(通常 6 个),SSE 长连接会占着不放,开几个标签页就容易把额度吃光。HTTP/2 多路复用能缓解,但得保证整条链路(包括 Nginx)都走 HTTP/2。

所以 EventSource 适合那种“服务端单向推、参数简单、需要自动重连”的场景,比如通知中心、实时日志。真到了 AI 对话这种要 POST 大请求体、要自定义鉴权的场合,我基本都会换成 fetch 读流。

服务端返回的数据一般长这样:

1event: delta
2data: {"text":"这是一段增量内容"}
3
4event: done
5data: {}

前端最好不要默认所有消息都是正文。真实项目里通常会有多种事件:

1delta       文本增量
2tool_start  工具调用开始
3tool_end    工具调用结束
4error       服务端错误
5done        生成完成

把事件类型设计清楚,后面页面才能展示“正在检索资料”“正在调用工具”“生成完成”这些状态,而不是只有一段不断增长的字符串。

这里多说一句协议本身的坑。SSE 是个有严格格式的文本协议,行与行之间用 \n 分隔,事件与事件之间用空行(也就是 \n\n)分隔。data: 后面那个空格是规范里写的可选前缀,解析时要记得 trim 掉,不然 JSON.parse 会因为多一个空格而出错——我就被这个坑过,调了半天发现是冒号后面那个空格没去掉。另外一条 data 可以跨多行,每行都以 data: 开头,浏览器会用 \n 把它们拼起来,自己手写解析器时这点别漏。

还有个隐蔽的兼容性问题:如果接口前面挂了 Nginx,默认会对响应做缓冲,结果就是后端明明在一段段推,前端却要等缓冲区满了或者请求结束才一次性收到。表现就是“流式失效,又变回一次性返回”。解决办法是给这个接口加 proxy_buffering off;,或者后端在响应头里带上 X-Accel-Buffering: no。响应头里 Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive 这几样也得齐,少一个都可能让链路上的某个环节把流给“攒”起来。

什么时候用 fetch 读取流

如果需要 POST 请求、携带复杂请求体,EventSource 就不太方便。

这时可以用 fetchReadableStream

1const response = await fetch("/api/chat", {
2  method: "POST",
3  body: JSON.stringify({ prompt }),
4});
5
6const reader = response.body.getReader();
7const decoder = new TextDecoder();
8
9while (true) {
10  const { value, done } = await reader.read();
11  if (done) break;
12
13  appendMessage(decoder.decode(value, { stream: true }));
14}

这种方式更灵活,但代码也更复杂。

这里还有一个细节:流式数据切块不等于文本边界。

一次 reader.read() 拿到的内容,可能刚好截在 JSON 中间,也可能把多条消息粘在一起。简单 appendMessage(decoder.decode(value)) 对纯文本还行,对事件协议或 JSON 就要小心。

我一般会保留一个 buffer:

1let buffer = "";
2
3while (true) {
4  const { value, done } = await reader.read();
5  if (done) break;
6
7  buffer += decoder.decode(value, { stream: true });
8
9  const chunks = buffer.split("\n\n");
10  buffer = chunks.pop() || "";
11
12  for (const chunk of chunks) {
13    handleStreamEvent(chunk);
14  }
15}
16
17// 循环结束后别急着收工,还有两件事没做完
18buffer += decoder.decode(); // flush:多字节字符可能卡在解码器内部缓冲区里,不传参数调一次才能吐出来
19if (buffer) {
20  handleStreamEvent(buffer); // buffer 里可能还剩最后一段没凑够 \n\n 分隔符的事件
21}

这个处理很朴素,但能避免半包数据导致解析失败。这里有两处收尾很容易漏掉:一是 TextDecoder{ stream: true } 模式下,如果某个多字节字符(比如中文)刚好被切在两个 chunk 之间,解码器会先把不完整的字节存在自己内部,等下一次 decode 调用时再拼上;循环跑完之后必须再调一次不带参数的 decoder.decode(),把这部分残留的字节强制 flush 出来,不然结尾那个字可能直接丢字或乱码。二是 buffer 变量本身:只要最后一段数据没有以 \n\n 结尾,它就永远不会被 split 切出来交给 handleStreamEvent,一直躺在 buffer 里没人处理。这两步都得在 while 循环之外补,缺一个都会在流结束的那一刻丢内容——而且概率不低,因为很多后端最后一个 done 事件后面根本不会再补一个空 chunk 来触发 flush。

实际项目要处理的细节

流式输出不是把文字追加到页面这么简单。

还要考虑:

  • 用户取消生成
  • 请求失败和重试
  • Markdown 分段渲染
  • 代码块未闭合时的展示
  • 滚动到底部
  • 防止重复提交
  • 长文本性能

如果这些细节不处理,体验会很粗糙。

取消生成要成为一等功能

AI 对话里,取消不是可有可无。

用户发现问题问错了、模型方向偏了、网络太慢,都应该能停止生成。前端用 fetch 读取流时,可以用 AbortController

1const controller = new AbortController();
2
3const response = await fetch("/api/chat", {
4  method: "POST",
5  body: JSON.stringify({ prompt }),
6  signal: controller.signal,
7});
8
9function stopGenerating() {
10  controller.abort();
11}

但只取消前端还不够。服务端也要能感知连接断开,停止继续请求模型或工具。否则用户界面停了,后端还在继续烧 token。

流中途断了怎么办

流式请求比普通请求更脆弱,因为它是一条要活好几秒甚至几十秒的长连接。中途网络抖一下、代理超时、服务端进程重启,都可能让 reader.read() 直接抛错,或者读到一半再也没有新数据。普通请求失败了重来一次就行,流式请求已经吐出去半段文字,重来会让用户看到内容突然从头再来一遍,体验很割裂。

我的处理分两层。第一层是把 reader.read() 包在 try/catch 里,区分“正常结束”和“异常中断”:

1try {
2  while (true) {
3    const { value, done } = await reader.read();
4    if (done) break;
5    buffer += decoder.decode(value, { stream: true });
6    // ...分包处理
7  }
8} catch (error) {
9  if (error.name === "AbortError") {
10    // 用户主动取消,不算错误,什么都不用做
11  } else {
12    // 真的断了,把已收到的内容标记成“未完成”,给个重试入口
13    markStreamInterrupted();
14  }
15}

要特别把 AbortError 单拎出来,因为用户点“停止生成”触发的 abort() 也会让 read() 抛错,但那是预期内的,不该弹一个“出错了”的红条吓用户。我一开始没区分,结果每次用户手动停止都被当成异常上报,把监控里的错误率搞得很难看。

第二层是尽量别让用户白等。如果后端支持从某个位置续传(比如按 message id 或已生成的 token 数),断线重连时可以带上“我已经收到这么多了”,让服务端接着往下推,而不是从头重来。这套和 EventSourceLast-Event-ID 是一个思路,只是换成 fetch 后得自己在请求体里维护这个游标。不支持续传的后端,退而求其次的做法是把已经吐出来的半段保留在界面上、置灰,旁边给一个“继续”按钮,让用户自己决定要不要重发——至少别让他盯着一段戛然而止的文字发懵。

还有一个容易被忽略的失败场景:服务端返回的根本不是流,而是一个 JSON 错误。比如鉴权过期、限流命中,网关会直接返回一个 application/json 的错误体,Content-Type 压根不是 text/event-stream。这时候如果你上来就 getReader() 按流解析,会解析出一堆乱七八糟的东西。稳妥的做法是先看响应状态和 Content-Type,确认是流才进读取循环:

1if (!response.ok || !response.headers.get("content-type")?.includes("text/event-stream")) {
2  const errorBody = await response.json().catch(() => ({}));
3  throw new Error(errorBody.message || `请求失败:${response.status}`);
4}

渲染不要每个 token 都重排

流式输出最容易写成“来一个字就 setState 一次”。

短回答问题不大,长回答、Markdown、代码块一多,页面就可能卡。更稳的做法是做一点缓冲,比如每几十毫秒合并一次更新,或者把原始增量先存到 ref,再节流刷新 UI。

1let pendingText = "";
2let scheduled = false;
3
4function appendDelta(text) {
5  pendingText += text;
6
7  if (scheduled) return;
8  scheduled = true;
9
10  requestAnimationFrame(() => {
11    setAnswer((prev) => prev + pendingText);
12    pendingText = "";
13    scheduled = false;
14  });
15}

用户不需要每个 token 都立刻触发一次 React 渲染。他需要的是内容持续出现、滚动稳定、页面不抖。

Markdown 和代码块要考虑未完成状态

流式 Markdown 有个很烦的小问题:内容还没生成完时,语法可能是半截的。

比如代码块开了三个反引号,但还没闭合;表格只生成了表头,正文还没来;链接生成了一半。这时候如果每次都完整解析 Markdown,展示可能闪烁。

我一般会把“生成中”和“已完成”分开处理。生成中可以用更宽松的渲染,完成后再做一次最终 Markdown 解析。代码块未闭合时,也可以临时补一个闭合标记用于展示,但不要把这个补丁写回真实内容。

AI 对话流式输出关键在于:服务端分段返回,前端边收边显示。

简单推送可以用 SSE,需要更灵活的请求体时可以用 fetch 读取流。真正上线时,还要认真处理取消、错误、滚动和 Markdown 渲染。