ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

实时AI后端架构:WebSocket心跳与异步工具调用实战

实时AI后端架构:WebSocket心跳与异步工具调用实战 1. 从一条热搜说起实时 AI 到底在“实时”什么Gemini Live Avatar 刚出来那阵子我朋友圈里做 AI 应用的人几乎都在转。大部分人第一反应是“哦数字人又升级了”然后划走。但我盯着它看了很久因为我发现大家把注意力全放在了“Avatar”这个皮上忽略了“Live”这个里子。Live 意味着什么意味着你说话的时候模型在听你停顿的时候模型在等你还没说完它已经在组织回应了。这跟传统的“发一条消息、等三秒、收一条回复”完全是两码事。传统模式是回合制像下棋实时模式是流式的像打电话。而一旦你开始做实时 AI 应用就会撞上一个绕不开的问题你的后端能不能扛住持续的双向通信这就是 WebSocket 登场的地方也是 RelayRouter 这类工具真正要解决的场景。我写这篇文章不是要教你接 Gemini 的 API——那个官方文档写得比我清楚。我想聊的是当你把“实时”这个需求从聊天场景抽出来放到文本工作流里整个技术选型和架构思路会发生什么变化。RelayRouter 在这个变化里扮演什么角色它的位置为什么值得单独拿出来说。如果你正在做 AI 应用的后端或者你是个全栈开发者正在琢磨怎么让自己的产品从“能用”变成“好用”那这篇内容应该能给你一些可以直接抄作业的东西。我会从架构设计讲到 WebSocket 心跳的具体实现从异步工具调用的坑讲到 RelayRouter 的选型逻辑尽量把每个“为什么”都说透。2. 实时 AI 的架构真相为什么 WebSocket 是绕不过去的坎2.1 从 HTTP 轮询到长连接一次通信范式的迁移先说说为什么传统 HTTP 在实时场景里会跪。HTTP 是请求-响应模型客户端不问服务端就不答。你要做实时最笨的办法是轮询——客户端每隔一秒发个请求问“有新的吗”。这个方案我早期做项目时用过结果是服务器 QPS 直接飙到几千大部分请求都是空手而归带宽和 CPU 全浪费在“没有新消息”这个回答上。后来有了长轮询客户端发一个请求服务端 hold 住不返回等有数据了再回。这比短轮询好一些但每次数据交换还是要重新建立连接HTTP 头部的开销反复消耗。而且服务端要维持大量挂起的连接对线程模型是个考验。WebSocket 解决的就是这个问题。它在 TCP 之上做了一次 HTTP 握手握手完成后连接就升级了之后双方可以随时互发数据帧没有请求-响应的束缚。对于实时 AI 场景——比如你对着麦克风说话音频流要持续上传模型的回应要持续下发——WebSocket 几乎是唯一合理的选择。我用一个生活类比来解释HTTP 就像你每次跟人说话都要先拨一次电话、说完挂断、下次再说再拨WebSocket 就像电话接通后一直不挂双方想什么时候说就什么时候说。实时对话要的是后者。2.2 RelayRouter 的定位不是“又一个路由”而是消息调度层RelayRouter 这个名字听起来像个网络设备但在 AI 应用的语境里它更像是一个消息调度中间层。它的核心职责是在客户端和多个后端服务之间维持 WebSocket 长连接并根据消息类型把数据路由到正确的处理单元。为什么需要这个东西因为实时 AI 应用的后端往往不是单一服务。你可能有一个服务专门处理语音转文字一个服务跑大模型推理一个服务做工具调用比如查天气、搜网页还有一个服务管理会话状态。如果让客户端直接连所有这些服务客户端会疯掉而且连接数会爆炸。RelayRouter 的做法是客户端只连它一个它来负责分发。这跟 API Gateway 的思路类似但 API Gateway 主要处理 HTTP 的短连接路由RelayRouter 要处理的是 WebSocket 的持久连接和消息帧的路由。我实测下来这种架构最大的好处是连接收敛。假设你有 1 万个在线用户每个用户需要跟 3 个后端服务交互。没有 RelayRouter 的话后端要维持 3 万个 WebSocket 连接有了它后端只需要跟 RelayRouter 维持连接客户端侧也只有 1 万个连接。连接数的降低直接带来内存和文件描述符的节省这在单机承载量上是数量级的差异。2.3 文本工作流为什么也需要实时通道这里要澄清一个误区很多人觉得“实时”只跟语音、视频有关文本工作流用 HTTP 就够了。我一开始也这么想直到我做一个多步骤的 AI 写作助手时踩了坑。那个助手的流程是用户输入一个主题模型先做大纲然后逐段扩写每扩写一段就推送给前端展示。如果用 HTTP前端要么轮询问“下一段好了吗”要么等整个流程跑完一次性返回。前者浪费资源后者用户体验极差——用户盯着空白屏幕等 30 秒不知道后台在干什么。换成 WebSocket 之后每扩写一段就推一帧过去前端实时渲染。用户看到文字一段段冒出来感知上的等待时间大幅缩短。而且中间如果某一步失败了可以立即推送错误信息不用等整个流程结束。所以文本工作流需要实时通道核心原因是过程可见性。AI 的处理不是瞬间完成的它有时间维度。把中间状态暴露出来用户体验就从“黑盒等待”变成了“过程参与”。RelayRouter 在这种场景里的价值就是保证这些中间状态能可靠、有序地送达客户端。3. WebSocket 心跳机制那些文档不会告诉你的细节3.1 为什么心跳是必须的NAT 超时与半开连接WebSocket 连接建立之后理论上可以一直开着。但现实网络环境里中间会经过各种设备——路由器、负载均衡、防火墙——这些设备对“空闲连接”有自己的容忍度。很多 NAT 设备的超时时间是 60 秒到 5 分钟如果一条连接在这段时间内没有任何数据流动设备就会悄悄把它回收掉。问题在于连接被回收的时候双方的应用层可能都不知道。客户端以为连接还在服务端也以为连接还在但实际上数据已经发不过去了。这就是半开连接。心跳机制就是用来解决这个问题的。客户端或服务端定期发送一个小的数据帧ping对方收到后回一个 pong。如果连续几次 ping 都没有收到 pong就可以判定连接已断触发重连逻辑。我踩过的一个坑是只在一端做心跳。早期我觉得服务端发 ping 就够了客户端不用管。结果发现某些移动网络下客户端的连接已经断了但服务端还在傻傻地发 ping因为 TCP 层面还没有收到 RST。后来改成双向心跳——客户端也定期发 ping服务端回 pong——检测速度快了很多。3.2 心跳间隔怎么定一个需要算账的参数心跳间隔不是拍脑袋定的。太短浪费带宽和电量太长检测不到断连。我的经验公式是心跳间隔 min(NAT 超时时间) / 2假设你的用户网络环境里最常见的 NAT 超时是 60 秒那心跳间隔就设 30 秒。这样在 NAT 回收连接之前至少有一次心跳数据流过刷新了 NAT 的计时器。但还要考虑移动端。手机在锁屏或切后台时系统可能会限制网络活动。如果心跳间隔太短会频繁唤醒无线电耗电很快。我一般会在移动端把心跳间隔放宽到 45 秒到 60 秒同时配合应用层的前后台状态检测——切到后台时暂停心跳回到前台时立即发一次心跳并检查连接状态。下面是一个我常用的心跳配置表你可以直接参考场景心跳间隔超时判定备注Web 桌面端25-30 秒2 次未响应网络稳定可积极检测移动端前台30-45 秒2 次未响应兼顾耗电移动端后台暂停回前台立即检测避免无谓唤醒服务端主动检测60 秒1 次未响应即标记服务端资源宝贵快速释放3.3 心跳的实现代码层面的关键决策心跳的实现看起来简单但有几个决策点会影响可靠性。第一个决策ping 帧用 WebSocket 协议层的还是应用层自定义的WebSocket 协议本身有 ping/pong 控制帧很多库都支持。但我在实际项目里更倾向于用应用层的心跳消息因为协议层的 ping/pong 在很多代理和负载均衡上会被拦截或特殊处理而应用层的消息是普通数据帧穿透性更好。第二个决策心跳消息里带什么我一般会带一个时间戳和一个递增的序列号。时间戳用来计算 RTT往返时延序列号用来检测消息丢失或乱序。如果发现 RTT 突然变大可以提前预警网络质量下降。第三个决策超时后怎么处理直接关闭连接重连是最简单的但会造成消息丢失。我的做法是超时后先把连接标记为“可疑”暂停发送新消息把待发消息放入本地队列如果下一个心跳周期恢复了就继续发送如果还是超时再关闭重连重连成功后把队列里的消息补发。// 一个我常用的心跳实现骨架 class Heartbeat { constructor(ws, options {}) { this.ws ws; this.interval options.interval || 30000; this.timeout options.timeout || 5000; this.seq 0; this.pendingPings new Map(); this.timer null; } start() { this.timer setInterval(() { const seq this.seq; const timestamp Date.now(); this.pendingPings.set(seq, timestamp); this.ws.send(JSON.stringify({ type: ping, seq, timestamp })); // 检查超时 setTimeout(() { if (this.pendingPings.has(seq)) { this.pendingPings.delete(seq); this.onTimeout(); } }, this.timeout); }, this.interval); } onPong(seq) { const sentAt this.pendingPings.get(seq); if (sentAt) { const rtt Date.now() - sentAt; this.pendingPings.delete(seq); this.onRttMeasured(rtt); } } onTimeout() { // 标记连接可疑触发重连逻辑 this.ws.close(); } stop() { clearInterval(this.timer); this.pendingPings.clear(); } }这段代码的关键点是每个 ping 都带序列号pong 回来时能对应上从而计算 RTT。超时检测是独立的 setTimeout不阻塞主循环。实际使用时还要加上重连退避策略避免网络刚断就疯狂重连。注意心跳消息的 JSON 序列化开销在低频场景可以忽略但如果你把心跳间隔设到 5 秒以下建议改用二进制帧或更紧凑的格式否则序列化本身会成为 CPU 负担。4. 异步工具调用实时 AI 工作流里最容易翻车的地方4.1 什么是异步工具调用为什么它让架构变复杂Gemini Live Avatar 这类实时 AI 的一个核心能力是模型在对话过程中可以调用外部工具。比如用户问“今天北京天气怎么样”模型不会自己编一个答案而是触发一个天气查询工具拿到结果后再组织语言回复。这个“触发工具-等待结果-继续生成”的过程就是工具调用。在传统回合制里工具调用是同步的模型输出一个工具调用请求后端执行把结果塞回给模型模型继续。整个过程在一个请求周期内完成。但在实时场景里事情变得复杂。用户还在说话模型可能已经决定要调用工具了。工具的执行需要时间——查数据库可能要 200ms调外部 API 可能要 1 秒。如果模型傻等对话就会出现尴尬的停顿。更糟的是如果工具调用失败或超时整个对话流可能卡住。异步工具调用的思路是模型触发工具后不等待继续处理其他输入或生成其他部分的回应工具在后台执行完成后通过一个异步通道把结果送回模型模型再决定怎么整合。这听起来很美好但实现起来有几个硬骨头结果怎么关联回原来的上下文多个工具并发调用怎么管理工具执行期间用户又说了新话怎么办4.2 RelayRouter 在异步工具调用中的角色RelayRouter 在这里的价值就体现出来了。它不只是转发消息还可以维护一个调用上下文表。当模型触发一个工具调用时RelayRouter 生成一个唯一的 call_id把当前的会话状态、消息历史、用户输入快照都关联到这个 call_id 上。工具执行完成后带着 call_id 回来RelayRouter 就能把结果准确地注入到对应的会话上下文中。如果没有这一层你就得在业务代码里手动管理这些关联关系。我早期做过一个项目工具调用的结果回来时用户已经又说了三句话结果注入位置错了模型把天气信息当成了对第三句话的回复闹了笑话。RelayRouter 还可以做并发控制。实时对话里模型可能同时触发多个工具——比如一边查天气一边查日历。RelayRouter 可以限制每个会话的并发工具调用数避免后端被压垮。我一般会设一个上限比如每个会话最多 3 个并发工具调用超出的排队等待。4.3 超时与降级工具调用失败时怎么不让对话崩掉工具调用一定会失败。外部 API 会挂数据库会慢网络会抖。关键不是避免失败而是失败时怎么让对话继续。我的策略是分级超时 优雅降级。每个工具调用设两个超时阈值软超时和硬超时。软超时到了RelayRouter 给模型发一个“工具还在执行”的信号模型可以选择先回复用户“我正在查稍等”或者继续处理其他部分。硬超时到了直接返回一个失败结果模型根据失败结果组织回复比如“抱歉天气查询暂时不可用”。下面是我常用的超时配置工具类型软超时硬超时降级策略本地数据库查询100ms500ms返回缓存结果外部 API 调用800ms3s返回“暂时不可用”大模型二次推理2s8s跳过该步骤文件读写200ms1s返回空结果并记录日志提示软超时和硬超时的比例我一般控制在 1:3 到 1:4 之间。软超时太短会导致频繁触发“等待”信号太长则失去意义。还有一个细节工具调用的结果回来时如果对应的会话已经结束了用户关闭了页面RelayRouter 应该直接丢弃结果而不是尝试推送。我见过一个 bug用户关了页面后后台工具还在跑结果回来时往一个已关闭的 WebSocket 写数据直接抛异常。后来在 RelayRouter 里加了一个会话存活检查问题解决。5. 把实时能力放进文本工作流一个可复现的架构方案5.1 整体架构从客户端到模型服务的完整链路说了这么多原理现在给一个可以直接参考的架构方案。这个方案是我在一个多步骤 AI 写作助手里实际用过的跑了大半年稳定性还不错。整体链路是这样的客户端浏览器/App→ WebSocket →RelayRouter→ 消息分发 →会话服务/工具服务/模型服务客户端只跟 RelayRouter 建立一个 WebSocket 连接。所有消息都走这个连接消息体里带 type 字段标识类型比如chat、tool_call、tool_result、heartbeat、control。RelayRouter 收到消息后根据 type 和会话 ID 路由到对应的后端服务。后端服务处理完后把结果发回 RelayRouterRelayRouter 再推送给客户端。这个架构的关键设计点是RelayRouter 不处理业务逻辑。它只做路由、连接管理、心跳维持、消息队列。业务逻辑全在后端服务里。这样 RelayRouter 可以做得非常轻量单机承载几万连接不是问题。5.2 消息协议设计让扩展变得容易消息协议我建议用 JSON虽然比二进制大一些但可读性和调试便利性远超那点带宽成本。每条消息的结构{ type: chat, session_id: sess_abc123, seq: 42, timestamp: 1700000000000, payload: { content: 帮我写一段关于秋天的开头, stream: true } }type是消息类型session_id标识会话seq是会话内的递增序列号用于检测丢失和乱序。payload是具体内容不同 type 的 payload 结构不同。我特意把seq放在顶层而不是 payload 里因为 RelayRouter 需要读取它来做顺序保证。如果客户端发的消息 seq 不连续RelayRouter 可以要求重发或标记异常。对于流式输出模型服务会连续发多条chat类型的消息每条带一个chunk字段。客户端收到后追加渲染。最后一条带done: true标记结束。5.3 会话状态管理内存、Redis 还是数据库会话状态放哪里是个需要权衡的问题。纯内存最快但 RelayRouter 重启就丢了。纯数据库最持久但每次读写都有延迟。我的做法是分层存储热状态放内存当前活跃会话的最近 20 条消息、工具调用上下文、心跳状态。这些数据访问频率极高放内存保证响应速度。温状态放 Redis会话的完整消息历史、用户配置。读写频率中等Redis 的性能足够而且支持持久化。冷状态放数据库超过 7 天的会话归档、审计日志。访问频率低用数据库存储成本更低。RelayRouter 在内存里维护一个 LRU 缓存活跃会话的状态常驻内存。当内存占用超过阈值时最久未活跃的会话状态被刷到 Redis内存里只保留一个索引。下次该会话有消息时再从 Redis 加载回来。这个策略实测下来单机 8GB 内存可以支撑大约 5000 个活跃会话每个会话平均 20 条热消息。超过这个量就需要加机器或调小热消息窗口。5.4 部署与扩容什么时候该加机器RelayRouter 是有状态的因为它维护了 WebSocket 连接和会话热状态。这意味着你不能简单地加机器做负载均衡因为同一个会话的 WebSocket 连接必须落在同一台 RelayRouter 上。我的扩容策略是一致性哈希 会话迁移。客户端连接时根据 session_id 做一致性哈希决定连哪台 RelayRouter。如果那台机器负载过高新会话会被引导到其他机器。已有会话不迁移除非机器下线。机器下线时上面的会话需要迁移。迁移过程是先把会话状态刷到 Redis然后通知客户端重连到新机器新机器从 Redis 加载状态。这个过程会有短暂的连接中断客户端需要做自动重连。我一般会在 RelayRouter 前面放一个负载均衡器但负载均衡器只做 TCP 转发不做 HTTP 层的路由。因为 WebSocket 升级后是长连接HTTP 层的负载均衡器可能会在连接空闲时切断它。TCP 层的转发更稳定。注意如果你的负载均衡器有空闲超时设置确保它大于你的心跳间隔。否则负载均衡器会在心跳到达之前就切断连接导致频繁重连。6. 常见问题与排查技巧实录6.1 连接频繁断开从心跳日志里找线索连接频繁断开是最常见的问题。我的排查顺序是第一步看心跳日志。如果心跳间隔是 30 秒但连接每 60 秒断一次那很可能是中间设备的空闲超时是 60 秒而心跳没有成功刷新它。检查心跳消息是否真的发出去了以及是否被中间设备拦截。第二步看断开时的错误码。WebSocket 关闭时会有 close code。1000 是正常关闭1001 是端点离开1006 是异常关闭没有收到 close 帧。1006 通常意味着网络层断了可能是 NAT 超时或代理切断。第三步抓包。如果日志看不出问题在客户端和服务端同时抓包看断开前最后几个数据帧是什么。我遇到过一种情况服务端发了 ping客户端回了 pong但 pong 在中间被某个代理吞了服务端以为客户端没响应主动断了。后来改成双向心跳客户端也主动发 ping问题就绕过去了。6.2 消息乱序与丢失序列号是你的朋友WebSocket 基于 TCPTCP 本身保证有序和不丢。但如果你在应用层做了异步处理——比如消息进入队列后由多个 worker 并发处理——那处理完成的顺序可能和接收顺序不一致。我的做法是每条消息带一个会话内递增的 seq。RelayRouter 在推送消息给客户端时检查 seq 是否连续。如果发现跳跃说明中间有消息丢失可能是 RelayRouter 内部队列溢出触发补发请求。客户端侧也维护一个期望的 seq。收到消息时如果 seq 大于期望值说明中间有消息没到客户端可以主动请求补发。如果 seq 小于期望值说明是重复消息直接丢弃。这个机制听起来简单但能解决 90% 的乱序和丢失问题。关键是 seq 的生成要严格递增不能因为服务重启就重置。我一般用“时间戳 机器 ID 自增数”的组合来生成全局唯一的 seq。6.3 工具调用卡住超时链路要层层设防工具调用卡住的原因很多外部 API 不响应、数据库锁等待、网络分区。排查时要从外到内逐层检查。先看 RelayRouter 的日志工具调用请求发出去了吗发出去了那问题在后端。没发出去检查 RelayRouter 到工具服务的连接。再看工具服务的日志请求收到了吗收到了看它调外部 API 的日志。没收到检查消息队列是否堆积。最后看外部依赖API 的响应时间是多少有没有限流数据库的慢查询日志有没有异常我的经验是每一层都要设超时。RelayRouter 到工具服务设 1 秒工具服务到外部 API 设 3 秒外部 API 本身如果有超时设置也要检查。任何一层没有超时都可能成为整个链路的堵点。下面是我整理的问题速查表现象可能原因排查方法解决连接每 60 秒断NAT 超时看心跳间隔和断开时间缩短心跳间隔消息偶尔丢失内部队列溢出看 RelayRouter 队列长度加队列容量或加机器工具调用无响应某层无超时逐层检查超时配置补上超时重连后状态丢失会话状态未持久化检查 Redis 写入重连时从 Redis 恢复移动端耗电快心跳太频繁看后台心跳日志后台暂停心跳6.4 我踩过的三个坑第一个坑心跳消息和业务消息共用连接但优先级没区分。有一次业务消息量很大心跳消息排在队列后面延迟很高导致误判超时。后来在 RelayRouter 里给心跳消息设了高优先级单独一个队列问题解决。第二个坑重连时没有做退避。网络刚断的时候客户端疯狂重连每秒试十几次把服务端打挂了。后来加了指数退避第一次等 1 秒第二次 2 秒第三次 4 秒最多等 30 秒。服务端压力瞬间降下来。第三个坑工具调用的结果没有做幂等。同一个工具调用因为重试被执行了两次结果返回了两条数据模型懵了。后来给每个工具调用加唯一 ID工具服务侧做幂等检查同一个 ID 只执行一次。7. 实时文本工作流的下一步我看到的几个方向Gemini Live Avatar 把实时多模态的门槛拉低了一大截但实时文本工作流的价值被严重低估了。我自己的体会是文本场景的实时化改造投入产出比远高于语音和视频因为文本的数据量小、处理链路短、用户感知明显。RelayRouter 这类中间层现在看起来是个“可选组件”但我判断它会越来越标配。因为实时 AI 应用的复杂度不在模型本身而在模型和用户之间的那层调度。谁把这层做稳了谁的产品体验就能拉开差距。如果你现在正在做类似的东西我的建议是先把 WebSocket 心跳和重连做扎实这是地基再把异步工具调用的超时和降级做好这是承重墙最后再考虑 RelayRouter 的部署和扩容这是装修。顺序反了后面会一直返工。我在实际使用中发现很多团队把精力花在模型选型和 prompt 调优上却忽略了连接层的稳定性。结果模型效果很好但用户经常遇到“消息发不出去”或“回复卡住”体验直接崩盘。连接层的东西不性感但它是实时 AI 的命脉。
返回列表