
做多Agent系统的人十有八九都会遇到同一个问题Agent数量一多协作就变成一场灾难。每个Agent本身业务能力挺强但真正要它们互相配合完成一个完整业务流程时最卡壳的反而是一个看起来很基础的问题——怎么找到正确的Agent怎么把任务准确地触达过去。这个痛点催生了我手上的项目Agent-Reach。简单说它是一套面向多智能体协作的触达与调度框架核心目标是解决Agent之间的任务路由、能力发现和稳定交接。项目早期我们只有5个Agent靠硬编码把所有调用关系写死也能跑通后来业务扩张到几十个Agent整个调用链像一团乱麻我才下定决心做一套通用的触达机制。如果你也在做多Agent系统或者正准备把单Agent能力拆成多Agent协作这篇文章应该能帮你少走不少弯路。1. 为什么需要Agent-Reach多智能体协作的“找人难”问题1.1 从单体程序到多Agent系统问题是怎么冒出来的早期做AI应用大家习惯把能力塞在一个Agent里用户问什么都由这一个Agent处理。但这种单体Agent有天花板业务一复杂提示词恨不得写五千字上下文窗口被一堆工具说明和分支规则占满模型输出开始不稳定。于是很多人开始拆Agent一个负责意图识别一个负责订单查询一个负责退款一个负责外呼通知。拆完之后问题来了假设客服Agent判断用户要退款它怎么知道退款Agent的存在以什么格式把任务交给退款Agent退款Agent忙不过来或者挂掉了客服Agent要不要等这些传统编程里的函数调用、异常处理问题在Agent场景里全部被重新放大了一遍。更麻烦的是Agent不是普通的服务接口它的输入输出是接近自然语言的没法像REST接口那样严格定义入参出参错误处理的复杂度又上了一层。这时就需要一个独立于各业务Agent之外的调度层也就是Agent-Reach存在的根本原因。我最早试过在客服Agent的提示词里把所有Agent写死让它自己决定调用谁。结果不到两周就撑不住了新增一个Agent要改提示词某个Agent接口响应慢一点整个对话流程就开始卡顿还出现过客服Agent发起退款助手呼叫后退款助手回了一句“我不负责这个事”的尴尬场面。这种靠提示词约定协作方式的路子本质上还是把Agent当聊天机器人玩根本扛不住真实业务场景的复杂度和稳定性要求。1.2 Agent触达的三个关键点找得到、找得准、接得快Agent-Reach要解决的问题可以浓缩成九个字找得到、找得准、接得快。找得到指的是每个Agent都要能在注册中心留下自己的名片包括它能处理什么、接口地址在哪、当前是否活着。这是最基础的一层相当于所有Agent在一个公共通讯录里挂了号其他人不用靠猜就知道系统里有谁。找得准说的是任务意图和Agent能力之间的匹配。单纯知道系统里有谁还不够请求进来之后调度层要把自然语言描述的任务意图和Agent名片上的能力描述做匹配。这一步往往决定整个系统的体验上限匹配偏了任务发给不相关的人后面再好的执行能力都白搭。接得快则是稳定性层面的要求。选中目标Agent之后还要用超时、重试、熔断机制保证任务不会挂死在半路保证任务一旦发出去要么有结果要么有明确的失败原因。这三个点放在一起很像一个公司内部的电话总机每个员工入职都要登记分机号和职责来电先找总机转接总机得知道每个部门是干什么的转接过去没人接还得帮忙处理。Agent-Reach干的就是这个总机的活只是把“分机号”换成了Agent的服务地址把“职责”换成了Agent描述文件把“转接”换成了语义路由。2. Agent-Reach的整体设计注册、路由、触达、反馈2.1 核心架构注册中心与Agent描述文件Agent-Reach的架构并不复杂核心由三部分组成注册中心、触达网关、各业务Agent。所有业务Agent在启动时把自己的描述文件上报到注册中心触达网关是对外唯一入口接收上游业务系统的请求解析请求意图查询注册中心里的候选Agent做路由决策然后把任务触达给目标Agent。执行结果再沿着同一条链路回传。这里最关键的设计决策是引入Agent描述文件。每个Agent必须用一份结构化文档描述自己而不是靠人写一段介绍挂在文档里。我用的描述文件大致长这样{ name: RefundAgent, description: 处理退款申请、退换货审核、退款进度查询相关任务, endpoint: http://agent-refund-svc:8081/execute, protocol: http-async, input_schema: { user_id: string, order_id: string, reason: string }, output_schema: { refund_status: string, estimated_time: string }, weight: 10, tags: [退款, 售后, 退换货] }这份描述文件要解决三个问题目标Agent在哪、它擅长什么、它希望收到什么样的输入。有了统一格式触达网关才能在完全不了解业务细节的前提下对所有Agent做无差别的触达。否则每个Agent说自己的一套格式网关就得为每一种Agent写适配代码那就又回到硬编码的老路上去了。在实际落地时描述文件里的description字段比大多数人想象的重要得多。它是后续语义路由的匹配依据写得太笼统会导致路由分不清两个Agent的边界写得太偏会导致该匹配的时候匹配不上。我们后来专门定了一条规矩description里必须包含主责场景、典型动词、典型对象三要素比如“处理退款申请”里的“处理”是动词“退款申请”是对象。这条规矩后来帮了大忙路由准确率提升很明显。2.2 路由策略意图匹配与能力筛选注册中心解决了发现的问题接下来就是路由。Agent-Reach采用两级路由策略第一级硬过滤第二级语义打分。硬过滤的目的是快速缩小候选集合。网关先从任务文本里抽取关键词和领域标签和描述文件里的tags做匹配把完全无关的Agent直接过滤掉。比如任务文本是“用户想退掉昨天买的键盘”关键词命中“退款”“售后”“退换货”候选集合就锁定在RefundAgent和AfterSaleAgent两个上而不是去和几十个Agent逐个做语义计算。第二级语义打分针对缩小后的候选集合做精细化判断。把任务文本和每个候选Agent的description分别做向量化计算余弦相似度得分最高的Agent就是最终触达目标。这里我用的是一套基于文本嵌入的轻量方案没有上特别大的模型因为候选集合已经被硬过滤缩小到个位数不需要全局扫描浅层模型已经够用。两级路由看起来多了一道工序但带来的收益是实打实的一是语义打分只在少量候选里做延迟可控二是硬过滤给业务同学留了一个明确的干预点他们可以通过调整tags来修正路由行为不需要理解向量是什么。这也算是在工程可维护性和模型智能化之间找到了一个平衡点。2.3 协议与反馈触达之后怎么保证任务闭环路由选定了Agent不等于任务就完事了。触达协议和反馈机制是Agent-Reach的另一个核心模块这里的每一个设计都踩过坑。我有两种触达协议同步HTTP和异步消息队列。同步HTTP适合查询类任务比如“查一下订单状态”“查一下退款进度”这类任务本身执行快需要立刻拿到结果回去给用户。异步消息队列适合退款审批、外呼通知这类长耗时或不可丢失的任务。任务进入队列目标Agent消费后执行执行完通过回调接口把结果回报给网关。异步方案里最重要的一件事是幂等。同一个任务因为网络抖动被发送两次退款Agent不能真的退两次款。我的做法是网关生成全局唯一的task_idAgent侧消费前先查重处理过的不再重复执行只把旧结果重新上报。这套机制思路不难但没有它异步链路根本不敢用在真实资金业务上。反馈回路也同等重要。网关里维护一张任务状态表记录每条任务的触达时间、路由结果、Agent执行状态。任务发出后如果没有在预定期限内收到回执网关会把任务重新投递或者把失败原因上报给上游业务方。这相当于给我们自己留了一双眼睛问题出来的时候能快速定位是路由错了、Agent挂了还是网络超时了。3. 核心模块拆解与选型3.1 注册中心的技术选型Redis还是数据库注册中心这个组件很多人第一反应是要不要上Consul、Nacos这类现成的东西。我的习惯是除非系统规模大到几十个Agent、多个机房否则完全没必要。用一个Redis就能解决绝大多数问题而且思路更直观。我在Redis里用Hash结构存Agent元数据key是agent:registerfield是Agent名value是描述文件JSON。同时给每条记录设置一个10秒的TTL每个Agent每3秒发一次心跳刷新过期时间。Agent异常退出时心跳停了TTL一过注册中心自动把它标记为不可用。这套机制在故障转移上天然好用不需要额外的健康检查服务。用数据库可不可以可以但数据库更适合做审计日志不适合做运行时注册。每次注册、每次路由决策、每次任务回执我建议都往数据库里写一条审计日志。因为运维定位问题时最常问的就是“那个时间点为什么路由到了A而不是B”没有日志就只能凭感觉猜。我踩过一个印象很深的坑一开始我只在MongoDB里存Agent状态结果某次Agent批量重启时几百个Agent同时排队去更新自己的状态记录更新并发一高MongoDB延迟飙升连带影响了正常的路由。后来改成Redis Hash加心跳TTL读写都轻量批量重启也一点不慌。所以要我说运行时状态和持久化数据一定要分开各用各的存储别搅在一起。3.2 路由匹配的三种实现路径路由匹配是Agent-Reach里技术含量最集中的部分我梳理下来有三种实现路径按适用程度依次是混合模式、纯规则匹配、纯向量匹配。纯规则匹配最好理解就是关键词映射。任务文本里出现“退款”就路由到退款Agent出现“订单”就路由到订单Agent。这套方案落地快、可控性最强但死穴是自然语言表达太灵活。“我不想买了”“东西不合适”“怎么把钱要回来”这些话里都没有“退款”但表达的都是退款意图。规则匹配处理不了这类情况。纯向量匹配正好反过来用嵌入模型把任务文本和Agent描述向量化计算相似度来路由能处理同义改写但问题是模型质量直接决定路由准头模型没训练过业务短语时结果容易让人摸不着头脑。我试过直接上通用向量模型结果“投诉”和“举报”在向量空间里的距离近得离谱路由经常在这两个场景里互相串门。最终我实际用的是混合模式先规则后向量。规则负责把候选集合从几十个缩小到几个保证匹配的确定性向量只负责在几个候选之间做精确排序发挥语义理解的优势。这套组合兼顾了可控性和灵活性也是我认为最适合大多数中小规模多Agent系统的路由方式。3.3 超时、重试、熔断触达链路的稳定性机制触达链路里我见过太多项目死于没有稳定性兜底。一个Agent响应慢了点整个请求链路就像多米诺骨牌一样被拖垮。Agent-Reach里把超时、重试、熔断这三个机制全部做了进去。超时是第一步。每次触达必须设置明确的上限不能无限等下去。我常用的参数是同步查询类任务超时3秒异步投递类任务超时20秒长任务一律改成异步模式不占同步链路。重试逻辑放在超时之后但重试次数最多两次而且第二次重试必须等第一次失败之后至少500毫秒再做避免两个重试请求同时打到目标Agent。熔断是更高级的兜底。我参照了经典的断路器思想某个Agent连续失败达到阈值后触达网关对该Agent的调用直接快速失败不再继续发请求给它留出恢复时间。等冷却期过后放一个探测请求过去成功了则恢复正常流转失败了继续保持断开状态。这套机制让个别Agent的故障不会拖垮整个系统。那时候我们有个外呼Agent因为合作方接口不稳定每隔一阵就抽风。没有熔断之前一抽风就有几十上百个任务堆在队列里等它响应其他Agent也被拖累。上了熔断之后外呼Agent异常时网关直接降级返回“稍后再试”其他业务完全不受影响。后来才发现稳定性机制不是为了对付常发的故障而是为了把偶发故障的影响半径控制住。4. 实操半小时搭一个可用的Agent-Reach4.1 环境与依赖理论讲完看看怎么落地。我会带你把一个最小可用的Agent-Reach跑起来包含注册中心、触达网关、两个示例Agent整体耗时大概半小时。环境准备很轻量一台装好Python 3.10以上版本的机器一个Redis实例几个Python库。如果你本机没有Redis用Docker跑一个最省事docker run -d -p 6379:6379 --name agent-reach-redis redis:7-alpine依赖装这几个FastAPI提供HTTP接口uvicorn做服务启动redis-py负责和Redis交互sentence-transformers做向量编码。如果你手头机器配置一般也可以先不用向量模型用一个简单的关键词评分函数代替后面的路由照跑不误工程框架完全一致。项目目录我建议这样组织agent-reach/ ├── gateway.py # 触达网关 ├── registry_client.py # 注册中心客户端 ├── order_agent.py # 示例Agent订单查询 ├── refund_agent.py # 示例Agent退款处理 └── requirements.txt这个小工程的代码量不大但麻雀虽小五脏俱全。看懂这套结构后往自己的业务里加Agent也就是照着复制改描述文件的功夫。4.2 Agent注册端实现先写注册中心客户端封装好Agent注册、心跳刷新、查询候选这三个基础操作。核心是用Redis Hash存元数据配合TTL做自动过期。import json import time import redis REDIS_POOL redis.ConnectionPool(host127.0.0.1, port6379, db0) REGISTER_KEY agent:register HEARTBEAT_TTL 10 def register_agent(agent_meta: dict): r redis.Redis(connection_poolREDIS_POOL) name agent_meta[name] r.hset(REGISTER_KEY, name, json.dumps(agent_meta)) r.expire(REGISTER_KEY, HEARTBEAT_TTL) print(f[registry] agent {name} registered) def heartbeat(name: str): r redis.Redis(connection_poolREDIS_POOL) # 每次心跳刷新整个hash的TTL简化处理 # 实际要分别维护每个agent的TTL可以用sorted set按过期时间管理 r.expire(REGISTER_KEY, HEARTBEAT_TTL) def get_candidates(): r redis.Redis(connection_poolREDIS_POOL) raw r.hgetall(REGISTER_KEY) return {k.decode(): json.loads(v.decode()) for k, v in raw.items()}Agent端启动时调用register_agent上报自己的描述文件然后起一个线程每3秒发一次心跳保持注册状态不过期。这段逻辑很简单却把“Agent还活着”这件事变成了系统可感知的事实。这里要注意如果Agent进程跑在容器里心跳线程一定要用独立进程或者独立服务去发不能把心跳逻辑埋在主业务线程里否则Agent主逻辑一旦阻塞心跳也跟着停网关会以为是Agent挂了。4.3 路由触达端实现网关侧的核心是接收任务、做路由、触达目标、返回结果。下面这段代码展示主干流程我对语义评分做了简化版本实际项目里可以替换成向量相似度计算接口保持不变。from fastapi import FastAPI, Request import httpx app FastAPI() SCORE_RULES { 直接退款: 3, 退款申请: 3, 退钱: 2, 不想要了: 2, 退货: 2, 查询订单: 3, 订单状态: 3, 物流进度: 2, } def score_task_agent(task_text: str, agent_meta: dict) - float: # 简易关键词评分生产环境建议替换成嵌入向量相似度 score 0.0 tags agent_meta.get(tags, []) for tag in tags: if tag in task_text: score 2.0 for phrase, s in SCORE_RULES.items(): if phrase in task_text: for tag in tags: if tag in phrase or phrase in tag: score s * 0.5 return score app.post(/task) async def receive_task(req: Request): body await req.json() task_text body[text] task_id body[task_id] candidates get_candidates() ranked sorted( candidates.values(), keylambda m: score_task_agent(task_text, m), reverseTrue, ) best_agent ranked[0] async with httpx.AsyncClient(timeout3.0) as client: resp await client.post(best_agent[endpoint], json{task_id: task_id, text: task_text}) return {task_id: task_id, agent: best_agent[name], result: resp.json()}短时间里这套代码直接把整个流程串起来了。注意触达目标时用的超时设置为3秒通过httpx的timeout参数控制。这个参数一定要设不设的后果就是一个慢Agent可以无限拖住网关线程进而拖住上游所有业务。我见过不少系统出现诡异的全链路变慢最后定位下来就是某个HTTP调用没有设置超时一个耗时的下游服务把整条链路的线程池吃光了。4.4 联调测试先把两个示例Agent跑起来。订单Agent的接口地址是http://127.0.0.1:9001/execute退款Agent是http://127.0.0.1:9002/execute。每个Agent启动后调用注册函数把自己的描述文件上报到注册中心。然后启动网关监听在8000端口。发一条测试任务“用户想退掉昨天买的键盘”。curl -X POST http://127.0.0.1:8000/task \ -H Content-Type: application/json \ -d {task_id: T10001, text: 用户想退掉昨天买的键盘}任务文本里带着“退掉”和“键盘”关键词评分会明显偏向退款Agent。网关返回结果里会带上选了哪个Agent和Agent的执行结果。如果你把这句话改成“帮我查一下键盘的物流进度”网关应该会路由到订单Agent。这一正一反两个测试过了基本就证明注册、路由、触达这套链路是通的。实测下来整个联调过程最常遇到的问题反而是服务没起全、端口对不上这类低级失误。所以联调之前最好按顺序自己列一个服务清单Redis活着订单Agent活着退款Agent活着网关活着。别嫌步骤基础这个习惯能帮你省下大把排查时间。5. 常见问题与排查实录5.1 Agent老是触达不到这是多Agent系统上线后最高频的问题。遇到“触达不到”先不要急着调路由策略按下面的顺序排查先看注册中心里这个Agent还在不在。如果Redis里查不到记录说明注册有问题或者心跳已经过期。检查Agent启动日志看看register_agent有没有执行成功心跳线程是不是被主逻辑阻塞了。再看Agent的endpoint写的是什么。如果是“127.0.0.1”而Agent实际上跑在另一个容器或另一台机器上那就永远触达不到。容器化部署里这种问题最多启动时最好由部署平台把内网地址注进环境变量Agent读取环境变量拼endpoint。如果注册中心有Agent但触达还是失败用telnet或者curl直接请求一下Agent的endpoint确认Agent进程本身活着。这一步能快速把问题切成两段到底是触达链路的问题还是Agent自身的问题。我见过有人在这类问题上排查了一整天才发现是Agent的服务进程挂了而注册中心里因为TTL还没过期Agent显示仍然是可用的。5.2 路由结果不稳定路由结果不稳定通常是两个原因Agent描述文件写得太模糊或者关键词/向量评分逻辑有歧义。描述文件是所有路由决策的基准如果两个Agent都写着“处理用户售后问题”网关就只能在它们之间瞎猜。我的经验是两个Agent的能力边界一定要用tags切干净不要让标签重叠。退款Agent的tags只写退款相关词售后Agent的tags只写非退款的售后场景这样硬过滤阶段就已经把边界画清楚了。如果边界画清楚了还乱路由就要检查评分逻辑。关键词评分要看是不是有太宽泛的词比如“处理”这种词出现在多个Agent的tags里就会把评分拉平。向量方案不稳定则要考虑换一个对垂直领域理解更好的模型或者干脆在向量评分后面加一道规则校正当两个候选Agent得分差距小于阈值时默认走业务权重更高或者上次成功更多的那个Agent。5.3 超时参数调优实录超时参数没有万能公式但可以参考我整理的一套初始值再结合自己的业务压测结果做调整任务类型超时时间重试次数说明同步查询类2-3秒1次适合订单查询、状态查询异步投递类15-30秒2次适合退款、外呼、审批链长时间任务30秒以上0次应改为异步任务模式轮询结果调参的顺序有讲究先调超时再调重试。超时太短容易误杀慢任务太长会在故障时堆积大量卡死的请求。通常做法是把超时从短往长调观察延迟分位数找到一个覆盖绝大多数正常耗时的值再留出20%到30%的余量。重试次数不要贪多两次足够再多就会造成重复任务和下游压力。每一次重试之间最好加一点随机退避避免多个请求在同一个时间点一起触发重试产生请求尖峰。我还想强调一点每次调完参数一定要看真实的链路日志别只看成功率。成功率只告诉你挂没挂延迟分布和超时分布才能告诉你堵不堵。我那时有个Agent成功率看着挺高但P99延迟已经超过超时阈值一大截大批请求都是被超时兜底打回来的只是重试成功后把成功率拉上去了。这种情况最迷惑人日志和监控不看细很容易漏过去。Agent-Reach走到这一步我个人最大的体会是名字听起来像是网络层面的东西但真正难的从来不是技术而是业务语义的触达。别一上来就追求全自动语义路由先把注册、心跳、超时、重试、熔断这些通信基础设施做稳了再谈模型和智能。还有一个小技巧从第一天起就把路由日志保留下来那些用户表达和最终路由结果的对应关系就是你将来训练路由模型最好的语料。这个项目后续要扩展权限控制、配额限制、Agent沙箱方向很多但骨架就是这篇文章里这套东西。