多智能体调度框架Agent-Reach:触达路由、动态权重与稳定性实践
Agent-Reach 这个名字定下来的时候项目已经跑了一个多月。做多智能体调度绕不开一个问题多个 Agent 同时要触达不同的服务、数据源甚至是不同团队维护的内部系统谁先谁后、怎么分配、失败了怎么办。Agent-Reach 解决的就是这个触达链路的“可达性”问题。它不是某个算法模型而是一套让多个智能体稳定触达目标服务的调度与路由框架。这篇文章把设计取舍、代码实现和踩坑记录都整理出来适合正在做 AI Agent 应用、多智能体编排或者统一接入层的工程师参考。1. 项目源于什么问题多智能体时代的触达瓶颈1.1 为什么需要 Agent-Reach从三个真实场景说起先说说我最初遇到的几个场景相信做过 Agent 应用的都熟悉。第一个是客服智能体。一次会话里Agent 需要同时拉取用户订单、库存、物流三个系统的数据但三个系统的接口能力完全不一样订单服务 QPS 高、响应快物流服务偶尔超时库存服务干脆有严格的限流策略。如果让 Agent 直接调用这三个接口限流会时不时触发超时重试又会把故障放大一个慢接口拖垮整条链路。第二个是内容生成场景。多个推理节点或者说模型实例挂在集群里每个节点负载不一样。有的节点已经排了上百个请求有的节点还在空转。简单轮询根本不行用户体验差别很大。第三个是自动化流程编排。Agent 把任务投递到不同的执行器执行器可能临时重启、升级或者代码出 bug 导致任务失败。如果 Agent 不看结果盲目重试就会出现重复任务下游系统收到两笔相同的扣款或者两张相同的工单——这种事故很难向业务方交代。这三个场景的共性问题不是“模型不够聪明”而是“目标不可达”。Agent 知道要去哪个服务但不知道怎么去、什么时候去、去了失败怎么办。没有一层统一的触达调度每个 Agent 都要自己处理超时、重试、限流、负载均衡代码会变得极其混乱而且每接入一个新服务就要重写一遍。1.2 核心设计思路把“可达性”当作一等公民Agent-Reach 的核心思路是把“可达性”Reachability从各个业务代码里抽出来当成一个独立系统来设计。用一个生活化的类比快递员手里有包裹但派送过程中路线不熟、小区门禁需要授权、收件人可能不在家。Agent-Reach 做的就是这件事——它不一定生产包裹但保证包裹能准点送到。它维护一份“哪些门能进、哪条路通、哪个时间点送最合适”的路由表快递员只需要把包裹交给它。所以整个系统有几个设计原则。第一触达层与业务层解耦。Agent 不需要关心目标服务在哪个网段、用什么协议、限流规则是什么它只需要向 Agent-Reach 发出触达请求由触达层负责找到可用路径。这样后续换服务、改协议、迁移机房上层 Agent 一行都不用改。第二路由策略可插拔。不同场景适合不同策略内部服务之间适合加权轮询高并发场景适合最少连接关键任务适合主备切换。Agent-Reach 把路由策略做成插件按业务维度配置。第三状态可观测。触达成功、失败、超时、熔断都要有明确指标和日志不然出了问题大家都只能猜。这套思路下来Agent-Reach 本质上变成了一个面向智能体的网关调度层但它比传统 API 网关更贴近 Agent 的使用方式它要理解任务类型、优先级、幂等性而不只是转发 HTTP 请求。2. 整体架构拆解四层模型与关键组件2.1 四层架构分别解决什么问题Agent-Reach 的逻辑架构分成四层我画不出来漂亮图用表格说明它们的位置和职责。层级名称核心职责第一层接入层接收 Agent 发出的触达请求做鉴权、参数校验、任务入队第二层触达层维护所有目标服务的适配器Reacher执行真实请求第三层调度层决定请求分配给哪个 Reacher、什么时候发、失败如何处理第四层策略层承载限流、熔断、重试、优先级等横切策略为什么要这样分最直接的原因是变更隔离。接入层解决的是“谁可以请求”的问题。Agent 身份不同权限也不同。比如内部运维 Agent 可以触达生产环境配置服务而面向 C 端的客服 Agent 只能触达只读服务。接入层做一次统一鉴权和参数校验避免把权限逻辑散落到各处。触达层解决的是“目标服务怎么连”的问题。每个目标服务都对应一个 Reacher 适配器。适配器里封装了协议细节——HTTP、gRPC、消息队列——以及目标服务的个性配置比如超时、认证 token、最大并发。接入新服务就是新增一个 Reacher不改其他部分。调度层解决的是“请求发给谁”的问题。它维护每个 Reacher 的健康状态、权重、当前负载并根据策略做路由决策。这是整个系统的核心也是我后面会重点讲的部分。策略层是横切的放在最顶上其实逻辑上有点不对应该在调度过程中被调用。但为了理解方便我把限流、熔断、重试、幂等都归到策略层它们是可插拔的组件。2.2 关键参数设计与路由规则调度层最有技术含量的其实是几个参数怎么定。第一是权重计算。Agent-Reach 给每个 Reacher 维护了成功率、平均响应时间、当前排队数三个指标。权重不是拍脑袋给的而是根据一段滑动窗口内的实际表现动态计算。基础公式如下weight (success_rate / avg_latency) * 1000 / (current_load 1)这个公式的含义是成功率越高、响应越快、当前负载越低的 Reacher权重越大。除以 (current_load 1) 是为了避免把请求全打到同一个节点上相当于给负载一个非线性惩罚。举个例子。Reacher A 成功率 0.95平均响应 200ms当前负载 10。Reacher B 成功率 0.8平均响应 500ms当前负载 3。A: (0.95 / 0.2) * 1000 / (10 1) ≈ 431.8 B: (0.8 / 0.5) * 1000 / (3 1) 400算出来 A 稍微高一点但 B 负载低所以差距并不大请求不会一股脑全冲到 A。当 A 的负载继续涨到 50权重会迅速掉到 190 以下这时候 B 就会被选中。这个动态性非常关键固定权重策略在流量高峰往往失灵。第二是超时与重试参数。我的经验是“快速失败”比“慢速重试”更重要。目标服务一旦开始超时说明大概率已经出问题再给它一个很长的超时时间只会拖垮整个系统的线程池。Agent-Reach 里我把连接超时和读超时分开连接超时 1s读超时 3s两者加起来不超过 5s。重试次数默认 2 次间隔采用指数退避——第一次间隔 500ms第二次 1s最多不重试超过 3 次。第三是熔断器的参数。熔断器有三个状态关闭、打开、半开。关闭状态下连续失败 10 次触发打开打开状态下所有请求直接快速失败不再触达目标持续 30s 后进入半开放少量探测请求如果成功则关闭熔断器如果失败则重新打开。这个 10 次和 30s 的组合是实测下来比较稳的经验值但不同服务业务量差异大应该根据目标服务的正常失败率调整。注意熔断器的触发条件是“连续失败”不是“失败率超过多少”。在 Agent-Reach 的实现里我用的是滑动窗口内失败率超过 50% 且请求量达到 20 次才触发。光用连续失败容易误伤因为个别偶发超时也会把熔断器打爆。3. 实操环节从零搭建一个最小可运行的 Agent-Reach 系统3.1 技术选型与骨架结构这个系统用什么语言实现主要看团队栈。我选择了 Python FastAPI原因有三Agent 应用生态目前 Python 最丰富团队后续维护成本低FastAPI 自带异步高并发能力对于 IO 密集型的触达调度足够开发效率高能更快把核心逻辑验证出来。但也要说明如果你们团队对延迟特别敏感、QPS 峰值很高建议用 Go。Go 的 goroutine 更轻量部署也更简单。我这边平均 QPS 在 2000 左右Python 的 asyncio 完全能扛住。骨架目录大致是这样agent-reach/ ├── app/ │ ├── main.py # FastAPI 入口 │ ├── config.py # 全局配置 │ ├── models.py # 请求/响应模型 │ ├── scheduler.py # 调度核心 │ ├── reacher.py # Reacher 基类与注册逻辑 │ ├── circuit_breaker.py # 熔断器实现 │ └── reachable/ # 各业务 Reacher 具体实现 │ ├── order.py │ └── inventory.py ├── tests/ │ └── test_scheduler.py └── requirements.txt依赖只需要 fastapi、uvicorn、httpx 三个核心库够用且不会把系统搞得过重。3.2 触达器注册与调度核心代码先看触达器基类。它的作用是统一封装“怎么请求一个目标服务”的行为。# reacher.py import time import httpx from dataclasses import dataclass, field dataclass class Reacher: name: str base_url: str weight: float 1.0 success_count: int 0 fail_count: int 0 total_latency: float 0.0 request_count: int 0 current_load: int 0 enabled: bool True async def invoke(self, payload: dict, timeout: float 3.0) - dict: start time.monotonic() self.current_load 1 try: async with httpx.AsyncClient() as client: resp await client.post( f{self.base_url}/invoke, jsonpayload, timeouttimeout, ) resp.raise_for_status() self.success_count 1 return resp.json() except Exception: self.fail_count 1 raise finally: self.total_latency time.monotonic() - start self.current_load - 1 def get_weight(self) - float: if self.request_count 20: return self.weight success_rate self.success_count / max(self.request_count, 1) avg_latency self.total_latency / max(self.request_count, 1) return (success_rate / max(avg_latency, 0.001)) * 1000 / (self.current_load 1)get_weight方法把前面说的动态权重公式写进去了。刚开始请求量不足 20 时用静态配置的默认权重避免冷启动阶段指标噪声太大。注册逻辑更简单。一个内存字典保存所有 Reacher 实例按名称索引。# reacher.py _registry: dict[str, Reacher] {} def register(reacher: Reacher) - None: _registry[reacher.name] reacher def get_registry() - dict[str, Reacher]: return _registry然后是调度核心。我这里实现的是加权轮询加最少负载的混合策略每次调度时遍历所有启用的 Reacher计算get_weight()作为加权因子再用随机加权选择。对于触达调度来说纯轮询容易在节点性能不一致时踩坑随机加权在 CPU 效率上不如平滑轮询但胜在实现简单、不容易出现“抖一下”的现象。# scheduler.py import random from reacher import get_registry async def dispatch(name: str, payload: dict) - dict: reachers [ r for r in get_registry().values() if r.enabled ] if not reachers: raise RuntimeError(fno reachable target: {name}) weights [r.get_weight() for r in reachers] total sum(weights) pick random.uniform(0, total) accumulated 0.0 selected reachers[-1] for r, w in zip(reachers, weights): accumulated w if pick accumulated: selected r break return await selected.invoke(payload)这里没有引入 Redis 之类的分布式协调因为 Agent-Reach 目前面向的是单机多 Agent 场景。如果后续要水平扩展需要把注册中心换成 Redis 或者 etcd但核心调度逻辑不变。这个取舍我后面会再提。3.3 模拟目标服务与压测验证为了验证系统我在本地用 FastAPI 写了两个模拟目标服务一个配置为响应快但成功率低另一个配置为响应慢但成功率稳定。模拟服务很简单# mock_target.py from fastapi import FastAPI import random import asyncio app FastAPI() app.post(/invoke) async def invoke(payload: dict): service_name payload.get(service, unknown) if service_name fast_unstable: await asyncio.sleep(0.05) if random.random() 0.3: raise ValueError(random failure) return {ok: True, from: fast_unstable} else: await asyncio.sleep(0.3) return {ok: True, from: slow_stable}然后启动 Agent-Reach写一个并发触达脚本模拟 20 个 Agent 同时发出 500 个请求。压测结果大致是这样快但易失败的节点响应中位数 120ms约 30% 请求首次失败但重试后成功率 97%慢但稳定的节点响应中位数 350ms首次成功率 100%整体系统成功率 99.2%避免了全量失败这个结果说明两点熔断和重试机制确实能把单点失败兜住但权重动态调整还不够快刚开始一些请求还是会被随机打到失败率高的节点上等到统计窗口积累了 20 个请求后权重才开始偏向稳定节点。提示如果想加快权重调整的响应速度可以把统计窗口设小一些比如每 10 个请求就重新计算一次权重但这样会引入抖动。我最后选择了折中方案窗口 20 个请求每成功或失败一次就重新计算但不重置计数。4. 实战中的坑与调优从能跑到稳跑4.1 高频故障与排查思路跑通一个最小系统很容易但要在生产环境稳定跑住会遇到一堆问题。我把自己踩过和周围同事踩过的典型问题整理成了一张排查表。问题现象可能原因排查思路与解决触达请求大面积超时目标服务真的慢但没到熔断阈值看目标服务的平均耗时曲线把超时时间从 3s 降到 1.5s让失败快速暴露触发熔断重试风暴所有 Agent 同时重试同一个失败服务重试策略没加抖动重试间隔增加随机抖动在指数退避基础上乘以random.uniform(0.5, 1.5)系统 CPU 不高但线程池占满触达层用了同步 HTTP 客户端确认是否用了 httpx.AsyncClient避免阻塞事件循环某个 Reacher 的失败率也正常但整体成功率不高路由策略选到了权重不合理的节点检查权重计算中 avg_latency 的单位秒和毫秒混用会导致权重差距被放大下游服务被重复投递任务重试没有考虑幂等在 payload 里加 request_id下游按 request_id 去重调度中心重启后全部 Reacher 失联注册表在内存里没做持久化加启动时从配置文件加载注册项后续再上统一注册中心其中最坑的是“重试风暴”。我们有一次压测某个下游服务从某个时间点开始变慢Agent-Reach 这边所有 Agent 都在等这个服务超时后立即重试重试的请求又堆到同一个服务上导致它更慢。后来我在重试策略里加了抖动jitter同时把最大重试次数降到 2 次情况立刻好转。4.2 稳定性优化心得稳定性的核心是控制失败影响半径不是消灭失败。这里有几点实在的经验。第一熔断器参数必须区分业务类型。写操作和读操作的熔断阈值不一样。读操作失败了可以快速失败让用户重试写操作失败要特别小心不能轻易重试。Agent-Reach 里面我把写操作的熔断窗口放大到 30 秒内失败 50 次才触发读操作则保持 10 次触发。这是为了避免在双 11 大促这种流量高峰因为偶尔抖动就熔断写链路导致业务完全不可用。第二背压机制不可省。Agent-Reach 接入层的任务队列必须设置上限。我用了asyncio.Queue(maxsize500)。队列满了新请求立即返回 503而不是无限堆积。否则下游一慢内存先被打爆。这个上限根据单机可用内存来算平均一个请求占内存约 10KB上限 500 就是 5MB再加上并发请求对象整个 Agent-Reach 进程内存控制在 300MB 以内很稳。第三幂等保护要前置到接入层。每个触达请求必须带上request_id接入层存一份最近 10000 个 id 的内存表重复请求直接返回第一次的结果缓存。这个去重表很简单但能避免大量下游重复写入。生产环境我是用 Redis 存储去重表每 60 秒清理一次。第四可观测性必须一开始就做不要等出问题了再补。Agent-Reach 每个请求都记录结构化日志actiondispatch、targetorder、successtrue、latency_ms120。指标用 Prometheus 暴露重点看四个QPS、成功率、P99 延迟、熔断器状态。有了 trace id 贯穿 Agent-Reach 到下游服务排查问题时间能减少一半。5. 写在最后关于 Agent-Reach 的几点个人体会项目做到这个阶段我最大的体会是所谓“智能体触达”难点根本不在智能而在工程。模型再聪明如果连一个可靠的调用链路都没有业务也没办法跑起来。Agent-Reach 的价值在于把不可控的网络问题、协议问题、负载问题集中到一个层面处理让上层 Agent 只关心业务。最后再分享一个小技巧。不要在写代码之前就想着把所有 Reacher 都做出来先跑通两三个核心业务把调度框架打磨稳再逐步接入新服务。接入成本会越来越低到后面新增一个 Reacher 只需要十几行代码。这个思路让我省了很多返工时间。