最近圈子里聊 Agent 架构,绕不开一个变化:OpenAI 在 Codex harness 这一波 Agent 会话接口上,把之前惯用的 HTTP 流式响应换成了 WebSocket 长连接通道。很多人第一反应是"不就是换个传输协议吗",但真把多工具调用的全链路跑一遍就会发现,这个改动直接把交互模型从"一问一答"变成了"全双工会话",影响的是整个 Agent 系统的设计方式。这篇文章我想把背后的架构逻辑彻底拆开,讲清楚为什么这个方向是对的,同时把我自己实现类似系统时踩过的坑一并列出来。
内容不涉及任何内部保密实现,更多是从开源 harness、公开文档和协议设计的通用规律来推演,你在自建系统时可以直接套用这套思路。适合两类人:一类是正在给 AI Agent 做接入层的后端工程师,另一类是想把多工具调用链路看懂、想自己搭一套 Agent 网关的产品或架构师。下面从头说起。
1. 为什么说这次切换是一次架构层面的关键转折
1.1 HTTP/SSE 时代的瓶颈:单向通道撑不住多工具协作
先回到老方案。之前大多数 Agent API 用的是 SSE(Server-Sent Events)做流式输出,模型生成的 token 一个接一个推给客户端。SSE 本身很成熟,基于 HTTP,穿透性好,Nginx、云厂商 LB 都认识它,自动重连机制也是协议内置的。对"把一段文本流式吐出来"这个场景,它几乎是最优解。
但多工具调用的场景一出现,SSE 的短板就暴露了。一个典型的多工具 Agent 会话里,服务端要先让模型"想一会儿",然后抛出一个工具调用请求给客户端(或者给工具执行器),等工具跑完拿到结果,再喂回模型继续生成。这个流程里有一个 SSE 天生不擅长的事情:服务端需要"反向"向客户端要数据——不是单向推送,而是请求-响应循环,而且这个循环可能要连续发生好几次。
用 SSE 硬撑也能做:客户端监听事件,收到工具请求后走一个普通的 POST 接口把结果回传,服务端靠 session_id 把结果关联回原来的生成流。但这样问题很多:每回传一次结果就是一次新的 HTTP 连接,连接频繁建立销毁;服务端要维护一个全局的 session 映射表;多个工具并行调用时,回传顺序和关联关系全靠应用层自己绕。竞态条件、超时、连接被中间层掐断,这些问题在线上非常折磨人。
1.2 WebSocket 带来的本质变化:从"请求-响应"到"全双工会话"
WebSocket 不一样。它在 HTTP Upgrade 握手之后,把连接升级成一条真正的全双工长连接,客户端和服务端可以随时往同一条连接上写消息。生活化类比的话,HTTP 像寄信,你寄一封对方回一封,寄件人和收件人的角色是固定的;WebSocket 更像打电话,两个人的角色是平等的,你说一句我回一句,还能同时说。
放到 Agent 场景里,这条长连接天然就是一个"会话"。模型生成的增量可以推、工具请求可以推、工具结果可以做即时回传、中途还可以发送取消指令——所有消息都在一条有状态的通道上流转,不存在"回传结果要另起一个请求"的割裂感。这也是为什么 OpenAI 在 Realtime API 这类交互性强、需要低延迟双向通信的接口上早就选了 WebSocket,而 Agent 会话接口跟进是迟早的事。
这一个转变带来的连锁影响是:整个系统的设计重心从"接口文档"变成了"协议设计"。你不再需要操心"我该调哪个 REST 端点好",而是需要设计一套清晰、可扩展、能跑在一条长连接上的消息协议。后面几节我展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. AI Agent 多工具调用的核心链路拆解
2.1 一次多工具调用背后发生了什么
先从用户视角走一遍完整流程。假设用户输入:"帮我把上个月的订单数据拉出来,做成表格,再给领导发一封汇总邮件。" 在一个多工具 Agent 系统里,大致的执行序列如下:
- 用户发送指令到 Agent 会话
- Agent 调用 LLM 做任务规划,LLM 决定先调用"查询订单"工具
- Agent 把 tool.request 事件发给工具执行方(可能是客户端插件,也可能是服务端函数)
- 工具执行方调用订单 API,拿到数据,回传 tool.result
- Agent 把结果喂回 LLM,LLM 生成表格内容,再决定调用"发送邮件"工具
- 重复 3-4 的过程
- 所有工具执行完毕,LLM 产出最终回复,Agent 把增量文本推给用户
在 WebSocket 会话里,这个链路是一条连续的对话流。每个消息带上唯一的 message id 和事件类型,工具请求与工具结果通过 req_id 关联。更重要的是,第 3、4 步的往返可以并发发生——比如先同时请求查订单和查联系人列表两个工具,谁先回来谁先喂回模型,不需要串行等待。
2.2 真正的瓶颈:工具结果回传与"反向请求"
很多人误解多工具调用的瓶颈在"模型推理速度",其实工程上最难啃的是工具结果回传链路。SSE 时代最难解决的就是"反向请求":服务端模型跑到一半,发现必须等一个外部工具的结果,这个结果只有客户端能拿到(比如需要用户授权、需要读本地文件)。服务端只能停下来干等。
用 WebSocket 解决这个问题的思路非常直白:所有的工具请求和结果都走同一条长连接,消息类型区分开来,再用关联 ID 把"请求-响应"配对。客户端收到 tool.request 后,可以立刻执行工具并回传,也可以弹窗让用户确认后再回传;服务端可以设超时,超时没等到结果就主动发一个 cancellation,让模型换一条路径处理。
这里有个容易忽略的点:全双工的另一个好处是支持"中途取消"。HTTP/SSE 下取消一个生成流通常只能靠断开连接,代价很高;WebSocket 下发一条 cancel 指令即可,连接还能继续复用,下一条消息接着发,不会把整个会话断掉。这对多轮、多工具的交互体验提升非常明显。
3. 用 WebSocket 承载 Agent 会话:协议设计与实现要点
把 WebSocket 当"管道"用很简单,把 WebSocket 当"会话总线"用才是难关。下面三个层面是我认为最核心的设计点。
3.1 连接生命周期:鉴权、心跳、优雅关闭
建连阶段,鉴权建议放在 HTTP Upgrade 的请求头里完成,比如 Authorization 头或 token 查询参数。选头而不是 query 参数,是为了避免 token 出现在访问日志和浏览器历史里。服务器在 Upgrade 阶段就校验 token,校验失败直接返回 401,客户端看到非 101 状态码就知道鉴权失败,不要继续走 WebSocket 逻辑。
建连之后,心跳是保命手段。很多 Agent 会话中间会长时间没有消息(比如工具在跑、用户在思考),Nginx、云 LB、运营商都会空闲断开连接。所以必须用 WebSocket 的 Ping/Pong 帧做保活,建议 15 到 30 秒发一个 Ping,连续两三个 Pong 没回来就判连接失效。用 Python 的 websockets 库时配置 ping_interval=20, ping_timeout=10 即可,Go 的 gorilla/websocket 也有 ReadDeadline 和 WriteDeadline 要自己设。
最后是优雅关闭。服务端要下线、会话要过期、客户端要主动断开,都应该走 Close 帧,用规范的关闭码表示原因,比如 1000 表示正常关闭,4001 可以自定义表示"会话过期",4002 表示"并发超限"。别直接断 TCP,否则客户端无法区分"网络抖动"和"业务拒绝",重试策略就没法写。
3.2 消息协议设计:请求相关性、事件类型与错误处理
协议设计我强烈建议一开始就统一消息信封格式,不要为了省事直接发裸 JSON。一个实用的信封大概长这样:
json复制{
"id": "msg_7f3a2c9e",
"seq": 42,
"type": "tool.result",
"ref_id": "msg_8f1b0d2a",
"payload": {}
}
id 是消息唯一标识,客户端用它做去重;seq 是会话内递增序号,用来检测丢消息和乱序;type 是事件类型;ref_id 是关联 ID,工具结果回填给哪条工具请求就看它;payload 是业务数据。
事件类型建议分类清晰一些。我常用的最小集合:
session.start/session.ended:会话生命周期user.message:用户输入agent.chunk:模型增量输出tool.request:服务端发起工具调用请求tool.result:工具执行结果回传(成功或失败都走这个类型,成败用 payload 里的 status 字段区分)error:协议级错误command.cancel:取消当前生成
错误处理上有个经验:协议级错误(格式不对、类型不认识、ref_id 找不到)统一走 error 消息,不要用断开连接来表达错误。只有无法恢复的故障才断连。这样客户端才能系统地处理问题,而不是靠猜。
3.3 服务端状态与水平扩展:从无状态到有状态的迁移
选 WebSocket 意味着服务端必须管理有状态连接,这是很多团队转型时摔跟头的地方。REST 接口天然无状态,加机器随便加;WebSocket 一上,每台实例都握着几千条活跃连接,负载均衡不能随便转发。
解决办法有三条路径,按成本从低到高排列:第一,用 Sticky Session,让同一 session_id 的请求始终打到同一台实例,网关配一下就行,缺点是实例故障时连接全丢,不优雅。第二,把会话状态外置到 Redis 或内存网格,实例只做转发,状态不落实例,故障恢复友好但延迟多一跳。第三,用推送网关组件(如 EMQX 等 MQTT 网关、或自研 Connection Service),连接层和业务层彻底分离,这也是大厂做法。
另外,如果前置有 Nginx,别忘了升级 WebSocket 头。经典配置长这样:
nginx复制map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
location /v1/agents {
proxy_pass http://agent_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
proxy_read_timeout 和 proxy_send_timeout 必须调大,否则长连接超过 60 秒(Nginx 默认值)没消息就会被掐断。这是线上最经典的一个坑。
4. 客户端接入实战:一段可上手的示例代码
理论讲完,给一段可以自己跑起来的最小实现。下面用 Python 的 websockets 库,模拟一个支持多工具调用的 Agent 客户端。
4.1 建立连接与鉴权
python复制import asyncio
import json
import uuid
import websockets
AGENT_URL = "wss://agent.example.com/v1/agents"
API_TOKEN = "你的token"
async def connect():
async with websockets.connect(
AGENT_URL,
extra_headers={"Authorization": f"Bearer {API_TOKEN}"},
ping_interval=20,
ping_timeout=10,
) as ws:
# 发送会话启动消息
await ws.send(json.dumps({
"id": str(uuid.uuid4()),
"seq": 1,
"type": "session.start",
"payload": {"features": ["tools", "streaming"]},
}))
await handle_messages(ws)
extra_headers 在握手阶段就会带上,服务端校验失败时 connect() 会抛异常,不会进入消息循环。ping_interval 和 ping_timeout 是心跳配置,20 秒一次 Ping,卡了 10 秒没响应就判定连接死亡,自动抛 ConnectionClosed。
4.2 多工具调用的消息交互
核心是消息循环。收到 tool.request 就执行工具函数,然后把结果按 ref_id 回传;收到 agent.chunk 就打印增量。这里注意 seq 要在每次发送时递增。
python复制async def handle_messages(ws):
seq = 0
def next_seq():
nonlocal seq
seq += 1
return seq
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "tool.request":
tool_name = msg["payload"]["name"]
args = msg["payload"]["arguments"]
print(f"[tool.request] {tool_name} {args}")
try:
result = await execute_tool(tool_name, args)
status = "ok"
except Exception as exc:
result = {"error": str(exc)}
status = "failed"
await ws.send(json.dumps({
"id": str(uuid.uuid4()),
"seq": next_seq(),
"type": "tool.result",
"ref_id": msg["id"],
"payload": {"status": status, "result": result},
}))
elif msg["type"] == "agent.chunk":
print(msg["payload"]["text"], end="", flush=True)
elif msg["type"] == "session.ended":
print()
break
elif msg["type"] == "error":
print(f"[error] {msg['payload']}")
python复制TOOL_REGISTRY = {}
def register_tool(name):
def decorator(func):
TOOL_REGISTRY[name] = func
return func
return decorator
async def execute_tool(name, args):
func = TOOL_REGISTRY.get(name)
if func is None:
raise RuntimeError(f"unknown tool: {name}")
return await func(args)
@register_tool("query_order")
async def query_order(args):
# 这里替换成真实的订单 API 调用
return {"order_count": 128, "total_amount": 32988.0}
@register_tool("send_email")
async def send_email(args):
# 这里替换成真实的发信逻辑
return {"accepted": True}
这段代码能跑通的核心在于 ref_id 关联。服务端发过来的每条 tool.request 都带自己的消息 id,客户端回传 tool.result 时原样带上这个 id,服务端就能把它喂回正在等待的模型生成流程。并发的多个工具请求天然不会串:每个 result 都精确绑定到对应的 request 上,顺序错乱也没关系。
4.3 断线重连与消息去重
长连接总有断的一天。断线重连不难,难在重连之后不能把消息搞重、搞丢。我的做法是两个机制配合:指数退避重连 + 消息去重。
python复制async def run_with_reconnect():
retry = 0
while True:
try:
await connect()
retry = 0
break
except (websockets.ConnectionClosed, OSError) as exc:
delay = min(2 ** retry, 30)
print(f"连接断开: {exc.__class__.__name__}, {delay}s 后重连")
await asyncio.sleep(delay)
retry += 1
重连后要把 Session 状态恢复:客户端重新发 session.start 时,payload 带上原来的 session_id,服务端根据这个 ID 恢复生成上下文。已经消费过的消息靠 seq 判断——客户端记录最后处理的 seq,重连时在 session.start 里带上 last_seq,服务端把未消费的补推过来。做完这两件事,绝大多数故障场景都能无缝恢复。
提示:去重缓存不要无限增长,用有界缓存(比如最多保留最近 1000 条消息的 id)就够了。
5. 常见坑与排查实录
这部分是我自己踩过的坑汇总,有一定的普适性。
5.1 高频问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 连接建立后一分钟左右必断 | 代理层 proxy_read_timeout 默认 60s |
调大 Nginx/LB 的 read_timeout,加上心跳保活 |
报错 WebSocket closed by server before response |
服务端在客户端尚未收到完整响应时主动断开,常见于会话超时或鉴权过期 | 检查服务端超时配置;确认 token 有效期;看服务端日志确认关闭码 |
| 客户端收到重复的工具结果 | 客户端重连后重发了 tool.result | 建立 dedup 缓存,按消息 id 去重 |
| 工具结果回传后模型不继续生成 | ref_id 没有对上工具请求的 id |
统一信封格式,回传时原样携带 ref_id |
| 服务端不响应但连接没断 | 应用线程阻塞,心跳没有处理 | 监控线程池饱和程度;把心跳处理放到独立协程 |
| LB 后面消息发到不同实例,对不上上下文 | 没有做 Sticky Session 或状态外置 | 网关开启会话粘滞,或把会话状态迁到 Redis |
5.2 三种典型故障的排查路径
第一个是"连接没断但消息无人处理"。先看服务端日志有没有收到消息,收到但没处理,说明卡在业务逻辑;没收到,说明消息没到服务端,可能被中间层吞了。WebSocket 没有类似 HTTP access log 的默认机制,排查时要在服务端把每帧消息的接收时间、类型、seq 打出来,这是最直接的抓手。
第二个是"重连后上下文丢失"。排查顺序:先确认 session_id 是否在重连时正常传递,再确认服务端是否真的按 session_id 存了上下文。很多团队把上下文放在内存里,实例一重启就全没了。要么做状态持久化,要么接受"会话降级"——主动告知客户端"session 已丢失,请重新发起请求",而不是让客户端死等。
第三个是"工具结果超时"。工具执行方可能是个慢接口,也可能是个永远不返回的坏接口。服务端一定给每个 tool.request 加超时控制,到了时间发一条 error 告诉模型"工具超时",让模型换条思路。用 HTTP 轮询时代大家习惯等 30 秒,WebSocket 时代建议把工具超时缩短到 5-10 秒,配合模型的自我纠错能力,整体体验反而更好。
最后再分享一个小技巧:协议版本号一定要从第一天就带上。在 session.start 的 payload 里放 protocol_version 字段,后续协议升级可以平滑兼容,不需要让所有客户端同时强制升级。我在实际项目里就是因为一开始没留这个字段,后面改消息格式时被迫做了两套兼容代码,代价不小。WebSocket 给 Agent 带来的不是某个单点性能提升,而是整个交互模型的重构——越早把协议层想清楚,后面越省事。
