1. 从一次“Agent打架”事故说起:我为什么需要Agent-Reach
去年年中,我们团队把客服场景拆成了三个独立的智能体:退款处理、退换货、物流查询。当时想着“术业有专攻”,让每个模型只干一件事,效果肯定比一个全能大模型强。上线第一周确实还行,直到有一天线上出了一个事故:同一个用户同时发起了退款申请和物流投诉,三个Agent在彼此不知情的情况下被同时唤起。
退款Agent判定可以退款,已经调用了退款接口;退换货Agent认为需要收集更多材料,给用户发了一条“请补充订单截图”;物流Agent查不到最新轨迹,又自动向仓储系统发了一次确认请求。结果用户在一分钟之内收到了三条互相矛盾的回复,而且退款请求因为状态机被后面的操作覆盖,最终显示“处理失败”,用户直接炸了。
这个故障排查了很久,最后发现根子不在模型本身,而在于三个Agent之间完全没有“触达”机制——它们不知道对方存不存在、谁在处理同一件事、消息怎么递过去。更麻烦的是,当时我们每个Agent都是通过硬编码的HTTP回调互相调用的,上线一个实例要改一堆配置。你用的时候觉得没什么,等Agent一多,这种“点对点直连”一定会变成鬼网。
这件事之后我开始查行业里同类团队的做法,发现大家其实卡在同一个阶段:模型能力已经不是瓶颈,瓶颈是多Agent之间的通信、调度、追踪和管理。于是我们自己写了一层东西,内部代号就叫“Agent-Reach”,定位是多智能体触达与调度层。它的核心价值很简单:让任何Agent都能通过统一的消息信封和注册中心,找到“该找的人”,把消息“稳妥地送过去”,并且让整个调用链“看得到、查得清”。
如果你也在搭多Agent系统,或者正准备从一个All-in-One大模型切到多个专职智能体的架构,这篇文章应该能帮你少走几个月的弯路。我会把Agent-Reach从设计到踩坑的完整过程讲一遍,包括消息模型、触达策略、稳定性设计,以及几起让我印象深刻的线上事故。
2. Agent-Reach的核心模型:注册中心、能力声明与消息信封
设计Agent-Reach之前,我给自己提了三个问题:Agent之间该怎么认路?消息改怎么封装才能扛得住重试和追踪?每个Agent到底能干什么,谁来告诉别人?
2.1 注册中心:让每次触达都知道“该找谁”
多Agent架构里,Agent实例不是固定的。一个客服Agent可能因为流量高峰从两个实例扩到八个,也可能因为发布新模型全部重启一遍。如果沿用老办法,在调用方配置里写死目标Agent的URL,扩个容就得改配置、走发布流程,成本太高。
所以Agent-Reach最底层先做了一个轻量级注册中心。每个Agent启动的时候,把自己的身份标识、能力标签、健康检查地址、当前状态注册进来,然后按固定间隔上报心跳。注册中心维护一张实时路由表,触达方只需要告诉Agent-Reach“我要找退款Agent”,它就会返回当前健康的所有退款Agent实例地址,再根据负载策略选一个。
这里有一个细节:注册中心不仅要管“活着”还是“死了”,还必须管“能干什么”。纯粹的地址注册可以用现成的服务发现组件,但多Agent场景下,你一定还会面临“按能力找Agent”的需求。比如“处理退款”这个语义,可能退款Agent能做,也可能一个综合客服Agent也能做,注册中心需要根据能力标签来做匹配和优先级排序。
2.2 能力声明:从“找名字”到“找能力”
Agent-Reach里每个Agent都要在启动时声明自己的能力,我们内部管这个叫 capability。它不是一个简单字符串,而是一组带格式的声明,形如:
refund.handle_pending refund.cancel logistics.query logistics.report好处是调用方不需要知道具体该找哪个系统,只需要带着“意图”来触达。比如一个上游流程说“我要查这个订单的物流”,Agent-Reach会遍历注册中心里所有声明了logistics.query的Agent,再按健康状态和负载选择实例。
这个设计还有一个副作用:它逼着你把Agent的职责边界想清楚。如果你声明能力的时候含糊,比如某个Agent把“退款”和“退货”全都塞进一个能力里,那么下游调用方就无法精确路由。上线两个月,我们最大的收获就是能力声明的粒度,决定了整个系统能在多大程度上并行协作。能力声明越细,Agent之间的职责就越清晰,触达的成功率和可预期性也越高。
2.3 消息信封:一次触达的生命周期
解决完“找谁”,接下来是“消息怎么封装”。早期我们直接传了一个JSON对象,里只有业务字段。等到开始做重试、做全链路追踪,才发现消息里缺了太多基础设施字段,补起来到处打补丁。
Agent-Reach现在统一用下面的消息信封结构:
@dataclass class AgentMessage: message_id: str # 本条消息的唯一标识 trace_id: str # 链路追踪ID,同一次业务操作共享 idempotency_key: str # 幂等键,供消费方去重 sender: str # 发送方Agent标识 capability: str # 期望调用哪个能力 payload: dict # 业务数据 ttl: int = 60 # 消息生存时间,单位秒 created_at: int # 创建时间戳这几个字段每一个都有明确用途,缺一不可:
- message_id是消息本身唯一标识,用于消息队列层面的去重,比如消费方重复拉取时靠它识别。
- trace_id贯穿一次完整的业务操作,比如一次退款从发起到最终通知用户,可能跨了三个Agent,有了trace_id才能把日志串起来。后面聊排查的时候我会重点讲它。
- ttl看起来是小事,但它救了我们的命。广播场景下没有TTL,一条消息可以被无限转发,直接打爆整个集群。
- created_at别看只是一个时间戳,它能配合ttl做消息过期判断,也能在业务方统计“整条链路耗时”时提供基准点。
如果你也在自己搭类似的调度层,我建议直接按这个结构起步,千万不要等出了事再补字段。改消息协议比想象中麻烦,因为消费方、日志分析、监控告警全都和它耦合。
3. 触达策略的实战选型:同步、异步、广播与编排
注册中心和消息信封只是“管道”,真正决定系统好不好的,是触达策略怎么选。Agent-Reach目前支持四种触达模式,我一个个讲它们各自解决的问题,以及很容易踩的坑。
3.1 同步触达:什么时候必须等一个Agent回话
同步触达就是请求-响应模式:A给B发消息,B处理完,再把结果返回给A。适合那些调用方必须拿到结果才能继续下一步的场景。比如用户在网页上发起退款,页面要立刻显示“退款已受理”还是“审批被驳回”,这必须同步完成。如果这里改用异步,用户交互体验就会非常奇怪。
Agent-Reach的同步触达用的是短连接 + 超时控制。默认超时是8秒,超时之后按策略重试。这里我想提醒一件事:**同步超时时间必须比下游业务本身的耗时上限更长,否则就会出现“业务还在处理,调用方却已经放弃”的尴尬局面。**我们一开始把超时设成5秒,结果下游有个Agent要调第三方支付接口,偶尔超过5秒,导致上游已经显示失败,下游实际却退款成功了。这种“假失败”比“真失败”还难排查,因为两边看到的结论完全相反。
3.2 异步触达:把“通知”和“结果”解耦
很多场景不需要同步拿结果,比如“退换货完成后通知库存系统更新数量”,这种事晚几秒执行完全没问题。异步触达的好处是调用方不等响应,直接把消息丢进Agent-Reach的消息队列就走,由调度层负责投递和重试。这样即使下游Agent临时抖动,也不会阻塞上游主流程。
我个人的经验是:**在一个多Agent系统里,能异步就尽量异步。**同步调用会占用连接、占用线程、延长链路响应时间,而且任何一个环节变慢都会向上游传递。异步却天然带缓冲和削峰能力。现在Agent-Reach上跑的触达请求里,异步占比超过70%,剩下30%必须同步的,几乎都是和人直接交互的场景。
3.3 广播触达:按能力搜索,而不是漫无目的地全发
广播是三种触达模式里最危险的。早期我们做广播很简单,把一个消息发给注册中心里所有Agent,结果某些Agent收到之后发现自己处理不了,又发了一条“请其他Agent处理”的消息,瞬间整个系统消息量暴涨。
Agent-Reach后来把“广播”重构成了“按能力广播”。也就是说,系统先根据capability字段过滤出所有有能力处理该消息的Agent,再决定是全部投递、还是只投一个。这个改动看着很小,但直接把无效流量干掉了95%。广播现在只用于明确需要“多方协作”的场景,比如用户投诉升级,需要客服、质检、舆情三个Agent各自生成报告,这时候才让它们都收到同一份事件。
3.4 工作流编排:把多Agent变成一条流水线
等到Agent数量超过5个,你会发现单纯的“找一个人”已经不够了。一个完整业务场景往往包含多个步骤,比如“退款申请”要走:风险评估Agent → 审批Agent → 支付Agent → 通知Agent。从第一个Agent到最后一个Agent,这其实是一条有方向的流水线。
Agent-Reach顺手做了一套轻量级工作流引擎。它不是市面上那些重型流程引擎,而是只做一件事:按照DAG(有向无环图)的定义,一步一步触达对应的Agent,每步之间传递和校验消息信封。每个节点都有自己独立的超时、重试和幂等策略。这个设计我们后来发现特别实用,因为业务方不需要自己写一堆状态机来回调用Agent,只要在配置里声明流水线结构就行。
同步、异步、广播、编排这四种触达策略,适用场景完全不同,选错会带来连锁问题。我做了一张简单的对照表,方便你自己排选型:
| 触达模式 | 适合场景 | 耗时特征 | 失败处理 |
|---|---|---|---|
| 同步 | 页面实时显示结果、决策后必须立即反馈 | 短,一般秒级 | 立即重试 + 超时兜底 |
| 异步 | 通知类、批量类、回调类 | 可接受分钟级延迟 | 队列重试 + 死信兜底 |
| 按能力广播 | 多方协同生成报告、事件同步 | 取决于最慢链路 | 逐Agent独立处理,互不阻塞 |
| 工作流编排 | 多步骤有依赖关系的业务流水线 | 全局取决于关键路径 | 节点级超时和重试单独配置 |
4. 稳定性设计:超时、重试、幂等和死信队列
多Agent系统最怕什么?不是模型答错,而是消息在系统里“悬空”:发不出去、反复执行、静默丢失。这一章我重点讲Agent-Reach在稳定方向做的四个设计决策,每一个都是从线上事故里换回来的教训。
4.1 分级超时:不能一个数字走天下
最开始我在Agent-Reach里只配了一个全局超时:10秒。后来发现这个配置很蠢。同步触达的8秒、工作流节点的30秒、异步消息队列的投递等待,它们根本不是同一个层面的东西。一个全局超时要么卡死短任务,要么放跑长任务。
现在我们拆成三层:
- 连接超时:默认1秒,用于TCP层建连。如果1秒建不了连,说明目标Agent健康状态不可信,直接换下一个实例。
- 处理超时:默认8秒,用于业务逻辑整体跑完。超过这个时间,调用方不再等待响应。
- 工作流节点超时:默认30秒,主要用于编排里某些步骤要等人工审批或第三方回调的场景,这类操作天然比普通处理慢。
分层之后,调整超时不再产生“按下葫芦浮起瓢”的连锁反应。
4.2 有限次重试:指数退避加抖动
重试不是越多越好。Agent-Reach在异步模式里默认最多重试3次,间隔分别是1秒、2秒、4秒,并且每次加一个随机抖动(0到200ms)。为什么加抖动?如果一批消息同时失败,所有发送方都按1秒重试,会在1秒后形成新的流量尖峰,二次打爆下游。抖动能把刺中心的尖峰摊平成一个缓坡。
这里也想提醒一句:**重试只对“瞬时错误”有帮助,对“确定性错误”毫无意义。**如果下游Agent因为缺权限而失败,你重试100次也是同样结果。所以在重试之前,Agent-Reach会判断失败类型——网络异常、超时这类瞬时问题才进重试队列,业务校验失败直接走拒绝路径,不浪费资源。
4.3 幂等:同一个动作不能执行两次
这是我在退款事故之后最重视的一个设计。消息重试机制保证“至少送达一次”,这同时意味着同一个业务动作可能被执行多次。如果不能保证幂等,一次退款请求因为网络超时就可能被实际扣款两次。
Agent-Reach统一规定所有Agent消费消息时,先查幂等表,检查 idempotency_key 有没有出现过。如果处理过直接返回之前的处理结果。关键是:**幂等键必须来自业务本身,而不是系统生成的时间戳或随机数。**比如退款场景的幂等键,我们用的是“订单号 + 用户ID + 操作类型”。只有这样,同一次业务操作无论被重试多少次,都能落到同一个键上。
4.4 死信队列:失败消息不能静默消失
异步触达里,消息重试3次仍然失败,不能就直接扔掉。Agent-Reach会把这类消息推进死信队列,并且附上完整的失败历史和原始信封。每天有专门的巡检任务扫一次死信队列,能自动补偿的就补,实在处理不了的再人工介入。这个设计让“疑似丢失”变成“明确可查”,线上排查心理压力小很多。
注意:死信队列本身也要设计过期策略。如果死信消息能无限囤积,最后会变成另一座垃圾山。我们目前是保留72小时,超过时间自动归档到冷存储。
5. 生产环境的真实故障与排查链路
系统上线之后,真正的挑战才刚开始。这一章我把几起线上故障的排查过程摆出来,包括现象、根因、修复方式。也许不适用于所有人的环境,但排查思路大概率有参考价值。
5.1 广播风暴:三个Agent互相触发,消息量半小时涨了60倍
这是Agent-Reach上线后遇到的第一起大故障。现象是集群消息量突然暴涨,日志系统率先报警,随后Agent实例CPU相继打满。当时第一反应是流量高峰,结果看了监控发现没有真实用户请求增长,而垃圾消息在Agent之间互相转发,形成了一道循环。
根因是广播没有约束递归触达。某个Agent收到广播消息后,因为它自己处理不了,又按同样的广播模式发了一条新消息,结果消息又回到自己手里,形成死循环。修复分两步:第一步在消息信封上加了最大跳数,每条消息每经过一次转发,跳数减1,减到0直接丢弃;第二步在广播策略里强制过滤掉发送方本身,A发出的广播绝对不允许再触达A。加完这两条之后,类似循环再也没出现过。
5.2 触达丢失:注册中心心跳误删导致偶发超时
某天业务方反馈,有5%左右的正常触达请求偶发超时。查了链路追踪发现,超时请求全都打到了一个“已经下线”的Agent实例上。那台实例因为网络抖动,心跳连续几次没上报,注册中心按照规则把它摘掉了。正常应该触达新实例,但当时有一个调用方本地缓存了旧路由表,没有实时刷新注册中心,所以还在往旧地址发请求。
修复方案给Agent-Reach加了一个“触达前校验”步骤:发送消息前先带trace_id检查注册中心里的最新健康状况,如果发现目标已下线,自动重新路由。同时把本地路由缓存时间从5分钟缩短到30秒,代价是注册中心查询变频繁,但换来的是触达准确率。
5.3 重复执行:重试机制导致退款单被处理了两次
幂等表上线之前,我们出过一次比较严重的资损隐患。一个退款Agent收到同步触达请求,正常处理完并返回成功,但响应因为网络问题延迟了很久,上游等不及触发了重试。这个Agent收到重试消息后,没有做幂等判断,于是同一张退款单又被处理了一次。虽然最后状态机拦截了第二次真正打款,但整个流程的日志已经错乱了,复核起来非常痛苦。
之后Agent-Reach把幂等检查下沉到了消息消费的最前端,也就是业务逻辑执行之前,先把 idempotency_key 写入去重表,再执行后续动作。这个顺序很关键:如果先执行业务再查重,重复消息照样会跑一遍业务逻辑,失去了防重意义。
5.4 链路追踪:用trace_id把所有断点连成一条线
排查上面这些故障时,最大的帮手就是那个一开始不起眼的trace_id。每次触达入口生成一个trace_id,它跟着消息信封穿过所有Agent,同时打印到所有日志里。排查问题时,你只需要在日志平台搜索“trace_id: xxx”,就能看到这次请求全链路的每个环节,包括谁触达了谁、耗时多少、在哪一步失败。
我们现在还把trace_id和监控告警绑定起来了。只要某个trace_id标识的调用链里有任何节点超时或重试,系统自动生成一条结构化告警事件。故障复核从“翻日志大海捞针”变成了“按trace_id抽链子”,效率提升非常明显。
6. Agent-Reach的边界和它以后能长成什么样
做完上面这些以后,我经常被同事问到两个问题:Agent-Reach和市面上那些“Agent框架”有什么区别?它还能做哪些事?
先说边界。Agent-Reach不是一个模型开发框架。它不负责你怎么调Prompt、怎么选模型、怎么训练微调。它只是一个运行期调度和触达层,管的是“模型之外的事”。这反而让它很稳,因为Agent业务逻辑再混乱,只要消息信封、注册表、重试策略正常,整个系统就不至于散架。
再说扩展方向,我自己现在看到的至少有三条路。
6.1 跨团队的Agent互通
我们内部不同团队已经开始各自维护自己的Agent了,比如客服团队维护客服Agent、仓储团队维护库存Agent。如果没有一个公共的触达层,跨团队Agent之间只能靠私下约定API接口,协作成本极高。Agent-Reach下一步会做成一个平台化的公共服务,让各团队把自己的Agent注册上来,通过能力标签互相发现和调用,而不是通过私人关系互相“拉群对接”。
6.2 人机协同的审批节点
有些场景必须有人拍板,比如大额退款、库存异常调整。Agent-Reach未来会在工作流编排里增加一个“等待人工审批”的节点语义。当一个Agent触达这个节点,消息就停在待审批队列,不超时、不重试,直到人工操作后再次触发后续Agent。这是把“人”也当成一个特殊的Agent接进系统,形成完整的人机协同闭环。
6.3 权限和治理
Agent越来越多以后,权限就成了大问题。A团队的Agent能不能调用B团队的能力?能调用到什么程度?我们需要给能力标签配上白名单,每个Agent启动时声明自己允许访问的能力范围,Agent-Reach在路由时做校验。把这一步做扎实,多Agent系统才能真正承载跨部门的业务,而不是在实验环境里跑跑Demo。
6.4 回看整个项目的体会
从我自己的经验来说,Agent-Reach最难的不是写代码,而是意识到“Agent Application”和“Single Agent”是两个物种。单个Agent用得好,不代表一批Agent也能自动配合好。模型再聪明,如果消息送不到、状态不同步、链路看不清,都会变成那场“Agent打架”事故的翻版。
如果有人也想搭类似的东西,我的建议是:不要一上来就追求复杂的编排引擎和花哨的框架,先把最简单的三样东西做扎实——注册中心能准确反映“谁活着”、消息信封带上足够的基建字段、重试和幂等不再丢单。这三件事稳了,后面做广播、做编排、做跨团队协作都会顺很多。这也正是Agent-Reach现在最让我满意的地方:它不抢模型的风头,但兜住了整个系统最底层的可靠性。