[控场AI]
· 8 分钟阅读· 4,140 字

LLM流式输出到浏览器:FastAPI与SSE的缓冲陷阱实战

LLM流式输出到浏览器:FastAPI与SSE的缓冲陷阱实战

从后端到浏览器全链路实现LLM流式输出,并系统拆解Nginx、Gzip、CDN等四类缓冲陷阱。

本文介绍了一套完整的方案,用Python后端(FastAPI + AsyncOpenAI)将LLM的流式Chat Completions中继到浏览器,核心动机是避免API Key暴露在前端。后端通过异步生成器将每个文本delta封装为JSON编码的SSE事件逐块推送,并处理了空choices块、客户端断开检测和错误事件化等关键细节。前端采用`fetch`而非`EventSource`以支持POST请求,并通过字符串缓冲区按空行切分SSE事件来应对网络分包的不确定性。文章重点分析了生产环境中流式失效的四类根源:Nginx代理缓冲、Gzip压缩中间件、Serverless平台收齐返回、CDN缓存规则,以及常被忽视的日志中间件。提供了用`curl -N`快速定位故障侧的方法,并给出了渲染优化和后端限流的补充建议。

构建基于大语言模型的Web应用时,一个高频出现的问题是:模型在终端里流式输出正常,可一到浏览器里,整段回答却一次性蹦出来。模型本身的流式没问题,真正的症结在于——后端与浏览器之间的某个环节把字节攒住了。

本文整理了一套从Python后端中继流式聊天补全到网页的完整方案,并逐一拆解缓冲(buffering)最容易潜入的那些位置。内容来自OpenAI兼容网关作者Aman Kumar的实战经验。

为什么不直接在浏览器里调API

最直接的理由是:API Key会随前端代码发给每一位访问者。任何写进前端JavaScript的密钥都是公开的,等于把账单敞开给全世界。

正确的模式是让后端做中转:

  1. 浏览器把用户消息发给你的后端;
  2. 后端用存储在环境变量里的密钥,带着 stream=True 调用LLM API;
  3. 后端在每个文本块到达时,立即转发给浏览器。

这样做还有额外收益——后端自然成为实施单用户限流、记录用量和选择模型的统一入口,而这些能力本来就是你需要的。

FastAPI与SSE流式输出方案

后端实现:FastAPI + OpenAI SDK

官方的 openai 包只要设置了 base_url,就能对接任何OpenAI兼容的端点。作者推荐使用异步客户端(AsyncOpenAI),这样一个缓慢的流不会阻塞其他请求。

核心逻辑是一个异步生成器 gen(),它遍历上游返回的流,把每个有内容的 delta 封装成SSE事件 yield 出去,最后用 StreamingResponse 以 text/event-stream 的媒体类型返回。

代码里有几个细节,比它们看起来更重要:

把每个块包成JSON

模型输出里会包含换行符,而SSE的 data: 行里直接出现裸换行会破坏帧的结构。把每一小段文本编码成JSON,可以彻底绕开这个坑。对应到前端,解析时再 JSON.parse 还原即可。

跳过 choices 为空的块

某些服务商会在最后发送一个只带用量数据、没有 choices 的块。如果你直接对它取 [0] 索引,程序会抛异常。因此遇到空 choices 要直接 continue。

检测客户端断开

如果用户关掉了标签页,上游请求在你不主动关闭的情况下会继续生成内容。在按量计费的API上这是真金白银的浪费,在按请求数限额的套餐里则是在烧配额。代码中通过 await request.is_disconnected() 判断,并及时 await stream.close()。

把错误作为事件发出

一旦响应以200状态码开始,你就无法再更改状态码了。因此把异常封装成一个显式的 error 事件发给前端,让它能展示有用的提示,而不是干等着挂起。

SSE(Server-Sent Events)是一种基于HTTP的单向推送协议,服务器以text/event-stream内容类型持续向客户端发送事件流,每条事件以data: 开头并以两个换行符\n\n结尾。相比WebSocket,SSE更轻量,天然复用HTTP连接,且支持断线自动重连,非常适合LLM这种「服务器持续推送、客户端只接收」的场景。异步生成器(async def gen()加yield)是Python中配合StreamingResponse的惯用模式:每次yield一个字节串,FastAPI就立即将其写入TCP缓冲区并刷新,而不是等整个函数执行完毕才返回,这正是流式体验的关键所在。

前端实现:用 fetch 而非 EventSource

EventSource 只支持GET请求,无法携带JSON请求体,所以对于聊天这种需要POST消息的端点,作者选择用 fetch 读取流。

实现上通过 res.body.getReader() 拿到读取器,配合 TextDecoder 逐块解码。最关键的是那个缓冲变量 buf:

一次网络读取可能在某个事件中间就结束了,也可能一次性包含三个事件。

解决办法是以事件之间的空行(\n\n)为分隔切割,并把剩余的半截内容留在缓冲里等待下一次拼接。正是这种「切分 + 保留余量」的处理,让流式解析变得可靠,不会因为网络分片的随机边界而丢失或错乱内容。

EventSource 是浏览器内置的SSE客户端API,使用极简,但它只能发送GET请求,且无法自定义请求头(例如Authorization)。聊天类应用通常需要通过POST请求体传递对话历史,因此必须改用fetch手动消费可读流(ReadableStream)。res.body.getReader()返回一个ReadableStreamDefaultReader,每次调用reader.read()会异步返回一个{done, value}对象,其中value是Uint8Array字节块。TextDecoder负责将字节还原为UTF-8字符串。由于网络层会按任意大小分包传输,单次read()可能只包含半条SSE消息,因此必须维护一个字符串缓冲区,以\n\n为边界切割完整事件,而不能假设每次读取正好对应一条完整事件。

四类缓冲陷阱

当流式在本地跑得好好的,一上生产环境就「不工作」,几乎总是下面某一个原因。

Nginx反向代理缓冲。 Nginx默认会缓冲上游响应。要么发送 X-Accel-Buffering: no 响应头(后端代码里已经加了),要么对该location设置 proxy_buffering off;。同时记得调高 proxy_read_timeout,避免较长的回答在60秒处被掐断。

压缩中间件。 Gzip类中间件往往会等攒满一个数据块才发送,直接破坏流式体验。应把 text/event-stream 排除在压缩之外,或者干脆不给流式路由挂 GZipMiddleware。

Serverless与部分PaaS平台。 有些托管环境会收齐完整响应后才返回。如果平台没有明确说明支持你的运行时的流式输出,先用一个每秒吐出时间戳的极简端点测一下,再回头怀疑自己的代码。

Cloudflare等CDN。 流式通常能透传,但缓存规则或响应转换可能产生干扰。Cache-Control: no-cache 有帮助,同时要确保该路由没有被「cache everything」规则匹配到。

此外还有一个容易忽略的坑:你自己的日志中间件。作者就见过有中间件为了记录日志而读取了完整响应体,结果把流又变回了一整块。任何触碰响应体的逻辑,都必须是流式感知的。

用 curl 快速定位问题在哪一侧

一行命令就能判断问题出在前端还是服务端:

curl -N -X POST https://your-app.example/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Count to 20 slowly"}]}'

-N 关闭curl自身的缓冲。如果文本在这里是逐字涌出的、而浏览器里却不是,那问题在前端解析;如果在这里也是一次性全部到达,就该去查服务器和各级代理。

理解「缓冲」发生的本质有助于系统性地排查:HTTP响应从后端到浏览器要经过多个层次——应用层(FastAPI/uvicorn)、Python中间件、反向代理(Nginx)、CDN/负载均衡、操作系统TCP栈,最后才到浏览器渲染引擎。任何一层都可能因为「等待凑满一个块再发」的优化策略而引入延迟。SSE的Content-Type: text/event-stream加上Cache-Control: no-cache响应头是向各中间层声明「这是实时流,请勿缓存或缓冲」的标准信号,但并非所有代理都遵守,因此需要逐层验证。X-Accel-Buffering: no是专门针对Nginx的指令,由上游应用在响应头中设置即可生效,无需修改Nginx配置文件,适合无法直接操作服务器配置的托管场景。

渲染不卡顿的技巧

每收到一个token就往 <div> 里追加纯文本是没问题的。但如果每个token都重新渲染一遍Markdown,长回答会越来越慢。

作者的做法是:流式过程中只追加纯文本,等到收到 done 事件时再统一渲染一次Markdown;或者最多每100毫秒左右才重新渲染一次。还有一条安全底线——永远不要把模型输出当作原始HTML插入,要始终把它视为不可信内容处理,防范注入风险。

限流逻辑应该放在后端

既然每个请求都经过你的后端,这里就是放置简单限流器的理想位置:在Redis或内存里维护一个「每用户每分钟」的计数器,在打开上游流之前先做检查。

无论你是按token付费还是有固定的月请求预算,这都能保护你不被单个用户(或一段有bug的前端循环)耗尽全部额度。

上线前的核对清单

  • 密钥只存在于服务端。
  • 每个SSE事件都是JSON编码,并以空行结尾。
  • choices 为空的块被跳过。
  • 客户端断开时关闭上游流。
  • 错误以事件形式发出。
  • 该路由关闭了代理缓冲与压缩。
  • 调试浏览器之前,先用 curl -N 测过。

这套代码对任何讲Chat Completions格式的服务商都适用,只需设置好 base URL、密钥和模型ID即可。它既是一份可直接套用的工程模板,也是一张排查流式故障的检查表。

#LLM流式输出#FastAPI SSE#Server-Sent Events#流式缓冲陷阱#OpenAI兼容API#Nginx proxy_buffering#fetch流式解析#大模型Web应用
分享:

相关推荐