我现在还记得第一次把三个Agent串起来跑通全链路时的那种兴奋,但兴奋没维持多久就被一盆冷水浇醒:Agent A调用Agent B的时候,B因为上下文过长直接超时,A却在那里傻等;另一边,Agent C产生了一个关键动作事件,却没有任何通道能主动通知到下游系统,只能靠轮询硬顶。做多智能体系统做到后面,真正卡脖子的往往不是模型能力,而是Agent与Agent、Agent与外部世界之间的“触达”链路。Agent-Reach 就是在这个背景下长出来的自研中间层,专门解决多Agent系统中的连接、路由、投递和观测问题。下文会从问题本质、架构拆解、投递语义、实战踩坑和可观测性几个方面,把这套设计完整摊开。
1. Agent越建越多,触达却还在“喊话”:Agent-Reach要解决什么问题
多Agent系统刚搭起来的时候,大家习惯的做法是“谁要调谁,直接写在代码里”。两个Agent跑Demo没问题,一旦规模上来,问题就像竹笋一样往外冒。
1.1 我看到的三类典型触达失败
第一类是同步等待型触达。Agent A需要一个确定性结果,于是调用Agent B。但Agent B背后可能挂了一长串工具调用,或者是LLM推理延迟高,结果A把线程池占满,整个服务像被石头堵住一样。常见的场景是“旅行规划Agent”调用“天气Agent”,天气Agent再调一个外部门户接口,接口慢了两秒,A这边所有请求都排起了长队。
第二类是异步事件型触达。Agent处理完一个长时间任务,比如“每天定时生成一份行业摘要”,任务在后台跑了几分钟。完成后它需要把结果推给用户、推给下游系统,但原本的架构里根本没有事件通道,只能靠下游轮询。轮询最大的问题是延迟不可控,最坏情况下用户要多等一个周期,而且大量空轮询把数据库压力顶得非常高。
第三类是流式触达。现在大多数LLM应用都是流式输出的,Token是一个一个蹦出来的。Agent把前置结果算完,要源源不断把Token推给前端,但直连模式下连接管理、心跳保活、背压处理全部散落在业务代码里,任何一个环节断了,用户看到的就是“生成中断”。
这三种形态有一个共同点:触达行为本身没有被抽象出来,每个Agent都在用自己的方式“喊话”。喊话听没听到、对方是不是还活着、消息有没有重复处理,全凭运气。
1.2 为什么不能靠互相写HTTP调用来解决
有人说,Agent之间通信不就是HTTP调用吗?给每个Agent配个地址,我调你你调我,不就行了?
问题出在“地址”和“协议”都不稳定。Agent实例随着弹性伸缩在变化,容器重启后IP就换了;有的Agent只暴露了gRPC接口,有的只能用HTTP,有的需要WebSocket做流式。业务代码里如果直接写对端地址,那每次变更都是一次上线事故。
更麻烦的是失败处理无法集中。A调用B超时了,要不要重试?重试会不会重复扣费?B返回了错误,错误码怎么统一?这些逻辑如果散落在每个Agent里,最终一定是各搞各的,出了事故都不知道该看哪个服务的日志。我见过太多团队把大量时间耗在“联调”上,其实就是因为Agent之间的交互规则没有统一收口。
1.3 Agent-Reach的目标
Agent-Reach 的设计目标说白了只有四句话:让每个Agent只关心自己能提供什么能力、需要什么能力;让触达过程有明确的超时、重试和幂等语义;让Agent的运行状态可以实时被发现;让每一次触达都能被追踪和回放。所有接入方不需要理解底层是HTTP还是gRPC,也不需要自己维护队列,只管声明能力和发起请求。
2. 一套网关加三类通道:Agent-Reach的架构骨架
Agent-Reach 不是一个大而全的Agent编排框架,它只做“触达”这一层。整个架构分成四块:Reach Gateway、Route Registry、Delivery Worker、Agent SDK,分别对应“收口请求、路由寻址、异步投递、接入埋点”。
2.1 核心组件和它们的分工
| 组件 | 职责 | 关键点 |
|---|---|---|
| Reach Gateway | 统一入口,负责协议转换、鉴权、限流和超时控制 | 无状态,可以水平扩展 |
| Route Registry | 保存Agent注册的能力、地址、运行状态 | 基于etcd实现,Lease自动过期 |
| Delivery Worker | 消费异步事件队列,负责重试、死信和幂等检查 | 至少一次投递语义的最终执行者 |
| Agent SDK | 让Agent以极简方式接入通道,自动注入trace上下文 | 几行代码完成接入 |
这个架构有几个好处。第一,所有请求都先进Gateway,协议差异被挡在最外层;第二,Route Registry不存“服务名”,存的是“能力标签”,这样Agent的物理地址怎么变都不影响调用方;第三,异步事件走队列,业务逻辑和投递逻辑彻底分离。
2.2 同步触达通道:Reach-RPC
同步通道选的是gRPC加Protobuf。选gRPC不是因为它是“新技术”,而是因为多路复用和流式承载能力对Agent场景非常关键。Agent调用Agent时,往往有大量的双向数据交换,HTTP/1.1的队头阻塞在这种场景下会很难受。
Reach-RPC约定了一套统一信封:请求必须带requestId、callerAgentId、targetCapability和deadline。Gateway拿着targetCapability去Route Registry查地址,查到后根据剩余时间设置下游调用的超时时间,然后转发。
这里有个容易被忽略的设计:deadline不是“本次请求最多等多久”,而是全链路同一个deadline。A给B的请求带的是总预算时间,B再去调C时,必须减去自己已经消耗的时间。如果B发现剩余时间不够完成C的调用,会直接快速失败返回,而不是让A继续干等。这个机制避免了下游一层层累加超时导致的“超时黑洞”。
2.3 异步触达通道:Reach-Event
同步通道解决的是“需要答案”的对话,但Agent世界里大量动作是“发完即走”的:事件通知、状态变更、定时任务结果。Reach-Event就是为这类场景设计的。
事件信封固定包含eventId、topic、sourceAgentId、occurredAt、payload。Producer调用SDK把事件推给Gateway,Gateway写入Redis Stream,Delivery Worker按topic消费并投递给订阅Agent。为什么用Redis Stream而不是直接调用订阅方?因为中间加一层队列后,生产者和消费者的频率完全解耦。Agent处理不过来时,消息会积压在队列里,而不是把请求直接打到Agent上把它拖垮。
Delivery Worker的处理流程是:从Stream读取任务,反序列化,检查幂等,投递到目标Agent,确认后提交消费位点。如果投递失败,做有限次重试;重试仍然失败,丢进死信队列并告警。
2.4 流式触达通道:Reach-Stream
大模型场景逃不开流式输出。Reach-Stream支持基于WebSocket和gRPC Streaming两种模式,网关负责长连接的生命周期管理。
流式通道最重要的是背压处理。Token生成速度远快于消费速度时,如果接收方不消费,发送方也不停地推,内存会先爆掉。Agent-Reach 在流式通道里加入了反向速率信号:接收方SDK会周期性上报可接收的缓冲余量,网关据此控制发送窗口。这个机制和TCP的滑动窗口逻辑类似,只不过跑在应用层。
3. 路由注册表与探活机制:Agent之间怎么知道该找谁
Agent之间触达的第一步永远是“找到对的那个Agent”。这一步如果靠写死地址,系统就退回了石器时代;如果靠全量广播,规模一大就会产生消息风暴。Agent-Reach 的做法是能力注册加状态探活。
3.1 注册的不是服务名,而是能力语义
每个Agent启动时,通过SDK向Route Registry注册自己,注册内容是能力标签,而不是服务名。举个例子:
{ "agent_id": "agent-email", "capabilities": ["email.send", "template.render"], "endpoint": "reach://agent-email/grpc", "lease_ttl": 15 }注册信息通过etcd的Lease机制自动续期。Agent正常运行时每隔5秒续租一次,进程挂掉后租约到期自动摘除。能力标签的价值在于:调用方不需要关心“发邮件应该调哪个Agent”,只需要声明“我需要email.send”,路由层会负责找到能够处理这个能力的Agent。
这就好比你订外卖只需要说“一份红烧肉”,不需要知道店里具体是哪个师傅做菜。师傅休息了,换一个师傅顶上,你作为顾客根本感知不到。
3.2 路由匹配的优先级
路由不是简单做精确匹配就行。Agent-Reach 的匹配策略从高到低分四层:
- 精确能力匹配:直接命中能力标签,权重随机选择目标实例。
- 标签语义匹配:调用方声明的能力和注册的能力存在同义词关系,比如“email.send”匹配“notify.email”。
- 兜底降级:目标能力没有可用实例时,路由到配置好的降级Agent,比如从“实时翻译”降级为“模板翻译”。
- 快速失败:没有降级方案时,返回明确错误码,而不是让调用方无限等待。
为什么要做语义匹配?因为Agent的命名和能力经常不一致。开发A叫“message.push”,开发B叫“push.message”,如果不是同一人维护,靠字符串硬匹配必然漏。Agent-Reach 在注册的时候允许声明能力别名,Gateway做一层轻量语义归一化。
3.3 探活与状态摘除
只靠心跳续租判断Agent存活的方案有个隐患:网络抖动会导致一瞬间的租约超时,Agent被误摘除,所有流量瞬间打到别的实例上,造成“探活雪崩”。
Agent-Reach 的探活做了三件事。第一,心跳和租约分离:心跳续租周期5秒,租约有效期拉长到15秒,即使偶尔一次心跳没续上,也不会立刻被摘除。第二,主动探测加连续失败计数:Gateway会定时发起轻量健康检查,只有连续失败3次才把实例切换成不可用状态,避免单次抖动触发状态翻转。第三,所有探活请求都带随机抖动:每个Gateway的探测时间不是固定的,避免所有探测请求在同一个瞬间打向Agent,把Agent自己压垮。
4. 投递语义的取舍:至少一次、精确一次和幂等消费的落地
做触达中间层,最绕不开的就是投递语义。消息到底会不会丢?会不会重复?这是所有接入方第一个会问的问题。Agent-Reach 默认选的是“至少一次”,而不是听起来更完美的“精确一次”。
4.1 三种语义的实际代价
| 语义 | 含义 | 场景示例 | 实现成本 |
|---|---|---|---|
| At-most-once | 丢就丢了,不重试 | 日志上报、非关键遥测 | 最低 |
| At-least-once | 保证不丢,但可能重复 | 业务通知、任务触发 | 中,需要配合幂等 |
| Exactly-once | 既不丢也不重复 | 资金扣款、库存扣减 | 高,需要分布式事务或严格去重 |
“至少一次”能成为默认方案,是因为它在可靠性和实现复杂度之间找到了一个非常实用的平衡点。真正实现“精确一次”需要在生产端、传输端、消费端三个环节同时去重,任意一环出问题,精确一次就会退化为至少一次。
4.2 为什么消费端必须自己做幂等
既然默认是至少一次,那么消息重复就是正常的,不重复才是运气好。所以Agent-Reach 强制要求所有异步事件的消费方实现幂等处理。
幂等键的设计很关键。我们在所有事件信封里固定放eventId,但不同场景的幂等键不是同一个字段:同步请求用requestId做幂等,外部回调用业务单号做幂等,定时任务用“任务ID+执行周期”做幂等。
消费端最简单的幂等实现是Redis SETNX,以幂等键设置一个带过期时间的标记,只有第一次写入成功的消费者才继续处理。如果业务本身有数据库,用唯一索引也是一样的道理。这里有个容易踩的坑:幂等标记的过期时间一定要比业务处理的最长时间更长,否则业务还没处理完,标记就过期了,第二个重复消息又会进来。
4.3 重试与退避:指数退避加随机抖动
Delivery Worker的重试策略不是傻乎乎地按固定间隔重试,那样会产生“重试风暴”。默认策略是指数退避加随机抖动:
delay = min(max_delay, base * (2 ** attempt)) + random(0, jitter)以base=200毫秒、max_delay=10秒、attempt从0开始计算:第一次重试延迟约200毫秒,第二次约400毫秒,第三次约800毫秒,以此类推。随机抖动的作用是让同一批次消息的重试时间点不再整齐划一,避免下游Agent在某一秒被突然涌入的重试流量打挂。
同时,Agent-Reach 把失败分类成可重试与不可重试。超时、连接拒绝、资源暂时不可用属于可重试;业务校验失败、权限错误属于不可重试。不可重试的消息不会进入重试队列,直接进入死信,避免无意义地消耗系统资源。
5. 超时重试、链路震荡与数据乱序:实测中填过的坑
架构设计再完整,也要经过真实流量的毒打。Agent-Reach 在灰度期间遇到过不少问题,有几个坑非常典型,写出来给后来人参考。
5.1 超时黑洞
第一次灰度时,Agent A调用Agent B超时,B超时后又去调C,结果A的线程池被大量超时请求占满。原因是没有全链路deadline,每一层都在按自己的默认超时设置等待,最终总耗时被无限放大。
修复方式就是在SDK里强制注入deadline。所有请求从入口处生成总预算,比如5秒。Gateway收到请求后,先计算当前已经消耗的时间,再由剩余时间决定下游超时。如果剩余时间只剩0.5秒,就直接返回“剩余时间不足”错误,而不是继续转发下去。这样做的代价是某些请求会快速失败,但换来了整个系统的线程不被拖死。
5.2 重试风暴
有段时间Agent B不稳定,故障窗口内大量请求失败。Agent A的重试逻辑是“失败就重试”,B自己也因为某些外部依赖做了重试,结果一个用户的请求被放大成几十次调用,B直接被压垮。
这是一个经典的“重试放大”问题。Agent-Reach 做了一次硬性约束:每个agent到下游的重试次数最多2次,并且重试只针对网络类超时和连接错误;同时Gateway层面进行重试预算控制,一个requestId的全链路重试总次数不超过3次。超出预算后直接返回失败,靠上层业务兜底,而不是无限重试。
5.3 数据乱序
异步事件通道上线后,我们很快发现一个怪现象:Agent D明明先发出了“订单已关闭”事件,随后又发出了“订单已支付”事件,但消费方最终看到的顺序是反过来的,导致订单状态被错误覆盖。
根因是事件经过不同分区时,没有严格的时间序保证。修复方案不是去拉平全局顺序,而是引入版本号机制。事件里增加一个sequence字段,消费方比较本地版本,只有新版本大于当前版本才允许更新。针对“订单关闭后再支付”这种极端情况,我们的策略是“版本优先,时间戳兜底”:版本相同才比较时间,时间也相同则拒绝更新并告警。
5.4 探活抖动引起的雪崩
前面提到探活用连续失败3次摘除,但有一次网络抖动导致所有Agent实例短暂不可达,Gateway集体把实例摘除,大量请求打到剩余实例上,剩余实例也被拖垮。最后发现根因是:虽然摘除条件严格,但探活请求是周期性瞬间发出的,抖动刚出现时所有Gateway同时判定失败,随后同时恢复,形成震荡。
修复方案是给每个Gateway的探活周期加20%到40%的随机偏移,并且加入冷却期:一次状态变更后,至少等30秒才能再次变更。这样即使发生抖动,也不会出现所有Gateway在同一秒摘除、又在同一秒恢复的共振现象。
6. 从trace到回放:Agent-Reach的可观测性设计
触达层出问题时,最痛苦的不是排查问题本身,而是没法复制现场。Agent-Reach 在可观测性上的投入,最终证明是性价比最高的一笔账。
6.1 每一跳都透传traceId和requestId
Agent SDK在入口自动生成traceId,生成后所有子调用都会携带这个traceId。Gateway在转发时会把traceId塞进Header,异步事件则塞进事件信封。这样无论是同步调用还是异步投递,最终都能通过一个traceId把所有相关日志串起来。
这里的细节是:不能只看“调了谁”,还要看“谁调的”。Agent-Reach 的事件日志里固定记录callerAgentId、targetAgentId、capability、耗时、返回码、序列号。排查问题时,只要拿到traceId,就能完整拼出触达链路。
6.2 核心指标的选取
指标不需要多,但一定要能反映触达链路的真实健康度。我们长期盯的核心指标如下:
| 指标 | 计算口径 | 预警阈值 |
|---|---|---|
| 触达成功率 | 成功回执数 / 总请求数 | 低于99%告警 |
| 链路时延P95 | 从网关进口到最终成功的耗时 | 超过3秒告警 |
| 异步队列积压量 | Stream中等待消费的消息数 | 持续超过5000告警 |
| 探活失败率 | 主动探测失败次数 / 探测总次数 | 超过10%关注 |
| 重试占比 | 发生过至少一次重试的消息占比 | 超过15%关注 |
不追求指标数量,是因为可观测性的核心是让故障能被看见,而不是制造十几个没人看的仪表盘。真正出事的时候,这些指标能一眼定位是路由问题、队列问题还是Agent自身问题。
6.3 回放工具:把“现场”留下来
Agent-Reach 做了一个比较重的日志回放能力。所有异步事件在投递前,都会把完整事件内容、幂等键、投递尝试次数、每次投递的返回结果写入日志存储。同步请求则记录请求摘要和最终响应。
有一次生产环境出现“用户收到重复通知”的投诉,我们就是靠回放定位的。根据userId关联出一批eventId,按时间线把事件从产生到消费的完整生命周期拉出来,发现是某台Worker消费成功后提交位点时发生网络闪断,导致该事件被第二个Worker重新消费。虽然现象上没有完全杜绝重复,但回放让我们在半天内就锁定了根因,而不是在几个服务之间来回猜。
踩了这么多坑之后,我的体会是:Agent-Reach 这类触达层的价值不在于它用了多新的技术,而在于它把一套枯燥但关键的规则固化下来,让每个Agent不用自己操心“连不上怎么办、重复了怎么办、出问题怎么查”。统一收口路由、统一投递语义、统一可观测,这三点任何一个做到了,多Agent系统都会好维护一个数量级。后续如果要继续演进,我个人最想强化的是流式通道的端到端追踪,因为大模型场景里流式消耗的资源最多,也最容易在“无声无息”中出问题。希望这份实践记录能给正在搭多Agent系统的团队一点参考。