ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从单Agent到Agent网络:Agent-Reach的轻量级通信中间件设计

从单Agent到Agent网络:Agent-Reach的轻量级通信中间件设计

从单Agent到Agent网络:我为什么写了Agent-Reach这个项目

过去大半年我一直在折腾多Agent协作,最初用的方案很朴素:每个Agent一个服务,互相之间通过HTTP硬编码调用。刚开始只有两三个Agent时还好,A调B写死一个URL就行。等Agent数量一旦超过五个,局面就彻底失控了——谁该调用谁、哪个Agent现在可用、任务发给谁最合适,这些问题全部变成了调用方的负担。我做Agent-Reach这个项目,本质上就是为了解决这些问题:让Agent像电话一样插上电话线就能找到彼此,而不是每个人手抄一份通讯录。

这个项目不是某个大厂的框架,也不是什么高深的研究课题,它就是一套轻量级的Agent发现与通信中间件。核心解决的问题只有三个:Agent怎么注册、消息怎么路由、任务状态怎么追踪。如果你也在做多Agent系统,正被Agent之间的连接问题折磨,这篇文章应该能给你不少启发。我会把Agent-Reach的设计思路、关键实现和踩过的坑都摊开来讲。

  1. 从单Agent到Agent网络:Reach要解决的真实问题

1.1 单机单Agent的舒适区与天花板

先说一个很现实的场景。假如你现在只有一个Agent,比如一个能查数据库、写SQL的智能助手。这个Agent的架构特别清晰:用户输入问题,Agent选择合适的工具,工具执行查询,Agent整理结果返回。整个链路是线性的,你不需要考虑AgentA和AgentB之间怎么通信,因为根本没有第二个Agent。

这种单Agent的玩法很舒服,但天花板也肉眼可见。任何一个Agent的能力边界就是你的工具集边界,你想让它做更多的事,就得不停给它添加工具。工具越加越多,上下文越长,推理越迟钝,维护成本也越来越高。于是很自然地会想到拆分:一个Agent专门做意图理解,一个Agent专门做数据查询,一个Agent专门做结果分析,各司其职,组合成一条流水线。

一旦走到这一步,通信问题就出现了。你的意图理解Agent识别出用户想查数据,它需要把请求发给数据查询Agent。最简单的做法是在代码里写死数据查询Agent的HTTP地址,直接调用接口。但紧接着问题来了:数据查询Agent如果升级了、重启了、换了端口,意图理解Agent还拿着旧地址,一调用就失败。更麻烦的是,如果系统里有十来个Agent,每一个都要知道其他Agent的地址,这简直是在给未来的自己埋雷。

我当初就是被这个雷炸过几次之后,才下决心认真做一个Agent之间的通信基础设施。单Agent是编程问题,多Agent是分布式系统问题,这两者之间的鸿沟,恰好就是Agent-Reach的切入点。

1.2 多Agent协作为什么总是卡在"怎么找到对方"

很多人都低估了"怎么找到对方"这件事的复杂度。你以为就是存一堆URL,其实远没这么简单。一个真实的多Agent系统里,Agent之间不是简单的两两通话,而是动态的网状协作。今天你的Agent A可能需要调用B和C,明天B下线了换成了D,后天新来了一个E,谁能感知这些变化?答案应该是系统本身,而不是编写业务逻辑的开发者。

"怎么找到对方"包含三个层次的问题。第一层是要找到可用的实例,就是Agent现在还活着没有,状态是否健康。第二层是要找到合适的实例,系统里有两个Agent都能处理数据分析,到底把任务交给哪个,这需要能力和负载信息。第三层是要找到能协作的路径,Agent A不认识Agent D,但A认识B,B认识D,能不能通过B搭桥把任务传过去。

很多团队的方案是引入一个配置文件或者环境变量,集中管理所有Agent地址。这比硬编码好一些,但远远不够。配置文件的更新是滞后的,Agent动态扩缩容的时候,配置文件根本来不及同步。而且配置文件只解决第一层问题,后面两层完全靠代码逻辑硬扛。Agent-Reach的设计目标就是把这三个层次的服务统一收口,做成一个对业务透明的通信层。

1.3 Agent-Reach是什么:一句话说清楚

用一句话概括,Agent-Reach是一个让Agent能够动态发现彼此、按能力路由消息、并追踪任务全生命周期的通信中间件。它做的事情和当年微服务架构里的服务注册中心有点像,但针对Agent场景做了很多定制化的设计,比如能力描述、意图匹配、任务上下文传递,这些通用注册中心不会替你想。

打个比方,微服务的服务发现解决的是"哪个服务实例在哪个IP的哪个端口",Agent-Reach解决的是"哪个Agent能干什么、现在忙不忙、消息怎么送到它手上"。前者是服务维度,后者是能力维度和语义维度,层次完全不同。我当时设计Agent-Reach时的基本原则就是:每个Agent只需要做两件事,启动时报备自己的能力和地址,运行中处理收到的消息并回传状态。至于消息怎么来的、为什么是它来处理、别人是不是也在处理同一个任务,这些统统交给框架。

  1. Agent-Reach的核心设计:注册、路由与消息通道

2.1 注册表与心跳:Agent的"电话簿"

Agent-Reach最基础的一块是注册表。每个Agent启动的时候,会向中心节点发送一个注册请求,把信息报备进去。我设计的注册信息包含四块:Agent的唯一ID、Agent对外暴露的通信地址、Agent声明支持的能力列表、以及一组可选的元数据(比如当前的负载权重、地域标签等)。注册之后就是心跳机制,Agent每隔一段时间上报自己的存活状态,中心节点据此更新注册表。

这个思路本身不新鲜,但我在设计时特别注意了Agent场景和传统服务的差异。传统微服务的实例数量通常是稳定的,而Agent可能是按需创建的,有任务时拉起来一批,任务结束就销毁。这就意味着注册表必须频繁地处理新增和剔除,不能把"注册一次"当成长期有效。我给的方案是:心跳窗口默认30秒,连续3次没有心跳就标记为不可用,但不会立刻从注册表删除,而是保留一段时间并标记为"僵尸状态"。这样设计的好处是,如果Agent只是网络抖动而不是真死掉,它恢复后可以通过一次轻量级续约快速回到可用状态,而不需要重新注册完整的元数据。

注册表的存储我也纠结过一阵子。一开始图省事直接用了内存Map,单机演示是没问题,但一旦中心节点重启,所有Agent的状态全部丢失,系统就瘫了。后来改成了内嵌的持久化存储,每次注册和心跳都会异步写入文件,确保重启后能快速恢复到上一个状态。这里我踩过一个坑:Agent首次注册和心跳续约的并发写冲突,导致注册信息被心跳覆盖,能力列表变成空的。后来通过把注册操作和心跳操作串行化到同一个队列里解决。

表:Agent注册表中一条记录的核心字段

字段示例说明
agent_idagent-db-query-01全局唯一标识
address10.0.0.15:9081通信地址
capabilities["sql.query", "schema.describe"]能力标识列表
statusAVAILABLE / BUSY / ZOMBIE当前状态
last_heartbeat2025-01-15T10:30:02Z最近心跳时间
metadata{"region": "cn-north-1"}附加元数据

2.2 能力路由:不是每个Agent都适合接所有消息

注册表解决的是"Agent存在不存在"的问题,路由解决的是"消息该送给谁"的问题。我最早实现路由的时候走了一条弯路:让Agent自己声明要订阅的消息类型,系统维护一个发布订阅关系表,消息来了直接推给所有订阅者。这个方案的问题在于太粗粒度,一个Agent可能订阅了"数据查询"主题,但实际场景里它只擅长查MySQL,不擅长查Elasticsearch,直接把消息推给它,它还得自己判断然后拒绝,浪费一次通信。

后来我把路由协议改成了"能力表达式"匹配。每个Agent在注册或更新的时候,除了声明能力列表,还可以给每个能力挂上属性约束,比如"sql.query"能力有个属性database_type,取值范围是["mysql", "postgresql"]。发送方在投递消息时可以附带请求条件,例如"需要sql.query能力且database_type=mysql"或者"任意具有sql.query能力的Agent"。中心节点在路由的时候先做能力粗筛,再做属性精筛,最后返回一个按负载排序的候选列表。

这个设计让我得到一个很重要的经验:Agent路由的本质不是消息转发,而是请求匹配。你要让用户的请求精确地找到"能办好这件事"的Agent,而不是把消息广播一圈碰运气。为了支持这个能力,我专门设计了一套简单的声明式条件语法,类似SQL的WHERE子句,注册表和请求里都可以写PQL表达式。后面我已经计划让它支持更复杂的逻辑运算符和嵌套条件,方便处理一些跨能力的路由需求。

2.3 消息通道与序列化选型

注册和路由是"找到人",消息通道是"把话送到"。Agent-Reach的消息通道我采用了异步事件驱动的模式,中心节点不直接持有长连接到每个Agent,而是通过一个内部的消息总线转发。这样做的好处是解耦,Agent的启停不影响消息通道的稳定性,消息先落到持久化队列里,目标Agent恢复后还能补发。

序列化格式我选的是JSON搭配二进制补丁。纯JSON的好处是直观、调试方便,但性能确实堪忧;纯二进制协议性能好,但对开发人员不友好,查个日志都要先解码。我采用的折中方案是:控制面和元数据用JSON(方便观察),数据面的大体积payload用MessagePack之类的二进制格式。一条消息在Agent-Reach内部被封装成标准信封,包含消息ID、来源Agent、目标Agent或路由条件、任务TraceID、时间戳和一个可选的二进制payload区。整个信封用JSON定义,payload字段留成字节数组,这样既保留了可读性又兼顾了性能。

在消息传输可靠性上,我实现了三个级别的投递语义。第一级是fire-and-forget,发送方发完不管,适合日志上报这种介质场景。第二级是at-least-once,消息持久化后投递,失败重试,但接收方需要做幂等处理。第三级是exactly-once,这个在分布式系统里非常昂贵,我没有在通用通道里直接实现,而是在业务层通过任务状态机提供了幂等能力,从需求落地角度看已经足够。

  1. 关键实现细节:从原型到可复用的完整过程

3.1 Agent接入的核心API是怎么设计的

Agent-Reach的客户端SDK是我花时间最多的地方。SDK的体验直接决定了Agent接入的难度,如果太难用,架构再好也没人愿意集成。我最终把SDK抽象成了三个核心对象:AgentHandle(Agent句柄)、MessageContext(消息上下文)和TaskTracker(任务追踪器)。

AgentHandle负责生命周期管理,Agent启动时调用AgentHandle.register()完成注册,关闭时调用unregister()。在注册成功之后,AgentHandle内部分派两个协程,一个专门发送心跳,一个专门接收消息。发送心跳的协程会按配置的间隔定时上报,并且支持随机抖动,避免大批Agent在同一时刻发起心跳造成网络尖峰。

MessageContext是Agent之间消息传递的载体,它封装了信封的解包逻辑和响应回传通道。Agent的业务代码只需要实现一个MessageHandler接口,收到消息后从MessageContext里取出请求数据,处理完调用messageCtx.reply(...)返回结果。这个接口设计借鉴了HTTP处理器的概念,符合大多数开发者的直觉。麻烦的地方在于:同一个Agent可能并发收到多个不同任务的消息,MessageContext必须绑定各自的TraceID,否则响应回来以后对不上号,所以我特别强调必须在每个处理函数入口就获取TraceID,而不能在全局变量里存。

3.2 任务状态追踪:从发起到闭环的全链路

光有消息收发还不够,调用方最关心的是:我发出去的任务到底执行得怎么样了。Agent-Reach在消息层之上封装了一层任务状态机,状态流转路径是这样的:PENDING(已创建)到ROUTED(已匹配到Agent)到DELIVERED(已投递成功)到PROCESSING(已开始处理)到SUCCEEDED或FAILED。这条链路在代码里体现为一个独立的TaskTracker模块,发送方和接收方都通过SDK上报状态变更。

设计任务追踪的时候有个争议点:到底是由中心节点统一维护状态,还是靠各个Agent上报汇总。中心维护的问题是会变成单点瓶颈,Agent多了以后所有的状态更新都打到中心节点,性能和可靠性都很紧张。纯汇报式的问题是状态链路不完整,只知道部分Agent的处理结果,一旦消息在路由阶段就走丢了,发送方根本察觉不到。

我最后是两边混合:路由和投递阶段的状态由中心节点负责更新,因为这两个阶段是中心节点在操作;开始处理和处理结果的状态由Agent上报,因为只有Agent本人才知道真实的执行情况。发送方持有一个TaskSession对象,可以查询当前状态,也可以注册一个状态变更监听器,在任务到达终态的时候收到通知。这个小设计非常实用,很多接入方就是靠这个监听器做超时告警的。

3.3 容错:超时、重试与熔断的取舍

多Agent系统里,容错设计的复杂度远高于单Agent。Agent可能会有各种问题:负载太高、推理太慢、依赖的外部API挂掉了。Agent-Reach对这些问题给出的基本策略是分层处理。

超时是第一道防线。我在AgentHandle里给消息处理设置了默认超时时间,比如30秒,超时后MessageContext的响应会返回一个超时错误。第二道防线是重试。不是所有失败都适合重试,我实现了一个退避重试器,只对可重试的错误类型(比如超时、503、网络异常)生效,对业务逻辑错误(比如参数不对、数据不存在)直接放弃重试。重试间隔采用指数退避加抖动,初始1秒,乘以2递增,最大不超过30秒。

熔断是最后一道重闸。如果连续N次重试都失败,这个Agent会被暂时拉黑,路由阶段直接跳过它,直到冷却期结束。熔断的阈值和冷却时间我一开始用的固定配置,后来发现不同的Agent能力差别太大,比如一个专门做文本生成的Agent和一个专门做向量检索的Agent,它们的负载模型完全不同,统一配置明显不合理。最后我改成每个Agent可以在注册元数据里覆盖这些策略参数,让Agent开发者按自己的情况做调优。

  1. 跑通之后踩过的坑:超时、重试与消息乱序

4.1 一次真实的事故:重试风暴把中心节点打挂了

测试环境一切都运行得很顺利,但是上线后的第三周就出事了。那天下午中心节点的CPU直接拉满,大量的Actor处于阻塞状态,控制台打日志都变得卡顿。一开始我以为是网络攻击,查了半天发现是重试机制出了大问题。

事情的起因是这样的:某个下游Agent依赖的第三方API临时不可用,这个Agent处理消息时会主动返回一个可重试的错误。发送方收到重试信号后按指数退避重发,这本身没有错。但这个Agent有多个上游调用方,每个调用方都在独立的线程里发起重试,导致同一时刻打过来的重试请求非常多。更糟的是,Agent-Reach的路由模块每收到一条重试消息,都要重新执行一次注册表查询和路由匹配,这些操作累积起来的负载不亚于新增请求。中心节点为了处理这些无效重试,几乎把所有资源都耗尽了,连基础的心跳续约都开始超时。

事后我在代码里做了两个修复。第一是给每个任务的同源重试加了速率限制,把退避窗口的基数调大,并且加入了全局限流器,超过一定并发数的任务会进入等待队列而不是直接触发路由。第二是把"可重试性"的判断前移,让发送方在发出请求之前先检查目标Agent的状态,如果发现目标Agent已经在熔断状态就干脆不发了,直接缓存到一个重试队列里等冷却。我的一些经验教训还发表在了团队内部分享会上,结论很简单:重试机制必须在全局限流和局部判断双重保护下才安全,任何单点的重试逻辑在复杂网络里都可能放大成雪崩。

4.2 消息乱序问题:同一Agent的任务串行化远比想象中重要

多Agent系统里消息乱序其实比很多人预期的还要常见。拿一个真实场景举例:用户在一个会话里连续问了三句话,意图理解Agent依次发出了三个子任务给下游数据处理Agent。这三个子任务必须按顺序执行,因为后一个任务的输入依赖前一个任务的输出。但由于网络延迟和Agent线程池调度的不确定性,下游Agent可能先收到了第二个任务,处理完了又收到第一个任务,最终结果是整个会话上下文错乱。

Agent-Reach在早期版本里完全不保证消息顺序,我把顺序保证的责任交给了业务层,结果接入方反馈这个约束太重了。后来我在消息信封里引入了sequence_number字段,并约定同源同目标Agent的消息必须按sequence_number升序处理。实现上是在接收方SDK里增加了一个有序消息队列,相同TraceID的消息按序号排队,只有当前一条消息处理完并返回后,下一条才会被取出。如果发现序号跳号了,会用重试机制拉取缺失的消息,超过容忍窗口则直接判定消息丢失并报错。

这个处理让整体吞吐量受到一定影响,尤其是并发嵌套的场景,但换来的顺序保证对Agen场景来说值得。实践里我的建议是:顺序保证只做在同源同业务链路的范围内,不要把全局所有消息都串行化,否则就失去多Agent并行处理的意义了。Agent-Reach的做法是让每一条链路自己决定是否需要顺序保证,需要就在注册或发消息时标记sequence_required=true,不需要的链路依旧走并发通道。

4.3 幂等设计:Agent重启后的重复投递让人防不胜防

at-least-once投递语义带来的核心麻烦就是重复消息。Agent处理器在处理消息的过程中如果崩溃了,重试机制会把同一条消息再次投递过来,如果处理器没有幂等保护,重复执行的结果可能是灾难性的。比如一个Agent每次处理的任务都是给用户账户增加一笔积分,重复执行两次用户就多拿了一倍。

这个问题的根治方案是让消息带上唯一的业务幂等键。Agent-Reach的消息信封里有一个dedup_key字段,接收方的SDK在处理之前先检查这个键是否已经出现在最近处理记录里。我实现了一个基于时间窗口的幂等过滤器,只记录过去5分钟内的消息ID,超过窗口自动淘汰。这个设计有局限,如果重试间隔超过5分钟,就无法过滤重复。所以我在SDK里额外提供了一个钩子,让业务方接入自己的持久化去重表,比如以agent_id+dedup_key作为唯一索引建一张表,处理前先插入,插入成功才继续处理,这样就能做到严格的幂等。

我特别提醒接入方,幂等键的值应该来自业务语义而不是系统生成的随机ID。比如在转账场景里,幂等键应该是"来源用户ID+目标用户ID+金额+业务日期"的哈希值,这样才能保证同一笔业务的重试被识别为重复。如果直接用随机生成的MessageID,Agent重启后MessageID都是新的,幂等过滤就形同虚设了。

  1. 从本地Demo到生产部署:Agent-Reach的演进与再思考

5.1 单中心节点怎么应对大规模Agent网络

Agent-Reach当前的状态是一个中心节点加若干个Agent的模式,中心节点承担了注册、路由和调度的大部分逻辑。这种设计在小规模集群里完全够用,但随着Agent数量增长,中心节点迟早会变成瓶颈。我在设计之初留了一个插槽:注册表和消息路由的存储层用了可替换接口,方便未来迁移到分布式键值存储。

如果想扩展成多中心节点模式,我构思的方案是把Agent按命名空间分区,每个命名空间由一组中心节点组成一个小集群,集群内部通过一致性协议同步Agent注册信息。跨命名空间的通信走网关转发,网关维护一张全局能力摘要表,只记录每个命名空间对外暴露的Agent能力索引,不保存完整的注册信息,这样能在扩展性的同时把同步压力控制在可接受范围。

这个演进目前还在规划中,因为实际项目里Agent的数量短期内不太可能突破几十个,单节点反而简单可靠。我自己通过压测得到的数据是,单中心节点在8核16G的配置下,可以稳定支撑约300个Agent的心跳和每秒数百条消息的路由转发,对小中型团队的项目来说够用了。

5.2 可观测性:没有监控的多Agent系统等于盲飞

Agent系统比普通微服务的排查更难受。微服务的调用链条好歹可以在链路追踪系统里看到完整的Span,Agent系统里消息的状态流转、路由决策、重试逻辑都发生在通信层,出了问题根本无从下手。所以我在Agent-Reach里把可观测性当成一等公民,在SDK内部埋了三种指标:路由指标(请求数、命中率、熔断次数)、消息指标(投递延迟、处理耗时、重试次数)、任务指标(任务状态分布、平均周转时间)。

除了指标,我还设计了统一的结构化日志输出。每条消息从产生到终态,都会在关键节点打出一条带有TraceID的日志,配合收集系统可以拉出一条完整的生命线。接入方只要给任务设置一个有意义的名字(比如billing_agent_task),排障的时候按TraceID一搜就能看到消息在哪个Agent停留了多久、为什么被重试、最后是怎么结束的。很多之前花几个小时才能定位的问题,现在几分钟就能看清楚。

5.3 后续计划:从通信层走向协作层

Agent-Reach目前解决的问题还集中在通信层,把Agent之间的"连接"和"路由"做到位。但多Agent系统真正的难题在协作层,也就是多个Agent需要共同完成一个复杂目标时的任务分解、依赖编排和结果合并。我下一步的规划是在Agent-Reach之上增加轻量级的任务编排引擎,把"DAG级别的任务依赖描述"转化为Agent之间的实际调用序列,并负责整体进度和异常处理。

这个方向如果再往前走,就涉及Agent之间的协商和自主决策了。比如一个Agent发现自己处理不了当前子任务,它能不能在系统里找到另一个Agent来接手,并把未完成的上下文完整传递过去。Agent-Reach目前提供的能力是消息传递和状态追踪,恰恰给这种更高级的协作提供了基础。我的初步想法是在消息信封里增加一个handoff字段,专门用于记录Agent之间交接任务的上下文快照。

从"找到对方"到"交给对方",再到"帮对方把事做完",Agent-Reach的方向正是一点点在变厚重的。我很清楚这条路还很长,通信层只是第一步,但只有第一步走稳了,后面的协作层才有地基。如果你也在构建多Agent系统,建议先想清楚通信层的基础设施怎么搭,别一上来就追求Agent的自主决策能力。Agent能自主协作的前提,是它们彼此之间能可靠地触达。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进