这两年多智能体项目干下来,我最头疼的从来不是模型能力,而是让一堆 Agent 互相找到对方、说上话、把工具调用链完整跑完。单机 Demo 里每个智能体都能回答得头头是道,一旦放到真实业务环境,要跟订单系统、库存服务、工单平台、审批流打交道,问题就变成:A 代理怎么知道 B 代理存在?B 代理的输入输出协议跟我不一样怎么办?某个代理超时了,整条链子要不要回滚?生产环境里还有权限、审计、灰度这些事,没有任何一个大模型会替你操心。
所以我把这套东西开源成了 Agent-Reach。它的定位不是又一个 Agent 框架,而是一层很薄的“代理通信与调度平面”——负责 Agent 注册、能力发现、消息路由、工具调用协调和人工审批钩子。我们内部用 Go 写服务端,提供 Python / TypeScript 的 SDK,跑了大半年,稳定扛住了日均几十万次代理调用。这篇文章把架构、实现、踩坑和生产化过程完整拆开,适合正在做多 Agent 系统、或者准备把 Agent 从 Demo 推向线上的人参考。
1. 从点对点硬编码到注册中心的跳跃
1.1 两个 Agent 协作没问题,二十个 Agent 就是一场灾难
最早我们做客服场景,只有两个 Agent:一个负责意图识别,一个负责查订单。代码写得很简单,A 直接调 B 的 HTTP 接口,接口地址写在配置文件里,字段对不上就手动改改。当时觉得这也没什么难度。
后来场景膨胀,意图识别、订单查询、退换货、优惠券计算、工单创建、人工客服转接……每个能力都是一个独立 Agent,而且开始有不同的团队维护。这时出现一个很典型的问题:订单 Agent 的接口从 v1 升到 v2,字段从order_id改成orderNo,调用方全部炸一遍。每个团队都按自己的想法设计接口,没有统一协议,没有服务发现,联调基本靠吼。
更麻烦的是跨 Agent 的链路。用户说要退货,系统先查订单,再算退款金额,再创建工单,最后还要通知人工审核。任何一环地址变了、超时了、参数不齐,整条链路直接断掉。错误日志散落在五个服务的日志文件里,定位问题像破案。
这不是加一个 API 网关就能解决的。普通网关做的是请求转发,而 Agent 之间的协作是会话式、状态化、长链路的:某个 Agent 可能需要先反问用户,拿到补充信息后再继续执行。这种模式需要把“代理”当成头等公民来管,而不是简单当成 URL。
1.2 Agent-Reach 的基本定位:它管什么,不管什么
Agent-Reach 管三件事:
- 注册与会话:每个 Agent 启动时向控制面注册自己的能力描述,维持心跳,下线时自动摘除。
- 路由与分发:调用方只需要声明“我要一个能做订单查询的 Agent”,控制面负责找到匹配的 Agent 并把请求路由过去。
- 协调与审批:支持同步调用、异步任务、事件广播,以及带人工审批的敏感操作钩子。
它有意识不管的事也很多:不限制你用什么模型,不规定 Agent 内部的 prompt 怎么写,不强制消息格式必须是某种 JSON Schema,更不做多 Agent 的推理编排(那是上层框架的事)。我们只想解决“连通性”和“可靠性”,把 Agent 之间的通信变成像 HTTP 一样顺手的基础设施。
这个边界很重要。一开始我们犯过错误,想顺便把 Agent 编排逻辑也做进去,比如自动判断该调用哪个 Agent。结果发现编排逻辑跟业务耦合太深,每次改流程都涉及控制面升级。后来砍掉,全部由调用方(可能是上层编排器,也可能是另一个 Agent)显式指定目标能力,Agent-Reach 只负责“找到”和“送达”。
2. Agent-Reach 的核心机制:注册、路由、调用三件事
2.1 注册中心如何知道每个 Agent 的能力
每个 Agent 在启动时,需要向注册中心发送一个register请求,描述自己是谁、能干什么、怎么联系。我们采用的是“属性标签 + 能力 Schema”的组合,而不是传统微服务里那种只注册服务名的做法。
一个典型的注册载荷长这样:
{ "agent_id": "order-agent-01", "name": "订单查询代理", "version": "2.1.0", "capabilities": [ { "name": "query_order", "description": "根据订单号查询订单状态和物流信息", "inputSchema": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] }, "outputSchema": { "type": "object", "properties": { "status": { "type": "string" }, "logistics": { "type": "array" } } } } ], "metadata": { "team": "commerce", "region": "cn", "tags": ["order", "customer-service"] }, "contact": { "type": "ws", "endpoint": "ws://127.0.0.1:9101/agent" } }注册信息不是一次性的,Agent 需要定期发送心跳续租,默认 30 秒一次,三次没收到心跳就自动摘除。这个设计跟分布式系统里的租约机制类似,防止宕机的 Agent 还残留在路由表里被调用。
能力匹配的时候,我们会把name、tags、metadata一起做索引。查询方可以精确到name,也可以按tags模糊匹配。比如上层编排器说“给我一个能处理退款的 Agent”,注册中心通过 tags 命中refund-agent。这种设计比精确调用更贴近真实场景,尤其是 Agent 数量上来以后,我们根本没法保证调用方跟提供方名字完全一致。
2.2 通信协议的选择:为什么是 WebSocket + JSON-RPC 而不是重框架
通信协议是整个系统里争议最大的地方。有人提议直接用 gRPC,有人想引入消息队列,还有人建议用现成的多 Agent 框架。我最后选了“WebSocket + JSON-RPC 2.0”,原因是三个字:够简单。
gRPC 的问题在于双向流和动态能力描述这一套组合起来太重,每个 Agent 都得生成一坨 proto 代码,对 Python 这种动态语言特别不友好。消息队列则把模型带偏了,Agent 之间的调用很多是“请求-响应”模式,用 MQ 处理同步返回还得自己实现关联 ID,麻烦不讨好。
JSON-RPC 2.0 本身有标准的请求、响应、错误格式规范,而且 WebSocket 天然支持双向消息和服务端推送。这意味着不但调用方可以请求 Agent,Agent 也可以主动上报事件,比如“某个长耗时任务完成了”。这个能力在做异步 Agent 场景时特别关键。
我们在 Agent-Reach 里定义了三个基础方法:
| 方法 | 方向 | 说明 |
|---|---|---|
agent.reach.register | Agent → 控制面 | 注册能力和联系方式 |
agent.reach.invoke | 调用方 → 控制面 | 请求调用某个能力 |
agent.reach.event | 控制面 → 调用方 | 异步推送事件或结果 |
所有消息都有统一的 envelope:
{ "jsonrpc": "2.0", "id": "req_8f3a2c", "method": "agent.reach.invoke", "params": { "target": { "capability": "query_order", "agent_id": "optional-specified" }, "payload": { "order_id": "20240518001" }, "timeout_ms": 5000, "trace_id": "trace-abc-123" } }统一 envelope 的价值,是在排障的时候特别明显。每个调用都有 trace_id,控制面转发的每个环节都打日志,用户从头到尾能看到同一个 trace_id 贯穿所有 Agent。后面生产环境排查问题,全靠这个字段。
2.3 消息投递机制:直接路由、广播与扇出
路由策略我们实现了三种,分别对应不同场景。
直接路由是最常用的一种,调用方指定能力名或者 Agent ID,控制面从注册表里找到对应 Agent,把请求转发过去。如果同一个能力有多个实例,支持轮询或按负载取最少连接数。比如订单查询部署了三个副本,控制面会自动分发给最闲的那个。
广播用于“谁有能力谁上”的场景。比如有一次我们要给某个用户推送售后问卷,发出广播后,所有声明支持user_notify能力的 Agent 都会收到消息,第一个确认能处理的 Agent 抢到任务。这个模式跟抢单很类似,我们专门在控制面实现了分布式锁,防止两个 Agent 同时处理同一件事。
扇出则是把一条请求发给多个 Agent,聚合所有结果再返回。这个在内容审核场景里很有用:一段用户评论同时发给敏感词 Agent、情绪识别 Agent、广告检测 Agent,三个结果合并成一条评分记录。扇出模式下控制面要自己完成结果聚合和超时管理,任何一个 Agent 超时都不能阻塞其他结果返回。
三种模式的核心代码都在路由层,跟 Agent 本身无关。这也是 Agent-Reach 能快速接入各种框架的原因——不管上层是 LangChain、MetaGPT 还是自研的编排器,只要能用 JSON 说话,就能接进来。
3. 搭建和接入:一份可直接照抄的本地实践
3.1 服务端:注册中心与路由表的实现
Agent-Reach 的服务端用 Go 实现(后来部分改成了 Rust,但核心版本还是 Go),主要因为 Go 在并发和部署上太舒服了。一个二进制文件扔到服务器就能跑,内部用标准库的sync.Map加读写锁维护路由表,进程内内存索引已经足够支撑几千个 Agent。
服务端目录结构大致是:
agent-reach/ ├── cmd/server/main.go # 入口 ├── internal/ │ ├── registry/ # 注册中心、心跳、租约 │ ├── router/ # 路由匹配、负载均衡、广播 │ ├── gateway/ # WebSocket 连接管理、协议解析 │ ├── auth/ # 令牌校验、权限模型 │ └── observability/ # 日志、指标、trace 导出 └── sdk/ ├── python/ # Python SDK └── typescript/ # TypeScript SDK启动服务端只需要一个命令和一个配置文件:
./agent-reach-server --config config.yamlserver: listen: ":8080" websocket_path: "/agent" registry: heartbeat_interval: 30s lease_timeout: 90s auth: mode: "token" token_hash: "..." router: queue_type: "memory" max_retry: 3 fallback_timeout_ms: 5000 observability: otlp_endpoint: "otel-collector:4317"配置文件不长,但每个字段都有讲究。lease_timeout如果太短,网络抖动会导致大量 Agent 被误下线;如果太长,宕机 Agent 要很久才被摘除。90 秒是我们压测出来的中间值,适合大多数内网环境。
3.2 Agent SDK 的接入:注册、心跳、接收指令
服务端部署好之后,Agent 端接入是非常顺滑的。以 Python SDK 为例,接入一个 Agent 只需要继承基类、声明能力、实现处理函数三件事:
from agent_reach import Agent, Capability class OrderAgent(Agent): def register_capabilities(self): return [ Capability( name="query_order", description="根据订单号查询订单状态和物流信息", input_schema={"order_id": "string"}, handler=self.handle_query_order, ) ] async def handle_query_order(self, payload): order_id = payload["order_id"] result = await query_db(order_id) return {"status": result.status, "logistics": result.logistics} agent = OrderAgent( agent_id="order-agent-01", endpoint="ws://127.0.0.1:8080/agent", api_token="...", ) agent.run()SDK 底层做了几件“看不见但关键”的事:连接 WebSocket 后自动发送注册请求,启动后台协程维持心跳,收到invoke请求时反序列化并调用对应的 handler,如果 handler 抛异常,自动封装成 JSON-RPC 错误响应返回给控制面。
有个细节想提醒一下:WebSocket 连接断线后必须做指数退避重连,不能每秒钟无脑重连,否则控制面会被打垮。我们的 SDK 默认第一次重连等 1 秒,然后依次 2 秒、4 秒、8 秒,封顶 60 秒。这一套在网络抖动的生产环境里救了很多次命。
3.3 工具调用代里的超时、重试和人工审批钩子
Agent 跟外部系统打交道时,最烦人的就是第三方接口不稳定。Agent-Reach 在 invoke 链路里内置了三个机制:超时、重试、熔断。控制面收到 invoke 请求后,会生成一个内部 context,带上timeout_ms,向下游传递。如果下游 Agent 没能在规定时间内返回,控制面立刻给调用方返回一个超时错误。实现方式很简单,Go 的context.WithTimeout,但能从配置层暴露出来,对用户就友好得多。
重试只对幂等操作开放。我们约定,能力注册时可以声明idempotent: true,只有声明过的能力才允许自动重试。像“创建工单”这种非幂等操作,重试会导致重复创建,所以默认不重试,宁可让上层编排器感知失败后自己决定。
人工审批钩子是很多场景的刚需,比如退款、发券、删除数据。我们在协议里加了一个特殊的错误码NEED_APPROVAL,当 Agent 判断操作需要人工确认时,返回这个错误码并附带一个approval_id。调用方会把该approval_id传给审批桌面端,管理员点击同意后触发一个agent.reach.event事件,重新拉起这个调用。
{ "jsonrpc": "2.0", "id": "req_88", "error": { "code": -32001, "message": "NEED_APPROVAL", "data": { "approval_id": "apr_12345", "target_capability": "refund_order", "reason": "退款金额超过1000元" } } }这套机制上线后,财务同事终于不用每天手工去业务后台翻单子再操作了,审批动作变成了一个标准事件流,所有操作都有审计日志。我觉得这个设计比很多只做“调用”的 Agent 框架要实用得多。
3.4 实际运行效果与本地测试
服务端和 SDK 都写好之后,我建议用一个简单的三节点拓扑做本地验证:一个控制面,一个订单 Agent,一个编排器 Agent。首先是启动控制面,观察日志里出现registry started, listening on :8080;然后启动订单 Agent,控制面日志会出现agent order-agent-01 registered, capabilities: [query_order];最后从编排器发一条 invoke。
我通常会用一个命令行工具来模拟调用方:
agent-reach-cli invoke \ --capability query_order \ --payload '{"order_id":"20240518001"}' \ --timeout 5000控制面日志此时会打印:
[router] matched capability=query_order to agent=order-agent-01 (instance=2) [router] forwarding invoke req_8f3a2c to agent=order-agent-01 [gateway] got response from agent=order-agent-01 in 320ms看到这三行日志,说明链路已经通了。整个本地环境从零到跑通,大概半小时以内。我建议新手先把这三个节点跑熟,彻底理解注册、路由、调用这三段的关系,再往里面加广播、扇出等复杂模式。
4. 生产环境会找你麻烦的四个场景
4.1 把 Agent-Reach 当万能网关:权限模型必须前置
Agent-Reach 上线初期,我们只做了 Token 校验,所有调用了 Token 的 Agent 都能访问任意能力。结果出了两次事故:一次是测试 Agent 误调了生产环境的退款能力,另一次是某个低权限 Agent 能读取到客户敏感订单数据。
后来我们加了完整的 RBAC 模型。每个 Agent 在注册时声明自己需要的权限范围,控制面在路由时做一次授权检查。授权规则从允许所有改成了默认拒绝,只有显式配置的策略才放行。比如:
policies: - agent_group: "customer-service" can_invoke: ["query_order", "create_ticket"] - agent_group: "finance" can_invoke: ["refund_order"] require_approval: true这个改动看着简单,真正做起来要命,因为存量系统里到处都在直接调用,加完权限后必须先跑一遍全量回归测试。但值得,安全审计时能拿出清晰的“谁在什么条件下能调用哪个能力”这个证据,比什么解释都管用。
4.2 网络分区与消息丢失:为什么需要持久化队列
用内存队列跑了一段时间后,有一天某个 Agent 所在的主机网络抖动,控制面把请求转发过去,WebSocket 连接断了,消息丢在半路。调用方只看到一个超时错误,但具体是什么原因完全不知道。
这个问题暴露了我们实现的短板:内存队列在进程重启时丢失全部消息,在转发失败时也不会重试。后来生产版本加了一个可插拔的后端,默认还是内存队列,但可以切换到基于 SQLite 或 NATS 的持久化队列。转发失败的消息进入重试队列,带指数退避和最大重试次数。重试耗尽后进入死信队列,并发送agent.reach.event通知调用方。
这个改动虽然只影响很小一块,但直接决定 Agent-Reach 能不能用在交易链路中。我当时跟团队说:丢消息和超时是两回事,超时至少调用方知道失败,丢消息是所有人都以为成功了,这最危险。
4.3 网络分区与消息丢失:在复杂代理链中定位“卡住”的那一环
Agent 链路一大,最糟心的体验就是“请求发出去了,十分钟没响应,但不知道卡在哪”。我们有个场景是五级代理链:入口 Agent → 意图识别 → 订单查询 → 售后策略 → 工单创建。某次压测时整条链路在高峰期偶发卡顿,靠传统日志挨个查,半小时才定位到是订单查询 Agent 连接的外部 ERP 接口超时。
后来我们强制启用了基于 OpenTelemetry 的分布式追踪。Agent-Reach 在控制面、每个 SDK 内部都有埋点,每个 invoke 请求都带trace_id,各个阶段耗时都上报。操作界面里直接按trace_id搜索,就能看到整条链路的瀑布图:哪一环耗时最长、哪一环报错、哪一环在等待人工审批。
实现到这一步,我才真正觉得 Agent-Reach 是一个可运维的系统,而不是一个玩具。强烈建议任何要做多 Agent 调度的人,从第一天就把 trace_id 机制设计进去,后面补几乎是推倒重来。
4.4 性能基准与容量规划
实测数据:控制面单节点内存模式,在 8 核 16G 的云主机上,纯转发 QPS 稳定在 1.2 万左右,P99 延迟 15ms。Agent 数量到了 2000 个,心跳处理会吃掉一部分 CPU,但整体还能扛。广播模式性能会随着 Agent 数线性下降,所以生产环境我们限制广播的目标数量上限 50 个,超过就建议改用扇出到指定组。
内存和连接数也要算好。每个 WebSocket 连接平均占用 10KB 左右缓冲区,1 万个连接大概是 100MB。心跳消息如果处理不当,每秒会有成百上千条,必须用 goroutine 池而不是每个请求开一个协程。下单到这种量级,Go 非常从容,换成 Python 服务端恐怕早就扛不住了。
容量规划我建议按三倍冗余来算:如果峰值转发 5000 QPS,就按 1.5 万 QPS 的规格买资源,因为重试风暴、广播风暴在故障时会把流量放大。第一次遇到故障时,我才明白所谓“故障时流量翻倍”不是开玩笑。
5. 往更远的场景扩展
Agent-Reach 目前的形态离我理想中的“Agent 互联网基础设施”还有距离。接下来的方向有几个:一是支持跨地域控制面同步,让不同机房或云账号下的 Agent 能互相发现;二是增加协议兼容层,比如把 JSON-RPC 转成 OpenAI 的 function calling 格式,让普通 LLM 也能直接参与调用链;三是把权限模型继续细化,支持基于数据内容(比如订单金额)的动态审批阈值。
我个人最期待的是协议兼容层。现在很多 Agent 框架各自为政,互相之间语言都不通,如果 Agent-Reach 能做一个“协议翻译器”,让 LangChain 的 Agent、自研 Agent、甚至外部的 SaaS Agent 都能互相调用,这个价值会比一个具体的调度工具大得多。
最后分享一个我自己的经验:做这种基础设施型项目,最容易犯的错是过度设计。第一版只要能跑通注册、路由、调用三件事,就不要去碰编排、多模态、动态规划这些虚的。把一条链路跑到生产环境,真实流量会告诉你下一步该做什么。我现在觉得,与其天天追着最新的 Agent 框架走,不如先把“让 Agent 好好说话、找到彼此、可靠地完成任务”这件朴素的事做到极致。