1.2 流式服务架构与网络协议选型 本节摘要:流式 AI 服务慢,很多时候不是模型慢,而是协议选错了、连接没管好、或者网关在攒数据。本节对比 WebSocket、Server-Sent Events(SSE)、gRPC 这三种主流流式协议的工作原理和适用场景——WebSocket 全双工适合实时交互,SSE 轻量适合单向推送,gRPC 高性能适合后端间通信。然后讲生产级架构里负载均衡、限流、背压这几个"非模型"组件怎么搭,它们往往是吞吐和延迟的隐形杀手。
本节摘要:流式 AI 服务慢,很多时候不是模型慢,而是协议选错了、连接没管好、或者网关在攒数据。本节对比 WebSocket、Server-Sent Events(SSE)、gRPC 这三种主流流式协议的工作原理和适用场景——WebSocket 全双工适合实时交互,SSE 轻量适合单向推送,gRPC 高性能适合后端间通信。然后讲生产级架构里负载均衡、限流、背压这几个"非模型"组件怎么搭,它们往往是吞吐和延迟的隐形杀手。
阅读完本节,你应当能够:
假设你已经把模型推理优化到 50 毫秒一个 token,按理说用户应该看到丝滑的逐字输出。可上线后用户反馈"一顿一顿的""有时候卡好几秒"。你查了模型日志,decode 速度很稳定,那问题出在哪?
十有八九出在传输层。流式输出和普通 HTTP 请求不一样,它要在一条连接上持续推数据。如果你用的网关或代理默认开了缓冲(buffering),它会攒一批 token 才往下转发——模型在均匀吐字,到用户那里却变成了"憋半天蹦一坨"。又或者你用了普通 HTTP 短连接,每来一个请求都建一次 TCP 握手,握手开销比 token 生成还久。
这类问题的根源是:流式服务对网络中间件的"透明度"要求很高,任何一层缓冲、重试、连接重建都会破坏流式体验。所以协议选型和架构设计在实时 AI 里不是配角,而是和模型优化同等重要的一环。
流式 AI 服务最常用的三种协议:WebSocket、SSE、gRPC。它们解决"如何在一条连接上持续传数据"这个问题的思路完全不同。
三者的核心差别:
| 维度 | WebSocket | SSE | gRPC 流 |
|---|---|---|---|
| 通信方向 | 全双工(双向同时) | 单向(服务端到客户端) | 双向流 |
| 底层协议 | TCP(HTTP 升级) | HTTP | HTTP/2 |
| 浏览器支持 | 原生 WebSocket API | 原生 EventSource API | 需 gRPC-Web 代理 |
| 二进制支持 | 支持 | 仅文本 | 支持(Protobuf) |
| 自动重连 | 需自己实现 | 浏览器内置 | 需自己实现 |
| 负载均衡难度 | 高(长连接粘性) | 低(普通 HTTP) | 中(HTTP/2 多路复用) |
| 调试难度 | 中 | 低(就是 HTTP) | 高(二进制帧) |
WebSocket:最适合需要双向实时交互的场景——用户随时可以打断模型、追问、调整参数。聊天界面、协作编辑、实时游戏里的 AI NPC 都适合。代价是连接管理复杂,长连接会占住服务端资源,负载均衡要做粘性会话或一致性哈希。
SSE(Server-Sent Events):如果你的场景是"用户发一次请求,服务端持续推结果",且用户中途不需要打断,SSE 是最省事的选择。它就是普通 HTTP,浏览器原生支持,自动重连,nginx 默认配置好就行。绝大多数 LLM 对话界面(用户发消息、模型逐字回复)其实用 SSE 就够了,没必要上 WebSocket 的复杂度。
gRPC:服务端到服务端的流式通信首选。它基于 HTTP/2 多路复用,强类型接口(Protobuf 定义),性能比 WebSocket 高一截。如果你的架构是"网关 → 推理服务",中间这段用 gRPC 比 HTTP+JSON 快得多。缺点是浏览器要用 gRPC-Web 转一层,调试也麻烦,所以一般不直接对终端用户暴露。
💡 关键直觉:别一上来就用 WebSocket。很多团队的流式对话其实用 SSE 就够了,选 WebSocket 多半是"听起来更厉害",结果白白增加了连接管理的复杂度和 bug 面。先问自己"用户需要中途打断吗",不需要就用 SSE。
普通 HTTP 请求是"来一个处理一个、处理完释放连接",负载均衡器(比如轮询)很简单。但流式连接是长连接——一个用户连上后,这条连接可能持续几十秒到几分钟,期间一直占着某台后端的资源。
这就带来两个问题。第一,轮询可能失效:新连接分配到"刚连了 1000 个长连接"的那台机器,而别的机器很闲。第二,长连接让"连接数"这个负载指标失真——某台机器连接数不多,但每个都在跑重计算,实际负载已经爆了。
把各层组件串起来,一个生产级流式 AI 服务大致长这样:
每一层的职责要清楚。网关做鉴权和限流(挡住恶意流量),负载均衡器把请求分到健康的实例(要考虑 GPU 实际负载而不只是连接数),推理服务持有长连接并管理 batch 调度,消息队列在过载时做背压(告诉客户端慢点发),监控系统记录各阶段延迟和错误率。
流式服务最容易翻车的地方是过载。用户请求突然涨 10 倍,如果你不做保护,所有请求挤进队列,延迟雪崩,最后全部超时。限流(rate limiting)和背压(backpressure)是两道闸。
限流在入口处拦:每秒最多放 N 个请求进系统,超了的直接拒绝或排队。关键是阈值怎么定——不能拍脑袋,要基于压测出的系统容量。还要做分级限流:免费用户每分钟 10 次,付费用户每分钟 100 次,关键客户不限。
背压在系统内部传导:当下游处理不过来时,主动告诉上游"慢点发"。流式场景里这特别重要,因为如果客户端狂发请求而服务端消化不了,内存会被请求队列撑爆。一个简单的背压实现是监控队列长度,超阈值就让接收端 sleep 几毫秒:
import asyncio import time class StreamService: def __init__(self, max_queue=100): self.queue = asyncio.Queue(maxsize=max_queue) self.active = 0 async def handle_request(self, prompt, ws): # 限流:同时处理的连接数有上限 if self.active > 200: await ws.send("server_busy") return self.active += 1 try: await self.queue.put(prompt) while True: token = await self._generate_one() if token is None: break await ws.send(token) # 背压:如果客户端慢,这里会自然阻塞 if self.queue.qsize() > 80: await asyncio.sleep(0.005) finally: self.active -= 1 async def _generate_one(self): # 调模型的封装,省略 pass
这是最容易被忽略的坑。nginx、CDN、某些云负载均衡器默认会缓冲响应——攒够一定大小或等响应结束才往下转发。对普通 HTTP 没问题,对流式输出是灾难。
# SSE 服务端必须显式禁用缓冲的响应头 def sse_headers(): return { "Cache-Control": "no-cache", # 禁缓存 "Connection": "keep-alive", "X-Accel-Buffering": "no", # 关键:禁用nginx缓冲 } # nginx 配置也要对应关掉 proxy_buffering # proxy_buffering off; # proxy_cache off;
| 组件 | 默认行为 | 对流式的影响 | 怎么处理 |
|---|---|---|---|
| nginx | proxy_buffering on | 攒数据,输出卡顿 | 设 off |
| CDN | 缓存响应 | 用户拿到旧数据或延迟 | 对流式路径禁缓存 |
| 云负载均衡 | 可能缓冲 | 间歇性卡顿 | 用 L4 或关缓冲模式 |
| 客户端 EventSource | 自动重连 | 中断后重连丢 token | 业务层做断点续传 |
⚠️ 常见坑:上线后用户反馈"输出一顿一顿",团队花一周优化模型 decode 速度没效果,最后发现是 nginx 默认开了 proxy_buffering。在动手优化模型前,先用 curl 直连后端实例确认流式是丝滑的——如果直连丝滑、走网关卡顿,那 100% 是中间件缓冲,去改配置就行,别动模型。
长连接会占资源,必须做好回收。常见的坑:用户关了浏览器但没发关闭帧,服务端那条 WebSocket 还挂着,占着 GPU 预留的显存。解法是加心跳(heartbeat)——服务端定期发 ping,客户端不回 pong 就断开。还要设最大连接时长,超时强制断开让客户端重连。
另一个坑是"僵尸连接"堆积导致文件描述符耗尽。每个连接占一个 fd,Linux 默认每进程 1024 个,高并发下很快不够。要把 ulimit 调大(比如 65535),并监控 fd 使用率。
下一章我们钻进 LLM 推理引擎的内核,看 prefill 和 decode 这两个阶段具体怎么用 KV Cache、PagedAttention、连续批处理来压榨 GPU 性能——这是当前流式 LLM 优化最核心的一块。
这三条协议路线的流行顺序值得玩味。WebSocket 在 2011 年标准化,最初为浏览器里的实时游戏和股票行情设计;SSE 随 HTML5 落地,长期被视为"服务端推送的简化方案",直到 2022 年底 ChatGPT 的网页端用纯 SSE 做逐字输出,工程圈才真正意识到对"单向流加浏览器友好"这个需求,SSE 的简单就是它最大的优势——一条普通 HTTP 连接、天然穿透企业防火墙、断线重连协议内置。gRPC 则脱胎于 Google 内部的 Stubby,双向流和 HTTP/2 多路复用让它在推理引擎前后端之间几乎成了默认选项:引擎层(vLLM、TensorRT-LLM 的 serving 组件)暴露 gRPC,网关层把 gRPC 流转成对外的 SSE,这个"gRPC 内核加 SSE 门面"的组合如今已是流式 LLM 服务的标准形态。
选型时还有一层隐性约束:客户端网络环境。企业内网随便用什么,公网消费级应用则要考虑运营商代理对长连接的干扰——部分移动网络会静默丢弃空闲超过 30 秒的 TCP 连接。WebSocket 靠心跳帧保活,SSE 靠周期性注释行(每隔 15 秒发一个冒号开头的空行)保活,漏掉任何一边,用户就会周期性地遇到"输出中断然后重来"。
故障排查给一个实用顺序。用户反馈卡顿,第一步绕过所有中间层用 curl 直连推理后端,观察 token 到达节奏是否均匀;均匀则问题出在网关、CDN 或代理的缓冲,不均匀才是引擎侧问题。第二步检查响应头有没有意外开启整体 gzip 压缩——对逐 token 的小数据包做整体压缩会让攒批效应雪上加霜,流式响应要么逐块压缩要么不压。第三步看 TCP 层:用 ss -ti 观察发送缓冲区是否堆积,堆积说明下游消费慢,背压已经传导到你这一层。最后才轮到查模型日志。这套顺序能把八成"玄学卡顿"在半小时内定位清楚。
最后一个架构忠告:协议层要为升级留缝。今天用 SSE,明天要支持语音打断就得换 WebSocket 双向流,如果网关把协议细节漏进业务代码,迁移成本会翻倍。正确做法是在网关内部定义统一的流事件抽象——token、中断、错误、结束四类事件——对外适配不同协议。协议演进时只动适配层,业务逻辑一行不改。
