先说个实际场景。我们团队去年同时跑了十二三个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 = f"agent:{agent.agent_id}" await redis.hset(key, mapping=agent.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 = f"dedup:{task.trace_id}" acquired = await redis.set(lock_key, "1", nx=True, ex=300) 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系统,我建议别一开始就追求复杂的路由算法,先把注册、心跳、幂等、熔断、日志这五件事做扎实。它们不性感,但都是保命的东西。