去年下半年我在重构一个内部的多智能体协作平台时,被一个问题反复折磨:每个智能体单独拎出来都能干活,但一旦让它们互相调用、共享上下文、协同完成任务,整个系统就变成一团乱麻。有的 agent 不知道去找谁要数据,有的 agent 被重复调用导致负载飙升,还有的 agent 一超时,整条任务链就崩掉。说白了,我缺的不是更强的单点能力,而是一层能管住“谁触达谁、怎么触达、触达不了怎么办”的基础设施。于是我自己动手搭了一层专门做触达编排的中间层,内部代号就叫Agent-Reach。
这个项目里 Agent-Reach 并不是一个类似“万能 API 网关”的转发工具,它更像给所有智能体配了一张统一的“通讯录 + 路由表 + 容错策略包”。从设计之初它要解决的就是 multi-agent 场景下最 boring 但最致命的问题:服务发现、路由选择、调用容错和全链路观测。这篇文章把整个项目的设计思路、核心模块和我实测中踩过的坑完整拆出来,如果你是做 AI Agent 应用、或者正在被多智能体协作搞得头疼,应该能直接拿走不少经验。
1. 从“单兵作战”到“触达网络”:Agent-Reach 要解决的问题
1.1 多智能体协作为什么总是卡在“互相找”这件事上
先说说我遇到的问题场景。单个智能体跑一个任务,本质上是一条直线:接收请求,调工具,出结果。但多智能体不一样,它是一个网状结构。比如一个客服系统里,有意图识别 agent、情绪分析 agent、知识库检索 agent、订单查询 agent,用户问一句“我之前买的东西为什么还没发货”,意图识别 agent 判断出这是售后查询,接着要把上下文转给订单 agent,订单 agent 又要调知识库 agent 获取物流政策,最后还要把情绪分析结果一起组装成回答。
听起来很简单,真做起来全是问题。最直接的就是“谁在哪儿”:每个 agent 部署地址不一样,有的是 HTTP 服务,有的走 gRPC,有的是内部函数直调,还有的其实是另一个团队维护的第三方接口。如果每个 agent 都各自维护一张 endpoint 清单,代码里到处写死 IP 和路径,改一次部署就要通知所有人连夜改配置。
更麻烦的是调用关系没有规则。agent A 可以直接调 B,B 又可以调 A,形成了隐藏的循环依赖;某个 agent 压力一大,响应从 200ms 涨到 5s,下游调用方还在用固定 3 秒超时,于是整条调用链路不断报错。等到你想排查问题的时候,日志是各个 agent 自己打的,trace 上下文断了一路,根本不知道是哪一环出了问题。
Agent-Reach 就是冲着这些问题去的。它的核心职责只有三块:告诉调用方“谁能提供我要的能力”、用统一规则完成一次触达、在触达失败时按策略优雅降级。它不干预单个 agent 内部的推理和逻辑,只专注管好 agent 和 agent 之间、agent 和外部工具之间的那条“连接”。
1.2 Agent-Reach 的逻辑定位
我一开始也纠结过,要不要直接用一个现成的服务网格或者 API 网关来做这件事。后来发现它们解决的是另一层问题:服务网格管的是流量,API 网关管的是对外暴露接口,但多智能体之间的触达是有“语义”的——调用方要找的不是某个 URL,而是某种“能力”,比如“查订单状态”“翻译文本”“识别用户情绪”。具体哪个 agent 能提供这个能力,此刻这个 agent 是否健康,用同步调用还是异步消息,这些都需要一个更上层的编排。
所以 Agent-Reach 的定位更像一个面向智能体的语义触达层。它向上对 agent 暴露一组简单的 SDK 接口,向下管理各种 agent 和工具的实际 endpoint。调用方只需要说“我要调用 order.query 这个能力”,Agent-Reach 负责把它翻译成具体请求、发送到正确的目标、处理超时和重试、并返回统一格式的结果。
这样设计有一个额外的好处:agent 之间的耦合从“代码级”降到了“契约级”。你和另一个团队合作,不用再跑到对方仓库里看接口实现,只要双方约定好能力标识和数据格式,触达的事交给 Agent-Reach 就行。
2. 触达层的三个核心模块与调用链设计
2.1 能力注册中心:先解决“谁能干什么”
整个 Agent-Reach 最早起步的模块就是一个能力注册中心。每个 agent 启动时需要向注册中心上报自己提供的能力清单,包括能力名称、版本、入参出参的 JSON Schema、endpoint 类型、健康检查地址等。注册中心把这些信息聚合起来,形成一张能力索引表。
我最初版本用的是简单的数据库表 + 定期心跳上报,后来改成 etcd 存储,因为需要对上线下线有更实时的事件通知。每当有 agent 实例上线,注册中心会推送一条变更事件给所有订阅了该能力的调用方。这么做的好处是调用方本地会缓存一份能力路由表,Agent-Reach 本身挂掉或者网络抖动,已经建立的路由关系还能继续工作一段时间,不会全校瘫痪。
这里有个容易踩坑的点。能力名称的命名规范必须从第一天就定好,而且要强制约束。我们用的格式是域.动作.对象,比如crm.order.query、nlp.sentiment.analyze。千万不要让每个团队按自己喜欢的方式起名,什么queryOrder、OrderQueryService、get_order_info混在一起,注册中心直接就变成垃圾场了。哪怕格式严格一点,后面做权限管理和路由匹配都会省心很多。
2.2 路由与编排引擎:不只是一个转发器
注册中心告诉你“有哪些能力”,路由编排引擎负责决定“这一次调谁”。这里的策略不是简单的负载均衡,而是要考虑多约束条件。
我的实现里,每个能力可以注册多个 provider(不同的 agent 或工具都声明自己能提供同一个能力)。路由引擎按以下顺序筛选:
- 上下文亲和性:如果调用方已经指定了要优先使用某个 provider,比如之前对话会话锁定了某一个 agent,那么路由引擎保持会话内的连续触达,不切换到别的实例。这对一致性问题很重要,尤其是涉及到多轮对话状态的智能体。
- 健康状态:拉取每个 provider 最近的心跳和错误率,过滤掉已经熔断或处于降级状态的实例。
- 权重与优先级:支持两种模式,一种是主备模式(primary + backup),一种是负载模式(round-robin / 最少连接)。主备用于核心链路,负载用于可以水平扩展的服务。
其中上下文亲和性是我真正遇到问题后才加进来的。早期版本不管这些,每次调用都用负载均衡,结果智能体 A 先调了一次“查订单”能力,准备再接着调“退款申请”能力时,路由引擎把请求分给了另一个 provider,那个 provider 没有对应的会话状态,直接返回“找不到上下文”。后来在能力契约里增加了一个可选的 affinity key 字段,路由引擎用这个 key 做哈希分配,才把问题解决。
2.3 传输适配器:屏蔽协议差异的最后一公里
触达层面向调用方提供的 SDK 接口只有那么几个,但底层为什么能同时接 HTTP、gRPC、消息队列、甚至直接进程内调用?靠的就是传输适配器层。
每一种底层通信方式都实现同一个接口,这个接口大致只有三个方法:Invoke(request)用于同步请求-响应,Cast(request)用于异步发送-不等待,InvokeStream用于流式返回(跑大模型的时候特别有用,文本是一块一块生成的)。适配器内部负责把契约数据转成目标服务需要的具体格式,比如对方是 protobuf 定义的接口,适配器就按 proto 序列化;对方是 JSON-RPC,适配器就封装成 JSON-RPC 请求。
我一开始想把所有底层协议都统一成 HTTP + JSON,省事。结果发现很多现有的 agent 是 Python 写的 gRPC 服务,强行改成 HTTP 意味着要改一堆既有代码。适配器模式的好处是既能兼容老系统,又能让新服务直接走最高效的通信方式。传输层花的时间不值得省,它是保证触达层能被顺利推下去的关键。
2.4 一次完整调用的数据流
把上面几个模块串起来,一次完整触达大致是这样:
- 调用方 agent 通过 SDK 发出
reach.invoke("crm.order.query", payload, options)。 - Agent-Reach client 先从本地路由缓存里找到
crm.order.query对应的 provider 列表,同时带上调用链追踪 ID。 - 路由引擎做一次筛选,选定目标 provider。
- 传输适配器根据 provider 的类型,构造实际请求并发送。
- 目标 agent 执行完逻辑,返回结果,适配器把响应统一包装成内部 Result 结构,包含状态码、数据体、耗时、错误信息。
- 调用方拿到 Result,再决定是继续下一步还是走失败兜底。
这条链路我测过无数次,正常情况下一次触达的额外开销控制在 1~2 毫秒左右(不包括目标 agent 本身的耗时),因为路由筛选和适配器转换都是轻量计算,真正的网络耗时还是占主导。但如果加了重试和熔断检查,这个耗时会有波动,后面专门讲容错的部分。
3. 命名服务与路由策略:最容易被低估的部分
3.1 为什么说“找得到”比“调得快”更重要
很多人听说 Agent-Reach 做得是个路由,第一反应是“哦,就是个负载均衡”。但实测下来,多智能体场景路由的难度和传统微服务完全不在一个量级。
传统微服务的路由非常成熟,目标服务基本是稳定的 IP + 端口,路由本质上就是挑一个可用的实例。但多智能体触达层面对的是会动态变化的能力列表。同一个能力,可能上午只有一个 agent 提供,下午另一个团队上线了新版 agent 也声明了同一个能力。能力版本升级期间,老版本和新版本要同时在线跑一段时间,路由层还要支持按版本灰度。
这让我意识到,路由策略的核心不是“选一台机器”,而是“选一个能够完成用户语义目标的 agent 实例,并且这个选择的依据本身要可解释、可调试”。没有命名服务作为基础,后面所有策略都是空中楼阁。所以我在 Agent-Reach 里单独做了命名服务模块,它不光管服务名,还管能力名、版本规则和标签。
3.2 基于能力而非端点的路由规则
我把 Agent-Reach 的路由设计成两层。第一层是“能力解析”,第二层是“实例选择”。
能力解析做的事,是把调用方请求里的能力名(如crm.order.query)匹配到一组 provider 集合。这个过程需要处理 aliases(别名)和版本范围。比如crm.order.query@^2.0表示只要 2.x 版本的 provider,但如果系统里只有一个 1.8 版本的 agent,路由引擎会返回失败,而不是自动降级到旧版本——语义结果可能完全不同,自动降级比失败更危险。
实例选择则是第二层筛选,逻辑比较简单,除了上面说的健康状态和权重,我还会加上一个 locality 策略。同一个数据中心的 agent 优先互调,避免跨机房触达带来的高延迟。这个策略对效果提升很明显,刚开始没做,测试环境没感觉,上了生产发现跨机房专属链路调用动辄多出几十毫秒 RTT,加了 locality 后整体延迟降了一个档次。
还有一个容易被忽略的设计:空结果不一定是错误。有些 agent 比如“情绪分析”,面对中性文本时会返回一个低置信度的中性结果,这不算失败。但路由层如果只按 response code 判断,可能会触发重试,导致重复消耗算力。所以我在契约里给 Result 加了一个definitely_success标志,供上层判断是否值得重试。
3.3 路由失败时的回退策略
路由可能失败的原因很多:没有 provider、所有 provider 都不健康、没有匹配版本、provider 超时等。Agent-Reach 对每种失败类型都定义了明确的行为:
- 找不到 provider:直接返回错误码,调用方不重试,因为它重试一万次也没用。
- provider 全部不健康:如果配置了 fallback 链(比如主用订单 agent,备用走一个简化的接口服务),执行 fallback;否则快速失败。
- 超时或者响应异常:按调用方指定的重试策略执行,默认最多重试一次,且只允许选择另一个 provider。
我在实际运行中发现,fallback 链的配置最能体现业务对可用性的容忍度。有些场景允许降级返回一个粗糙结果(比如“订单查询失败,请稍后再试”),有些场景不行(比如支付失败,必须让用户明确知道失败原因)。所以 fallback 不该是一刀切的配置,每个能力都要能独立设置 fallback 行为和降级文案。
4. 超时、重试与熔断:让多个智能体协作不失控
4.1 超时时间不能拍脑袋,要有分层设计
多智能体协作里最让人头疼的故障源,就是超时设置。之前团队的做法是每个调用方自己定超时时间,结果五花八门,有的设 2 秒,有的设 30 秒,链路一长,整体耗时完全不可控。
Agent-Reach 引入了一套分层超时机制。每个触达请求可以带一个总的 deadline,整个链路内的所有子调用必须在这个 deadline 内完成。比如一次客服回复生成任务,总 deadline 设为 10 秒,分配给意图识别 1.5 秒、情绪分析 1 秒、订单查询 2.5 秒、知识库检索 3 秒、大模型生成 2 秒,加起来正好 10 秒。任何子调用如果提前完成,剩余时间可以传递给下游;如果子调用超时,立刻返回失败,不再无谓等待。
这套机制实现起来并不复杂,就是在请求 context 里塞一个 deadline 时间戳。但收益巨大。调用方不会因为某个 agent 卡住而无限等下去,整条任务链的可预测性大大增强。强推 deadline 机制后,我们线上 P95 耗时直接从原来的 15 秒以上降到了稳定 8 秒左右,用户体验改善非常明显。
4.2 重试策略:不是所有失败都值得重来一次
重试看起来简单,实际是最容易引发雪崩的操作。一个 agent 已经负载很高了,响应超时,如果所有调用方都立刻重试,等于给这个 agent 又来一波猛攻,直接打挂。
Agent-Reach 的重试策略有几个默认原则。第一,只重试安全的调用,也就是幂等的操作。写操作默认不重试,除非调用方显式声明这是幂等的。第二,重试必须换 provider。同一个 provider 刚才能超时,马上再打一次大概率还是超时,换一个健康的实例才有意义。第三,重试次数有上限,默认 1 次最多 2 次,控制整体延迟增长。
另外我加了 Exceeded deadline 检查:如果当前剩余时间已经不足总 deadline 的 20%,直接放弃重试,不管重试多少次都来不及在 deadline 内返回了,不如快速失败让上层做兜底。
4.3 熔断与隔离:别让一个坏 agent 拖垮全局
熔断这块我借鉴了微服务里比较成熟的 Circuit Breaker 模式,但根据大模型类 agent 的特点做了一些调整。大模型 agent 的失败模式往往不是“完全不可用”,而是“间歇性高延迟”。所以触发熔断的条件不仅是失败率,还包括慢调用率。比如在滑动窗口内,如果有超过 30% 的调用耗时超过某个阈值(比如 3 秒),就认为该 provider 处于亚健康状态,打开熔断开关,后续请求快速走 fallback,不再进入这个 provider。
熔断还有一个半开状态,隔一段时间放少量试探请求进去。如果试探成功,慢慢恢复流量;如果继续失败,继续维持熔断。
我在实践中的感受是:熔断参数一定要按 provider 实际表现单独调,通用的默认值只能保底。有的 agent 是纯规则服务,正常耗时 50ms,那 200ms 就值得警惕;有的 agent 底层是 GPT 类大模型,一次生成要 2~5 秒,那阈值就得放宽到 10 秒。参考熔断组件 Hystrix 的经验,我对每个能力维护了一套独立的熔断配置,尽管运维成本高了一些,但整体稳定性提升显著。
4.4 限流与背压:防止调用方打爆下游
触达层还有一个隐性作用,就是当多个调用方同时请求同一个能力时,避免瞬间流量打爆单一 provider。Agent-Reach 提供了两层限流:一是调用方维度的并发限制,防止某一个调用方霸占某能力;二是 provider 维度的并发保护,超过阈值的请求直接返回资源忙错误,而不是排队等。
排队这件事我特意不做。排队看着是友好,实际上在 deadline 机制下毫无意义——排到一半已经超时了,白白浪费了 provider 的处理资源。直接快速失败,让调用方决定是降级还是另找 provider,比在队列里干等更合理。
5. 可观测性与调试:多智能体排障的真实体验
5.1 全链路追踪:把一盘散沙串成一条线
多智能体系统最反人类的排查体验,是问题不知道出在哪一环。用户报了一个 bug,你说“我来看看”,然后开始翻 20 个 agent 的日志。如果每个 agent 的日志系统还不统一,通常需要开 5 个终端窗口同时 tail,最后发现在中间的某一个调用里数据参数传错了。
Agent-Reach 在早期就把 tracing 内建进去了。每一次触达调用都携带一个全局 trace ID,路由引擎在每个节点记录 span 信息,包括 provider 名称、耗时、状态、错误信息,统一上报到日志平台。前端调试时只需要一个 trace ID,就能看到完整的调用时间线。
这个设计最值钱的地方不在于“好看”,而在于它让“触达失败”从一团迷雾变成一个可定位的事件。我见过太多多智能体项目死在 debug 阶段,不是能力不行,是出了问题没人能说清楚在哪一步挂的。可观测性不是锦上添花,是这类系统的生死线。
5.2 本地沙箱与录制回放
为了高效调试 Agent-Reach 本身的路由逻辑,我还在本地搭了一个沙箱模式。沙箱里跑的不是真实 agent,而是 mock 服务,每个 mock 可以配置固定延迟、固定错误率、特定异常行为。路由引擎的各种超时、熔断、fallback 策略,先在沙箱里跑一遍测试用例,再上到 staging 环境验证。
更有用的是录制回放功能。线上实际请求经过 Agent-Reach 时,会把请求和响应上下文录制一份(脱敏后)存下来,可以拿到沙箱里回放,用来复现某个特定路由决策是否合理。比如线上有一个请求走了 fallback,但你不确定这个 fallback 决策对不对,于是回放请求,调整不同策略参数看结果差异。这个功能成了我后来调优路由策略的主要工具。
5.3 告警规则的经验值
可观测性不只是事后排查,还需事前预警。我总结了两个经验值,直接分享给读者参考:
- 当某个能力的 P95 耗时在 10 分钟内连续上升超过 40%,说明这个 provider 可能出问题了,需要告警。
- 当熔断从关闭到打开的切换次数在 1 小时内超过 3 次,说明熔断参数设置不合理或者 provider 状态飘忽,需要人为介入。
告警规则弄太多反而是负担,运维同学会被告警疲劳淹没。我只保留了这三四个高价值的告警项,剩下的交给 dashboard 自行查看,实践中效果比原来全量告警好了太多。
6. 落地实践中的取舍与未尽事项
6.1 什么时候你其实不需要 Agent-Reach
任何架构都有边界,Agent-Reach 也一样。如果你的系统里只有两三个 agent,调用关系基本固定,互相之间直接写调用代码完全可以,引入触达层就是徒增复杂度。如果你的 agent 都跑在同一个 Python 进程里,用函数直接调函数,那更不需要。
我的判断标准是:当你的 agent 数量超过 5 个,或者不同 agent 由不同团队维护,或者需要支持动态伸缩时,才去考虑搭一个触达层。过早引入会产生不必要的抽象,反而拖慢开发。
6.2 协议选型:JSON-RPC 还是一个自研绑定协议
Agent-Reach 内部传输协议默认是 JSON-RPC 风格,每个请求包含 method、params、id 三个字段,响应也遵循 JSON-RPC 规范。选它是为了简单、通用、跨语言友好。Python、Go、Node 都很好解析,浏览器端调试也方便。
但随着传输的数据量变大,尤其是大模型流式输出的场景,JSON 序列化和反序列化的开销开始有点扎眼。后来我对流式场景单独做了二进制分帧协议,普通 JSON 场景保持不动。这个折中方案效果最好。换协议这块千万不要一刀切,按调用类型实际流量大概率是混合的。
6.3 身份认证与权限控制
多智能体之间的互调一样要管好权限。Agent-Reach 在每个能力注册时就要声明访问控制列表(ACL),只有被授权的调用方 agent 才能调用。比如知识库检索能力可以被意图识别 agent 调用,但不能被用户画像 agent 调用,防止内部数据被无关模块拉走。
认证实现比较简单,在触达请求里带上调用方 agent 的身份凭证,Agent-Reach 校验凭证是否对该能力有调用权限,无权限直接拒绝。这个机制看起来多余,等你的平台开放给第三方 agent 接入时就明白有多必要了。
6.4 现在还存在的问题
整个平台目前能稳定支撑线上业务,但还是有几个问题我暂时没有完全解决。
一是复杂跨域事务。一个用户请求可能会触发多个 agent 协作,如果中间某个环节写完数据库但在返回时超时了,整个事务的一致性怎么保证?Agent-Reach 目前只保证触达层的可靠性,业务上的一致性需要 agent 自己实现 Sagas 或者用事件补偿,这已经超出了触达层的范围,但作为整体设计必须提前思考。
二是上下文漂移。多智能体协作经常需要把一个大上下文传给多个 agent,Agent-Reach 自己不做上下文存储,全靠调用方在 payload 里携带。上下文一大,传输开销就上去了。后续我打算引入一个共享上下文存储服务,但这又会引入新的依赖和一致性问题,还在权衡。
三是人为干预通道。让系统完全自动处理可能出错的情况,我始终不放心。现在 Agent-Reach 支持管理员手工上下线某个 provider、手工切换流量,但在极端的线上事故中,这个操作还是不够快捷。未来做成“一键止血按钮”会更安心。
6.5 如果我重做一次,会在哪一步投入更多
这个问题我无数次问过自己。如果让我重新开始,我会把第一版的能力契约设计做得更严格,花更多时间和各团队对齐 JSON Schema 和命名规范。触达层本身的技术实现并不难,真正难的是让所有人都愿意遵守同一套契约。
一旦契约混乱,后续的路由、权限、trace 全部跟着倒霉。
另外我会更早地搭建沙箱调试环境。第一版几乎是在 staging 环境里靠手工构造请求来测,效率极低。后来沙箱建好后,整个迭代速度明显变快,很多策略可以在几分钟内验证完,而不是等环境、造数据、再验证。前期的“慢”和后期的“快”之间,确实需要慢慢找到平衡点。