ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

实时AI架构实战:WebSocket与异步工具调用编排

2026/10/2 15:57:47 拓冰建站 浏览量
实时AI架构实战:WebSocket与异步工具调用编排 1. 从 Gemini Live Avatar 说起实时 AI 的真实分层Gemini Live Avatar 这类产品刚出来的时候很多人的第一反应是哦就是把聊天窗口换成了一个会说话的数字人。我一开始也这么想直到自己动手拆了一遍它的数据流才发现这个理解偏得离谱。它真正做的事情是把一条原本请求-响应式的文本工作流改造成了一条持续在线、双向流动的实时通道。这两者的差别不是前端换个皮肤那么简单而是整条链路的架构逻辑都变了。先把结论摆出来实时 AI 的核心不是视频这个表现形式而是文本工作流从同步阻塞变成了异步流式。Avatar 只是这层变化最直观的外壳。你看到的嘴型、表情、语音背后全是一连串文本事件在驱动——用户说的话先转成文本文本进模型模型吐出的 token 再被拆成语音和口型指令。整条链路里文本始终是主干视频和音频只是渲染层。那 RelayRouter 在这里扮演什么角色简单说它是这条实时链路里的交通调度员。当模型需要调用外部工具查天气、查数据库、调 API时这些调用不能阻塞主对话流否则用户就会看到 Avatar 卡在那里一动不动。RelayRouter 要做的就是把这些工具调用异步化、路由化让主链路继续流畅地吐 token工具结果回来了再插进对话里。这就是异步工具调用这个词在实时场景下的真实含义。这篇文章我想聊的不是怎么做一个 Avatar而是想把这套实时架构里最容易被忽略、也最容易踩坑的部分——文本工作流如何与 WebSocket、异步工具调用、路由层配合——掰开揉碎讲清楚。适合谁看如果你正在做实时对话类产品或者你手上有个 Django/React 项目想加实时推送又或者你单纯好奇实时 AI 到底难在哪那这篇应该能给你一些能直接抄的东西。我会尽量用从业者的口吻把原理、参数、踩坑经验都摊开讲不整那些虚的。2. 实时 AI 的架构分层与 RelayRouter 的定位2.1 为什么聊天变视频是个伪命题先破一个常见的误解。很多人以为实时 AI 的难点在于怎么把视频流做流畅于是把大量精力砸在编解码、帧率、带宽上。但实际上视频流本身是成熟技术真正难的是驱动视频的那条文本链路能不能做到低延迟、不阻塞、可中断。我举个具体的场景。用户对着 Avatar 说帮我查一下明天北京的天气顺便看看有没有去上海的火车票。这句话里其实藏了两个工具调用天气查询和车次查询。如果按传统的同步请求-响应模式流程是这样的用户说完 → 语音转文本 → 文本进模型 → 模型决定调天气工具 → 等天气结果 → 模型决定调车次工具 → 等车次结果 → 模型组织语言 → 转语音 → 播放。这一串下来用户要盯着一个静止的 Avatar 等好几秒体验直接崩掉。实时 AI 要做的是把这条链路拆开模型一边思考一边吐 token工具调用在后台并行跑谁先回来谁先插进对话。Avatar 的嘴一直在动用户感觉它在边想边说。所以你看核心矛盾从来不是视频而是文本工作流的调度。视频只是把文本在流动这件事可视化了而已。2.2 RelayRouter 到底解决什么问题RelayRouter 这个名字听起来像个网络设备但在这套架构里它其实是一个应用层的消息路由与调度组件。它的职责可以拆成三块第一块是连接管理。实时场景下客户端和服务端之间维持的是一条长连接通常是 WebSocket。RelayRouter 要负责这条连接的建立、心跳维持、断线重连、以及多路复用。为什么需要多路复用因为一条对话里可能同时有语音流、文本流、工具调用结果流如果每条都开一个连接管理成本会爆炸。第二块是消息路由。模型产生的消息不是只有一种类型。有给用户看的文本 token有发给工具执行器的调用指令有工具返回的结果还有系统级的控制消息比如打断当前输出。RelayRouter 要根据消息类型把它们分发到正确的处理管道。第三块是异步工具调用编排。这是最容易被低估的部分。工具调用不能同步等待否则主链路就堵死了。RelayRouter 要把调用请求发出去记录一个 pending 状态然后继续处理主链路。等工具结果回来再通过一个回调或者事件机制把结果注入到对话上下文里。我用一个生活化的类比来解释。想象一个餐厅厨师模型在做菜服务员RelayRouter负责传菜和取食材。如果服务员每次取食材都要站在仓库门口等那厨师就得停工。好的服务员会把单子递给仓库然后继续传别的菜仓库备好了喊一声服务员再去取。RelayRouter 就是这个不傻等的服务员。2.3 文本工作流在实时架构中的位置很多人做实时 AI 的时候会把文本工作流当成一个附属品觉得反正最后都要转成语音和视频文本随便处理一下就行。这是个致命的误区。实际上文本工作流是整条链路的中枢神经它决定了模型什么时候该说话什么时候该调工具工具调用的结果怎么合并回上下文用户打断时哪些状态要保留哪些要丢弃多轮对话的上下文怎么在长连接里维护我见过一个团队前端做得非常炫Avatar 表情丰富得不行但用户一打断就出问题——因为他们的文本工作流是同步的打断信号发过去的时候模型还在等一个工具结果整个链路卡住了。最后返工重写了整个调度层。所以我的建议是先把文本工作流的异步化和可中断性做扎实再考虑渲染层。3. WebSocket 在实时文本流中的实操要点3.1 为什么是 WebSocket 而不是轮询或 SSE实时文本流对传输层的要求有三个双向、低延迟、长连接。轮询polling第一个就出局了因为它本质上是客户端反复问有数据吗延迟高、浪费带宽用户说一句话要等好几个轮询周期才能看到响应。SSEServer-Sent Events是单向的服务端能推给客户端但客户端要发消息还得另开 HTTP 请求做双向对话很别扭。WebSocket 是唯一同时满足三个要求的方案。握手之后连接一直保持双方随时可以发消息延迟基本就是网络往返时间。我在实际项目里对比过同样的对话场景WebSocket 的首字延迟比轮询低了大概 60% 到 70%这个差距在实时场景下是决定性的。不过 WebSocket 也不是没有代价。它需要服务端维持大量长连接对内存和连接数管理有要求。而且它不像 HTTP 那样天然有请求-响应语义消息的对应关系要自己维护。这些代价换来的是实时性值不值取决于你的场景。如果是用户发一条、等几秒看结果的场景其实 SSE 加 HTTP POST 就够了没必要上 WebSocket。但如果是边说边出字、随时可打断的场景WebSocket 是必选项。3.2 连接建立与心跳机制的设计WebSocket 连接建立本身不复杂但心跳机制是很多人会忽略的坑。长连接如果长时间没有数据往来中间的网络设备负载均衡、防火墙可能会悄悄把连接掐掉而两端都不知道。等你下次发消息的时候才发现连接已经死了用户就会看到消息发出去没反应。心跳的设计有几个关键参数。我一般用这样的配置参数建议值说明心跳间隔25-30 秒太短浪费资源太长容易被中间设备判定为空闲心跳超时心跳间隔的 2 倍超过这个时间没收到 pong 就判定连接异常重连退避1s, 2s, 4s, 8s...指数退避避免雪崩式重连最大重连次数5-10 次超过后提示用户手动刷新心跳的实现有两种常见方式一种是应用层自己发 ping/pong 消息另一种是用 WebSocket 协议自带的 ping/pong 帧。我倾向于用应用层的心跳因为可控性更强能携带额外的状态信息比如当前对话 ID排查问题的时候也方便。这里有个实操心得心跳消息不要走业务消息的同一条处理管道。我踩过一次坑把心跳和业务消息混在一起处理结果业务逻辑一忙心跳就被延迟处理服务端误判客户端掉线把连接踢了。后来我把心跳单独走一条轻量通道只做存活检测不碰业务逻辑问题就没了。3.3 消息格式与多路复用的设计一条 WebSocket 连接上跑着多种消息必须有个清晰的格式来区分。我一般用这样的结构{ type: text_token, session_id: abc123, seq: 42, payload: { content: 北京, is_final: false } }type字段是路由的关键RelayRouter 就是靠它来决定消息往哪走的。常见的 type 有text_token模型输出的文本片段、tool_call工具调用请求、tool_result工具返回结果、control控制消息如打断、结束、heartbeat心跳。seq字段是序列号用来保证消息顺序。WebSocket 本身保证单条连接上的消息有序但如果你做了多路复用或者重连就需要自己维护序列号来去重和排序。我遇到过重连后旧消息和新消息交错的情况就是因为没有序列号前端把过期的 token 也渲染出来了用户看到文字重复。session_id用来区分不同的对话会话。一个用户可能同时开着多个对话或者一个页面里有多个实时组件靠 session_id 来隔离。注意消息格式一旦定下来前后端要严格对齐。我建议在项目初期就把消息类型定义成一个共享的 schema比如用 TypeScript 的 type 或者 JSON Schema前后端都从这个 schema 生成代码避免手写导致的字段名不一致。3.4 前端 WebSocket 的封装与状态管理前端这块直接用原生 WebSocket API 也能跑但状态管理会很乱。我一般会封装一个类把连接状态、重连逻辑、消息队列都管起来。核心要处理的状态有四种connecting、open、closing、closed。UI 上要根据这些状态给用户反馈比如连接中显示 loading断线了显示正在重连。React 项目里我习惯把 WebSocket 实例放在一个 Context 或者自定义 Hook 里避免组件重渲染时反复创建连接。这里有个坑不要在 useEffect 里直接创建 WebSocket 而不做清理否则组件一挂载卸载就会产生一堆僵尸连接。正确的做法是在 useEffect 的返回函数里关闭连接或者用 ref 持有实例。还有一个细节是消息队列。连接还没建立好的时候用户可能已经发了消息这些消息不能丢。我会维护一个 pending 队列连接 open 之后把队列里的消息依次发出去。同理断线期间服务端推的消息重连后要能补上这就需要服务端支持消息重放靠 seq 或者时间戳。4. 异步工具调用的编排与 RelayRouter 的实现4.1 同步调用为什么会拖垮实时体验前面提过同步工具调用会让主链路阻塞。我这里展开讲讲具体的影响。假设模型决定调用一个查天气的 API这个 API 平均响应 800 毫秒。如果是同步调用这 800 毫秒里模型不能输出任何 tokenAvatar 就静止了。用户会觉得它卡了。如果一轮对话里有三个工具调用那就是 2.4 秒的静止体验直接完蛋。异步调用的思路是模型决定调工具的那一刻RelayRouter 立刻把调用请求发出去同时给模型返回一个调用已发起的信号模型可以继续输出比如先说一句我帮你查一下等工具结果回来再继续。这样用户感知到的就是连续的输出中间没有卡顿。这里有个关键设计模型需要知道工具调用是异步的。也就是说在 prompt 或者工具定义里要明确告诉模型调用工具后不要等待结果先继续说话。否则模型会傻傻地等。我在实际项目里会在系统提示里加一句类似工具调用是异步的你可以在等待结果的同时继续与用户交流的说明效果很明显。4.2 RelayRouter 的消息路由逻辑RelayRouter 的核心是一个消息分发器。我用伪代码展示一下它的主循环逻辑async def route_message(message): msg_type message[type] if msg_type text_token: await forward_to_client(message) elif msg_type tool_call: call_id generate_call_id() pending_calls[call_id] message asyncio.create_task(execute_tool(call_id, message)) await send_ack_to_model(call_id) elif msg_type tool_result: call_id message[call_id] if call_id in pending_calls: del pending_calls[call_id] await inject_to_context(message) elif msg_type control: await handle_control(message)这段逻辑里tool_call的处理是关键。它不等待工具执行完而是创建一个异步任务然后立刻给模型发一个 ack。模型收到 ack 后继续输出。工具执行完结果通过tool_result消息回来RelayRouter 再把它注入到对话上下文里。pending_calls这个字典用来追踪哪些调用还在进行中。它的作用有两个一是防止重复注入结果二是当用户打断时可以取消所有 pending 的调用。打断场景下如果不管 pending 调用工具结果回来后会突然插进已经结束的对话里造成混乱。4.3 工具结果的注入时机与顺序工具结果什么时候注入是个需要仔细考虑的问题。注入太早模型可能还没说完当前的话结果插进去会打断输出注入太晚用户等太久。我的做法是在模型当前输出段落结束时注入。具体来说RelayRouter 监听模型的输出流当检测到一个自然的停顿比如句号、换行或者模型主动说稍等时检查有没有 pending 的结果有就注入。顺序问题也很重要。如果一轮对话里有多个工具调用结果回来的顺序可能和调用顺序不一致。比如先调天气慢后调车次快车次结果先回来。这时候不能直接按回来顺序注入否则模型会先讲车次再讲天气逻辑就乱了。我的处理方式是给每个调用分配一个序号注入时按序号排序确保结果按调用顺序进入上下文。提示工具结果的注入最好带上原始调用的上下文比如你刚才查询的北京天气结果是...这样模型能准确对应不会把结果张冠李戴。4.4 打断与取消的处理打断是实时对话里最考验架构的环节。用户说停一下这时候可能有正在输出的文本流、正在执行的工具调用、正在排队的消息。正确的处理是立即停止向客户端推送文本 token取消所有 pending 的工具调用如果工具支持取消的话清空消息队列里还没发出去的消息保留已经完成的结果但标记为未使用给模型发一个控制消息告诉它当前输出被中断这里有个坑取消工具调用不一定能真正取消。如果工具是一个 HTTP 请求你发起了就很难撤回只能忽略它的结果。所以 RelayRouter 要维护一个已取消的标记工具结果回来时检查这个标记如果是已取消的直接丢弃不注入上下文。我踩过的另一个坑是打断后的状态清理不彻底。有一次用户打断后继续说话模型却把打断前的一个工具结果又用上了导致回答驴唇不对马嘴。后来发现是 pending_calls 没有在打断时清空结果回来后被误注入了。所以打断逻辑里清理 pending 状态是必须的。5. 常见问题排查与实战避坑5.1 连接建立了但收不到消息这是最高频的问题之一。表现是 WebSocket 的 onopen 回调触发了但 onmessage 一直不触发。排查思路按这个顺序走先确认服务端有没有真的发消息。在服务端的发送逻辑里打日志看消息有没有进入发送队列。如果服务端发了但客户端没收到检查是不是被中间层负载均衡、网关拦截了。有些网关对 WebSocket 的消息大小有限制超过就静默丢弃。再检查消息格式。如果服务端发的 JSON 格式有问题客户端解析失败可能不会触发 onmessage 的错误回调而是静默失败。我建议在 onmessage 里加 try-catch解析失败时打日志。还有一种情况是连接被中间设备降级了。有些代理会把 WebSocket 升级请求当成普通 HTTP 处理握手看起来成功了但实际上走的是轮询模拟消息延迟很大甚至丢失。这种情况要看响应头里有没有Upgrade: websocket。5.2 心跳正常但业务消息延迟高心跳能通说明连接是活的但业务消息延迟高通常是消息处理管道堵塞了。可能的原因有业务逻辑里有同步阻塞操作比如同步的数据库查询、同步的文件读写把事件循环堵住了消息处理没有做并发控制大量消息堆积序列化/反序列化开销太大消息体过大我的排查方法是先看消息从服务端发出到客户端收到的耗时分布。如果耗时集中在服务端处理阶段那就是业务逻辑的问题如果集中在传输阶段那就是网络或消息体大小的问题。定位到之后对症下药阻塞操作改成异步消息体做压缩或者分片。5.3 工具调用结果丢失或重复结果丢失通常是 pending_calls 的管理有问题。比如调用发出去了但 pending_calls 里没记录结果回来时找不到对应的调用就被丢弃了。或者结果回来了但注入逻辑有 bug没真正进上下文。结果重复一般是重试机制导致的。如果工具调用失败后自动重试而重试的结果和第一次的结果都回来了就会重复注入。解决办法是给每个调用一个唯一的 call_id注入前检查这个 call_id 是否已经处理过。我整理了一个速查表方便对照排查现象可能原因排查方向连接建立但无消息网关拦截、格式错误、连接降级查服务端日志、检查响应头心跳正常但延迟高事件循环阻塞、消息堆积查耗时分布、检查同步操作工具结果丢失pending 管理缺失检查 call_id 记录工具结果重复重试未去重检查 call_id 去重逻辑打断后状态混乱pending 未清理检查打断清理逻辑重连后消息错乱无序列号检查 seq 维护5.4 重连后的状态恢复重连是必然会发生的关键是重连后能不能恢复到正确的状态。我的做法是客户端在重连成功后发送一个resume消息带上最后收到的 seq。服务端根据这个 seq把之后的消息重放给客户端。如果服务端没有缓存这些消息那就只能从当前状态继续中间丢失的部分要提示用户。这里有个设计取舍缓存多少消息用于重放缓存太多占内存太少重连后补不全。我一般缓存最近 5 分钟或者最近 1000 条消息超过就丢弃最旧的。对于实时对话场景这个量级基本够用。注意重放的消息要标记为历史消息前端渲染时不要触发副作用比如语音播放否则用户重连后会听到一堆重复的语音。6. 从文本工作流到实时体验的完整链路6.1 一条消息的完整生命周期我把一条用户消息从发出到收到响应的完整链路串一遍这样你能看到 RelayRouter 在每个环节的位置。用户在客户端说话语音转文本后通过 WebSocket 发出去。消息到达服务端RelayRouter 接收并解析识别为user_input类型。这条消息被送进模型推理管道。模型开始输出 token每个 token 通过 RelayRouter 的text_token通道推给客户端。如果模型决定调用工具它输出一个tool_call指令RelayRouter 拦截发起异步调用同时给模型发 ack。模型继续输出客户端继续收到 token。工具结果回来RelayRouter 检查 pending 状态注入上下文。模型基于新上下文继续输出直到本轮结束。客户端收到is_final标记的 token结束本轮渲染。整条链路里RelayRouter 出现了四次接收用户输入、转发模型 token、拦截工具调用、注入工具结果。它就像一个贯穿始终的调度中枢。6.2 延迟优化的几个关键点实时体验的核心指标是首字延迟用户说完到看到第一个字的时间和 token 间隔字与字之间的时间。优化这两个指标我总结了几个有效的点首字延迟方面最大的瓶颈通常是语音转文本和模型的首 token 生成。语音转文本可以用流式识别边说边出结果不用等整句说完。模型首 token 可以用预热或者更小的模型做首句生成。token 间隔方面关键是别让任何同步操作卡在输出管道上。工具调用必须异步数据库查询必须异步日志写入最好也异步。我见过一个项目因为日志是同步写的每输出一个 token 就写一次磁盘token 间隔直接翻倍。改成批量异步写之后流畅度立刻上来了。还有一个容易被忽略的点是消息的序列化和网络传输。如果每个 token 都单独发一条 WebSocket 消息消息头开销会很大。我一般会做小批量聚合比如攒够 5 个 token 或者间隔超过 50 毫秒就发一次这样既保证实时性又降低开销。6.3 这套架构的扩展方向这套架构不只适用于 Avatar 场景。任何需要实时文本流 异步工具调用的场景都能用。比如实时客服系统用户提问的同时后台查订单、查物流结果异步注入比如实时协作编辑多人的操作通过 WebSocket 同步冲突解决用类似 RelayRouter 的路由逻辑再比如实时数据看板数据变化通过 WebSocket 推送前端增量更新。扩展的时候RelayRouter 的路由规则要相应调整但核心的连接管理 消息路由 异步编排这三层结构是不变的。我个人的经验是把这套骨架搭好之后换业务场景主要是改路由规则和工具定义底层的连接和调度逻辑基本可以复用。最后分享一个我在实际项目里的小技巧给 RelayRouter 加一个消息追踪 ID从用户输入到最终响应整条链路上的所有消息都带上这个 ID。排查问题的时候拿这个 ID 一搜整条链路的消息流就全出来了比在多个日志文件里翻要快得多。这个习惯帮我省了无数排查时间强烈建议你也加上。