Skip to main content

流式链路上的缓冲点

流式输出不是一个前端特性,是一条端到端的链路能力。链路上任何一层决定"先攒一攒再发",流式就没了——而且你不会收到任何报错,只会收到一句"好像变慢了"。

这类问题难查的地方在于:本地开发环境通常是直连的,一切正常;上到测试环境隔了两层代理,就变成"等十几秒一次性吐完"。

ProviderBFFNginxCDN浏览器确认 stream 真的生效别读完整 body 再转发proxy_buffering off关缓存与压缩按 \n\n 切帧,别按 chunk取消要能一路传下去浏览器 abort → BFF 断开 → provider 连接关闭,任何一环断了都在付费生成没人读的 token本地正常、线上变一次吐完十有八九是中间某一层在缓冲;用 curl -N 直连上游对比节奏X-Accel-Buffering: no 由应用下发,proxy_buffering off 在网关配,两处都要
图:链路上每一跳都有机会把流式变成非流式,且不会报错

1. 五跳,每一跳都要确认​

1.1 Provider 侧:确认 stream 真的生效​

请求体里写了 stream: true,不代表返回的一定是流。走第三方代理转发时尤其常见——代理收下参数,返回完整 JSON。

验证办法是绕开自己的代码直连上游:

curl -N -X POST https://api.example.com/v1/chat \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"model":"...","stream":true,"messages":[...]}'

-N 关掉 curl 自己的缓冲,这样才能看到真实的到达节奏。如果这里就是一次性返回,问题在 provider 或中间代理,跟你无关。

1.2 BFF 侧:不要读完再转​

最常见的写法是把上游响应读完、拼成字符串、再返回。这样写流式就断在自己手里了。

// 错:等于把流式降级成非流式
const full = await upstream.text()
return new Response(full)

// 对:直接把上游流交给下游
return new Response(upstream.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
})

第二个容易被忽略的杀手是中间件:日志中间件把响应体读一遍再转发、压缩中间件等攒够一个块才输出。这两类"顺手加的"中间件经常就是元凶。给流式路由配排除规则。

1.3 Nginx:两处都要改​

应用下发 X-Accel-Buffering: no 是按响应生效的,即使全局开着缓冲它也能关闭。但响应头可能被中间层剥离,所以网关配置也改一份:

location /api/chat/stream {
proxy_pass http://app_upstream;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off; # 大 prompt 别先落临时文件
gzip off; # gzip 有自己的 4–8KB 缓冲
proxy_read_timeout 3600s;
client_max_body_size 32m;
}

三点补充:

  • 别在 location / 全局关缓冲,那会拖累普通 JSON 路由的吞吐
  • gzip 也要关,它有自己的缓冲区,即使 proxy_buffering off 也可能等攒够一个可压缩块才输出
  • 嵌套 location 不会继承父级的 add_header,每个流式块都要自己写一遍

1.4 CDN / 边缘网关​

Cloudflare、各家云 CDN、API 网关都有自己的缓冲开关,位置各不相同。Cloudflare 文档化的是 Cache-Control: no-transform(X-Accel-Buffering 是 nginx 专有头,一起发是业界惯例但不是它认的开关);新版还有独立的 response body buffering 规则要检查。ALB 不是"不需要配置"——它有空闲超时(默认 60 秒),流静默超过这个时间就会被掐断,这是 SSE 最常见的线上故障之一。

一条硬规则:绝不要给流式响应加 Cache-Control: max-age。加了之后 CDN 会把整个流缓存下来重放,所有用户看到同一段回答。

1.5 静默期要有心跳​

前面讲的都是"某层攒着不发"。反向的故障同样常见:流长时间没有数据——在等工具、等推理、等慢查询——中间的代理或客户端自己的空闲超时会直接把连接断掉。

按 SSE 的惯例,服务端每隔约 15 秒发一个注释行当心跳:

: ping

它是合法帧、会被客户端忽略,作用只是让每一层知道"连接还活着"。取值要小于链路上最短的那个超时(nginx 的 proxy_read_timeout、ALB 的空闲超时、客户端的 fetch 超时)。心跳间隔比超时还长,等于没发。

1.6 浏览器侧:按事件边界切,不按网络 chunk 切​

网络给你的 chunk 边界和 SSE 的事件边界没有任何关系。一个 chunk 可能含半条事件,也可能含三条半。正确做法是先入缓冲,再按 \n\n 切分,切剩下的留到下一轮补齐。这部分展开在《SSE 流式解析与切帧处理》里。

还有个中文特有的坑:TextDecoder 要开流式解码,否则一个三字节的汉字被切成两半时会出现乱码。

2. BFF 该做什么、不该做什么​

转发层存在的理由不是"多一跳",而是它要做几件只有它能做的事。

该做的:

  • 鉴权与租户校验——凭证不下发到浏览器(见《密钥不能进浏览器》)
  • 审计——谁在什么时候问了什么
  • 限流——按用户、按租户计数(见《限流的三个维度》)
  • 请求裁剪——长度校验、敏感词前置过滤
  • 埋点——首字延迟、总耗时、token 用量

不该做的:

  • 读完整响应再转发(上面说过)
  • 改写或重排流中的内容——你会破坏事件边界
  • 在流里塞自己的进度事件而不遵守 SSE 格式——客户端会解析失败
  • 无限制地放大并发——上游限流是按你的账号算的

如果转发层承担不了这些,那它就是纯增加延迟,可以考虑让客户端直连(前提是凭证问题另有解法)。

3. 取消要一路传下去​

用户点了"停止",浏览器关了标签页,如果取消没有传到 provider,你还在为没人读的 token 付费。

链路是:浏览器 abort → BFF 感知请求断开 → provider SDK 取消 → 上游连接关闭。断任何一环,钱就白花。

// 服务端:把入站请求的取消信号传给上游
const upstream = await fetch(providerUrl, {
method: 'POST',
body: JSON.stringify(payload),
signal: req.signal, // 客户端断开时一并取消
})
return new Response(upstream.body, { headers: streamHeaders })

各家的 SDK 也有对应做法,比如 OpenAI SDK 的 stream.controller.abort()。关键是显式接上,不要指望框架自动处理。

前端侧要注意 EventSource 会自动重连、且不配合 abort 语义,所以用 fetch + AbortController 自己读流更可控。状态怎么组织见《对话界面的状态机》。

4. 背压:什么时候真的会发生​

大模型直连浏览器的场景基本不会背压——每秒百来个 token 大约 400 字节,远低于任何网络带宽。

但三种情况会真实发生:

扇出到多个订阅者。 把 token 塞进 Redis Pub/Sub 或内存队列再分发给多个订阅者时,某个慢订阅者会让队列无界增长。要给队列设上限,满了就丢或阻塞。

移动端弱网。 socket 发送缓冲区填满后,write() 会返回 false(它不返回 Promise,await 一个布尔值没有意义)。这时必须等 drain 事件再继续写,否则数据会在应用层内存里堆起来,进程越吃越多。

服务端转存。 一边收流一边写数据库或对象存储,下游比上游慢时同样堆积。要么限流要么落盘。

5. 三种超时​

流式请求上有三个独立的超时,混为一谈就会出奇怪的问题:

超时作用建议
连接超时建立到 provider 的连接5–10 秒
首字超时等到第一个 token15–30 秒,用户感知最强的就是这个
总时长超时整条流的上限与 max_tokens 对齐,留余量

首字超时要单独设并单独监控。它跟总耗时不是一回事——总耗时可能因为回答长而正常偏高,但首字慢是明确的用户体验问题(见《AI 对话的流式输出实现》)。语音对话对首字的要求比文本更苛刻,那里的链路还多两级编解码,见《实时语音链路》。

网关侧的 proxy_read_timeout 要大于总时长超时,否则网关会先于应用断开,前端拿到的是一个截断的流而不是你设计的错误响应。

6. 排查顺序​

"本地正常,线上不流"时按这个顺序查,每步都能单独验证:

  1. curl -N 直连 provider——确认源头是流
  2. curl -N 打自己的 BFF(绕过 nginx)——确认应用层没缓冲
  3. 加上 nginx——确认网关配置
  4. 加上 CDN——确认边缘层
  5. 浏览器 DevTools 看 Network 面板的响应类型——确认是 eventsource 且随时间增长

第 5 步最直观:如果响应体的大小是"最后一秒突然从 0 变成 30KB",缓冲点一定在链路上;如果是"每秒涨几百字节",流式是通的,问题在前端渲染(可能是每帧 setState 太频繁,或者没做节流)。

按顺序查的好处是每步都有明确结论,不用在五个地方同时改配置然后祈祷。