一个星期天的下午,我原本只想把三个 AI Agent 的任务串起来跑通,结果发现整个调用链彻底失控:Agent A 要调用 Agent B 的能力,B 又依赖 C 的结果,而 C 的地址已经变了,A 还在用旧配置重试。日志里全是 timeout 和 connection refused。那一刻我意识到,我们缺的不是更聪明的模型,而是一层能让 Agent 之间稳定“触达”的通信底座。Agent-Reach 就是在这个背景下被我一点点搭出来的一个轻量级 Agent 触达层。它解决的问题,说白了一句话:在一个多 Agent 系统里,让一个 Agent 能像调用本地函数一样,可靠地找到并调用另一个 Agent 的能力。
这篇文章我会从最初遇到的具体故障讲起,把 Agent-Reach 的设计思路、注册发现机制、调用链路、稳定性的关键细节以及实测中踩过的坑,全部摊开来讲。如果你正在做多 Agent 编排、想把手里的 Agent 从“脚本玩具”变成真正可协作的系统,这篇内容应该能给你一套可以直接参考的方案。
1. Agent 协作的触达难题:调用链是怎么失控的
1.1 从单体函数到分布式 Agent,问题开始变化
先说说为什么单 Agent 项目不会遇到这类问题。当你只有一个 Agent 时,所谓“调用工具”不过就是进程内的函数调用,参数传进去、结果返回,最多加个 try except。整个过程是同步的、原子的,状态天然一致。
但一旦进入多 Agent 场景,事情就变了。每个 Agent 都是一个独立运行的服务,可能部署在不同机器、不同网络环境,甚至由不同团队维护。它们之间的协作不再是函数调用,而是实实在在的网络通信。这时候你立刻要面对三件事:地址从哪来(寻址)、消息按什么格式传(协议)、调用失败怎么办(容错)。任何一个环节没处理好,调用链就会像开头那样彻底崩掉。
1.2 我遇到的三个实际故障:死地址、协议打架、状态不同步
Agent-Reach 诞生的直接原因是三个具体的故障,我挨个说。
第一个故障是“死地址”。我们的 Agent B 挂在容器编排平台上面,IP 是动态分配的。Agent A 的配置文件里写着 B 的旧 IP,结果 A 连续重试了三分钟,全部超时。这个问题的本质是静态配置无法适应动态环境,没有服务发现机制,地址信息全靠人肉维护,必然出错。
第二个故障是“协议打架”。Agent C 团队用的是 JSON over HTTP,Agent D 那边坚持用 protobuf。两边为了“谁来转换格式”争吵了很久,最后集成出来的代码里全是 if branch。这个问题的本质是缺少统一的协议约定,每个 Agent 都在用自己的方式暴露能力,调用方被迫兼容 N 种风格。
第三个故障是“状态不同步”。某个 Agent 执行一个长任务需要两分钟,调用方默认的 HTTP 超时是 30 秒,结果请求被切断,但任务还在后台继续跑。调用方以为失败了,重新触发一次,任务实际执行了两遍。这个问题的本质是超时策略与任务生命周期不匹配,调用语义太粗糙。
1.3 结论:需要一层“触达服务”
把三个故障放在一起看,结论就很清晰了:多 Agent 协作不能靠零散的 HTTP 调用拼凑,必须在中间加一层服务,专门负责“Agent 之间怎么找到对方、怎么说话、怎么确认结果”。这层服务就是 Agent-Reach。
“Reach”这个词在这里的意思是“触达”。它要保证的不只是消息能发出去,而是在动态变化的网络环境下,调用方能够以可预期的方式到达被调方,并拿到符合预期的结果。这就不仅仅是网络通信的问题了,它还包含注册、发现、路由、超时、重试、幂等这些细节。
2. Agent-Reach 的定位与选型:轻量消息路由而不是重中间件
2.1 核心设计模型:注册中心 + 能力标签 + 双向连接
Agent-Reach 的整体模型由三部分组成。
第一部分是注册中心。每个 Agent 启动时向 Registry 上报自己的身份信息、服务地址、暴露的能力列表和当前状态。注册中心维护一张“谁在哪、能做什么”的实时表。
第二部分是能力标签。让调用方不直接指定“我要调用 Agent B”,而是指定“我要调用具备某某能力的 Agent”。标签可以分层次,比如text_analysis是能力大类,sentiment是具体技能,v2是版本号。调用方只描述需求,路由层去匹配具体执行者。
第三部分是双向连接。Agent 与 Agent-Reach 节点之间建立持久化的双向连接,而不是每次调用都新建连接。这样做既能减少握手开销,也能让路由节点实时感知下游状态,发请求前就知道对方是否健康。
2.2 为什么不用全对称 P2P
在设计阶段,我确实考虑过类 P2P 方案:每个 Agent 都直接跟其他 Agent 通信,彼此互相发现,不设中心节点。这种架构听起来很“去中心化”,看起来也酷。
但我很快否掉了它。原因是多 Agent 系统的拓扑往往是“少数调用方,大量被调方”,而且 Agent 的能力提供方相对集中。如果做全对称 P2P,每个节点都要维护全量路由表,还要实现 Gossip 协议来同步状态变化。这个复杂度对于一个内部工具层来说,完全是负资产。
举个例子,我们有 20 个 Agent,全对称连接意味着要维护 20 份路由表,任何一个 Agent 上下线,都要向其他 19 个节点广播状态。这是指数级的维护成本。而中心化注册的路由方式,只有注册中心和它管理的节点需要感知变化,范围可控得多。
2.3 为什么不用 gRPC 全家桶 / 服务网格
也有人问我:既然要通信,为什么不直接用 gRPC 加服务发现,甚至上个 Service Mesh?
我的理由有两点。第一,gRPC 做的是“RPC 通信”,它不管事情的分层和编排。Agent 之间不是简单的一问一答,还有长任务、异步回调、任务去重这些语义,需要业务层面的协议来规范。第二,Service Mesh 解决的是微服务之间的流量治理,但多 Agent 场景的粒度更大,对于 Agent 的“能力”管理、任务状态生命周期,Mesh 根本不知道也不关心。
Agent-Reach 不做流量拦截劫持,不做 sidecar 部署,它做得更“薄”一些:它是一层应用级路由和触达服务,把 Agent 的能力抽象和位置抽象分离,并管理一次调用的完整生命周期。这个定位让它比 Mesh 轻很多,部署也非常简单,一个单进程节点 + 一个数据库表就够起步了。
| 维度 | 直接 HTTP 调用 | gRPC + 服务发现 | Agent-Reach |
|---|---|---|---|
| 寻址方式 | 静态配置 | 服务发现 | 能力标签 + 自动寻址 |
| 协议约定 | 无统一标准 | 强类型接口 | 统一消息信封,内部可变 |
| 调用语义 | 简单请求/响应 | 请求/响应 | 同步、异步、长任务统一 |
| 失败处理 | 各写各的 | 重试库兜底 | 超时分级 + 幂等去重 |
| 部署成本 | 最低 | 中高 | 低 |
3. 注册、发现与路由:让 Agent 具备“可触达”属性
3.1 注册信息的组织方式:服务名 + 能力标签 + 元数据
Agent 启动时,需要向注册中心上报一段注册信息。我设计的注册结构是这样的(这是简化后的 JSON 示例):
{ "agent_id": "agent-sentiment-v2-01", "service_name": "text-analysis", "capabilities": [ "text-analysis.sentiment", "text-analysis.keyword", "text-analysis.summary.v2" ], "node": { "host": "10.20.30.40", "port": 9527, "transport": "tcp" }, "metadata": { "version": "2.1.0", "owner_team": "nlp-group", "max_concurrency": 8, "region": "cn-east" }, "status": "ready" }字段设计的思路可以拆开讲。agent_id全局唯一,用于幂等和审计;service_name是一个粗粒度的分组,表示这个 Agent 属于哪类服务;capabilities是细粒度的能力列表,这是核心,路由完全依赖它;metadata里的键值对可以随便放,用于更精细的筛选;status则表示当前是否 ready。
这里有个很关键的细节:不要把 IP 写死进配置,而是运行时通过环境变量或者所在主机的探测方式获取。容器环境下尤其要注意,Pod 每次重启 IP 都可能变,靠配置文件里写死 IP 很容易犯我们之前“死地址”的错。
3.2 心跳与健康检查:状态不能只靠启动时打一次卡
注册只是第一步。如果 Agent 中途崩了、网络闪断了、或者进入“活着但处理不了请求”的状态,注册中心必须感知到。
Agent-Reach 的心跳机制设了三个阈值参数:
heartbeat_interval:Agent 每隔 5 秒上报一次心跳;heartbeat_timeout:路由节点超过 15 秒没收到心跳,就标记该 Agent 为“suspect”;cleanup_threshold:超过 30 秒还是 suspect,直接摘除路由表。
有意思的是,心跳本身不只是一个“我还在”的信号,它还会携带当前负载信息,比如正在处理的请求数、队列长度。路由层做负载均衡时,优先选负载低的节点。
还有一种情况:Agent 的进程还活着,但内部某个依赖的服务挂了。这时候心跳仍然正常上报,但 Agent 可以调整自己的status为degraded,同时把受影响的能力从capabilities里临时移除。这样路由层就会绕开它,等恢复后再重新上报。这个“能力级健康检查”比进程级检查更接近真实业务,也是我们实测中非常管用的设计。
3.3 路由匹配与负载均衡规则:避免“花式路由”
路由匹配的逻辑其实很简单,我刻意没有设计复杂的规则引擎。整个过程就三步:
- 调用方通过消息信封声明需要的能力,例如
capability: "text-analysis.sentiment"; - 路由节点在能力表中查找所有声明了该能力的 Agent;
- 用可选的过滤条件(region、version、metadata)缩小范围,然后按负载策略选一个。
负载均衡策略我用的是“最少在途请求数”而不是轮询。原因很简单:不同请求的处理时间差异极大,有的 100ms 就返回,有的要算 5 秒。轮询会把短请求也分给已经排长队的节点,而按在途请求数分配,能更平滑地适配这种不均衡负载。
路由层返回值的时候,顺便带上actual_target_id,告诉调用方这次到底是哪个 Agent 处理的。这对排查问题很重要,你不需要去日志里大海捞针。
4. 调用链路设计:同步、异步与长任务
4.1 同步调用的超时分级:一个 timeout 打天下肯定出问题
同步调用是最常见的场景,但超时策略不能一刀切。我们设定的是三级超时:
- 连接超时:3 秒。只在建立网络上连接时生效,用于快速发现地址不可达;
- 处理超时:默认 30 秒。从请求发出到拿到最终响应,覆盖下游 Agent 的正常处理时间;
- 总预算超时:60 秒。包含排队等待时间。
问题在于,下游 Agent 处理时间可能波动很大。原本 30 秒的接口,在一次数据量暴增时跑了 45 秒。所以 Agent-Reach 又加了一层:超时协商。调用方在请求里带上preferred_timeout,被调方在处理前先评估自己的队列长度,如果预估会超过,就在响应头里回一个would_timeout: true和一个建议等待时间。调用方可以决定继续等、走缓存或者换一个 Agent 重试。
这个机制的价值是:不是所有超时都该被当成错误。有些任务本就耗时,你主动等待可能比放弃重来更划算。超时应该是一种策略,而不是一个固定的数字。
4.2 异步任务与任务状态回传:ACK 与 callback
不是所有调用都适合同步等待。有的 Agent 任务,比如长文本生成、视频分析,可能要跑几分钟。Agent-Reach 对这类场景设计了异步模式。
调用方发出请求后,路由节点快速回一个 ACK,里面有task_id。这时候调用方就知道“任务被接收了”,可以去做其他事情。任务完成后,被调方把结果推送到调用方声明的回调地址。这里的回调地址是调用方在初始请求里提前注册的,只需要传一个callback_url。
为了防止回调丢失,我们加了一个配套机制:回调重投。如果回调推送失败,路由节点会按照1min / 5min / 30min / 2h / 12h的节奏重试,最多投递 5 次。同时,调用方需要主动回报一个ack_receipt,告诉路由节点“我收到了,别再推了”。这个设计参考了 Webhook 投递场景的常用做法,不算新鲜,但在 Agent 协作里非常实用。
4.3 长任务的探测机制:回到两阶段提交的思路
长任务的另一个坑是:调用方等不到结果,它怎么知道任务到底完成了没有?
一开始我们偷懒,让调用方每隔 10 秒轮询一次任务状态。这个方法能用,但在高并发下会产生大量无意义的查询,路由节点压力很大。
后来我在 Agent-Reach 里做了一个接近“两阶段提交”的简化状态机。核心是四个状态:
RECEIVED:请求已到,被调方已确认接收;PROCESSING:任务执行中;SUCCEEDED:任务完成且结果已落库;FAILED:任务失败,附带失败原因。
路由节点维护每个任务的当前状态。调用方第一次拿到 ACK 后,只需要发送一次subscribe请求订阅这个task_id,当状态从PROCESSING变成SUCCEEDED或FAILED时,路由节点主动推送状态变更,调用方再根据状态决定拉取结果还是发起补偿。
这个方案把轮询从“固定频率”变成了“事件触发”,实测下来,无意义查询减少了 80% 以上,用户体验也更像“等一个通知”而不是“反复刷新”。
5. 稳定性细节:超时重试与幂等,少踩坑
5.1 重试策略:指数退避 + 抖动,而不是疯狂重试
网络环境下,调用失败是常态。但很多人的重试策略写得太粗暴:失败就立刻重试,重试还失败就再试,直到自己放弃。
这种策略在链路瞬时抖动时有一定作用,但如果下游真的是集体故障,疯狂重试只会把服务打挂。
Agent-Reach 内置的重试策略是:指数退避 + 随机抖动。重试间隔 =base_interval × 2^attempt + random(0, jitter),其中 base 是 500ms,attempt 从 0 数起,jitter 控制在 0 到 200ms 之间。
为什么加随机抖动?因为如果所有调用方都在相同的时间点重试,产生的流量会形成“共振波”,对下游造成更严重的打点。抖动让重试时间点错开,能明显降低这种风险。
同时,不是所有错误都值得重试。连接错误、超时这种“下游可能只是忙”的错误可以重试;而像“能力不存在”“参数不合法”这种确定性错误,重试只会浪费资源。我会把错误分类放进策略表,由 Agent-Reach 自动判断,而不是让业务代码手动判断。
5.2 幂等设计:调用 ID 与去重表
前面提到过“任务执行了两遍”的问题,根因就是缺少幂等控制。Agent-Reach 的做法是:每个调用都生成一个全局唯一的call_id,这个 ID 在整条链路中透传,包括重试时也保持不变。
被调方在执行前,先去数据库的去重表里查一下这个call_id是否存在。如果存在,直接返回上一次的结果,不再重新执行。这个表很简单,字段就三个:call_id、result_path、created_at。我加了 24 小时的过期清理,防止表格无限膨胀。
这个方案有一个前提:被调方必须拥有可靠的存储。如果被调方自身是无状态的,每次调用都没法记住之前的call_id,那幂等就只能靠调用方在结果不确定时主动查询。所以实际操作中,我把幂等拆成了两层:路由层负责缓存已完成的响应快照,被调层负责在执行前做去重。两层都生效时,才能做到真正不重不漏。
5.3 网络波动下的降级方案:降优先级而不是一刀切
网络抖动时,一个常见的贪心做法是“发现调不通就立刻切换备选 Agent”。这听起来很合理,但我要提醒一句:不要过度切换。如果每次失败都换一个 Agent,可能导致所有 Agent 都开始接收不熟悉的新任务,状态一致性变得更加混乱。
我们采用的降级方案是分级的:
- 第一级:单次失败后重试当前 Agent 两次;
- 第二级:请求带上
allow_fallback: true,在路由层找同能力的备选节点; - 第三级:降级为“缓存结果”,如果这个能力对应的请求结果在缓存里存在且未过期,直接返回缓存的旧结果;
- 第四级:向调用方返回明确的降级错误码,不再尝试。
这个顺序的核心思路是:先保证一致性,再保证可用性。随意切换执行 Agent 的代价,远大于一次重试的代价。
6. 运行实测与踩坑记录
6.1 性能基线:一个路由节点能托起多大规模的 Agent 网络
把 Agent-Reach 接到真实环境之后,我第一时间做了压测。配置是一台 4C8G 的普通云主机,模拟 30 个 Agent 节点,每个 Agent 有 3 个能力标签,请求大小约 1KB 的 JSON 消息。
实测数据如下:
- 单跳转发延迟:0.3ms 左右,几乎可以忽略;
- 吞吐量:在 5 万 QPS 时单节点 CPU 使用率仅 35%;
- 连接数:单个节点能稳定维持 5000 个长连接,空闲连接的内存开销约为 20 字节/条。
这个数据说明:Agent-Reach 不是性能瓶颈。多数项目的 Agent 数量远远到不了这个量级,反而是 Agent 本身的推理耗时成为主要瓶颈。所以做这层触达服务时,不用过度考虑极端性能,把精力放在正确性上更重要。
6.2 踩坑记录:连接复用导致的心跳误判
运行过程中最容易出问题的,反而是看起来最简单的“心跳”。我们有些 Agent 平时几乎不处理请求,路由层发现“这个连接很久没数据了”,决定关闭连接。但 Agent 端的心跳线程不知道这个变化,还在正常上报——结果心跳发不出去,反复失败几次后,Agent 就被注册中心误判为宕机。
排查到最后,问题出在两边的空闲连接回收策略不一致。路由层设了 30 秒无数据就断开连接,但 Agent 心跳间隔是 5 秒,按说不会超过 30 秒。真正的原因是 Agent 端线程池满了,心跳任务被排队,5 秒的心跳实际延迟到了 40 秒。线程池一忙,心跳就丢了,连接又恰好被路由层回收,两边状态彻底对不上。
修复方案有两个:一是把心跳线程独立出来,不跟业务线程池混在一起;二是把 Agent-Reach 的空闲回收时间拉长到心跳间隔的 3 倍以上。这个坑说明,任何分布式系统中的“少量基础服务”都要认真对待,心跳和连接管理这种看起来不起眼的部分,恰恰是最容易引发连锁故障的。
6.3 踩坑记录:能力标签过于粗糙导致路由错乱
另一个坑和路由管理有关。一开始我们给 Agent 打标签时很随意,比如有的 Agent 写了text-analysis,有的写了text_analysis,下划线不一致;还有的 Agent 把“概括”写成summary,另一个写成summarize。路由时如果按精确字符串匹配,会出现大量“明明有对应能力,却找不到可调用的 Agent”的问题。
后来我做了两件事。第一是引入标签规范:大类用连字符,比如text-analysis;具体技能用点号,比如text-analysis.sentiment。第二是在注册中心增加一个alias字段,允许相同含义的关键词互相映射。但这只是补救,根源还是要有一份能力标签的“字典文档”,让每个 Agent 的接入方都按字典来。
经过这件事,我强烈建议你在设计 Agent 能力注册体系时,第一周就确定好命名规范,否则后面标签数量膨胀后,改起来牵一发动全身。
6.4 安全与隔离:内部系统的底线
Agent-Reach 目前主要部署在可信内网环境,但安全设计不能全裸奔。我做了三方面的事情:
- 传输层加密:Agent 与路由节点之间采用 mTLS 双向认证,确保连接的两端都是已知身份,防止伪造节点接入;
- 能力白名单:每个调用方 Agent 在注册时声明自己能访问的能力范围,路由层做鉴权,不匹配直接拒绝;
- 敏感操作审计:所有调用记录都落日志,包括调用方、被调方、能力、耗时、结果状态。一旦出问题,能快速定位是谁在什么时间调用了哪个 Agent。
这个审计日志最初只是为排查问题设计,后来发现它还能用来统计每个 Agent 的真实调用率和失败率,反过来指导容量规划。所以我的建议是:日志别省,哪怕最开始只是 JSON 写文件,也要把链路信息记完整。
7. 从 Agent-Reach 到真正的 Agent 网络
Agent-Reach 目前已经在我们内部稳定跑了几周,几十个 Agent 节点通过它互相调用,最直观的变化是:没有再出现过因为地址变更导致互相找不到、因为协议不一致导致联调拖延、因为状态不同步导致重复执行的问题。
我现在越发觉得,“让 Agent 协作起来”这件事,技术上的难点反而不在模型推理时延,而在于把每个 Agent 的能力、位置、状态和调用边界管清楚。Agent-Reach 不负责让 Agent 变得更聪明,它负责让 Agent 之间的协作变得可靠、可观测、可治理。如果你正在做一个 Agent 编排项目,遇到类似“调用链总是稀碎”的问题,我的建议是:先不要急着上 20 个 Agent 和复杂的编排引擎,先把每个 Agent 的“可触达性”做好——注册、发现、路由、超时、幂等,这一层稳了,上层写业务逻辑才踏实。
最后补充一个实用技巧:Agent-Reach 的注册信息与路由规则,建议全部用配置文件加版本号维护,纳入版本管理,别直接用数据库手动改。我们踩过“测试环境改规则顺手改到生产”的坑,后来所有变更都走配置发布流程,才把这个问题堵死。这个东西很简单,但能省掉不少半夜被叫起来排查问题的麻烦。