☰
多智能体通信基础设施:Agent-Reach 的可靠消息路由与触达设计
2026/10/7 9:37:34 网站建设 项目流程

作为一个长期折腾多智能体系统的开发者,我一直在思考一个问题:两个Agent之间沟通,难道不就是"把消息发出去"那么简单吗?直到我自己在一个真实的协作项目里被消息丢失、路由错乱、对方Agent无响应这几个问题反复折磨,才意识到——Agent与Agent之间的"触达"本身就是一件需要专门设计的事。这也是我做Agent-Reach这个项目的起因:把多智能体之间的通信、路由、确认、重试这些底层能力,从业务代码里彻底剥离出来,做成一层的"触达基础设施"。

Agent-Reach这个名字里的Reach,我理解有两层含义:一是"可达性",确保任何Agent都能被其他Agent找到;二是"触达范围",决定一条消息能扩散到哪些Agent、以什么路径送达。这篇文章就是我完整复盘Agent-Reach从设计到落地全过程的核心思路,包括消息信封协议、注册发现机制、路由策略、超时重试、可观测性设计,以及我在其中踩过的一些坑。适合正在做多Agent协作框架、或者准备给现有系统加一层通信中间层的开发者参考。

1. 为什么多智能体协作需要独立的触达层

1.1 点对点直连的失控:真实事故复盘

先讲一个让我下决心做Agent-Reach的事故。当时我有一个系统,里面跑了三个Agent:一个负责解析用户意图(IntentParser),一个负责查天气和机票(WeatherAgent),一个负责生成回复文案(ResponseWriter)。起初它们之间的通信方式很简单——IntentParser直接调用WeatherAgent的HTTP接口,拿结果再传给ResponseWriter。这在一两个Agent的时候完全没问题,但第三个、第四个Agent加进来之后,噩梦开始了。

有一天我发现ResponseWriter收到了好几条重复的天气查询结果,因为IntentParser在超时重试之前已经把请求发出去过一次了,WeatherAgent本身处理很快,但返回的响应在网络抖动中丢失了。IntentParser判断"没收到响应,认为对方挂了",于是又发了一次。下游ResponseWriter拿到的上下文里混了两次查询各自带的时间戳,整个回复逻辑直接混乱。更麻烦的是,当我把WeatherAgent从单机部署迁移到集群之后,调用方还记着旧的服务地址。

这不是简单的网络问题,而是缺少一层统一的消息语义。点对点直连意味着每个Agent都要知道其他Agent的消息格式、接口地址、超时策略和重试次数,Agent之间的耦合会随着数量增加指数级恶化。当时我花了很多时间排查那些问题,每一个最终都指向同一个结论:需要有一个专门的层来管理消息的投递语义,而不是让每个Agent各自为政。

1.2 触达层解决的问题清单

Agent-Reach在最初的定位里,就是一个独立的通信与协调层。它设计为位于所有Agent之下、业务逻辑之上,统一接管以下六类问题:

问题表现Reach的解法
寻址调用方硬编码对方地址Agent注册中心 + 动态路由
消息格式每个Agent一套私有协议统一信封协议
投递可靠性消息丢失、重复消息ID + ACK确认 + 去重
超时语义对方慢,调用方反复重试统一超时控制与退避策略
失败扩散一个Agent挂掉导致全链路雪崩隔离、熔断、降级
可观测性不知道消息走到哪一环链路追踪 + 消息日志

这六类问题,本质上是把分布式系统领域积累的成熟经验,借鉴到多Agent场景里。如果你做过微服务,对这些概念一定不陌生;但Agent有自己的特殊性——消息不再是简单的请求响应,更多时候是带有目标意图的事件流,比如"用户催单了,所有相关Agent都要知道这件事"。这会直接影响协议设计和路由表结构。

1.3 设计取舍:为什么不用现成消息队列硬扛

可能在读这段的时候你会想:这些问题用RabbitMQ、Kafka不就能解决吗?我的回答是:消息队列只是Reach的一部分,远不是全部。队列解决了消息的存储和转发,但解决不了Agent语义层面的寻址与协商。比如一条消息要发给"所有能处理退款意图的Agent",这在MQ里你怎么表达?用固定的topic可以,但Agent的能力集合是不断动态变化的,topic的维护会变成一个新的痛点。

Reach的做法是:底层传输可以接MQ(我接了一个轻量级的Redis Stream,也试过RabbitMQ做对比),但上层必须维护一份能力索引——每个Agent启动时上报自己处理什么类型的意图和消息,Reach根据这条索引做语义路由。这是普通消息队列不会替你做的事。所以更准确的定位是:Reach = 能力注册与发现 + 意图路由 + 投递语义保障。

2. 消息信封设计:一份所有Agent都认的通用协议

2.1 信封结构:Header、Payload与Traces

Agent-Reach里所有的通信都基于一套信封协议。所谓信封,就是在业务消息外面再包一层标准化的元数据。每个Agent在收发消息时,不需要关心对方业务参数怎么组织,只需要能解析信封就够了。

我实际使用的信封结构如下:

{ "msg_id": "a3f2c1e4-9b8d-4f3a-8c2e-1a2b3c4d5e6f", "type": "intent.request", "version": "1.0", "timestamp": 1735113600123, "source": { "agent_id": "intent-parser-01", "node_id": "node-a" }, "target": { "pattern": "capability:refund", "scope": "all" }, "correlation_id": "conv-8877", "ttl_ms": 30000, "priority": 5, "payload": { "schema": "refund.request.v1", "body": { "order_id": "ORD-20241225-001" } }, "traces": { "previous_hops": ["intent-parser-01"], "current_hop": 2 } }

这个信封里比较关键的设计点我展开说明一下。

msg_id:全局唯一的消息ID,是去重的基础。我在实现时用UUID v7,它带时间戳信息,排序方便。发送方生成之后,在整个消息生命周期内不变;每个接收方在处理完之后会向Reach上报处理结果。

target.pattern:这是核心设计。消息目标并不是"具体的某个Agent实例",而是一组描述性的匹配规则。规则支持三种匹配方式:

  • agent_id:xxx:定向发给某一个Agent
  • capability:xxx:发给所有拥有xxx能力的Agent
  • group:xxx:发给某个Agent逻辑分组

这个抽象的意思是:业务消息只关心"谁有这个能力",而不关心"具体是哪台机器上的谁"。Agent实例挂了、迁移了、扩容了,对调用方透明,因为匹配规则始终有效。

2.2 语义校验与Unknown Message处理

信封协议不能只是"能解析",还要具备语义校验能力。我在Reach层内置了几类校验规则:

  1. 必填字段校验:msg_id、type、timestamp、source、target 缺一不可,任何一项缺失直接拒绝并返回错误码。
  2. 值域校验:type必须在已注册的消息类型白名单里,防止有人随意发明类型导致接收方不支持。
  3. 优先级范围校验:priority必须在1到10之间,超出会被钳制到合法区间。
  4. TTL校验:TTL归零的消息直接丢弃不再投递。

这层校验解决的是多Agent协作里最常见的"垃圾进垃圾出"问题。如果没有这层校验,A发了一个拼错的type给B,B拿着未知类型无从下手,往往只能抛异常了事。现在Reach在入口处就能拦住,返回明确的错误码,调用方可以根据错误码判断是自己消息构造错误还是对方真的没这个能力。

对于确实发到了接收方但接收方不认识的type,我设计了一个规范动作:接收方收到Unknown消息时,必须统一回复一条error.unknown_type信号,并且带上自己支持的消息类型列表,发送方据此可以动态调整。这在Agent异构程度高的系统里特别实用——我手里一个Python编写的Agent和一个Node.js编写的Agent之间通信,格式上完全不用关心对方的内部实现。

2.3 Payload Schema:业务数据与信封的解耦

信封的payload部分我采用schema标记的方式,类似事件驱动架构里的CloudEvents。不同业务场景有自己的schema名称,例如refund.request.v1、weather.query.v2。Reach不直接解析payload内部结构,但会在注册中心登记schema的兼容性信息。

这样做有几个好处:一是接收方Agent升级了payload结构(比如新增字段、废弃旧字段)时,可以通过schema版本单独演进,老消息用老版本解析器处理;二是可以在Reach层做灰度,比如让weather.query.v2先后端兼容处理,确认稳定再全量切流;三是日志和追踪系统可以直接按schema维度索引消息,后续排查问题非常痛快。

这里我特别想说一个实践体会:schema字段一定要用"语义化"的命名,而不是那种a、b、c的简写。我刚开始图省事,payload里的字段都写成d1、d2这种,结果三个月后回看日志,根本想不起d3到底是查询条件还是返回结果。后来全部改成query_city、query_date这类命名,排查效率提高了一个量级。

3. 注册与发现:让每个Agent都"找得到"与"找得对"

3.1 Agent上线流程:注册、心跳与能力上报

触达的前提是"知道谁在那、它能干什么"。Agent-Reach的注册中心维护着一张动态的Agent信息表,每个Agent实例上线时执行完整的注册流程:

  1. Agent实例启动,生成自己的实例ID与Node ID。
  2. 调用Reach的注册接口,上报自身元信息:agent_id、capabilities、schema_versions、endpoint、auth_token。
  3. Reach校验元信息格式,并检查agent_id是否冲突。
  4. 注册通过后,Agent周期发送心跳(默认5秒一次),Reach记录最近心跳时间。
  5. 每次心跳时,Agent可以增量更新自身能力列表(比如动态新装了一个技能插件)。

如果Reach在连续3个心跳周期内没收到某个Agent的心跳,就把它标记为"疑似离线",停止向其路由新消息;再等3个周期仍没有恢复,就彻底注销。这个机制的边界效应很重要,我用的是一种宽松的最终一致策略,而不是强一致的分布式锁,因为Agent注册中心对瞬时故障的容忍度远比强一致更高。

3.2 语义路由表:从"发给谁"到"发给能处理这事的人"

Reach内部维护的核心数据结构是一张语义路由表。它的每一项大致是:

{ "pattern": "capability:refund", "agents": [ {"agent_id": "refund-svc-01", "healthy": true, "weight": 3}, {"agent_id": "refund-svc-02", "healthy": true, "weight": 1} ], "strategy": "weighted_random" }

当一条消息进来时,Reach提取信封里的target.pattern,去语义路由表里匹配符合规则的Agent列表,再按照strategy从中选取一个或多个目标。我实现了三种选择策略:

  • weighted_random:带权重的随机选择。适合几个Agent能力完全对等、纯粹做负载均衡的场景。
  • round_robin:轮询选择。适合各Agent处理能力相当、处理时间相近的场景。
  • broadcast_all:广播给所有匹配的Agent。适合事件通知类场景,比如"用户地址已更新,所有相关Agent刷新上下文"。

我踩过的一个深坑是:广播模式下,下游Agent的重复消费。最开始我把广播消息直接发出去了,结果退款Agent和库存Agent同时收到"订单已取消"事件,各自更新了自己的本地状态,这没问题;但后来有个Agent依赖别人处理后产生的结果,它也同步收到了事件,导致它拿着还没落库的状态做计算,出了一次线上事故。这个问题的解法是:广播消息里加一个process_order标记,如果某个Agent不是该消息的直接处理者,应该只把消息写入本地事件存储,不执行联动逻辑。简单说,不是所有收到消息的Agent都该立刻行动,有些人先记下来更安全。

3.3 心跳过期与实例摘除的边界条件

关于心跳和摘除,我只讲一个最容易忽略的细节:Agent处理任务时可能会阻塞心跳。

我当时有个OCR Agent,接到一张大图时处理耗时可以达到30秒,但它本体是健康的。如果按固定心跳策略,它在这30秒内无法上报心跳,Reach会误判它离线,把原本发给它的消息转给另一个能力偏弱的Agent,然后引发冲突。后来我在心跳机制里加了"忙碌标记"——Agent在处理长任务时可以主动发一条busy状态的心跳,Reach收到这个状态后不会把它摘除,而是把路由权重临时调低,但不会完全禁用。这就避免了对健康实例的误杀,也保留了对假死实例的剔除能力。

4. 路由与超时控制:触达不只要送到,还要"控得住"

4.1 超时三层分解:网络超时、处理超时与总超时

如果把消息发送比作寄快递,网络超时是"运输路上该花多长时间",处理超时是"收件人打开包裹该花多长时间",总超时是"从寄出到对方确认收货——整个事件最多等多久"。Agent-Reach允许发送方在消息里显式声明这三层的期望值,默认值分别是3秒、15秒、30秒。

这三个值的设计逻辑是:网络抖动通常很快恢复,3秒足够;Agent处理一个普通意图请求大多在几百毫秒到几秒之间,15秒留了充足余量;总超时30秒是给用户侧交互兜底,超过这个时间用户已经等不了了,与其继续耗着不如直接走降级流程。如果你曾经因为某个下游Agent响应慢,把超时改到60秒、120秒,结果整个调用链跟着一起卡顿,那你应该能理解分层超时的意义——它把"到底慢在哪一段"这个问题的定位成本降到了最低。

4.2 重试策略:指数退避与去重屏障

投递失败时是否重试、怎么重试,是我早期设计里最纠结的一块,后来形成了三条硬规则:

  1. 只有可重试错误(如网络超时、目标临时不可达)才重试。
  2. 重试必须走指数退避:第一次1秒、第二次2秒、第三次4秒,最多5次。
  3. 接收方收到带相同msg_id的消息时,必须先去重,不能重复执行业务逻辑。

第3条尤其重要。我见过很多系统,业务幂等靠接收方自己保证,但收方怎么判断"我已经处理过这个消息"?最简单可靠的方式就是Reach维护一张最近处理过的msg_id集合,在接收方处理之前先查一下。我会在Redis里存消息ID和对应的处理结果,有效期设为总超时的两倍,确保重试窗口内去重有效。这样发送方尽管重试,接收方的业务逻辑始终只跑一次。

这里有个典型反例:我早期没有去重屏障的时候,下游Agent处理退款请求超时了,发送方重试了一次,结果用户被收了两笔退款。这个教训让我把"重试必须幂等"写进了Reach的技术规范里,而且我不信任"业务方自己保证幂等",坚持在触达层就提供默认去重能力。

4.3 无效处理结果识别:别把假成功当成功

比超时更隐蔽的问题是**"成功"但没用的响应**。有些Agent在超时边缘勉强处理完了请求,但是返回的结果是半成品。比如查天气的Agent在规定时间内返回了结果,但城市字段是空的,因为它的上游数据源超时了。Reach不能彻底解决业务数据的正确性,但它可以通过结果完整性校验来识别这种"带伤成功"。

我在Reach中实现了一个简单的完整性策略:每个Agent在响应消息里可以携带confidence字段,标识本次处理结果的置信度。当confidence低于某个阈值,Reach会自动把这次响应标记为"部分成功",并触发一次重新路由,把消息发往另一个备份能力Agent。这个机制在容灾场景特别有用,等于在系统层面给了消息第二次机会。

5. 可观测性:Reach层出问题时,靠什么定位

5.1 每个Hop一个Trace点:消息从哪来、到哪去

多Agent系统的排障难度不在于代码逻辑,而在于跨进程、跨语言的消息链路。一条消息可能从IntentParser出发,经过Reach路由到API Agent,再回调通知另一个Agent,中间任何一环出错都会表现为"用户没得到预期回复"。

为此我在Reach的每个处理环节都埋了Trace点。每一条消息从进入Reach开始,会留下完整的时间线:

Trace点含义
message.received消息进入Reach
route.matched语义路由匹配成功,选出目标Agent
route.sent消息分发到目标Agent
agent.received目标Agent确认收到
agent.responding目标Agent响应中
agent.completed目标Agent响应完成
message.delivered整个消息流程终结

这些事件全部写入结构化日志,并带上correlation_id。排障时,只要拿到用户会话ID,几秒钟就能查出整条链路卡在哪一步。我实际使用中,这套追踪帮我在生产环境把平均定位时间从半小时缩到了三分钟内。

5.2 指标与告警:你会发现,链路监控比代码审查更有效

除了Trace日志外,我还在Reach里暴露了一套Prometheus指标。最核心的几个指标:

  • reach_messages_in_total:进入Reach的消息总量。
  • reach_route_success_total:路由成功的消息量。
  • reach_route_fail_total:路由失败的消息量。
  • reach_delivery_duration:消息从进来到送达的耗时分布。
  • reach_agent_offline_count:当前离线Agent数量。

我设置了三类告警规则,命中后会直接推送到企业微信:

  1. 路由失败率连续3分钟超过5%。
  2. 消息投递P99耗时超过10秒。
  3. 某Agent连续5分钟离线。

有一次就是靠这类告警发现问题的:某Agent离线指标一直为零,但用户反馈天气查询特别慢。我查Trace才发现,消息被路由到了另一个新上线的测试环境实例,而非老实例。因为注册中心里维护的新实例健康状态是正常的,所以路由策略一直选它。这个问题的根因是没做好环境隔离——测试环境的Agent不应该注册到生产路由表里。我在注册中心加了env标签,路由规则必须匹配env=prod才算有效,问题就再没发生过。

5.3 消息日志:事件存储是最好的回放工具

最后是可观测性里最容易被忽略但价值极大的部分:消息日志的事件存储。我不只是记录Trace点,还会把信封的入参、出参、错误码、耗时一并存到事件存储中。这里的存储设计要买单量,不要买关系型存储,直接用ES就行。

为什么说它价值极大?比如回溯场景:用户A说"我在12月26日中午发起的退款请求没有得到结果",如果只靠代码审查,你要从哪个Agent开始查?有了事件存储,你直接按correlation_id和time搜索,看到消息在退款Agent上处理超时、重试后又因为状态冲突被丢弃、最后没有产生任何响应——整个过程一目了然。可以说,可观测性是触达层的最后一层保障,没有它,前面的协议、路由、重试设计都只是理论。

6. 从单机到多节点:Agent-Reach的部署演进

6.1 单机内嵌模式:轻量起步,适合小项目

Agent-Reach初版是单机内嵌模式,即Reach以库的形式嵌在每个Agent进程内,共享同一张内存路由表。这种模式的好处是零额外部署成本、延迟极低、消息不落盘,特别适合本地调试和Agent数量少于五个的小项目。

但它有明显的天花板:一旦Agent拆分到多个进程,内存路由表各持一份,互相之间无法同步;消息丢失后没有持久化可恢复的载体。如果说你在做的是一个Agent原型验证,用单机内嵌模式完全够用;但如果你要把它推向生产环境,就要尽快演进到独立服务模式。

6.2 独立服务模式:为生产环境而生

我在第二阶段把Reach抽成了一个独立的无状态服务,多个Reach节点组成集群。外部Agent通过注册中心接入,消息通过负载均衡进入任一Reach节点。路由表存储在Redis里,所有Reach节点共享同一份数据。

为什么是无状态?因为Reach需要快速水平扩展——高峰期多开几个节点,低峰期缩容到两个。有状态的话,缩容会牵扯消息重放、节点切换的复杂逻辑,很容易出错。无状态服务的部署、升级、回滚都更简单。消息临时存储放在Redis Stream里,不是直接消灭持久化,而是把持久化外包给了Redis。这样设计下来,整体架构简单清晰,可靠性却比单机模式高很多。

6.3 单点故障的防御:我的实际兜底方案

即便Reach做成了集群,也不可能100%避免故障。我给自己留了三层兜底方案:

  1. 路由表冗余:Redis采用主从部署,主节点挂了自动切换从节点。
  2. 消息重试投递:已进入但未投递完的消息,如果Reach节点重启,Redis Stream里的积压消息会被重新消费。
  3. 降级开关:Reach整体异常时,允许Agent之间退化为直连模式,调用方可以读取本地缓存的Agent地址表,直接发起HTTP请求。这个降级模式质量差一些,但保住了基本可用性。

这三层兜底里,我最重视的是第3个降级开关,因为很多系统死在"基础设施挂了导致业务全挂"。有了降级方案,即使Reach完全瘫痪,核心链路仍然能靠直连维持基本运转。这也是"触达层"应该有的态度——它服务业务,而不是绑架业务。

7. 踩坑复盘:Agent-Reach开发中最值得警惕的三个问题

7.1 坑一:没有消息去重时候,重试变成放大故障

前面提过退款被重复执行的事故,这里我把它完整复盘一遍。事故发生时的消息链路是:订单系统确认退款成功,发送refund.done事件给财务Agent和用户通知Agent。财务Agent收到后要执行凭证入账,网银接口很慢,15秒超时。发送方等了15秒后重试了一次,财务Agent又收到一份refund.done,但它的状态机没考虑"同一条消息处理两次"的情况,结果入账两笔。

排查下来发现,不只是退款,其他类型消息也普遍存在重复消费风险。我当时的修复方案分两层:

  • 在发送方增加"重试去重标志",同一个msg_id不重复做业务操作。
  • 在接收方Reach层加入消息ID去重屏障。

现在回头看,这两层缺一不可。只做发送方去重,扛不住发送方本身也有状态丢失的风险;只做接收方去重,发送方的重试请求仍然会给接收方带来无谓的压力。只有两边都做,才真正做到了幂等。

7.2 坑二:超时时间一刀切,导致长任务Agent永远超时

另一个让我印象深刻的坑是超时时间设置。最初我在Reach里把默认处理超时设为10秒,本以为够宽裕了,结果上线没两天,报表Agent传来的告警一堆——它要算一个G的订单聚合数据,正常要30秒,10秒根本不够。10秒超时导致每条报表请求都被误判失败,然后又因为重试策略疯狂重发,把数据库加载到高负载。

这个坑的本质是默认值不该全局一致。我后来的做法是分三档:普通查询类消息处理超时10秒、计算密集类消息超时60秒、长任务类消息超时300秒。具体采用哪一档由Agent在注册时声明自己的max_process_time,Reach据此动态调整超时参数。不再套统一标准,长任务Agent的故障率直线下降。

7.3 坑三:测试环境Agent污染生产路由表

这个坑在5.2那节已经提到过,测试环境的Agent注册信息混进了生产路由表。当时我排查了半天,最后发现是某位同事在本地调试时,没有把环境变量里的env改成dev,结果本地Agent进程连的是生产Redis。修复方案:注册中心强制校验env环境信息与网络白名单,测试环境Agent的网络IP不在生产白名单里,直接拒绝注册。同时在路由规则里强制加环境过滤,即使注册成功了也不会被路由选中。

这次事故给我的教训是:设计注册中心时,环境隔离是硬需求,而不是后期优化项。从第一个版本就应该把env、zone这类隔离维度设计进去,不然后面补不仅费劲,还容易漏补。

8. 一点点自己的体会

Agent-Reach做下来,我最深的感受是——多Agent系统的复杂度,并不在于你要设计多么华丽的推理算法,而在于那些看起来简单的"把消息从A送到B",背后有一整层严谨的基础设施要做。消息信封是否统一、路由规则是否语义化、超时与重试是否可配置、链路是否可追踪、环境是否隔离,这些细节决定了一个多Agent系统能不能从Demo走向生产环境。

如果你也在做Agent相关项目,我的建议是:别等到Agent数量多到失控再去补通信层,尽早把触达层纳入架构设计。哪怕第一版做得简单一点,只做到统一消息格式和注册发现,后面也会让你少掉很多头发。想从原型开始快速验证的,可以先在单机内嵌模式里跑通协议;准备上生产的,直接独立服务模式起步,Redis和路由表自研就行,不需要引入重量级框架。

最后分享一个我实测特别好用的小技巧:给每个Agent的响应里都加一个processing_host字段,标出是哪个节点的哪个Agent处理了这条消息。听起来很低级,但它能让你在Nginx四层负载均衡模式下,快速定位"为什么同一个请求两次打到了不同Agent身上"。这种小字段在排障时省下的时间,远比那几字节的存储成本值钱得多。

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

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

立即咨询