
作为一个长期折腾多智能体系统的开发者我一直在思考一个问题两个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与TracesAgent-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定向发给某一个Agentcapability:xxx发给所有拥有xxx能力的Agentgroup:xxx发给某个Agent逻辑分组这个抽象的意思是业务消息只关心谁有这个能力而不关心具体是哪台机器上的谁。Agent实例挂了、迁移了、扩容了对调用方透明因为匹配规则始终有效。2.2 语义校验与Unknown Message处理信封协议不能只是能解析还要具备语义校验能力。我在Reach层内置了几类校验规则必填字段校验msg_id、type、timestamp、source、target 缺一不可任何一项缺失直接拒绝并返回错误码。值域校验type必须在已注册的消息类型白名单里防止有人随意发明类型导致接收方不支持。优先级范围校验priority必须在1到10之间超出会被钳制到合法区间。TTL校验TTL归零的消息直接丢弃不再投递。这层校验解决的是多Agent协作里最常见的垃圾进垃圾出问题。如果没有这层校验A发了一个拼错的type给BB拿着未知类型无从下手往往只能抛异常了事。现在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实例上线时执行完整的注册流程Agent实例启动生成自己的实例ID与Node ID。调用Reach的注册接口上报自身元信息agent_id、capabilities、schema_versions、endpoint、auth_token。Reach校验元信息格式并检查agent_id是否冲突。注册通过后Agent周期发送心跳默认5秒一次Reach记录最近心跳时间。每次心跳时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秒、第三次4秒最多5次。接收方收到带相同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消息进入Reachroute.matched语义路由匹配成功选出目标Agentroute.sent消息分发到目标Agentagent.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数量。我设置了三类告警规则命中后会直接推送到企业微信路由失败率连续3分钟超过5%。消息投递P99耗时超过10秒。某Agent连续5分钟离线。有一次就是靠这类告警发现问题的某Agent离线指标一直为零但用户反馈天气查询特别慢。我查Trace才发现消息被路由到了另一个新上线的测试环境实例而非老实例。因为注册中心里维护的新实例健康状态是正常的所以路由策略一直选它。这个问题的根因是没做好环境隔离——测试环境的Agent不应该注册到生产路由表里。我在注册中心加了env标签路由规则必须匹配envprod才算有效问题就再没发生过。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%避免故障。我给自己留了三层兜底方案路由表冗余Redis采用主从部署主节点挂了自动切换从节点。消息重试投递已进入但未投递完的消息如果Reach节点重启Redis Stream里的积压消息会被重新消费。降级开关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_timeReach据此动态调整超时参数。不再套统一标准长任务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身上。这种小字段在排障时省下的时间远比那几字节的存储成本值钱得多。