☰
多Agent协作如何实现?注册、路由与协议适配的Agent-Reach架构实战
2026/10/6 4:13:05 网站建设 项目流程

从标题“Agent-Reach”开始说。这不是某个圈内工具包的名字,而是我最近在调试AI Agent系统时自己起的一个内部代号。简单说,它解决的就是一个很具体的问题:当你手上有多个Agent(有的管代码生成,有的管数据分析,有的负责外部API调用),它们之间怎么互相触达、怎么编排调度、怎么避免“各干各的、最后拼不起来”。我把它做成一个轻量级的Agent触达与路由中间层,代码量不大,但把这套东西理清楚之后,多智能体协作的复杂度确实降下来不少。

这篇东西不是论文,是我实际落地过程中的项目复盘。适合正在搞多Agent系统、又觉得“把Agent串起来”这件事没有头绪的开发者看,也适合刚接触Agent编排、想搞清楚“注册、发现、路由、上下文传递”这几件事到底是什么关系的朋友。我会把设计思路、核心细节、实操代码、踩坑记录都摊开讲,尽量讲人话。

1. 为什么需要Agent-Reach:多智能体协作的本质问题

1.1 多Agent系统的通病:能造但串不起来

这两年玩Agent框架的人不少,LangChain、AutoGPT、各种自研Agent都试过一轮之后,你会发现一个尴尬的现象:单跑一个Agent,效果还挺像回事;一旦让两三个Agent协作,问题就成堆地冒出来。最常见的情况是,A Agent调用B Agent时,要么硬编码了B的接口地址,要么写死了B的输出格式,B一旦调整,A直接罢工。更麻烦的是,当Agent数量超过三个,谁该调用谁、什么时候调用、结果返回到哪,基本靠人的“记忆”在维护,时间一长完全失控。

1.2 我从通信系统里借来的思路

我做Agent-Reach的核心思路,其实是从传统微服务架构里借过来的。微服务要解决的是服务发现和网关路由,多Agent系统遇到的是同一个问题,只是对象变成了“智能体”。所以我给每个Agent做了三件事:注册自己的能力和地址、声明自己需要什么输入、向统一的路由层上报状态。任何Agent想触达另一个Agent,不直接找对方,而是先找Agent-Reach。

这个设计的好处是,Agent之间彻底解耦。A不需要知道B是HTTP服务还是gRPC服务,不需要知道B的IP和端口,它只负责发出一个“请求意图”,剩下的寻址、协议转换、结果回传,全部由Agent-Reach处理。这就像你打电话不需要知道对方在哪个基站,拨号就行,中间的交由运营商搞定。

1.3 Agent-Reach到底解决了什么

拿我手头的一个实际场景举例。我在做一个内部知识库问答系统,里面有三个Agent:检索Agent负责查文档向量库,生成Agent负责整理答案,查重Agent负责校对。之前的做法是,生成Agent直接调检索Agent的本地接口,查重Agent又直接调生成Agent的临时文件路径,三个Agent绑定在一起,改一个全崩。引入Agent-Reach之后,每个Agent只负责“把自己注册上去”,然后用统一消息格式发请求。检索Agent查完文档,丢回消息队列;生成Agent订阅到内容后生成答案;查重Agent接收到答案做校验。整个链路清清楚楚,谁崩溃了都不至于拖垮全链路。

2. Agent-Reach的架构设计与核心模块拆解

2.1 五层结构的取舍说明

Agent-Reach的整体结构,我分了五层,网上很多文章爱画复杂架构图,但我个人偏好朴素一点。五层分别是:接入层、注册中心、路由引擎、协议适配层、监控层。每层职责单一,不玩什么微内核、插件化,够用就行。

接入层负责统一接收各Agent发来的消息,不管Agent是用HTTP轮询、WebSocket长连接,还是直接写消息队列,只要能投递进来就行。注册中心是Agent-Reach的心脏,它记录着每个Agent的名字、能力标签、健康状态、调用地址。路由引擎拿到一个请求后,根据注册信息决定把消息送去哪个Agent。协议适配层解决格式不一致问题,有的Agent返回JSON,有的Agent返回纯文本,有的Agent只接受特定Schema,这层负责翻译。监控层记录每次调用的耗时、成功率、失败原因。

2.2 注册中心的微妙设计

注册中心看起来简单,实际是坑最多的地方。第一个坑是,Agent的能力描述不能太粗粒度。比如一个Agent号称“会写代码”,但具体是写Python还是C++、擅长写前端还是后端,注册信息里不写清楚,路由时就只能靠猜。我最后用的方案是,注册信息里带能力标签列表,每个标签带权重,比如code_generator: 0.9、python_expert: 0.8、frontend: 0.1。路由引擎做匹配时按加权打分。

第二个坑是健康检查不能只看进程是否活着。Agent进程活着不代表它现在能干活,有可能它正在处理一个超长任务,资源已经占满;也有可能它陷入了死循环,但心跳还在发。我在健康检查里加了一个“忙闲度”字段,由Agent自己上报,取值范围0~1,0表示完全空闲,1表示满负载。路由引擎优先选择空闲度高的Agent。

2.3 为什么不用中心化消息总线

做架构时大家都会想:干脆上Kafka或者RabbitMQ算了。我为什么不用?因为多Agent系统的对话模式以“一来一回的请求响应”为主,不是纯事件流。如果硬套消息总线,每个请求都得自己维护消息ID、关联ID、回执超时,代码复杂度直线上升。Agent-Reach采用的是一种轻量的请求路由模式:一个请求进来,路由引擎给它分配一个RequestID,通过同步HTTP或异步回调两种方式把结果送回。同步适合对延时有强要求的调用,比如生成Agent问检索Agent“文档里有没有提到分布式事务”,必须立刻拿到结果;异步适合捋羊毛式的后台任务,比如查重Agent批量校对历史答案,丢到队列里慢慢跑就行。

3. 核心细节解析:注册、路由、协议三块硬骨头

3.1 Agent注册的字段设计与扩展性

注册信息我定义了一个JSON结构,第一版几个关键字段至今没变过。Agent名必须全局唯一;能力标签是数组,每个标签带权重;入口地址是Agent-Reach实际转发请求时的目标URL;输入Schema声明这个Agent能接收什么样的结构,用JSON Schema描述,这一步很多人会偷懒不写,后来必然后悔。因为你没法保证所有Agent都用一套入参规范,有了Schema至少路由层能在转发前做一次参数校验,无效请求直接返回400,不浪费Agent资源。

注册接口我设计成幂等的,Agent启动后反复注册不会产生脏数据,相同Agent名重复注册时只更新时间戳和地址。有个细节是:Agent下线时一定要调用注销接口,别指望健康检查发现超时自动清理。因为Agent可能只是卡顿,不是真的死了,健康检查的误杀率比你想象的高。

3.2 路由策略:从随机到加权最少连接

路由引擎最初的策略是随机选一个可用Agent,第一个版本跑通后,问题很快暴露。三个Agent处理能力差距太大,随机路由导致强Agent闲死、弱Agent忙死。然后我改成轮询(Round Robin),但又有新问题:有的Agent一次请求只要200毫秒,有的Agent一次请求要10秒,轮询照样不均衡。

后来我参照负载均衡里的“最少连接数”思路,定制了加权算法。每个Agent的连接数是实时统计值,权重则来自注册时上报的忙闲度和历史成功率。实际效果比轮询好很多,虽然做不到完美均衡,但至少不会出现某个Agent长期空转的情况。路由策略最好做成可插拔的,方便按场景更换,不用重新编译代码。

3.3 协议适配:别让Agent各说各话

最让人头疼的往往不是路由,而是各Agent返回的数据格式千奇百怪。有的Agent返回的是结构良好的JSON,有的返回Markdown文本,有的干脆直接返回一段带格式的字符串。协议适配层处理这件事,但我不建议用正则去解析返回结果,太脆弱。我的做法是:在Agent接入Agent-Reach前,先由开发者为每个Agent写一个“适配器”(Adapter),适配器负责把Agent原生的输出转换成统一的消息格式。

比如检索Agent返回的结果里,doc_id可能在两层嵌套之下,适配器负责把它提取到统一字段source_id。虽然一开始给每个Agent配适配器的工作量不小,但长远看这才是最稳定、最清晰的方案。等Agent数量增多,这个适配器层会成为你维护系统可靠性的重要屏障,值得投入。

4. 实操:我从零实现Agent-Reach的过程记录

4.1 环境、目录和依赖

讲讲我在项目里具体怎么做出来的,方便你复现。我的环境是Python 3.10,主要用到FastAPI、APScheduler和一个简单的内存字典充当注册表。实际生产环境可以换成Redis或者Consul,但阶段一用内存足够。目录结构很简单:

agent_reach/ ├── main.py # 入口,FastAPI服务 ├── registry.py # 注册中心(内存实现) ├── router_engine.py # 路由引擎 ├── adapter.py # 协议适配器基类 ├── monitor.py # 监控记录 └── config.py # 配置参数

依赖就三样:fastapi、uvicorn[standard]、apscheduler。不需要额外数据库,注册信息丢了能重建,消息不落盘。真实项目如果要求审计,再考虑引入消息持久化。

4.2 注册中心实现细节

注册中心的核心数据结构是一个字典:键为Agent名,值为字典,包含能力标签、入口地址、下游Schema、状态、权重。我的实现里额外放了一个last_seen时间戳,用来做超时判断。写入注册信息使用写锁,避免多线程环境下数据不一致。

# registry.py import time import threading class AgentRegistry: def __init__(self): self._agents = {} self._lock = threading.RLock() def register(self, agent_name, entry): with self._lock: entry["last_seen"] = time.time() entry["health"] = "online" self._agents[agent_name] = entry return True def unregister(self, agent_name): with self._lock: self._agents.pop(agent_name, None) def heartbeat(self, agent_name, busyness): with self._lock: if agent_name not in self._agents: return False self._agents[agent_name]["busyness"] = busyness self._agents[agent_name]["last_seen"] = time.time() return True def get_available_agents(self, capability): with self._lock: now = time.time() results = [] for name, info in self._agents.items(): if now - info.get("last_seen", 0) > 30: continue # 超时视为不可用 if info.get("health") != "online": continue tags = info.get("capabilities", {}) if capability in tags: results.append((name, info)) return results

这版代码不算复杂,核心点在于:注册时对capabilities的格式做了约束,必须是字典而不是列表。为什么?因为路由时需要权重,列表没法直接表达“擅长程度”。{“code_generator”: 0.9, “python_expert”: 0.8}比[“code”, “python”]信息量大得多。

4.3 路由引擎的实现:得分与选择

路由引擎是路由的决策部分。每次请求都带一个目标能力名,比如doc_retriever,Agent-Reach在注册表中取所有具备该能力的Agent,然后给每个Agent计算综合得分。综合得分由三部分构成:能力权重占比、历史成功率、当前空闲度。加权公式是我现场调的,先用0.5权重做基座,后续根据线上表现再微调。

# router_engine.py import random CAPABILITY_WEIGHT = 0.5 SUCCESS_RATE_WEIGHT = 0.3 IDLE_WEIGHT = 0.2 def score(agent_name, info, capability): cap_score = info["capabilities"].get(capability, 0) success_rate = info.get("success_rate", 0.9) busyness = info.get("busyness", 0) idle_score = 1 - busyness total = ( CAPABILITY_WEIGHT * cap_score + SUCCESS_RATE_WEIGHT * success_rate + IDLE_WEIGHT * idle_score ) return total def route(registry, capability): candidates = registry.get_available_agents(capability) if not candidates: raise NoAgentAvailableError(capability) # 按得分排序,取最优结果,随机因子打破并列 candidates.sort(key=lambda x: score(x[1], capability), reverse=True) top_score = score(candidates[0], capability) top_agents = [c for c in candidates if score(c, capability) >= top_score - 0.01] return random.choice(top_agents)[0]

这里加随机因子的原因很好理解:如果两个Agent得分相同,每次都选第一个,会导致另一个Agent没有新请求,从而被误判为闲置甚至下线。保留随机性有助于整个系统在动态环境中的鲁棒性。

4.4 协议适配器:一次统一,处处收益

适配器这层我定义了一个基类,所有Agent适配器都继承它,子类只需要实现to_canonical()和call_agent()。call_agent负责真正执行请求,to_canonical把Agent返回的任意格式规范化为统一数据结构。

# adapter.py from abc import ABC, abstractmethod class BaseAdapter(ABC): @abstractmethod def call_agent(self, payload: dict, config: dict) -> dict: """向Agent实际发起请求并返回原始响应""" pass @abstractmethod def to_canonical(self, raw_response) -> dict: """将原始响应转换为统一消息格式""" pass def handle(self, payload, config): raw = self.call_agent(payload, config) return self.to_canonical(raw)

拿检索Agent的适配器举例。原始响应是带嵌套结构的:{“result”: {“docs”: [{“_id”: “x123”, “text”: “...”}]}},适配器只取需要的字段,转成统一格式:

class RetrieverAdapter(BaseAdapter): def to_canonical(self, raw_response): docs = raw_response["result"]["docs"] return { "sources": [ {"source_id": d["_id"], "content": d["text"]} for d in docs ] }

这样写的优势在于,AI生成Agent消费统一格式的数据时,完全不关心原始返回值长什么样,后续替换检索Agent后端也不影响上层逻辑。

4.5 完整请求流程串一遍

我把整个流程用一个实际请求穿起来。假设用户问“什么是CAP定理”,请求先被总控Agent接收,它需要找检索Agent查文档。总控Agent向Agent-Reach的POST/v1/invoke发请求,body里带capability为doc_retriever、payload里带查询关键词。Agent-Reach先做入参校验,然后路由引擎选出一个检索Agent,把payload转发到检索Agent的接口。检索Agent返回文档列表,适配器把原始响应转成统一格式,Agent-Reach把结果回给总控Agent。这一整个过程中,总控Agent完全不知道检索Agent的请求地址和内部实现。

异步使用场景又不一样。比如查重Agent在处理完一条答案后,不想干等结果,就向Agent-Reach丢一个fire-and-forget类型的请求,Agent-Reach收到后先把请求入队,路由到目标Agent处理完再回写结果。这个模式在低频、可延迟的任务里节省大量等待时间,适合做后台批量处理。

5. 上线后踩过的坑:常见问题排查与避坑实录

5.1 注册信息“漂移”与配置冲突

最经典的坑是:Agent明明在跑,Agent-Reach却路由不到它。查了半天,发现Agent的注册名在某次配置变更后变了,旧Agent名留下一个僵尸注册状态,心跳又误报仍活跃,路由引擎就把请求打到了旧地址上,必然失败。这个问题光靠打印日志很难定位,最后是在注册中心旁边加了一个“注册信息摘要”的调试接口,才能看到全部Agent状态。

此后我养成了一个习惯:Agent配置变更时,第一件事就是注销旧名、重新注册新名,并且把Agent名当作环境变量显式传给服务进程,不写死在代码里。环境的一致性必须用配置管理工具统一锁定,否则每个开发本地环境注册到同一中心,立刻出乱子。

5.2 路由到错误Agent的原因:能力标签权重问题

有一次用户反馈,查文档的请求竟然被路由到了“摘要生成Agent”,答非所问。查日志发现,摘要生成Agent注册时把doc_retriever的权重误设成0.7,而真正的检索Agent权重是0.9,按理说不应该选错。问题出在打分函数里,成功率和空闲度两项权重加起来占比0.5,摘要Agent的调用成功率一直很高,空闲度又接近满值,综合得分超过了检索Agent。这提醒我:权重调参是动态的,需要持续监控,不能设一次就不管了。

修正方式是给每个能力标签加了“硬性校验”:只有标签权重超过0.8的Agent才具备候选路由资格,否则直接过滤掉。虽然损失了一点灵活性,但换来的是稳定性的显著提升。

5.3 调用超时与幂等投递

Agent-Reach默认超时时间是30秒,但AI生成Agent有时候一张嘴就憋一分钟,调用方和路由层一定要处理好超时后重试的问题。我当时踩了个坑:重试时直接重新投递原始请求,结果下游Agent把同一用户的同一问题处理了两遍,产生重复内容。后来在消息头中加了X-Request-ID,每次重试都携带同一个ID,Agent端收到重复ID直接丢弃第二次请求。这是幂等设计的基本功,必须一开始就做对。

5.4 上下文切分的细化

把Agent调用链路拉长之后,上下文丢失的问题越来越突出。最初是一整段对话历史原封不动地透传给所有Agent,效果不好,因为很多Agent根本不需要那么多历史信息,反而被无关上下文干扰。

我后来的做法是:在路由时机上按需求切分上下文。检索Agent只需要问题本身和必要的知识域限定;生成Agent才需要更大的对话上下文和用户偏好。Agent-Reach在设计时允许每个请求携带context_scope字段,Agent在适配器层拿到对应的切片。这个设计对AI类Agent尤其重要,因为上下文过长不仅浪费token,还影响生成质量。

5.5 监控与告警:不做事后诸葛亮

Agent-Reach的监控层要记录的东西,和传统API网关的指标不太一样。除了常规的QPS、延迟、错误率,我额外记录了两项:Agent调用成功率变化趋势和各Agent的忙闲度变化。忙闲度曲线的价值在于能提前发现Agent性能退化,比如某个Agent的忙闲度长期超过0.8,说明它处理不过来,应该提前扩容。

告警规则我设定成:连续三分钟成功率低于98%,或者单次请求平均延迟超过5秒,就触发告警。没有一开始就把告警阈值设得过严,否则群里全是告警消息,大家基本会选择静默,失去了告警的价值。

6. Agent-Reach的后续扩展思路

做一个可用的Agent调度层,已经解决了眼前问题,但还有一些后续方向值得探索,我也在继续尝试。

第一是接入更多Agent类型。现在适配器是手写的,如果一个新Agent只有一个OpenAPI文档,能不能自动生成适配器?尝试过根据OpenAPI Schema动态映射,但复杂格式转换还是需要人工确认,目前只能做到半自动。

第二是会话级路由策略。目前路由是单次独立的,没有考虑用户上下文的一致性。比如同一个用户连续询问三次,前两次路由到一个AgentA,这次可能路由到AgentB,而B没有上文的记忆,回答就“断片”了。基于会话的亲和性路由(Sticky Session)是明显的优化方向,让同一会话尽量命中同一批Agent。

第三是全链路追踪与离线回放。现在排查问题主要靠日志,链路追踪还做得比较原始。如果加上Trace ID在Agent间传递,就能按一次完整请求串联所有Agent日志,快速定位是哪一层出了偏差。这是进入生产环境前,我会优先补上的能力。

实际体会

把Agent-Reach从零搭起来的过程中,我最深的一个体会是:多智能体系统目前最大的瓶颈,不在于单个Agent的智能水平,而在于Agent之间如何高质量、高效率地协作。没有调度层,几个Agent还能靠硬编码凑合;数量一多,复杂度就成指数级上升。Agent-Reach更像是给Agent们装上了一个“通信中枢”,把无序的直连变成有序的路由。

另一个体会是,这类基础组件不要一上来就追求“大而全”的功能,最好是从最核心的注册、发现、路由、协议适配开始,先跑通一条链路,再慢慢补齐监控和扩展能力。这样你始终有一个能用的系统,而不是一套精美的架构图。Agent-Reach这个代号本身是临时的,但解决Agent触达问题的思路,会一直在后续项目里沿用下去。如果你也在搞多智能体协作,不妨从Agent的注册和路由先入手,这个切入点性价比最高。

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

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

立即咨询