2024 年,一次"我做的 AI 对话功能,本地一个字一个字往外蹦、丝滑得不行,一上线就变成憋十几秒、然后一整段啪地砸出来"的事故,把我对"流式输出"这件事的理解,从头到尾翻新了一遍。我们做了个 AI 助手,核心体验就是那个"打字机效果"——你问一句,大模型的回答像有人在实时敲字一样,一个词一个词地冒出来。这个效果我用 SSE(Server-Sent Events)实现:后端每从大模型那里收到一小段,就立刻往响应流里写一段,前端收到一段就渲染一段。我在开发机上反复测,光标后面文字一个一个地蹦,顺滑得让我很满意。上线。结果上线后,用户的反馈是:"点了发送,转圈十几秒,什么都没有,然后唰一下,一整段答案突然全冒出来了。"——我精心做的打字机效果,没了。变成了一个"长时间空白 + 瞬间全量"的、体验极差的东西。我懵了。我的后端代码一个字没改:它明明还在【一小段一小段】地往外写。我的前端代码也没改:它明明还在【收到一段就渲染一段】。两头都还在"流",为什么用户那头,流就【不流了】?如果后端在一段一段地发、前端在一段一段地收,那中间,到底是【谁】,把我那些本该鱼贯而出的小水流,偷偷地、全都【截下来、攒成了一桶】,等满了才一次性泼出去?这件事逼着我把 SSE 流式输出到底是什么、为什么本地好上线坏、Nginx 的 proxy_buffering 干了什么、gzip 压缩为什么也会毁掉流式、流式输出还有哪些隐藏的坑,彻底理清了。本文复盘这次实战。
问题背景
环境:一个 AI 对话功能,后端 SSE 流式输出,前端 EventSource 接收
线上架构:用户 -> Nginx(反向代理)-> 应用后端 -> 大模型 API
事故现象:
- ★ 本地开发(浏览器直连后端):回答逐字蹦出,打字机效果完美
- ★★ 上线后(经过 Nginx):点发送后空白十几秒,然后整段一次性出现
- 后端代码、前端代码,一个字没改
现场排查:
# 1. ★ 先确认后端自己,到底是不是在"流式"地发
$ 在后端直连的端口上,用 curl 看原始输出
$ curl -N http://127.0.0.1:8000/chat?q=...
# -N = 关掉 curl 自己的缓冲,看真实到达节奏
# ★★ 结果:逐字、逐段地往屏幕上蹦 —— 后端没问题,它在流
# 2. ★★ 再用 curl 走一遍线上的 Nginx
$ curl -N https://线上域名/chat?q=...
# ★★ 结果:光标卡住十几秒,然后"哗"地一整段全出来
# —— 问题锁定:就在 Nginx 这一层,流被"攒"住了
# 3. ★ 看响应头,找证据
$ curl -i -N https://线上域名/chat?q=...
# Content-Type: text/event-stream ← SSE 类型对的
# ★ 但没有任何"禁止缓冲"的迹象
# 4. ★ 翻 Nginx 的反向代理配置
$ grep -r proxy_buffering /etc/nginx/
# (什么都没搜到)
# ★★ 关键:没配,不等于没有 —— proxy_buffering 默认就是 on!
根因(后来想清楚的):
1. ★ 流式输出的本质,是"后端边生产、边交付":生产
一小段,就立刻把这一小段交付出去,不等全部完成。
2. ★★ 但后端到用户之间,隔着一个 Nginx 反向代理。
Nginx 默认开启 proxy_buffering —— 它会把后端
返回的内容,先【攒在自己的缓冲区里】,攒够一块、
或者后端响应【完全结束】,才一次性转发给用户。
3. ★★ 于是后端那些"一小段一小段"的流,到了 Nginx
这里,被它【尽数截留、攒成一整桶】。后端早就把
第一个字发出来了,但 Nginx 死死按着,非要等到
后端把最后一个字也发完、整个响应结束,才开闸。
4. ★ 本地为什么好:本地浏览器是【直连后端】的,
中间没有 Nginx 这道"攒水的闸",流自然是通的。
5. ★ 流式是一个【端到端】的特性:它要求从后端到
用户,沿途【每一层】都即时转发、都不缓冲。只要
有一层"攒一攒再发",整条流式链路就退化成一次性。
真相:流式没坏在后端,也没坏在前端,坏在中间。
Nginx 默认的 proxy_buffering,把"流"攒成了"桶"。
流式输出,必须沿途逐层关闭缓冲。
修复 1:流式变"一次性"——先定位是哪一层把流"攒"住了
# === ★ 流式失效,第一刀:沿着链路,逐段找出"攒水的人" ===
# === ★ 先有一个正确的排查模型 ===
# ★ 一段流式响应,从产生到抵达用户,要穿过好几层:
# 大模型 API -> 你的后端 -> Nginx 反向代理 -> (可能
# 还有 CDN / 负载均衡)-> 用户浏览器。
# ★ ★ "流式"是个【端到端】特性:这一串里,只要有
# 【任何一层】决定"我攒一攒再发",流式就当场死亡。
# 所以排查的思路,就是【沿着这条链,一段一段地切】,
# 找出到底是哪一层在攒水。
# === ★★ 决定性工具:curl -N,看流的"真实到达节奏" ===
# ★ 浏览器有自己的渲染节奏,用它看不准。要看"数据
# 到底是一段段来、还是一坨来",最干净的工具是 curl:
$ curl -N http://...
# ★ ★ -N(--no-buffer):关掉 curl 自己的输出缓冲。
# 加了它,数据【什么时候到,屏幕上什么时候显】 ——
# 你就能用肉眼,直接看见流的真实节奏。
# === ★ 切第一刀:直连后端 ===
# ★ 绕开 Nginx,直接 curl 后端监听的端口:
$ curl -N http://127.0.0.1:8000/chat?q=test
# - ★ 逐字逐段往外蹦 -> 后端没问题,它确实在流式发;
# - 一次性全出来 -> 后端自己就没在流(那是另一个
# 问题:应用层没 flush,见修复 4)。
# ★ 本文这一刀的结果:后端是好的。
# === ★ 切第二刀:走 Nginx ===
# ★ 换成线上域名(经过 Nginx)再 curl 一次:
$ curl -N https://你的域名/chat?q=test
# - ★★ 如果这一刀变成"一次性全出来" —— 那就【锁定】
# 了:是【后端到这里之间的某一层】(最常见就是
# Nginx)把流攒住了。
# ★ 本文这一刀:果然卡住、然后整段蹦出 —— 元凶在 Nginx。
# === ★ 看响应头,交叉验证 ===
$ curl -i -N https://你的域名/chat?q=test
# ★ 重点看:Content-Type 是不是 text/event-stream;
# 有没有 Content-Length(★ 流式响应【不该】有
# 固定的 Content-Length,有了说明被整体缓冲过)。
# === 认知 ===
# ★ 流式失效先建立排查模型:流式是【端到端】特性,
# 从大模型到用户要穿过后端、Nginx、CDN 等多层,
# 任何一层"攒一攒再发"流式就死。★★ 决定性工具是
# curl -N(关掉 curl 自身缓冲,肉眼可见流的真实
# 到达节奏)。排查方法:① 直连后端 curl,逐段蹦
# 说明后端在流;② 再走 Nginx curl,若变成一次性
# 全出来就锁定是后端到用户之间某层(最常见 Nginx)
# 攒住了流;③ 看响应头,流式响应不该有 Content-Length。
修复 2:核心根因——Nginx 反向代理默认开 proxy_buffering
# === ★ 把这次事故的总根,挖出来 ===
# === ★ Nginx 做反向代理时,默认会"缓冲"后端响应 ===
# ★ Nginx 当反向代理(proxy_pass)时,它和后端之间,
# 有一个开关叫 proxy_buffering。★★ 它默认是 on。
# ★ ★ on 的时候,Nginx 的行为是:它从后端【尽快地、
# 能读多少读多少】地把响应读进【自己的缓冲区】,
# 然后【按自己的节奏】再转发给客户端。
# === ★ 这个设计,本意是好的 ===
# ★ proxy_buffering 默认开,是为了【性能】:
# - ★ 后端可以"啪"地把整个响应吐给 Nginx,然后
# 立刻被释放,去处理下一个请求 —— 后端的连接、
# 线程,能更快地被回收复用;
# - ★ 把"慢慢悠悠把数据喂给慢速客户端"这件累活,
# 交给专门干这个的 Nginx,后端不必陪着耗。
# ★ ★ 对【普通的一次性响应】(返回个 HTML、返回个
# JSON),这个缓冲是纯收益,你根本不会察觉。
# === ★★ 但它,和"流式输出"是天生的死对头 ===
# ★ 流式输出的命根子,是【实时性】:第 1 个字一产生,
# 就要立刻送到用户眼前,一刻都不能等。
# ★ ★★ 而 proxy_buffering 干的事,恰恰是【打断这种
# 实时性】:它把后端"一小段一小段"发出的流,全部
# 截留在自己的缓冲区里,直到缓冲区满、或者后端
# 【整个响应结束】,才一次性放出去。
# ★ ★ 对一个 SSE 流来说,后端"整个响应结束",意味着
# 大模型把话【全说完了】。所以 Nginx 实际上是在说:
# "你后端慢慢生成吧,等你全部生成完,我再一次性
# 交给用户。" —— 流式,就这么被它【彻底拍平】成了
# 一次性。
# === ★ "没配置"是最大的陷阱 ===
# ★ 我当时 grep 整个 nginx 配置,搜不到 proxy_buffering
# 一个字,就以为"没这回事"。★★ 这是大错:配置项
# 【没出现】,生效的是它的【默认值】 —— 而它默认
# 就是 on。"我没开它"和"它没开",是两码事。
# ★ ★ 排查这类问题,心里要有一根弦:一个行为诡异的
# 组件,你要查的不是"配置里写了什么",而是"它此刻
# 【实际生效】的值是什么" —— 包括所有你没写、走
# 默认的项。
# === 认知 ===
# ★ 根因是 Nginx 做反向代理时 proxy_buffering 【默认
# on】:on 时 Nginx 把后端响应先攒进自己缓冲区,按
# 自己节奏再转发。★ 这设计本意是性能 —— 后端快速
# 吐完就被释放去处理下一个请求,喂慢速客户端的累活
# 交给 Nginx,对普通一次性响应是纯收益。★★ 但它和
# 流式输出天生对立:流式的命根子是实时性,而 buffering
# 恰恰把一小段一小段的流截留到响应整个结束才放出,
# 把流式彻底拍平成一次性。★ 最大陷阱是"没配置"不等
# 于"没生效" —— 配置项不写,生效的是默认值 on,排查
# 要看组件【实际生效】的值而非配置里写了什么。
修复 3:SSE 到底是什么——一条"不关闭"的长响应
# === ★ 要治流式的病,先得真正看懂 SSE 是怎么"流"的 ===
# === ★ 普通 HTTP 响应:一锤子买卖 ===
# ★ 平时一个接口返回 JSON:后端把数据准备好 -> 一次性
# 写进响应体 -> 关闭这个响应。客户端收到完整的一坨,
# 解析,结束。请求和响应,是一来一回、干净利落的
# 【一次性】交易。
# === ★★ SSE:一条"迟迟不关闭"的响应 ===
# ★ SSE(Server-Sent Events)的精髓,在于它把那条
# 响应,【故意一直开着不关】:
# - ★ 后端先回一个响应头,告诉客户端"接下来是个流":
# Content-Type: text/event-stream
# - ★★ 然后,后端【不结束这个响应】。它每生成一小段
# 内容,就往这条【还开着】的响应流里,写一行:
# data: 这是一小段内容\n\n
# - ★ 客户端那边,不是"等响应结束再处理",而是
# 【收到一行 data:,就处理一行】。
# - ★ 直到后端把话说完,才真正关闭这条响应。
# ★ ★ 所以 SSE 不是"很多次请求",它【自始至终就一个
# 请求、一条响应】 —— 只是这条响应的"身体"很长,
# 且是【一点一点长出来】的。
# === ★ SSE 的报文长什么样 ===
# ★ 它的格式极简单,核心就是一行行的 "data: xxx",
# 每条事件之间用一个【空行】隔开:
# data: 你好\n\n
# data: ,世\n\n
# data: 界\n\n
# ★ 客户端每读到一个 "\n\n",就知道"一条事件完整了"。
# === ★ 为什么 buffering 对它是"致命"的 ===
# ★ 现在就懂了:Nginx 的 proxy_buffering 在等"响应
# 结束"。可 SSE 这条响应,【天生就迟迟不结束】 ——
# 它要等大模型把整段话生成完才结束。
# ★ ★★ 于是 buffering 一路死等,等到 SSE 流终于结束
# 的那一刻,才把【攒了一路】的全部 data: 行,一次性
# 倒给用户。SSE"边生产边交付"的设计,被 buffering
# "等结束再交付"的逻辑,彻底架空了。
# === ★ 它和 WebSocket 的区别(顺带厘清)===
# ★ SSE:【单向】(只能服务端推客户端)、基于普通
# HTTP、轻量、浏览器原生 EventSource 支持、断线
# 自动重连。做"AI 回答逐字推送"这种单向场景,够用。
# ★ WebSocket:【双向】、是独立协议(需 Upgrade
# 握手)、更重。要双向实时(如在线协作)才用它。
# === 认知 ===
# ★ 看懂 SSE 才能治流式的病。普通 HTTP 响应是一次性
# 交易:数据备齐 -> 一次写完 -> 关闭。★★ SSE 的精髓
# 是把响应【故意一直开着不关】:先回 Content-Type:
# text/event-stream 的头,然后每生成一小段就往这条
# 还开着的响应里写一行 data: 内容\n\n,客户端收到
# 一行处理一行,直到后端说完才关闭 —— 自始至终就
# 一个请求一条响应,只是响应一点点长出来。★ 所以
# buffering 是致命的:它在等"响应结束",而 SSE 天生
# 迟迟不结束,于是攒了一路最后才一次性倒出。
# ★ 后端 SSE 流式输出示例(Flask):关键在每写一段就 yield
from flask import Flask, Response, request
app = Flask(__name__)
@app.route('/chat')
def chat():
q = request.args.get('q', '')
def generate():
# ★ 向大模型发起流式请求,它会一段段地返回
for chunk in call_llm_stream(q):
# ★★ 每收到一小段,立刻 yield 出去 —— 这就是"流"
# yield 一次,Flask 就往响应流里写一次
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n" # ★ 约定一个结束标记
# ★ Content-Type 必须是 text/event-stream
resp = Response(generate(), mimetype='text/event-stream')
# ★★ 关键响应头:明确告诉 Nginx —— 这条别缓冲(见修复 4)
resp.headers['X-Accel-Buffering'] = 'no'
resp.headers['Cache-Control'] = 'no-cache'
return resp
修复 4:治本——关掉沿途每一层的缓冲
# === ★ 流式是端到端的,治本就是逐层把"攒水的闸"打开 ===
# === ★★ 第一层:Nginx —— 对 SSE 的 location 关掉 buffering ===
# ★ 不要全局关 proxy_buffering(普通接口还指望它提性能)。
# ★ 只在 SSE 那个 location 里,精准关掉:
# proxy_buffering off;
# ★ 同时,流式连接往往很长,顺手把代理超时也调大,
# 别让 Nginx 嫌它"太久没动静"提前掐断(见修复 5)。
# === ★ 一个更优雅的办法:X-Accel-Buffering 响应头 ===
# ★ ★ 你不一定能改 Nginx 配置(可能是运维管的)。
# 有个由【应用后端】就能控制的办法:让后端在响应里
# 带一个头 —— X-Accel-Buffering: no。
# ★ ★★ Nginx 看到这个头,就会【对这一条响应】关闭
# 缓冲。好处:控制权在应用代码手里,跟着代码走,
# 不依赖运维去改 nginx.conf,不会漏配。
# === ★★ 第二层:小心 gzip 压缩 —— 它也会"攒" ===
# ★ ★ 一个极隐蔽的坑:就算你关了 proxy_buffering,
# 如果 Nginx 对响应开了 gzip 压缩,流式可能【还是
# 坏的】。
# ★ 原因:压缩这件事,本身需要"攒一批数据"才好压。
# gzip 模块会先囤一块内容,压缩成一个块,再发出 ——
# 这又是一次"攒"。
# ★ ★ 解法:对 SSE 的 location,【关掉 gzip】:
# gzip off;(text/event-stream 本就不该被压)。
# === ★ 第三层:应用后端自己,别忘了 flush ===
# ★ ★ 如果你直连后端 curl,发现后端【自己】就不流式
# (一次性出),那是应用层的缓冲在作怪:
# - 很多 Web 框架 / 语言运行时,写响应也是带缓冲的
# —— 你"写"了,不代表它"发"了,它在攒。
# - ★ 你得在每写一段后,【显式 flush】,逼它立刻发:
# Python 用生成器 yield(框架通常会自动 flush)、
# 或显式调 flush;PHP 要 ob_flush() + flush();
# Node.js 是 res.write() 后基本即时,但要关掉
# 可能存在的压缩中间件。
# === ★ 第四层:别落下 CDN / 其他代理 ===
# ★ ★ 如果你的流量前面还套了 CDN,或者还有别的代理
# 层 —— 它们【每一个】都可能有自己的缓冲。逐个确认。
# ★ 记住那句话:流式是端到端的,链路上【任何一环】
# 破坏它,整条就废。要一层一层全查到、全打通。
# === 认知 ===
# ★ 流式是端到端特性,治本就是逐层关掉沿途的缓冲:
# ①★★ Nginx 对 SSE 的 location 关 proxy_buffering
# off(别全局关,普通接口还要它提性能);★ 更优雅
# 是后端响应带 X-Accel-Buffering: no 头,控制权在
# 应用代码手里不依赖运维改配置;②★★ 小心 gzip ——
# 压缩天生要攒一批数据才好压,对 SSE 的 location 要
# gzip off;③ 应用后端自己别忘 flush,写了不等于发了
# 框架运行时也有缓冲(PHP 要 ob_flush+flush);
# ④ 别落下 CDN 和其他代理层,每一层都可能有自己的
# 缓冲 —— 链路上任何一环破坏流式整条就废。
# ★ Nginx 里给 SSE 接口单独一个 location,逐项关掉"攒水"
location /chat {
proxy_pass http://backend;
proxy_buffering off; # ★★ 核心:关掉响应缓冲,别攒
proxy_cache off; # ★ 流式响应不缓存
gzip off; # ★★ 关压缩,压缩也要攒一批才压
chunked_transfer_encoding on; # ★ 允许分块传输
proxy_http_version 1.1; # ★ HTTP/1.1 才支持长连接流式
proxy_set_header Connection ''; # ★ 清掉 Connection,保持长连
proxy_read_timeout 600s; # ★ 流式连接很长,超时调大(见修复5)
proxy_send_timeout 600s;
}
# ★ 普通接口的 location 保持默认(proxy_buffering on),
# 别为了 SSE 把全站的缓冲都关了
修复 5:流式输出还有这些坑——超时、断流、客户端重连
# === ★ buffering 是头号坑,但流式的坑不止这一个 ===
# === ★ 坑 1:连接"太久没动静",被超时掐断 ===
# ★ ★ 流式连接的特点是【活得久】:大模型生成一段长
# 回答,可能要几十秒甚至上分钟。这条连接全程开着。
# ★ ★ 而链路上每一层(Nginx、负载均衡、网关)都有
# 各自的"读超时" —— 它们会觉得"这连接怎么半天不
# 传数据,八成是死了",于是【主动掐断】。
# - ★ 解法 1:把这些层对 SSE 的超时(proxy_read_timeout
# 等)调到足够大。
# - ★ 解法 2:★★ 后端定期发"心跳"。SSE 里发一行注释
# (以冒号开头,如 ": keep-alive\n\n")—— 它不是
# 数据、客户端会忽略它,但它能让链路上每一层都
# "看到这连接还活着",不去掐它。
# === ★ 坑 2:中途出错了,怎么告诉前端 ===
# ★ ★ 普通接口出错,你可以返回一个 500 状态码。但
# SSE 流【一旦开始】,响应头(含 200 状态码)就
# 【已经发出去了】 —— 你没法再改成 500。
# ★ ★ 所以流式中途的错误,只能【在数据流里】传:
# 约定一种事件,如 "event: error\ndata: 出错原因\n\n",
# 前端收到这种事件,就知道是出错了,做相应处理。
# === ★ 坑 3:EventSource 会"自动重连",可能重复 ===
# ★ ★ 浏览器原生的 EventSource,有个贴心也"危险"的
# 特性:连接一断,它会【自动重连】。
# ★ ★ 危险在:如果你的接口是"一问一答",重连会让它
# 把【同一个问题又问一遍】 —— 大模型又生成一遍,
# 你又花一遍钱,用户那边答案还可能重复。
# - ★ 解法:回答正常结束时,发一个明确的结束标记
# (data: [DONE]),前端收到就【主动 close()】掉
# EventSource,别给它自动重连的机会。
# === ★ 坑 4:负载均衡把同一个流"打散"了 ===
# ★ SSE 是【一条】长连接,天然就落在【某一台】后端
# 实例上,这个一般没问题。但如果你用的是"客户端
# 每次轮询"的伪流式,或有重连 —— 要注意负载均衡
# 会不会把重连请求,甩到一台【没有上下文】的实例上。
# === ★ 坑 5:Content-Type 一定要对 ===
# ★ ★ 必须是 text/event-stream。如果后端不小心返回
# 成 text/html 或 application/json,浏览器的
# EventSource 会直接【拒绝】或行为异常。
# === 认知 ===
# ★ 流式输出的其他坑:① 连接活得久(长回答要几十秒),
# 链路各层的读超时会觉得"半天没数据"主动掐断 ——
# 调大超时,★★ 或后端定期发心跳(": keep-alive\n\n"
# 注释行,客户端忽略但能让各层看到连接还活着);
# ② SSE 流一开始响应头就发了没法再改 500,中途错误
# 只能在数据流里用 event: error 事件传;③★★ 浏览器
# EventSource 断线会自动重连,可能把同一问题重问一遍
# 多花钱,正常结束要发 [DONE] 标记让前端主动 close();
# ④ 注意负载均衡对重连请求的处理;⑤ Content-Type
# 必须是 text/event-stream。
// ★ 前端用 EventSource 接收 SSE:收到结束标记要主动 close
const es = new EventSource('/chat?q=' + encodeURIComponent(question));
es.onmessage = (e) => {
if (e.data === '[DONE]') {
es.close(); // ★★ 收到结束标记,主动关闭,杜绝自动重连
return;
}
appendToUI(e.data); // ★ 收到一段,渲染一段 —— 打字机效果
};
// ★ 后端中途出错,是用一个 error 事件传过来的
es.addEventListener('error', (e) => {
// ★ 注意:网络断开 和 业务出错,都会进这里,要区分
if (es.readyState === EventSource.CLOSED) {
showError('连接已断开');
}
es.close(); // ★ 出错也要 close,别让它无脑重连重问
});
修复 6:AI 流式输出排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 流式失效,先用 curl -N 沿链路逐段切,找攒水的人 ===
# === 2. ★★ 直连后端正常 + 走 Nginx 变一次性 = 元凶在中间层 ===
# === 3. ★★ Nginx 反向代理 proxy_buffering 默认 on,会攒住流 ===
# === 4. ★ "配置里没写"≠"没生效",生效的是默认值 ===
# === 5. ★ SSE 是一条迟迟不关闭的长响应,边生产边交付 ===
# === 6. ★★ 治本:对 SSE 的 location 关 proxy_buffering / gzip ===
# === 7. ★ 更优雅:后端响应带 X-Accel-Buffering: no 头 ===
# === 8. ★ 后端自己也要 flush,写了不等于发了 ===
# === 9. ★ 流式连接活得久,超时要调大 + 发心跳防被掐断 ===
# === 10. ★ 正常结束发 [DONE] 标记,前端主动 close 防重连重问 ===
# === 流式失效的排查步骤链 ===
$ curl -N http://后端直连端口/... # ① 后端自己流不流
$ curl -N https://走Nginx的域名/... # ② 走代理后还流不流
$ curl -i -N ... # ③ 看响应头有无 Content-Length
$ grep -r buffering /etc/nginx/ # ④ 没搜到也别信,默认就是 on
# ⑤ 对 SSE location:proxy_buffering off + gzip off + 超时调大
命令速查
需求 命令 / 做法
=============================================================
看流的真实到达节奏 curl -N URL (-N 关掉 curl 自身缓冲)
看响应头 curl -i -N URL (流式不该有 Content-Length)
直连后端验证 curl -N http://127.0.0.1:后端端口/...
关 Nginx 响应缓冲 location 里 proxy_buffering off
关 Nginx 压缩 location 里 gzip off
应用层控制不缓冲 响应头加 X-Accel-Buffering: no
SSE 的 Content-Type text/event-stream
SSE 数据格式 data: 内容\n\n (空行分隔事件)
SSE 心跳防超时 定期发注释行 : keep-alive\n\n
SSE 结束标记 data: [DONE]\n\n 前端收到主动 close
流式连接超时 proxy_read_timeout / proxy_send_timeout 调大
口诀:流式是端到端特性 沿途任何一层缓冲都会把流攒成桶
Nginx proxy_buffering 默认 on 对 SSE 必须 off
避坑清单
- 流式回答变成一次性蹦出,先用 curl -N 沿链路逐段切,找出是哪一层把流攒住了
- curl 一定带 -N 关掉它自身缓冲,否则你看到的节奏是 curl 的不是数据真实的
- 直连后端流式正常、走 Nginx 变一次性,基本锁定元凶在后端到用户之间的代理层
- Nginx 做反向代理时 proxy_buffering 默认就是 on,它会把流式响应攒到结束才放出
- 配置文件里 grep 不到某项不代表它没生效,生效的是默认值,要看实际生效的值
- SSE 是一条故意不关闭的长响应,Content-Type 必须是 text/event-stream,边生产边交付
- 别全局关 proxy_buffering,只在 SSE 的 location 关,普通接口还指望缓冲提性能
- 就算关了 buffering,gzip 压缩也会攒一批数据才压,对 SSE 的 location 要一并关掉 gzip
- 后端写响应也带缓冲,写了不等于发了,要显式 flush,PHP 尤其要 ob_flush 加 flush
- 流式连接活得久要调大各层超时并定期发心跳,正常结束发 [DONE] 让前端主动 close 防重连
总结
这次"AI 流式回答上线后变成一次性蹦出"的事故,纠正了我一个关于"功能在哪里"的、藏得极深的错觉。在我过去的脑子里,一个功能"做没做好",是一件可以在【一个地方】拍板的事——流式输出这个功能,我写在后端:后端一段一段地 yield,前端一段一段地渲染,我在开发机上亲眼看着光标后的文字一个一个往外蹦。那一刻我心里盖了章:这个功能,做好了。我对"做好了"的理解是如此天经地义——我把实现它的那段代码写对了,我在我面前把它跑通了,它就【是】做好了。所以当它上线后变成一坨一次性的东西,我的第一反应是难以置信:我的代码,从后端到前端,一个字都没变,凭什么它在我手里是流式的,到了用户手里就不流了?直到我用 curl -N 一段一段地切那条链路,在 Nginx 那一层看见我的流被 proxy_buffering 截下、攒成一桶,我才如梦初醒地看清:流式输出这个功能,它根本【不在】后端那段代码里,也不在前端那段代码里——它在那条【从后端一直延伸到用户浏览器的链路】上。后端会流、前端会接,只是这个功能"成立"的【必要条件】,远不是它"成立"的【充分条件】。复盘到最深,我意识到我混淆了两种东西:一种功能,是【局部】的,它写在一个函数、一个模块里,这个函数对了它就对了;另一种功能,是【端到端】的,它不属于任何一个单点,它是一条链路上【每一个环节共同协作】才涌现出来的性质。流式输出,就是后者。它要成立,要求大模型、后端、Nginx、可能还有的 CDN、一直到用户浏览器——这条链上的【每一层】,都同时做到"即时转发、绝不囤积"。而残酷的是,这条链上的每一层,几乎都怀着一个和"流式"作对的、根深蒂固的本能:为了效率,它们都想"攒一攒再发"。Nginx 的 buffering 想攒,是为了让后端早点解放;gzip 想攒,是为了凑一批好压缩;后端的运行时想攒,是为了少几次系统调用。这些"攒"的本能,在【一次性响应】的世界里,个个都是美德。流式输出,本质上是在逆着这一整条链的本能而行——它要求每一层,都【主动放弃】自己那个为效率而生的缓冲习惯。我只说服了我自己写的那两层,却天真地以为中间那些我没写的层,会默认配合我。这个教训,我后来到处都看见它的影子:一个"低延迟"的特性,被链路中一个有缓冲的环节拖垮;一个"实时"的推送,被一层好心的合并批处理打破节奏;一个"端到端加密",只要中间有一跳解了密,整个承诺就破了。它们全都不是某一个点的属性,而是整条链的【共识】——只要链上有一个环节不知情、不配合,这个属性就当场不成立。这次最大的收获,是我给自己立了一条新规矩:每当我要做一个带"实时""流式""端到端""低延迟"这类字眼的功能时,我会先停下来,在纸上把那条数据【真正要走完的完整链路】画出来——从最源头,一跳一跳,画到用户眼前——然后我会指着这条链上的【每一跳】问一句:你,会破坏它吗?我不再满足于"我写的那段代码对了",我要的是"这条链上的每一环,都不和我作对"。Nginx 那个默默把我的流攒成桶的 proxy_buffering 教给我的,不是一个配置项,而是一个朴素到我用了十几年代码才真正懂的道理:有些东西,你没法在一个点上"做好"它,你只能在一整条链上"守护"它。你写对的那段代码,从来只是这条链的【一环】;而功能成不成立,从不取决于最强的那一环,只取决于——最不配合的那一环。
—— 别看了 · 2026