什么是流式输出,后端怎么生成,前端怎么渲染
流式输出(Streaming Output) 就像是在看视频直播,内容是一边产生一边传输给你的,而不是像下载电影那样,必须等整个文件下完才能开始看。
在 AI 领域(比如 ChatGPT),流式输出表现为文字一个接一个地“蹦”出来,而不是转半天圈圈后突然甩出一大段话。
什么是流式输出,有什么特点
1. 它是怎么实现的?
流式输出的核心技术通常是 SSE (Server-Sent Events,服务器发送事件)。
在传统的 HTTP 请求中,模式是“一问一答”:客户端发请求,服务器处理完全部逻辑,打成一个大包发回客户端。而在流式输出中,过程如下:
-
建立持久连接:客户端发送一个请求,并在 HTTP 头部声明
Accept: text/event-stream。 -
分块传输:服务器每生成一个字(或一个 Token),就立即将其封装成一个小数据块发送出去。
-
保持连接:直到内容生成完毕,服务器才会发送一个特殊的结束标志(如
[DONE]),随后关闭连接。
2. 流式输出的特点
| 特点 | 说明 |
|---|---|
| 低首字节延迟 (TTFB) | 用户能瞬间看到反馈,大大缓解了等待焦虑。 |
| 实时性 | 适合长文本生成。如果是普通模式,生成 1000 字可能需要 20 秒,流式模式下第 1 秒就开始显示。 |
| 单向通讯 | 不同于 WebSocket 的双向奔跑,SSE 主要是服务器向客户端“单向投喂”数据。 |
| 资源占用 | 因为要长时间维持一个 HTTP 连接,对服务器的并发处理能力有一定要求。 |
3. 为什么 AI 必须用它?
大语言模型(LLM)的推理过程是自回归的。简单来说,它每生成一个词,都要基于前面所有的词重新算一遍概率。
这个过程本身就很耗时。如果等 2000 个 Token 全部算完再给用户,用户可能以为网页卡死了。流式输出完美契合了模型“一个一个词往外蹦”的运行逻辑。
后端怎么实现
在后端实现流式输出,本质上是打破“处理完全部逻辑再返回”的传统模式,改为利用 HTTP 分块传输(Chunked Transfer Encoding) 持续向客户端推送数据。
最常见的方法是使用 SSE (Server-Sent Events)。下面我以 Python (FastAPI) 和 Node.js (Express) 为例展示核心逻辑。
1. Python 实现 (以 FastAPI 为例)
FastAPI 内置了 StreamingResponse,非常适合配合大模型的生成器(Generator)使用。
2. Node.js 实现 (以 Express 为例)
在 Node.js 中,通过手动设置 HTTP 响应头并使用 res.write() 来持续发送数据。
3. 实现的关键要素
要确保后端流式输出成功,必须满足以下几个条件:
-
正确的 Content-Type: 必须设置为
text/event-stream。 -
禁用缓冲 (Buffering):
-
如果你使用了 Nginx 等反向代理,它可能会默认缓存后端的数据,攒够一波再发给前端。
-
解决方法: 在 Nginx 配置中设置
proxy_buffering off;,或者让后端返回X-Accel-Buffering: no响应头。
-
-
特定的数据格式:
-
每条消息必须以
data:开头。 -
每条消息必须以两个换行符
\n\n结尾。
-
4. 进阶:如何对接大模型 (LLM)
如果你是在调用 OpenAI 或 Anthropic 的 API,它们通常提供 stream=True 参数。你的后端实际上充当了一个中转站(Proxy):
-
后端调用 AI API(开启流式)。
-
后端迭代接收 AI 返回的每一个 Chunk。
-
后端立刻将这个 Chunk 转发给前端。
前端怎么实现
在前端捕获流式数据,主要有两种主流方案:传统的 EventSource 和现代的 fetch + ReadableStream。
由于现在的 AI 接口(如 OpenAI)大多使用 POST 请求,方案二 (fetch) 是目前最通用的做法。
方案一:使用 fetch 结合 ReadableStream (推荐)
fetch API 本身支持流式读取。通过 response.body,你可以获取一个读取器(Reader),逐块解析数据。
方案二:使用 EventSource (仅限 GET)
如果你的后端接口支持 GET 请求,EventSource 是最简单的原生实现,它会自动处理重连和心跳。
核心难点:如何优雅地“渲染”?
在处理 AI 流式输出时,你可能会遇到以下两个坑:
-
Markdown 渲染:数据是一点点出来的,如果你每出一个字就渲染一次 Markdown,性能会炸掉。
- 对策:使用带缓存的渲染库(如
markdown-it),并限制渲染频率(如 100ms 刷新一次)。
- 对策:使用带缓存的渲染库(如
-
数据截断:有时候一个 Unicode 字符或者一个 JSON 字符串会被拆分到两个不同的 Data Chunk 中。
- 对策:在前端维护一个缓冲区(Buffer),将接收到的
value累加,直到匹配到完整的\n\n再进行解析。
- 对策:在前端维护一个缓冲区(Buffer),将接收到的