
实时 AI 这个词最近被聊得很多但大多数讨论都停在能实时对话能生成视频头像这类表层体验上。Gemini Live Avatar 这类产品确实把实时交互的观感拉满了可如果你是一个做文本工作流、做后台系统、做数据管道的工程师看完热闹之后大概率会有一个疑问这玩意儿跟我每天写的业务代码到底有什么关系我真正关心的不是聊天窗口里那个会动的头像而是当模型开始以流的方式持续产出内容时我的文本处理链路该怎么接、怎么路由、怎么保证不丢不乱。这篇就从这个角度切入聊聊 RelayRouter 在文本工作流里到底站在什么位置以及为什么 WebSocket 这类长连接机制会成为绕不开的基础设施。不管你是刚接触实时推送的前端还是已经在写 Django 后台推送的老手这里面的取舍逻辑和踩坑经验应该都能对上你的场景。1. 从 Gemini Live Avatar 的表象往下挖一层1.1 实时 AI 真正改变的不是界面而是数据流形态很多人第一次看到 Gemini Live Avatar注意力全在那个能对口型、有表情的虚拟形象上。这很正常视觉冲击力最强的东西总是最先被记住。但如果你把注意力从界面上移开去看它背后的数据是怎么流动的会发现一个更本质的变化传统的请求-响应模式被打破了。过去的 AI 调用基本是这样的前端发一个完整的 prompt后端等模型把整段话生成完一次性返回前端再渲染。这个模式简单、好调试但代价是用户要盯着空白屏幕等好几秒。而实时 AI 把这个过程拆成了连续的 token 流或者音频帧流模型一边生成服务端一边往外推前端一边消费一边渲染。数据不再是一整块而是一条持续不断的河。这个变化听起来只是体验优化实际上它把整个工程链路的重心从计算挪到了传输和编排。因为一旦数据变成流你就必须回答一连串新问题这条流用什么协议传中途断了怎么办多个流之间怎么协调文本流和音频流怎么对齐这些问题在传统的请求-响应世界里根本不存在但在实时场景里全是硬骨头。1.2 文本工作流为什么也需要实时思维有人会说我又不做虚拟人我就处理文本实时跟我没关系。这个想法其实是个误区。文本工作流里实时的需求一点都不少只是它不像视频头像那么显眼。举几个很常见的场景。第一个是长文档的流式生成比如让模型写一份几千字的报告用户希望看到内容一点点冒出来而不是等半分钟突然刷出一整屏。第二个是后台任务的进度推送比如一个批量文本清洗任务跑在后台前端要实时知道处理到第几条了、有没有报错。第三个是多步骤 Agent 工作流一个任务要经过检索、摘要、改写、校对好几个环节每个环节的中间结果都需要及时反馈给用户或者下一个环节。这些场景的共同点是数据是持续产生的消费方需要尽快拿到而且往往有多个消费方或者多个处理阶段。这跟 Gemini Live Avatar 面对的底层问题是一模一样的只不过一个传的是音视频帧一个传的是文本片段。所以别被Avatar这个词骗了它展示的是一套通用的实时数据流架构文本工作流完全可以、也应该借鉴这套思路。1.3 RelayRouter 出现的背景流多了就需要调度当你的系统里只有一条流的时候怎么传都行直接开个连接怼过去就完事。但真实系统里往往同时存在好几条流用户 A 的对话流、用户 B 的文档生成流、后台任务的进度流、系统告警的通知流。这些流的优先级不一样、生命周期不一样、消费方也不一样。如果没有一个统一的调度层代码很快就会变成一团乱麻每个业务模块自己开连接、自己管心跳、自己处理重连重复代码一大堆出了问题还特别难排查。RelayRouter 这类组件要解决的就是这个问题——它站在流的中间负责把不同来源的数据流按规则路由到不同的消费方同时统一处理连接管理、心跳保活、断线重连这些脏活累活。你可以把它理解成一个流的交通枢纽。上游是各种数据生产者模型输出、后台任务、数据库变更下游是各种消费者前端页面、其他微服务、消息队列RelayRouter 在中间决定哪条流该往哪走、走哪条道、堵了怎么办。这个定位决定了它在文本工作流里不是一个可有可无的装饰而是当流多起来之后必然会出现的那个中间层。2. RelayRouter 在文本链路里到底承担什么角色2.1 它不是模型也不是前端而是中间的分发器先把定位说清楚避免概念混淆。RelayRouter 既不是跑模型推理的地方也不是渲染界面的地方它就是一个纯粹的分发和调度组件。模型负责生成内容前端负责展示内容RelayRouter 负责把内容从前者可靠地送到后者。这个定位有点像快递分拣中心。商家模型把包裹数据交给分拣中心分拣中心根据地址路由规则决定这个包裹该发往哪个片区消费方然后安排运输连接管理。分拣中心自己不生产包裹也不拆包裹看内容它只关心这个包裹该去哪、怎么去最稳。理解这一点很重要因为它决定了你该怎么用它。如果你指望 RelayRouter 帮你做文本清洗、格式转换、内容审核那是用错了地方这些应该在它上游或者下游的专门环节做。它的价值在于把传输这件事从业务逻辑里彻底剥离出来让业务代码只关心我要发什么而不用操心怎么发才不丢。2.2 路由规则决定了文本流的走向RelayRouter 的核心能力是路由而路由的本质是一套根据什么条件把数据发到哪的规则。在文本工作流里常见的路由维度有这么几类。第一类是按会话或用户路由。用户 A 的流只能发给用户 A 的页面绝不能串到用户 B 那里去。这是最基本的安全边界实现上通常靠连接建立时绑定的身份标识来做匹配。第二类是按消息类型路由。比如一条流里既有正文内容又有状态通知还有错误信息不同类型的消息可能需要走不同的处理路径。正文直接推给前端渲染状态通知可能还要写一份到日志系统错误信息则要触发告警。第三类是按优先级路由。高优先级的流比如用户正在等待的交互响应走快速通道低优先级的流比如后台批量任务的进度可以适当缓冲、批量发送避免占用太多连接资源。第四类是按处理阶段路由。在多步骤工作流里一个任务的输出可能是下一个任务的输入RelayRouter 需要知道当前这条流处于哪个阶段该把它交给哪个下游处理器。这四类规则在实际系统里往往是组合使用的。设计路由规则的时候有个经验规则要尽量简单、可预测能用配置表达的别写死在代码里。因为路由规则一旦复杂到需要读代码才能理解后面维护的人会非常痛苦出问题也很难定位到底是哪条规则匹配错了。2.3 和直接开 WebSocket 相比多这一层值不值这是很多人会纠结的问题我直接在前端和后端之间开个 WebSocket 不就行了为什么要多引入一个 RelayRouter答案取决于你的系统复杂度。如果你的系统只有一条流、一个消费方、逻辑简单那确实没必要直接开 WebSocket 最省事。但一旦出现下面这些情况中间层的价值就体现出来了。场景特征直接开 WebSocket引入 RelayRouter流的数量单条简单多条来源多样消费方单一前端多个前端、多个微服务重连逻辑各写各的重复统一处理一处维护路由需求几乎不需要按用户、类型、优先级分发排查难度连接少时还好集中管理日志统一扩展性加一条流改一堆代码加规则即可从表里能看出来RelayRouter 的价值不是能不能做到而是做到之后好不好维护。它把散落在各处的连接管理、心跳、重连、路由逻辑收敛到一个地方短期看是多了一层长期看是省了无数重复代码和排查时间。我的经验是当你的系统里同时活跃的流超过三条或者消费方超过两个就该考虑上这一层了。3. WebSocket 为什么是文本流推送的主力协议3.1 从轮询到长连接的演进逻辑要理解 WebSocket 为什么适合文本流推送得先看看它替代了什么。最早的时候前端想知道后台有没有新数据用的是轮询每隔几秒发一次请求问有数据吗。这个方式简单粗暴但问题很明显——大部分请求都是空手而归浪费带宽和服务器资源而且数据产生的时刻和前端拿到的时刻之间有个延迟最快也要等一个轮询周期。后来有了长轮询前端发一个请求服务器 hold 住不返回直到有数据或者超时才返回。这比定时轮询好一些但每次拿到数据后还得重新发请求连接反复建立销毁开销依然不小。WebSocket 的思路完全不同一次握手之后连接就一直保持着双方随时可以往对方那边发数据不需要每次重新建立连接。对于文本流这种数据不定时产生、需要尽快送达的场景这个特性几乎是量身定做的。数据一产生就能立刻推出去延迟可以做到毫秒级而且没有反复建连的开销。3.2 WebSocket 在文本场景下的几个关键特性WebSocket 有几个特性特别契合文本工作流值得单独拎出来说。首先是全双工。连接建立后前端和后端可以同时发数据互不阻塞。这在交互式文本场景里很有用比如用户一边输入新的指令模型一边推送上一轮的生成结果两边互不干扰。其次是消息边界清晰。WebSocket 是基于消息的每一帧就是一个完整的消息不像 TCP 那样是字节流需要自己切分。对于文本来说这省了很多事你发一条 JSON对面收到的就是一条完整的 JSON不用自己处理粘包拆包。第三是协议开销小。握手阶段有点开销但连接建立之后每一帧的头部开销很小比 HTTP 每次都要带一堆 header 划算得多。对于高频的小文本消息这个优势很明显。第四是浏览器原生支持。前端不用引入额外的库直接用浏览器自带的 WebSocket API 就能用这对前端来说门槛很低。3.3 什么时候 WebSocket 反而不如 SSE 合适WebSocket 虽好但不是所有文本推送场景都该用它。有一个经常被忽略的替代方案是 SSEServer-Sent Events。SSE 也是长连接但它是单向的——只能服务器往客户端推客户端不能通过这条连接往回发。如果你的场景是纯推送比如后台任务进度、日志流、模型生成结果的单向输出那 SSE 其实更合适。原因有几个SSE 基于普通 HTTP走的是标准端口和标准协议穿透各种中间层更省心SSE 自带断线重连机制浏览器会自动重连不用自己写SSE 的实现比 WebSocket 简单得多服务端就是保持一个 HTTP 响应不关闭持续往里写数据。反过来如果你的场景需要双向通信比如用户要能随时打断生成、发送新的指令那 WebSocket 更合适。所以选型的时候先问自己一句这条连接需要双向吗不需要的话SSE 往往是更省事的选择。很多团队一上来就用 WebSocket结果发现根本用不到双向能力白白增加了复杂度。4. 把文本流跑起来从后端推送到前端消费的完整链路4.1 后端推送的几种实现路径后端要往 WebSocket 连接里推数据实现方式取决于你用什么技术栈。这里说几种常见的路径。如果你用的是 Django原生是不支持 WebSocket 的因为 Django 的设计是同步的请求-响应模型。要在 Django 里做 WebSocket通常要引入 Channels 这个库它把 Django 的异步能力补齐了让你能写消费者consumer来处理 WebSocket 连接。Channels 的核心概念是 channel layer它负责在不同的进程之间传递消息这样你的后台任务进程才能把数据推给持有 WebSocket 连接的进程。如果你用的是 FastAPI 或者 Node.js那 WebSocket 支持是原生的写起来直接得多。FastAPI 里一个app.websocket装饰器就能定义一个端点Node.js 里用ws库几行代码就能跑起来。还有一种路径是用消息队列做中转。后台任务把数据丢进 Redis 的 pub/sub 或者 RabbitMQWebSocket 服务订阅这些频道收到消息后再推给对应的连接。这种方式解耦得最彻底后台任务完全不用关心有多少个 WebSocket 连接扩展性最好但架构也最复杂。选哪种取决于你的团队技术栈和系统规模。小项目直接在后端进程里推就行大项目建议走消息队列中转中间状态用 Redis 缓冲。4.2 前端消费文本流的代码骨架前端这边消费 WebSocket 文本流的核心就是监听onmessage事件拿到数据后更新界面。但真正写好有几个细节要注意。const ws new WebSocket(wss://your-host/stream); ws.onopen () { console.log(连接已建立); // 建立后可以发送身份认证或订阅指令 ws.send(JSON.stringify({ type: subscribe, channel: doc-123 })); }; ws.onmessage (event) { const msg JSON.parse(event.data); if (msg.type chunk) { // 追加文本片段 appendText(msg.content); } else if (msg.type done) { finalize(); } else if (msg.type error) { handleError(msg); } }; ws.onclose () { console.log(连接关闭准备重连); scheduleReconnect(); }; ws.onerror (err) { console.error(连接出错, err); };这段代码看着简单但有几个坑。第一onmessage里拿到的event.data是字符串如果后端发的是 JSON 就得 parseparse 失败要有兜底不然一条坏消息能把整个处理逻辑搞崩。第二文本片段追加的时候要考虑渲染性能如果一秒来几十条消息每条都触发一次 DOM 更新会卡通常要做一个缓冲攒一小批再统一渲染。第三onclose里一定要有重连逻辑而且重连要加退避不能一断开就疯狂重试那样会把服务器打垮。4.3 消息格式设计让文本流可扩展消息格式设计是个容易被忽视但影响深远的事。一开始可能只想着传文本内容但很快你就会发现还需要传状态、传错误、传元数据。如果一开始没设计好后面就是不断地打补丁。我的建议是统一用一个带type字段的 JSON 结构所有消息都走这个格式。比如{ type: chunk, seq: 42, content: 这是文本片段, timestamp: 1700000000000 }type用来区分消息种类seq是序号用来检测丢包和乱序content是实际内容timestamp用于调试和排序。这个结构简单但够用后面要加字段也不影响老客户端。序号这个字段特别值得强调。文本流最怕的就是丢片段或者乱序用户看到的内容就断了或者错位了。有了序号前端就能检测出我收到了 41 和 43缺了 42然后主动请求补发或者提示用户。没有序号的话丢了都不知道丢了。5. 心跳、重连、丢包文本流稳定性的三个硬骨头5.1 心跳机制为什么不能省WebSocket 连接建立之后如果长时间没有数据往来中间的网络设备路由器、负载均衡、防火墙可能会认为这条连接已经没用了悄悄把它断掉。而两端可能都不知道连接已经断了还在傻等。这就是所谓的连接但不接受信息的典型症状——前端以为连着后端以为连着实际上中间早就断了。心跳机制就是解决这个问题的。原理很简单客户端或者服务端定期发一个很小的消息ping对方收到后回一个pong。只要心跳还在正常往来就说明连接是活的。如果连续几个心跳周期都没收到回应就判定连接已死主动关闭并重连。心跳间隔的设置有个权衡。太短了浪费资源太长了发现断线不及时。经验值一般在 15 到 30 秒之间。如果你们的网络环境比较复杂可以适当缩短到 10 秒。另外要注意心跳消息本身要足够小别搞个几百字节的 JSON那样心跳本身就变成负担了。let heartbeatTimer null; let lastPong Date.now(); function startHeartbeat(ws) { heartbeatTimer setInterval(() { if (Date.now() - lastPong 45000) { // 超过三个周期没收到 pong判定断线 ws.close(); return; } ws.send(JSON.stringify({ type: ping })); }, 15000); } // 收到 pong 时更新 lastPong5.2 断线重连的正确姿势重连这件事写对了不难写错了很坑。最常见的错误是无脑重连一断开就立刻重连失败了立刻再试结果服务器一抖动成千上万个客户端同时疯狂重连直接把服务器打挂。这叫重连风暴。正确的做法是加退避。第一次断开等 1 秒重连失败了等 2 秒再失败等 4 秒以此类推直到一个上限比如 30 秒。这样即使服务器短暂不可用客户端的重连请求也是分散开的不会形成尖峰。let retryDelay 1000; const MAX_DELAY 30000; function scheduleReconnect() { setTimeout(() { connect(); retryDelay Math.min(retryDelay * 2, MAX_DELAY); }, retryDelay); } // 连接成功后重置 function onOpen() { retryDelay 1000; }还有一个细节是重连之后的状态恢复。如果断开期间有数据产生重连后怎么补上这就要靠前面说的序号了。前端重连后告诉后端我最后收到的序号是 42后端从 43 开始补发。没有这个机制的话断开期间的数据就永久丢了。5.3 丢包和乱序的检测与补救WebSocket 底层是 TCP理论上不会丢包也不会乱序。但实际系统里丢往往发生在应用层比如后端推的时候连接刚好断了这条消息就没发出去或者前端处理消息的时候抛异常了这条消息就被跳过了。检测的手段就是序号。前端维护一个期望序号每收到一条就比对发现跳号就说明中间丢了。补救的方式有两种一种是前端主动请求补发缺失的序号区间另一种是后端在重连时根据前端上报的最后序号自动补发。前者实时性好但实现复杂后者简单但依赖重连。乱序在单条 WebSocket 连接里基本不会出现但如果你的数据经过了消息队列中转多个消费者并行处理就可能乱序。这时候序号同样能派上用场前端按序号排序后再渲染就行。6. 实战中那些文档不会告诉你的坑6.1 代理和负载均衡对长连接的干扰WebSocket 连接要经过很多中间层CDN、负载均衡、反向代理。这些中间层很多是为短连接设计的对长连接支持不好。最常见的问题是空闲超时——代理觉得这条连接太久没数据了直接掐断。而你的心跳间隔如果比代理的超时时间还长就会出现心跳还没发连接已经被掐了的情况。解决办法是让心跳间隔小于中间层的最短超时时间。具体是多少得看你的部署环境一般负载均衡默认空闲超时是 60 秒那心跳设 30 秒以内就比较安全。另外有些代理需要显式配置才支持 WebSocket 的协议升级这个在部署的时候要确认不然连接根本建不起来。6.2 多实例部署下的连接归属问题当你的服务部署了多个实例前面挂了负载均衡问题就来了用户 A 的连接可能落在实例 1 上但产生用户 A 数据的后台任务可能跑在实例 2 上。实例 2 想推数据给用户 A但它手里没有用户 A 的连接推不了。这是多实例部署下最典型的坑。解决办法是引入一个共享的消息中转层比如 Redis 的 pub/sub。实例 2 把数据发到 Redis 的某个频道所有实例都订阅这个频道实例 1 收到后发现这是用户 A 的数据而用户 A 的连接在我这就推出去。这样不管数据在哪个实例产生都能送到持有连接的那个实例。这个方案的关键是频道设计。可以按用户 ID 分频道也可以用一个全局频道然后靠消息里的目标标识来过滤。前者更高效但频道多后者简单但每个实例都要处理所有消息。用户量大的话建议按用户分片比如按用户 ID 的哈希值分成若干频道。6.3 前端渲染性能别让高频消息拖垮页面文本流的特点是消息可能非常密集尤其是模型快速生成的时候一秒几十条很正常。如果每条消息都触发一次 DOM 操作页面很快就会卡顿。优化的思路是批量渲染。用一个缓冲区把收到的文本片段先攒起来用requestAnimationFrame或者定时器每隔一小段时间比如 50 毫秒统一渲染一次。这样 DOM 操作的频率就降下来了视觉上依然是流畅的。let buffer ; let scheduled false; function appendText(text) { buffer text; if (!scheduled) { scheduled true; requestAnimationFrame(() { document.getElementById(output).textContent buffer; buffer ; scheduled false; }); } }还有一个细节是自动滚动。文本不断追加用户希望看到最新的内容所以要自动滚到底部。但如果用户手动往上滚去看历史内容了你就不能强制把他拉回底部否则体验很糟。判断逻辑是只有当滚动条本来就在底部附近时才自动滚动。6.4 连接数暴涨时的资源控制每个 WebSocket 连接在服务端都要占资源一个 socket、一些内存、可能还有心跳定时器。连接数一多资源消耗就很可观。一个实例能撑多少连接取决于你的内存和文件描述符限制。文件描述符是个容易被忽略的点。Linux 默认的单进程文件描述符上限通常是 1024每个 WebSocket 连接占一个那超过 1024 个连接就建不起来了。要支持更多连接得调高这个限制同时也要调高系统的整体限制。另外心跳定时器如果每个连接一个连接多了定时器也很多。更好的做法是用一个全局的定时器每隔一段时间遍历所有连接检查一次而不是每个连接单独开定时器。这样定时器数量是固定的不随连接数增长。7. 把 RelayRouter 放进真实文本工作流的几种姿势7.1 长文档生成场景的流式编排长文档生成是文本工作流里最典型的流式场景。用户提交一个主题模型开始生成内容一段段推给前端。这个场景里 RelayRouter 要做几件事。首先是分片路由。模型输出的 token 流可能非常细碎直接推给前端太频繁RelayRouter 可以做一个缓冲攒成有意义的句子或段落再推。其次是多路分发同一份生成结果可能既要推给前端展示又要写进数据库存档还要发给审核服务检查RelayRouter 把这些下游都接上上游只管发一次。还有一个细节是生成中断的处理。用户可能中途取消生成这时候 RelayRouter 要能收到取消信号通知模型停止同时把已经生成的部分保存下来而不是直接丢弃。这个部分结果在很多场景下是有价值的。7.2 后台任务进度推送的轻量方案后台任务进度推送是另一个高频场景而且它比长文档生成简单得多往往不需要 WebSocket 这么重的方案。如果只是单向推送进度SSE 就够了甚至用轮询都能接受。但如果任务很多、进度更新很频繁WebSocket 加 RelayRouter 的组合就更合适。RelayRouter 在这里的价值是统一管理所有任务的进度流前端只需要连一个连接就能订阅多个任务的进度不用为每个任务单独开连接。任务完成后RelayRouter 负责关闭对应的流前端收到完成消息后更新界面。这个场景里有个实用技巧进度消息要做节流。一个任务可能每秒更新几十次进度但前端根本不需要这么高的刷新率每秒更新两三次就够了。RelayRouter 可以在中间做节流把高频的进度更新合并成低频的推送既省带宽又省前端渲染开销。7.3 多步骤 Agent 工作流的阶段衔接多步骤 Agent 工作流是最复杂的场景因为它涉及多个处理阶段的衔接。一个任务可能经过检索、摘要、改写、校对四个阶段每个阶段的输出是下一个阶段的输入同时每个阶段的中间结果都要反馈给用户。RelayRouter 在这里扮演的是阶段调度器的角色。它知道当前任务处于哪个阶段知道这个阶段的输出该往哪送。阶段之间的数据传递可以通过 RelayRouter 内部完成用户看到的是一条连续的流实际上背后经过了多次路由和转换。这个场景的难点是错误处理。如果某个阶段失败了整个工作流怎么办是重试当前阶段还是回退到上一个阶段还是直接终止这些策略要在 RelayRouter 的路由规则里定义清楚。我的经验是每个阶段都要有独立的超时和重试配置不能用一个全局配置糊弄因为不同阶段的耗时和失败率差别很大。8. 选型与落地时我个人的几条判断标准聊了这么多原理和实现最后说几条我在实际项目里总结出来的判断标准都是踩过坑之后才想明白的。第一条先问这条流需要双向吗。不需要双向就别上 WebSocketSSE 更省事。很多团队一上来就 WebSocket结果发现根本用不到双向能力白白增加了服务端复杂度。双向能力只有在用户需要实时干预生成过程的时候才真正必要。第二条流的数量超过三条再考虑 RelayRouter。少于三条的时候直接管理连接的成本比引入中间层还低。中间层是有维护成本的它自己也会出问题别为了架构好看而过早引入。第三条心跳间隔一定要小于中间层的最短超时。这个坑我踩过不止一次表现是连接莫名其妙就断了排查半天才发现是代理把空闲连接掐了。部署前一定要确认清楚链路上所有中间层的超时配置。第四条序号字段从第一天就要有。别觉得刚开始简单就不加等到发现丢消息的时候再补前端后端都要改成本高得多。序号是文本流可靠性的基石早加早省心。第五条多实例部署必须配消息中转。单实例的时候怎么推都行一旦上了多实例连接归属问题立刻就会暴露。Redis pub/sub 是最轻量的方案够用就别上更重的东西。第六条前端渲染一定要做批量。这是纯性能问题但直接影响用户体验。高频消息不批量渲染页面必卡没有例外。这套东西说到底核心就一句话实时 AI 的价值不在于界面多炫而在于它逼着我们把数据流这件事想清楚。Gemini Live Avatar 只是把这个问题用最显眼的方式摆到了台面上而 RelayRouter 加上 WebSocket 这套组合是解决文本工作流里同类问题的一个务实答案。想清楚你的流有几条、往哪走、断了怎么办剩下的就是按部就班地实现和调优了。