上个月帮朋友把内部知识库的问答接口改成流式输出,后端 Python + SSE,本地测试逐字往外蹦,效果很丝滑。结果一上生产,前端半天一个字都不出,等十几秒之后整段回答“啪”地一次性砸出来,跟没改一样。

前端同事第一反应是 WebSocket 帧解析有 bug,差点去重写渲染层。我说先别动前端——流式这种事,最怕的不是慢,是“攒”。链路上任何一层攒够了再往下发,流式就退化成非流式。而你本地是直连,生产中间隔着好几层代理,每一层默认都在攒。
一、先确认问题在哪一层:curl -N 是最省事的探针
排查流式问题,别开浏览器,先上终端:
curl -N -H "Content-Type: application/json" \
https://example.com/api/chat/stream \
-d '{"q":"用三句话解释什么是SSE"}'
-N(–no-buffer)会关掉 curl 自己的缓冲,让数据到多少显示多少。看结果分两种情况:
- curl -N 也是卡住后一次性吐出:问题在服务端到 Nginx 这一段,Nginx 配置或后端缓冲没关。
- curl -N 是逐字吐,浏览器才一次性出:问题在 Nginx 之后的链路,重点查 CDN 和浏览器缓存。

这一步 30 秒就能把故障域砍掉一半。我当时 curl -N 就是一次性吐出,问题锁定在 Nginx。
二、Nginx 的三层“蓄水池”,一个都别留

Nginx 对上游响应默认开着代理缓冲(proxy_buffering):先把响应攒进自己的 buffer,攒不下再写临时文件,攒完才往客户端发。这对普通页面是优化,对流式是灾难。除了它,还有 gzip 压缩和 fastcgi 缓冲两层也在偷偷攒。流式路径的 location 大概要长这样:
location /api/chat/stream {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
# ① 关代理缓冲:数据到多少发多少
proxy_buffering off;
proxy_cache off;
# ② 禁用临时文件兜底,防止 Nginx 又把流写盘攒批
proxy_max_temp_file_size 0;
# ③ 默认 60s,模型排队久了直接 504
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# ④ 尊重后端的缓冲声明,双保险
add_header X-Accel-Buffering no;
# ⑤ gzip 需要攒数据判断压缩比,对流式是隐形杀手
gzip off;
}
逐条说下为什么:proxy_http_version 1.1 必须写,Nginx 默认用 HTTP/1.0 访问上游,1.0 没有 chunked 传输编码,Nginx 只能等完整 body 才知道长度——这一条漏了,前面全白配。Connection "" 是配合 1.1 复用长连接的标准写法。X-Accel-Buffering no 这个响应头还会被传到下游,让中间的 CDN 也别缓冲。
三、后端是 PHP-FPM 的话,还有第四层
如果你的流式接口跑在 PHP 上,fastcgi 这条链路也有一层缓冲,光关 Nginx 的 proxy_buffering 没用:
location ~ \.php$ {
...
fastcgi_buffering off;
}
PHP 代码侧也要把输出缓冲清干净,并主动声明流式:
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
while (ob_get_level() > 0) { ob_end_flush(); }
ob_implicit_flush(true);
另外 php.ini 里 zlib.output_compression = On 的话也得关,它和 Nginx 的 gzip 是一回事:压缩算法要攒够数据才划算,流式直接被攒死。
四、五个真实踩坑
- gzip 是最隐蔽的一层。全局
gzip on开着时,location 里的proxy_buffering off照样白搭,症状一模一样。流式路径必须显式gzip off,CDN 层的自动压缩也要顺手关掉或加旁路规则。 - proxy_http_version 默认 1.0。没有 chunked 编码,Nginx 只能等完整响应,这是“配置都加了还是不行”的头号原因。
- proxy_read_timeout 60s 不够用。大模型排队久时 60 秒没吐第一个 token 就 504,前端报“网络错误”,实际后端还在跑,用户一刷新请求翻倍。
- 后端误设了 Content-Length。流式响应一旦带上固定长度,浏览器就必须等满这个字节数才渲染。SSE / chunked 响应绝对不能手设 Content-Length。
- CDN 缓存规则把 /api/* 也缓存了。接口响应被当成静态资源命中缓存,流式直接变“录播”。给 API 路径显式设 Bypass Cache。
五、上线前一分钟自检
- 终端
curl -N请求接口,确认数据是一行一行往外蹦,而不是转半天一次性出; - 浏览器 Network 面板看 Response,流式正常时内容会持续增长;
- 临时绕过 CDN 直连源站再测一遍,确认问题不在 CDN 层。
流式输出不是“打开一个开关”,而是让链路上每一层都允许它流。浏览器、CDN、Nginx、PHP/应用四层里只要有一层在攒,用户的体感就是“AI 卡死了”。下次再遇到半天不出字,先 curl -N,再从外往里逐层关缓冲,基本十分钟内能定位。