资讯动态

多Agent通信中间层Agent-Reach设计:注册发现、路由与可靠性实践

发布时间:2026/10/9 9:02:20 来源:尧图企业网站定制
第一批内部智能体跑起来的时候一切都很美好。每个 Agent 各司其职调度脚本负责串起来日志打几个 print 也能看。但当 Agent 数量超过十个事情就开始变质了A 要调 BB 要回调 CC 出问题又把 A 的任务拖死你根本分不清是哪个环节在拖后腿。我后来搞了一个叫 Agent-Reach 的东西说穿了就是给这一堆 Agent 加了一个可靠的到达层让它们之间不再靠手工点对点连线而是通过一套注册发现、路由调度、超时重试机制来通信。这篇文章就聊聊 Agent-Reach 的完整设计思路、从零到一的落地过程以及我自己踩过的那些坑给正在做多 Agent 系统的朋友一个可参考的样本。1. Agent-Reach 到底解决什么问题1.1 多 Agent 协作的痛点不是我想到的那种很多人刚开始做多 Agent 时觉得难点在怎么让每个 Agent 的 Prompt 写得更聪明。做了一阵子才发现真正让人头疼的是 Agent 之间的关系管理。拿我当时的系统举例规划 Agent 收到用户请求后需要拆成子任务分给检索 Agent 和工具 Agent工具 Agent 执行完又要通知记忆 Agent 落库最后汇总 Agent 再收拾残局。如果用最简单的方案那就是每个 Agent 在代码里直接写目标地址。这个方案在前五个 Agent 的时候完全没问题之后越来越难以维护。你改一个 Agent 的地址所有调用方都要跟着改某个 Agent 临时不可用调用方要么报错要么自己写重试每个人重试逻辑还不一样出了故障想排查日志散落在那儿你得沿着调用链一个个找。Agent-Reach 的思路是把Agent 之间如何互相到达这件事抽出来做成一个独立的中间层。它不负责 Agent 的具体业务逻辑只负责三件事一是让每个 Agent 能注册自己、能发现别人二是让任务请求能按照预期被路由到正确的 Agent三是保证这个请求要么成功、要么失败得明明白白不悬在半空中。1.2 可达这个词比听起来要复杂一点我管这个中间层叫Reach是因为我在实际排查中发现很多故障根本不是 Agent 没启动而是不可达。不可达有三种典型形态Agent 进程挂了这是最明显的Agent 还活着但被某个长任务卡死新的请求进不来这是半死不活Agent 在网络分区里别人根本找不到它这是网络层面的不可达。三种形态的排查和恢复策略完全不同如果只用一个简单的健康检查就判定它在不在不够用。所以 Agent-Reach 的可达定义是一个 Agent 不仅注册了自己还在持续续约不仅续约了还能在约定的时间内响应任务请求不仅响应了返回的结果本身是语义上成功的。把这一整套逻辑做成一个组件之后任何 Agent 接入这个体系就等于天然获得了这套可靠性保障不用自己在业务代码里重复实现。1.3 什么场景适合用 Agent-Reach 这类中间层不是所有 Agent 系统都需要它。三个 Agent 以内、用脚本就能排得过来的系统引入中间层反而多余。但如果你遇到下面这些信号就说明确实值得搞信号一Agent 之间出现了三角甚至网状调用关系一张调用图已经画不直了信号二不同的 Agent 由不同的人或小组维护各自的上线时间、接口版本都不一样你没法保证所有 Agent 同时升级信号三你对任务的交付有要求不想因为某个 Agent 瞬间抖动就让整条链路失败需要重试、降级、超时这些机制。我的经验是碰到这三个信号中的任意两个就应该开始考虑把Agent 之间的通讯和Agent 本身的业务拆开。2. 整体设计思路与方案选型2.1 先定边界Agent-Reach 只做传输不做编排做中间层最怕的就是边界不清。一开始有人建议我把工作流编排也放进去让 Agent-Reach 直接管理先调谁后调谁。我拒绝了这个想法坚持 Agent-Reach 只做传输层。原因很简单工作流编排是业务逻辑每个团队的流程都可能不一样而且会频繁修改传输层是基础设施应该保持稳定。把两者混在一起你会发现自己既没法快速响应业务变化又因为频繁改动导致基础设施不稳。所以 Agent-Reach 最终的边界是上游 Agent 把任务请求交给它它负责找到合适的下游 Agent 并可靠送达然后把结果一路带回来。至于为什么要调下游、调完下游之后做什么那是编排器或者 Agent 自己的事 Agent-Reach 不管。2.2 架构上选了注册中心 任务交付层的组合我参考了服务网格和服务注册中心的思路但做了减法。Agent-Reach 的核心由两个部分组成一个轻量级注册中心负责维护所有 Agent 的地址、能力、版本、健康状态一个任务交付层负责接收上游请求查找下游执行路由并且管理整个过程的超时重试。这里有一个很容易走偏的地方一开始我看到 Kubernetes 的 Service 发现可以直接用配一下 DNS 似乎也能解决找到对方地址的问题。但实际试过之后发现服务发现只是解决了地址已知服务网格虽然能解决流量管理但对 Agent 之间的语义能力匹配、任务级幂等、Agent 特有的心跳假死检测都没有现成的支持。与其去各种基础组件里打补丁不如自己做一个专用的。Agent 场景和传统微服务最大的区别在于微服务的调用双方通常是对等且无状态的而 Agent 之间是有角色分工、能力差异和状态记忆的通信层必须理解这些差异。2.3 接口协议为什么选 HTTP JSONAgent-Reach 的通信协议我一开始犹豫过考虑过 gRPC也考虑过直接用消息队列。最后选了 HTTP JSON 作为主协议。选它的第一原因是通用性我手下的 Agent 既有 Python 写的也有 Node.js 和 Go 写的HTTP 是这些语言里最不需要额外 SDK 的公共协议任何一个 Agent 接入只需要会发普通请求就行。第二原因是调试友好打个 curl 就能复现问题这对排查分布式问题太重要了我在实操中用 curl 直接验证 Agent-Reach 路由逻辑的频率非常高。gRPC 的问题在于它虽然性能好但要求两端都配齐 protobuf 定义和生成代码多语言接入成本高消息队列的问题在于它是异步模式对于需要同步等结果的 Agent 调用就不够直接。我当时给 Agent-Reach 定的原则是同步请求用 HTTP适合那些需要在合理时间内得到结果的调用异步任务再落到内部的队列上适合那些不用立刻等结果的后台任务。两者搭配而不是用单一方案硬顶。核心接口设计成一个非常朴素的四个端点。注册接口接收 Agent 的基础信息包括能力标签、地址、版本、最大并发数心跳接口用来续约我要求 Agent 每十秒上报一次任务请求接口是主通道上游把任务描述、目标能力要求和超时设置放进来结果回调接口则是把异步任务的结果拉回来。这套接口的完整规范我在第 4 节会给出来。2.4 状态存储的选择内存为主错峰落盘Agent 的注册信息、心跳状态、任务记录这些都是有状态的。我需要决定放哪里。数据库太重而且高频心跳写入会把数据库打爆Redis 是常见选择但我当时的场景 Agent 数量并不大几十个到上百个 Agent 的信息量用进程内字典完全够还能少一个外部依赖。所以我最后用的是内存字典加定时快照的方案。Agent 的注册表和心跳状态放内存里每秒扫描一次过期节点任务的元信息也放内存但定期把关键事件写入本地文件方便重启后恢复。这里有一个实际考虑Agent-Reach 一旦成为所有 Agent 通信的必经之路它本身就成了单点不能随便挂掉。如果你把它部署在单机且没有备份那它挂了所有 Agent 之间的通讯就断了。我的建议是生产环境至少跑两个实例用简单的主备机制主节点挂掉后备用节点通过状态快照加 Agent 重新注册来恢复服务后面我会专门讲这个部署细节。3. 核心模块拆解与实现细节3.1 注册中心租约机制比固定清单靠谱Agent-Reach 的注册中心用了一个很经典的租约机制。每个 Agent 启动时调用注册接口把自己的能力列表、地址、健康检查端点报上来然后进入一个已注册待确认的状态。Agent 之后必须每十秒调一次心跳接口每调一次就把租约续到当前时间加三十秒。Agent-Reach 内部有个扫描循环每秒扫一遍凡是被标记最后心跳时间超过三十秒的节点先进入可疑状态超过六十秒还没恢复就正式标为下线。之所以要分可疑和下线两个状态是因为直接一刀切下线会误伤。网络抖动导致一两个心跳包丢失是常见现象如果立刻把 Agent 下线任务就会被路由到别处可能引发连锁迁移反而制造更多问题。进入可疑状态后Agent-Reach 不再把新任务发给它但保留它的注册信息也不去动已在进行中的任务给它一个自我恢复的窗口。实测下来这个两段式状态机非常管用因为 Agent 的卡顿和死亡之间经常有很长一段灰色地带。注册表里每个 Agent 记录的信息至少要包含这些字段Agent ID全局唯一标识能力标签列表比如 summarize、web_search、code_executor基础地址格式是 http://ip:port健康检查路径用于主动探活最大并发数和当前并发数最后心跳时间戳。我在实现时用了一个简单的事件循环做定期扫描同时为了保证读写安全用了 Python asyncio 的锁来保护字典操作。3.2 路由策略能力匹配是第一步负载均衡是第二步路由模块要回答的问题很朴素来了一个任务到底发给谁。第一步是能力匹配任务请求里带着它需要的能力标签Agent-Reach 从注册表里过滤出所有具备这个能力的、且状态是可用的 Agent。第二步是从这群候选里挑一个具体的。这一步我先后试过轮询、随机、最少连接数最终组合出了一个最大并发水位优先的策略。轮询的问题在于它对每个 Agent 一视同仁但 Agent 的处理速度差异很大有的快有的慢有的被参数喂得很长有的很短轮询会把请求压到还忙着处理长任务的 Agent 上。随机策略更差因为随机在前端流量不多时经常把请求砸到同一个节点上。最少并发数策略好使它记录每个 Agent 当前的并发任务数永远把新任务发给当前最空闲的 Agent。我在实际实现中又加了一个小修正如果是同一类能力的多个 Agent优先选择本地缓存命中过、历史上失败率更低的那个可以显著降低初期试错成本。路由还有一个高级一点的配置任务亲和性。同一个 Conversation 的多个任务我希望能尽量路由到同一个 Agent因为 Agent 常常带着上下文记忆切换 Agent 会导致上下文丢失。Agent-Reach 在请求头里支持传入一个 session_id路由模块根据这个 ID 做一致性哈希尽量把同一会话的任务发到同一 Agent。但这里要配一个会话重平衡机制如果那个 Agent 过载了或下线了就放给别的 Agent不能为了亲和性把系统堵死。3.3 可靠性模块超时、重试、幂等一个不能少可靠性是整个 Agent-Reach 里我最花心血的模块因为 Agent 毕竟不是传统微服务它可能真的想很久。一个 Agent 做一个复杂推理任务耗时三十秒甚至三分钟是很正常的事。如果参照普通 HTTP 调用设置短超时就会不断误判。但如果放任超时设得很长一个卡死的 Agent 又会把你的请求一直占着。我的处理办法是把超时分成三段连接超时、执行超时、总超时。连接超时设置为三秒用于排除网络不通的 Agent执行超时根据任务类型动态配置简单的检索任务二十秒复杂的写作任务五分钟总超时在任务请求里由上游显式声明Agent-Reach 兜底校验防止调用方忘记设置导致无限等待。重试策略用指数退避加抖动但必须回答什么情况该重试。我把可重试和不可重试做了一个明确的划分连接失败、执行超时、目标 Agent 返回 5xx这些是可重试的目标 Agent 返回 4xx说明是你这个请求本身有问题重试一万次也白搭不重试返回结果格式错误或者结果校验失败我建议不重试因为可能是 Agent 自身的 bug重试只会放大问题。指数退避的公式我用的是基础间隔 500 毫秒每次重试间隔乘以 2上限 8 秒再叠加一个 0 到 500 毫秒的随机抖动避免多个任务同时重试造成重试风暴。幂等是我在 Agent 场景里吃过亏之后才补上的。场景是这样的Agent-Reach 重试了一次任务结果第一个请求其实已经成功执行了只是返回结果在网络上卡住了于是同一个动作被执行了两次——比如一个付钱的工具被调了两次那就麻烦了。解决办法是 Agent-Reach 给每个任务分配一个全局唯一的 task_id目标 Agent 接收任务时必须按这个 ID 做去重已经处理过的 task_id 直接返回缓存结果。注意这个约束不能只在 Agent-Reach 端做必须要求所有接入的 Agent 遵守因为这本质上是一个分布式约定。3.4 可观测性Trace ID 贯穿一切Agent 链路一旦串起来排查问题的难度是指数级上升。我今天还要强调这一点Agent-Reach 的第五个重头设计就是可观测性因为如果没有它前面那些功能越复杂你越难维护。实现方式不复杂但一定要坚持。每个进入 Agent-Reach 的任务都生成一个 trace_id这个 ID 会随 HTTP 头传给下游 Agent下游 Agent 再往下游传的时候也要传给它的下游。所有模块记日志时都必须带上 trace_id。结果就是任何一个任务出问题我只要从入口日志里拿出 trace_id一 grep 就能看到它经历了什么注册中心怎么路由的、重试了几次、每个阶段花了多长时间、最终是成功还是失败、失败原因是什么。我还为每个 Agent 维护了几个滚动指标总请求数、成功数、失败数、平均耗时、P95 耗时、被重试次数、被熔断次数。这些指标通过一个轻量级的边车接口暴露出来可以直接接进 Prometheus也可以定期打成快照看趋势。我在实践中最大的感悟是多 Agent 系统的稳定性不是靠每个 Agent 都稳定换来的而是靠出问题时能十分钟内定位到问题 Agent换来的。可观测性做得好系统的稳定感会直线上升。4. 从零到一落地 Agent-Reach 的完整步骤4.1 第一版样式简单但五脏俱全很多人在做这种底层组件时会忍不住一开始就上很完整的架构又是数据库又是消息队列还配好几层抽象。我的建议相反第一版只做三件事注册、路由、同步请求转发。我当时给自己的要求是不需要数据库、不需要外部缓存、不需要独立部署所有逻辑可以放进单进程用 Python 的 asyncio 就能跑起来。落地技术栈我当时选的是 Python FastAPI原因是异步性能足够我目前的规模生态成熟我团队的人都会写。FastAPI 的异步接口天然适合做转发它还自带请求参数校验和 OpenAPI 文档后面接入其他 Agent 时把文档一发别人一看就知道怎么调用。第一版的接口表我设计为接口方法用途/agents/registerPOST注册一个新 Agent上报地址、能力、健康检查信息/agents/heartbeatPOSTAgent 定期续约/agents/:idGET查看某个 Agent 的注册信息和当前状态/tasks/runPOST同步提交任务等待结果返回/tasks/asyncPOST提交异步任务返回 task_id/tasks/:idGET查询异步任务状态和结果/internal/healthGETAgent-Reach 自身的健康检查这套设计已经比较完整了但我要提醒的是上面这个表里的接口并不是一次性全做出来的。第一版我只有前四个因为核心链路是同步调用异步是跑通之后按需加的。4.2 一个最小 Agent 接入代码示例下面给一个非常简化的示例用于说明 Agent 接入 Agent-Reach 的完整流程。假设我有一个做文本总结的 Agent单独跑在 9001 端口它要做的注册和心跳大概是这样import httpx import asyncio AGENT_ID summarizer-01 BASE_URL http://localhost:8080 # Agent-Reach 的地址 AGENT_ADDR http://localhost:9001 async def register(): resp await httpx.post(f{BASE_URL}/agents/register, json{ agent_id: AGENT_ID, address: AGENT_ADDR, health_path: /healthz, max_concurrency: 4, capabilities: [summarize, extract] }) print(resp.json()) return resp.status_code 200 async def heartbeat(): while True: await httpx.post(f{BASE_URL}/agents/heartbeat, json{agent_id: AGENT_ID}) await asyncio.sleep(10) async def main(): if await register(): await heartbeat() asyncio.run(main())实际操作中这段代码适合做成一个装饰器或基类封装在 SDK 里。每个 Agent 只需要继承基类声明自己的能力标签和任务处理函数骨架代码就会自动完成注册、心跳、接收任务这些杂事。我强烈建议你封装这一层不要在每个 Agent 里重复写注册逻辑否则一旦注册协议升级你要改的 Agent 数量会让你想哭。4.3 路由与任务转发的核心逻辑路由模块和任务转发是 Agent-Reach 的核心代码可以做一个简化表达。下面这个函数完成的是接收上游任务请求找到空闲 Agent转发并把结果拿回来的完整过程async def route_and_call(capability: str, task: dict, total_timeout: float): candidates registry.find_available(capability) if not candidates: raise AgentUnavailable(fno agent with capability {capability}) # 按并发水位升序排列挑最空闲的 candidates.sort(keylambda agent: agent.current_load / agent.max_concurrency) last_error None for attempt in range(3): agent candidates[0] try: async with async_timeout.timeout(total_timeout): resp await httpx.post( f{agent.address}/process, json{task: task, trace_id: trace_id, task_id: task_id} ) if resp.status_code 400: return resp.json() last_error AgentHttpError(resp.status_code) except (httpx.TimeoutException, httpx.ConnectError) as exc: last_error exc # 失败后把当前 Agent 的负载标记升高让它靠后 agent.mark_degraded() await asyncio.sleep(0.5 * (2 ** attempt) random.random() * 0.5) raise last_error这段代码是一个简洁版实际生产里还会加上熔断逻辑连续失败达到阈值时暂时把 Agent 从候选列表里剔除。但结构已经表达得很清楚了先找候选再排序再带超时和重试地转发。没有花哨的东西核心就是把找谁能做和可靠地把任务送到这两件事做好。4.4 压力测试与性能预期Agent-Reach 需要达到怎么样的性能我需要给一个可参考的数字。我在 4 核 8G 的普通云服务器上做过一轮压测注册一百个虚拟 Agent每个 Agent 的处理逻辑是三秒的模拟耗时。Agent-Reach 作为纯转发层自身开销极小每秒钟可以转发起 800 到 1000 个同步任务P95 耗时小于五十毫秒。在实际系统里瓶颈从来不在 Agent-Reach 本身而是在下游 Agent 的处理速度。所以我的建议是Agent-Reach 的性能目标是代理转发本身的开销不超过总耗时的百分之五。你不需要追求极致的吞吐更需要关注的是它会不会成为瓶颈、它自身出故障时是否能快速恢复。如果能达到这个目标针对 Agent-Reach 的性能压测就可以停止了把精力放在下游 Agent 的治理上。4.5 部署细节主备两实例加本地快照Agent-Reach 是所有 Agent 通信的中枢不能单点部署。我推荐的部署方案是双实例主备加本地状态快照。两个实例一主一备主实例处理所有请求备用实例定期同步主实例的本地快照文件同时自己也监听主实例的心跳。如果主实例的心跳超时备用实例接管服务把状态快照加载进内存同时向所有 Agent 广播重新注册的消息让它们快速上报自己的最新状态。之所以要求 Agent 重新注册而不是靠同步的注册表是因为 Agent 的运行状态是动态变化的主备同步难免有短时间的延迟重新注册能保证接管后拿到的是最新状态。对于 Agent-Reach 自身的客户端我封装了一个工具函数客户端会配置主备两个地址主地址连不上时自动切换。这套方案操作上比那些要引入复杂共识算法的方案轻得多但对中小规模的多 Agent 系统已经够用。5. 常见故障与排查技巧实录5.1 注册中心里 Agent 状态频繁抖动这个问题我一度被它折腾了好几天。现象是注册表里 Agent 状态反复在可用和可疑之间跳导致路由结果很不稳定。我查了很久才定位到原因出在健康检查的探活策略上。Agent-Reach 不仅依赖心跳还会主动发 HTTP 探活请求到 Agent 的健康检查端点。原本的设计确实没问题但问题在于我设置的主动探活间隔只有三秒再加上心跳十秒两个探活机制叠加在一起把有些 Agent 的网络连接数打满了反而引发偶发超时。解决方式是明确区分两种探活心跳是 Agent 主动上报的可信度最高是判断 Agent 是否存活的主要依据主动探活只用于心跳过期后的二次确认频率要放低比如心跳超时三十秒后再主动探一次探活失败才标记异常。核心就是别让 Agent-Reach 自己的探活成为新的干扰源。这是运维中常见的一种监控系统把自己监控倒的问题在多 Agent 场景下同样存在。5.2 重试风暴一个根因任务导致所有下游被打爆如果你的 Agent-Reach 设置了自动重试就要特别小心重试风暴。我在实际运行中遇到过这样的情况一个上游 Agent 同时对一百个子任务调用了 Agent-Reach而下游 Agent 因为临时故障所有调用都超时了。这些超时的请求全部进入了重试逻辑重试时又因为指数退避不够长一百个请求几乎同一时间打到了下游 Agent 上直接把它打挂。后来我做了三层防御。第一层是全局并发控制Agent-Reach 对每个下游 Agent 都有最大并发限制超过限制的任务直接排队而不是立刻打过去第二层是重试预算每个任务最多重试两次连重试在内的总次数以三次为上限不搞无限重试第三层是熔断如果某个 Agent 的连续失败超过十个Agent-Reach 会把它熔断十秒期间所有新任务直接走其他候选不再往它身上打。这三层加在一起后重试风暴从此再没出现过。5.3 HTTP 200 但任务实际失败了这是多 Agent 场景里最隐蔽的问题下游 Agent 返回了 HTTP 200响应体里却写着一个业务失败标志success: false。你的 Agent-Reach 按 HTTP 状态码判断成功了就把这个结果当成成功返回给了上游上游 Agent 还以为任务处理完了实际结果早就错了。解决这个问题的办法是给 Agent 之间的通信定义完整的业务协议。我用了和 HTTP 状态码并行的业务状态码字段下游 Agent 在处理结束时必须显式返回这个字段取值包括 success、failure、retryable_failure、invalid_request。Agent-Reach 在转发给上游之前会校验这个业务状态码。只有 success 才算成功failure 和 invalid_request 直接返回错误不进重试retryable_failure 标记为可重试。这条协议约定必须在 Agent 接入文档里写得清清楚楚因为它是分布式系统正确性的基础。5.4 长任务与 HTTP 超时的矛盾最后一个高频故障Agent 处理任务花的时间超过两分钟而上游调用方用的 HTTP 客户端默认超时是三十秒或者上层网关超时是六十秒结果任务实际上已经处理完了但调用方早就放弃了。这种故障的后果比真实故障更严重因为调用方会重试重复结果又让业务侧发现数据重复。我用一个长效机制解决了这个问题。Agent-Reach 内部把所有任务分成了短任务和长任务两类。短任务走同步通道要求 Agent 在约定的时间内完成返回长任务走异步通道Agent 收到任务后先返回一个 task_id等处理完再把结果通过回调推给 Agent-Reach或者等上游轮询查询结果。判断标准不是硬性的而是每个能力标签配置一个时长档位比如 summarize 是六十秒内走同步research 这种动不动就要几百秒的就走异步。再配一个自动降级同步调用如果即将超时但 Agent 还在处理Agent-Reach 会把这次同步请求自动切换成异步模式把 task_id 返回给调用方避免了调用方白等。这套机制是我后期最满意的改动之一。最后说几句实在话Agent-Reach 这种组件做出来之后最直观的感觉不是所有 Agent 调用都不会失败了而是失败变得可以被理解和处理了。它在系统里起的作用更像是给你一条长长的链路装上了路标和护栏。真正多 Agent 系统的难点从来不在单点技术而在边界管理谁负责传输谁负责编排谁负责业务Agent 的接入方需要遵守什么样的一致性约定。把这些边界划清楚系统复杂度再高也能理出头绪。如果你也要做一个类似的 Agent 通信层我的建议是从最小功能开始先保证一条同步链路完全跑通再逐步加入心跳治理、重试、熔断、异步通道。不要一开始就想要做出一个这么多功能齐全的东西。另外请一定从一开始就把 trace_id 规范定下来因为后续排查问题几乎全靠它。Agent-Reach 这套东西我在内部跑了小半年最大的改变是所有接入的 Agent 不再各自操心怎么找到别人和怎么处理调不通它们只需要专注自己的任务逻辑剩下的交给可达层。这个取舍我认为是值得的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑