Agent-Reach:解决多Agent协作的注册、路由与异步回执问题

发布时间:2026/10/29 18:52:36
Agent-Reach:解决多Agent协作的注册、路由与异步回执问题

搞多Agent系统的时候,最让我头疼的从来不是单个Agent本身怎么写,而是让它们彼此“找到对方、说上话、拿到结果”。每个Agent单独跑都好好的,一旦两个Agent要协作,就得开始手工对接:动态端口、请求格式不一致、流式输出和非流式输出混在一起、隔壁Agent半夜悄悄挂了没人知道。Agent-Reach这个项目,就是我在折腾这些问题时沉淀出来的一个轻量Agent触达中间件,用来解决多Agent场景下的注册、路由、调用和回执四个问题。如果你也在自建Agent集群,或者正准备把内部工具改造成“可被调用的能力”,这篇内容应该能给你省下不少冤枉路。我会把设计思路、可复现的代码片段、以及我当时被生产环境教育过一轮的踩坑复盘,一次讲完。

1. Agent互连困局:每个Agent都是一座孤岛

1.1 三个让Agent协作崩溃的现实问题

先说清楚我面临的具体场景:团队里有几个独立部署的Agent服务,一个做舆情数据收集,一个做内容摘要,一个负责定时推送。单独调用任何一环都很顺,但当我尝试让“摘要Agent”在拿到“爬取Agent”的输出后再干活时,问题就来了。

第一个是连接问题。Agent服务部署在内网的不同机器上,端口每次启动都动态分配,写死的URL第二天就失灵。更麻烦的是,很多Agent之间的协作是长对话性质的,调用方需要保持连接等待结果,而Agent本身可能因为一次超时就主动断开。生产环境的网络又一次次重演:几十秒空闲后,中间的代理层直接把连接回收了。

第二个是协议不统一。有的Agent暴露的是普通HTTP,有的支持SSE,有的偏要用WebSocket推消息。对接每个Agent都要单独写一份适配代码,调用方不仅要知道“找谁”,还得知道“怎么和它说话”。当Agent数量超过5个,这段适配代码的维护成本就基本失控了——加一个新Agent等于要改一遍所有相关调用方的代码。

第三个问题最隐蔽也最要命:结果不可追踪。调用方把任务交给了目标Agent,目标Agent跑到一半崩溃了,任务到底是没执行、还是执行了一半、还是执行完了但结果丢了?没有统一的回执机制,排查问题全靠人肉问。我在一次联调里就遇到过一个Agent重复执行了三次任务,扣了三次上游费用,事后查日志才发现是调用方没收到响应自动重试导致的——而目标Agent其实每次执行状态码都是200。

1.2 Agent-Reach的定位:做一个连接线而不是又一个框架

我尝试过直接上消息队列方案,也看过几个开源RPC框架。但我需要的其实不是一套完整的微服务框架,而是解决“互连”这一个点:让Agent能把能力注册出来,让其他Agent或客户端能用统一的方式发起调用,让异步任务的结果能安全地回到调用方手里。Agent-Reach的定位就放在这里——它是一根连接线,不是万能的分布式框架。

它和市面方案的区别在于三件事:第一,它不强求你改Agent的原有对外接口,可以用适配模式包裹已有服务;第二,它把“路由”和“触达”分离,路由逻辑是插拔的,你可以按tag、按领域、按参数特征自定义;第三,它内置了一套针对异步任务场景的回执机制,不会因为调用方中途断开就丢掉结果。

这套东西做完之后,我最大的感受是:很多看起来是“Agent不智能”的问题,其实本质是“Agent之间的基础设施太原始”。Agent-Reach解决的就是这个基础设施问题,让协作的复杂度从O(N²)收敛回O(1)——每个Agent只需要和触达层打交道,不需要认识其他所有Agent。

2. Agent-Reach的设计骨架:注册、路由、触达、回执四个能力

2.1 注册:用一份统一的能力描述表替代满天飞的接口文档

Agent-Reach的注册机制核心是一份能力描述文件。每个Agent在启动时向注册中心提交自己的“能力卡片”,里面写清楚三件事:它能干什么、调用它需要什么参数、它返回什么格式。我用的是JSON Schema来表达这部分,因为它现成就有校验库,不需要自己发明规则。

实际落地时,我建议schema不要只描述“数据类型”,还要写上一句人类可读的用途说明,以及一个可选的tag字段。比如舆情收集Agent可以这样注册:

{ "service_name": "collector_agent", "version": "1.2.0", "tags": ["crawler", "news", "public-opinion"], "capabilities": [ { "name": "fetch_by_keyword", "description": "按关键词抓取新闻标题和正文URL列表", "input_schema": { "type": "object", "properties": { "keyword": {"type": "string", "minLength": 1}, "limit": {"type": "integer", "default": 10} }, "required": ["keyword"] }, "output_schema": { "type": "array", "items": { "type": "object", "properties": { "title": {"type": "string"}, "url": {"type": "string"} } } } } ] }

注册中心本身我用了简单的内存表加定期续约机制:每个Agent每30秒上报一次心跳,超过90秒没续约,注册中心就把它标记为离线。这个设计是为了避免“Agent进程还活着但假死”的状态长期霸占路由表。

2.2 路由:不要迷信全自动语义匹配,标签先顶上

一开始我试过让路由层“理解”调用方的自然语言意图,自动找到合适的Agent。想法很好,实际效果很不稳定——同一个意图在两个不同Agent的描述里表达方式不一样,路由就随机漂移。后来我换成了更务实的三级路由策略:

  • 第一级,按tag精确匹配:调用方显式声明要找携带某个tag的服务,路由层直接过滤。
  • 第二级,按能力名匹配:调用方指定capability name,路由层在注册表里查找拥有该能力的在线Agent。
  • 第三级,按参数结构匹配:当调用方只给出了一组JSON参数没有指定目标时,路由层遍历所有在线Agent的input_schema,用参数结构相似度打分,取最高分。

三级都命不中时就明确报错,绝不静默转发。这段决策过程会被记录在回执的metadata里,方便事后排查:“为什么这个任务会交给A而不是B。”这点对于Agent运营来说非常关键,因为路由决策不透明,就会变成玄学,出了问题根本没法定位。

2.3 触达:统一的进出场协议封住“怎么通信”的差异

触达层解决的是“协议不统一”的痛点。我把它拆成两部分:出站适配器和入站适配器。出站适配器面向调用方,无论底层Agent用的是HTTP还是SSE还是WebSocket,调用方看到的都是同一个请求-响应模型,返回值永远是一个统一结构的JSON。入站适配器放在Agent一侧,负责把Agent本来提供的接口包装成Agent-Reach的标准形式。

当时我做了一个关键决定:触达层的默认超时设为30秒,超过30秒不再等待同步结果,而是把任务转成异步模式,立刻返回一个task_id。这个设计直接把我后面遇到的“长任务卡死调用方”问题消灭了一大半。同步调用只适合真正快返回的场景,凡是Agent内部要跑三步以上流程的,都应该走异步回执。

统一进出场协议的另一个收益是调用方对接成本极低。你的业务系统只要实现一个客户端,就能调用所有已注册的Agent能力。新增一个Agent,对调用方完全透明,真的做到了一根连接线接入所有服务。

2.4 回执:任务结果不会因为连接断开而人间蒸发

异步任务最大的坑就是结果丢失。Agent执行完任务后要把结果发给谁?调用方可能已经断了,可能换了连接,可能它自己根本不想等结果。Agent-Reach的回执机制设计如下:

  • 调用方发起异步任务时拿到task_id,同时注册一个callback地址(可以是HTTP接口,也可以是消息队列topic)。
  • 目标Agent执行完毕后,把结果连同task_id提交给Agent-Reach的暂存区。
  • 暂存区先按callback地址主动推送,推送失败则保留结果并等待调用方主动拉取。
  • 结果在暂存区保留24小时,超时未领取触发告警。

这套机制不复杂,但解决了实际痛点。后来我在生产环境遇到过一次Agent下线导致任务堆积,恢复后暂存区把积压结果全部补推给了调用方,数据一条没丢。这种“连接断了但结果不会丢”的踏实感,是很多Agent方案里没有的。

3. 最小可用版落地:注册、发调用、接回执的三十分钟走通

3.1 先搭注册中心和路由内核

我先用Python写了一个最小可运行的版本,核心就三个类:Registry、Router、Transport。以下代码是我简化后的骨架,去掉了一些细节,保留了核心逻辑,你可以直接照着跑起来。

import time import uuid import json import threading from collections import defaultdict class Registry: """存储所有Agent注册信息,并维护心跳续约""" def __init__(self): self._agents = {} self._lock = threading.Lock() def register(self, agent_info): agent_id = agent_info["service_name"] agent_info["last_seen"] = time.time() with self._lock: self._agents[agent_id] = agent_info return agent_id def heartbeat(self, agent_id): with self._lock: if agent_id in self._agents: self._agents[agent_id]["last_seen"] = time.time() return True return False def drop_expired(self, ttl=90): now = time.time() with self._lock: expired = [k for k, v in self._agents.items() if now - v["last_seen"] > ttl] for k in expired: del self._agents[k] return expired def find(self, tag=None, capability=None, params=None): with self._lock: candidates = list(self._agents.values()) if tag: candidates = [a for a in candidates if tag in a.get("tags", [])] if capability: candidates = [a for a in candidates if any( cap.get("name") == capability for cap in a.get("capabilities", []) )] return candidates

Router就简单了,在Registry返回的候选列表里执行三级匹配逻辑。参数结构相似度我直接用的字段集合重合度,没有引入向量模型:

class Router: def __init__(self, registry): self.registry = registry def route(self, tag=None, capability=None, params=None): candidates = self.registry.find(tag=tag, capability=capability) if len(candidates) == 0: return None if len(candidates) == 1: return candidates[0] # 多候选时按参数schema匹配度打分 best = None best_score = -1 for agent in candidates: for cap in agent.get("capabilities", []): if capability and cap.get("name") != capability: continue try: schema = cap.get("input_schema", {}) props = set(schema.get("properties", {}).keys()) given = set(params.keys()) score = len(props & given) / max(len(props | given), 1) if score > best_score: best_score = score best = agent except Exception: continue return best

这段代码里,我刻意没把分数计算搞复杂,因为实践下来,字段集合重叠度在多数场景下已经够用。出现两个Agent功能高度相似、字段也相似时,本来就该由调用方显式指定tag,而不是让路由层猜。

3.2 一个真正的调用链路长什么样

把一个Agent接入Agent-Reach需要做的改造其实不多。假设我有一个摘要Agent,本身提供了一个HTTP接口,我只需要写一个入站适配器,把它的HTTP接口包装成标准能力描述,然后注册到Registry:

# agent侧注册示例 agent_info = { "service_name": "summarizer_agent", "version": "0.9.0", "tags": ["nlp", "summary"], "capabilities": [ { "name": "summarize", "description": "输入一段文本,返回摘要", "input_schema": { "type": "object", "properties": { "text": {"type": "string", "minLength": 20} }, "required": ["text"] }, "output_schema": {"type": "object"} } ] } registry.register(agent_info)

客户端发起调用的方式是这样的:

# 调用方侧 client = ReachClient(registry=registry, router=router) task_id = client.invoke_async( capability="summarize", params={"text": "这是一段很长的正文..."}, callback_url="http://my-service/callback/summarize" ) # 通过轮询拉取结果 result = client.fetch_result(task_id, timeout=30)

关键点在于:invoke_async返回的是一个task_id,不是结果本身。目标Agent启动后,无论花3秒还是30分钟,结果最终都会通过callback_url回到调用方手上。如果你不关心实时性,也可以用fetch_result主动拉取。

这套最小版大概花了三十分钟就能跑通注册到调用的完整流程。我强烈建议你先照着这个最小结构搭一遍,再往上加鉴权、限流、持久化这些正经生产功能。因为Agent-Reach的每一层分工都很清晰,先跑通主干再补细节,会让你对整体逻辑有更落地的感觉。

3.3 我为什么不推荐一开始就上消息队列

有一个点我想重点说一下:一开始有同事建议直接在Agent-Reach里集成Kafka或者RabbitMQ作为底层传输。我没有采纳,原因是Agent协作场景里绝大多数调用的生命周期很短,走队列反而引入了额外延迟和运维负担。而且一旦用了队列,调用方就会天然地依赖队列的“at-least-once”语义,这会让幂等设计变得比直接用HTTP回调更复杂。

Agent-Reach的传输层只做两件事:同步请求用HTTP,异步任务用带暂存区的回调。这个组合覆盖了我遇到过的一切真实场景。至于消息队列,我把它定位成“可选的存储后端”,只有当你的回调可靠性要求高到需要磁盘持久化时才接上。初期阶段,先保持简单。

4. 稳定运行之前先踩坑:连接、路由和重试的排查复盘

4.1 坑一:代理下的空闲连接回收,让长任务“静默死亡”

第一次把Agent-Reach部署到测试环境的第三周,有同事反馈:摘要Agent偶尔会整个消失,日志里什么都没有,进程还活着,但就是不再响应任何新请求。我一开始怀疑是线程池满了,查了线程堆栈,发现大部分线程都阻塞在HTTP长连接上——它们在等一个永远不会到来的响应。

后来仔细排查,发现根因是测试环境前面有一层代理,代理的空闲连接回收策略是60秒。而摘要Agent内部有一步调用外部模型API,耗时经常在30到90秒之间波动。当一步外部调用超过60秒时,代理就认为连接空闲了,主动断开;摘要Agent拿到的是一段被截断的响应,处理逻辑抛了异常,异常被某个库里的全局捕获器吞掉了,导致整个Agent挂起。

修复很简单:把触达层的出站连接设置上TCP keepalive,同时给任何外部API调用都加上“连接复用前先探测”的逻辑;更重要的,是把我之前提到的30秒同步超时直接用起来——超时后立刻转异步模式,不让目标Agent长时间占用连接。那个全局异常吞掉的问题也修了:Agent-Reach的适配器会在异常发生时主动上报ERR状态给注册中心,而不是自己闷着。

这个坑给我的教训是:Agent互连层看到的现象往往是“对方死了”,但根因可能是一整条链路上任何一个环节的静默丢弃。排查这种问题,日志里那些被吞掉的Exception是第一个要翻的地方。

4.2 坑二:路由误判,相似能力被派给错误Agent

第二件让我长记性的事是路由误判。当时我有两个Agent,都能做“内容处理”类的活:一个做文本摘要,一个做关键词抽取。它们的input_schema都包含text字段,描述分别是“对长文本生成摘要”和“从文本中抽取关键词”。由于我的路由逻辑当时只匹配了字段重合度,传入{"text": "..."}的调用经常随机落到其中一个Agent头上,导致摘要Agent经常返回关键词列表,关键词Agent经常返回摘要文本。

排查链路是这样的:先看回执里记录的metadata,发现“按参数结构匹配”在所有误判案例里都命中过;再看注册表,确认两个Agent的tag各不相同——摘要Agent带的是["nlp", "summary"],关键词Agent带的是["nlp", "keyword"]。问题很明确:路由逻辑没有把“调用方未指定capability时是否允许猜测”作为可配置项。

修复方案是三层:

  • 如果调用方传了tag,路由只在该tag集合内选择。
  • 如果调用方传了capability名字,路由只在拥有该能力的Agent中选择。
  • 如果两者都没传,默认拒绝而不是猜测,并返回一个错误码1302。

我把“宁可失败也不猜错”写成了默认行为。因为在Agent协作里,一次静默的“猜错”比一次显式的“失败”要昂贵得多——任务被错误执行后,下游所有Agent都会被带偏。这个策略改动上线后,误判类问题清零。

4.3 坑三:重试风暴导致下游Agent被重复触发

第三个坑来自调用方的重试逻辑。某天深夜,舆情收集Agent的数据库连接池被打满,紧接着所有调用它的任务全部超时。调用方设置的自动重试策略是“最多重试3次,每次间隔1秒”,看起来完全合理。但问题在于:调用方重试的是“整个请求”,而目标Agent的入站适配器在执行任务前没有做去重,导致同一个fetch任务被重复执行了4次,上游搜索API的配额被刷爆。

排查时我花了很长时间才确认根因——因为入站适配器返回的都是200,从Agent-Reach的调用日志层面根本看不出重复。后来是从上游API的计费日志里发现同一个keyword在1秒内出现了4次请求,才顺藤摸瓜找到重试链路。

修复分成两层:调用方侧,新增幂等键机制,每个请求带上唯一的request_id,重试时复用同一个request_id;Agent-Reach的入站适配器侧,维护一个最近N个request_id的缓存,发现重复就直接返回上一次的结果,不再触发真正的执行。这里的关键点是:不要指望所有调用方都自觉遵守幂等规范,在入口处做一层兜底判断是最省心的。

这个坑的经验后来被我写进了Agent-Reach的接入检查清单里:凡是Agent有外部副作用(扣费、写库、调外部API),接Agent-Reach之前必须先确认有没有幂等能力。没有的话,要在适配器这一层补上。

5. 规模化部署时的配置决策与进阶玩法

5.1 多实例与健康检查:别让注册中心变成单点

当Agent数量超过几十个,单机版的Registry内存表就不再够用了。此时我会把Agent-Reach的注册中心拆成独立服务,并用一个简单的etcd做状态同步。每个Agent的注册信息存入etcd,Agent-Reach的多个实例订阅同一个etcd,这样任何一个实例挂了,其他实例还能继续路由。

健康检查也需要升级。心跳续约只代表“进程活着”,不代表“能力可用”。我给每个Agent多加了一个主动探测接口:Agent-Reach的调度器每60秒调用一次Agent的健康接口,连续3次失败就把该Agent标记为“不健康”,从路由表里摘除。这个和注册中心的“过期剔除”是两层不同的机制,前者检测的是能力可用性,后者检测的是进程存活性。

配置上也要有意识地区分这两种状态:

状态含义处理动作
ONLINE注册成功,心跳正常可被路由
SUSPECT健康检查连续失败1-2次路由优先级降低
OFFLINE心跳超时 或 连续3次健康检查失败从路由表移除

这个三态设计很管用——SUSPECT状态让我在Agent半死不死的时候有一个缓冲窗口,可以在流量切走的同时保留数据现场,不会因为一次抖动就把Agent摘下来,也不会让半死的Agent继续被调用。

5.2 Tag路由与灰度发布:同一个Agent,多个版本共存

多版本共存是规模化之后绕不开的问题。我的做法是给Agent名称后面加@版本号,比如summarizer_agent@1.0.0和summarizer_agent@1.1.0,两个版本都注册在同一个Registry里,但带的tag不同——旧版带stable,新版带candidate。

灰度逻辑就落在Router里:当调用方没有显式指定版本时,默认90%的任务路由到stable,10%路由到candidate。等新版本跑满一天观察指标无误后,把新旧版本的tag互换,再跑一轮,最后下线旧版。这个玩法在Agent场景里尤其好用,因为Agent的行为和纯RPC服务不一样,它内部可能有大模型推理,输出有随机性,绝对不能直接全量切换。

Agent-Reach在路由决策时会把“命中哪个版本”写进回执的metadata,所以灰度期间收集调用反馈做对比验证非常方便,不用额外埋点。这也是我坚持在路由层保留tag机制的原因——它天然解决了灰度发布的需求。

5.3 进阶配置:超时梯队、熔断与接入侧限流

最后一个让我觉得“有Agent-Reach才算真正稳定”的功能组是三件套:超时梯队、熔断、限流。

超时梯队的意思是不再使用一刀切的30秒超时,而是根据能力类型分级:普通查询类能力默认10秒,推理类能力默认30秒,重任务类能力默认五分钟。调用方传参时可以覆盖默认值,但覆盖必须走显式参数,防止由于误配置导致超时形同虚设。熔断则是为每个目标Agent维护一个失败率滑动窗口:最近10次调用中失败5次,该Agent自动熔断60秒,期间路由直接跳过它并返回调用方“暂不可用”。这个状态恢复是渐进的,不是一次全开。

限流的接入侧实现放在Agent-Reach的统一入口:每个调用方(按API key区分)拥有独立的令牌桶,每秒放行N个请求,超出部分直接拒绝。这个设计是因为我踩过不加限流的坑——某个Agent响应慢时,调用方会疯狂重试,把本就拥挤的Agent直接打垮,形成螺旋崩溃。有了入口限流,这条崩溃链路从源头就被切断了。

# 超时梯队配置示例 "timeout_policy": { "default": 30, "overrides": [ {"tag": "quick-query", "timeout": 10}, {"tag": "heavy-task", "timeout": 300} ] } "circuit_breaker": { "window_size": 10, "failure_threshold": 5, "recovery_seconds": 60 } "rate_limit": { "mode": "token_bucket", "capacity": 50, "refill_per_second": 10 }

这套配置放到生产之后,我观察到的最大变化是:Agent层的异常不再直接传导到业务调用方了。以前是某个Agent一抖,整个调用链跟着抖动;现在是Agent-Reach在这一层把抖动吸收掉,该熔断就熔断,该限流就限流,该转异步就转异步。调用方体验到的,只是偶尔一次“稍后再试”,而不是“整个系统挂了”。

6. 接入Agent-Reach的检查清单:给新Agent的体检项目

每个新Agent接入Agent-Reach之前,我都会让团队走一遍检查清单,这几条全是踩坑换来的硬经验:确认Agent是否具备幂等能力;确认Agent对外暴露的超时时间是否大于触达层的超时时间;确认Agent的健康检查接口会不会因为流量高而误报;确认Agent的入站适配器是否会吞异常而不上报。尤其是幂等这一条,几乎决定了你在生产环境的睡眠质量。

清单跑完之后,新Agent注册进Agent-Reach,我会先观察它两天的路由统计和错误率,再把它开放给所有调用方。这个“体检+观察期”的模式,让我后面几乎没再遇到过“新Agent上线把整个系统带崩”的事故。