跳到主要内容

LLM 应用中的 SSE vs WebSockets vs gRPC Streaming:那个稍后会让你头疼的协议抉择

· 阅读需 13 分钟
Tian Pan
Software Engineer

大多数构建 LLM 功能的团队选择流式协议的方式就像选择字体一样:快速、不加思索,然后忍受由此带来的后果多年。这种选择第一次让你踩坑通常是在生产环境中——比如 CloudFlare 524 超时导致你的 SSE 流损坏,WebSocket 服务器在持续负载下发生内存泄漏,或者 gRPC-Web 集成在单元测试中表现良好,但在客户端需要向上游发送消息时静默失败。协议决定了你的故障模式。基于基准吞吐量进行选择是一个错误的切入点。

每个主要的 LLM 提供商——OpenAI、Anthropic、Cohere、Hugging Face——都通过 Server-Sent Events (SSE) 流式传输 Token。这一事实是一个强有力的先验理由,但并不是因为 SSE 快。而是因为 SSE 是无状态的,能与 HTTP 基础设施轻松兼容,且其故障模式是可预测的。问题的关键在于你的应用是否有某些需求迫使你偏离这条路径。

各协议的实际失效模式

在 LLM 应用产生的消息速率下,SSE、WebSockets 和 gRPC 流式传输的延迟基准测试结果基本一致。在每秒 50–100 个 Token 的情况下,你远未达到任何这些协议的吞吐量上限。真正重要的其实是故障模式特征。

SSE 在代理层失效。当 Nginx 或 CloudFlare 代理位于你的服务器和客户端之间时,其默认行为是缓冲响应,直到收到 Content-Length 响应头或连接关闭。由于 SSE 两者都不发送,代理会持续等待,你的客户端便看不到任何内容,第一字节直到整个生成完成后才到达——这让流式传输变成了批处理交付。解决方法很明确:在 Nginx 中设置 proxy_buffering off,添加 X-Accel-Buffering: no 作为响应头,并且每 15–30 秒发送一次心跳消息(data:\n\n)以防止空闲超时。如果任何静默时间超过 100 秒,CloudFlare 默认的超时机制会直接在你的事件流中注入一个 HTML 错误页面。这是真实的生产故障,而非假设。

WebSockets 在背压层(backpressure layer)失效。该协议没有内置机制让服务器检测客户端消费消息的速度是否慢于发送速度。如果一个处于较差移动网络连接下的客户端每秒只能处理 1KB,而服务器以 100KB/s 的速度推送,服务器的 TCP 发送缓冲区就会填满,操作系统套接字发送队列会增长,每个客户端占用的内存会无限制累积。繁忙服务器上的单个慢速客户端可能会导致与其共享同一网络接口的所有其他客户端出现可见的延迟激增。与 SSE 不同(浏览器最终会在 HTTP 层进行节流),WebSocket 的实现将这一责任完全推给了应用层。你需要主动监控 socket.bufferedAmount,实现每个客户端的写入超时,并限制缓冲区大小——否则你在高负载下必然会遇到问题。

gRPC 流式传输在浏览器边界失效。HTTP/2 内置的流量控制意味着背压在服务间通信中能得到正确处理。但浏览器无法表现双向 gRPC 流:Fetch API 会在发送前缓冲整个请求体,且浏览器的 HTTP/2 实现没有暴露 gRPC 所需的钩子。截至 2026 年,任何稳定版本的浏览器都不支持客户端流式传输和双向流式传输。一元调用(Unary calls)和服务器流式传输是可行的,但如果你设计的方案要求客户端在流式响应期间向上游发送消息,那么在单元测试通过后,你依然会撞上这堵墙。

大多数团队意识到得太晚的浏览器限制

在 HTTP/1.1 下,浏览器对单个域名的并发连接数限制为 6 个。这包括 EventSource (SSE) 连接。打开第二个从相同域名流式传输内容的标签页,你就已经消耗了 6 个槽位中的 2 个。这并非浏览器漏洞——这是 Chrome 和 Firefox 标记为“不予修复” (won't fix) 的规范决策,因为 HTTP/2 使得该问题不再相关。在 HTTP/2 下,多路复用意味着你可以通过单个连接有效地支持无限数量的并发 SSE 流。

实际问题在于:许多团队在开发期间使用 HTTP/1.1,在生产环境中当智能体(Agents)运行多个并行任务时才发现 6 个连接的上限,然后为了规避它而切换到 WebSocket——却没有意识到 HTTP/2 本可以解决这个问题。如果你使用的是现代基础设施(Vercel、CloudFlare、AWS ALB)并正在通过 HTTPS 提供服务,你可能已经在使用 HTTP/2 了。如果不确定,请检查一下。

WebSocket 没有相应的连接限制。gRPC-Web 也没有相应的连接限制。但为了逃避 HTTP/1.1 的限制而求助于这两者,实际上是在解决错误的问题。

边缘代理会破坏什么以及如何破坏

典型的 LLM 服务栈至少有两个代理层:基础设施边界内的反向代理(如 Nginx),以及其前置的 CDN 或边缘层(如 CloudFlare)。两者都会以各自的方式破坏流式传输。

Nginx 默认会缓冲上游响应。对于 SSE,你需要:

proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_read_timeout 86400s;

proxy_read_timeout 的重要性超出了大多数人的预期。它定义了 Nginx 在关闭连接前等待上游服务器发送数据的时间。默认值为 60 秒。如果一个缓慢的 LLM 生成过程在响应中途停顿了 61 秒,连接将被静默关闭。

对于 WebSocket,必须传递 Upgrade 响应头:

proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;

对于 gRPC,Nginx 提供了原生支持,但需要其特定的指令:

grpc_read_timeout 86400s;
grpc_send_timeout 3600s;

CloudFlare 对看似空闲的连接强制执行 100 秒的超时限制。服务器在计算复杂的智能体任务时保持 HTTP 连接开启,就会触发此超时。它返回的错误是原始 HTML——当这些内容被插入到正在进行的 SSE 事件流中时,会破坏所有期望 data: 前缀行的事件解析器。缓解措施要么是心跳消息,要么是迁移到 CloudFlare Workers,后者原生支持 SSE 且没有代理超时限制。

企业网络使情况更加复杂。公司防火墙通常会对所有出站流量进行代理。如果不识别没有 Content-Length 的分块传输编码(chunked transfer encoding),代理可能会对整个 SSE 流进行排队。长连接的 WebSocket 可能会被具有空闲会话限制的防火墙断开。从公司代理后连接的智能体将经历看起来像服务器错误的连接中断。

没人预料到的重连风暴

WebSocket 连接会中断。移动端用户切换网络、笔记本电脑休眠、公司 VPN 重新连接。当连接中断时,客户端会重新连接。在只有几百个用户的小型应用中,这是无感的。但在拥有数万或数十万并发用户的大规模场景下,一个相关的重连事件——如短暂的服务器重启、DNS 抖动或 CDN 闪烁——可能会产生所谓的“惊群效应”(thundering herd)。

每次 WebSocket 重连都需要经过 TCP 握手、TLS 协商和 HTTP 升级(upgrade)的往返。TLS 协商是 CPU 密集型的:一个现代服务器核心每秒大约可以处理 2,000 次 TLS 握手。一台 8 核服务器在面对 100,000 个同时进行的重连时,在处理任何应用流量之前,需要超过 6 秒的纯 TLS 计算。在这 6 秒内,已连接的客户端会感到延迟增加。新的重连尝试会排队。这种效应会产生连锁反应。

解决方法是在客户端使用带有抖动(jitter)的指数退避(exponential backoff)。这不仅仅是可选的基础设施优化,而是任何处理重连的 WebSocket 系统的正确性要求。SSE 也能从中受益(浏览器的 EventSource 具有内置重连功能,但没有抖动),但惊群效应在 SSE 中不那么严重,因为 SSE 连接是无状态的,且重新建立的成本更低。

浏览器中的 gRPC:诚实的现状

gRPC-Web 并不是真正的 gRPC。它是一个将 gRPC 适配到浏览器安全 HTTP 的转换层。对于一元调用(unary calls)和服务器流式传输,它表现良好。但对于客户端流式传输和双向流式传输,如果没有 Envoy 代理来中介连接,它在浏览器中无法工作。

WHATWG Fetch 规范中有一个 duplex: 'full' 标志,它可以使 Fetch API 能够同时发送和接收——这将解决双向流式传输的障碍。虽然它存在于规范中,但目前还没有稳定的浏览器支持它。

对于希望现在就在浏览器中使用 gRPC 语义的团队来说,Buf 的 Connect 协议是一个务实的替代方案。它使用标准的 HTTP,而不是 gRPC 特定于 HTTP/2 的传输格式,使其无需 Envoy 即可兼容,可在浏览器开发者工具中调试,并且能以更小的客户端包体积支持服务器流式传输。如果你的团队正在构建一个需要流式传输且已经拥有 gRPC 服务的浏览器应用,那么在引入 Envoy 依赖项之前,Connect 值得评估。

对于没有浏览器参与的服务间通信,gRPC 流式传输是正确的选择。HTTP/2 流控可以处理背压(backpressure)。二进制 Protocol Buffers 编码非常高效。连接复用意味着许多并发的逻辑流可以共享单个物理连接。对于微服务架构中服务间延迟至关重要的场景,gRPC 通常能将延迟从数百毫秒(REST 轮询)降低到 50ms 以下。

如何实际进行选择

决策树比大多数文章描述的要短。

如果你的服务器向浏览器客户端流式传输 Token,且客户端只需要接收,请从 SSE 开始。这是 90% 的 LLM 应用场景。显式地配置你的代理,添加心跳消息,并确保你使用的是 HTTP/2。SSE 的简单性是一个优势:服务器无状态、无需会话管理、支持 Serverless 函数和边缘运行时。

当客户端在活跃的生成过程中需要发送信号(如取消、流中途转向、工具调用确认、智能体移交协调)时,请转向 WebSocket。握手开销(约 150ms)是成本,连接状态管理是运维负担。在发布之前,请实现背压监控和带有抖动的指数退避重连。

在微服务架构的服务间流式传输中,请使用 gRPC 流式传输。不要在没有 Envoy 的情况下在浏览器中使用 gRPC-Web,如果你需要具有双向语义的浏览器客户端,请评估 Connect 协议。

避免架构错配。 为了逃避 SSE 的 HTTP/1.1 连接限制而使用 WebSocket 可能是错误的修复方法。当你需要浏览器双向通信时使用 gRPC-Web 则会在生产环境中失败。你遇到的失败取决于你对协议能力的假设与该协议在你基础设施中的实际表现之间的错配。

你的失败模式告诉了你什么

如果用户报告 LLM 响应是全量显示而非流式显示:你的代理(proxy)正在缓冲。在更改协议之前,先修复代理配置。

如果你的 WebSocket 服务器内存随负载增加而持续增长,且重启能暂时解决:说明你为慢速客户端积累了无限制的写入缓冲区。请添加缓冲区上限和针对每个客户端的写入超时。

如果你的 SSE 流式传输在开发环境下正常,但在公司代理之后失效:请添加心跳消息,并调查代理是否需要 Content-Length。某些公司代理会完全拒绝分块编码(chunked encoding)。

如果你正在构建一个用户需要取消正在运行的工具调用的智能体工作流:SSE 将无法工作,因为你需要一个从客户端到服务器的上行通道。这是使用 WebSocket 的正当理由。如果你正在构建同样的智能体工作流,但用户可以等到智能体提议操作后再处理:SSE 配合一个用于批准/拒绝的独立 HTTP 接口就能工作得很好。

协议不是产品本身,它是支撑产品的承重基础设施。能够做好这个决策的团队,是那些在投入使用前测试整个技术栈(反向代理、CDN、移动网络状况)的团队。而决策失误的团队,只有在用户发现协议假设被违背时,才会意识到该协议对网络环境做了哪些假设。

总结

SSE 在 LLM 流式传输中占据主导地位,因为它契合了 LLM 生成的本质:服务器向等待中的客户端推送 token,且在流传输期间没有上行消息。WebSocket 的双向性只有在客户端需要在生成过程中进行交互时才真正必要——这在智能体(agent)工作流中越来越常见,但并非普遍需求。对于服务间通信,如果强类型契约和流控比浏览器兼容性更重要,那么 gRPC 流式传输则是正确选择。

协议本身很少导致生产环境故障。代理配置、重连行为、背压(backpressure)处理以及协议之下的底层基础设施假设才是导致故障的原因。选择符合你实际通信模式的最简协议,然后加固周边的基础设施。规范本身是最简单的部分。

References:Let's stay in touch and Follow me for more thoughts and updates