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的密钥都是公开的,等于把账单敞开给全世界。
正确的模式是让后端做中转:
- 浏览器把用户消息发给你的后端;
- 后端用存储在环境变量里的密钥,带着
stream=True调用LLM API; - 后端在每个文本块到达时,立即转发给浏览器。
这样做还有额外收益——后端自然成为实施单用户限流、记录用量和选择模型的统一入口,而这些能力本来就是你需要的。

后端实现: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即可。它既是一份可直接套用的工程模板,也是一张排查流式故障的检查表。
相关推荐

uGAP:统一100+非洲地理数据集的AI平台
uGAP是开普敦大学团队开发的非洲地理空间AI平台,统一120余个非洲数据集并提供Python SDK,支持数据发现、处理与模型训练一站式工作流,已用于气旋洪水制图等实战场景。

Cursor能控制你的电脑了:AI代理可驱动桌面应用测试
Cursor 新增桌面控制能力,AI 代理可驱动 iOS 模拟器等桌面应用测试自己构建的功能。本文分析这一更新对开发工作流的影响、技术实现可能路径及可靠性考量。

Vibecoding自研推理引擎:理想丰满,现实骨感
Reddit开发者分享Vibecoding自研推理引擎的真实经历:用WebGL2和GLSL在浏览器跑GGUF Q4模型,却陷入tokenizer调试地狱。本文分析浏览器端LLM推理的工程难点,以及为何直接clone llama.cpp是更理性的选择。