Agent-Reach 这个名字,最近在智能体圈子里讨论得不少。它不是某个大厂发布的重量级产品,而是一个更偏底层的开源调度框架,主打的是 "智能体触达" 这件事。通俗点说,以前我们写 Agent(智能体)更多地关注单个 Agent 的推理能力、工具调用能力,但一旦把几十个、上百个 Agent 放到同一个系统里协同工作,问题立刻就变了:A 智能体要调 B 智能体的能力,能不能稳定调通?B 宕机了怎么办?消息发出去之后怎么知道对方到底收到了没有?如果网络抖动,同一个任务被两个 Agent 重复执行了怎么办?Agent-Reach 解决的就是这一系列"智能体之间能否可靠触达"的问题,核心是路由、投递、状态同步和失败恢复。这篇文章我以自己的实际使用体验为主,把它的架构思路、核心配置、部署步骤和踩坑记录完整分享出来,适合正在搭建多智能体协同系统、做 RPA 流程编排,或者研究 Agent 通信机制的开发者参考。
1. 整体设计思路与架构拆解
1.1 为什么需要给 Agent 单独做一个"可达层"
在引入 Agent-Reach 之前,我自己的项目里用的是最原始的方式:在 Agent 代码里直接写 HTTP 调用,或者通过 Redis 发布订阅广播消息。项目规模小的时候没问题,但 Agent 数量一多,问题就非常明显。
首先是"地址管理"混乱。每个 Agent 实例可能是动态扩缩容的,今天在容器 A,明天可能就调度到容器 B。直接写 IP 加端口的方式根本没法维护,每次扩容都要改一遍配置。其次是"投递语义"完全没有。HTTP 请求超时了,到底算不算对方收到?如果对方处理了但是返回超时,重试会不会导致重复执行?业务上很多环节是不能接受重复处理的,比如扣费、下单、发通知。第三是状态不可见。A 调 B 的时候,B 正在跟 C 做长对话,无法处理新任务,但 A 不知道,只能傻等或者抛异常。
Agent-Reach 相当于在"Agent 业务逻辑"和"实际网络链路"之间加了一层抽象的可达性网络。每个 Agent 启动时向 Agent-Reach 注册自己的能力标识和实例信息;调用方只需要声明"我要调用 order-service 的 createOrder 能力",剩下的地址发现、路由选择、消息重试、超时熔断,全部由框架完成。这一层最重要的设计原则是"让 Agent 之间不直接发生物理连接"。这句话建议反复品一下:不直连就意味着调用关系可以被治理,可以被观测,也可以被动态调整,这是单纯点对点集成做不到的。
1.2 核心组件一览:路由、投递、状态、观测
Agent-Reach 的逻辑架构并不复杂,我在生产环境里实际部署后,把它拆成了四个核心模块:
- Reach Router(可达路由引擎):负责维护"能力名到 Agent 实例"的动态映射关系。每个 Agent 启动时持有一个 Agent ID,同时声明自己对外暴露的能力列表,比如
tool.invoice.query、llm.chat.deepseek。Router 把这些信息写入本地内存路由表,并通过 Raft 协议在多节点间同步,保证任何一个 Router 节点挂了,其他节点都能无缝接管。 - Delivery Worker(投递状态机):这是整个框架里最核心的部分。它给每条消息定义了一个明确的生命周期,从
PENDING到DELIVERED,再到ACKED或FAILED。投递不是简单的一次性发送,而是一套带确认、带补偿的状态流转机制。后面我会详细讲。 - State Store(状态存储):投递状态和路由快照都会持久化。默认支持 Redis 或 PostgreSQL,我是用的 PostgreSQL,毕竟要存历史审计数据。它的作用是用来做"断点恢复",进程重启后可以从上次记录的游标继续处理,不丢消息。
- Observability(链路观测):内置了 OpenTelemetry SDK,每个 Agent 的调用会生成 Trace ID,配合 Jaeger 可以看到完整的调用链。另外还有一套指标接口,Prometheus 直接抓取,用来监控投递成功率、平均投递时延、当前 PENDING 消息积压量。
组件之间通过内部 gRPC 通信,对外暴露的接口则是 WebSocket 和 HTTP 两种。WebSocket 适合 Agent 端做长连接订阅,HTTP 适合简单的同步调用场景。
1.3 技术选型:为什么用自研协议而不是直接上消息队列
你可能会问,这不就是一个消息队列吗?用 RabbitMQ 或者 Kafka 不行吗?我在选型阶段确实重度对比过。结论是:消息队列擅长"广播和削峰",但不太擅长"一对一的请求-响应语义"和"动态路由到特定能力实例"。Kafka 的消费组模型天然适合流式处理,但要让 A Agent 等 B Agent 的响应并做超时判断,需要自己写很多协调逻辑。Agent-Reach 更像是"请求驱动型"的投递层,它允许同步等待结果,也允许异步回调。
传输协议没有用自定义 TCP 二进制,而是选择了 HTTP/2 + Protobuf,主要原因有三个:第一,HTTP/2 的多路复用能在一个长连接上同时承载大量 Agent 的消息;第二,生态成熟,各种语言都有成熟的 HTTP/2 实现,接入成本低;第三,Header 里可以塞自定义的投递语义元数据,比如依赖 idempotency-key 做幂等控制。实际压测下来,在百级并发下延迟稳定在 5ms 以内,完全够用。
2. 核心实现细节与关键参数
2.1 可达性路由表:能力注册、心跳与摘除
Agent-Reach 的"可达性"不是静态配置,而是动态维护的。每个 Agent 实例启动时,要通过 Register 接口上报自己的 Agent ID、能力列表、负载分数和一个自定义标签组。比如:
{ "agent_id": "agent-order-01", "capabilities": ["order.create", "order.query", "order.cancel"], "load_score": 0.2, "tags": {"env": "production", "region": "hz"} }Router 收到注册请求后,会把该 Agent 加入路由表,同时启动一个心跳计时器。心跳间隔默认 30 秒,Agent 需要以agent.ping消息周期上报存活状态。如果连续 3 个周期没有心跳,Router 会先把该实例标记为suspected(疑似存活),再过 2 个周期仍然没有恢复,就彻底标记为offline,并触发事件通知。这个设计比直接摘除要稳妥:有些 Agent 可能只是 GC 停顿了一会儿,稍后又能恢复,没必要立刻赶出路由表。
路由策略默认是load_balanced(加权轮询),权重由 Agent 上报的 load_score 动态调整,负载高的实例会被降低权重。如果你有分组隔离需求,还可以配tag_routing,比如某个内部工具只能由region=hz的实例处理,其他地区的请求直接返回"不可达"异常。我在实际使用中最常用的反而不是轮询,而是pin_agent模式:某些 Agent 有状态性,比如用户会话绑定在 agent-order-01 上,那后续的会话消息必须路由到同一个实例,这时候就需要在消息 Header 里加上x-reach-sticky-keys,框架会按这个 key 做一致性哈希路由。
2.2 投递状态机:一条消息的完整生命周期
这是我觉得 Agent-Reach 做得最值得学习的地方。每条投递消息在框架内部都是一个状态机对象,状态流转如下:
- 创建消息:调用方通过 SDK 的
reach.send(target_capability, payload, opts)发起调用。消息进入PENDING状态,写入 State Store。 - 路由选择:Delivery Worker 根据目标能力和当前路由表选择一个可用实例,如果找不到任何可用实例,消息不会被直接丢弃,而是进入
PENDING_RETRY状态,等待路由表刷新后重新尝试。 - 发送与确认:消息通过长连接发送给目标 Agent,目标 Agent 处理完成后返回一个显式的
ACK或者NACK。收到ACK后,状态变为ACKED,整个投递成功;收到NACK(比如业务逻辑校验失败),状态变为FAILED,并保留错误详情。 - 超时处理:如果在规定时间内既没有 ACK 也没有 NACK,状态转为
TIMEOUT。此时框架会根据配置决定是进入重试队列还是终止投递。
这个状态机的关键意义在于:调用方永远不需要处理"半成功"状态。要么收到明确的成功响应,要么收到失败原因,要么收到超时异常。从业务代码的角度看,这就是一次可靠的方法调用,Agent 之间的网络细节被完全屏蔽了。
2.3 超时、重试与幂等:参数怎么调才合理
下面这组参数我在生产环境里最终稳定使用的配置是这样的:
| 参数名 | 默认值 | 我的推荐值 | 说明 |
|---|---|---|---|
delivery.timeout.ms | 3000 | 8000 | 单次投递等待 ACK 的最长时间 |
delivery.retry.max | 3 | 5 | 最大重试次数 |
delivery.retry.backoff.base | 500 | 1000 | 指数退避的初始间隔毫秒数 |
delivery.max.parallel | 100 | 256 | 每个 Worker 同时处理的投递消息数 |
delivery.deadletter.enabled | false | true | 是否开启死信队列,超过重试次数的消息进入 DLQ |
超时值不能拍脑袋。如果目标 Agent 是普通的工具调用,比如查数据库,3 秒没问题;但如果目标 Agent 要调用大模型接口,生成一段长文本,没准就是 10 秒以上。我的做法是对每个能力单独配置超时时间,Agent-Reach 支持在能力级别覆盖全局默认值。
重试时最容易踩坑的是"消息重复执行"。比如目标 Agent 执行了下单操作,但它返回 ACK 给 Agent-Reach 时网络断了,Agent-Reach 判定超时,发起重试,结果订单就下了两次。解决方案有两个层面:一是要求所有业务 Agent 在处理消息时使用 Header 里的x-reach-message-id作为幂等键,自己维护已处理消息表;二是在 Agent-Reach 侧开启deduplication模式,短时间内同一条消息不会重复投递给同一个实例。但框架侧的去重防不住"先执行后 ACK 丢失"的场景,所以业务侧幂等依然是必须的。我在设计 Agent 内部逻辑时,强制规定所有写操作必须带幂等键,这也是接入 Agent-Reach 时的第一条纪律。
2.4 观测性埋点:看不到链路,等于白搭
Agent-Reach 内置的观测设计给我省了很多事。它遵循 OpenTelemetry 标准,每条消息都会生成一个 Trace ID,并在消息 Header 里透传。这样,从调用方 SDK 发起,到 Router 路由,再到目标 Agent 处理响应,整条链路的每个环节耗时都能在 Jaeger 里看到。
更重要的是它提供的核心指标,我直接接入了 Grafana 做监控大盘,重点关注四个指标:
reach_delivery_total:总投递次数,按能力和结果类型打标;reach_delivery_duration_seconds:投递耗时直方图,我用来判断当前系统延迟是否恶化;reach_pending_messages:当前 PENDING 状态消息数量,这个指标一旦持续上涨,说明消费速度跟不上生产速度,需要扩容 Worker;reach_route_instance_online:在线 Agent 实例数,用来感知集群规模变化。
我自己踩过的一个坑是:指标库的标签值里带了 Agent 实例的 Pod 名,结果 Pod 扩缩容之后,Prometheus 的高基数列直接拖慢了查询。后来把标签固定为能力名和服务名,实例级别的监控单独用日志事件采样记录,查询性能一下子就恢复正常。
3. 从零部署 Agent-Reach 的完整实操
3.1 环境准备:依赖组件与版本选型
Agent-Reach 的官方发行版是编译好的单二进制文件,或者你也可以用 Docker 镜像。我推荐 Docker 方式,省去很多环境兼容性问题。依赖的持久化组件我使用的是 PostgreSQL 15 和 Redis 7,如果只做演示,用 Docker Compose 一把拉起也很快。Agent-Reach 本身要求 Java 21 运行时,因为核心用到了虚拟线程处理长连接。注意官方说明是 Java 21 以上,不要用 17,否则部分协程机制会退化为平台线程,并发能力会明显下降。
我这里以 Docker Compose 演示完整环境:
version: "3.8" services: db: image: postgres:15-alpine environment: POSTGRES_USER: reach POSTGRES_PASSWORD: reach123 POSTGRES_DB: reach_state ports: ["5432:5432"] redis: image: redis:7-alpine ports: ["6379:6379"] reach-router: image: agent-reach/router:1.4.2 depends_on: [db, redis] ports: ["8100:8100", "9100:9100"] volumes: - ./config/router.yaml:/app/config/router.yaml reach-worker: image: agent-reach/worker:1.4.2 depends_on: [db, redis, reach-router] volumes: - ./config/worker.yaml:/app/config/worker.yaml这里 Router 和 Worker 是分开的,Router 负责元信息和连接管理,Worker 负责消息状态流转。规模小的时候可以两个组件合并运行,官方提供了一体化模式,不过是试验特性,我不建议在生产环境用。
3.2 配置细节逐项解释
router.yaml是路由层的核心配置,以下是我线上使用的精简版:
cluster: node_id: router-01 listen: 0.0.0.0:8100 peers: ["router-01:8100", "router-02:8100", "router-03:8100"] raft: heartbeat_interval_ms: 500 state: type: postgres endpoint: jdbc:postgresql://db:5432/reach_state username: reach password: reach123 route: heartbeat_timeout_strikes: 3 heartbeat_grace_period_ms: 10000 sticky_hash_slots: 4096 metric: enable: true listen: 0.0.0.0:9100cluster.peers:三个 Router 会组成 Raft 集群,任意一个节点故障,集群元数据仍然可用。请求可以随机打到任何一个节点,内部会通过共识同步路由表。state.endpoint:PostgreSQL 用来存储持久化状态,主要是路由快照和投递游标。Redis 在这里其实更多用作缓存和临时队列,所以必须有 PostgreSQL 做主存储。route.heartbeat_timeout_strikes:连续心跳超时次数,超过该次数标记疑似存活,之前讲的“2 个周期宽限期”也在这个参数控制下。sticky_hash_slots:一致性哈希的槽位数。这个值影响pin_agent模式下流量分布的均匀性。我一般设置为一个较大的质数,默认 4096 够用。
Worker 侧的worker.yaml重点配置投递参数:
worker: threads: 16 poll_interval_ms: 50 delivery: timeout_ms: 8000 retry_max: 5 retry_backoff_base_ms: 1000 max_parallel: 256 deduplication: enable: true window_seconds: 60 deadletter: enable: true queue: reach_dlqWorker 会周期性从 State Store 里捞出 PENDING 或待重试的消息,再交给线程池投递。我建议worker.threads不要超过 CPU 核心数太多,因为大部分时间是在做网络 IO 等待,虚拟线程已经把阻塞成本降得很低,过多的物理线程反而增加上下文切换。
3.3 启动单节点与集群模式的完整步骤
先讲单节点启动。我把两个组件合并部署到同一台机器上,方便本地调试。步骤如下:
- 克隆示例配置仓库:
git clone https://github.com/agent-reach/examples.git; - 修改
config/router.yaml里的cluster.peers为单个节点地址; - 修改 worker 配置里的
delivery.timeout_ms为适合你本地的值,比如 5000; - 执行
docker compose up -d启动依赖组件,再分别启动 router 和 worker; - 通过
curl http://localhost:9100/metrics验证指标端口正常输出; - 打开
http://localhost:8100/health,看到{"status":"OK"}就说明启动成功。
集群模式主要多做两件事:一是配置多个 Router 节点,二是把 Worker 也部署成多副本,共享同一套数据库和 Redis。启动顺序建议:先启动数据库和 Redis,再启动全部 Router,等 Router 节点之间 Raft 选主完成后,最后启动 Worker。如果顺序反过来,Worker 启动时找不到 Router 会自动重试,影响不大,但日志里会有大量告警,强迫症看了难受。
3.4 与业务 Agent 集成:Java 和 Python 两种方式
Agent-Reach 官方 SDK 提供 Java、Python、Go 三种语言,我用得最多的是 Java 和 Python。以 Python 为例,接入一个业务 Agent 只需三步:
第一步,安装 SDK 并创建 Agent 实例:
from reach_sdk import ReachAgent, capability agent = ReachAgent( name="agent-invoice", router_url="ws://localhost:8100/ws", agent_id="agent-invoice-01", capabilities=["invoice.list", "invoice.download"], heartbeat_interval_seconds=30 ) @capability("invoice.list") def handle_invoice_list(payload, context): invoices = query_invoices(payload.get("customer_id")) return {"invoices": invoices} agent.start()第二步,调用方通过 Client 发起调用:
from reach_sdk import ReachClient client = ReachClient(router_url="ws://localhost:8100/ws", agent_id="agent-portal-client") resp = client.call( target_capability="invoice.list", payload={"customer_id": "C001"}, timeout_ms=8000, sync=True ) print(resp.result)第三步,在 Agent 返回结果时,务必保证返回值能序列化,并且不要返回特殊的None。Agent-Reach 会把你返回的 dict 封装成响应消息,如果序列化失败,发送端会收到NACK,但错误原因可能不太直观,所以建议在本地把返回结果先 json.dumps 一遍试试。
Java SDK 的接入类似,核心是异步回调风格。我在项目里用的 Java 版本会在reach.call()返回一个CompletableFuture<ReachResponse>,非常适合配合虚拟线程做高并发吞吐。
4. 常见问题与排查实录
4.1 智能体注册成功但路由不到:先查标签匹配
我遇到的第一类问题是:Agent 明明已经启动了,服务列表也能看到实例,但业务调用总是报NO_ROUTE。排查之后发现,Agent 注册时上报的标签里有env: staging,而调用方在请求里指定了tags: {env: production},Router 的tag_routing策略严格匹配后,没有任何实例满足条件。
建议在配置路由策略时,不要把标签匹配设成必须项。除非你有明确的灰度隔离需求,否则可以用preferred_tags表示偏好,而不是required_tags。另外,注册后要习惯性检查一下 Router 的/routes/{capability}接口,它会返回当前能力对应的所有在线实例和标签,直接看这个可以省掉一半的排查时间。
4.2 消息重复投递:不要只靠框架,业务幂等才是护城河
前面已经提到了幂等的重要性。这里我分享一个线上事故:某个 Agent 做计费操作,每次处理消息都会调用公司内部的支付接口创建支付单。有一次网络抖动导致 ACK 丢失,Agent-Reach 重试了三次,支付接口被调用了三次,好在支付接口本身有幂等键校验,否则后果很严重。
后来我总结了一条编码规范:凡是 Agent 消息处理函数里涉及任何写操作,第一行就检查本地状态表里是否已经存在message_id。如果存在就直接返回上一次的处理结果,甚至不需要重新计算业务逻辑。这样做虽然多一次查库,但换来的是绝对的安全感。
4.3 高并发下的背压处理:PENDING 积压报警
高峰期的时候,我发现reach_pending_messages指标从个位数冲到几千,下游 Agent 响应变慢,大量消息积压在 PENDING 状态。分析后有两个原因:一是目标 Agent 本身扛不住,GC 频繁,处理一条消息要 5 秒,但投递超时设置也是 5 秒,导致每次都超时重试,积压越来越严重;二是 Worker 不断重新捞取重试消息,反而加剧了数据库压力。
解决方法是给目标 Agent 单独加一份限流配置,让 Agent-Reach 的并发请求数不超过 Agent 能承受的上限。同时把重试退避基数调大,比如从 1000ms 调成 3000ms,避免快速重试淹没下游。这一套组合拳打完之后,积压量立刻恢复平稳。
4.4 日志排查技巧:用好 Trace ID 与事件时间线
排查 Agent-Reach 的问题时,不要一上来就看业务日志。它的投递日志里每个消息事件都带message_id和trace_id,建议先把这两个字段记下来,再配合 Jaeger 工具查看调用链。
另外,Agent-Reach 默认日志级别是 INFO,如果你要排查路由问题,建议把com.agentreach.router包下的日志级别临时调整为 DEBUG,会输出每个路由判定的详细规则。排查完记得改回来,否则日志量会非常恐怖。我在生产环境是开了动态日志级别 API 的,通过curl /api/log/level调整,不用重启进程,非常方便。
5. 压测数据与性能优化经验
5.1 本地 4 节点压测实测数据
我在自己的开发机(8 核 16G,Docker 内存限制 4G)上部署了一个 Router、两个 Worker、两个模拟 Agent,用压测脚本模拟 50 个并发调用方,每个调用方连续发送 1000 条请求到order.query能力。压测结果如下:
| 指标 | 数值 |
|---|---|
| 总请求数 | 50000 |
| 平均延迟 P50 | 4.2ms |
| P99 延迟 | 17.8ms |
| 投递成功率 | 99.94% |
| 生产消费速率 | 1250 msg/s |
P99 比 P50 高四倍,主要原因是模拟 Agent 里有一小部分在处理时触发了慢查询。如果你想追求更低的尾延迟,可以考虑在 Agent 侧做结果缓存,或者把模拟数据预热进内存。
5.2 提升吞吐的三个优化方向
第一,优先扩充 Worker 数量而不是单 Worker 线程数。多 Worker 并行地从数据库取消息,比单 Worker 内疯狂加线程更有效,因为瓶颈往往在数据库轮询游标的 IO 上。第二,消息体尽量精简,能传 ID 就不要传整个对象。Agent-Reach 的消息是整体编码进 Protobuf 的,消息体太大,序列化和网络传输都会明显变慢,内存占用也会上升。第三,把 State Store 的 PostgreSQL 连接池调大,默认连接池是 10,我在高并发场景下提升到 30 后,取消息的等待时间下降了一半以上。
6. 最后再分享一个实用技巧
如果你准备把 Agent-Reach 接入已有项目,不要一开始就追求完整的多语言客户端和集群高可用。我自己的建议是,先单节点、单语言、跑通一条核心能力链路,再加上幂等、观测和死信队列,最后再考虑多活部署。用这种步步为营的方式,整个接入过程会顺畅很多。另外,Agent-Reach 支持插件机制,我后来给它加了一个"按语义相似度兜底路由"的插件:当某个能力名不存在时,会尝试把请求交给名称最接近的在线 Agent,这在一些历史系统迁移场景下非常有用,能显著降低直连调用改造的阻力。希望这篇分享能帮你少踩几个坑。