资讯动态

多Agent系统调度实战:Agent-Reach如何用可达性驱动解决路由混乱

发布时间:2026/10/27 13:52:25 来源:尧图企业网站定制
先说个实际场景。我们团队去年同时跑了十二三个Agent有做信息抽取的、有做客服意图识别的、有做长文润色的还有几个接外部API的执行型Agent。这堆东西跑着跑着就乱了最后逼我们自己做了个叫Agent-Reach的调度中间层。Agent-Reach不训练模型也不实现具体业务动作它的唯一职责是搞清楚每个Agent能触达到哪儿、现在状态如何然后决定眼前这个请求到底该交给谁。这篇文章把当时的立项原因、核心设计思路、关键模块实现、上线后一次真实故障的完整排查链以及最终数据都拆一遍。正在做多Agent系统的团队尤其是从几个Agent各自为战往统一编排过渡的阶段应该能从中找到一些可以直接抄的作业。就算你只是一个人写了好几个脚本Agent也值得花几分钟看看注册、幂等、熔断这一套思路因为迟早用得上。1. 为什么多Agent项目会越跑越乱一次事故背后的四个信号1.1 事故现场一次重复触达引发的业务事故先还原一次事故。当时我们做电商场景的智能客服和运营助手接了一个短信营销插件。某天下午业务方反馈有用户收到了两条一模一样的优惠券领取短信而且后台显示同一个领取接口被调用了两次。查日志查到半夜发现同一个用户请求从网关进来之后被意图识别Agent、售前咨询Agent、运营推荐Agent三个服务各处理了一遍。为什么会被处理三遍因为这三个Agent都宣称自己能识别领券意图调用方各自维护路由规则每个人都觉得自己选中了一个合适的Agent结果同一事件被消费了三次。当时看到的还只是短信重复如果是扣款、发券这类强写操作重复执行带来的后果会直接喂给真实用户。这是多Agent协作里最让人头疼的问题——能力重叠本身不是坏事但没有统一调度时重叠就是重复和混乱。1.2 四个信号当团队开始怀疑Agent失控那次事故之后我们做了一次复盘发现其实在此之前已经冒出了一堆信号只是当时没有系统性去看。整理下来基本是四条能力重叠严重。同一个任务类型两三个Agent都宣称自己能做但调用方并没有一个统一的决策依据基本靠人猜。负载极度不均。有的Agent一天处理几万条有的Agent一天只处理几十条但都是全量部署、全量付钱资源浪费很明显。没有统一链路追踪。出问题只能翻各自Agent的日志再拼拼接接一次跨Agent故障排查至少要两三个人对半天。故障无感知。某个Agent的进程还活着但它依赖的下游API其实已经挂了调用方不知道照样把流量打过来于是错误率和延迟一起涨。这四个信号其实是很多多Agent团队共有的问题。大家通常的第一反应是再招个人专门管路由或者让每个Agent都更智能一点。但老实说问题的根子不在Agent本身而在于缺了一层独立于业务Agent之外的调度基础设施。这也是Agent-Reach立项的起点我们需要一个东西知道每个Agent到底能触达什么、触达得怎么样并在故障时主动避开坏节点。2. Reach带来的调度理念转变从手动路由到可达性驱动2.1 快递分拨中心与Agent调度的类比在动手设计Agent-Reach之前我们内部吵过一阵要不要做一个Super Agent把其他Agent全都包起来让它当统一入口后来放弃了这个思路。原因很简单Super Agent本身的逻辑会越来越重而且它和业务Agent强耦合任何一个下游Agent改接口Super Agent都得跟着改等于把之前的混乱集中到一处。后来换了个类比来想问题——快递分拨中心。快递公司不会把每个包裹都塞给同一个快递员也不会随机扔给某一个而是根据包裹流向、快递员当前手上的单量、他熟不熟悉这个片区来分配。对应到Agent场景包裹是任务请求快递员是Agent片区就是Agent的能力边界。Agent-Reach里的Reach说的就是这个能力边界一个Agent在某一类任务上实际能触达到什么程度。2.2 可达性计算的三个维度我们把Reach拆成三个可量化的维度用来给每个Agent打分静态能力维度Agent启动时主动注册声明支持哪些task_type、最大并发上限、哪些时间段可用。这是它能做什么的基础。动态可用性维度通过心跳和实时上报记录当前排队长度、进程存活状态、下游依赖是否健康。这是它现在能不能干活。历史表现维度滚动统计过去24小时内该Agent处理同类任务的成功率、平均耗时、失败模式。这是它干得好不好。这三个维度合并出的分数我们内部叫Reach Score。分派的时候不直接找最聪明的Agent而是找对该任务可达性最高的Agent。这套设计让新Agent接入变得非常简单只要它注册时把能力边界写清楚后续是否被调度到完全由平台根据实时情况决定不再依赖任何人的主观判断。这么做还有一个附带好处谁靠谱谁不靠谱最终会被数据反映出来而不是靠人拍脑袋。提示Reach Score不是越高越好的静态排名它是一个实时变化的动态值。别把它做成这个Agent金牌、那个Agent银牌的固定评级否则负载又会倾斜。3. Agent-Reach的关键模块实现注册、分派与兜底3.1 Agent注册与能力声明Agent-Reach第一版用Python写的消息队列选的是Redis Stream基础设施主要是Redis没有引入太重的东西。每个Agent启动时会调用注册接口把能力声明写到Redis Hash里同时启动一个心跳协程每10秒续期一次TTL。注册的数据结构大致是这样的{ agent_id: intent-recognizer-01, task_types: [intent_classify, slot_fill], max_concurrency: 20, current_load: 0, priority: 1, downstream_status: healthy }对应到代码层面我贴一段当时注册逻辑的精简版本async def register_agent(agent: AgentMeta): key fagent:{agent.agent_id} await redis.hset(key, mappingagent.to_dict()) await redis.expire(key, TTL) asyncio.create_task(heartbeat_loop(agent.agent_id))有两个设计细节想说一下。task_types是白名单制一个Agent想接一类任务必须在启动时明确声明而不是由路由层去猜。这样做的目的是避免能力重叠演变成责任不清。max_concurrency是Agent自己评估出来的大多数情况下我们信任Agent的自我评估但会加一层约束连续两次上报的current_load都不低于max_concurrency时平台会自动把它标记为过载在调度时降权。这个机制后来救过我们一次后面会讲到。3.2 分派引擎按Reach Score而非手动路由分派引擎本身是一个常驻Worker进程。它通过Redis Stream持续接收上游产生的任务按task_type在注册中心里筛选候选Agent集合然后对每个候选用下面的方式计算实时得分def calc_reach_score(candidate: AgentMeta) - float: load_ratio candidate.current_load / candidate.max_concurrency error_ratio 1 - candidate.success_rate_24h return load_ratio error_ratio * 0.5 candidate.failure_penalty这个公式是简化过的实际实现还加了时间片权重和优先级系数但核心思路就是负载越接近上限越往后排历史成功率越高越靠前正在持续失败的追加惩罚分。算完之后选分数最低的那一个把任务写入对应的Agent任务队列。整个过程对上游调用方完全透明上游只需要知道任务会被交给一个合适的Agent不需要知道具体是哪一个。我特别想强调一下这个透明的意义。在接入Agent-Reach之前每次新增一个Agent调用方都要改代码、改路由表接入之后调用方只跟Agent-Reach通信。Agent本身可以随时上下线不用通知所有人。这种解耦带来的维护成本下降在中期会体现得非常明显。3.3 熔断与兜底链Reach再高也不能保证100%成功调度做得再好Agent端还是会有失败的可能。所以Agent-Reach里同时实现了熔断器和兜底链。熔断逻辑是单个Agent在30秒内连续失败3次就进入open状态接下来的5秒不再向它分派任何任务5秒后放一个试探请求成功则恢复失败则继续熔断。这个30秒内连续3次失败最早是写死的后来改成了按task_type单独配置。原因很直接不同任务对失败容忍度差别很大。纯查询类任务失败成本低熔断条件可以放宽涉及扣款之类的强写任务一失败就要立刻熔断哪怕只是连续2次也不行。如果一刀切就会出现查询任务偶尔超时触发熔断、结果把流量切到别的Agent身上反而制造新热点的问题。兜底链是另外一项保险。每个task_type都可以配一条降级路径比如意图识别Agent失败 - 规则引擎兜底 - 人工工单队列。这里有个原则兜底链只允许往下退不允许横向重试同级别Agent。这样设计是为了防止多个同级Agent在故障时互相触发重试打出一个重试风暴。踩过这个坑之后我才真正明白兜底设计的本质不是提高成功率而是让失败变得有边界、可预期。4. 上线一周的真实故障幂等缺失引发的消息风暴4.1 故障表现Redis Stream积压与数据库连接耗尽Agent-Reach上线后第一周整体很平静我们甚至觉得是不是平得太快。结果第五天下午监控群开始报警Redis Stream消息积压从几百条一路涨到十万级两个执行型Agent的数据库连接数打满线上接口P99直接从400毫秒涨到2秒以上。那会儿第一反应是调度引擎挂了。登录机器一看Worker进程CPU和内存都正常Redis也没崩。这就是最典型的看起来哪里都正常但系统就是不行的阶段。说实话这种故障比明显崩溃难处理得多因为没有一个真正意义上坏掉的东西可以让你立刻定位。4.2 排查链路从监控面板到一行错误日志整个排查过程大约花了一个半小时链路大概是这样的第一步先看Redis Stream的长度确认数据在持续堆积而不是消费端停了。结果stream长度在剧烈增长说明生产者还在不停写入而且写入速度远大于消费速度。第二步看消费端日志发现Worker在正常消费但消费完要把任务转发给某个Agent时Agent端返回了超时于是Worker按预设逻辑把任务放回了队尾。放回的动作本身设计了失败计数但问题在于上游营销系统的推送模块有自己的内部重试机制它没收到确认就会重推重推的任务又带着同一个业务trace_id进来。第三步去查Redis里有没有做去重。结果发现进队逻辑当时只校验了消息格式没有基于trace_id做幂等。营销系统重试三次三次都被当成新任务消费而且每次消费失败又触发Worker层面的放回重试消息以指数级膨胀。数据库连接耗尽只是表象真正的根子是调度层完全没做入队幂等。4.3 根因确认与修复先堵源头再优化调度修复分两步。第一步先把营销系统的推送重试停掉再把积压的stream清空让系统先恢复。第二步在Agent-Reach的入口层加了一个基于Redis SETNX的幂等过滤器每个任务带业务trace_id入队前尝试写入一个带过期时间的锁只有写入成功才允许入队。async def enqueue_if_duplicate(task: Task): lock_key fdedup:{task.trace_id} acquired await redis.set(lock_key, 1, nxTrue, ex300) if acquired: await redis.xadd(task_stream, task.to_dict())这样上游哪怕重试一百次同一个trace_id也只会进一次队列。重放流量之后重复处理单量归零延迟也降下来了。事后复盘有个结论对我们影响很大Agent调度层的Reach能做得多好其实还取决于消息泵本身够不够可靠。如果没有幂等调度再智能也挡不住重复消息引发的连锁故障。这也是为什么我在文章开头强调幂等和去重应该排在功能开发前面而不是等到出了事故再补。5. 压测与线上数据调度层带来的真实收益5.1 我们怎么做的对比压测Agent-Reach稳定运行两周后我们做了一次相对严谨的对比。方法不复杂挑出两条流量特征接近的业务链路一条走原来的直连方式一条接入Agent-Reach各跑一周对比统计指标。虽然不算是严格的AB实验但两条链路的流量来源和峰值时段都比较接近参考价值还是有的。预发环境另外做了一轮压测。用JMeter模拟三个高峰时段的流量曲线并发从100逐步加压到1000主要看P99延迟在调度层介入后会不会劣化。结果调度层本身的处理开销其实非常小因为核心逻辑只是读Redis、算分数、写队列完全没有复杂计算。5.2 关键数据盘点直接看几项主要数据指标接入前接入后P99延迟约820ms约460ms核心链路成功率96.8%99.5%高峰闲置Agent占比35%-40%10%以下同事项重复处理单量每周约200接近0故障恢复时间约30分钟3-5分钟延迟下降最直观因为请求不再被随机分配给一个排队中的Agent而是交给当前负载最低的处理者。成功率提升来自熔断机制下游Agent不健康时流量被快速切换到兜底链路而不是硬着头皮往坏节点上打。重复处理单量清零靠的是前面说的幂等过滤器。5.3 别把数据神话这套系统的适用边界说实话这些数据在小团队量级下很有参考性但绝对不足以证明Agent-Reach在任何场景下都有效。它更适合的任务特征是任务类型相对清晰、Agent数量在几个到几十个之间、请求对延迟有一定容忍度。如果你的Agent只有两三个或者任务类型高度确定手动写一个简单的if-else路由可能就够用。如果你的Agent数量上百那需要的不只是调度可能还要引入真正的服务网格级别治理能力。Agent-Reach恰好解决的是中间这一段多到让你管不过来但又没有多到值得上一套大型中间件。6. 如果重做一遍五条核心教训与可复用清单6.1 顺序问题幂等永远排在调度之前第一条也是最重要的一条先做幂等再做路由。任何一个从外面进来的请求无论来源是上游服务、消息队列还是人工触发都必须有一个在系统内部可全局去重的标识。没有这个前提时调度越智能分发速度越快反而可能在故障场景下放大问题。这一点是被那次消息风暴教育出来的代价不小。6.2 能力声明要从紧不要从松Agent注册时声明的task_types越精确越好。宁可拆成更多细粒度类型也不要让一个Agent说自己什么都能做。能力声明直接影响调度时的候选集大小和路由决策质量。如果一个Agent声明的能力过宽Reach Score的意义就打了折扣调度引擎还得额外做很多排除逻辑。6.3 熔断参数别一刀切熔断阈值和熔断时长一定要按task_type拆分。我们早期统一配置结果一个查询类任务偶尔超时就触发熔断把流量切走导致另一个Agent无谓地扛下所有请求。后来改成在注册表里为每个task_type指定独立的失败容忍度这个问题才消失。如果你的Agent处理的任务类型差别很大这条尤其要注意。6.4 可观测性从第一版就要有Agent-Reach第一版上线时我们其实已经把每个任务的调度轨迹都打成了结构化日志包括task_id、选中的Agent、落选Agent及各自得分。当时觉得可能用得上后来排查消息风暴时正是靠这些日志快速定位到重复任务进入了不同Agent队列。如果当时没有这些日志排查时间恐怕要翻倍。调度轨迹日志这种投入永远值得做。6.5 不要过度设计最后一条与第一条看起来有点矛盾但很重要不要因为调度平台好用就把所有Agent都强行接进来。如果一个任务只有一条处理路径不需要路由和兜底让它保持直连就好。Agent-Reach第一版做完之后我们一度想把它推广到所有链路后来发现有两个内部工具Agent根本不需要调度接入后反而多了一层跳转。退回去之后整体延迟还低了一点。调度层应该是一层可选的基础设施而不是所有请求的必经之路。最后再分享一点个人体会。Agent-Reach这个名字当初纯粹是因为团队里有人喜欢双关才起的意思是智能体能触达多远。但做下来我才真正明白一个调度系统最大的价值不是让系统看起来更智能而是让整个协作过程变得可预测、可追踪、有边界。如果你也在搭多Agent系统我建议别一开始就追求复杂的路由算法先把注册、心跳、幂等、熔断、日志这五件事做扎实。它们不性感但都是保命的东西。

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

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

免费获取报价 →
↑