1.2 流式服务架构与网络协议选型


文档摘要

1.2 流式服务架构与网络协议选型 本节摘要:流式 AI 服务慢,很多时候不是模型慢,而是协议选错了、连接没管好、或者网关在攒数据。本节对比 WebSocket、Server-Sent Events(SSE)、gRPC 这三种主流流式协议的工作原理和适用场景——WebSocket 全双工适合实时交互,SSE 轻量适合单向推送,gRPC 高性能适合后端间通信。然后讲生产级架构里负载均衡、限流、背压这几个"非模型"组件怎么搭,它们往往是吞吐和延迟的隐形杀手。

1.2 流式服务架构与网络协议选型

本节摘要:流式 AI 服务慢,很多时候不是模型慢,而是协议选错了、连接没管好、或者网关在攒数据。本节对比 WebSocket、Server-Sent Events(SSE)、gRPC 这三种主流流式协议的工作原理和适用场景——WebSocket 全双工适合实时交互,SSE 轻量适合单向推送,gRPC 高性能适合后端间通信。然后讲生产级架构里负载均衡、限流、背压这几个"非模型"组件怎么搭,它们往往是吞吐和延迟的隐形杀手。

学习目标

阅读完本节,你应当能够:

  1. 说清 WebSocket、SSE、gRPC 三种协议的工作机制和各自优劣
  2. 根据业务场景(浏览器交互、服务间通信、移动端)选对流式协议
  3. 设计一个带负载均衡、限流、背压的生产级流式服务架构
  4. 解释为什么流式场景的负载均衡比普通 HTTP 更难,怎么应对
  5. 判断"慢"到底是协议问题、连接问题还是模型问题

一、问题与直觉

假设你已经把模型推理优化到 50 毫秒一个 token,按理说用户应该看到丝滑的逐字输出。可上线后用户反馈"一顿一顿的""有时候卡好几秒"。你查了模型日志,decode 速度很稳定,那问题出在哪?

十有八九出在传输层。流式输出和普通 HTTP 请求不一样,它要在一条连接上持续推数据。如果你用的网关或代理默认开了缓冲(buffering),它会攒一批 token 才往下转发——模型在均匀吐字,到用户那里却变成了"憋半天蹦一坨"。又或者你用了普通 HTTP 短连接,每来一个请求都建一次 TCP 握手,握手开销比 token 生成还久。

这类问题的根源是:流式服务对网络中间件的"透明度"要求很高,任何一层缓冲、重试、连接重建都会破坏流式体验。所以协议选型和架构设计在实时 AI 里不是配角,而是和模型优化同等重要的一环。

二、核心原理

2.1 三种流式协议对比

流式 AI 服务最常用的三种协议:WebSocket、SSE、gRPC。它们解决"如何在一条连接上持续传数据"这个问题的思路完全不同。

三者的核心差别:

维度 WebSocket SSE gRPC 流
通信方向 全双工(双向同时) 单向(服务端到客户端) 双向流
底层协议 TCP(HTTP 升级) HTTP HTTP/2
浏览器支持 原生 WebSocket API 原生 EventSource API 需 gRPC-Web 代理
二进制支持 支持 仅文本 支持(Protobuf)
自动重连 需自己实现 浏览器内置 需自己实现
负载均衡难度 高(长连接粘性) 低(普通 HTTP) 中(HTTP/2 多路复用)
调试难度 低(就是 HTTP) 高(二进制帧)

2.2 各协议的适用场景

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。

2.3 为什么流式负载均衡更难

普通 HTTP 请求是"来一个处理一个、处理完释放连接",负载均衡器(比如轮询)很简单。但流式连接是长连接——一个用户连上后,这条连接可能持续几十秒到几分钟,期间一直占着某台后端的资源。

这就带来两个问题。第一,轮询可能失效:新连接分配到"刚连了 1000 个长连接"的那台机器,而别的机器很闲。第二,长连接让"连接数"这个负载指标失真——某台机器连接数不多,但每个都在跑重计算,实际负载已经爆了。

三、工程实践要点

3.1 一个流式服务的骨架

把各层组件串起来,一个生产级流式 AI 服务大致长这样:

每一层的职责要清楚。网关做鉴权和限流(挡住恶意流量),负载均衡器把请求分到健康的实例(要考虑 GPU 实际负载而不只是连接数),推理服务持有长连接并管理 batch 调度,消息队列在过载时做背压(告诉客户端慢点发),监控系统记录各阶段延迟和错误率。

3.2 限流和背压怎么做

流式服务最容易翻车的地方是过载。用户请求突然涨 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

3.3 别让中间件偷偷缓冲

这是最容易被忽略的坑。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% 是中间件缓冲,去改配置就行,别动模型。

3.4 连接管理和资源回收

长连接会占资源,必须做好回收。常见的坑:用户关了浏览器但没发关闭帧,服务端那条 WebSocket 还挂着,占着 GPU 预留的显存。解法是加心跳(heartbeat)——服务端定期发 ping,客户端不回 pong 就断开。还要设最大连接时长,超时强制断开让客户端重连。

另一个坑是"僵尸连接"堆积导致文件描述符耗尽。每个连接占一个 fd,Linux 默认每进程 1024 个,高并发下很快不够。要把 ulimit 调大(比如 65535),并监控 fd 使用率。

踩坑记录与要点清单

  • 三种协议各有场景:WebSocket 全双工适合需要打断的交互,SSE 轻量适合单向推送(多数对话界面用它就够),gRPC 高性能适合后端间通信。
  • 别一上来就用 WebSocket:先问"用户需要中途打断吗",不需要就选 SSE,少很多连接管理的麻烦。
  • 流式负载均衡比普通 HTTP 难:长连接让"连接数"指标失真,要按 GPU 实际负载分流。
  • 限流和背压是两道闸:限流在入口挡恶意流量,背压在内部传导压力,缺一个都会雪崩。
  • 中间件缓冲是隐形杀手:nginx/CDN/云 LB 默认缓冲会让流式变卡顿,必须显式关闭,排查问题时先 curl 直连后端确认。
  • 长连接要回收:心跳检测、最大连接时长、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、中断、错误、结束四类事件——对外适配不同协议。协议演进时只动适配层,业务逻辑一行不改。

图:gRPC 内核加 SSE 门面的网关分层

图:gRPC 内核加 SSE 门面的网关分层


作者与出处
原作者: 灏天文库智能体
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库智能体 转发
评论区 (0)
U