流式输出这东西,说实话,我第一次意识到它有多重要,是在一次糟糕的等待体验里。那时候还不流行大模型,但已经在做类似机器翻译的接口。用户点一下按钮,要等三到五秒才看到完整结果。当时有人在页面上加了一个转圈动画,但我心里很清楚:用户不是没耐心,而是“看不到进度”会让等待变得极其煎熬。后来换成流式输出,边生成边显示,用户等待体感直接砍半,甚至有人反馈“好像变快了很多”——其实总耗时一点没变,但体验就是完全两回事。从那时起,我就觉得流式输出不是锦上添花的功能,而是现代应用里决定用户体验档次的关键环节。这篇博文,我想完整聊聊流式输出的原理、实操实现、以及我踩过的那些坑,帮同样在做这类功能的人少走点弯路。
1. 流式输出解决的核心问题与适用场景
1.1 什么时候必须考虑流式输出
流式输出,专业点说叫 Streaming,本质是把一次完整的网络响应拆成多个小块,让服务器先推送一部分数据过来,客户端立即处理渲染,而不是等全部数据组装完毕才一次性返回。
最常见的场景就是大模型对话。比如我本地部署过的开源模型,生成一句几百字的回复,如果需要十秒,那么你盯着空白对话框等十秒,和看到第一个字在0.5秒就冒出来、然后像打字一样逐渐生成完整内容,体验差距是天壤之别。前者让人怀疑系统挂了,后者让人感觉“AI在思考后开始回答”,反而增加了透明度和信任感。
再从更广的视角看,适合流式输出的场景至少有三类:
- LLM 对话与文本生成:ChatGPT 这类应用的基本盘,逐字逐句渲染生成内容。
- 长耗时任务的任务进度反馈:比如文件转码、视频处理、批量数据分析,服务器处理需要几十秒到几分钟,持续向客户端推送进度百分比和日志。
- 实时日志与监控面板:持续输出运行日志、指标变化,类似命令行 tail -f 的效果,适合运维和信息流类应用。
这里有个容易被忽略的细节:流式输出和普通长连接不是一回事。普通 HTTP 请求会等服务器完全处理完才返回响应,而流式输出的关键是服务端可以在计算还没有结束时就“边算边发”。这背后依赖的传输机制,我下面细说。
1.2 等完整响应 vs 流式输出的用户体验差异
我做过一个很直观的测试。把同一个大模型接口改成流式和非流式两种方式,模拟用户点击发送按钮到完整结果渲染出来的整个过程,统计用户感知到的“响应时间”。
非流式模式下,用户看到的是:点击发送 → 转圈等待 → 十秒后整段文本突然出现。流式模式下:点击发送 → 0.5秒后第一个字出现 → 持续逐字输出,直到完整内容渲染。
结果很有意思。用户的投诉和重新提问次数,在非流式模式下明显更高。因为在等待期里,用户会反复点击按钮、刷新页面、修改提问,甚至误判“系统崩溃了”。而流式模式下,用户知道系统“正在工作”,能提前浏览已输出部分,甚至在生成结束前就开始阅读,整体等待感被大幅稀释。
从技术指标来看,流式输出优化的不是“总耗时”,而是“首字节时间”(TTFB,Time To First Byte)。如果一个接口的总耗时是十秒,非流式的 TTFB 就是十秒,流式可以压到 0.5~1 秒。用户对“快”的感知更多来自 TTFT(首 Token 时间),而不是总耗时。这是做用户体验优化时特别关键的一个认知转变。
2. 流式输出的协议选型与底层原理
2.1 传输层的三种方案对比
流式输出不是新概念,但不同落地方式的取舍差异很大。当前主流的方案有三类:HTTP 分块传输、SSE(Server-Sent Events,服务器推送事件)和 WebSocket。
| 方案 | 传输方向 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| HTTP 分块传输(Chunked) | 服务端→客户端单向 | 低 | 文件下载、普通流式内容 |
| SSE | 服务端→客户端单向 | 低 | LLM 流式回复、日志推送、通知流 |
| WebSocket | 双向通信 | 高 | 聊天室、协同编辑、实时游戏 |
HTTP 分块传输是 HTTP/1.1 协议内置机制,服务端不告诉客户端 Content-Length,而是持续发送分块数据,每个分块前都有十六进制长度标识,最后以空块收尾。浏览器自动支持,但普通开发者的 JS 代码不太好直接控制读取节奏,一般靠 fetch 响应体流式读取。
SSE 是基于 HTTP 的一种轻量级推送协议,响应头的 Content-Type 是 text/event-stream,服务端不断发送以data:开头、空行分隔的事件流。它的最大优势是浏览器原生支持(EventSource API),断线还能自动重连,非常省心。大模型流式输出的标准格式其实就是 SSE 的变种。
WebSocket 虽然能实现同样效果,但双向通信意味着更强的实时交互能力和更高维护成本,如果只是服务端单向推送给客户端,用它反而“杀鸡用牛刀”。LLM 场景里,用户提问是普通请求,只是响应需要流式返回,SSE 是最省事且最稳定的方案。
2.2 SSE 协议的格式规范和核心要点
SSE 协议看着简单,但有几个细节如果不注意,会在对接时踩大坑。先看一个标准的 SSE 流长什么样:
data: {"message":"你"} data: {"message":"好"} data: {"message":"!"} data: [DONE]每行以data:开头,后面跟数据内容,事件与事件之间用空行\n\n分隔。可以带id:、event:、retry:等字段,分别用于标识事件序号、自定义事件类型、断线重连的时间间隔。
最关键的点是:事件必须被完整发送,且每个字段要用空行分隔。很多人在自己写 Push 服务时容易漏掉那个空行,浏览器会一直收不到事件。另外,如果一条数据本身包含换行,就要拆成多条data:行,客户端会自动用换行符拼接。我踩过的另一个坑是:SSE 的编码默认是 UTF-8,一旦有中文字符被错误转码,前端解出来就是乱码。
在实际大模型接口中,常见的 SSE 变体还有两种格式:
- 旧版允许一行输出多个 JSON 片段,用换行符分隔,前端需要一次性解析,体验上不够“逐字”。
- 新版标准做法是每个 Token 或一小段文本作为独立事件推送,事件之间以空行隔离,前端逐事件处理。这种方式对打字机效果最友好。
我做流式对接时,优先采用“新版标准事件流”格式,每个事件携带一个增量文本块,前端拿到直接拼接渲染。这个选择和具体模型服务商无关,是通用约定。
2.3 为什么推荐用 SSE 而不是轮询和 WebSocket
很多人在选择流式方案时会犹豫:轮询不行吗?WebSocket 不是更强大吗?我分别解释一下为什么 SSE 在大多数场景下是更优解。
轮询的问题在于浪费资源和时间滞后。客户端每隔几百毫秒请求一次,查“有没有新内容”,相当于大炮打蚊子。如果服务器生成很快,第一毫秒就产出了,你也要等下个轮询周期才能拉到;如果服务器生成很慢,你又反复打空请求,白白消耗带宽和服务连接数。最终结果是:实时性差、资源开销大、还容易触发限流。
WebSocket 的实时性是强,但使用成本明显更高。它需要连接握手,需要维护长连接状态,需要处理心跳保活、断线重连、消息编解码。对于纯“服务端推→客户端收广播”的场景,用 WebSocket 引入复杂度,收益并没有任何提升。而且 WebSocket 的协议栈调试难度比 SSE 高不少,跨网关、跨代理时更容易出问题,比如某些代理对 Upgrade 头处理不当。
SSE 则智能得多。它本质上是一次普通 HTTP 请求,只是响应一直不结束。代理和防火墙对它友好,可以直接走 CDN,自带断线重连和事件 ID 机制(Last-Event-ID 头回传),这些特性让它在 LLM 这类单向流式场景里成了绝对主流。
3. 服务端流式接口的实现与关键参数
3.1 基于 FastAPI 的流式响应示例
后端框架层出不穷,但万变不离其宗。我这里用 FastAPI 演示一个典型的 SSE 流式接口,因为它语法简洁,而且自带 StreamingResponse,省掉了很多手工设置。
为了贴近真实场景,我模拟了一个接入大模型并用流式返回的函数。核心代码如下:
import asyncio import json from fastapi import FastAPI from fastapi.responses import StreamingResponse app = FastAPI() async def mock_llm_stream(prompt: str): # 模拟模型生成过程:这里按批生产文本片段 chunks = [ "你好,", "很高兴", "为你服务。", "流式输出", "本质上就是", "分块传输。", ] for chunk in chunks: await asyncio.sleep(0.3) # 模拟生成耗时 yield f"data: {json.dumps({'message': chunk}, ensure_ascii=False)}\n\n" @app.get("/api/chat") async def chat(message: str): return StreamingResponse( mock_llm_stream(message), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", # 禁用 Nginx 缓冲 } )这里有几个关键点要展开讲。
media_type="text/event-stream"必须写对,写错的话浏览器不识别流式格式,会把它当普通文本处理。Cache-Control: no-cache是防止代理或浏览器缓存整个响应,否则用户可能看不到增量内容。X-Accel-Buffering: no是给 Nginx 看的特殊响应头,让 Nginx 不要缓冲响应体,直接透传流式数据。这个头在直接对接公网部署时尤其重要,很多人流式返回失效,就是被反向代理缓冲给“攒住”了。
另外,不要轻易在生成器里用print输出日志,因为生成器在流式运行中,日志直接打到控制台可以接受,但如果打到日志文件,可能会影响响应流的正常交付——这在某些框架中会导致数据不 flush。调试时最好通过单独的日志接口记录,或者在生成器外单独开协程做记录。
3.2 哪些框架设置容易让流式“失效”
同样的流式生成器,换个部署环境就可能完全没有流式效果。经验上最常出问题的有三处:
第一是缓冲层干预。如果前面有 Nginx、Apache、GateWay 等反向代理,默认开了响应缓冲,它会把服务端吐出来的小块数据攒成一大块再发给客户端。用户看到的就不是逐字渐显,而是等全部生成完一次性出现。解决办法有两个:在服务端响应头上加X-Accel-Buffering: no(适合 Nginx),或者在反向代理配置里关掉当前路由的 proxy_buffering,例如 Nginx 的proxy_buffering off;。
第二是框架缓冲区设置。某些 Web 框架(尤其是 Java 系的 Servlet 或 Spring 的老版本)默认会缓冲输出流,需要手动调用response.flushBuffer()或设置@ResponseBody的写模式,否则即使你写了yield或write,内容也会积压在缓冲区迟迟不发。Python 的 WSGI 服务器(如早期版本的 Gunicorn)也以缓冲著称,生产环境建议直接用 Uvicorn 或其他支持 ASGI 的服务器跑流式接口。
第三是客户端读取方式不对。有些客户端库(比如某些老版 Axios)会把整个响应一次性接收,要配置responseType: 'stream'或使用支持流式读取的底层 fetch API。这个问题我放在前端章节详细讲。
我实际部署时遇到过经典一幕:本地开发一切正常,字是一蹦一蹦出来的;部署到服务器后变成等十秒整个回复“啪”一下出现。一顿排查后确认,是 Nginx 配置里默认开了 proxy_buffering。给接口加了X-Accel-Buffering: no后立即恢复正常。所以这个头我对流式接口的标配。
3.3 大模型服务对接的流式参数注意点
如果直接对接第三方大模型服务(如 OpenAI 兼容接口),一般是通过它的 SDK 或 HTTP 接口开启流式模式。最常见的做法是:
curl -N https://api.llm.example.com/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "some-model", "messages": [{"role": "user", "content": "你好"}], "stream": true }'-N表示禁用 curl 的缓冲,实时打印响应内容。返回结果是一串 SSE 事件流,每个事件里的choices[0].delta.content字段就是增量文本。
这里有一个容易被忽略的关键点:请求参数里要显式设置stream: true,并且要确认模型服务商是否支持流式。很多模型平台默认stream为 false,如果不传,服务器会把整个生成结果攒好,一次性以 JSON 返回,你的前端代码就收不到增量事件。
另外,某些平台还要求传stream_options: {"include_usage": true}来在流式响应的末尾返回 Token 用量统计,方便做计费与成本分析。不是所有服务都支持,但值得确认,能省去后面单独查用量接口的麻烦。对于本地私有化部署的开源模型,如 vLLM、TGI 之类,同样会暴露兼容接口,流式参数也是一样的语义。
4. 前端流式消费与渲染实战
4.1 用 fetch 和 ReadableStream 处理流
前端对接流式接口,最核心的是不能把整个响应一次性等完。现代浏览器都支持fetch返回的response.body是一个可读的流对象,我们可以逐段读取。
这里先给出一个基于 fetch 的完整实现,看起来并不复杂,但细节量巨大。
async function chatStream(message, onChunk, onDone, onError) { try { // 发起普通 fetch 请求 const response = await fetch('/api/chat?message=' + encodeURIComponent(message), { method: 'GET', headers: { 'Accept': 'text/event-stream', }, }); // 检查响应格式是否正常 if (!response.ok) { throw new Error('HTTP ' + response.status); } // 读取响应流 const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; // 将二进制块转为文本 buffer += decoder.decode(value, { stream: true }); // 按双换行切分 SSE 事件 const events = buffer.split('\n\n'); buffer = events.pop(); // 保留不完整事件 for (const event of events) { if (!event.trim()) continue; const lines = event.split('\n'); for (const line of lines) { if (line.startsWith('data:')) { const data = line.slice(5).trim(); if (data === '[DONE]') { onDone(); return; } try { const jsonData = JSON.parse(data); onChunk(jsonData.message || ''); } catch (e) { console.warn('JSON 解析失败,忽略该事件:', data); } } } } } onDone(); } catch (error) { onError(error); } }这段代码有几个必知细节:
TextDecoder('utf-8')必须用流模式({ stream: true }),否则碰巧把一个多字节中文字符拆成两个二进制块时,会直接解出乱码。浏览器读取流时,每次拿到的是一个 Uint8Array,需要指定解码器才能转成字符串。
buffer.split('\n\n')是按 SSE 事件分隔符切分。为什么要buffer.pop()?因为最后一个事件可能只收到一半,没凑齐双换行。把半个事件留在 buffer 里,等下次数据到达后再拼接,这个处理逻辑很多人会漏掉,它直接关系到在低速网络下能否稳定解析流式消息。
另外,data: [DONE]是服务端约定好的结束标记。遇到它就要停止读取,并调用完成回调。格式不统一的服务,可能用event: done或空流结束来标识,需要按你对接的服务实际情况做匹配。
4.2 打字机效果与渲染策略的细节
拿到增量文本后,怎么渲染到页面上也很讲究。最直接的思路是每次收到一块文本就往容器元素的 textContent 里追加,但如果拼的是大段 HTML,要特别小心 XSS 注入。对大模型回复,推荐只做纯文本渲染,或者用一个转义函数处理后再插入 DOM。
我常用的吞字渲染方式如下:
const contentDiv = document.getElementById('content'); const fullText = []; function appendChunk(chunk) { fullText.push(chunk); const text = fullText.join(''); contentDiv.textContent = text; // 自动滚动到底部 contentDiv.scrollTop = contentDiv.scrollHeight; }为了让屏幕上看起来有“打字机”效果,服务端流式推送本身就有间隔感,前端不需要额外再加延迟。但如果服务端一次性推了十几个片段、间隔极短,客户端会因渲染太快而失去逐字渐显的效果。这时候可以加一个小的缓冲队列,控制每 30 到 50 毫秒渲染一次。这是我实测下来节奏感比较合适的区间。
自动滚动这里有一个体验优化点:当用户在阅读历史内容时,自动滚到底部反而很烦。所以一般会在容器内加一个“是否在底部”的判断,只有用户接近底部时才自动跟随。比如监听 scroll 事件,判断scrollTop + clientHeight + 50 >= scrollHeight就跟随,否则不跟随。这个细节对长文本尤其重要。
4.3 断线重连与取消请求的完整方案
流式请求是长连接,一旦网络波动或服务端异常,连接经常会被中断。SSE 规范里,服务端可以通过事件 ID 来让客户端重连时带上 Last-Event-ID,从而实现从断点续传。但在 fetch 手写流解析的模式下,这个机制需要你自己实现。
我的做法是:在事件里带上一个序号字段,比如id: 12,客户端每次处理完一个事件就记录该序号。如果连接断开后重连,就在请求头里加Last-Event-ID: 12,服务端据此从序号 12 之后继续推送。注意这个头是 SSE 协议规范要求的,很多服务端框架未必默认支持,需要自己解析并跳过已发送部分。
另外,用户不想继续等生成时,必须支持主动取消。fetch API 是支持 AbortController 的:
const controller = new AbortController(); const signal = controller.signal; async function chatStream(message, callbacks) { const response = await fetch('/api/chat', { signal, /* ... */ }); // ... } // 用户点击“停止生成”按钮 stopButton.addEventListener('click', () => { controller.abort(); });调abort()后,fetch 会抛出一个 AbstError,需要在 catch 中区分“用户主动取消”和“网络错误”,不要把它当报错提示给用户。实践中,点击停止按钮时 UI 应显示“已停止生成”,而不是“请求失败”。
一个你必然会在实际中遇到的坑:用户快速连续提问时,上一次未结束的流式请求如果没有取消,会继续回流数据,导致前端新开一轮对话时文本被前一个会话的内容污染。所以每次新请求发出前,一定要 abort 掉上一个未完成的流,并重置内部状态。这个处理在并发场景下极其关键。
5. 流式输出全链路故障排查与避坑指南
5.1 我踩过的典型问题和排查路径
流式输出部署到生产环境后,我遇到的故障远比开发时多。以下按频率从高到低列出几个典型问题和对应的排查方法。
问题一:本地正常,部署后变成一次性返回
这个现象前面提过,主要怀疑 Nginx 或网关开启了缓冲。排查时直接用命令行测,看响应是否边到边显示:
curl -N --no-buffer https://your-api.example.com/api/chat?message=你好如果命令行也一次性打印,那问题在服务端或反向代理;如果命令行能流式打印,但页面不流式,那问题在客户端读取代码。确认后,在响应头里加X-Accel-Buffering: no,或者改 Nginx 的proxy_buffering off;。
问题二:中文乱码
常见原因是返回头没有声明Content-Type: text/event-stream; charset=utf-8,或网络传输分层时编码不一致。排查时先看响应头,再确认生成器的编码方式。推荐在生成器内部对每个输出做 UTF-8 编码确保。前端用TextDecoder('utf-8')解码,可以解决大部分乱码。
问题三:流式时有明显的卡顿现象
服务端推一批数据过来后长时间无数据,然后突然又涌来一批。这种现象通常是服务端处理逻辑本身在“等待”,比如模型推理确实是分批产出,但某批耗时过长;也可能是反向代理的缓冲设置了较大的分段阈值。排查时建议先把服务端内部的生成耗时打点记录,确认是不是模型推理自身的问题。若是模型推理的问题,可以调低温度或缩短最大 token 数来降低单批生成时间。
问题四:前端 state 不同步
很多前端框架(比如 React、Vue)对大数组的频繁更新处理不好,流式数据一段段 push 进来会导致渲染卡顿。我实测下来,把流式文本放在一个 ref 或一个独立的 mutable 对象里,每次只改文本内容,然后通过一个低频的渲染机制(如每帧强制重绘、或 rAF 控制节流)更新 UI,流畅度会好很多。这是数据驱动框架里很典型的性能优化点,新手很容易碰到“文本越界长就越卡”的问题。
问题五:连接断开后不会自动恢复
SSE 规范里浏览器 EventSource 可以自动重连,但手写 fetch 流没有这个特性。我在前端代码里加了一个简单的重试机制:检测到异常中断时,使用指数退避方式(首次 1 秒,最多 30 秒)实现自动重连。注意每次重连前要把已展示的文本备份好,服务端断点续传的逻辑若没实现,至少避免内容被重复拼接。一个比较稳妥的降级做法是,重连时把已展示文本作为历史发送给服务端,让它生成时跳过已生成部分,或者干脆重新生成整个回复(简单但消耗大)。从产品角度考虑,多数场景下让用户手动点击“重新生成”更省事。
| 现象 | 优先排查点 | 修复方式 |
|---|---|---|
| 一次性返回 | Nginx 缓冲 | proxy_buffering off;或X-Accel-Buffering: no |
| 中文乱码 | 响应头 charset | 设置text/event-stream; charset=utf-8 |
| 流式卡顿 | 服务端批耗时/代理分段 | 打点分析模型生成耗时;调低代理缓冲阈值 |
| 侧边状态不同步 | 前端渲染策略 | 使用 mutable ref + rAF 节流渲染 |
| 断线不恢复 | 无自动重连机制 | 实现指数退避重连,或 EventSource 原生方案 |
5.2 生产环境的性能与并发考量
流式接口比普通接口对服务器资源的占用要高一些,因为每个请求都占用一个常驻连接,且持续占着 I/O。如果并发量上来,容易把后端的连接数打爆。
一个常见误区是给所有接口都上流式。不是所有接口都需要流式,只有真正耗时长、用户等待感强的才合适。像登录、简单查询这种秒回接口,上流式反而是负优化,增加服务器负担和前端代码复杂度。
真正生产化时要注意几个指标:
- 设置合理的读超时和写超时。读超时不要太短,否则模型思考超过几秒就断连;写超时要大于最慢的单批产出间隔。一般建议读超时 60 秒以上,写超时 30 秒以上。
- 并发连接数有限制。Nginx 默认
worker_connections和代理长连接数都要调优。如果单机量大,可以考虑单独划一域名给流式接口,和应用主接口隔离开,这样主接口不会被流式拖垮。 - 负载均衡无状态。流式接口后端的服务实例要注意连接亲和性问题,断线重连时最好还是落到同一实例,否则恢复连接拿不到之前的状态。最省事的方式是每个流式请求都是完全无状态、由客户端带回上下文,这样即使换实例也没关系。LLM 场景一般通过请求消息数组携带历史来实现,天然满足这个要求。
另外,日志要做好降噪。流式接口如果每个 chunk 都打一条日志,一天的日志量会非常恐怖。建议只在开始、结束和异常时打印关键信息,chunk 级别的日志只保留在 debug 模式。我见过有人生产环境开着 debug 日志,结果一天几个 GB,磁盘都写满了。
5.3 服务端推流常见异常汇总
| 异常码或现象 | 原因 | 处理方法 |
|---|---|---|
| ECONNRESET | 客户端断开,服务端写入失败 | 忽略该次写入异常,结束生成 |
| timeout 报错 | 读超时太短或模型无响应 | 调大读超时,加心跳包维持连接 |
| ReferenceError: buffer is not defined | 前端漏定义缓冲变量 | 检查代码里 buffer 的作用域 |
| 流式返回中夹杂非 SSE 文本 | 服务端误输出普通日志 | 把日志输出改到 stderr 或独立文件 |
| 前端收到多个 data 行但无法解析 | 使用了非标准格式 | 统一改标准事件流或自定义解析器 |
这里特别提醒一点:SSE 标准里一个事件可以分多行 data,如果业务数据本身是 JSON,JSON 里不能有裸换行,否则会把事件拆得乱七八糟。最好的做法是:将 JSON 序列化后的字符串作为单行data:推送,如果太长需要换行,必须在发送前把换行转义,前端再做反转义。我们通常直接把 JSON 压成一行,不换行,最省事。
6. 体验打磨与进阶思路
6.1 打字机效果的节奏控制技巧
流式输出实现出来后,还有个体验进阶问题:显示太快也未必好。如果服务端很快把一整个段落都推完了,用户的视觉体验和一次性加载完没有区别,中间等待被压缩了,但“看到生成过程”这种安心感也消失了。
我在实践中摸索出来的经验:用户最理想的节奏是“以正常阅读速度略快一点的速度逐字出现”。如果服务端本身速度偏快,可以在前端加一个简易节流函数,比如保证每 40 毫秒最多渲染一个字符或一个片段。这样用户不会因为文字闪现而感到突兀。
但如果反过来,服务端生成极慢(比如每 5 秒一个 chunk),在前端做“假打字机”反而加重焦虑。这种情况更合理的做法是显示一个进度指示器,比如“正在生成第 2/5 段”这类进度提示,或者一个简洁的呼吸指示条,让用户知道系统没有挂掉。纯文本 UI 也可以用“打点动画”(生成中加入一个滚动的省略号)来缓解等待焦虑。
6.2 停止生成与重新生成的配合
停止生成功能是流式对话的必需品。用户在大模型回复了一段不想要的内容后,一般会想立即叫停。我在实现时加入了一个比较完善的交互逻辑:
- 点击“停止生成”按钮,前端调用 abort,并把当前已生成内容保留在界面里。
- 同时将“完整生成结果”标记为未完成状态,此时用户不能对这段内容做“复制完整全文”等操作,但可以重新提问。
- 如果“重新生成”被触发,前端清空当前内容,重新发起流式请求,而停止按钮保持可用直到完成。
这里有一个细节:停止后已生成的内容是否保留,取决于产品定位。如果对话是逐轮式的,保留已生成内容可以防止误删;如果内容质量太差,干脆清空重来更干净。我一般选择保留,并显示一句“已停止生成,可点击重新生成”提示。这样既能保留上下文,也给了用户再次尝试的出口。
6.3 低成本接入流式输出的降级方案
如果你的现网后端暂时不好大改,还有一个紧急的降级方案:用轮询伪装流式。服务端每 500ms 或 1 秒把“当前生成进度快照”通过普通接口返回给前端,前端拿到快照后替换展示。这个方案虽然不优雅,但能让用户体验快速得到改善,特别适合那种老项目接口没法立刻重构的场景。
快照接口的数据结构可以设计的很简单:
{ "status": "generating", "content": "当前已生成的内容快照", "finished": false, "updatedAt": 1720000000 }前端拿到新的快照后,如果内容比上一轮长,就把新内容渲染出来;如果 status 变为 finished,就结束轮询,切换为完整结果态。这个方案的缺点是服务端每轮都要序列化整个内容快照,内容很长时带宽开销不小;优点是改造面小,前端代码基本不用动。
当然,这只是过渡方案。有条件还是建议直接上 SSE 或真正的流式接口,长期来看成本和体验都会更好。但如果项目排期紧、改动面大,先用轮询把最核心的“渐进显示”效果做出来,是完全可以接受的产品决策。上线之后有空档期时,再逐步切换到真正的流式方案,产品体验还会有一次跃升。
流式输出不是哪个框架的专属能力,而是一种服务端与客户端协作模式的思维转变。它把“等待”变成了“过程”,把黑盒变成了白盒。按照我自己在多个项目里落地的经验,最稳妥的路径就是先把服务端流式接口打通,再用前端 fetch + ReadableStream 做精细消费,最后再加上断线恢复、停止生成、节奏控制这些体验细节。所有坑都踩过一遍之后,再看流式输出这件事,本质就是理解传输层的缓冲机制、掌握事件协议格式、以及做好核心的边界处理。希望这篇实战记录能帮你少走几步弯路,让你用户第一次看到“逐字蹦出来的回复”时,也会觉得你写的东西很高级。