IT加油站

SSE 流式实测:nginx 不缓冲,HAProxy 首 token 延迟 206ms

10浏览 3小时前 软件教程 MA123624

原文:https://dev.to/remdore/nginx-streams-your-tokens-fine-haproxy-holds-them-for-206ms-10p2(作者 @remdore)

到处都能看到这条建议:如果你在用 nginx 转发 Server-Sent Events(SSE),一定要关掉 proxy_buffering,否则你的 token 会一次性扎堆到达。

我搭了一套测试装置来实测。结论是:nginx 并不会缓冲 token 流。真正会缓冲的是 HAProxy——在默认配置下,它会把你的第一个 token 扣住 206 毫秒。而大家解决 SSE 缓冲问题时顺手就用的那个响应头对它毫无作用,因为那个头是 nginx 的约定,而 nginx 从来就不是问题所在。

四款代理,全部锁定到确切的补丁版本,都挂在同一个发送端(emitter)前面,在一台 Linux 云主机上实测,结果如下:

  • 直连(无代理):首 token 2ms,帧间隔 50.0ms,每次读取帧数 1.02
  • nginx 1.31.5:首 token 3ms,帧间隔 50.0ms,每次读取帧数 1.02
  • nginx 1.31.5(proxy_buffering off):首 token 2ms,帧间隔 50.0ms,每次读取帧数 1.02
  • Caddy 2.11.4:首 token 2ms,帧间隔 50.0ms,每次读取帧数 1.02
  • Traefik v3.7.13:首 token 2ms,帧间隔 50.0ms,每次读取帧数 1.02
  • HAProxy 3.4.4:首 token 206ms,帧间隔 0.0ms,每次读取帧数 5.12

发送端每 50ms 发送一帧。经过 nginx、Caddy 和 Traefik,帧与帧之间相隔 50ms 到达,每次读取恰好一帧,与完全不经过代理无从区分。而经过 HAProxy 时,帧会以五个一组的形式扎堆到达,帧与帧之间零间隔,整体晚了 206ms。

上面 nginx 那一行场景里,proxy_buffering 默认是开启的。我没有只信配置文件,而是在运行中的容器里用 nginx -T 实际确认过。把它关掉之后,结果没有任何变化。

衡量指标

本文所有结论都建立在一个数字上:每次读取帧数(frames per read)。拿客户端收到的 SSE 帧总数,除以至少返回了一帧的 recv() 调用次数。1.0 表示每一帧都单独到达;41.0 表示整个流在一次读取中一次性落地。

它是个比值,所以解读时不需要基线参照,而且在宿主机存在噪声时也比绝对延迟更扛得住干扰。

在信任这个指标之前,我先把它对准了一个故意把上游响应整个读空、再一次性冲刷出去的中继。直连:1.00;经过中继:21.00。只要合并(coalescing)确实存在,这个测量手段就能把它测出来。

本文中有两个数字不是结论,我想在下面数据列出来之前先白纸黑字讲清楚:

  • 每次读取帧数存在约 1.02 的噪声下限。 直连、无代理的路径测出来就是 1.02。41 次读取里出现一次合并,是客户端和内核造成的,跟代理无关。谁要是把 1.02 当成与 1.00 有区别的结果来报告,读到的就是噪声。
  • 这台主机本身就会产生孤立的 30-45ms 调度尖峰,此时路径里既没有代理也没有 Docker。 单独跑发送端,40 轮:p90 为 10.89ms,最大值 44.50ms。所以在这套装置上,任何约 40ms 的首 token 停顿都不能算到代理头上。下文还会再谈这一点,因为我差点就把一个这样的结论发了出去。

token 大小的帧是 HAProxy 的最坏情况

这部分才是让这件事对所有从大模型拉流的人真正要紧的地方。

同样的 50ms 发送间隔,同样的 HAProxy,同样的默认配置。唯一的变量是把每帧从约 60 字节增大到约 1.1KB:

  • 约 60 字节:首 token 206ms,帧间隔 0.0ms,每次读取帧数 5.12
  • 约 1.1KB:首 token 53ms,帧间隔 41.0ms,每次读取帧数 1.46

把载荷加大之后,合并现象基本消失。首 token 快了四倍,帧以接近发送间隔的节奏陆续到达、不再扎堆,接近每次读取一帧。

改变发送频率也指向同一个方向。每次读取帧数:5ms 间隔时是 13.67,50ms 时是 5.12,200ms 时是 2.05。发送越快,单位时间内积累的字节越多;字节越多,冲刷就越早发生。

所以触发条件基本上是「填满缓冲区」,而不是某个固定定时器。我不会给出一个字节阈值:不同组数据反推出来的值对不齐——一组算出来约 2.2KB,另一组约 1.05KB——要真正钉死这个数字需要一轮专门的扫描测试,而我还没做过。

但实际影响非常明确。LLM 的 token 流恰恰是「小帧持续稳定到达」的形态,正是会触发这个问题的流量形状。如果你用贴近现实的 1KB 数据块去压测同一个代理,根本看不到这个现象。这大概也是它没有被更多人知道的原因。

这也意味着「每次读取帧数」这个数字刻画的是这条流经过这个代理的行为,而不是 HAProxy 的固有属性。把「HAProxy 每次读取交付 5 个事件」当成关于 HAProxy 的固定事实来引用是错的:5ms 间隔时它每次读取交付 13.67 个,200ms 时是 2.05 个。

人人都在用的那个响应头毫无作用

X-Accel-Buffering: no 是官方文档里针对这个问题的标准逃生通道。我带上这个头又测了一遍:

  • HAProxy,不带该头:首 token 206ms,每次读取帧数 5.12,最大间隔 256.4ms
  • HAProxy,带 X-Accel-Buffering: no:首 token 214ms,每次读取帧数 5.12,最大间隔 256.2ms

完全一样。206ms 与 214ms 的差异只是轮次间的正常波动;比值和最大间隔在三位有效数字上完全吻合。

这是 nginx 的约定。nginx 认这个头,但 nginx 本来就没在缓冲。HAProxy 也从未声称自己会读它。于是,解决 SSE 缓冲问题的标准方案,恰恰被这组测试里唯一会缓冲的代理无视了。

proxy_buffering 什么时候真的会让你付出代价

我不能把 nginx 的结果简单归结为“这条建议没必要”,因为上面每个测试单元用的都是及时读取的客户端,而且总流量只有约 2.4KB。nginx 默认的 proxy_buffers 是 8 × 4k 或 8k,也就是 32 到 64KB。缓冲区从来没接近过写满,这条指令根本无事可做。

于是我构造了一个它真正发挥作用的场景:约 328KB 的负载,客户端故意在每次读取之间休眠 200ms。

  • direct(直连):首 token 时间 2ms,每次读取帧数 3.73
  • nginxproxy_buffering 开启):首 token 时间 53ms,每次读取帧数 3.73
  • nginxproxy_buffering off):首 token 时间 3ms,每次读取帧数 3.57

看到了。开启缓冲会带来约 50ms 的首 token 延迟,而且非常稳定:十次运行的中位数是 53ms,最大值 54ms,没有一次快过。

注意什么没有发生。无论开关,每次读取的帧数都是 3.73,和直连一样。那一列里的帧合并来自慢客户端和内核。这条指令付出的是延迟;它并没有把流变成一整块。

所以关于 proxy_buffering 的诚实结论,和网上流行的两种说法都不一样:

  • 小帧、及时读取的客户端——这才是真实的 token 流式场景——在四个独立测试单元中,它的代价测不出来。
  • 大负载、慢客户端——代价约为 50ms 的首 token 延迟。
  • 没有任何一个测试单元出现它广为人知的那种剧烈帧合并。

我亲手毙掉的发现

早期在 macOS 上用 Docker Desktop 测量时,我看到 Caddy 和 Traefik 稳定地需要约 42ms 才到首 token,而 nginx 只要 2ms。启动后的第一个请求很快,之后每个复用的请求都慢,这是池化上游连接上发生延迟确认(delayed-ACK)停顿的典型特征。我有了机制,能预测哪种条件会复现它,还搭了一个 2×2 实验来演示。

随后,这个故事按顺序出了三个问题。

一位审稿人指出,我的 nginx 配置里没有 upstream{} 块,也没有 keepalive,所以 nginx 根本从不池化上游连接。它永远处于 Caddy 和 Traefik 只在第一个请求才会遇到的那种“全新连接”状态。我比较的根本不是两个代理,我比较的是池化与非池化。

接着我单独运行发射器,链路上既没有代理也没有 Docker,它自己就产生了 30 到 45ms 的尖峰。不管我之前测到的是什么,都没法归因于代理。

然后我换到 Linux,这个现象彻底消失了。所有九个测试单元、所有条件——池化或全新连接、Nagle 开或关——Caddy 和 Traefik 都稳定在 2 到 5ms。fresh-conn 单元会把整个栈重启五次,所以每一行都是真正的首次请求:Caddy 5ms,Traefik 4ms,与池化状态没有差别。

那是 Docker Desktop 造成的测量假象。把它当作代理行为来报告会是错误的,而我差一个实验就真的这么写了。

换成真实模型还成立吗

上面所有数字都来自合成发射器,因为精确的节奏控制才能让比较具备因果性。于是我把上游换成 DigitalOcean 的 serverless 推理端点,用同一个客户端测量,15 次调用,全部 HTTP 200:

  • HAProxy:每次读取帧数 5.43,帧间隔 0.0ms,总帧数 169
  • nginx:每次读取帧数 1.04,帧间隔 8.6ms,总帧数 185
  • nginx(proxy_buffering off):每次读取帧数 1.02,帧间隔 8.7ms,总帧数 176

合成数据是 5.12 对 1.02。来自 gpt-oss-20b 的真实 token 是 5.43 对 1.04。

这张表本身不能证明因果——模型的首 token 时间在每次调用之间会有波动,这正是论证要靠合成测试单元来扛的原因。它唯一的任务是证明这个效应不是我发射器造成的假象。结果证明,它不是。

这些数字是如何保持诚实的

有两道守卫机制把守着整个测试,任何一道都能作废一个结果。

发送端会记录自己的发送时间戳并将其暴露出来,因此每次运行结束后,测试框架都会检查发送端是否真的按规定节奏发送。如果出现了漂移,这次运行会被丢弃,而不是把账算到某个代理头上。直连(不经代理)路径也必须呈现出干净的增量流,否则该主机上任何代理的数据行都没有意义。

测试套件对每个单元格只做一次尝试,绝不重试。这条规则之所以存在,是因为我自己违反过它。早期有一次运行在第六次尝试时“成功”了,而当我停止重试后,一次诚实的尝试只让六个单元格中的一个通过了认证。反复重跑直到守卫通过,实际上是在挑选机器上安静的时段,会以不可见的方式让所有数字产生偏差。

单次运行确实会被丢弃,丢弃率视单元格不同在 1.7% 到 11.7% 之间,因为每个单元格大约有 2,400 次计时写入,出现一次调度抖动的概率几乎必然。这种剔除之所以站得住脚,是因为它事先声明、纯机械执行、由发送端一侧独立于被测代理进行测量,而且每一次剔除都被计入公开发布的表格中。而“重跑直到变绿”这四条一条都过不了。

让我确信这套机制有效的一次内部检验:在发送端重新启用 Nagle 算法后,剔除率从 5.0% 翻倍到 10.0%,而所有代理的数字纹丝不动。剔除掉的实验装置噪声,而不是在塑造结果。

这些数字来自一台 2 vCPU 的 Ubuntu 24.04 云主机(droplet),而不是笔记本电脑。macOS 上的 Docker Desktop 会把容器流量经由一层虚拟机网络栈转发,这会给一项信号只有 50ms 的测量引入几十毫秒的噪声——而且正如前文所述,还凭空制造出了一个“发现”。

以上是简短版本。测试框架本身在产出一个我愿意为之背书的数字之前,曾以十四种方式出错:守卫机制每十次运行才抽查一次、节奏时间戳落在了阻塞写入的错误一侧、健康检查反而扰动了它所检查的对象,还有上文那个重试循环,对我的数字做的正是守卫机制本来要防止的事。上述每一处错误,以及证明每项修复确实生效的实验,都单独写在《我的基准测试框架在测出任何数据之前错了十四处》一文中:My benchmark harness was wrong fourteen ways before it measured anything

我还会继续测什么

  • HAProxy 中触发 flush 的确切条件。在固定间隔下做一轮载荷大小扫描,就能确定它是一个字节阈值、一个计时器,还是两者兼有。
  • option http-no-delay 是否能消除这个延迟,以及代价是什么。
  • 显式配置上游 keepalive 后的 Caddy 和 Traefik,因为我的配置里连接复用都用的是厂商默认值,而 nginx 的默认值是完全不复用。

测试框架只用 Python 标准库和 Docker,没有任何第三方依赖。selftest.sh 会在任何测量之前先对主机做合格校验,并且拒绝在不是由它自己启动的服务器上运行。run.sh 会把镜像摘要和每个代理配置的完整文本写入结果文件,因此本文中的配置不可能与产出这些数字的配置发生漂移。

如果你正在用默认配置的 HAProxy 流式传输 token,那你最初的那 206 毫秒就是这么没的。

原文:https://dev.to/remdore/nginx-streams-your-tokens-fine-haproxy-holds-them-for-206ms-10p2(作者 @remdore)

#SSE #HAProxy #nginx #proxy_buffering #流式输出 #LLM