ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Agent-Reach:多智能体协作的触达层设计与生产实践

Agent-Reach:多智能体协作的触达层设计与生产实践

搞多智能体系统的人,大概率都经历过这么一段:单个 agent 跑得好好的,一旦让它们互相协作,问题立刻变成“谁该调谁”“调不到怎么办”“调到了但状态丢了”。我在团队里同时维护十几个不同职责的 agent 之后,彻底被这类问题缠住了。后来我做的 Agent-Reach,就是专门解决这层问题的中间层:把“智能体之间的触达”从业务代码里抽出来,独立成一套路由、调度、状态恢复和协议适配的基础设施。

这篇文章会把 Agent-Reach 从设计动机、架构取舍,到核心模块实现、压测数据、生产环境排障的完整过程写出来。如果你正在做一个 multi-agent 系统,或者已经有几个 agent 在互相调用但开始感到混乱,这篇文章应该能帮你少走不少弯路。我不太喜欢写那种只贴架构图不给细节的文章,所以下面所有内容都以我实际跑过的版本为基础,参数和配置也都来自实测。

1. 为什么必须有一层“触达”而不是直接互调

1.1 直接互调最容易被低估的三个成本

两个 agent 互相调用的时候,最朴素的做法就是 HTTP 直连。A 知道 B 的地址,B 知道 A 的格式,双方约定好 JSON 结构就完事了。团队只有两三个 agent 的时候,这么做一点问题都没有。

但当 agent 数量上来之后,直连模式的三个隐性成本会迅速放大。

第一个是地址管理分散。每个 agent 都要维护一份“我可能要调用谁”的列表,这份列表散落在配置文件、环境变量、甚至代码常量里。新增一个 agent 实例,要改所有人的配置;下线一个 agent,又要等所有人把配置更新完。这个过程中间漏掉一个调用方,线上就会出现“调了一个已经不存在的地址”的故障,而且排查的时候你都不知道去哪找这个配置。

第二个是重试、幂等、超时这些机制各搞各的。A 调 B 超时了,A 自己重试三次;B 因为网络抖动其实已经收到请求并且处理完了,只是响应没回来,于是 A 的重试导致 B 重复处理同一个任务。如果这个任务是有副作用的——比如发通知、改数据库、调外部接口——重复处理就是事故。每个 agent 各自实现幂等逻辑不仅浪费,还很难统一标准。

第三个成本是会话上下文的延续。agent 之间不是每次调用都孤立的,很多时候 A 需要 B 基于一段上下文继续处理,比如客服 agent 把用户原始问题交给分析 agent,分析 agent 再交给 SQL agent。如果上下文是各进程自己持有的,A 重启一下,整条链路的记忆就断了。这还只是进程级故障,更常见的是会话状态散落在不同 agent 的私有存储里,查问题的时候要把好几个系统的日志拼凑在一起才能还原用户的完整请求路径。

Agent-Reach 的做法很简单:把地址管理、重试幂等、状态传递这三件事,统一收归到触达层。

1.2 我最初的错误:把触达做成了业务流程编排

Agent-Reach 第一版其实走了一个大弯路。我当时觉得既然都收归到一个层了,那干脆把业务流程也定义在这层好了——哪个 agent 先执行、哪个后执行、条件分支怎么走,全部用一份配置来驱动。结果这个“编排器”很快变成了灾难。

原因很直接:业务流程是高度业务化的东西,变化频率远高于技术基础设施。客服 agent 的流程改了一版,编排配置要跟着改;营销场景想插入一个新的处理环节,编排配置又要动。团队里负责业务的同事既要理解业务,又要理解编排规则,每次改动都要提心吊胆地重新验证整条链路。更要命的是,编排配置一旦写复杂,你实际上是在用一堆 YAML 重新实现一个工作流引擎,而市面上成熟工作流引擎已经发展十几年了,我不认为自己能做得更好。

后来我想明白了一件事:agent 之间的“流程决定”是 agent 自己的事,触达层不该干预。Agent-Reach 的定位应该是“你要去找谁,告诉我;怎么找到、怎么送达、怎么恢复,我来做”。这就是从重编排转向轻路由的关键分界点。Agent-Reach 的名字里带 Reach,含义就是“触达”,不是“编排”。

2. 触达层架构:轻路由而非重编排

2.1 核心设计原则

Agent-Reach 重新设计时,我给自己定了几条原则,后续所有模块都是围绕这些原则展开的。

第一,期望式调用,不控制式调度。调用方只需要声明目标 agent 的逻辑名字,不需要知道具体实例地址。触达层负责解析这个名字,选择可用实例,并进行协议转换。

第二,统一入口,但入口不承载业务逻辑。所有调用都穿过 Agent-Reach,但触达层不做任何“下一步该做什么”的判断。它只理解目标、消息和期望的结果类型。

第三,异步优先。跨 agent 调用本质上是分布式协作,同步阻塞式交互会把单点故障放大。Agent-Reach 内部的处理链路全部走异步消息,只有入口对外保留同步接口给调用方选择——很多场景其实用异步更适合,这点后面压测里再讲。

2.2 组件划分

Agent-Reach 采用了几组组件协作的整体结构:

  • 入口网关(Ingress Gateway):接收来自各 agent 的调用请求,做鉴权、流量统计、协议入站解析。它不接触业务字段,只把信封结构提取出来。
  • 路由引擎(Router):根据目标 agent 名字查路由表,结合健康状态和权重选择具体实例,然后封装成内部消息投递。
  • 状态网关(State Gateway):负责会话快照的读写,幂等键去重,以及跨节点传递上下文。这是 Agent-Reach 区别于普通消息队列的关键点。
  • 连接复用池(Channel Pool):维护到各个 agent 的长连接。支持 gRPC 和 HTTP/2,负责连接复用、连接重建、背压控制。
  • 可观测性模块(Observability):所有请求都在入口生成 trace_id,贯穿整条链路;每个节点的处理耗时、失败原因、重试次数都汇总到监控。

这几部分中,路由引擎和状态网关是最核心的。路由引擎解决“找得到”,状态网关解决“不丢不乱”。

2.3 为什么“只有一个中心”不会变成瓶颈

有人会担心:所有调用都经过一个中心层,这不是把单点风险又带回来了吗?

我的回答是:Agent-Reach 的中心是“注册中心 + 路由中心”,不是“计算中心”。它不参与 agent 的业务计算,只做寻址、转发、状态读写。这类中心化组件的处理能力是可以通过横向扩展提升的,而且它带来的可观测性和状态一致性收益,远大于那一点转发开销。

当然,Agent-Reach 后续也可以整体拆成多实例部署,路由状态放到分布式存储里。第一版我用单点部署来降低复杂度,线上跑了两三个月后才扩展成三节点,后面部署章节会给具体做法。

3. 三个核心模块的实现细节

3.1 路由表:动态权重和健康状态

路由表是 Agent-Reach 的“通讯录”,但它比普通通讯录多两层东西:健康状态和动态权重。

路由表不是每次请求实时扫描 agent 列表,而是启动时加载,随后通过健康检查结果增量更新。一条典型的路由记录类似这样:

agents: - name: sql-query version: 2.1.0 instances: - id: sql-query-01 endpoint: http://10.0.1.21:8080 protocol: grpc weight: 50 health: active - id: sql-query-02 endpoint: http://10.0.1.22:8080 protocol: grpc weight: 50 health: active

路由引擎选择实例时,默认使用加权轮询,权重跟实例的实测健康状况绑定。Agent-Reach 每 5 秒对每个实例发一次轻量探测,连续三次失败,就把该实例标记为 inactive 并从候选集合里摘除。恢复探测的频率是 10 秒一次,连续两次成功就重新加回来。

这里有个细节:健康探测路径必须和真实业务路径分开。我见过很多系统把健康检查打到业务接口上,结果业务接口因为流量大响应慢,健康检查误判实例不可用,反而把流量全部导到另一个实例导致雪崩。Agent-Reach 的每个 agent 接入时,必须提供一个单独的 /healthz 端点,这个端点只检查进程是否存活、依赖的最小连接是否存在,不做任何业务操作。

加权轮询之外,路由引擎还支持节点亲和路由。比如某些 agent 实例本地缓存了特定数据,调用方可以在请求信封里带一个hint字段,路由引擎优先选择hint指向的实例;如果该实例不健康,再回退到权重轮询。这个机制在处理有本地状态的节点时非常有用。

3.2 会话状态:幂等与快照

状态模块是整个 Agent-Reach 里我投入时间最多的地方。

先解决幂等。任何一条跨 agent 请求,都会在入口生成全局唯一的request_id,结构是trace_id + 序号。调用方如果主动重试,必须复用同一个request_id。接收方 agent 内部维护一个最近 request_id 的缓存,相同 request_id 来了,直接返回上一次的处理结果。

这个逻辑说起来简单,但实现时有个容易踩坑的地方:缓存不能只记请求 id,还要记请求的 hash。因为被调用方可能已经重启过,内存里的 request_id 缓存丢了。Agent-Reach 状态网关会把最近 N 个 request_id 以及消息体 hash 持久化到后端存储。收到请求时,如果 id 相同但 hash 不同,说明调用方用了同一个 id 发不同的内容,这属于调用方 bug,会直接报错并让调用方看到明确的错误码。如果 id 相同且 hash 相同,就返回缓存结果。

再解决会话上下文。多 agent 协作时,后续 agent 往往需要前面 agent 的部分中间结果。Agent-Reach 的做法是快照式传递:每个会话(session)有一个session_id,状态网关按 session 存储序列化后的上下文快照。调用方发起新请求时,可以选择引用某个session_id,路由引擎会把该快照随信封装载到目标 agent。

快照不适合存太大内容。一开始我天真地把完整中间数据都塞进快照,结果一次调用链传了十几 MB 的 JSON,直接把网络和序列化拖垮。后来改成引用式:快照里只存关键摘要和状态位置,原始大对象存在对象存储里,目标 agent 需要时再按引用去拉取。快照字段设计如下:

{ "session_id": "sess_7f3d92", "revision": 3, "summary": "用户意图已识别:退款咨询;商品已确认;待调用订单系统", "payload_ref": "s3://agent-reach-snapshots/sess_7f3d92/rev3.json", "expire_at": "2026-05-20T12:00:00Z" }

revision字段是防串号的关键。后面故障复盘里我会专门讲一个因为缺它导致的消息覆盖问题。

3.3 协议适配:统一信封

团队里不同 agent 是用不同语言、不同框架写的,有的只暴露 REST 接口,有的用 gRPC,还有内部 SDK 型调用。Agent-Reach 的协议适配层把这一切统一成同一个内部信封结构。

向外暴露的接口很克制,只有几个:

  • dispatch:同步调用某个目标 agent,返回结果或超时错误。
  • dispatch_async:投递消息后立即返回,后续目标 agent 通过回调或消息队列的返回通道回传结果。
  • register_agent:新 agent 启动时向路由表注册自己的能力、版本、地址和协议类型。
  • get_session/update_session:操作会话快照。

这些接口既可以通过 REST API 调用,也可以通过 gRPC 调用,还可以直接使用 Agent-Reach 提供的客户端 SDK。适配层做的事情是把外部请求解析成内部统一信封:

{ "trace_id": "t_8a2f1b04", "request_id": "t_8a2f1b04-1", "source": "customer-service", "target": "sql-query", "method": "execute_query", "payload": { "sql": "SELECT count(*) FROM orders WHERE created_at > ?", "params": ["2026-05-01"] }, "session_ref": "sess_7f3d92", "timeout_ms": 8000, "retry_policy": { "max_attempts": 3, "backoff_base_ms": 200, "backoff_factor": 2 } }

我特意在信封里把trace_id、request_id、source、target这些元数据和业务payload分开。业务代码看到的是干净的 payload,不用关心触达层逻辑;基础设施看到的是完整的元数据,不用解析业务字段就能做路由和监控。

协议适配层还有一个功能:协议转换。比如 source 用的是 gRPC,target 只暴露 HTTP Webhook,适配层负责把 gRPC 请求转换成 HTTP 请求,并把响应映射回原来的调用方。这块代码不复杂,难的是要处理好不同协议的时间戳格式、错误码映射和二进制字段的编解码。Agent-Reach 目前内置了 gRPC、HTTP/JSON、内部二进制这三个适配器,新增协议只需要实现一个接口。

4. 压测数据:瓶颈不在代码,在协议设计

Agent-Reach 的压测我做了三轮,每一轮都推翻了一些想当然的结论。

4.1 第一轮:200 并发就露出原形

第一版我信心满满地写了路由引擎和协议适配,然后直接用 Locust 上了 200 并发。结果不到 5 分钟,失败率开始飙升,P99 延迟到了 850ms,进程 CPU 打到 90% 以上。

一开始我以为路由引擎的计算逻辑复杂,结果 profile 一看,大头都在 JSON 序列化和连接创建上。每个请求进来,入口网关拿encoding/json把 payload 转成 map 再取字段,转发时又序列化一次;每个请求都现场创建一条到目标 agent 的 gRPC 连接,用完再断。这两件事在低并发下没问题,一旦超过几百并发,开销立刻被放大。

这个教训后来被我总结成一句话:在触达层,协议设计远比路由决策重要。路由就几次查表几行代码,而序列化和连接管理是每个请求都要面对的。

4.2 第二轮优化:连接复用、压缩、超时拆分

第二版做了三件事。

第一,连接池化。Agent-Reach 启动时按目标 agent 预建一批连接,请求到了直接从池里取,用完归还。gRPC 本身支持 HTTP/2 多路复用,所以池里连接数不需要很多,每个目标节点保持 10 条活跃连接就够顶住较大流量。连接池还要带自动重建机制,发现连接确实断了就丢弃并新建。

第二,序列化优化。内部信封放弃encoding/json,换成经过代码生成的高性能序列化格式,并开启 gzip 压缩。对于大 payload,压缩节约的带宽让 P99 延迟下来的非常明显。

第三,超时拆分。之前一个请求从进门到出门共用一个超时时间,网络抖动一次,整个请求就超时了。现在改成三段超时:请求排队等待最长 100ms,路由寻址最长 30ms,目标处理等待最长按调用方指定。三段超时互不影响,前面的阶段超时了可以直接快速失败,不用占用线程等后面的结果。

优化完的第二轮压测,500 并发 P99 降到了 210ms,失败率 0.3%,整体稳定了不少。

4.3 第三轮:5000 并发下的稳定配置

决定把 Agent-Reach 投生产之前,我按预计峰值打了一次 5000 并发。结果发现偶尔会出现“整机线程数爆炸”的迹象,排查后发现问题出在线程池边界:入口网关接收任务用的是无界队列,一旦下游响应慢,任务排队排到天边去,每个任务占一个线程,内存和线程数一起涨。

解决方法是给线程池设置明确的队列上限,队列满了直接走背压逻辑。背压不是把请求丢掉,而是快速返回一个503 overloaded状态,调用方看到这个状态会走自己的重试策略。这比无限排队带来的连环超时要健康得多。加上背压之后,第三轮压测的数据终于稳定下来:

压测轮次并发数有效QPSP99延迟失败率主要瓶颈
第一轮200约320850ms5.2%序列化、频繁建连
第二轮500约800210ms0.3%无界队列隐患
第三轮5000约3200290ms0.02%线程池边界

有一个容易忽略的细节:压测时务必要让客户端也复用连接。如果压测脚本每次请求开一个新连接,你的压测结果会同时包含连接建立的损耗,导致误判服务端性能。我第三轮压测用了一个常驻连接池的客户端脚本,数据才真正反映服务端水平。

5. 生产故障排查:五个“不可能”的 bug 复盘

上线之后遇到的几个诡异问题,每一个排查过程都挺有意思,我把它们完整记录下来。

5.1 偶发超时:健康检查把工作线程池占满了

现象:某天開始 P99 偶尔飙到 2 秒以上,但 QPS 没有明显波动。

排查过程:先看监控,路由引擎 CPU 不高,GC 也正常;再看日志,发现大量请求在“等待目标连接”阶段超时。但从监控上看连接池的活跃连接数并没有打满。

最后是用线程 dump 解决的。栈上显示很多线程卡在healthcheck.Ping()的阻塞调用里。原来我在实现健康检查时,为了复用 worker 线程池,直接把 ping 操作丢进了同一个执行任务的线程池。某个目标 agent 的/healthz接口处理慢,或者被防火墙挡了迟迟不返回,就会占住线程一直等。大量健康检查请求占掉的线程,真正业务请求能用的线程就少了,于是随机超时。

修复很简单:健康检查单独走一个独立的、小容量的线程池,且每次 ping 强制设置短超时,例如 1 秒。健康检查本来就该是轻量动作,绝不能让它在主链路上抢资源。

5.2 会话串号:全局 TraceID 的线程污染

现象:某个客服会话的上下文,莫名被另一个用户会话的数据覆盖了。

排查过程极其拧巴。从日志看,update_session写入的 session_id 确实对应 A 用户,但内容里却包含了 B 用户的订单信息。代码里 session_id 生成是 UUID,不可能重复。

最终定位到问题出在全局 TraceID 传递上。Agent-Reach 早期为了日志方便,把 trace_id 放在一个线程局部变量里,然后在各适配器间传递。但在异步回调场景下,任务从线程池的线程 A 切到线程 B,线程局部变量并不会跟着走。更糟的是,有些中间件在异步完成后不及时清理这个局部变量,导致下一个复用该线程的请求,读取到上一个请求残留的 trace_id。

这个问题本质上是“异步上下文泄漏”。修复方案是所有跨线程的上下文必须显式传递,不能依赖线程局部变量。Agent-Reach 后来统一改用上下文对象作为参数传递,入口处生成后,任何切换线程的边界都显式把上下文传下去。这虽然代码上啰嗦了一点,但彻底消除了串号隐患。

5.3 消息乱序:重试叠加版本号解决

现象:一个会话的上下文,回退到了两分钟前的状态。

原因是两个请求几乎同时到达更新同一个会话。请求 X 里带着“订单未支付”的快照,请求 Y 里带着“订单已支付”的快照。由于网络乱序,Y 先到先写,X 后到又把数据覆盖了回去,老数据盖掉了新数据。

我从数据库多版本并发控制得到灵感:会话快照加一个单调递增的revision,每次更新都必须带上“我基于哪个 revision 变更”。如果当前存储的 revision 大于请求带来的 revision,说明这条更新是在旧版本基础上发起的,直接拒绝。这个设计在分布式系统里很常见,但在 agent 协作场景经常被忽略,因为大家默认消息会按序到达。实际上跨网络、跨服务、跨重试,乱序是常态,不是异常。

5.4 路由震荡:健康检查参数太激进

现象:某个本应很稳定的 agent 实例,在一个小时内被摘除并恢复了好几次,每次摘除都会触发一批请求路由到其他实例,带来额外的延迟波动。

健康检查的逻辑是“连续三次失败摘除,连续两次成功恢复”。问题出在检查周期太短,5 秒一次,而目标实例偶尔会遇到超卖造成的短暂响应延长。三次失败可能只是一次 15 秒的网络抖动,结果就把实例摘了;恢复检查又过于敏感,10 秒后有两次成功就立刻加回来,于是路由表在一个小时内反复震荡。

修复的关键不是单纯延长周期,而是给摘除和恢复分别加阻尼。连续失败次数提高到 5 次,且失败窗口期内摘除后的恢复至少等待 60 秒;恢复成功要求连续 5 次检查通过。这样短时抖动不会触发摘除,真正的故障也能较快隔离。路由震荡往往比节点故障本身更伤系统,它会放大全网延迟波动,这一点排障时值得优先排查。

5.5 内存泄漏:连接池的过期连接没有清理

现象:Agent-Reach 进程运行了 10 天后,堆内存持续增长,最终 OOM。

排查起来也很典型。heap profile 显示大量grpc.ClientConn残留。原因是连接池里对连接的活跃次数做了计数,但目标 agent 主动断连后,连接池的清理程序只清掉了活跃连接里的断连项,没有把“空闲待回收”列表里的连接移除。这些连接已经不可用,却一直占着内存和句柄,最终把进程拖崩。

修复就一行代码的事:在连接池的定期回收逻辑里,同时检查“空闲连接是否仍然有效”,无效就从元数据列表里删掉,再关闭底层连接。这类问题的教训是,连接池不只是“建连接”那么简单,“回收连接”同样要进入设计范围。任何长期运行的服务,都要把连接生命周期作为一等公民对待。

6. 部署清单和一些可以直接抄的配置

6.1 最小的部署拓扑

Agent-Reach 第一版部署非常简单:一个无状态网关服务、一个状态存储、一个路由缓存。生产环境我建议跑三副本,配合一个负载均衡入口。状态网关可以挂到 PostgreSQL 或者 Redis 上,路由表缓存用 Redis 是因为它天然支持 TTL 和发布订阅,路由变更能实时通知到所有副本。

我给出的最小配置是一份可直接改造的docker-compose.yml骨架:

version: "3.9" services: agent-reach-gateway: image: agent-reach:2.4.0 environment: AR_REGISTRY_REDIS_ADDR: redis:6379 AR_STATE_STORE_DSN: postgres://ar_user:ar_pass@postgres:5432/agent_reach AR_ROUTING_REFRESH_MS: "5000" AR_CHANNEL_POOL_SIZE: "10" AR_HTTP_PORT: "8080" ports: - "8080:8080" depends_on: - redis - postgres redis: image: redis:7.4-alpine postgres: image: postgres:16-alpine environment: POSTGRES_DB: agent_reach POSTGRES_USER: ar_user POSTGRES_PASSWORD: ar_pass

生产环境里网关副本之间不需要互相通信,只要共享同一份 Redis 和 PostgreSQL 就能保证状态一致。

6.2 配置项速查表

我整理了一份我自己最常用的配置项,控制台配置界面里不一定全部开放,但环境变量或配置文件里都能覆盖:

配置项默认值建议值作用
路由刷新周期5000ms5000ms从 Redis 拉取路由变更
健康检查探测间隔5s5s定期检查目标实例
健康检查摘除阈值3次失败5次失败防止抖动误摘
恢复检查阈值2次成功5次成功防止路由震荡
连接池大小(每目标)1010-20控制并发连接数
线程池队列上限无界5000触发背压保护
快照 TTL24h视业务而定防止状态无限增长

6.3 监控和告警建议

Agent-Reach 的监控重点和业务系统不太一样。我最关注的指标是四个:request_volume(请求量)、error_rate(错误率)、latency_p99(P99 延迟)、routing_table_health(路由表健康度)。

其中routing_table_health是最容易忽略的:它表示当前路由表中健康实例数占注册实例数的比例。一旦这个比例持续低于 70%,说明大量 agent 实例处于不健康状态,即使网关本身处理能力没问题,整体服务质量也会快速下降。

告警阈值我建议这样设置:

  • 请求量陡降超过 50%,且持续 3 分钟:大概率是链路断裂或上游调度异常。
  • 错误率单接口口径超过 2%:进入关注;超过 5%:立即拉群。
  • P99 持续超过 1 秒:说明下游某些 agent 处理能力不足,该扩容或查慢调用了。
  • routing_table_health低于 70% 持续 5 分钟:立刻排查实例掉线原因。

6.4 如果重做一遍,我会改的三个决策

写到最后,说点个人感受。Agent-Reach 这个项目做下来,技术上的收获很多,但最值钱的认知是关于“分层”的。触达层和业务层的分离,不是简单地抽出几个公共模块,而是把“我该做什么”和“我该怎么到达”彻底解耦。前者永远属于业务,后者才属于基础设施。

如果让我重新做一遍,有三个决定我会尽早改变。

第一,一开始就把状态网关和路由引擎分开设计,而不是先揉在一起再拆分。它们解决的问题不同,但第一版为了省事合并了代码,后来花了额外的重构成本才拆开。

第二,异步优先的思想应该在第一天就确立。第一版很多接口是同步阻塞式,为了接入简单。等到实际运行的 agent 开始互相等待、形成长链路的时候,再往异步迁移,改动面比一开始就异步大得多。

第三,路由表的设计一开始就该考虑“按能力路由”,而不是只按名字路由。按名字路由要求调用方精确知道目标 agent 是谁,这在 agent 数量少时没问题,agent 多了以后,调用方往往只关心“谁能做这件事”,不关心具体是哪个 agent。Agent-Reach 支持了这种能力匹配,但属于后补功能,从名字路由演进到能力路由,数据结构变动不小。如果你现在就有多个能力重叠的 agent,这个功能越早规划越好。

Agent-Reach 目前已经稳定跑了几个版本,路由表、状态网关、协议适配这三个核心模块也都是从实际故障里打磨过的。如果你也在做多智能体系统,希望这篇记录能帮你少踩一些我踩过的坑。

返回列表