☰
多Agent通信底座Agent-Reach:基于路由表与收件箱的可靠消息投递实践
2026/10/14 13:52:08 网站建设 项目流程

1. 从一场联调事故说起:Agent 之间为什么总"失联"

上个月我们内部做一次多 Agent 协作的压测,场景很简单:一个规划 Agent 接到用户需求后,拆出三个子任务,分发给检索 Agent、代码生成 Agent 和质检 Agent。听起来很常规对吧?结果第一次跑通就翻了车。规划 Agent 拿着子任务的执行结果去做汇总,发现三个子 Agent 的响应里有两个超时,一个返回了别的主线的中间结果。日志里全是Connection refused和Task not found。当时我们几个工程师对着监控面板面面相觑:每个 Agent 单测都是好的,服务也都活着,为什么一协作就变成一锅粥?

问题出在一个被绝大多数人忽视的环节:Agent 之间的"寻址"和"投递"完全是裸奔的。单体 Agent 内部怎么调函数都行,一旦拆成多个 Agent 独立部署、各自维护状态,你面对的就是一个分布式系统最经典的三个难题——服务发现、可靠投递、失败恢复。当时市面上有不少编排框架,但要么太重(强制你上全套工作流引擎),要么太轻(只做 HTTP 转发),真正适合中小团队自建 Agent 网络的通信底座几乎是空白。

我花了三周时间写了一个叫 Agent-Reach 的协调层组件,用来解决上面这堆破事。这篇文章就把它的设计思路、核心实现、以及我在真实压测中踩过的坑全部摊开讲。适合正在搭多 Agent 系统、被互联互通折磨得想骂人的同学参考。它不是银弹,但能让你从"天天救火"变成"睡个安稳觉"。

2. 设计之前,先把"Agent 通信"这件事拆明白

2.1 三种通信模式,你混用了吗

动手写代码之前,我先把 Agent 之间的通信模式梳理了一遍。几乎所有协作场景都逃不出这三种:

第一种是请求-响应,规划 Agent 派活给执行 Agent,等它干完把结果拿回来。这是最常见的模式,也是最容易出问题的,因为一次请求的完整链路包含"派活、执行、回传、确认",任何一环断了,对调用方来说都是"超时"。

第二种是事件广播,比如一个 Agent 监测到配置变更,需要通知所有相关 Agent 刷新缓存。这种模式对"送达"的要求没那么严格,漏一两个订阅方可以接受,但你不能把关键事件也做成广播——那纯粹是给自己埋雷。

第三种是流式推送,适合长时间运行的任务,比如代码生成 Agent 每产出一个文件就往仓库 Agent 推一条消息。流式场景里真正难的不是推,而是"断点续传"和"消费位点管理"。

Agent-Reach 的核心思路很简单:所有模式统一抽象成"消息",只不过消息的投递语义不同。请求-响应是request类型,广播是event类型,流式推送是stream类型,再加上一个ack类型专门做确认回执。类型一收拢,后面的存储、路由、重试逻辑就都能统一处理了。

当时我自己的踩坑经历是:第一版把所有通信都 HTTP 同步调,以为最简单。结果广播一多,Agent 之间互相等,链路一长就雪崩。后来才意识到,同步调用只能用于请求-响应,事件广播和流式推送必须走异步队列,这是架构层面的红线,不是优化层面的喜好。

2.2 注册中心?不,Agent-Reach 用"路由表 + 租约"

很多人的第一反应是上 ZooKeeper 或者 etcd 做服务发现。但我做 Agent-Reach 时的约束条件很明确:不想引入额外的基础设施,团队里没人愿意专门维护一个注册中心集群。所以我把服务发现做成了基于租约(lease)的轻量路由表,直接嵌在协调节点进程里。

每个 Agent 启动时向 Agent-Reach 的协调节点注册自己,注册信息包括:Agent 编号、服务名、能力标签(比如capability:codegen)、回调地址、以及一个 TTL 租约。协调节点维护一张内存路由表,Agent 每隔 TTL/2 的时间续租一次。租约过期没续上,路由表就把这个 Agent 标记为不可用,再等一个宽限期就彻底剔除。

这套机制跟注册中心比,牺牲了"跨节点容灾"和"历史变更追溯",换来了零依赖、毫秒级感知故障,对小规模 Agent 网络(几十个节点以内)完全够用。你要问我什么时候该上 etcd?当 Agent 数量超过一百、协调节点需要多活、或者你需要严格的版本兼容管理的时候,再考虑不迟。

路由表里还有一个容易被忽略的设计:能力路由。调用方不指定具体 Agent 编号,只声明"我要一个能做代码生成的 Agent"。协调节点会根据能力标签、当前负载、租约新鲜度三个维度打分,选一个最合适的投递目标。这个设计让 Agent 网络具备了一定的弹性——某个 Agent 挂了,能力路由会自动把任务分给同能力的另一台。

2.3 为什么必须引入"收件箱"

我见过不少团队做 Agent 通信,直接在回调地址上做 POST,然后把任务 ID 挂在 URL 上。表面看能跑,但有一个致命问题:Agent 执行任务期间可能重启、可能断网,重启之后回调地址可能都变了。这时候任务状态就丢了,你根本不知道它执行到哪一步。

Agent-Reach 给每个 Agent 维护一个持久化收件箱。所有发往该 Agent 的消息先进收件箱,Agent 处理完一条弹出一条。收件箱的存储我用的是本地嵌入式数据库(SQLite 就够用,单机场景没必要上 PostgreSQL),每条消息记录message_id、type、payload、status、create_time、attempts六个字段。消息的流转状态机是:pending → processing → done,或者pending → processing → failed → pending(待重试)。

收件箱的价值在于:Agent 的消费进度不依赖进程内存,重启后从数据库里接着干。这本质上就是 Outbox 模式在 Agent 场景的应用。我当时想明白这一点之后,很多"任务神秘消失"的问题都迎刃而解了。

3. Agent-Reach 的核心实现:注册、路由、投递、回执

3.1 注册与租约续期的代码骨架

Agent 端的启动注册逻辑,我用 Python 写了个示例,实际项目里不管什么语言,套路是一样的:

import asyncio import time import httpx class AgentClient: def __init__(self, agent_id, coordinator_url, callback_url, ttl=30): self.agent_id = agent_id self.coordinator_url = coordinator_url self.callback_url = callback_url self.ttl = ttl self.lease_task = None async def register(self, capabilities): payload = { "agent_id": self.agent_id, "callback_url": self.callback_url, "capabilities": capabilities, "ttl": self.ttl } async with httpx.AsyncClient() as client: resp = await client.post(f"{self.coordinator_url}/register", json=payload) if resp.status_code == 200: self.lease_task = asyncio.create_task(self._keep_lease()) async def _keep_lease(self): interval = self.ttl / 2 while True: await asyncio.sleep(interval) async with httpx.AsyncClient() as client: await client.post( f"{self.coordinator_url}/renew", json={"agent_id": self.agent_id} )

这里有一个细节:续租间隔我特意设为 TTL 的一半,而不是 TTL。网络抖动是常态,如果间隔设成接近 TTL,一次偶发超时就会导致 Agent 被误判下线,然后能力路由把任务发给别的 Agent,造成重复执行。TTL=30 秒、每 15 秒续租一次,既能容忍一定网络波动,又能让故障感知控制在 30 秒内,算是一个经过压测验证的平衡值。

协调节点那端维护租约的数据结构也很直接:

class LeaseRegistry: def __init__(self): # agent_id -> {capabilities, callback_url, last_renew, status} self.agents = {} def renew(self, agent_id): if agent_id in self.agents: self.agents[agent_id]["last_renew"] = time.time() self.agents[agent_id]["status"] = "online" def sweep(self, now, ttl, grace_period): # 定期清扫过期租约 for agent_id in list(self.agents.keys()): last = self.agents[agent_id]["last_renew"] if now - last > ttl: self.agents[agent_id]["status"] = "suspect" if now - last > ttl + grace_period: del self.agents[agent_id]

grace_period这个参数我吃过亏,后面专门讲。现在先记住一个原则:判定下线要慢,标记可疑要快,真正剔除要更慢。宁可让一个 Agent 被短暂重复投递,也不能让一个活着的 Agent 被永久剔除。

3.2 消息投递的三段式:入队、分拣、回执

消息从调用方发出到被 Agent 消费,Agent-Reach 的处理管线是三段式的:

第一段入队。调用方调用POST /dispatch,协调节点把消息写入目标 Agent 的收件箱,状态置为pending,然后立刻返回一个message_id。注意这里不是同步转发,无论目标 Agent 是否在线,消息都已经落库了。这个设计保证:调用方不需要等目标 Agent 就绪,消息一定不会丢。

第二段分拣。Agent 端有一个长轮询的消费者线程,每隔几百毫秒向协调节点拉取自己收件箱里status=pending的消息。为什么用拉而不是推?因为推模式下 Agent 重启期间推送的消息会堆积在协调节点的内存缓冲里,Agent 恢复后容易重复消费或漏消费。拉模式天然支持断点续传:Agent 重启后从数据库里恢复自己的消费位点,再继续拉。这就是前面说收件箱的价值落地。

第三段回执。Agent 处理完消息后调用POST /ack,传message_id和处理结果。协调节点把消息状态从processing改为done。如果 Agent 处理失败,调用POST /nack,协调节点根据attempts字段判断是重试还是进入死信。

调用方怎么拿到最终结果?每个request类型消息都有一个reply_to字段,指向调用方自己的收件箱。目标 Agent 处理完,结果作为一条reply消息进调用方的收件箱,调用方拉取即可。你会发现,每个 Agent 既是消息的生产者也是消费者,整个网络是对称的。这个设计让 Agent-Reach 天然支持多级流水线:Agent A 派活给 B,B 的回复进 A 的收件箱,A 继续往下派。

3.3 重试策略:指数退避与死信队列

消息重试是最容易拍脑袋设计的部分。我第一版写的是固定间隔重试 3 次,每次间隔 5 秒。实测下来问题很大:如果目标服务因为过载而拒绝,固定 5 秒重试三次等于在人家最忙的时候又捅了三刀。

正确姿势是指数退避加抖动。重试间隔 =base_interval * 2^attempt,再乘以一个随机因子,比如在 0.8 到 1.2 之间浮动。base_interval 建议 2 秒起,最大间隔封顶 120 秒。同时设置max_attempts,超过之后进死信队列,由人工或其他兜底 Agent 处理。

这是我实测下来的重试参数表,可以直接抄:

参数值说明
base_interval2s首次重试的基础等待时间
max_interval120s重试间隔封顶,避免无限拉长
backoff_factor2指数增长的底数
jitter0.8 ~ 1.2随机抖动,避免惊群
max_attempts5超过进死信队列
dead_letter_ttl7d死信消息保留时间

还有一个坑:重试要尽量在协调节点做,不要在 Agent 端做。如果你让 Agent 自己重试,Agent 重启后重试状态就丢了,而且无法全局控制重试频率。协调节点统一管重试,才能和路由表联动——目标 Agent 已经下线了,就直接把消息重新路由给另一个能力相同的 Agent,而不是傻等。

4. 压测中踩过的三个坑:你以为稳了,其实全是隐患

4.1 坑一:租约宽限期设置不当导致的"脑裂式重复消费"

第一次压测跑 50 个并发任务时,我观察到一个诡异现象:同一份任务被执行了两遍,而且两个执行 Agent 都不认为自己有问题。查下来的根因是租约宽限期设太短。

我当时 TTL 设 15 秒,宽限期设 5 秒。某次压测时协调节点的 GC 停顿了 8 秒,期间所有 Agent 的续租请求都没处理。等 GC 结束,协调节点认为所有 Agent 都过期了,把任务重新路由给备用 Agent。原 Agent 其实只是没续上租,任务还在正常执行,于是两边同时处理,产生重复结果。

修复方案分两层。第一层:宽限期改成 TTL 的两倍,也就是至少 30 秒,让 GC 之类的偶发停顿不至于引起大规模误判。第二层:投递时带上execution_token,Agent 每次真正开始执行前要把 token 从pending抢到processing,这个操作是原子的。谁抢到 token 谁执行,抢不到的自动放弃。这相当于给消息投递加了一把分布式锁,重复路由的问题从根上被堵住了。

4.2 坑二:Agent 间循环调用引发的消息风暴

Agent 协作系统有一个特别隐蔽的陷阱:循环调用。A 给 B 派活,B 处理不了,转给 C,C 觉得应该找 A,于是一圈一圈转下去。由于每条消息都在收件箱里有记录,这个循环不会造成传统意义上的"死循环"——它是会结束的,但每一步都在产生新消息,最终把协调节点的消息表撑到几十万行。

我在压测里设计了一个"查不到数据就转给上一个 Agent 补充"的流程,结果三条消息在四个 Agent 之间转了几十轮,消息表膨胀到十几万条,SQLite 的写入性能崩了。

解法有两个,缺一不可。第一个是跳数上限,每条消息加一个max_hops字段(默认 10),每经过一次转发减一,归零就把消息丢进死信。第二个是转发轨迹追踪:协调节点在消息的trace字段里记录所有经过的 Agent 编号,每次转发前检查 trace,如果发现目标 Agent 已经在自己这条轨迹里,说明出现了环,直接终止并告警。这两个机制加起来,循环调用最多造成一条死信消息,不会拖垮整个系统。

4.3 坑三:回调地址漂移,收件箱成了"幽灵信筒"

第三个坑来自一个很日常的操作:某个 Agent 因为要扩容,用同样的 Agent ID 在新机器上重新部署。新机器 IP 变了,回调地址自然变了。老机器的进程没完全退干净,还在续租,于是路由表里出现了两个相同 Agent ID 的条目,一个指向新地址,一个指向旧地址。旧地址的收件箱消息全部投递失败,新地址的 Agent 却完全不知道有人给它发过消息。

排查了很久才发现,问题出在注册协议太宽松:Agent ID 相同就直接覆盖注册信息,不做版本校验。修复方案是给注册请求加一个instance_id(机器级唯一 ID),协调节点发现同一 Agent ID 重复注册时,强制要求新的注册带上epoch字段,epoch 必须大于旧实例,否则拒绝。老实例的租约续期请求也会被拒绝,因为它携带的 instance_id 已经不是当前活跃实例了。

这个坑给我的教训是:Agent ID 只能标识逻辑身份,不能标识物理实例。凡是涉及物理实例的注册、续租、回调,都必须以 instance_id 为准。有点类似 HTTP 的 ETag 机制——光靠 URL 寻址不够,还得校验版本。

5. 稳定性之外的进阶优化:负载感知、局部分片、可观测性

5.1 负载感知路由:别把所有任务堆给同一个 Agent

能力路由如果只看"在线+有对应能力",压测时就会暴露一个问题:所有任务被均匀地(其实是轮询地)分发给所有 Agent,但每个任务的复杂度差别巨大。处理简单任务只要 200 毫秒的 Agent 和处理 5 分钟重度任务的 Agent 被同样对待,忙的忙死,闲的闲死。

我在 Agent-Reach 里加了一个体重指标:每个 Agent 在续租时上报自己的实时负载,包括队列深度(收件箱 pending 消息数)、最近一分钟处理耗时 P95、当前并发数。协调节点在能力路由打分时,这三个指标各占权重 30%、40%、30%,选得分最优的 Agent 投递。

这个优化上线后,压测里的 P95 延迟直接降了 37%。原理不复杂:从"谁有空转给谁"变成"谁最快把活干完转给谁"。负载感知本质上做的是全局调度,而不是简单的负载均衡。

5.2 收件箱局部分片:单 Agent 收件箱的一百万条消息问题

压力测试做到单个 Agent 收件箱超过 100 万条消息时,SQLite 的select * from inbox where status='pending' limit N开始出现明显的性能退化。原因是 pending 消息和已处理消息混在同一张表里,没有索引覆盖,全表扫描越来越慢。

修复方法很粗暴但很有效:按status做局部分区。pending消息存一张只有几百条记录的热表,done消息定期归档到冷表,failed消息单独存放。热表因为体积小,查询永远是毫秒级;冷表只做历史追溯,不在乎慢。

这个优化带出一个通用经验:任何消息系统,热数据和冷数据必须物理分离。不要因为"数据量还没那么大"就偷懒,等到 100 万条再处理,代价是几小时的迁移和一堆线上事故。

5.3 消息轨迹的三种监控视图

最后一个大家容易忽略的模块是可观测性。Agent 通信链路比单体接口长得多,出问题的时候如果没有轨迹追踪,排查一天都不一定有结果。

Agent-Reach 里我实现了三种监控视图,都是在消息的trace字段基础上做的:

  • 链路视图:输入一个message_id,画出它从发出、经过哪些 Agent、最终完成的完整时间线,每一跳的耗时都标出来。排查"任务到底卡在哪一步"时,这张图是救命稻草。
  • 队列视图:每个 Agent 的收件箱 pending 数量、处理速率、平均等待时间实时展示。队列深度暴涨往往比接口延迟更能提前暴露系统瓶颈。
  • 失败归因视图:所有 failed 消息按失败类型聚类,看看是超时、连接拒绝、还是处理异常。如果连续五分钟超时占比超过 20%,自动告警。

这三种视图加起来,让我从"事后看日志"变成了"事前看趋势"。有一次压测中发现某个 Agent 的队列深度缓慢上涨但接口延迟正常,顺着队列视图往下查,发现是这个 Agent 在处理某个特定类型的消息时出现线性重试,每次都要等满 120 秒。如果没有队列视图,这个问题要等用户投诉才能发现。

6. 收尾:Agent-Reach 适用的边界,以及我最后的建议

写了这么多,得说点实在话收个尾。Agent-Reach 不是什么高大上的框架,它的定位就是自建多 Agent 系统时的一块通信拼图。我最初做它是因为实在受不了 Agent 之间裸连 HTTP 导致的各种玄学问题(其实一点都不玄,全是分布式系统的基本问题,只是分散在 Agent 场景里显得特别难查)。

适用边界说一下:Agent 数量在几十个量级、部署在同一个内网、不需要跨数据中心强一致性的团队,用它最合适。如果你的场景是几百上千个 Agent、要求多区域容灾、需要严格的消息事务语义,那么建议直接考虑成熟的 MQ 或工作流引擎,Agent-Reach 的能力模型会变成你的限制。

最后分享几个我实际用下来的体会:

第一,先把失败场景想清楚再写正逻辑。Agent 通信 90% 的代码都是在处理"消息丢了怎么办""Agent 挂了怎么办""重复消费怎么办"。我第一版把正逻辑写得很漂亮,后来 80% 的时间都在补失败路径。如果重来,我会先写故障注入测试。

第二,给每条消息留下足够的元数据。message_id、trace、max_hops、execution_token这些字段,看似废话,但每一个都在实际事故里救过我。别嫌消息头重,宁多重十个字段,不要在排查时因为没有 trace 而看半天。

第三,把重试、超时、路由这些逻辑统一收敛在协调节点。Agent 端保持无状态是最好的,这样你随便怎么缩扩容都不用担心 Agent 端的本地状态丢失。Agent-Reach 的核心价值就是把"稳"做到了协调层,而不是指望每个 Agent 自己自觉。

如果你也在搭 Agent 网络,希望这篇分享能帮你少踩几个坑。通信底座稳了,Agent 的智能才能真正发挥出来——不然再聪明的 Agent,消息都送不到对方手里,那不叫协作,叫互相折磨。

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

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

立即咨询