跳转到主要内容

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

阅读需 2 分钟Tian PanTian Pan

你的 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 或你客户端流量经过的公司代理不起作用,但它消除了一种常见的故障模式。

会员专享

余下内容仅对会员开放。

会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。

  • 完整文章,包含未公开存档的部分
  • 可落地的工作框架,附带权衡与决策依据
  • 新文章抢先看,先于公开发布

随时取消 · 一次订阅,畅读全部

参考资料

保持联系,关注我获取更多内容

阅读需 11 分钟

供应商将你的模型标识符重定向到特定租户的微调模型,而其他人使用的却是基础模型

如果将 LLM 模型标识符视为权重的名称而非路由决策的标签,那么供应商可能会在评估套件仍保持“绿色”通过状态时,静默地将你的租户从微调模型切换回基础模型,导致客户最先察觉到问题。

insider
llm
阅读需 9 分钟

当源数据已更改,你的 Prompt 缓存仍在提供旧的工具执行结果

Prompt 缓存将易变的工具结果转化为你与模型提供商之间的一项隐藏 TTL 契约。当缓存 TTL 的生命周期超过了数据的有效期时,你的 Agent 就会在享受缓存命中率的同时,自信地提供“昨天的真相”。

insider
ai-engineering
阅读需 11 分钟

长出胳膊和腿的缓存提示词前缀

六个月前你的提示词前缀是 4k tokens,几乎可以摊销到免费。今天它是 11k tokens,你的缓存命中率是 31%,没有人能指出是哪个 PR 干的。

insider
llm
阅读需 9 分钟

主权崩塌:记录你的 Prompt 究竟去了哪里

应用日志显示请求发送到了 eu-west-1,但提供商在故障转移期间将其路由到了美国和新加坡。构建单次请求的主权路径,将审计转化为可查询的资产。

insider
llm
阅读需 10 分钟

在写第一个 Prompt 之前,先设计好你的 Agent 状态机

先写 Prompt 再拼接逻辑的直觉,会导致 agent 在简单测试中正常运行,却在生产环境中神秘失败。先设计状态机,会改变一切。

insider
ai-engineering