1. 从三个Agent同时卡死的那天说起
先说清楚,Agent-Reach不是某个大厂的开源项目,也不是论文里的概念验证,是我自己在实践多Agent系统三个月之后,被现实狠狠教育了一顿,才下定决心写的一个小框架。它解决的事情用一句话概括就是:让一群各自为战的AI Agent能够互相发现、互相调用、协同干活,而不是各写各的prompt、各调各的API,最后在业务上完全对不上话。
事情的导火索很普通。当时我在做一个内部工具,三个Agent分别负责:解析用户需求、检索技术资料、整理成落地方案。单独跑每一个都挺正常,但一旦让它们串起来协作,问题就层出不穷。最常见的一幕是:Agent A完成了需求解析,把结果塞进一个目录就以为任务结束了,Agent B根本不知道去哪个目录拿;即使拿到了,消息里带着各种我根本没有定义过的字段,B只能靠prompt硬猜。更离谱的是有一次A和B互相调用,两边都设了超时重试,结果在同一个问题上循环重试了四十多分钟,把API额度烧掉一大半,最后给我返回了一条"任务失败"。
我把这个现象归结为三个字:不可达。Agent之间没有一套统一的发现机制、消息协议和调用约定,那么它们就是一堆孤岛。市面上已经有编排框架,比如用图的方式定义Agent之间的依赖关系,或者用消息队列做异步解耦,但这些方案都默认了一件事:Agent是静态的、提前配置好的。而我的场景里,Agent可能会动态加入、临时下线、能力还会升级,我需要的是让Agent像人一样,知道"谁能帮我""怎么找到他""怎么把话递过去",这就是Agent-Reach这个项目的出发点。
如果你现在也在做多Agent系统,人一多就开始乱套,或者你正准备从单Agent往多Agent迁移,那这篇文章应该能给你省下不少试错的时间。下面我不会按什么架构图的顺序讲,而是按照我自己从设计到落地、再到踩坑修复的真实路径来写,尽量把每一步的"为什么"也说清楚。
2. 可达性到底是什么意思:三层拆解
在写第一行代码之前,我把"Agent可达"拆成了三个层面,这个拆解后来被证明是整个项目里最有价值的部分,因为很多方案设计不下去,就是因为层面没分清楚,什么问题都往传输层上扯。
2.1 发现层:Agent怎么知道其他Agent存在
这是最基础的一层。一个Agent要调用另一个Agent,前提是它得知道对方存在、支持什么调用方式、当前是否在线。我最早试过配置文件写死,后来发现完全不可行,因为Agent的能力会迭代,今天这个Agent还只能检索资料,明天它可能就接入了工单系统,配置文件根本跟不上变化。
Agent-Reach的做法是引入一个轻量的注册中心。每个Agent启动的时候,向注册中心发送自己的能力清单(一个JSON格式的capability manifest),然后周期性发心跳。能力清单里不光写Agent的名字和地址,更重要的是写它暴露了哪些可调用操作、每个操作需要什么样的输入、能返回什么类型的输出。这样别的Agent就不需要预先知道对方的一切,只需要问注册中心:谁有这个能力?注册中心返回候选列表。
有人可能会问,这不就是微服务那套服务注册发现的翻版吗?是,也不是。微服务的注册发现解决的是"服务实例在哪、健不健康"的问题,而Agent之间的发现还多了一层语义问题——不只要找到"一个能处理JSON的服务",而是要找到"一个能把自然语言需求拆成结构化任务的人"。所以注册信息里必须带能力描述,而且这个描述要能被Agent通过语义匹配去理解,而不只是精确匹配接口名。
2.2 语义层:消息双方理解的是同一件事
发现层解决"找得到",语义层解决"听得懂"。我最初犯的一个错误,是直接拿REST接口那套思维来设计Agent之间的交互。REST没问题,它在人设计的系统里非常好用,接口文档写清楚,参数类型固定,前后端各守各的约定就行。但Agent的输入输出天然是模糊的、非结构化的。
举个真实的例子。我有一个Agent专门做技术方案评审,它接收的输入是"评审一份架构文档"。这个输入如果走REST接口,你得定义清楚文档怎么传、评审维度有哪些、输出格式是什么。但在Agent场景里,"评审一份架构文档"这句话本身可能就是另一个Agent传过来的自然语言,意图是清楚的,边界却是模糊的。它可能附带了一个文件路径,也可能附带了一段粘贴的文本,还可能只是一个标题让人家自己去找。
所以Agent-Reach在消息协议里设计了一个payload结构,分为三个部分:意图(intent)、主体内容(content)、上下文(context)。意图用固定枚举值或者结构化编码,保证路由层能识别;主体内容是真正要处理的东西,可以是文本、文件引用、结构化数据;上下文则携带对话历史摘要、来源Agent信息、截止时间等辅助信息。这样的设计让接收方Agent既能快速定位"要干什么",又能保留处理模糊信息的弹性。
2.3 调用层:请求、响应、错误到底怎么对齐
最后一层才是实际的调用执行。我一开始用的是同步HTTP调用,Agent A发一个请求给Agent B,B处理完返回结果。这在两个Agent之间没问题,但一旦链路拉长,A调B、B调C、C调D,同步调用就变成了灾难——每一环都要等,任何一个环节超时,整条链就断了。
Agent-Reach最后做成了同步和异步混合的模式。短任务(几秒到几十秒能出结果的)走同步,长任务(比如Agent要查一堆资料再汇总)走异步:调用方先拿到一个任务ID,然后轮询或者订阅完成事件。这个设计不复杂,但非常关键,后面我在踩坑部分还会详细说超时和重试的问题,这里先不展开。
三层拆完之后,整个系统的边界就非常清晰了:发现层管注册和寻址,语义层管消息格式和对齐,调用层管执行和返回。后面写代码的时候,每层各自独立演进,互不牵扯,这是我认为这个项目做得最对的一个决定。
3. 为什么没直接上微服务那套:Agent和服务的本质差异
确定了三个层面之后,我身边一位做后端的朋友建议我直接用现成的服务网格方案,说注册发现、负载均衡、熔断限流都现成,干嘛自己造轮子。我当时确实认真调研了一圈,也试过一个轻量级服务网格,但跑了两个星期就撤下来了。原因不是服务网格不好,而是Agent这个对象和常规的网络服务有几点本质差异,硬套会别扭。
第一,Agent的"接口"是不稳定的。普通服务的API,前后端约定好了就基本不动;但Agent的能力是动态演化的,今天它暴露了三个操作,明天prompt一调,可能就合并成两个操作了,或者一个旧的调用方式还能用但行为已经变了。服务网格的接口管理假设接口是相对稳定的,它管的是流量,管不了语义变化。Agent-Reach的做法是每次调用前先做一次能力匹配,调用方不直接写死对方的接口,而是声明自己的需求,由注册中心帮忙匹配满足需求的那个Agent的最新能力版本。
第二,Agent的调用是会"思考"的,延迟差异巨大。普通服务接口的响应时间通常在毫秒到百毫秒级别,Agent因为内部要跑LLM推理,响应时间跨度可以从三秒到几分钟。服务网格里常见的超时配置单位是秒,熔断器设置的阈值也是基于秒级响应设计的,放在Agent场景里,三秒钟没响应就判定失败合理吗?完全不合理,Agent可能只是在想。你如果把超时调到五分钟,一个卡死的Agent又会导致调用方长时间挂起。这种场景需要的是任务化的调用语义,而不是HTTP层面的连接管理。
第三,Agent之间的交互天然是多轮的,带上下文的。普通服务调用是无状态的请求-响应,Agent协作则是对话式的,B在回答A的问题时,可能需要基于A之前的几个请求来理解。REST是无状态的,你得把上下文每次都传一遍,很快消息体积就失控。Agent-Reach为每个Agent维护了一个轻量的会话状态,通过消息头里的session_id关联上下文,这样语义层的设计才能落得下去。
还有一个很实际的问题:成本。微服务框架普遍都重,Sidecar什么的部署一套,内存开销先上去几百兆。我的Agent进程本身就是吃显存和内存的大户,实在不想为了协调几个Agent再背上一个沉重的依赖。当然我这个项目的规模不大,如果你的Agent数量上百,那确实值得考虑更重的方案,但对于大多数团队几十个Agent的规模,一个轻量的可达层完全够用,而且够灵活。
4. 核心实现:注册、路由、调用在代码里长什么样
理论拆完,代码才是见真章的地方。Agent-Reach的代码量不大,核心模块就三个:注册中心、消息路由、调用客户端。我把它做成了一个Python库,Agent接入的时候只需要初始化一个client,声明自己的能力,然后注册handler,剩下的通信细节都封装掉了。
4.1 注册与能力清单
能力清单是Agent-Reach的基石。每个Agent在启动时调用register方法,把自己能提供的操作注册上去。这个清单长什么样子,我贴一个简化版的结构:
{ "agent_name": "tech_retriever", "agent_version": "1.2.0", "capabilities": [ { "operation": "search_docs", "description": "搜索内部技术文档库,根据关键词返回相关文档摘要列表", "input_schema": { "keywords": {"type": "string", "required": True}, "top_k": {"type": "integer", "default": 5} }, "output_schema": { "results": {"type": "array", "items": {"type": "object"}} } }, { "operation": "get_doc_detail", "description": "根据文档ID获取完整内容", "input_schema": { "doc_id": {"type": "string", "required": True} } } ] }注意这里的description不是给人看的那种注释,而是会被其他Agent真正消费的语义信息。当一个Agent需要找人帮忙搜资料时,它会把需求发给注册中心,注册中心做语义匹配,找出capabilities里描述最贴合的那几个Agent。这一步用的是embedding相似度匹配,我在注册中心里内置了一个简单的向量检索逻辑,把capability描述和请求意图都embedding化,然后算余弦相似度,阈值之上的进入候选池。
这个设计的好处是:调用方Agent不需要知道具体该找谁,只要模糊地表达自己的需求,剩下的由注册中心去匹配。尤其在Agent数量变多、能力越来越碎的时候,这种语义发现方式比写死调用关系要健壮得多。
4.2 消息信封与路由
消息传输我用了一个统一的消息信封,所有Agent之间的交互都走这个结构。它的核心字段如下:
@dataclass class AgentMessage: msg_id: str # 全局唯一消息ID,用于幂等和追踪 session_id: str # 会话ID,关联多轮对话上下文 msg_type: str # request / response / event / error intent: str # 意图标识,如 "search_docs" source: str # 发起方Agent name target: str # 接收方Agent name,如果为空则由路由层决定 payload: dict # 主体内容,结构根据intent不同而变化 context: dict # 上下文信息,如截止时间、优先级、链路追踪ID timestamp: float # 发送时间路由层拿到消息之后,按照以下顺序处理:
- 如果target明确写了Agent name,直接投递;
- 如果target为空,根据intent去注册中心做语义匹配,找出候选Agent列表;
- 如果匹配到多个候选,根据候选的负载情况和历史成功率排序,选最优的。
这里有一个我花了不少心思的细节:路由结果需要回传。调用方发出一个target为空的请求,路由层选定目标之后,会把"实际投递给了谁"作为消息头的一部分返回给调用方,调用方可以缓存这个路由结果,下次同类请求直接指定target,减少语义匹配的开销。这个缓存还有失效机制,收到目标Agent的离线事件时会自动清除。
4.3 调用体验
调用端的体验我尽量做成了同步函数的样子,让接入方学习成本最低。比如Agent A要调用Agent B的搜索能力,代码大概是这样的:
from agent_reach import Client client = Client(agent_name="planner") # 同步调用,内部会处理发现、路由、超时和重试 result = client.call_agent( intent="search_docs", payload={"keywords": ["Agent-Reach", "架构"]}, agent_hint="tech_retriever", # 可选,指定倾向的目标 timeout=120 )如果是需要长时间执行的任务,异步版本长这样:
task_id = client.submit_task( intent="review_architecture", payload={"doc_id": "20250415_design_v3"} ) # 之后可以轮询,也可以注册回调 status = client.get_task_status(task_id)提交任务返回的task_id由接收方Agent生成,它处理完之后会把结果关联到这个task_id上。因为用了统一的存储,即使接收方处理过程中挂了,只要把中间状态持久化了,恢复之后还可以接着把结果写到同一个task_id下。
4.4 事件机制:Agent状态变化的广播
除了请求-响应,Agent-Reach还有一个轻量的事件总线,用来广播Agent状态变化。比如Agent上线、离线、能力更新、任务完成,都会发对应的事件。这个设计让系统具备了动态特性:新Agent加入不需要改任何配置文件,老Agent下线后,调用方能在下次调用时拿到错误提示并自动选择备选Agent。
事件总线我没有引入外部MQ,就用了一个基于Redis pub/sub的简单实现。Agent进程多的时候,Redis的连接数会上去,但几十个Agent的规模完全扛得住。如果将来规模再大,替换成真正的消息队列也只是适配层的事,核心消息模型不用动。
5. 跑起来之后踩到的一串坑
写完核心功能,我开始往真实业务里接入,那才是我觉得这个项目真正开始有意义的阶段。理论设计归理论设计,一跑真实任务,各种预想不到的问题全冒出来了,这里挑几个有代表性的记录一下,每一个都是真金白银换来的经验。
5.1 序列化:Python对象在消息边界上被撕碎
第一个坑最基础但也最要命。我的Agent之间传的数据一开始直接用的json.dumps,很快发现两个问题:一是datetime、numpy类型根本序列化不了,二是LLM返回的内容里经常带一些奇怪的控制字符,json解析直接报错。
我的处理方案是统一用MessagePack + 自定义类型适配器。MessagePack序列化速度快、体积小,更重要的是它支持二进制类型,传文件内容的时候方便很多。自定义类型适配器解决datetime这类常见类型的转换问题,所有Agent接入的时候都会注册同一个适配器集合,保证同一个类型的对象在消息边界上不会被撕碎。
这个坑提醒我的事情是:多Agent系统的消息协议里,序列化方案一定要从第一天就确定,而且要按"必然会传复杂类型"来设计,不要心存侥幸觉得传传字符串就行。
5.2 重试风暴:两个Agent互相重试,把系统打挂
这是我开头提到的那次四十多分钟循环重试事故的正式复盘结果。当时A调B超时,A的重试逻辑是"失败后重试3次,每次间隔指数退避";B调C也超时,B同样有重试逻辑。结果A重试的任务又被B接收,B调C失败又触发B的重试,链条一叠,实际产生的请求量是正常量的几十倍。
Agent-Reach的解决方案有三层:
- 第一层是幂等控制。每条消息带msg_id,接收方在session维度记录已处理过的msg_id,重复消息直接返回之前的结果,不再重新执行。
- 第二层是全局重试策略统一。所有Agent的重试默认走Agent-Reach内置的retry manager,它会把"重试次数"和"当前链路深度"做联合控制,链路越深,单次重试的成本收益比也越低,所以越往链路下游越要保守重试。
- 第三层是熔断。当某个Agent的连续失败率超过阈值,注册中心会把它的健康状态标记为"降级",新路由请求自动绕开它,直到它恢复。
这三层合起来,重试风暴基本被扼杀了。后来我把这个经验也提炼成一个原则:Agent系统的重试必须全局可见、全局可控,任何一环自己拍脑袋设重试参数都是定时炸弹。
5.3 超时设置:LLM的慢和三秒的约定不兼容
还有一个坑是超时。最早我把Agent之间调用的默认超时设成了30秒,结果一堆任务因为这个时间不够而超时失败。你仔细想一下,Agent收到请求之后,要先解析意图、检索资料、构造prompt、调一次LLM,LLM生成几百个token可能要十几秒,生成完还要做结果清洗和后处理,一套下来30秒真的不够用。
但超时也不能无限拉长,否则一个Agent卡死会把调用方线程池拖垮。我的最终方案是:把所有调用按预估执行时间分级,每个Agent在自己注册的能力清单里标注每个operation的预估耗时区间(fast/normal/slow三档),调用方按这个信息设置超时,并且超时之后不是直接判定失败,而是先发起一个query状态请求,确认对方是还在处理还是已经死了。这个设计解决了一个核心矛盾:不能因为慢就贸然重试,很可能任务已经在执行了,贸然重试会造成重复执行,这种重复执行在LLM场景下是真的会花钱的。
5.4 调试是最大的隐性成本
这个坑虽然不涉及代码,但我觉得值得单独说。多Agent系统的调试比普通分布式系统痛苦得多,因为每个Agent内部是个黑盒,你看到的是它吐出的最终结果,中间做了哪些推理、为什么选了这个tool、为什么返回了这个错误,都不透明。我第一次排查一个跨三个Agent的链路问题,花了整整一个下午,最后发现是其中一个Agent的prompt里对输出格式的约束不够,导致返回的JSON里多了一个非常规字段,下游Agent解析的时候崩了。
所以我强烈建议所有做多Agent系统的人,从第一天就做全链路追踪。Agent-Reach现在要求每个Agent在处理消息时,把自己的关键处理步骤以结构化日志写入trace,包括输入摘要、调用了哪些工具、LLM请求的参数和耗时、输出摘要。这个trace并不是给人读的日志,而是专门为了事后排查准备的。排查的时候根据session_id把链路完整重放出来,每一步都看得清清楚楚,效率提升是质变级的。
6. 实测数据、性能边界和我的使用心得
项目跑了一个多月之后,我整理了比较详实的实测数据和性能表现,也明确了Agent-Reach的适用边界,这里挑几个关键数字说说。
在稳定运行期间,系统内一般有9到12个Agent在跑,涵盖了需求解析、资料检索、代码生成、方案评审、任务跟踪这些角色。每小时处理的跨Agent消息量在400到1200条之间,注册中心的语义匹配接口P99延迟在80毫秒左右,消息投递的失败率在千分级,绝大多数失败都发生在目标Agent主动下线或者模型服务暂时不可用的时候。对这样一个规模来说,Agent-Reach的表现我整体是满意的。
内存和CPU的额外开销也很小,注册中心是一个单进程的FastAPI应用,跑在一台2核4G的小机器上,日常CPU占用率不超过10%,内存占用稳定在300MB上下。接入Agent库本身占用的额外内存可以忽略不计。坦白说,如果你的Agent数量超过上百个、消息吞吐达到每秒几千条,Agent-Reach现在的单点架构肯定扛不住,但这也不是这个项目的目标场景。对于中小规模的Agent网络,它已经够用了。
目前所有Agent之间的调用,处理一条完整链路的平均耗时有明显分层:单纯信息查询类的链路通常在30秒内完成,涉及生成和评审的多跳链路大部分在2分钟以内,少数复杂任务因为LLM推理反复重试会拖到5分钟以上。这个耗时分布反过来又验证了我之前对超时分级的设计是合理的。
我现在的使用模式也基本稳定了。新Agent接入的流程已经被压缩到十几分钟:写好能力清单,注册需要的handler,跑一个连接自检,然后就能被其他Agent发现并使用。日常运维只需要盯着注册中心的健康面板和事件日志,Agent挂了重启,能力更新了重新注册,不需要人工去改任何其他Agent的配置。这种"加入即被感知"的体验,正是我最早做这个项目想要的那种效果。
最后说两件我平时不太挂在嘴边的事。一件是Agent的id设置,我强烈建议不要用简单的角色名,比如"search_agent"这种,因为能力会迭代,角色会合并拆分,名字一旦固定后面迁移很麻烦。Agent-Reach里我用的是一串固定ID加上语义标签,ID不变,标签随便改,这样历史消息和数据才能对得上。另一件是定期做一次全链路的故障演练,比如手动下线一个关键Agent,看调用方能不能平滑切换,这种演练做过一次之后,你对系统在真实故障面前的表现会有完全不同的认识。