☰
基于WebSocket与RelayRouter的实时AI文本工作流架构设计
2026/11/2 9:00:16 网站建设 项目流程

1. 从 Gemini Live Avatar 说起:实时 AI 的真实门槛在哪里

Gemini Live Avatar 刚出来那阵子,我朋友圈里做前端和做 AI 应用的人都在转。大部分人第一反应是“数字人又进化了”,但我盯着它那个交互链路看了很久,真正让我感兴趣的其实不是那张会说话的脸,而是它背后那条实时数据通道——用户说话、模型理解、工具调用、结果回传、Avatar 口型同步,这一整套动作要在几百毫秒内完成,靠传统的“请求-响应”模式根本跑不起来。

这就是我想聊的核心问题:实时 AI 不只是把聊天变成视频。很多人把注意力放在视觉呈现上,觉得接个 TTS、配个口型动画就叫实时 AI 了。但真正做过线上服务的人都知道,视觉层是最容易替换的,难的是中间那层——文本工作流怎么在保持实时性的同时,还能可靠地完成异步工具调用。Gemini Live Avatar 之所以能跑通,是因为它把文本流、音频流、工具调用结果流做了统一调度,而不是简单地把聊天记录丢给一个渲染层。

在这个背景下,RelayRouter这个思路就值得拿出来单独说。它不是一个具体的开源项目名,而是一类架构模式的代称:在客户端和模型服务之间加一层中继路由,负责消息分发、状态同步、工具调用编排和连接保活。你可以把它理解成实时 AI 应用里的“交通枢纽”——所有进出的数据包都要经过它,但它本身不生产内容,只做调度和转发。

这篇文章适合谁看?如果你正在做以下任意一件事,那接下来的内容应该对你有用:

  • 用 WebSocket 做实时推送,但发现工具调用一多就乱序或超时;
  • 在 React 前端和 Python 后端之间传 AI 生成内容,不知道怎么设计消息协议;
  • 想给现有文本工作流加上“实时感”,但不想重写成全双工音视频架构;
  • 对 RelayRouter 这个概念好奇,想知道它在实际项目里怎么落地。

我会从架构选型、WebSocket 细节、异步工具调用编排、前端状态同步、常见坑排查这几个角度,把这条链路拆开讲清楚。不堆概念,直接说我在实际项目里怎么做的、为什么这么做、哪里踩过坑。

2. RelayRouter 到底解决什么问题:文本工作流里的中继路由设计

2.1 为什么不能让前端直接连模型服务

很多人第一版实时 AI 应用是这么写的:前端开一个 WebSocket 直接连到模型服务的流式接口,收到 token 就渲染。简单场景下没问题,但一旦涉及工具调用,问题就来了。

模型在生成过程中说“我要查一下天气”,这个工具调用请求得发给谁?如果前端直接连模型,那前端就得负责执行工具、把结果塞回去、再继续接收后续 token。这意味着:

  • 工具的执行逻辑暴露在客户端,安全性和可维护性都很差;
  • 多个工具并行调用时,前端要自己管理调用 ID 和结果映射;
  • 连接断了之后,正在执行中的工具调用状态全丢;
  • 不同模型服务商的流式协议不一样,前端要写多套适配。

RelayRouter 的思路就是把这些脏活累活收拢到中间层。前端只跟 RelayRouter 建立一个 WebSocket 连接,所有消息——用户输入、模型输出、工具调用请求、工具执行结果、状态变更——都走这一条通道。RelayRouter 负责:

  • 维护与模型服务的上游连接;
  • 解析模型返回的流式事件,识别出工具调用意图;
  • 把工具调用分发给对应的执行器(可以是后端服务、也可以是前端注册的处理器);
  • 收集工具结果,按协议回传给模型;
  • 把最终文本流和状态事件推送给前端。

这样做的好处是前端只需要理解一套消息协议,模型换了、工具加了、执行器迁移了,前端几乎不用改。

2.2 RelayRouter 的核心职责拆解

我把 RelayRouter 的职责归纳成四块,每一块都有对应的设计决策:

第一块是连接管理。前端和 RelayRouter 之间是长连接,RelayRouter 和模型服务之间也是长连接。两条连接的生命周期要分开管理。前端断线重连时,RelayRouter 不应该立刻断开上游,而是保留一段时间会话上下文,等前端回来或者超时再清理。这个“保留窗口”设多长,取决于你的业务对实时性的要求,我一般设 30 到 60 秒。

第二块是消息路由。每条消息都要有明确的类型和路由目标。用户输入走user_input通道,模型流式输出走model_stream通道,工具调用走tool_call通道,工具结果走tool_result通道,系统状态走system_event通道。通道之间不能混,混了之后排查问题会非常痛苦。

第三块是工具调用编排。这是 RelayRouter 最核心的价值。模型可能在一次响应里发起多个工具调用,有的可以并行,有的必须串行。RelayRouter 要维护一个调用队列,记录每个调用的 ID、状态、超时时间、重试次数。并行调用时,要等所有结果都回来才能继续;串行调用时,要按顺序执行,前一个失败要考虑是否中断后续。

第四块是状态同步。前端需要知道当前会话处于什么状态:是模型正在生成、还是等待工具结果、还是空闲。RelayRouter 要主动推送状态变更事件,而不是让前端去猜。状态机设计得越清晰,前端逻辑越简单。

2.3 和直接轮询、SSE 方案的对比

有人会问:为什么不用 SSE 或者轮询?我用一张表把几种方案在实际项目里的表现列出来:

方案实时性双向通信工具调用支持断线恢复适用场景
短轮询差否难容易低频状态查询
长轮询一般否一般容易兼容性要求高的场景
SSE好否单向推送可以,回传困难一般纯服务端推送
WebSocket好是天然支持需要自己实现实时双向交互
WebSocket + RelayRouter好是编排能力强可控实时 AI 工作流

SSE 的问题在于它是单向的。模型推流给前端没问题,但工具调用结果要回传时,你还得另开一个 HTTP 接口,两条通道的状态同步很麻烦。WebSocket 天然双向,加上 RelayRouter 做编排,是目前实时 AI 文本工作流里比较稳妥的选择。

注意:WebSocket 不是银弹。如果你的场景只是服务端单向推送通知,SSE 更简单、更省资源。只有当你需要双向交互、工具调用回传、多路消息复用的时候,WebSocket + RelayRouter 的复杂度才是值得的。

3. WebSocket 通道设计:心跳、重连与消息协议

3.1 连接建立与鉴权怎么做才稳

WebSocket 握手阶段是 HTTP 升级请求,所以鉴权可以走标准的 Header 或者 Query 参数。我一般用短期 token 放在 Query 里,因为浏览器原生 WebSocket API 不支持自定义 Header。token 有效期设短一点,比如 5 分钟,连接建立后通过第一条消息做一次应用层确认,确认通过后 token 就可以作废。

// 前端建立连接示例 const token = await fetchShortLivedToken(); const ws = new WebSocket(`wss://your-relay-host/ws?token=${token}`); ws.onopen = () => { // 应用层握手确认 ws.send(JSON.stringify({ type: 'client_hello', sessionId: currentSessionId, clientVersion: '1.0.0' })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'server_hello_ack') { // 连接正式可用 setConnectionState('ready'); } };

服务端收到client_hello后,要校验 sessionId 是否合法、是否已有活跃连接。如果同一 session 已有连接,策略有两种:踢掉旧连接,或者拒绝新连接。我倾向于踢旧留新,因为用户可能只是刷新了页面,旧连接还没超时。

3.2 心跳机制:不只是 ping/pong

WebSocket 协议层有 ping/pong 帧,但很多代理和负载均衡器会拦截或者不转发。所以应用层心跳更可靠。我的做法是:

  • 客户端每 15 秒发一次heartbeat消息,带一个递增的 seq;
  • 服务端收到后立刻回heartbeat_ack,也带 seq;
  • 服务端如果 45 秒没收到任何客户端消息,主动关闭连接;
  • 客户端如果 30 秒没收到 ack,主动重连。
// 前端心跳实现 let heartbeatSeq = 0; let lastAckTime = Date.now(); const heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { heartbeatSeq++; ws.send(JSON.stringify({ type: 'heartbeat', seq: heartbeatSeq, timestamp: Date.now() })); } // 检查是否超时 if (Date.now() - lastAckTime > 30000) { console.warn('心跳超时,主动重连'); ws.close(); reconnect(); } }, 15000); // 收到 ack 时更新 function onHeartbeatAck(msg) { lastAckTime = Date.now(); }

实操心得:心跳间隔不要设太短,15 到 30 秒比较合适。设太短会增加服务端压力,设太长则断线发现不及时。另外,心跳消息不要走业务消息队列,单独处理,避免被大量流式 token 阻塞。

3.3 消息协议设计:类型、序号与幂等

RelayRouter 的消息协议我建议用 JSON,字段固定,类型明确。核心字段包括:

  • type:消息类型,如user_input、model_stream、tool_call、tool_result、system_event;
  • sessionId:会话 ID;
  • seq:消息序号,用于排序和去重;
  • timestamp:毫秒时间戳;
  • payload:具体内容,根据 type 不同而不同。
{ "type": "tool_call", "sessionId": "sess_abc123", "seq": 42, "timestamp": 1710000000000, "payload": { "callId": "call_xyz789", "toolName": "get_weather", "arguments": { "city": "Shanghai" }, "timeoutMs": 5000 } }

序号seq很重要。前端收到消息后按 seq 排序,如果发现 seq 跳跃,说明中间有消息丢了,可以主动请求补发或者触发重连。工具调用结果回传时,callId必须原样带回,RelayRouter 靠它来匹配请求和响应。

幂等性方面,tool_result消息要支持重复发送。如果前端不确定服务端是否收到,可以重发,RelayRouter 根据callId去重,只处理第一次。

3.4 断线重连与状态恢复

断线重连不是简单地重新连上就行,关键是状态恢复。我的做法是:

  1. 前端记录最后收到的seq;
  2. 重连成功后,发送resume消息,带上lastSeq;
  3. RelayRouter 检查该 session 的缓冲区,把lastSeq之后的消息补发;
  4. 如果缓冲区已经清理,返回resume_failed,前端清空本地状态,重新初始化会话。

缓冲区大小要设上限,比如保留最近 500 条消息或者最近 5 分钟的消息。超过上限就丢弃最旧的,并标记该 session 不可恢复。

// 重连后恢复 function onReconnect() { ws.send(JSON.stringify({ type: 'resume', sessionId: currentSessionId, lastSeq: lastReceivedSeq })); } // 处理恢复结果 function onResumeResult(msg) { if (msg.type === 'resume_ok') { // 补发的消息会陆续到达,按 seq 插入 } else if (msg.type === 'resume_failed') { // 清空本地状态,重新开始 resetSession(); } }

4. 异步工具调用:RelayRouter 最容易被低估的部分

4.1 工具调用的生命周期管理

模型发起工具调用时,RelayRouter 要做的第一件事是注册调用。每个调用有一个唯一callId,状态从pending开始,然后变成dispatched、running、completed或failed。状态变更要记录时间戳,方便排查超时。

# 服务端调用注册示例(Python 伪代码) class ToolCallRegistry: def __init__(self): self.calls = {} def register(self, call_id, tool_name, arguments, timeout_ms): self.calls[call_id] = { 'call_id': call_id, 'tool_name': tool_name, 'arguments': arguments, 'status': 'pending', 'created_at': time.time(), 'timeout_ms': timeout_ms, 'retries': 0 } def mark_running(self, call_id): if call_id in self.calls: self.calls[call_id]['status'] = 'running' self.calls[call_id]['started_at'] = time.time() def mark_completed(self, call_id, result): if call_id in self.calls: self.calls[call_id]['status'] = 'completed' self.calls[call_id]['result'] = result self.calls[call_id]['finished_at'] = time.time()

超时检查用一个后台任务定期扫描,发现running状态超过timeout_ms的调用,标记为failed,并决定是否重试。重试次数一般不超过 2 次,且只对幂等工具重试。

4.2 并行调用与串行调用的编排策略

模型一次可能发起多个工具调用。RelayRouter 要判断这些调用之间有没有依赖关系。如果模型在同一个响应里发起了多个调用,默认按并行处理;如果模型明确说了“先查 A 再用 A 的结果查 B”,那就是串行。

并行调用的编排逻辑:

  1. 收集本轮所有tool_call;
  2. 为每个调用启动独立的执行任务;
  3. 等待所有任务完成或超时;
  4. 把所有结果打包,按协议回传给模型;
  5. 如果有关键调用失败,决定是中断还是继续。
# 并行调用编排(Python 伪代码) async def handle_parallel_calls(calls): tasks = [] for call in calls: task = asyncio.create_task(execute_tool(call)) tasks.append(task) results = await asyncio.gather(*tasks, return_exceptions=True) formatted_results = [] for call, result in zip(calls, results): if isinstance(result, Exception): formatted_results.append({ 'callId': call['callId'], 'status': 'error', 'error': str(result) }) else: formatted_results.append({ 'callId': call['callId'], 'status': 'success', 'result': result }) return formatted_results

串行调用则要维护一个执行队列,前一个完成后才触发下一个。串行场景下,超时时间要累加计算,不能每个调用都用同一个超时。

4.3 工具结果回传与模型续写

工具结果回传给模型时,格式要符合模型服务商的协议。以常见的流式接口为例,回传的消息里要包含callId和结果内容,模型收到后会继续生成后续 token。

这里有个容易踩的坑:模型可能在等待工具结果期间已经关闭了流式连接。RelayRouter 要检测这种情况,如果上游连接已断,要么重新建立连接并带上上下文,要么把工具结果缓存起来,等下次请求时一并提交。

注意:不同模型服务商对工具结果回传的格式要求不一样。有的要求把结果放在tool角色的消息里,有的要求放在function字段里。RelayRouter 要做一层适配,把内部统一的格式转换成上游要求的格式。

4.4 超时、重试与降级处理

工具调用超时是常态,不是异常。RelayRouter 要预设降级策略:

  • 超时后先重试一次,重试仍失败则返回错误结果给模型;
  • 模型收到错误结果后,可以选择忽略、换工具、或者告知用户;
  • 如果多个工具都失败,RelayRouter 可以主动中断本轮生成,推送system_event告知前端。

重试策略我一般用指数退避:第一次等 500ms,第二次等 1s,第三次等 2s。但总时间不能超过模型等待工具结果的超时上限,否则模型那边已经超时了,你重试也没意义。

5. 前端状态同步:React 里怎么管实时消息流

5.1 消息队列与渲染节流

前端收到流式 token 后,不能每来一个就 setState,那样 React 会频繁重渲染,性能很差。我的做法是维护一个消息缓冲区,用requestAnimationFrame或者固定间隔(比如 50ms)批量刷新到 UI。

// React 中批量刷新示例 function useStreamBuffer() { const bufferRef = useRef([]); const [displayText, setDisplayText] = useState(''); useEffect(() => { let rafId; const flush = () => { if (bufferRef.current.length > 0) { const chunk = bufferRef.current.join(''); bufferRef.current = []; setDisplayText(prev => prev + chunk); } rafId = requestAnimationFrame(flush); }; rafId = requestAnimationFrame(flush); return () => cancelAnimationFrame(rafId); }, []); const pushToken = useCallback((token) => { bufferRef.current.push(token); }, []); return { displayText, pushToken }; }

这样每帧最多刷新一次,渲染压力可控。如果 token 量特别大,还可以进一步降低刷新频率,比如 100ms 一次。

5.2 工具调用状态在前端怎么展示

工具调用对用户来说应该是“可见但不可扰”的。我的做法是在消息流里插入一个轻量的状态条,显示“正在查询天气...”,完成后变成“已获取天气信息”,失败则显示“查询失败,正在重试”。

前端需要维护一个toolCalls的 Map,key 是callId,value 是状态对象。收到tool_call时插入,收到tool_result时更新。渲染时按时间顺序展示。

const [toolCalls, setToolCalls] = useState(new Map()); function onToolCall(msg) { setToolCalls(prev => { const next = new Map(prev); next.set(msg.payload.callId, { name: msg.payload.toolName, status: 'running', startedAt: Date.now() }); return next; }); } function onToolResult(msg) { setToolCalls(prev => { const next = new Map(prev); const existing = next.get(msg.payload.callId); if (existing) { next.set(msg.payload.callId, { ...existing, status: msg.payload.status, result: msg.payload.result, finishedAt: Date.now() }); } return next; }); }

5.3 连接状态与用户提示

用户需要知道当前连接是否正常。我一般做三档提示:

  • 绿色:连接正常,实时更新中;
  • 黄色:连接不稳定,正在重连;
  • 红色:连接断开,请检查网络。

状态切换要有防抖,避免网络抖动导致提示频繁闪烁。比如从绿色变黄色要连续 3 次心跳超时才触发,从黄色变绿色只要一次 ack 就恢复。

5.4 多标签页与多会话的隔离

用户可能同时开多个标签页,每个标签页一个会话。RelayRouter 要支持按sessionId隔离消息。前端每个标签页生成独立的sessionId,存在sessionStorage里,这样刷新页面会话还在,新开标签页则是新会话。

如果用户希望多标签页共享会话,那就把sessionId存在localStorage里,但要注意处理多连接冲突。我的建议是默认隔离,共享作为可选配置。

6. 常见问题与排查技巧实录

6.1 连接建立成功但收不到消息

这是最经典的问题。WebSocket 的onopen触发了,但onmessage一直不触发。可能原因和排查步骤:

现象可能原因排查方法
onopen 触发,无消息服务端未发送应用层握手确认检查服务端是否在连接后主动推送
心跳有 ack,业务消息无消息被路由到其他 session检查 sessionId 是否匹配
偶发无消息代理缓冲区未刷新检查是否有中间代理,尝试小消息测试
完全无消息消息格式解析失败检查 JSON 解析是否抛异常

我遇到过一次,服务端日志显示消息已发送,但前端就是收不到。最后发现是 Nginx 的proxy_buffering没关,消息被缓冲了。加上proxy_buffering off;就好了。

6.2 工具调用结果乱序或丢失

乱序通常是因为并行调用时没有按callId匹配,而是按到达顺序处理。解决方法是前端维护callId到 UI 元素的映射,收到结果时按 ID 更新,而不是追加。

丢失则可能是超时后被清理了。检查 RelayRouter 的超时设置和缓冲区大小。如果工具执行时间确实很长,要适当调大超时,或者改成异步通知模式——先返回“已受理”,完成后单独推送结果。

6.3 心跳正常但业务消息延迟高

心跳走的是轻量通道,业务消息走的是流式通道,两者可能被不同的队列处理。如果业务消息延迟高,检查:

  • 流式 token 是否在服务端被批量聚合后才发送,聚合窗口是否过大;
  • 前端渲染是否阻塞了消息处理,把渲染放到requestAnimationFrame里;
  • 网络带宽是否被其他请求占满,考虑给 WebSocket 消息做优先级标记。

6.4 重连后状态不一致

重连恢复依赖seq和缓冲区。如果缓冲区太小,恢复失败,前端要能优雅降级:清空当前消息流,显示“会话已重置”,然后重新初始化。不要试图合并新旧状态,那样更容易出错。

实操心得:我在生产环境里会把resume_failed的比例做成监控指标。如果这个比例突然升高,说明缓冲区设置不合理或者断线太频繁,需要调整参数或者排查网络。

6.5 内存泄漏与连接数暴涨

WebSocket 连接是长连接,如果前端组件卸载时没关闭连接,或者重连时旧连接没清理,连接数会暴涨。检查清单:

  • 组件useEffect的 cleanup 里是否调用了ws.close();
  • 重连前是否先关闭旧连接;
  • 服务端是否有连接数上限和空闲连接清理;
  • 是否有定时器在组件卸载后还在跑。

我一般会在服务端加一个连接数监控,超过阈值就告警。同时给每个连接设置最大空闲时间,比如 10 分钟无消息就主动关闭。

7. 从文本工作流到实时 AI 的演进路径

回到开头那个问题:实时 AI 不只是把聊天变成视频。Gemini Live Avatar 展示的是最终形态,但支撑这个形态的,是 RelayRouter 这类中继路由在文本工作流里做的脏活累活。如果你现在手里有一个文本 AI 应用,想让它变得“实时”,我的建议是分三步走:

第一步,先把 WebSocket 通道建起来,把消息协议定清楚,心跳和重连做好。这一步不涉及工具调用,只是把原来的 HTTP 请求改成双向流。

第二步,引入 RelayRouter 层,把工具调用从业务逻辑里抽出来,做成可注册、可编排的模块。这一步完成后,你的应用就能支持模型主动发起工具调用了。

第三步,优化前端状态同步和渲染性能,把工具调用状态、连接状态、消息流都做成用户可见但不干扰的 UI 元素。

这三步走完,你的文本工作流就具备了实时 AI 的骨架。至于要不要加 Avatar、要不要加语音,那是表现层的事,随时可以替换。但中间这条数据通道和编排逻辑,才是真正决定体验上限的东西。

我在实际项目里最大的体会是:实时性不是靠某一个技术点实现的,而是靠整条链路上每个环节都不拖后腿。WebSocket 选对了,心跳设合理了,工具调用编排清楚了,前端渲染不卡了,实时感自然就出来了。反过来,任何一个环节掉链子,用户都能感觉到“卡了一下”。所以与其追求某个酷炫的视觉效果,不如先把 RelayRouter 这层做扎实。

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

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

立即咨询