Agent-Reach这个名字,第一次看到的人容易摸不着头脑——Agent我懂,Reach是什么鬼?其实它解决的是多智能体系统里一个极其普遍、却又常被忽略的问题:触达。你可以想象这样一个场景:系统里跑着客服Agent、订单查询Agent、优惠计算Agent、库存预测Agent,每一个单独拎出来都挺聪明,但把它们放到同一个业务链路上,麻烦就来了——一个新上线的Agent怎么让其他Agent知道?用户抛过来一个模糊请求,到底该让哪个Agent去接?Agent正在高负载或者已经下线了,分发系统知道吗?这些问题统称为Agent之间的“触达”。我这段时间完整落地了一遍Agent-Reach的方案设计,把能力注册、意图路由、消息投递、结果回传这一整条链路都跑通了,踩了不少坑,也沉淀了一套可以复用的方案。这篇文章就把整个拆解过程和实操细节全部摊开,适合正在做多Agent编排、或者被“Agent一多就乱”困扰的团队参考。
1. 项目整体设计与思路拆解
1.1 Agent一多,“触达”为什么会失效
先说一个最直观的现象。Agent数量少的时候,两三个Agent靠if-else就能调度清楚,但规模一旦上来,问题立刻变味。我见过一个实际业务里的调度代码,光路由逻辑就堆了上千行,全是散装的if条件:如果关键词包含“退款”就走客服Agent,如果包含“库存”就走库存Agent,再叠加什么会员等级、地域、时间段的判断,简直是蜘蛛网。这种硬编码路由至少有四个致命伤。
第一是能力不透明。老Agent根本不知道新Agent能干什么,新能力上线后要人工通知、人工改代码,业务方忘了同步就永远没人调用它。第二是状态不同步。Agent本身是有运行状态的,可能在过载、在维护、甚至已经宕了,但路由方对此一无所知,照样把请求往那儿塞。第三是语义缺失。用户的话往往含糊,不是每个请求都带着“退款”“库存”这种明确关键词,硬编码路由对模糊表达基本无能为力。第四是回传混乱。请求发给Agent之后,执行到哪一步了、成没成功、失败原因是什么,全链路没有统一跟踪,出了问题只能靠翻日志猜。
这里有个特别值得强调的点:传统开发里模块间的调用关系是编译期就确定了的,A方法调B方法,写死就完了。但Agent是运行时实体,它的能力边界、健康状态、上下文能力都是动态变化的。静态的路由思路天然就不适配动态的Agent体系,这就是需要Agent-Reach这类触达层的原因。
1.2 三层架构:把“触达”这件事拆干净
我一开始设计的思路很简单,想做一个大网关,所有请求进来,由网关统一调Agent。但写着写着就发现不行——网关里要干的活太多了:既要理解用户意图,又要管Agent的注册表,还要处理消息投递、重试、结果回传,全塞一起的话,任何一个模块变更都要重新发布整个网关,耦合太严重。后来参考微服务注册中心的设计思路,把架构拆成了三层:触达层、调度层、通道层。
触达层干的事比较纯粹:接收外部请求,做意图识别和初步校验,然后把“要干什么”这个语义描述交给调度层。它本身不依赖任何具体Agent的信息,所以可以随便横向扩容。调度层是整个方案的大脑,它维护Agent注册表、能力目录、负载状态,根据触达层传来的意图做路由决策,选出最合适的Agent列表。通道层则负责把任务真正投递出去,管理HTTP调用或MQ消息的发送、超时重试、结果回传,把“调谁”和“怎么调”彻底隔离。
这三层的数据流是单向清晰的:请求先进触达层,触达层拿着语义描述去问调度层“谁能干这活”,调度层查注册表、打分排序后返回目标Agent列表,触达层再让通道层去执行投递,Agent处理完的结果同样通过通道层回传。每一层只对上一层负责,修改任何一层都不影响其他层,这在实际维护中的收益非常大。
1.3 为什么不直接用消息队列硬扛
有人可能会问,RabbitMQ、Kafka这些消息中间件不是已经能解决投递问题了吗,何必再造一套?我的回答是:消息中间件解决的是“消息不丢不重”的投递问题,但它完全不理解“语义”。你往Kafka里丢一条消息,它根本不知道这条消息应该由哪个消费者处理,还是得靠你自己写路由规则。而且消息队列的方案里,Agent的注册发现、能力匹配、负载感知、意图理解这些核心逻辑一样都省不掉,等于该造的轮子一个没少,还得额外维护一套MQ基础设施。
我把两个方案的核心差异整理成了一个对比表,方便大家直接参考。
| 对比维度 | 纯消息中间件方案 | Agent-Reach触达层方案 |
|---|---|---|
| 语义理解 | 不支持,消息就是字节流 | 支持意图识别与语义匹配 |
| 能力发现 | 无,需要手动写清消费者映射 | 自动注册、动态发现能力 |
| 负载感知 | 无,消费能力全靠消费者自己声明 | 记录负载状态并参与路由打分 |
| 兜底策略 | 需自研 | 内置降级、多Agent编队 |
| 实现复杂度 | 低,但路由逻辑全堆在外面 | 中等,但逻辑内聚、可复用 |
所以我的结论很简单:如果只是做异步任务解耦,MQ完全够用,别折腾。但如果核心痛点是Agent之间的发现与路由,那必须有一个懂语义的触达层,Agent-Reach的思路就是针对这个问题设计的。
2. 核心模块解析与关键技术细节
2.1 能力注册表:Agent的“身份证”怎么设计
整个Agent-Reach方案的地基,是能力注册表。每个Agent上线时必须先在系统里登记,之后调度层才能按“能力”而不是按“名字”来找它。这块设计得不好,后面路由再花哨也是空中楼阁。
我设计的注册信息包含几个关键字段。agent_id是全局唯一标识,endpoint是Agent的实际调用地址,capabilities是一组能力标签,比如“订单查询”“优惠计算”“退款处理”,这是路由匹配的主维度。除了标签,我还要求每个Agent提交一段自然语言能力描述,比如“我能查询订单状态,支持按订单号、手机号查询,能判断是否超时发货”,这段描述会在意图识别阶段做语义相似度计算,弥补标签过硬的缺陷。另外还有几个动态字段:负载水位(当前并发数/最大并发数)、健康状态(在线/忙碌/离线)、可用时段、上下文窗口大小。
注册和续约的流程借鉴了服务注册中心的心跳机制。Agent启动时调用注册接口,把上面这些信息登记进来,然后每隔一段时间(我用的30秒)发送心跳续约。调度层有一个后台任务在盯着所有Agent的最近心跳时间,超过90秒没收到心跳就标记为离线,并从候选路由池里摘掉,等它恢复心跳后再自动加回来。这套机制保证路由决策永远基于Agent的最新状态,而不是停留在它上线那一刻的快照。
这里有个我特别想强调的细节:能力描述一定要让业务Agent的开发方自己写,不要代劳。我之前试过让平台方统一给Agent写描述,结果写出来的全是标准化套话,语义相似度计算效果很差。后来改成让每个Agent的开发方用自己的话描述能力,反而匹配得特别准——因为只有开发方自己最清楚Agent能干什么、擅长干什么。
2.2 意图识别与路由打分:怎么让请求找到对的Agent
触达层收到请求后,第一步是意图识别。我的做法不是一上来就做复杂的LLM分类,而是先用一个轻量的分类模型提取意图标签和关键实体,比如“帮我查一下上周买的手机到哪了”识别出意图是“物流查询”、实体是“手机”,然后再把“意图标签 + 原始文本”一起送进调度层做路由。
调度层的路由决策分两步:候选集过滤和打分排序。过滤阶段先把不满足硬性条件的Agent排除掉——比如意图标签完全不匹配的、健康状态离线的、不在可用时段的、上下文窗口装不下当前请求的。排除之后剩下的Agent进入打分环节,我用的是一个加权公式:
score = 技能相似度 × 0.5 + 历史成功率 × 0.3 + 负载余量 × 0.2
技能相似度是意图标签和Agent能力标签的匹配程度,加上能力描述的语义相似度辅助;历史成功率是过去24小时这个Agent处理同类请求的成功比例;负载余量由Agent当前并发数和其声明的最大并发数计算而来。三个维度加权汇总后,选得分最高的一个或者前几个作为目标。如果得分都低于一个阈值(我设的0.35),就触发兜底策略,返回提示信息或者转人工处理,而不是硬塞给一个不合适的Agent——硬塞的结果往往是答非所问,比不答更糟。
可以打个比方,这套机制就像外卖平台点餐:先根据你选的美食分类筛掉不相关的店铺,再综合评分、配送距离、当前排队情况排序,最后选综合体验最好的那家下单。单看评分高没用,还得看它忙不忙、送不送得到。
2.3 触达通道与消息协议:投递不丢不重不漏
路由决策只解决了“把任务给谁”,接下来还面临“怎么给”的问题。通道层我做了两种适配:如果Agent提供的是HTTP接口(绝大多数自己开发的业务Agent都是这种),就用HTTP调用的方式同步或异步返回;如果Agent是订阅MQ消息的(一般是外部系统接入的场景),就把任务封装成消息投递到指定队列。为了统一屏蔽差异,我把两种方式都封装成了同一个调用接口,对外只暴露send(agent_id, payload)这个方法。
投递这块有个老生常谈但特别容易栽跟头的问题:超时与重试。我给HTTP调用设了三档超时:连接超时3秒、读超时15秒、业务处理超时30秒。为什么分三档?因为它们失败的含义完全不一样,连接超时说明网络有问题,可以直接快速失败换一个Agent;读超时说明Agent可能在处理但在慢慢来;业务超时说明Agent已经接收但流程卡住了。重试的时候不能一概而论,读超时还可以等等再重试一次,业务超时重试往往没有意义,反而可能让Agent重复执行。另外所有投递消息都必须带一个幂等键,业务Agent端要按这个键做去重,防止网络重试导致同一任务被执行两遍。
结果回传我用的是一条独立的回调通道。Agent执行完后把处理结果按约定格式POST回触达层,如果Agent在调用时携带了request_id,回传时会带上同一个request_id,这样整条链路就能串起来做监控跟踪。这一块看着简单,但如果没有从一开始就约定好协议,后期对接外部Agent时非常痛苦。
3. 实操过程:从零搭建Agent-Reach核心组件
3.1 环境准备与最小工程结构
我实现Agent-Reach用的是Python 3.10 + FastAPI,选FastAPI是因为业务侧的Agent也大多是FastAPI写的,做集成测试方便。注册信息临时放内存字典,生产环境可以替换成Redis或者MySQL,这个不影响核心逻辑。
最小工程结构我建议这样组织:
agent_reach/ ├── agent_reach/ │ ├── __init__.py │ ├── register.py # Agent注册与心跳管理 │ ├── router.py # 意图路由与打分排序 │ ├── channel.py # 通道层:HTTP投递/回传 │ ├── gateway.py # 触达层:FastAPI接口 │ └── model.py # 数据模型(注册信息、任务包) ├── agents/ │ ├── order_agent.py # 示例Agent:订单查询 │ ├── promo_agent.py # 示例Agent:优惠计算 │ └── faq_agent.py # 示例Agent:常见问题解答 └── tests/ └── test_route.py # 路由逻辑单元测试生产级工程还会加缓存、监控、配置中心这些,但核心链路就靠上面六个模块,先把主链路跑通最重要。
3.2 核心代码:注册中心与路由分发
先看注册模块。我用一个AgentRegistry类维护所有Agent的注册信息和状态,同时提供注册、心跳续约、状态查询三个接口。核心逻辑是依赖一个loaded_at字段做过期淘汰。
import time import threading from typing import Dict, Optional class AgentRegistry: def __init__(self): self._agents: Dict[str, dict] = {} self._lock = threading.Lock() self._offline_timeout = 90 # 秒 def register(self, agent_info: dict) -> str: agent_id = agent_info["agent_id"] with self._lock: now = time.time() agent_info["updated_at"] = now agent_info["status"] = "online" self._agents[agent_id] = agent_info return agent_id def heartbeat(self, agent_id: str) -> bool: with self._lock: if agent_id not in self._agents: return False self._agents[agent_id]["updated_at"] = time.time() self._agents[agent_id]["status"] = "online" return True def get_online_agents(self) -> list: now = time.time() online = [] with self._lock: for aid, info in self._agents.items(): if now - info.get("updated_at", 0) < self._offline_timeout: info["status"] = "online" online.append(info) else: info["status"] = "offline" return online这里有一个容易忽略的点:过期淘汰不能只在查询时做,还要有一个后台线程定期把离线Agent踢掉,否则Agent如果永久下线,注册表里的脏数据会一直占用内存,而且可能导致查询老返回一个已经不存在的东西。我在项目里是用一个daemon线程每30秒扫描一次,把超时的Agent从字典中移除,让健康检查这层逻辑彻底交给触达层来保证。
再看路由打分模块,这是整套方案的核心。我实现了一个Router类,根据传来的任务意图在在线Agent里做过滤和打分,然后返回排名列表。
import numpy as np class Router: def __init__(self, registry: AgentRegistry): self.registry = registry def dispatch(self, intent: str, context: dict) -> list: agents = self.registry.get_online_agents() candidates = [] for agent in agents: # 过滤阶段:硬性条件不满足直接跳过 if not self._match_capability(agent["capabilities"], intent): continue if agent.get("current_load", 0) >= agent.get("max_load", 10): continue score = self._score(agent, intent, context) candidates.append((score, agent)) candidates.sort(key=lambda x: x[0], reverse=True) return [(score, agent["agent_id"]) for score, agent in candidates[:3]] def _match_capability(self, capabilities: list, intent: str) -> bool: # 标签完全匹配 if intent in capabilities: return True # 同义词匹配 for cap in capabilities: if self._similarity(intent, cap) >= 0.6: return True return False def _score(self, agent: dict, intent: str, context: dict) -> float: sim = self._similarity(intent, " ".join(agent["capabilities"])) success_rate = agent.get("success_rate", 0.8) load_ratio = 1 - agent.get("current_load", 0) / max(agent.get("max_load", 10), 1) return sim * 0.5 + success_rate * 0.3 + load_ratio * 0.2 @staticmethod def _similarity(text_a: str, text_b: str) -> float: # 实际项目中这里用的是文本向量余弦相似度 # 简单演示时用字符集合Jaccard近似 set_a, set_b = set(text_a), set(text_b) if not set_a or not set_b: return 0.0 return len(set_a & set_b) / len(set_a | set_b)这段代码里的_similarity在生产环境我会换成预训练模型的向量余弦相似度,演示用Jaccard只是把雏形跑通。负载余量参与打分这个设计,我实际用了很久才发现它的价值:如果不考虑负载,高并发场景下得分最高的Agent会被连续打满,其他Agent干闲着,整体吞吐反而下降。加上负载权重之后,路由会自动做负载均衡,不需要额外写一套流量分配逻辑。
3.3 端到端联调:模拟三Agent协作
工程骨架和核心代码都有了,我用一个贴近业务的小场景做了端到端联调:三个Agent分别是订单查询Agent、优惠计算Agent、常见问题Agent。用户的一条请求进来:“我上周买的手机怎么还没发货?另外现在有没有优惠?”
这条请求同时包含两个意图。触达层先拆解成一个主任务:订单查询,和一个附加任务:优惠计算。两个任务串行执行:主任务先由订单Agent查询发货状态,结果里带上订单金额;接着这个金额作为参数传给优惠计算Agent,算出可用的优惠信息;最后把两块结果合并返回。FAQ Agent不参与这次路由,因为两个意图跟它都不匹配。
我在gateway.py里把整条链路串起来,模拟运行时的日志大致长这样:
[触达层] 收到请求: 我上周买的手机怎么还没发货?另外现在有没有优惠? [触达层] 意图识别完成: [订单查询(0.87), 优惠计算(0.79)] [调度层] 订单查询候选: [(0.82, order_agent), (0.51, faq_agent)] [调度层] 优惠计算候选: [(0.79, promo_agent)] [通道层] 投递任务 -> order_agent (request_id: r_20250214_001) [通道层] order_agent 返回: 订单状态=已揽收, 预计送达=2月16日, 订单金额=3899 [通道层] 投递任务 -> promo_agent (request_id: r_20250214_002) [通道层] promo_agent 返回: 可用优惠=满3000减200, 有效期至2月28日 [触达层] 合并结果,返回用户跑通这组联调花的时间比我预期久,主要卡在意图拆解上。最开始我直接把整句话送进意图识别,只识别出一个主导意图,结果优惠计算那条就被漏了。后来改成“先拆解子任务、再逐个路由”的方式,效果才稳定。这个经验分享出来,给要做多Agent链路的同学提个醒:一个用户请求往往包含多个子任务,触达层必须先把任务拆解到底,再做逐个子任务的路由,否则整条链路的效果会大打折扣。
4. 常见问题与排查技巧实录
4.1 Agent注册后永远“不在线”
我在联调时第一次遇到的坑是:Agent启动后调了注册接口,注册表里能看到心跳也在更新,但路由查询时它就是不出现。排查了半天才发现问题出在Agent的endpoint上——我注册的是内网地址,但是路由进程跑在同一台机器上还能通,一旦部署到不同环境就出现网络隔离。后来又发现另一个更隐蔽的问题:我把健康检查路径写成了/health,但Agent实际只实现了/ping,注册时检查健康状态直接失败,Agent被标记为不健康,自动从候选池里摘掉了。
这个经验的核心结论是:注册信息里的endpoint和健康检查路径,一定要做两次验证。第一次是Agent进程启动后自己验证,第二次是触达层真的发起一次HTTP探测,不能只看注册表的“在线”标记。我后来把checks变成了三重验证:心跳活跃、健康检查探活、最近一次路由是否产生过真实调用。就差最后一重验证没加之前,线上出现过Agent挂着心跳但实际处理能力已经完全退化的情况。
4.2 路由结果不稳定:同一请求打到不同Agent
还有一个典型问题是:一模一样的请求,过一段时间再发,路由结果变了。这其实不一定算bug,因为负载权重本身就在动态变。但如果出现“同一时刻同一个请求,两次路由结果不一样”,那就要查是不是哈希遍历顺序不稳定导致的。Python3.7之后字典是有序的,但如果你用了set或者多线程并发改注册表,遍历顺序就可能飘。
这类问题我用了一个很简单的修复:在路由打分前对整个候选列表做一次确定性排序,先按agent_id升序,再按分数降序,这样即使打分分数一样,也能保证同一个请求永远选中同一个Agent。此外,阈值和权重不是拍脑袋定的,我跑了小批量样本看分布情况,发现相似度低于0.35的匹配基本都是错配,才把阈值定在那个位置。
4.3 投递消息丢失与重复执行
通道层最折磨人的问题有两个:消息丢了和消息重复。消息丢的根源几乎都是没有确认机制——投递HTTP请求后,Agent返回了200,但通道层没等响应体完整解析就标记成功,结果响应体在网络中被截断,数据就丢了。改成必须收到完整响应体才确认后,这个问题消失了。
消息重复则来自重试机制。我之前写了一个简单的超时重试,只要Agent没在5秒内响应就重发一次。有一天排查数据重复,发现Agent确实幂等了,但重试者的重试计数没有重置,导致连续触发了五次重试。正确的做法是重试次数上限与幂等键挂钩:同一request_id最多重试两次,不管响应多慢都绝不重发第三次,同时Agent端根据request_id去重。这两端配合,才能既保证可用性又保证一致性。
4.4 性能压测与参数调优
最后给一组我在这套方案上跑过的压测数据,方便作为调参的起点。我的环境是三台服务器,触达层两节点,调度层单节点,三组Agent各单副本。压测工具用locust,模拟用户请求总量10000,峰值QPS控制在500左右。
| 场景 | 平均耗时(ms) | P99耗时(ms) | 成功率 |
|---|---|---|---|
| 单意图直路由 | 42 | 89 | 99.96% |
| 双意图拆解路由 | 76 | 158 | 99.82% |
| 带Agent执行时间(模拟200ms) | 286 | 489 | 99.21% |
调参方面,路由缓存TTL我从60秒降到了10秒,耗时会增加一点点(缓存命中率下降),但换来的是Agent状态感知实时性大幅提升,我觉得值。心跳续约间隔30秒没变,但离线判定超时从120秒收紧到90秒,因为通讯正常的情况下60秒内应该能看到活跃心跳。另外,我把注册表的锁从粗粒度的全局锁换成了分片锁,压测下锁竞争导致的耗时从占比12%降到不足3%。如果你们的Agent数量不超过50个,完全不用纠结锁的粒度,等量级上来了再优化也不迟。
最后分享一点个人体会。Agent-Reach这类触达层,本质上是在做“语义层面的服务治理”,它不是给Agent增加业务能力,而是让已有的Agent能力能被更准确、更高效地调度起来。我在落地的过程中最大的一个感触是:先别急着上复杂的路由算法、向量匹配、模型推理,先把“能注册、能探活、能路由、能投递、能回传”这条主链路跑通、跑稳,再逐步引入更聪明的决策逻辑。很多团队一开始就把调子定得很高,结果被底层链路的不稳定拖住了,反而是基础方案先跑通、后面再迭代,更容易出效果。如果你们团队也正在被类似的问题困扰,可以从一个最小可用的注册表加路由服务开始试,跑一个月再回头看,你会发现自己已经离不开这个触达层了。