尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

AI Agent通信从SSE到WebSocket:多工具调用架构优化实践

AI Agent通信从SSE到WebSocket:多工具调用架构优化实践 那次调试真是把我折腾得够呛一个 AI Agent 同时调用了三个工具两个很快返回了第三个因为上游接口比较慢SSE 连接在服务端那边已经开始忙着处理新上下文结果客户端这边收到的流就断了工具结果没送回去Agent 卡在等待工具返回的状态整整一分多钟。后来我查了下 OpenAI 这几轮迭代的方向他们在 Agent 场景里把通信方式从以往的 HTTP SSE 切换成了 WebSocket配合 Codex 这类工具已经能看出端倪。这篇文章就来聊聊这套切换背后的架构逻辑以及它到底把多工具调用这个老大难优化在了哪里。如果你正在做 AI Agent 的协议层或者准备给 Agent 加工具调用能力这篇文章应该能帮你少走不少弯路。1. 为什么 SSE 撑不住多工具调用的长链路要说清楚 WebSocket 的优势得先把 SSE 在 Agent 场景里的局限讲明白。SSE 本身不是坏技术它做单次模型生成的流式输出一点问题没有但 AI Agent 这种规划-执行-反馈-再规划的长链路任务SSE 的架构会逐渐变得别扭。1.1 SSE 的单向推送本质SSE 叫做 Server-Sent Events核心模型是服务端通过一个 HTTP 长连接向客户端持续推送事件。这个模型有个隐含前提客户端大部分时间是接收方服务端是唯一的发送方。模型生成文本时这个模型很合适因为方向是固定的模型产生 token 就往客户端推。问题在 Agent 场景里就暴露出来了。Agent 的流程是服务端模型先推理给出工具调用指令客户端拿到指令去执行本地工具然后把执行结果送回服务端服务端拿到结果继续推理再给出下一步指令。这个流程里数据方向是服务端到客户端和客户端到服务端频繁交替的而且交替的节奏不可预测可能几十毫秒就切换一次也可能中间隔了很长时间比如工具执行要跑几十秒。SSE 对客户端到服务端的方向几乎是不支持的。规范上客户端确实能通过 HTTP 请求往服务端发数据但那实际上是另起一个新的普通 HTTP 请求跟 SSE 这条长连接基本没关系。这导致一个很尴尬的局面Agent 每做完一次工具调用客户端都要重新构造一个 POST 请求把结果送回去服务端拿到结果再推送下一轮内容。本来一个连续的任务被拆成了一次 SSE 长连接 N 次普通 POST的混合模式。1.2 多工具调用场景下的连接爆炸如果只是单工具调用上面那套模式还能忍真正压垮它的是多工具并行调用。想象一个场景Agent 接到帮我把这个月的收支数据整理成报表的任务它决定同时调用读取账单工具、调用汇率转换工具、查询分类规则工具。模型一次推理会返回三个并行的工具调用指令。SSE 连接把三个指令推给客户端之后客户端开始执行三个工具。问题是这三个工具的执行时长不同查询分类规则可能 50 毫秒就回来了汇率转换要等外部接口可能要 2 秒读账单更慢要 5 秒。用 SSE 那套模型客户端只能等所有工具都执行完把三个结果攒成一批再通过一个新的 POST 请求统一回传。这个等全部完成的过程非常浪费因为服务端完全可以先把分类规则这个结果拿过去算一下提前准备下一轮规划。但 SSE 的模型不允许你做增量回传你只能等等最慢的那个工具结束。更麻烦的是如果中间某个工具抛了异常整个批次的结果都受影响。你不想把失败的工具结果伪装成成功但如果不把其他成功结果一起送回去这个任务就没法继续。结果就是 Agent 的下一步规划被一个异常工具卡住了。还有上下文重建的问题。每发一个 POST服务端都要根据 session id 或者历史消息恢复上下文。理论上这能做到但代价是把状态管理从连接层面挪到了应用层面。每次新请求都要做一次上下文序列化和反序列化任务链越长这个开销越明显。1.3 半双工模型下的打断与干预成本SSE 还有一个隐藏很深的痛点打断操作。用户看到 Agent 跑偏了或者连续调了一堆工具发现方向不对想让它停下来。在 SSE 模型里客户端能做的只有两种一是原地断开连接但这会导致服务端误认为网络故障清理掉整个会话状态二是发一个独立的 POST 请求去通知我要打断但这个请求可能要排队还要跟当前正在执行的 SSE 推送竞争。这种半双工的控制方式最终会让 Agent 的人类干预变得很笨重。很多团队最后靠的是客户端断连 重建 session这种简单粗暴的方案代价是会话记忆全丢。当时我调那个卡住的任务时最终也是这么处理的但那次经历彻底让我意识到Agent 这种场景需要的是真正可以双向通信的通道而不是单向推送。2. WebSocket 如何把 Agent 循环折叠进一条连接WebSocket 和 SSE 最本质的区别就是全双工建立连接后客户端和服务端可以在任意时刻向对方发送消息不需要重新握手不需要额外创建 HTTP 请求。2.1 连接即会话的架构模型WebSocket 引入了一个特别适合 Agent 的思维模型一条 WebSocket 连接就是一次 Agent 会话的全部载体。连接建立时客户端携带鉴权信息和服务端协商会话参数模型、工具列表、系统提示词等。连接存活期间所有交互都在这条连接上发生服务端推送模型推理的增量文本客户端上报工具执行结果服务端推送下一轮的规划指令用户中途插入一条新指令所有这些消息都以独立帧的形式在同一连接里流转。这个模型的优势在于服务端不需要为每次交互重建上下文。会话上下文天然绑定在连接上连接还活着上下文就在。客户端不用维护我在哪个 session、上一次 POST 是什么时候这类状态因为整个生命周期都在一条连接里。我自己的理解是这相当于把 Agent 任务从HTTP 请求-响应的聚合变成了一条持续的实时对话流。类比一下SSE 像单向广播电台你只能听不能随时说话WebSocket 像电话双方能随时开口沟通的节奏是自然的、双向的。2.2 Agent 循环的消息流设计在实际架构里Agent 基于 WebSocket 的消息循环可以抽象成两种方向、若干事件类型。下面是我在类似项目里使用过、也被证明可行的消息模型方向事件类型说明负载示例服务端 → 客户端session.created会话建立成功session_id、模型、可用工具列表服务端 → 客户端conversation.delta模型推理的增量文本文本片段、完成状态服务端 → 客户端tool_call.batch模型发起的工具调用指令工具 ID、工具名、输入参数服务端 → 客户端tool_call.status单个工具调用的状态更新工具 ID、状态pending/running/completed/failed服务端 → 客户端permission.request高风险操作等待用户确认确认 ID、操作描述、风险等级服务端 → 客户端agent.finished整个任务结束结束原因、统计信息客户端 → 服务端tool_result.report客户端上报工具执行结果工具 ID、结果内容、错误信息客户端 → 服务端user.interrupt用户意图取消或干预打断原因、是否保留上下文客户端 → 服务端permission.response用户对权限请求的回复确认 ID、同意/拒绝双向heartbeat应用层心跳时间戳每一条消息都带自增 id 和消息类型方便对端做幂等处理。多工具调用的关键就在这里服务端可以在一个 tool_call.batch 里下发多个工具指令tool_id 唯一客户端不用等所有工具都完成每个工具执行完就立刻发一个 tool_result.report。服务端拿到一个结果就能基于这个结果开始下一步推理同时其他工具还在客户端继续跑。2.3 和 Codex 的本地 Agent 闭环呼应如果你看过 OpenAI Codex 的架构会发现它已经在用这种模式了。Codex CLI 在本地跑模型推理和工具执行是两条并行的线云端模型通过 WebSocket 维持会话本地工具比如代码检索、文件读取、终端命令执行完一个就通过同一条连接把结果送回模型模型立刻调整下一步计划。整个闭环不需要每一次工具调用都重新发一个 HTTP 请求这也是它能连续执行复杂编码任务的原因之一。从框架设计角度看这种本地执行 云端推理共享一条长连接的模式未来会被更多 Agent 产品采用因为它让开发者不需要在工具结果回传这件事上花太多精力把注意力放在真正的 Agent 逻辑上。3. 多工具并行、中途打断与权限确认WebSocket 带来的几个直接优化标题里强调多工具调用大优化这里把几个真正落到实处的优化点拆开讲。3.1 工具结果可以边执行边回传了这是最直接的一层优化。SSE 模式下客户端必须等所有工具完成才能统一回传因为每次回传实质上是新的 HTTP 请求你在一个请求里塞一个工具的结果那下一个工具的结果就还得再发起一个请求这既浪费又慢。WebSocket 模式下工具结果可以实时回传不管其他工具是否还在执行。举个例子模型同时发出四个工具调用{ type: tool_call.batch, tool_calls: [ { tool_id: tool_01, tool_name: search_docs, input: { query: WebSocket API 文档 } }, { tool_id: tool_02, tool_name: call_http, input: { url: https://api.example.com/status } }, { tool_id: tool_03, tool_name: read_file, input: { path: ./config.json } }, { tool_id: tool_04, tool_name: query_db, input: { sql: SELECT COUNT(*) FROM tasks } } ] }客户端收到后并行执行这四个工具read_file 先完成立刻上报{ type: tool_result.report, tool_id: tool_03, result: { ok: true, content: { \timeout\: 30 } } }服务端读到这个结果假设它发现 config.json 里的 timeout 是 30可以先调整后续规划同时 search_docs 还在跑query_db 也还在跑。等到 call_http 完成再上报一次。这种增量式反馈对长任务的意义很大它把 Agent 的决策延迟从最慢的工具耗时降到了当前已完成的工具耗时整体体验会流畅得多。3.2 同一连接上的权限确认闭环Agent 调用外部工具时尤其涉及发送消息、修改文件、调用后端接口这些操作通常需要用户确认。以前的流程是模型推理出一个需要确认的工具调用 → 服务端给你推 SSE 消息告诉你要确认 → 客户端收到后弹窗 → 用户点了同意 → 客户端发一个 POST 到某个确认接口 → 服务端收到后再通过 SSE 推后续内容。这中间涉及两套通道的配合时序稍微一乱用户确认了但后续内容没推过来体验就会很怪。WebSocket 把整个闭环收敛到一条连接里{ type: permission.request, permission_id: perm_001, operation: send_email, description: 向 userexample.com 发送邮件标题为本周报表, risk_level: medium }客户端弹窗用户点击允许客户端回复{ type: permission.response, permission_id: perm_001, decision: allow }服务端在同一连接上继续跑推理推送下一个工具调用或文本增量。这种闭环看似只是省掉了一个请求但对交互节奏的影响是决定性的尤其是用户希望快速干预 Agent 行为的时候。3.3 打断不再需要断连重来还有打断这个操作WebSocket 的表达方式非常自然。客户端直接发{ type: user.interrupt, reason: direction_changed, preserve_context: true }服务端收到后立即停止当前推理但不销毁会话上下文。Agent 可以从打断点继续而不是把所有记忆清零重建。这在 SSE 模式下几乎做不到因为 SSE 根本没有办法在同一个连接上逆向上行数据只能通过额外请求或断连实现。当然打断之后模型的 token 流可能已经发了一半客户端要能处理收到 interrupt 之前可能还有几条残留的 conversation.delta这需要做消息序号比对但这是一次性的工程改造比断连重建会话要省心得多。3.4 长耗时任务可以实时观测Agent 跑一个长任务时用户特别需要知道它现在在干什么。WebSocket 的好处是服务端可以在任何时刻主动推消息比如tool_call.status报告每个工具是 pending 还是 running中间态的日志信息可以直接推给客户端展示模型每个推理阶段的产出可以直接流式可见。这种实时可观测性对调试也很有价值我在本地开发 Agent 时经常把 WebSocket 消息打出来一眼就能看出模型到底调用哪个工具、工具结果什么时候回来的比以前抓 HTTP 日志要直观太多。4. 客户端迁移实操从 HTTP 长连接换成 WebSocket 会话如果你已经有一套基于 HTTP SSE 的 Agent 客户端迁移到 WebSocket 大体上要改四块连接建立、消息解析、工具结果上报、断线重连。4.1 连接建立与鉴权WebSocket 握手本质上是 HTTP Upgrade鉴权一般在握手请求里完成用 Authorization 头或 query 参数。const socket new WebSocket(wss://api.example.com/v1/agent/session, { headers: { Authorization: Bearer sk-xxxx, OpenAI-Beta: agent-session-v1 } });注意浏览器环境下new WebSocket()是不允许自定义 headers 的所以服务端也要允许通过 query 参数传 token比如wss://api.example.com/v1/agent/session?tokensk-xxxx。服务端解析 token 后在握手响应里返回 session.created 事件。这会带来一个安全上的小提醒token 出现在 URL 里容易被日志系统记录所以要确保日志脱敏这是迁移里很容易忽略的点。4.2 消息解析与分发连接建立后所有消息都是 JSON 文本帧。客户端需要维护一个分发器根据type字段把消息路由到不同的处理函数。我常用的是一个很简单的实现import asyncio import websockets import json async def handle_agent_message(raw_message: str): message json.loads(raw_message) event_type message.get(type) if event_type session.created: print(f会话建立: {message[session_id]}) # 在这里记录 session_id 备用 elif event_type conversation.delta: print(message.get(text, ), end) elif event_type tool_call.batch: # 收到工具调用指令分发到本地执行器 for tc in message[tool_calls]: asyncio.create_task(execute_tool_and_report(tc)) elif event_type permission.request: # 弹窗/提醒用户确认 user_decision await ask_user(message[description]) await ws.send(json.dumps({ type: permission.response, permission_id: message[permission_id], decision: user_decision })) elif event_type agent.finished: print(f\n任务结束: {message[reason]}) await ws.close()execute_tool_and_report里执行完工具后立刻通过同一连接回传async def execute_tool_and_report(tool_call: dict): try: result await run_local_tool(tool_call[tool_name], tool_call[input]) report { type: tool_result.report, tool_id: tool_call[tool_id], result: {ok: True, data: result} } except Exception as exc: report { type: tool_result.report, tool_id: tool_call[tool_id], result: {ok: False, error: str(exc)} } await ws.send(json.dumps(report))这段代码的核心思路是收到tool_call.batch之后不用等全部完成每个工具独立执行、独立上报。4.3 原 HTTP 参数迁移后落在哪如果你原来是用 HTTP 调 Agent API 的有一批参数需要重新找到落位。我整理过一份迁移对照原 HTTP 参数迁移到 WebSocket 后的位置说明model握手 URL 的 query 或 session.created 前的参数会话级配置连接建立时确定messages握手或 session 初始化字段用于恢复历史会话时填充tools握手或 session 初始化字段连接生命周期内可用tool_choice每条 conversation.delta 后的控制事件里可以动态调整不必在建立时锁死temperature / top_psession 初始化字段对话中可改可不改看服务端支持stream_options不再需要显式指定WebSocket 天然流式max_tokenssession 初始化字段或每次推理事件字段按服务端设计而定关于服务端如何做这里也提一句如果大家要自己搭服务端记得把会话上下文放在连接对象里而不是放在请求对象里。原来 HTTP 模式下每个请求都是一个瞬时对象做完就丢。WebSocket 模式下连接长期存活上下文要跟随连接生命周期一起存储。状态放在连接上是整套架构最核心的一个转变。4.4 心跳与显式关闭WebSocket 虽然自己有协议层的 ping/pong 帧但很多网络环境里服务器不一定回协议层 pong或者代理层会静默丢弃。更可靠的方案是应用层自己做心跳客户端每隔 30 秒发一个自定义 heartbeat 消息服务端收到后回一个 heartbeat.ack。连续几次没有收到 ack就认为连接已经死掉触发重连。这个细节非常重要尤其是在有负载均衡器的环境下。负载均衡器一般有一个空闲超时时间如果连接超过一段时间没有数据流动就会被回收表现就是连接突然断开但没有任何报错。应用层心跳保证连接在空闲时也有数据在走可以保住连接。5. 切换 WebSocket 后容易踩的坑和我的排查经验迁移到 WebSocket 之后你会遇到一批跟 HTTP 时代完全不同的坑。这里列几个我实际踩过、也帮别人排查过的问题。5.1 代理层和网关拦截了 Upgrade 握手这是迁移初期最典型的故障。表现为HTTP 时代一切正常切到 WebSocket 之后客户端建立连接时直接报 400 或者 403。原因基本都出在反向代理/网关上。WebSocket 握手依赖Upgrade: websocket和Connection: Upgrade这两个头但很多代理默认只放行普通 HTTP 请求对 Upgrade 请求需要额外配置。比如 Nginx 里必须明确开proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;而且要有proxy_read_timeout的合理设置否则长连接会被代理层提前掐断。排查方法也很简单在客户端抓包/看请求头确认握手请求有没有成功返回101 Switching Protocols而不是200 OK。如果是200 OK那说明请求被代理层吃掉了没走 WebSocket 转发逻辑。5.2 stream disconnected before completion: websocket closed by server before res这个报错字段你要是搜过应该不陌生。它本质是服务端在客户端还没准备好接收后续消息时主动关闭了连接。我排查过的一个案例是这样的客户端打开了一个 WebSocket 连接发了一个工具结果过去但服务端因为这个工具结果需要额外的鉴权比如工具本身有独立权限在判断过程中直接把连接关了客户端就收到这个错误。这类问题的重点在于关闭连接不等于结束业务要区分是正常关闭还是异常关闭。WebSocket 的关闭帧里有一个 close code服务端主动关闭时最好用明确的 code 和 reason。客户端侧要把这些信息打出来看socket.addEventListener(close, (event) { console.log(close code:, event.code); console.log(close reason:, event.reason); });如果 close code 是 1000表示正常关闭如果是 1006表示连接异常中断可能网络层问题如果是 4000 的业务自定义 code那要看服务端文档。业务代码里不要把所有关闭事件都当成网络错误去重连否则会出现用户主动结束任务客户端还在重连的尴尬局面。5.3 断线重连后的重复执行问题WebSocket 连接不稳定时客户端会触发重连。重连之后整个会话状态可能丢失也可能恢复。这里最大的坑是重复消息。假设客户端发了一个tool_result.report消息已经送到了服务端但服务端的 ack 还没回到客户端网络就断了。客户端重连后因为没收到 ack会判定消息没送达于是重新发一次。服务端对同一个工具 ID 收到了两次结果如果不做幂等那这个工具就会被执行两次效果可能翻倍也可能产生脏数据。解决方案是两层第一消息 id 机制。每条tool_result.report都要带一个全局递增或者 UUID 格式的消息 id服务端缓存最近处理过的消息 id相同的 id 直接忽略。第二工具执行幂等性。设计工具时尽量让工具的副作用是幂等的比如写入文件天然不幂等但按内容哈希决定是否覆盖就幂等了。我当时踩坑之后在客户端做了一个很简单的重试队列所有发出但没收到 ack 的消息进入待确认队列重连成功后重新发送收到 ack 再移除。服务端那边加了一个最近 1000 条消息 id 的缓存两个改动加起来重复执行问题基本绝迹了。5.4 背压工具结果上报太快WebSocket 是消息驱动没有 HTTP 那套天然的请求-响应节奏限制这带来一个反向问题客户端工具执行得很快一瞬间上报十几个 tool_result服务端如果处理不过来会积压消息。这不仅是服务端性能问题客户端也会受影响。TCP 层的发送缓冲区如果被占满发送端会自动减速但 WebSocket 层不会主动感知服务端有没有处理完。如果服务端处理一个工具结果需要 200 毫秒客户端 50 毫秒就能发一个消息堆积就会越来越严重。做法一般是两层第一客户端控制上报节奏不要一个结果一个地发可以在同一帧里打包多个结果。第二服务端用 ACK 机制做流控客户端维护一个unacked_count超过某个阈值就等待 ACK 再继续发。socket.onmessage (msg) { const data JSON.parse(msg.data); if (data.type tool_result.ack) { pendingReports.delete(data.messageId); } };虽然 WebSocket 底层会自动做流量控制但应用层的事件积压如果不理连接占用内存会持续增长这个坑在长连接场景特别明显。5.5 长时间空闲被 NAT/网关超时回收这个问题前面提到过但是踩坑的时候那叫一个隐蔽。你在办公室开发环境一切正常部署到客户那边客户的办公网络 NAT 超时是 60 秒WebSocket 连接一空闲就死。表现是客户端没有任何感知服务端收不到心跳消息一推就发现连接已断开重连又需要时间Agent 体验会非常差。解决方法是双管齐下应用层心跳间隔要小于 NAT 和 LB 的超时时间我一般用 15-25 秒同时服务端在推送消息前如果发现连接最近没有 pong先发一个 ping 探测探测失败就主动重建连接。把被动断线变成主动感知这样才能避免消息丢失。6. 从响应式 API 到会话式 APIAgent 协议设计的下一步看完上面这些你会发现 WebSocket 切换的意义不只是换了一个传输协议而是整套 API 设计范式在变从响应式 API走向会话式 API。6.1 连接即会话消息即事件HTTP 时代API 的粒度是请求WebSocket 时代API 的粒度是连接和消息。想想看一次 Agent 任务里有模型推理、工具执行、用户确认、日志输出如果在 HTTP 体系里这些都是不同接口需要协调整合。但在 WebSocket 体系里它们只是同一连接上不同 event type 的消息。这种设计对客户端开发者非常友好你不需要先调推理接口再轮询工具状态再调确认接口你只需要维护一个消息分发器处理不同的消息类型。未来 AI Agent 的接口设计大概率会沿着这个方向走。协议层面的消息模型会越来越像一个实时协作协议而不是一个远程函数调用协议。6.2 消息模型的扩展性设计这套消息模型时有几个原则值得记下来消息类型用字符串而不是数字枚举因为字符串天然可扩展加新类型不破坏旧客户端每个消息都要有唯一 id这是做幂等、追踪、排查问题的基础消息版本要体现在握手或 session 参数里避免服务端升级后旧客户端解析不了新消息。我见过一些团队直接把type字段做成了一个字段值枚举结果加新事件类型时旧客户端直接崩了。其实只要解析时对未知 type 兜底跳过并且前端显示收到未知消息类型就不会出事。6.3 多端同步与断线恢复会话式 API 还有一个隐藏能力多端同步。因为会话状态绑定在连接上客户端掉线后可以用同一个 session_id 重新建立连接服务端把历史消息重放一遍就能恢复上下文。这意味着用户可以在桌面端发起一个 Agent 任务切到手机端继续看进度。这在 HTTP 模型下几乎不可能做到HTTP 的每次请求都是一次性的没有持续会话的概念。Codex 这类工具已经在用这个模型本地执行任务时维持一个长连接断线重连后可以继续。未来多 Agent 协作、Agent 之间的消息路由本质上也是会话式协议的扩展连接不再是一次请求的载体而是一段任务生命周期的载体。如果你正好在设计 Agent 的通信层我建议直接按照会话优先的思路来做别把 HTTP 语义硬塞到 WebSocket 里。比如不要试图在 WebSocket 上模拟 REST 风格的请求-响应模式而是设计好消息 ID、ACK、重连、幂等这些基础能力把 Agent 循环真正折叠进一条长连接里。这是一个范式转换转换期会有阵痛但一旦适应了你会发现 Agent 的工具调用、实时反馈、权限交互这些事情终于有了一个顺手的底座。
返回列表