跳到主要内容

被反向代理剥离的 SSE Keep-Alive,以及你支付了两次费用的 Prompt

· 阅读需 11 分钟
Tian Pan
Software Engineer

你的 Agent 调用了一个耗时 35 秒的工具。在这 35 秒内,没有任何 Token 从模型流回浏览器。Provider 的 SSE 流仍然开启。你的工具仍在运行。用户的加载动画也在旋转。而在路径中间的某个你无法控制的反向代理认为连接静默时间过长,关闭了它,随后你的客户端重连逻辑尽职地从头重新启动了整个请求。

第一次响应产生了 4,200 个 Prompt Token 和 600 个 Completion Token。第二次响应也是 4,200 个 Prompt Token 和 600 个 Completion Token。用户得到了一个答案。而你的账单却收到了两份。

这就是流式传输指南从未真正警告过你的失败模式,因为这些指南是由处于两端的人编写的——Provider 和浏览器——而中间的代理链是别人的问题,直到有一天它变成了你的问题。当你把真正的生产网络置于 LLM 调用和用户之间时,你就继承了一份你从未谈过的契约:路径上的每个中间设备都有自己的规则,规定一个 TCP 连接可以保持静默多久,而这些规则会被无声且不均匀地强制执行。

Token 之间的沉默是正常的,而这正是问题所在

LLM 流式传输看起来像 Token 的洪流,但在字节层面它是突发性的。模型生成一个 Token,然后思考,再生成下一个。大多数情况下间隔很小。但一旦 Assistant 决定调用工具——Agent 循环尽职地去访问下游 API、查询数据库、运行沙盒脚本——SSE 流就会进入空闲状态。没有关闭,只是空闲。Provider 仍保持连接开启,等待发送下一个事件。你的客户端仍在等待接收。一切正常。

对于网络路径上的每个中间代理来说,“一切正常”看起来与“连接已被放弃”完全一致。反向代理无法读取 SSE 流的语义;它只能计算自上一个字节以来的秒数。如果间隔超过了它的空闲超时时间,它就会执行配置的操作:关闭连接。从 Provider 端看,Socket 突然被重置(RST)。从你这边看,EventSource 触发 onerror,如果你使用的是标准的重连库,它会立即通过新请求重新打开连接——在无状态的 LLM API 上,这意味着重新发送整个 Prompt,并为整个响应再次付费。

那些坑人的默认值无处不在且非常短。AWS ALB 默认为 60 秒空闲超时。Nginx 的 proxy_read_timeout 默认为 60 秒。Cloudflare 的免费版和专业版计划强制执行 100 秒的上限,你无法通过配置文件提高。AWS Service Connect 被观察到在约 15 秒时切断 SSE 流,因为它处理 SSE 的方式与处理 WebSocket 不同。用户看到的症状总是相同的:一个在本地运行良好、在没有代理的测试环境中运行良好,然后在生产环境中正好在 N 秒后莫名其妙地断开,而 N 是路径上最小的空闲超时时间。

Keep-alive 消失的地方

标准且文档齐全的缓解方案是发送心跳(Heartbeat):一个以冒号开头的 SSE 注释行,每 15 到 30 秒发送一次,在网络线路上推送几个字节,这样就没有代理会认为连接处于空闲状态。SSE 规范明确容纳了这一点——以 : 开头的行是注释,会被客户端的解析器忽略,但对于任何检查字节的设备来说,它与真实流量无异。

中招的团队通常不是因为忘记了心跳,而是因为他们假设心跳在传输过程中幸存了下来。但事实往往并非如此。

Provider 每 15 秒发送一个 : keep-alive 注释。它首先到达的是你的边缘层——Cloudflare、CloudFront、AWS ALB 或任何位于最前端的组件。该层可能配置了 proxy_buffering on(Nginx 的默认值),这意味着它会在 4-8 KB 的缓冲区中积累响应字节,只有在缓冲区满或上游关闭时才会将其推向下游。少量的冒号和换行符字节在很长一段时间内都无法填满该缓冲区。Provider 在发送心跳,你的客户端却在接收沉默。链路上的下一个代理——距离你的客户端更近的那个——计算着沉默的秒数并关闭了连接。心跳确实存在;但在超时触发时,它正静静地躺在两跳之外的上游缓冲区里。

这就是为什么经典的 Nginx SSE 配置是六条指令,而不是一条。你通过 proxy_buffering off 禁用缓冲,通过 proxy_cache off 禁用缓存,通过 proxy_read_timeout 3600s 提高读取超时,清除 Connection 请求头以防止上游 Keep-alive 干扰,强制使用 HTTP/1.1 以确保分块传输(Chunked Transfer)正常工作,并关闭分块传输编码转换。漏掉任何一个,流的表现就会大打折扣。特别是漏掉 proxy_buffering off,用户会看到整个响应在最后时刻一次性弹出,这经常被误诊为“模型太慢”,而实际上是“代理在囤货”。

X-Accel-Buffering: no 响应头是告诉 Nginx 应用程序知道自己在做什么,请不要缓冲这个特定响应的礼貌方式,无论全局配置如何。在服务器上设置它的成本很低,且能适应大多数代理重新配置。它对 Cloudflare、AWS ALB 或你客户端流量经过的公司代理不起作用,但它消除了一种常见的故障模式。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates