ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent-Reach:面向多智能体协作的触达中间层设计与实践

Agent-Reach:面向多智能体协作的触达中间层设计与实践

做AI Agent落地的人,大概率都遇到过这么个尴尬事:单机Demo跑得飞起,模型推理、工具调用、工作流编排全都丝般顺滑,一进入多智能体协作、跨系统调用的真实环境,就被各种连接问题卡得死死的。Agent-Reach这个项目,就是我在这个背景下折腾出来的一个中间层,专门解决智能体在复杂环境里的“触达”问题——让一个Agent能稳定、安全、可追踪地找到目标Agent或外部服务,完成调用并拿到结果。

这篇文章我会把这套方案的核心思路、架构拆解、落地过程和踩坑实录完整写出来。适合正在做多Agent系统、智能体平台、或者想把AI能力接入公司内部系统的朋友参考,不管你是技术负责人还是一线开发,都能从中找到可以直接复用的设计方法和实操经验。

1. 为什么需要Agent-Reach:Agent孤岛与触达能力缺失

1.1 什么是Agent的“触达”问题

先说个具体场景。假设你手上有一个客服Agent,它需要查询订单状态、调用库存接口、再让另一个负责售后策略的Agent给出处理建议。单看每个Agent的能力,它们都是完整的,但真正连起来跑的时候问题就来了:

  • 订单服务在老系统里,只暴露SOAP接口,客服Agent只会调REST API;
  • 库存接口的鉴权方式是内部Token,但Agent框架里写死的是API Key;
  • 售后Agent部署在另一套K8s集群里,IP一直在变,没法写死地址;
  • 没有统一的可观测入口,一旦调用失败,你根本分不清是Agent逻辑错了、网络超时了还是服务没起来。

这些问题本质上都是同一个:Agent之间、Agent与系统之间,缺少一条稳定可靠的“通路”。我给这类问题起了个名字叫“Agent触达能力缺失”。单个Agent的智能水平再高,触达不到目标就等于是个孤岛。Agent-Reach的定位就是补上这条通路,做一个专门面向智能体协作的触达中间层。

1.2 为什么不直接让Agent互相直连

你可能会想,Agent之间直接调HTTP不就行了吗?我在早期确实是这么干的,但维护成本很快就失控了。直连模式下,每个Agent都要知道对端的地址、协议、鉴权方式、容错策略,本质上是把分布式系统里所有的复杂性平摊到了每个节点上。一旦Agent数量多了,调用关系变成网格状,光是升级一个协议就得所有Agent跟着改,这显然不可持续。

后来我换个思路:让每个Agent只和Agent-Reach通信,由Agent-Reach负责找到目标、转换协议、做鉴权、处理重试。这样调用关系从网格变成了星型,每个Agent只需要实现一套接入协议,剩下的都交给网关处理。说白了,这就跟公司里配了前台一样,你不需要认识全公司所有人,要找谁告诉前台就行。

1.3 Agent-Reach的设计目标

这个项目的核心目标归纳起来有五条:

  • 服务发现:Agent实例上线后自动注册,客户端不用关心目标在哪;
  • 协议转换:统一使用一种内部协议通信,对外兼容HTTP/gRPC/消息队列等;
  • 安全鉴权:所有调用都经过身份校验和权限校验,避免Agent被越权调用;
  • 可靠性:自动重试、超时熔断、负载均衡,让Agent调用具备基础设施级别的稳定度;
  • 可观测:每一次跨Agent调用都有链路追踪,调完能查、能分析、能复盘。

这五条并不是拍脑袋定的,而是从实际故障反推出来的。每一项背后都至少对应一次线上事故。比如鉴权,最初开放内网权限以为没事,结果一个Agent的请求Key泄露,内部接口被扫了一轮,从那以后鉴权就变成强制项而不是可选项。

2. Agent-Reach整体架构与核心模块拆解

2.1 总体架构:一条清晰的调用链路

Agent-Reach的架构不复杂,核心是三个组件加一条链路。

三个组件分别是:reach-gateway(网关)、reach-registry(注册中心)、reach-sdk(接入SDK)。调用链路大致是这样的:

  1. 发起方Agent通过SDK将请求发给reach-gateway;
  2. 网关解析请求头,从reach-registry查询目标Agent的可用实例;
  3. 网关按负载均衡策略选中一个实例,做协议转换后转发请求;
  4. 目标Agent处理完后按原路返回结果;
  5. 网关记录整条链路的trace信息,落日志和指标。

在部署形态上,reach-gateway是无状态服务,可以水平扩展,前面挂负载均衡器;reach-registry底层用的是Redis Cluster,负责存储Agent实例的注册信息和心跳状态。SDK则集成在Agent进程内,负责注册、心跳、发送请求和处理响应。

2.2 reach-registry:注册中心为什么选Redis

注册中心是整个系统的“通讯录”,Agent上线后要在这里报个到,并持续续期心跳,下线了或者宕机了会被自动移除。选型上我对比过etcd、Consul、Zookeeper,最后用了Redis Cluster,原因有三个。

第一是Redis的Key过期机制天然适合做心跳淘汰。Agent按期上报心跳时刷新TTL,中途崩溃了TTL到期自动删除,不需要额外写定时任务去清扫僵尸节点。这个机制能省掉很多代码。

第二是Redis的读写性能足够支撑高频心跳。即使有几百个Agent实例、每个5秒心跳一次,整体QPS也就几百,这对Redis来说非常轻松。用etcd也不是不行,但在这个场景下有点杀鸡用牛刀。

第三是运维成本低。大部分团队已经有Redis基础设施,不需要再维护一套etcd集群。考虑到Agent-Reach本身就是个中间层,再引入一个重量级依赖会让部署门槛变高,这不是我想看到的。

注册信息的数据结构我用的是Hash,Key格式为reach:agent:{agentName},Field是实例ID,Value是序列化的地址和元数据。配合一个reach:agents的Set来维护所有Agent名称集合,方便做目录查询。

2.3 reach-gateway:路由与协议转换的实现思路

网关是整个系统里代码量最大、最考验细节的部分。它要做的核心事情是“把外面发来的各种请求,转成统一内部请求,再发给目标Agent”。这里有两个关键设计点。

路由这块,我用的是“四元组路由”:namespace + sourceAgent + targetAgent + operation。namespace用于隔离不同业务线,避免A部门的Agent被B部门误调用;operation对应目标Agent里的具体动作。比如order-service / customer-agent / refund-agent / createRefundTicket,这样一个请求就能精确定位到某个业务域下的某个Agent的某个能力。

协议转换这块,Agent-Reach内部统一使用gRPC流作为Agent间通信协议,但在接入侧做了插件化适配器。外部系统可以继续用HTTP/JSON调用Agent-Reach,网关负责把REST请求转成内部gRPC请求,后端Agent则通过SDK直接暴露gRPC接口。这样既保住了外部系统的兼容性,又拿到了gRPC的强类型和流式传输优势。关于为什么用插件化而不是直接上HTTP网关,后面实操章节我会详细说。

2.4 鉴权与安全边界:不能让Agent裸奔

安全这块是我在最开始低估、后来又花最多时间补的模块。Agent-Reach的鉴权分两层:接入鉴权和调用授权。

接入鉴权发生在网关入口,每个调用方(无论是外部系统还是Agent本身)都要提供一个Access Key,网关校验Key的有效性和签名。这个机制跟云服务商的API鉴权类似,我用的是API Key + HMAC-SHA256签名的方式:请求体加上时间戳和随机数,用Key计算签名,网关用同样的方法验签,同时校验时间戳防止重放攻击。用时间戳做防重放很重要,不然截获一个合法请求就能无限重发。

调用授权发生在路由阶段,网关在找到目标Agent后,会检查调用方是否有权限调用这个Agent的这个operation。权限规则存在Redis里,支持从配置文件或管理接口动态更新。授权规则不建议写死在代码里,因为业务调整很快,写死就意味着要重新发版。

还有一个细节是网络层面的白名单。Agent-Reach只接受来自信任网段的HTTP和gRPC请求,而且内部通信全部走TLS双向认证。总结起来就是:传输层双向TLS、接入层Key+签名、业务层RBAC权限,三层叠加,基本杜绝了Agent裸奔的情况。

3. 实操落地:从部署到接入的完整过程

3.1 环境准备与依赖选型

Agent-Reach的核心代码用Go实现,主要原因是Go在网关类服务上的生态成熟,并发模型简单,部署产物是单一二进制,很适合这种基础设施型组件。前端管理控制台用TypeScript + React,不过不是必需组件,后面会说明为什么可以暂时不上。

需要准备的环境:

  • Linux服务器一台(4核8G起步,生产建议8核16G)
  • Go 1.21+ 编译环境
  • Redis 6.2+(生产用Redis Cluster,本地测试单机即可)
  • Docker(可选,用于容器化部署)
  • 一个用于测试的HTTP客户端,比如curl或Postman

我把整个系统分成三个产物:reach-registry(Redis依赖)、reach-gateway、reach-sdk。SDK以代码库形式提供给各Agent接入,目前支持Go和Python两种语言。

3.2 关键配置解析:网关配置实战

我先把核心配置贴出来,然后逐个讲关键参数为什么这么定。以下是一份精简版的gateway.yaml:

server: http_port: 8080 grpc_port: 9090 registry: redis_addrs: ["10.0.0.1:6379", "10.0.0.2:6379", "10.0.0.3:6379"] redis_password: "reach-redis-pwd" heartbeat_ttl: 15 agent_state_refresh_ms: 3000 route: load_balancer: "weighted_round_robin" default_timeout_ms: 2000 max_retries: 2 auth: enable: true cache_ttl_seconds: 60 internal_tls_enabled: true observability: trace_endpoint: "http://jaeger-collector:14268/api/traces" metrics_endpoint: ":9091"

几个容易被忽视的参数,我分别解释一下。

heartbeat_ttl: 15这个值是心跳周期的3倍。Agent默认每5秒发一次心跳,TTL设成15秒,意味着一个Agent最多能容忍两次心跳丢失,第三次到期就会被移除。这个容忍度是经验值:网络抖动一般在几秒内恢复,15秒足够覆盖绝大多数瞬时故障;如果设太短,比如跟心跳周期一样是5秒,那么一次偶然丢包就会导致误删节点。误删节点会引起路由失败,代价比短暂把请求打到故障节点要高得多。

route.default_timeout_ms: 2000是全局调用超时,这里有个估算过程。我统计过内部Agent的P99处理耗时是700ms,加上网关转发开销和网络RTT,1秒左右基本能完成调用。超时定2秒,留出了一倍的余量来容忍GC停顿和网络波动。注意这个超时不是越大越好,太大会拖垮网关的线程池。合理的做法是压测后取P99耗时的两倍,再往上取整。

max_retries: 2这个要特别小心。重试只能用于幂等操作,比如查询、生成工单号这类天然幂等的场景。如果是转账、扣库存这类非幂等操作,重试会引发重复执行,这时候需要配合SDK层的幂等ID来使用。我的建议是:非幂等操作一律不做自动重试,宁可失败返回错误码,由上层业务决定怎么处理。

3.3 Agent接入:SDK初始化和请求示例

Agent接入SDK是迭代最频繁的部分。以Python为例,接入代码大概长这样:

from reach_sdk import AgentReachClient, AgentConfig # 初始化客户端 config = AgentConfig( agent_name="refund-agent", namespace="order-service", gateway_addr="grpc://reach-gateway:9090", access_key="ak_order_01", secret_key="sk_order_refund", heartbeat_interval=5 ) client = AgentReachClient(config) client.start() # 启动后自动注册并开始心跳 # 给另一个Agent发请求 resp = client.call_agent( target_agent="risk-control-agent", operation="evaluateRefundRisk", payload={"order_id": "A12345", "amount": 299.00}, timeout_ms=3000, idempotency_key="req_20250301_A12345" )

这段代码有四个细节值得说。

第一,client.start()内部做了初始化注册和启动后台心跳线程,注册失败会阻塞重试,不会直接抛异常让Agent退出。如果Agent起来的时候Redis正好连不上,SDK会有个退化机制:先以临时模式工作,等Redis恢复后自动补注册。这个机制实现起来不复杂,但能避免“缓存依赖连不上就全部停摆”的连锁故障。

第二,call_agent是同步阻塞调用,底层封装了超时控制和重试逻辑。我只暴露同步API给Agent主流程,异步API暂时没做,原因是Agent业务逻辑本身是模型驱动的,同步调用更符合“思维链走到某一步需要调工具拿结果”的模式。异步会增加管理复杂性,收益不大。

第三,idempotency_key必须由调用方生成并保持唯一。我见过很多线上的脏数据问题,就是重试时没有幂等键导致重复处理。Agent-Reach网关会把幂等键透传到目标Agent,并缓存响应结果,同一幂等键的重复请求直接返回缓存,这个缓存在网关层做,对调用方完全透明。

第四,targetAgent和operation的命名需要公司内部统一规范。我习惯用域名-团队-能力三段式,比如risk-control-agent / evaluateRefundRisk。命名规则看似小事,在Agent数量不多时无所谓,一旦超过几十个,一个清晰的命名体系能让你快速定位请求归属,治理成本大幅下降。

3.4 调用链测试与验证

我准备一个最简单的联调环境:一台服务器跑Redis、Agent-Reach网关,本地跑两个模拟Agent——一个是weather-agent,提供天气查询;一个是order-agent,提供订单状态查询。然后模拟客服Agent来发起调用测试。

在Agent-Reach的管理接口上先看注册列表:

curl http://localhost:8080/api/v1/agents?namespace=demo

返回结果类似:

{ "agents": [ { "name": "weather-agent", "instances": [ {"id": "ins-abc123", "addr": "192.168.1.10:6001", "weight": 10} ], "status": "healthy" }, { "name": "order-agent", "instances": [ {"id": "ins-def456", "addr": "192.168.1.11:6002", "weight": 10} ], "status": "healthy" } ] }

然后用curl模拟客服Agent发起调用:

curl -X POST http://localhost:8080/api/v1/invoke \ -H "X-Access-Key: ak_customer_service" \ -H "X-Timestamp: 1710000000" \ -H "X-Nonce: 550e8400-e29b-41d4-a716-446655440000" \ -H "X-Signature: 计算出的HMAC值" \ -H "Content-Type: application/json" \ -d '{ "namespace": "demo", "source": "customer-agent", "target": "order-agent", "operation": "queryOrderStatus", "payload": {"order_id": "A12345"} }'

网关返回的响应会带一个X-Trace-ID响应头,这个Trace-ID贯穿了整条调用链。在Jaeger里能看到时间轴:网关入口→鉴权校验→Redis查询目标→转发→order-agent处理→返回。有一次我在线上排查一个“偶发3秒超时”的问题,靠的就是这个Trace-ID定位到耗时其实在目标Agent内部的模型推理上,而不是网络或者网关问题。

性能方面我也做了简单压测。用wrk并发200个请求,网关单实例的P99延迟在32ms,转发到模拟Agent成功率99.9%,没有丢包也没有超时。这个表现对我的场景够用了,如果你的系统对延迟更敏感,可以考虑在网关前面加一层本地缓存路由表,后面我会讲到怎么做。

4. 踩坑记录与高频问题排查实录

4.1 高频问题速查表

Agent-Reach跑了大半年,我把线上和联调中遇到的典型问题整理成了一张速查表。遇到问题先查表,能省很多时间。

现象可能原因处理手段
Agent注册后不久被移除心跳周期大于TTL,或GC导致心跳停顿先确认TTL是否≥3倍心跳周期;再检查Agent进程的GC日志
调用经常超时,但目标Agent日志显示已处理网关与目标Agent间TLS握手开销大开启gRPC连接复用,检查是否频繁重连
请求偶发返回401,刷新配置后恢复鉴权缓存过期且加载失败检查Redis中权限规则缓存,完善缓存降级逻辑
新注册的Agent不可用,等几秒后恢复注册信息同步延迟网关本地路由表刷新周期偏长,调短refresh_ms
重试导致重复处理非幂等操作配置了自动重试非幂等操作禁掉重试,必须用幂等键
Redis主从切换时注册列表清空Cluster故障转移期间Key丢失多集群容灾,配置中增加灾备Redis地址

这张表看起来简单,每一行背后基本都是一次真实的线上事故。下面挑三个印象最深的坑详细讲讲。

4.2 坑一:网络分区把健康节点误判为死亡

这是我踩的第一个大坑,最高级也最隐蔽。当时有一组Agent隔三差五被注册中心从列表中移除,但进程日志显示Agent一直活着,心跳线程也没报错。查了很久才发现,问题出在Redis的网络分区上:网关和Redis在一个网段,Agent在另一个网段,中间链路偶发拥塞时,Agent到Redis的心跳请求超时,但Agent内部并不知道,它只是“发出去没收到响应”而已。心跳线程没报错,是因为SDK对心跳失败做了静默容忍,不会panic。

问题在于:TTL到期后,Redis自动把节点Key删了,而Agent的心跳线程下个周期才恢复成功,这中间有整整10秒(TTL 15秒减去一个心跳周期5秒)的时间窗口,所有路由到该节点的请求都会失败。

这个坑的修复方案很值得一说。我不再单纯依赖TTL删除,而是给节点引入三个状态:healthy、suspect、dead。心跳丢失触发suspect状态,网关路由时把suspect节点权重降为0但保留在注册列表里;连续丢失超过阈值才标记为dead并删除。这样一来,网络抖动只会短暂降权,不会误删。后来我意识到,这就是分布式系统里常见的Phi-Accrual Failure Detector思路的简化版,只不过我用Redis TTL + 状态机实现了一个够用的版本。

4.3 坑二:gRPC长连接被负载均衡器掐断

另一个高频问题是gRPC长连接的不稳定。网关和目标Agent之间用的是gRPC,默认会维持长连接,这是为了性能。但生产环境里,gRPC流量经过四层负载均衡器时,LB会有一套自己的空闲连接回收策略,比如超过15分钟没有数据就断掉连接。断掉之后,Go的gRPC客户端会重连,逻辑上没问题,但重连期间正好有请求过来,就会出错。

这个坑的根因是:Agent-Reach服务端配置了空闲超时管理,但没有告诉客户端怎么优雅地保持连接。解决办法很简单,在SDK里给gRPC连接开启keepalive参数:

grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 30 * time.Second, Timeout: 10 * time.Second, PermitWithoutStream: true, })

关键是PermitWithoutStream: true,这个参数允许在没有活动RPC请求时也发送keepalive心跳,这样LB就不会因为“空闲”而掐断连接。调完之后,长连接断开导致的偶发超时彻底消失了。这个参数在很多gRPC项目里是默认关闭的,如果不主动开启,经典地“看起来一切正常但就是偶尔报错”。

4.4 性能优化与容量规划建议

最后说说性能和容量。Agent-Reach网关本身是无状态的,水平扩容就能提升吞吐。但有几个瓶颈要注意。

第一个是连接数。每台8C16G实例的服务器,经验上能稳定维持1万个长连接,超过这个数CPU和内存会明显上升。如果Agent实例很多,建议通过配置让同一命名空间下的Agent尽量复用连接,而不是每个实例单独建一条。

第二个是Redis的QPS。注册心跳是轻量操作,但鉴权权限查询的QPS会随着调用量上升。我建议在网关层加一层本地缓存,缓存2秒的鉴权结果,这样Redis的QPS能降一个数量级。缓存时间别太长,权限变更的生效速度会变慢,2秒是个不错的折中。

第三个是日志和链路追踪的写入开销。调用的日志量很容易在高峰期把磁盘IO打满。生产上我用的是异步批量上报和采样策略,trace采样率设成10%。对于问题排查来说,10%的采样率已经能覆盖绝大多数错误路径,如果觉得不够,可以单独为错误码为5xx的请求开启100%采样,这样既能排查故障又不会拖垮系统。

总的来看,Agent-Reach这次的改造让我最深的感受是:把Agent当作一等公民来治理会带来很多额外工作,但长期看是值得的。很多问题看似偶然发生,背后其实都是架构层面缺少某个能力。比如心跳超时导致误删,本质上是缺故障检测状态机;gRPC偶发断开,本质上是缺连接保活机制。这些坑如果不在中间层解决掉,迟早会在某个夜里让整个Agent集群集体掉线。做一个可靠的基础设施,就是把这些问题提前吸收掉,让上层业务Agent可以安心地处理自己的逻辑。

如果你也在做类似的Agent连接层,我的建议很简单:先保证链路可观测,再谈性能优化;先处理非幂等调用的约束,再放开自动重试;定义好命名和权限规则,然后再写路由逻辑。这四件事做扎实了,Agent-Reach这类中间层就算成功了大半。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进