☰
实时AI文本工作流实战:RelayRouter与WebSocket长连接工程细节
2026/10/2 22:54:45 网站建设 项目流程

实时 AI 这个词最近被聊得很多,但大多数讨论都停在"把聊天窗口换成视频通话"这个层面。我一开始也这么理解,直到自己动手把一套文本工作流接进实时通道之后才发现,真正难的不是让画面动起来,而是让"实时"这件事在文本链路里也成立。Gemini Live Avatar 这类产品给人的直观冲击是"AI 有了脸",可它背后暴露出来的工程问题——长连接怎么维持、工具调用怎么异步化、文本流怎么和音视频流共存——才是每个做 AI 应用的人都绕不开的。RelayRouter 这个名字最近在热搜里频繁出现,很多人第一反应是"又一个路由库",但把它放进文本工作流里看,它解决的其实是实时场景下消息分发和状态同步的老问题。这篇内容适合已经在做 AI 应用、正在被 WebSocket 长连接和异步工具调用折磨的开发者,也适合想搞清楚"实时 AI 到底实时在哪"的产品和技术同学。我会从 Gemini Live Avatar 这个现象切入,把 RelayRouter 在文本工作流里的真实位置讲清楚,顺带把 WebSocket 心跳、断线重连、SSE 与 WebSocket 选型这些实操细节一次说透。

1. 从 Gemini Live Avatar 反推"实时"到底实时在哪

1.1 视频外壳下藏着的是一条文本主链路

很多人看 Gemini Live Avatar 的演示,注意力全在数字人的表情和口型上,觉得这是一次多模态的胜利。但如果你把它的交互拆开看,会发现整条链路的核心依然是文本:用户的语音先转成文本,文本进入模型推理,模型输出的文本再驱动语音合成和口型动画。视频只是最外层的表现层,真正决定响应速度和交互质量的是中间那条文本链路。

这个认知很关键,因为它直接决定了你该怎么优化自己的系统。我见过不少团队一上来就砸资源做音视频编解码优化,结果用户还是觉得"卡",最后排查发现瓶颈在文本推理的排队上。实时 AI 的"实时"不是画面流畅,而是从用户输入到系统给出有意义反馈的端到端延迟足够低。Gemini Live Avatar 之所以看起来自然,是因为它把文本链路的延迟压到了人感知不到的程度,视频层才有机会显得流畅。

所以当我们讨论"实时 AI 不只是把聊天变成视频"时,真正的意思是:视频是结果,不是原因。原因在于文本工作流被重新设计成了实时架构。这个架构里,长连接、消息路由、异步工具调用三件事必须同时成立,缺一个都会让"实时"变成"看起来实时"。

1.2 实时文本工作流和传统请求响应的根本差异

传统的文本工作流是请求-响应模型:用户发一条消息,服务端处理完返回一条结果,连接就结束了。这种模型简单、好调试、好扩展,但它有个致命问题——它假设"处理"是一个瞬时动作。可现实里,一次 AI 交互往往包含多个步骤:检索知识库、调用外部工具、等待第三方接口、多轮推理。这些步骤加起来可能几秒甚至几十秒,请求-响应模型下用户只能干等。

实时文本工作流把这条链路拆成了事件流。用户输入是一个事件,工具调用开始是一个事件,工具返回是一个事件,模型开始生成是一个事件,生成结束又是一个事件。每个事件都通过长连接实时推给前端,用户能看到"系统正在做什么",而不是盯着一个转圈图标。这就是 Gemini Live Avatar 那种"有生命感"的来源——不是它更快,而是它把过程暴露出来了。

这个差异带来的工程挑战是巨大的。请求-响应模型下,状态是短暂的,处理完就丢;实时模型下,状态必须长期维护,连接断了要能恢复,消息丢了要能重发,顺序错了要能纠正。这就是为什么 WebSocket 和 SSE 这类技术会重新成为焦点,也是为什么 RelayRouter 这种消息路由层会变得重要。

1.3 为什么"实时"在文本场景里反而更难做

音视频的实时有成熟的协议栈和硬件加速,丢一帧两帧用户感知不明显。但文本不一样,文本是有语义的,少一个字、顺序错一位,意思可能完全变了。这意味着文本实时链路对可靠性的要求比音视频更高。

更麻烦的是文本工作流里的工具调用天然是异步的。用户问"帮我查一下明天的天气然后推荐穿什么",这里有两个依赖步骤:查天气、根据天气推荐。查天气可能要调外部 API,耗时不确定;推荐要等天气结果回来才能开始。在请求-响应模型里这很简单,串行执行就行。但在实时模型里,你得让用户看到"正在查天气"这个中间状态,还得保证天气结果回来后能正确触发下一步,同时不能阻塞其他用户的请求。

我踩过的一个坑是:早期用同步方式处理工具调用,结果一个慢接口把整个连接堵死了,前端一直收不到心跳,误判断线然后疯狂重连,服务端瞬间被打爆。后来才明白,实时文本工作流的核心不是"快",而是"不阻塞"和"可观测"。这两点做到了,用户自然觉得快。

2. RelayRouter 在文本工作流里到底扮演什么角色

2.1 别把 RelayRouter 当成普通路由库

热搜里 relayrouter 这个词出现得很频繁,但很多人对它的理解停留在"消息转发"层面,觉得无非是把 A 的消息转给 B。如果只是这样,那它确实没什么特别的,一个简单的发布订阅就能替代。RelayRouter 真正的价值在于它处理的是"带状态的、有依赖关系的、需要保证顺序的消息流"。

在实时文本工作流里,消息不是孤立的。一条工具调用请求和它对应的返回是一对,模型生成的多个 token 块属于同一个响应,用户的连续输入可能构成一个会话上下文。这些消息之间有依赖、有顺序、有生命周期。普通路由只关心"送到哪",RelayRouter 这类方案还要关心"什么时候送""按什么顺序送""送失败了怎么办"。

我自己的理解是,RelayRouter 在文本工作流里的位置,类似于音视频里的媒体服务器。它不生产内容,但它决定了内容怎么在生产者(模型、工具)和消费者(前端、其他服务)之间流动。这个中间层做得好不好,直接决定了整个实时系统的稳定性和可扩展性。

2.2 消息分发、状态同步与背压处理三件事

RelayRouter 要解决的核心问题可以拆成三块。第一块是消息分发:一个用户的消息可能需要同时推给前端、写入日志、触发下游任务,怎么保证每个消费者都拿到、且不重复。第二块是状态同步:连接可能断,用户可能切换设备,怎么保证重连后状态能恢复,不会出现"消息发到旧连接上"的情况。第三块是背压处理:当生产速度大于消费速度时,消息会堆积,怎么优雅地降级而不是直接崩掉。

这三块里,背压处理最容易被忽略,也最容易出事。我见过一个案例,模型生成速度很快,前端渲染速度跟不上,消息在服务端队列里越堆越多,最后内存爆掉。正确的做法是在路由层就做流控,当某个消费者的待处理队列超过阈值时,要么丢弃低优先级消息,要么通知生产者降速。RelayRouter 这类方案如果设计得当,应该把流控作为一等公民,而不是事后补丁。

状态同步这块也有讲究。很多人以为重连就是重新建立连接,其实重连的关键是"续传"。客户端要告诉服务端"我上次收到的是第 N 条消息",服务端从 N+1 开始补发。这要求路由层维护消息序号和短期历史。没有这个机制,重连后要么丢消息,要么重复处理,用户体验都会崩。

2.3 和直接裸用 WebSocket 的对比

有人会问,我直接用 WebSocket 不就行了,为什么要加一层 RelayRouter?这个问题很实在。如果你的场景很简单,就是一对一推送,那确实不需要。但只要出现下面任何一种情况,裸 WebSocket 就会开始难受:多个服务需要往同一个连接推消息、消息需要按类型路由到不同处理器、需要做消息持久化和重放、需要横向扩展多个服务端实例。

裸 WebSocket 的问题在于它只提供了"管道",没提供"调度"。当你有十个服务都想往用户的连接上写数据时,你得自己协调谁先谁后、怎么避免并发写冲突、怎么在服务端扩容时让消息找到正确的连接。这些协调逻辑如果散落在各个业务代码里,很快就会变成一团乱麻。RelayRouter 的价值就是把这团乱麻收拢到一个中间层,让业务代码只管"我要发什么",不管"怎么发到正确的连接上"。

下面这张表是我在实际选型时整理的对比,供参考:

维度裸 WebSocket加 RelayRouter 层
消息分发业务代码自己维护连接映射路由层统一管理
多服务推送需要共享连接状态,难扩展路由层做汇聚,服务无状态
消息顺序依赖单连接,多写者易乱序路由层可保证序号
断线续传需自行实现消息缓存路由层内置历史与重放
背压处理基本没有,容易堆积可做队列与流控
调试难度连接状态分散,难追踪消息流集中,可观测

当然,加一层也有代价:多一次网络跳转、多一个要维护的组件、多一层可能的故障点。所以我的建议是,先用裸 WebSocket 跑通最小闭环,当你发现自己在写第三遍"连接管理"代码时,就该考虑引入路由层了。

3. WebSocket 长连接的工程细节:心跳、重连与消息可靠性

3.1 心跳机制不是定时发个 ping 那么简单

WebSocket 心跳机制实现这个话题被搜了很多次,说明大家都在踩这个坑。表面上看,心跳就是每隔几秒发个 ping,收到 pong 就说明连接活着。但实际做起来,问题一大堆。

第一个问题是间隔怎么定。太短浪费资源,太长发现断线不及时。我的经验是,服务端心跳间隔设在 15 到 30 秒之间比较合适,客户端如果超过两个心跳周期没收到服务端消息,就主动探测。为什么是两倍?因为网络抖动是常态,单次丢失不能判定断线,连续两次才比较可靠。

第二个问题是心跳和业务消息的关系。很多人把心跳做成独立通道,结果业务消息堵住的时候心跳也发不出去,误判断线。正确做法是心跳要能"插队",或者至少和业务消息走不同的优先级队列。我在一个项目里就遇到过,模型生成大段文本时把发送队列占满,心跳包排在后面发不出去,客户端以为断了开始重连,结果重连上来又收到一堆旧消息,乱成一锅粥。

第三个问题是服务端怎么判断客户端还活着。光靠 TCP 层的 keepalive 不够,因为中间可能有代理层,TCP 连接活着不代表应用层还通。所以应用层心跳是必须的,而且服务端要维护一个"最后活跃时间",超过阈值就主动清理连接,释放资源。

// 客户端心跳的常见实现,注意重连时的退避 let heartbeatTimer = null; let reconnectDelay = 1000; const MAX_DELAY = 30000; function startHeartbeat(ws) { clearInterval(heartbeatTimer); heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } }, 20000); } function scheduleReconnect() { // 指数退避,避免服务端被打爆 reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY); setTimeout(connect, reconnectDelay); }

这段代码里有个细节值得说:重连一定要用指数退避。我见过太多客户端断线后立刻重连,服务端刚重启还没起来,瞬间被几千个重连请求打挂,然后客户端又断,又重连,形成雪崩。退避加上随机抖动,能把这个冲击摊平。

3.2 断线重连后的状态恢复才是真正的难点

心跳解决的是"发现断线",重连解决的是"重新连上",但真正难的是"连上之后状态对不对"。用户断线期间,服务端可能已经生成了好几条消息,这些消息要不要补发?补发的话从哪条开始?用户断线前正在进行的工具调用,重连后要不要继续?

我的做法是在协议里引入消息序号和会话游标。每条服务端推给客户端的消息都带一个单调递增的序号,客户端本地记录"已确认收到的最大序号"。重连时客户端把这个序号带上,服务端从序号加一开始补发。同时服务端要保留一段时间的消息历史,比如最近 5 分钟或最近 1000 条,超出范围的就不补了,让客户端做一次全量刷新。

这里有个坑:补发的消息和实时产生的新消息可能交错。如果处理不好,客户端会先收到新消息再收到旧消息,顺序就乱了。解决办法是补发阶段先暂停新消息推送,补完再恢复,或者给补发消息打标记让客户端做缓冲排序。我倾向于前者,简单可靠。

还有一个容易被忽略的点:工具调用的状态恢复。如果用户断线时正好有个工具调用在进行,重连后这个调用的结果怎么处理?我的方案是把工具调用也纳入消息流,调用开始和调用结束都是消息,客户端根据消息重建状态。这样即使断线,只要消息历史还在,状态就能恢复。

3.3 消息可靠性:至少一次、恰好一次还是尽力而为

消息可靠性有三个层次:尽力而为(发了不管)、至少一次(保证送到但可能重复)、恰好一次(不丢不重)。实时文本工作流里,大部分场景用"至少一次"就够了,但要在客户端做幂等处理。

为什么不用恰好一次?因为恰好一次需要分布式事务或者复杂的确认机制,代价太高,而且在实时场景里收益有限。文本消息重复一条,客户端根据消息 ID 去重就行,成本很低。但如果为了恰好一次引入两阶段提交,延迟会上去,实时性就没了。

幂等处理的关键是给每条消息一个唯一 ID,客户端维护一个已处理 ID 的集合(可以用 LRU 缓存控制内存)。收到重复 ID 直接丢弃。这个机制简单但极其有效,我在多个项目里都靠它兜底。

需要"恰好一次"的场景通常是涉及副作用的操作,比如"扣款""下单"。这类操作不应该走实时消息通道,而应该走独立的、有事务保证的接口。实时通道只负责通知"操作完成了",不负责执行操作本身。这个边界划清楚,系统会简单很多。

4. 异步工具调用:实时文本工作流里最容易翻车的地方

4.1 同步工具调用为什么会拖垮整个连接

前面提到过同步工具调用堵死连接的问题,这里展开说。假设用户问了一个需要调用外部 API 的问题,你的处理逻辑是:收到消息,调用 API,等结果,生成回复,推送。如果这个 API 耗时 10 秒,那这 10 秒里这条连接上的其他消息都处理不了。

在低并发场景下这没什么,但在实时场景下,用户可能同时在打字、可能触发了其他操作,这些消息都排在后面。更糟的是,如果这个 API 挂了或者超时,整个连接就卡死了。我早期的一个项目就是这么崩的,一个第三方接口不稳定,导致大量连接被占满,新用户连不进来。

正确的做法是把工具调用异步化。收到用户消息后,立即返回一个"已收到,正在处理"的事件,然后后台异步执行工具调用,完成后通过消息通道推送结果。这样连接始终是畅通的,用户也能看到进度。

4.2 工具调用的生命周期管理与超时设计

异步化之后,工具调用就有了生命周期:创建、执行中、成功、失败、超时、取消。每个状态都要能推送给前端,让用户知道发生了什么。这听起来简单,但状态管理很容易乱。

我的做法是给每个工具调用分配一个 ID,维护一个状态机。状态转换必须合法,比如不能从"成功"转到"执行中"。每次状态变化都产生一条消息,推给前端。前端根据这些消息渲染不同的 UI,比如"正在查询..."、"查询完成"、"查询失败,请重试"。

超时设计是重点。外部工具调用必须设超时,而且超时时间要分层:单次请求超时、整体调用超时、用户可感知的等待超时。单次请求超时比如 5 秒,超了就重试;整体调用超时比如 30 秒,超了就放弃并告知用户;用户可感知的等待超时比如 3 秒,超过这个时间还没结果,前端就要显示"正在处理"的提示,不能让用户干等。

# 异步工具调用的状态管理示意 import asyncio from enum import Enum class ToolState(Enum): PENDING = "pending" RUNNING = "running" SUCCESS = "success" FAILED = "failed" TIMEOUT = "timeout" async def execute_tool(call_id, tool_fn, args, timeout=30): await push_event(call_id, ToolState.RUNNING) try: result = await asyncio.wait_for(tool_fn(**args), timeout=timeout) await push_event(call_id, ToolState.SUCCESS, result) except asyncio.TimeoutError: await push_event(call_id, ToolState.TIMEOUT) except Exception as e: await push_event(call_id, ToolState.FAILED, str(e))

这段代码的关键点是asyncio.wait_for,它保证了即使工具函数内部卡住,外层也能按时超时。很多人只在自己的代码里加超时,但工具函数可能调用了别的库,那些库的超时不受你控制,所以外层必须再包一层。

4.3 多个工具并行调用时的结果聚合

复杂问题往往需要多个工具并行调用。比如"对比一下北京和上海明天的天气",需要同时查两个城市的天气。并行调用能省时间,但结果聚合是个麻烦事。

首先是顺序问题。并行调用的返回顺序是不确定的,但前端展示可能需要固定顺序。解决办法是给每个调用分配一个序号,聚合时按序号排序,而不是按返回时间。

其次是部分失败的处理。如果北京天气查到了,上海天气超时了,怎么办?我的策略是"尽力而为加明确告知":能拿到的结果先展示,失败的部分明确标注"上海天气获取失败",而不是整个请求失败。用户要的是信息,不是完美。

还有一个坑是结果聚合的时机。你不能等所有调用都返回才推送,那样就失去了并行的意义。正确做法是每个调用返回就推送一次,前端增量更新。用户会看到北京天气先出来,上海天气后出来,体验反而更自然。

5. SSE 还是 WebSocket:文本工作流里的选型逻辑

5.1 单向推送场景下 SSE 的隐性优势

热搜里有个词是"react + sse/websocket 轮询文件变化",说明很多人在纠结 SSE 和 WebSocket 怎么选。我的观点是:如果只需要服务端往客户端单向推送,SSE 往往是更好的选择,尽管它看起来"更弱"。

SSE 的优势在于它基于普通 HTTP,天然支持自动重连、天然穿透大部分代理、天然支持事件 ID 和断点续传。浏览器对 SSE 的重连是内置的,你什么都不用做,断了它自己会重连,还会带上 Last-Event-ID 让服务端知道从哪续。这些在 WebSocket 里都要自己实现。

SSE 的劣势是单向,客户端要发消息得另开一个 HTTP 接口。但在文本工作流里,客户端发消息的频率通常远低于服务端推消息的频率,用 HTTP 发消息完全够用。所以"SSE 推 + HTTP 发"这个组合在很多场景下比纯 WebSocket 更简单可靠。

我自己的经验是,纯展示型的实时文本流(比如日志、通知、AI 生成内容)优先用 SSE;需要双向高频交互的(比如协作编辑、实时游戏)才用 WebSocket。不要因为 WebSocket"更强大"就无脑选它,强大意味着你要自己处理更多东西。

5.2 双向交互和二进制场景下 WebSocket 不可替代

当然,WebSocket 有它不可替代的场景。需要客户端高频发消息的、需要传二进制数据的、需要极低延迟双向交互的,这些 SSE 都做不了。Gemini Live Avatar 这种场景就必须用 WebSocket,因为音视频数据是双向的、二进制的、高频的。

还有一个实际考虑是连接数。SSE 每个连接占用一个 HTTP 连接,浏览器对同域 HTTP 连接数有限制(HTTP/1.1 下通常 6 个),如果开多个 SSE 会互相挤占。WebSocket 不受这个限制。所以在需要多个实时通道的场景下,WebSocket 更合适。HTTP/2 下这个限制缓解了,但仍有并发流的上限。

选型的时候我会问自己三个问题:客户端需要频繁发消息吗?需要传二进制吗?需要多个实时通道吗?三个都是否,用 SSE;有一个是,考虑 WebSocket。

5.3 混合方案:用 SSE 做通知,用 WebSocket 做交互

实际项目里,我越来越倾向于混合方案。用 SSE 做轻量通知(比如"有新消息了""任务完成了"),用 WebSocket 做重交互(比如实时对话、协同操作)。这样通知通道简单可靠,交互通道专注性能,各司其职。

混合方案的关键是两者要能协同。比如 WebSocket 断了,SSE 通知还能工作,用户至少知道"连接有问题"。或者 SSE 收到"任务完成"通知,前端再通过 WebSocket 拉取详细结果。这种设计让系统在部分故障时仍能提供降级体验。

不过混合也增加了复杂度,要维护两套连接、两套重连逻辑。所以小项目别一上来就混合,先用一种跑通,遇到瓶颈再拆。

6. 把 RelayRouter 放进真实文本工作流的落地路径

6.1 最小可行架构:先跑通一条消息的完整生命周期

说了这么多原理,落地的时候还是要从最小闭环开始。我的建议是先跑通一条消息从产生到消费的完整生命周期,不追求功能全,追求链路通。

最小架构包含四个部分:消息生产者(比如模型服务)、RelayRouter(路由层)、连接管理(WebSocket 或 SSE)、消费者(前端)。一条消息的流程是:生产者产生消息,带上会话 ID 和序号,交给 RelayRouter;RelayRouter 根据会话 ID 找到对应的连接,按序号推给消费者;消费者收到后确认,RelayRouter 记录确认位置。

这个闭环跑通后,再逐步加东西:加心跳、加重连续传、加工具调用、加多消费者。每加一个都要保证前面的闭环不被破坏。我见过太多项目一上来就设计大而全的架构,结果每个部分都没跑通,最后推倒重来。

6.2 从单机到多实例:连接状态该放哪

单机的时候,连接状态放内存就行。但一旦要多实例部署,问题就来了:用户的连接在实例 A 上,但处理他消息的服务可能在实例 B 上,B 怎么把消息推给 A 上的连接?

解决办法是把连接状态外置。常见方案是用 Redis 的发布订阅:每个实例订阅自己负责的频道,消息通过 Redis 广播,实例收到后检查自己有没有对应的连接,有就推送。这样实例之间不需要直接通信,通过 Redis 解耦。

但 Redis 发布订阅有个问题:它不保证消息可靠,实例短暂断连期间的消息会丢。对于实时文本流,偶尔丢一条可能可以接受(因为有重连续传兜底),但如果要求高可靠,就得用更重的方案,比如消息队列加确认机制。我的经验是,先用 Redis 发布订阅,遇到可靠性问题再升级,不要一开始就上重方案。

还有一个细节是连接和实例的绑定关系要能查询。当需要主动给某个用户推消息时,得知道他的连接在哪个实例上。这通常用一个 Redis 的映射表来维护,连接建立时写入,断开时删除。要注意处理实例崩溃导致的脏数据,可以用心跳续期的方式让映射自动过期。

6.3 可观测性:实时系统没有日志就是瞎子

实时系统最怕的是"看起来在跑,其实已经坏了"。连接还在,但消息不流动;心跳还在,但业务逻辑卡死。没有可观测性,你根本不知道问题出在哪。

我的做法是至少埋三类指标:连接指标(当前连接数、新建速率、断开速率、断开原因分布)、消息指标(生产速率、消费速率、队列深度、端到端延迟)、错误指标(工具调用失败率、超时率、重连率)。这些指标要能按会话、按用户、按消息类型下钻。

日志也很关键,但实时系统的日志量很大,不能什么都记。我的策略是记状态变化,不记每条消息。比如连接建立、连接断开、工具调用开始、工具调用结束、重连发生,这些事件记日志。消息内容本身只在采样或者出错时记,避免日志爆炸。

还有一个实用技巧是给每个会话分配一个 trace ID,贯穿整条链路。这样排查问题时,拿一个 trace ID 就能看到这个会话的所有事件,不用在多个日志文件里翻。这个投入在出问题的时候回报巨大。

7. 几个我踩过的坑和对应的解法

7.1 心跳和业务消息抢通道导致的假断线

前面提过这个坑,这里说具体表现和解法。现象是:用户在用的时候偶尔会看到"连接已断开,正在重连",但网络明明没问题。排查发现是模型生成大段文本时,发送队列被占满,心跳包排在后面,客户端等不到心跳就判定断线。

解法有两个层面。一是发送队列要分级,心跳和控制消息走高优先级队列,业务消息走普通队列,高优先级队列永远优先发送。二是客户端判定断线要更宽容,不能一个心跳周期没收到就断,至少给两个周期,而且要考虑客户端自己的发送队列是否拥堵。

这个坑的教训是:实时系统里,"控制面"和"数据面"要分开。心跳、重连、确认这些控制消息,不能和业务数据挤在一起。

7.2 重连风暴:服务端重启后的雪崩

服务端重启或者网络抖动后,大量客户端同时重连,瞬间把服务端打挂,然后客户端又断,又重连,形成循环。这个坑我踩过一次,印象很深。

解法是客户端重连必须加随机抖动和指数退避。退避让重连分散开,抖动避免所有客户端在同一时刻重连。服务端也要有限流,超过承载能力的重连请求要排队或者拒绝,而不是全部接受然后一起崩。

还有一个进阶做法是服务端在关闭前主动通知客户端"我要重启了,请稍后重连",并给出建议的重连时间。这样客户端可以有序重连,而不是一窝蜂。当然这要求服务端能优雅关闭,不是所有场景都能做到。

7.3 工具调用结果回来时连接已经换了

用户断线重连后,连接 ID 变了,但之前发起的工具调用还在跑。结果回来时,按旧连接 ID 推送就丢了。这个坑很隐蔽,因为工具调用通常很快,不容易触发,但一旦触发就是丢消息。

解法是不要把消息绑定到连接 ID,而是绑定到会话 ID。连接可以变,会话不变。推送时根据会话 ID 查当前连接,而不是用发起时的连接 ID。这样即使连接换了,消息也能找到正确的去处。

这个思路其实适用于所有实时消息:连接是易变的,会话是稳定的。以会话为中心设计,而不是以连接为中心,能避免很多这类问题。

7.4 前端渲染跟不上导致的背压堆积

服务端推得快,前端渲染得慢,消息在客户端堆积,内存上涨,最后卡死。这个坑在文本流场景特别常见,因为文本渲染涉及 DOM 操作,比纯数据更新慢得多。

解法是前端要做节流和批量更新。不要每收到一个 token 就更新一次 DOM,而是攒一小批(比如 50 毫秒或 20 个 token)一起更新。这样渲染次数大幅减少,用户感知的流畅度反而更高。

服务端也要配合,如果发现某个客户端的确认延迟持续偏高,要主动降速或者丢弃低优先级消息。这就是前面说的背压处理,必须两端配合才能做好。

8. 关于实时文本工作流的一些个人判断

做了几个实时 AI 项目之后,我有个越来越强的感受:实时文本工作流的难点不在"实时"技术本身,而在"状态管理"。WebSocket、SSE、心跳、重连这些都是成熟技术,文档一搜一大把。真正让人头疼的是状态:连接状态、会话状态、工具调用状态、消息确认状态,这些状态交织在一起,任何一个处理不好都会出问题。

RelayRouter 这类方案的价值,本质上就是把状态管理从业务代码里抽出来,集中到一个地方处理。它不神奇,但它让状态有了统一的归属,而不是散落在各个服务里。这也是为什么我觉得它在文本工作流里有位置——不是因为它能做什么别人做不了的事,而是因为它让复杂的事情有了秩序。

Gemini Live Avatar 给我的启发是,实时 AI 的竞争最终会落到工程细节上。模型能力大家都能买到,但把模型能力稳定、低延迟、可恢复地送到用户面前,这是工程活。谁把这些细节做扎实,谁的实时体验就好。视频只是表象,文本链路才是里子。

如果你正在做类似的东西,我的建议是别急着上复杂架构。先把一条消息的生命周期跑通,把心跳和重连做扎实,把工具调用异步化,把状态管理收拢。这几件事做好了,系统就稳了。至于用 SSE 还是 WebSocket、用不用 RelayRouter,都是在这个基础上根据场景做的选择,不是起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询