过去一年里,我一直在折腾多 Agent 系统。手底下管着十几个不同团队开发的 Agent,每个单拎出来能力都不差,但想让它们互相协作的时候,最先崩溃的往往不是模型本身,而是通信这一层。写死 API 地址?改一个字段就要全线跟进。用消息队列硬顶?请求-响应式的对话根本没法容忍异步延迟。后来我把内部的一个 RPC 框架彻底重构,做成了现在这个 Agent-Reach:一套面向多 Agent 场景的轻量级通信网关协议层。
这篇文章算是一份完整的技术复盘,从设计思路到落地实现、从压测数据到踩坑记录都会覆盖到。如果你也在做多 Agent 协同、智能体编排,或者只是手头有几个 Agent 想打通任督二脉,这条路应该能帮你省下不少试错时间。
1. 为什么 Agent 之间“说话”这么难
1.1 孤岛化的 Agent:每个都很强,但连不起来
先说一个我见过无数次的场景。团队 A 做了一个客服意图识别 Agent,团队 B 做了一个订单查询 Agent,团队 C 做了一个售后政策 Agent。从能力上来说是互补的——用户问“我昨天买的手机能退吗”,需要 A 识别意图、B 查订单、C 查政策,然后还得有个什么东西把它们的结果串起来。可真要做这个串联的时候,问题全冒出来了:
- A 的接口是内部 HTTP 服务,B 用的是 gRPC,C 直接读数据库。
- A 返回的字段叫
intent_code,B 返回的字段叫order_status,对不上。 - 三个 Agent 各自的超时时间是 10 秒、3 秒、30 秒,到底该等谁?
- AB 之间的网络隔离策略都不同,有的能通,有的不能。
- 最要命的:谁也不知道当前集群里有哪些 Agent 在线,能力是什么,版本是什么。
这本质上是“分布式系统中服务发现问题”的 Agent 版。传统微服务有注册中心、有 API 网关、有统一协议,但 Agent 这个物种出现得比微服务晚,大家默认各写各的接口,根本没人定义过“Agent 之间怎么相遇”。
1.2 直接用消息队列硬接的失败教训
我最早也走过弯路。当时想着“既然要解耦,上个消息队列总没错吧”,于是把 Kafka 拉进来,每个 Agent 发消息到一个固定 topic,然后监听另一个 topic。结果实践了两周就放弃了,原因非常现实:
- Agent 之间的对话大部分是“我问你答”的同步模式,不是“我发你收”的异步流水。一条对话要跨好几个 Agent 串行处理,用队列之后每次都要回调、等待,排查问题的时候全是断点。
- 消息队列消费组、分区、提交 offset 这些机制,对 Agent 开发者来说是额外负担。人家只是想把自己的能力暴露出去,不想学 KafKa 运维。
- 最尴尬的是队列本身的“业务不感知”特性:队列只管把消息从 A 送到 B,但不会管“这条消息该匹配谁的能力”“B 忙不忙”“需不需要重试透明地调度到 C”。
那之后我才想明白一件事:Agent 之间缺的不是“管道”,而是一个“握手层”。这个握手层要解决三个问题——发现(谁知道谁存在)、连接(建立稳定的双向通道)、语义(双方交换的信息如何被双方理解)。
1.3 Agent-Reach 的定位:通信层,不是编排层
这里需要划一个清晰的边界。Agent-Reach 不会替你做任务规划,不关心你这个 Agent 内部是 ReAct 还是 Plan-and-Execute,更不会替你写 Prompt。它只做三件事:
- 维护一份全局的 Agent 能力目录,你注册进来,别人就能发现你。
- 提供一条稳定的消息通路,支持请求-响应和发布-订阅两种模式。
- 保证消息在传递过程中格式可校验、版本可感知、错误可追溯。
打个比方:Agent-Reach 是“电话交换机”,不是“秘书”。秘书会帮你筛选电话甚至替你决定接不接,交换机只负责让电话能拨通。编排逻辑和决策逻辑,应当留在 Agent 上层的编排器里,而不是沉到通信层。
2. Agent-Reach 的设计骨架:三角色、两通道、一协议
2.1 三个核心角色
落地的时候我把系统拆成了三个角色,职责分得非常清楚:
| 角色 | 职责 | 类比 |
|---|---|---|
| Agent Node | 真正干活的服务,注册自身能力、收发消息 | 电话机 |
| Reach Router | 维护路由表,转发控制面消息,做连接的建立与拆除 | 电话交换机 |
| Control Registry | 集中存储 Agent 元数据、能力声明、在线状态 | 电话号码簿 |
消息分两个平面走:控制面走 Router 转发,负责注册、发现、心跳、握手、鉴权;数据面走点对点直连,Router 只做路由决策不持久化业务内容。这个设计在初期被人问过“为什么不全部走 Router”,原因下文单独说。
2.2 Reach 协议的三层模型
通信协议我参考了 HTTP、gRPC、XMPP 的各自经验,设计了这么一套三层结构:
Transport 层:默认走 WebSocket 长连接,Agent 与 Router 保持常驻连接,主要考虑的是控制面存在大量心跳和状态同步,长连接省去反复握手的开销。数据面和 Agent 之间的直接对话支持 WebSocket 和 HTTPS 两种模式,前者面向实时流式输出(Agent 说话往往是流式的),后者面向一次性响应。传输层的序列化格式用 MessagePack,比 JSON 小约 30%,比 Protobuf 更方便调试(因为保留了字段名)。
Routing 层:消息头携带topic(主题)、src_agent_id(来源)、dst_agent_id(目标)、message_type(请求、响应、事件)、request_id(请求追踪 ID)。Router 根据路由表决定消息是转发给某个具体 Agent,还是广播给一组符合条件的 Agent,还是直接回落给编排器。
Payload 层:真正的业务负载,Agent-Reach 不做任何内容层面的解析,但要求每个 Agent 声明自己的输入输出 JSON Schema。Router 在转发时会做一次 schema 的快速校验,不匹配直接拒收并返回错误码。这一步在早期救了我无数次——因为大家的字段类型太容易在迭代里悄悄变了。
2.3 为什么数据面要设计成“注册中心 + 点对点直连”
这是整个架构里最值得说的一点。早期版本我把所有消息都走 Router 中转,当时只有十几个 Agent 的时候很香——流量小、日志集中、什么问题都能抓包看。但模拟压测到 200 个 Agent 时,中转模式的瓶颈就出来了:
- 每条消息都要被 Router 反序列化再重新序列化,CPU 占用先爆了。
- 数据面大流量(比如一个 Agent 流式输出对话内容,每秒几百条 chunk)把控制面的心跳消息挤到队尾,导致正常 Agent 动不动被误判离线。
后来改成注册中心 + 点对点直连之后,Router 只保存一张 [目标 Agent ID -> 地址:端口] 的路由表,业务消息由发起方 Agent 直接打到目标 Agent。Router 只在连接建立、握手、鉴权、心跳超时判定时介入。这话怎么说呢——“查号靠总机,通话靠专线”,总机才不至于被闲聊拖垮。
2.4 能力声明:让“发现”真正可用
“发现”这事不能只停留在“某个 Agent 上线了”,更关键的是“它能干什么”。我给 Agent-Reach 定义了一套 Capability Manifest——每一个 Agent 注册时必须上报自己的能力清单,内容大致像这样:
{ "agent_id": "order-query-agent", "agent_version": "2.1.0", "capabilities": [ { "name": "query_order_status", "input_schema": { "type": "object", "properties": { "order_id": { "type": "string" } }, "required": ["order_id"] }, "output_schema": { "type": "object", "properties": { "status": { "type": "string" }, "delivery_date": { "type": "string" } } } } ], "topics": ["order.event.created", "order.event.shipped"], "auth_scope": "read:order", "schema_version": "1" }有了这份清单,穿戴层可以做三件事:一是编排器用语义匹配找到“谁能处理订单状态查询”;二是在消息到达之前,Router 先用 input_schema 预校验;三是升级 Agent 时对比 schema_version,发现不兼容直接拒绝接入——这个后面“踩坑”部分还要展开。
3. 从 0 到 1 把 Agent-Reach 跑起来
3.1 本地环境准备
Agent-Reach 本身对部署环境没什么特殊要求,我在本地用的是一台 4 核 8G 的 Linux 机器,装好 Docker 就能跑。基础设施依赖只有一个 Redis(做注册表的 TTL 存储和分布式锁),Router 和 Registry 都是单二进制文件,直接 Docker 镜像启动即可。
这里有个容易被忽略的点:Agent-Reach 的私用 Agent 版本和使用注册表最好用同一个时钟源。早期我本地跑的时候发现 Agent 的注册请求经常被拒绝,排查到最后是本地机器时间慢了两分钟,心跳签名的过期校验直接失败。跨机部署一定统一对时,NTP 这个老朋友必须重视。
3.2 配置文件里值得细看的几个字段
启动 Router 的config.yaml有几个字段我特地说一下,因为它们直接决定系统的行为边界:
router: listen_addr: ":7800" registry_refresh_interval: 30s heartbeat_timeout: 60s max_message_size: 4MB registry: storage: redis redis_addr: "localhost:6379" agent_ttl: 180s enable_schema_validation: true auth: token_ttl: 24h enable_signature_verification: true这几个值得展开的坑位:
agent_ttl: 180s:注册信息在 Registry 里只会存活 180 秒,Agent 必须周期性续约。如果 Agent 异常退出,不需要显式调用注销接口,TTL 到期后自动完成祛除。这个设计是不信任任何“优雅退出”的保险丝——实际运行时确实会有各种 kill -9,与其靠 Agent 自觉不如靠 TTL。heartbeat_timeout: 60s:Router 判定一个连接死掉的时间阈值,必须大于心跳发送间隔的 2~3 倍。经验值:心跳 20 秒一发,超过 60 秒没收到就判定离线。max_message_size: 4MB:这是个大坑,后面踩坑节里细说。
3.3 Python SDK 接入一个搜索 Agent
Agent-Reach 附带了一个轻量级 Python SDK,接入过程分三步:注册、订阅、收发消息。我直接贴一段当时接入搜索 Agent 的代码,核心流程都在里面:
from agent_reach import AgentClient, Capability, EventSubscription # 1. 构建一个客户端实例 client = AgentClient( agent_id="search-agent", router_url="ws://192.168.1.20:7800/ws", registry_url="http://192.168.1.20:7900", token="<access_token>" ) # 2. 注册函数式能力 @client.capability(name="web_search", input_schema={ "type": "object", "properties": { "query": {"type": "string"}, "top_k": {"type": "integer", "default": 3} }, "required": ["query"] }) async def search_handler(payload, context): query = payload["query"] # 实际调搜索引擎的逻辑省略 result = do_real_search(query, payload.get("top_k", 3)) return {"items": result, "query": query} # 3. 订阅感兴趣的事件(比如别的 Agent 发出来的“需要外部资料”的事件流) @client.on_event("agent.need_external_info") async def handle_event(event_payload, context): query = event_payload["query"] return await search_handler({"query": query}, context) # 4. 启动连接,保持进程驻留 client.run_forever()整体接入体验我自己评价是相当轻量的——SDK 底层帮你处理了注册、心跳续约、重连保活这些事,业务代码只需要声明能力和写处理函数。你要是想快速验证,也可以先用agent-reach-cli命令行工具注册能力和手动发消息,我再放一段 CLI 的用法:
# 注册一个临时 Agent,30 秒后自动移除 agent-reach-cli regist --router ws://localhost:7800/ws --name temp-agent --ttl 30 # 向某个 Agent 发请求,等待响应打印 agent-reach-cli request --to order-query-agent --topic order.query \ --data '{"order_id": "SO12345"}' # 查看当前在线 Agent 列表 agent-reach-cli listCLI 工具适合本地验证和学习——你不需要写一行 Python,就能把整套通信流程手动跑通,这对理解机制特别有帮助。
3.4 路由规则:topic 与 Agent 能力的匹配
当一条消息从 Agent A 发出时,Router 需要确定它该到谁手里。我用的是“topic 匹配 + 能力匹配 + 负载策略”三件套:
- topic 先过滤:消息头里的
topic字段必须属于目标 Agent 声明过的topics之一,或符合目标 Agent 能力名称的映射规则。 - 能力再验证:Router 拿着 Payload 和目标的
input_schema做快速校验,类型不对直接返回ERR_SCHEMA_MISMATCH。 - 负载策略兜底:同能力有多个 Agent 注册时,支持随机、轮询、最少连接数三种策略。默认是最少连接数,因为 Agent 的长连接池占用情况更能反映真实负载。
路由表变化的传播通过事件驱动:Agent 上线/下线/能力更新都会发出路由变更事件,Router 本地缓存会自动增量更新。这里我压测时发现的性能上限是:单 Router 支撑约 500 条路由规则时,路由决策延迟 < 1ms;超过以后决策延迟陡增,建议分片。
3.5 会话与追踪的工程落地
Agent 对话和普通 RPC 有一个显著差异:一条完整的 Agent 任务可能横跨 5~8 个 Agent,任意一个环节失败都可能需要回退重来。所以 Agent-Reach 里对“会话上下文”的支持是内置的:
request_id:一次外部请求发起时生成,贯穿整条调用链。correlation_id:同一个任务里产生的多条消息共用,用来把并行调用的结果重新组合。trace_id:对接 OpenTelemetry 的 trace,用于消息路径的分布式追踪。
我在开发时把这三个 ID 强制放进 Routing 层头部,初期团队里有人嫌“加字段麻烦”,但上线排障时是救命稻草。有一次多 Agent 任务超时,我直接按trace_id查了整张消息流转日志,一秒定位到是第三个 Agent 的数据库死锁拖慢了全链路——没有这个机制,这种跨进程问题几乎没法查。
4. 实测表现:控制面与数据面的延迟和吞吐
4.1 压测的部署形态与方法
我把压测分成两条线来测——控制面和数据面,因为两者的访问模式和瓶颈点完全不同。压测环境在我本地那台 4 核 8G 机器上,跑了 1 个 Router + 1 个 Registry + Redis,同时用 Go 写了模拟 Agent 的负载发生器(能并发 300 个虚拟 Agent 连接)。测试方法不是教科书式的固定 QPS 测试,更接近我工作中的真实场景:模拟 Agent 集群反复上报能力、互相发现、频繁调用。
| 测试项 | 并发规模 | 结果 |
|---|---|---|
| Agent 注册 + 能力上报 | 300 个并发注册 | 平均完成 286ms,P95 420ms |
| 路由查询(控制面) | 500 并发请求 | 平均延迟 0.7ms,P95 1.5ms |
| 数据面点对点中转(HTTP) | 1000 QPS | 平均延迟 8.2ms,P99 23ms |
| 数据面流式消息(WebSocket) | 1500 QPS | 平均延迟 6.8ms,P99 19ms |
| 心跳保活(300 Agent 常驻) | 15 条/秒 | CPU 额外占用 < 2% |
这套数据告诉我们:控制面不是瓶颈(0.7ms 的查询延迟几乎可以忽略),真正的压力在数据面。而数据面之所以还能压住,正是靠了“注册中心 + 点对点直连”——Router 只做了初步的轻量转发决策,不碰 Payload 内容。
4.2 长期稳定运行的观察
压测是短时间的证明,上线运行才是长了络的检验。我让 20 个 Agent 跑了一整周,每个 Agent 和 Router 保持一条 WebSocket 长连接,观察到的长期状态指标如下:
- 单连接空闲态内存占用约 2.1MB,常驻 20 条连接,Router 内存占用稳定在 180MB 左右。
- 长连接断线重连发生两次,均为 Agent 重启导致,重连耗时约 2~5 秒(包含重新注册能力的时间)。
- 没有出现内存泄漏的迹象。这一点归功于消息池:每当消息生命周期结束,内存会随 GC 正常回收。
- Redis 中注册表条目数始终维持在预期值附近,没有堆积。
4.3 一个真实任务的完整链路
为了让你有更直观的感受,我拿真人任务举例:用户问“昨天买的手机今天能不能退”。
这条消息从入口 Agent 进来,编排器解析意图后转给 Agent-Reach。Agent-Reach 在 40ms 内完成了“查找订单查询能力 Agent -> 路由 -> 返回订单状态”的一轮调度;随后编排器又向售后政策 Agent 发起能力查询。整条链路上四次 Agent 间通信,Agent-Reach 总共引入的额外延迟是 31ms——相对模型调用动辄数秒的时间,这个开销完全在可忽略的范围。
5. 上线三个月踩过的四个坑
5.1 事件风暴:Agent 重启导致注册表爆炸
这个坑是生产环境第一周就遇到的,印象特别深刻。
现象:某团队把自己的编排 Agent 做成容器调度模式,Pod 频繁重建。每次重建都会带上一个新的唯一实例标识重新注册,而旧实例的注册信息因为 TTL 还没到期,也继续留在 Registry 里。几天后我查询在线 Agent 列表时发现:物理上只有 30 个 Agent,注册条目却有 2700 多条。
排查链路:第一步,我看到注册数量异常后,先拉取了 Registry 的 key 列表,发现大量条目集中在少数几个 agent_id 上;第二步,查看这些条目的心跳时间,发现全部来自同一批已经消失的旧容器;第三步,看 Agent 的启动日志,确认它没有先注销旧注册再注册新实例。
修复方案分两层:一是 Agent SDK 启动时检测到现有注册记录里面 agent_id 相同但实例 ID 不同,先主动注销旧条目再上报新能力;二是 Router 增加“重复注册收敛机制”,同一agent_id只保留最新的实例 ID,旧条目立即失效。双管齐下之后,注册表规模回归正常。
5.2 消息版本不兼容时“假装没事”会造成更严重的错
第二个坑在我们升级 Agent 版本时爆发。当时有个订单查询 Agent 从 v1 升到 v2,把输出字段status从字符串改成了结构化对象。Agent-Reach 按照设计应该拒绝不匹配 schema 的消息——但问题出在“旧 Agent 主动发来的消息”上。旧 Agent 不知道自己的 schema 已被更新,业务代码还在按旧格式组装响应,新 Agent 收到后解析失败,连续三次重试也失败。
我当时的第一反应是“加个兼容层”,在 Agent 端做字段转换。后来被教训了一课:让旧 Agent 升级永远比兼容旧格式靠谱。减少版本爆炸的正确方式是在注册时强制对比schema_version—— 如果 Router 发现消息的 payload 格式与目标 Agent 当前声明的 schema 版本不兼容,直接返回ERR_SCHEMA_VERSION_MISMATCH,引导调用方去更新能力版本,而不是让 Agent 端做“尽力而为”的暴力转换。
5.3 重试风暴:一个 503 引发几十倍流量
这是多 Agent 编排场景老生常谈的坑,但在 Agent-Reach 里表现得格外明显。某次下游 Agent 因为数据库连接池满了,对上游返回 503。上游三个 Agent 各配置了重试逻辑,每人重试五次——结果 1 次请求被放大成十五次调用,数据库直接被拖死,然后所有 Agent 的请求全部超时,整个系统雪崩。
修复链路:第一步,在 Agent-Reach 的 SDK 里内置了层统一的节点重试策略——默认只重试一次,且重试间隔采用随机抖动(jitter)指数退避;第二步,在 Router 层加了熔断器,当某个目标 Agent 的错误率在 10 秒窗口内超过 50% 时,停止向该 Agent 转发新请求并快速返回失败,避免上游 Agent 反复踩坑。第三步,所有“需要多次重试”的请求必须在编排器层级显式声明重试次数,不允许下游 Agent 自行乱加。
还有一个我建议所有项目都采用的配置:重试上限参数集中管理,不要在 Agent 代码里逐份写重试逻辑。Agent-Reach 把这个参数放在 SDK 初始化配置里(retry_max_attempts),统一赋值,统一审计。
5.4 大消息把内存打爆,端口假死
第四个坑和最开始的max_message_size字段有关。默认 4MB 是我拍脑袋设的,结果某个 Agent 在做文档解析时,直接把一张 20MB 的图片 Base64 塞进消息 payload。Router 在转发时发现消息超限,按照设计应该拒收——但由于最初的校验逻辑是把整个消息先读到内存再判断大小,20MB 的消息有 10 个并发同时到达时,Router 内存直接顶满,端口开始假死。
后来我把校验前置:在 WebSocket 收帧时通过帧头声明长度提前判断,超过限制立即断连而不是攒完再判。同时也把 max_message_size 从默认 4MB 调低到 2MB,强制大文件走独立的文件交换通道,而不是塞进消息负载。这条经验我强烈建议所有做消息网关的人都记下来——限制必须前置,不能等数据全收下来了才做判断。
6. 下一步:从通信网关走向 Agent 编排底座
6.1 能力市场:让 Agent 能力变成可发现可订阅的商品
Agent-Reach 接进十几个 Agent 之后,我逐渐意识到一个问题:现在“发现”解决了,但“怎么知道哪个 Agent 的最新能力适合我的任务”仍然靠人肉。所以下一步准备做“能力市场”(Capability Marketplace)——每个 Agent 发布能力到统一目录,消费方通过语义检索找到能力,一键订阅并自动生成调用代码。这个灵感很朴素,像应用商店之于手机,能力市场之于 Agent 生态是自然演化。
6.2 联邦化:跨机房的多 Router 互联
目前我业务还只是一个机房内单 Router 的规模。如果哪天需要跨机房调度,就要考虑多 Router 组网了。设计草图里是这样:每个机房一个 Router,Router 之间通过 gossip 协议交换路由摘要,把“本机房有哪些 Agent 在线”同步给邻居——查询本地 Router 拿不到结果时,转发到邻居 Router。控制面多跳路径控制在两跳以内,整个联邦网络依然保持低延迟。
6.3 拥抱编排层:我并不担心 Agent-Reach 被编排器取代
总会有人问我:LangGraph、CrewAI、AutoGen 能不能干 Agent-Reach 的事?我的回答是:编排框架负责“想清楚步骤”,Agent-Reach 负责“把步骤间的话传到位”。二者是不同层的。框架那层调 Agent 时,可以复用 Agent-Reach 的能力发现和路由机制,两个项目完全可以共存。真正让我兴奋的发展方向是:通过 Agent-Reach 观测到的消息流转日志,反过来指导编排器优化规划策略——比如发现某类请求总是超时,就可以提示编排器优先选择别的 Agent 路径。这种“通信层 -> 数据反哺 -> 编排优化”的闭环,才是通信网关最大的长期价值。
最后再分享一个我实际操作过程中的体会:不要一开始就把通信协议设计得太丰富。Agent-Reach 早期最大的失误之一,就是我给它加了太多用不到的“增强能力”,比如消息优先级的九级选项、自定义路由脚本。真正上线以后,每天用的就是最朴素的“注册 - 发现 - 路由转发 - 心跳保活”这几个核心能力。通信层的核心价值就是“稳定地传话”,话术和语义的事,留给上层的脑子去操心。先跑通最小的闭环,再在数据驱动下逐步增加新能力,这是我辅导过多个内部团队接入 Agent-Reach 总结出的第一原则。