资讯详情

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

📅 2026/10/26 23:18:34 | 华诺云谱 👁 阅读
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 用的是 gRPCtarget 只暴露 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_passpostgres: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_p99P99 延迟、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 目前已经稳定跑了几个版本路由表、状态网关、协议适配这三个核心模块也都是从实际故障里打磨过的。如果你也在做多智能体系统希望这篇记录能帮你少踩一些我踩过的坑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑