前端实现 AI 对话流式输出:SSE 是最容易落地的方案
AI 对话页面里,回答通常不是等全部生成完再一次性显示,而是一段一段地输出。
用户看到文字不断出现,会觉得响应更快,也能更早判断答案是否有用。
这类效果通常叫流式输出。
流式输出解决的核心是用户等待时的心理感受,逐字效果好不好看反而是次要的。
模型完整生成可能要十几秒,但第一段内容如果 1 秒内出来,用户就会觉得系统在工作。他可以边看边判断方向对不对,必要时提前停止。这种体验比最后一次性吐出一大段更自然。
我们最早那版没做流式,产品同学反馈最多的一句话就是“点了之后像卡死了”。其实接口一直在跑,只是前端在 loading 转圈,用户感知不到任何进展。后来上了流式,投诉直接少了一大半,连接口本身的耗时都没变——变的只是“等待被看见了”。
还有一个容易被忽略的指标:首 token 时间(TTFT,time to first token)。后端那边很多优化(比如预热、prompt 裁剪、缓存命中)最终都是为了把这个数压下去。前端做流式,就是要把这个第一段内容尽快、尽稳地呈现出来。我会专门埋一个点,记录从发起请求到收到第一个 delta 的间隔,这个数比总耗时更能反映用户的真实感受。
为什么不用普通请求
普通接口一般是这样:
- 前端发起请求
- 服务端处理完成
- 一次性返回完整结果
如果模型生成需要 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 xxx,EventSource给不了,只能退而求其次用 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-stream、Cache-Control: no-cache、Connection: keep-alive 这几样也得齐,少一个都可能让链路上的某个环节把流给“攒”起来。
什么时候用 fetch 读取流
如果需要 POST 请求、携带复杂请求体,EventSource 就不太方便。
这时可以用 fetch 加 ReadableStream:
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 数),断线重连时可以带上“我已经收到这么多了”,让服务端接着往下推,而不是从头重来。这套和 EventSource 的 Last-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 渲染。