ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent-Reach:打造多Agent协作的可靠触达层与管理实践

Agent-Reach:打造多Agent协作的可靠触达层与管理实践

如果你的多 Agent 系统只在一台机器上跑,你可能还意识不到 Agent-Reach 的价值。等 Agent 被拆到不同容器、不同机器、不同网络区域后,你就会遇到一类特别诡异的现象:Agent 的进程明明活着,日志还在打印,但调度 Agent 就是连不上它。我在负责一个多 Agent 质检系统时,被这个问题连续折磨了两周。后来折腾出了一个轻量工具,就是 Agent-Reach。它解决的问题非常具体:多 Agent 协作里的触达管理——Agent 如何注册、如何被感知、任务消息如何可靠投递。

这篇文章会从一次真实事故讲起,掰开揉碎介绍 Agent-Reach 的架构、核心算法、接入步骤和排查经验。无论你是在用 LangGraph、CrewAI 这类编排框架,还是准备自己手写多 Agent 服务,只要你的 Agent 需要跨进程、跨机器协作,这篇文章里的思路都可以直接参考。

1. 先从一次线上事故说起:Agent 为什么叫不应

1.1 一次真实的多 Agent 超时事故

当时我们有一个质检系统,跑着 12 个 Agent,分别部署在两个机房和一组容器节点上。为了把这些 Agent 串起来,我们用了主流的微服务注册中心做服务发现,每个 Agent 启动后上报自己的 IP 和端口,主调度 Agent 通过注册中心拿地址,然后直接发起 HTTP 调用。起初一切正常,直到某次发布后,集中式调度器开始大量出现connect timeout,很多子 Agent 的任务队列里堆了上千条消息。

我们第一反应是网络问题,于是登录到调度机器上手动 curl 那些 Agent 的地址。结果非常诡异:几乎每个地址都能通,端口响应也正常,Agent 的健康检查接口返回 200。但一旦让调度器去调用真实的业务接口,就有一半概率卡住,最终超时。后来我们查了很久,才发现问题出在受检 Agent 内部:其中一个核心组件出现了死循环,导致处理线程几乎被占满,但负责健康检查的线程还能响应简单请求。

这个事故让我彻底意识到,传统微服务的注册中心模式放在 Agent 协作场景里是不够的。Agent 的"可达"不能只看进程是否活着,更要看它有没有能力接收并处理当前的任务消息。为了应对这一类问题,我需要一个专门为 Agent 设计的东西,它得懂 Agent 的注册、心跳、网络探测和消息投递,而不是让我把微服务全家桶搬过来再魔改一遍。后来,Agent-Reach 就这么诞生了。

1.2 Agent-Reach 是什么,适合谁

Agent-Reach 本质上是一个面向多 Agent 协作的轻量触达层。它不是一个完整的 Agent 框架,不提供大模型推理能力,也不替代 LangGraph 这类编排工具。它专注解决三个问题:Agent 如何注册和发现、Agent 当前是否真正可达、任务消息如何可靠地投递到正确的 Agent 实例上。你可以把它理解成 Agent 世界的"电话交换机 + 健康档案"。

Agent-Reach 用 Go 实现,核心是一个中心服务,各 Agent 通过多语言 SDK 接入。目前我主要维护 Python 和 Node 两种 SDK,基本覆盖了实验性项目的常用语言。如果你正在做跨进程、跨机器部署的 Agent 协作,尤其是遇到"服务注册了但调用不到""进程活着但任务不消费"这类问题,很适合尝试这套思路。如果你只是在本机用两个 Agent 做演示,那暂时不必引入它,Agent-Reach 的价值在于分布式环境下的确定性。

2. 整体架构设计:Agent-Reach 如何做到统一触达

2.1 注册、探测、路由三层结构

Agent-Reach 的架构不复杂,核心是三个组件:注册中心(Registry)、可达性探针(Reach Probe)和触达路由器(Router)。这三个组件互相配合,形成一条完整的链路。

注册中心负责维护每个 Agent 的元数据,包括 Agent ID、能力标签、地址列表、版本号、启停时间等。它默认使用 SQLite 存储,单机几百个 Agent 没问题;如果要部署多副本,可以把存储后端切换到 etcd。可达性探针会不断从注册中心拉取 Agent 地址,并主动发起 TCP 探测,测量往返时延、丢包率和连通性,然后生成一个综合的可达性评分。触达路由器则是消息的"发货员":当调用方通过 Agent ID 请求某个 Agent 时,路由器根据注册信息和评分结果选择一个最优实例,把消息投递过去,失败时自动进入重试流程。

三者之间的数据流大致是这样的:Agent 启动时,SDK 先调用agentreach.Register()把自己注册进去,然后后台线程每隔固定时间上报一次心跳;服务端的探针从注册中心拿到地址列表,主动测一遍,并把结果写回注册中心;当业务代码调用agentreach.Send()时,Router 会结合注册列表、心跳状态、探测结果来决定投递路径。整个过程中,调用方永远只关心 Agent ID,不需要维护任何 IP 或端口表。这个设计带来的直接好处是:你调整 Agent 副本、迁移服务器、更换容器网络,都不需要改动调用方代码。

2.2 为什么不用纯注册中心方案

可能有人会问,直接用 Nacos、Consul 这类注册中心不行吗?我之前也这么想过,但实践下来有几个坑。

第一,传统注册中心的健康检查通常看 HTTP/health接口,返回 200 就认为是健康的。但 Agent 场景的问题在于,健康接口可以很轻,而实际业务处理线程可能已经被拖垮。也就是说,传统健康检查给不出"是否有能力消费消息"的判断。第二,Agent 的实例地址经常动态变化,尤其是容器环境,一个 Agent ID 可能对应多个实例,传统注册中心虽然支持服务列表,但按能力标签路由需要大量二次开发。第三,注册中心本身不负责消息投递,它只解决"谁能被找到",不解决"消息怎么送进去"。Agent 协作最关键的恰恰是后者:任务消息要可靠地进入目标 Agent 的处理队列,并且在失败时能够重试、转移。

Agent-Reach 没有把自己定位成又一个注册中心,而是把注册、探测和投递三个动作整合到同一个上下文里。调度决策可以同时参考元数据和实时探测结果,尽量避免"查到的节点活着,结果一调用就超时"的尴尬。这套逻辑用熟了以后,你再回去看传统注册中心,会觉得它只解决了一半问题。

2.3 心跳与探测双通道的取舍

Agent-Reach 使用了双通道来做可达性判断:应用层心跳和服务端主动探测。这两条通道各有侧重,缺一不可。

应用层心跳由 Agent SDK 主动发起,默认每 10 秒上报一次,除了"我还活着"之外,还会携带当前负载、正在处理的任务数、最近一次任务完成时间等业务状态。服务端主动探测则从 Agent-Reach 服务端的视角出发,向 Agent 注册地址发送 TCP 探测包,测量 RTT、丢包率等网络指标。双通道的价值在于互相校验:心跳是 Agent 自己说的,可能因为代码 bug 而失真;探测来自服务端,更接近真实调用的网络视角,但它看不到业务内部状态。只有两者交叉,才能同时发现"进程假活"和"网络不通"这两种问题。

举一个实际例子:某个 Agent 因为资源泄漏进入半死状态,心跳线程还能跑,但业务线程已经全部阻塞。此时链路探测可能完全正常,不过 Agent 上报的"正在处理任务数"会持续上涨。Agent-Reach 就会结合这个业务指标,把该 Agent 标记为"业务性不可达",而不是继续把新任务硬塞进去。反之,如果心跳线程挂掉了,但探测正常,说明 Agent 进程还在但状态上报出了问题,这种情况会触发告警但不会立刻判定为离线。两条通道互相印证,信息更可靠。

3. 核心细节:可达性评分与可靠性投递

3.1 AgentReachScore 评分公式

Agent-Reach 最核心的指标是AgentReachScore,范围 0~100。它不是简单的"通或不通",而是通过加权计算,给上层调度提供一个精细的优先选择依据。默认公式是:

ReachScore = 50 * heartbeatScore + 30 * rttScore + 20 * lossScore

三个子分数的计算方式分别如下。心跳分数heartbeatScore取决于最近一次心跳的新鲜度:设置的心跳间隔是 10 秒,如果心跳 age 小于 10 秒,取 1.0;超过 30 秒后线性衰减到 0。RTT 分数rttScore根据服务端到 Agent 的探测往返时延计算:小于 20ms 为 1.0,大于 500ms 为 0,中间线性映射。丢包分数lossScore基于最近 10 次探测的丢包率:0% 为 1.0,超过 30% 为 0。

这三个权重不是拍脑袋定的。我们在压测中发现,Agent 失去响应比单纯网络慢对协作体验的破坏要大得多,所以心跳新鲜度权重最高;RTT 主要影响单次调用的等待时长,影响次之;短期丢包可以通过重试来缓解,所以权重最低。你可以根据自己网络的实际情况调整权重。比如在内网低延迟环境,RTT 通常是稳定的个位数毫秒,就可以降低 RTT 权重,避免因为偶尔一次探测抖动导致误判。

举个具体的计算例子。假设一个 Agent 最近心跳 age 为 12 秒,探测 RTT 为 150ms,最近 10 次探测丢包率 5%。那么heartbeatScore = 1 - (12-10)/(30-10) = 0.9,rttScore = 1 - (150-20)/(500-20) = 0.73,lossScore = 1 - 5/30 = 0.83。最终ReachScore = 45 + 21.9 + 16.6 = 83.5分,属于可用但状态一般。这个评分会直接参与路由选择,分数高的实例优先被投递。

3.2 自适应探测频率与连接池隔离

探测本身不能太频繁,否则会无谓地消耗 Agent 资源和网络带宽;但也不能太稀疏,否则故障感知会变慢。Agent-Reach 默认的策略是:Agent 状态健康时,每 5 秒探测一次;当某个 Agent 的 ReachScore 降到 60 以下时,探测频率自动提高到 1 秒;如果连续 3 次探测全部失败,就会把该 Agent 标记为UNREACHABLE,同时把重试探测的频率降为每 30 秒一次。这样既能在故障发生时快速发现,又不会对着一个已经宕机的地址持续高频打探。

这里有一个必须强调的细节:探测用的连接池和业务消息投递连接池是要分开的。最初我的实现里,探针复用了业务连接池,结果在一次高负载测试中,探针连接把连接池占满,真实的任务消息反而排不进去了。后来我单独为探测维护了一个连接池,默认最大 8 个连接,超过就跳过本次探测。这个改动虽然不大,但对稳定性提升非常明显,属于那种不踩坑不会注意到的设计。

另外,探测结果还需要做平滑处理,不能因为一次 RTT 抖动就疯狂改变路由。Agent-Reach 内部保存了最近 10 次探测结果的滑动窗口,路由时取的是窗口内的加权平均值。这样做的好处是,偶尔一次网络毛刺不会被当成 Agent 故障处理。

3.3 重试、指数退避与死信处理

Router 在投递消息时,不会直接同步阻塞等待目标 Agent 返回。它先把消息写入一个本地持久化队列,默认使用 BoltDB,然后异步投递。这个设计其实是借鉴了消息队列的"至少一次"语义,但用更轻量的方式实现。为什么不直接上 Kafka?因为引入 Kafka 会增加部署复杂度,对中小规模的 Agent 协作系统来说性价比不高。

投递失败时,Router 会按照失败原因做分流处理。目标 Agent 短暂不可达时,进入重试队列,按指数退避重试,最多 5 次。第一次重试延迟 1 秒,第二次 2 秒,第三次 4 秒,依次翻倍,上限 30 秒。目标 Agent 返回业务错误,比如"当前负载已满"或"任务类型不受支持",这时不盲目重试,而是立即回调给调用方,让上层 Agent 决定是否更换目标。重试次数耗尽后,消息进入死信队列,同时触发告警,方便人工介入。

因为投递是异步的,调用方拿到的是一个任务提交结果,而不是最终执行结果。如果你需要知道 Agent 是否真正处理成功,就得依赖任务 ID 做结果回传。Agent-Reach 内置了结果回调接口,Agent 处理完消息后可以调用agentreach.reportResult()把执行结果上报,调用方通过agentreach.waitForResult()异步等待结果。这套机制对耗时任务非常友好,不会让调用方一直占用一个同步连接。

3.4 多副本与按标签路由

Agent 生产环境中经常一个 Agent ID 下挂多个实例,例如三个相同的文档解析 Agent 同时运行。Router 在选择目标实例时,会先根据能力标签过滤,然后在可用实例里按照ReachScore和当前负载综合排序,选最优实例投递。默认策略是"最高分优先",但如果最高分和次高分之间差距小于 5 分,会采用随机选择,避免同一个实例被反复选中造成热点。

标签路由是我在实际使用中认为最有价值的功能之一。你可以给每个 Agent 打上doc、image、gpu、cpu_only这类标签,然后调用时指定标签要求。例如:

result = agentreach.send( method="task.process", tags=["doc", "gpu"], payload={"doc_id": "abc123"} )

Router 会自动在带有这两个标签的 Agent 中筛选实例,而调用方完全不需要关心具体是哪个 Agent 处理。这就让 Agent 的扩容和缩容变得非常灵活:新加一个 Agent 实例,只要打上相同标签,立刻就能接到任务;下线一个实例,也只需要从注册中心摘除。

4. 实操记录:从零接入 20 个异构 Agent

4.1 部署 Server 端与配置文件

我推荐用 Docker 镜像方式部署 Server,这样依赖干净、升级方便。需要在目标机器上准备好一个数据目录,用来存放注册数据和持久化队列。启动命令如下:

docker run -d --name agent-reach \ -p 8800:8800 \ -p 8900:8900 \ -v /opt/agent-reach/data:/data \ agentreach/server:0.4.2

这里 8800 是 HTTP 管理端口,供查询和配置使用;8900 是 Agent SDK 通信端口。如果不方便用 Docker,也可以下载编译好的二进制文件直接运行:

./agent-reach-server --config config.yaml

对应的config.yaml里,我习惯重点配置这几项:

server: port: 8900 admin_port: 8800 storage: type: sqlite # 可选 etcd path: /data/agent-reach.db probe: interval: 5s fail_threshold: 3 retry_interval: 30s router: max_retry: 5 dead_letter: true

probe.interval默认 5 秒是一个比较平衡的配置。调成 1 秒虽然故障发现更快,但会让 Agent 的日志刷新得很快,也会增加连接占用;调成 10 秒以上,则会让故障发现变得迟钝。除非你的监控系统有更高频的需求,否则先用默认值。

4.2 Python Agent 接入与消息收发

以 Python 为例,Agent 端接入分为三步:注册、上报心跳、启动消息回调。第一步,在 Agent 进程启动时初始化:

import agentreach agent = agentreach.Agent( agent_id="doc-analyzer-01", tags=["doc", "gpu"], addr="172.18.0.5:7001", heartbeat_interval=10 ) agent.register() agent.start_heartbeat()

注意addr必须是其他 Agent 也能访问到的地址,不能写127.0.0.1,否则 Agent-Reach 服务端从别的机器探测时永远连不通。这也是新手最容易踩的坑之一。

第二步,把 Agent 的业务处理函数暴露成回调接口。Agent-Reach 提供了一套简单的装饰器:

from agentreach.web import route @route("task.process") def handle_task(task_id: str, payload: dict): result = process_document(payload["doc_id"]) return {"code": 0, "data": result}

装饰器会在 Agent 本地启动一个 HTTP 服务,把task.process映射到对应路径。Router 投递消息时,会携带task_id、method、payload三个字段,自动 POST 到这个路径。

第三步,调用方发消息:

result = agentreach.send( agent_id="doc-analyzer-01", method="task.process", payload={"doc_id": "abc123"}, task_id="task-20240915-001" )

task_id我强烈建议由业务自己生成。如果不传,SDK 会生成一个 UUID,但你没有办法在业务上关联原始请求。有了自己的task_id,后续幂等和排查都会方便很多。

4.3 用管理接口验证触达状态

部署完成后,可以通过管理端口快速检查集群里的所有 Agent 状态:

curl http://localhost:8800/api/v1/agents

返回的 JSON 数组里,每个 Agent 会包含reach_score、probe_status、last_heartbeat、last_probe_error等字段。我当时的 20 个 Agent 里,19 个reach_score稳定在 95 分以上,只有一个跨专线部署的 Agent 因为 RTT 一直在 300ms 左右,分数只有 70 多,但仍然属于可用状态。

我还会用另一个命令观察消息投递指标:

curl http://localhost:8800/metrics

重点关注agentreach_router_delivery_total、agentreach_router_retry_total和agentreach_router_dead_letter_total三个指标。如果重试次数持续上升,说明网络不稳定或者目标 Agent 应用层处理能力不足,需要进一步排查。

4.4 长任务与异步回调的处理

Agent 处理任务不总是瞬间完成的。有些任务,比如批量文档分析,可能需要几十秒甚至几分钟。如果调用方一直同步等结果,会把连接资源和调用线程都占住。Agent-Reach 对此有两种处理方式。

第一种,Agent 收到消息后立刻返回一个"已接收"状态,然后在后台任务完成后通过agentreach.reportResult()回调结果。第二种,调用方在发送消息时传入timeout参数,并配合waitForResult()做异步等待。我在接入那个质检系统时,把所有耗时超过 5 秒的任务都改成了异步回调模式,系统吞吐量立刻上了一个台阶。

这里要给 Agent 开发者一个建议:回调接口一定要做并发控制。我最初把 Agent 回调用的是单线程 HTTP 处理,结果连续投递几十个长任务后,回调端口自己先堵了,大量消息堆在 TCP backlog 里,看起来服务端一直在重试,实际 Agent 根本来不及消费。后来给回调接口增加了线程池,并用max_concurrency元数据告诉 Agent-Reach 当前实例的并发上限,路由时自动跳过已经达到上限的实例,问题迎刃而解。

5. 常见问题与排查技巧实录

5.1 显示不可达但手动连接正常

这是 Agent-Reach 落地时最常被问到的现象:管理接口显示某个 AgentUNREACHABLE,但手动curl或nc连接又通。一般情况下,问题出在判断维度不同。Agent-Reach 判定不可达是综合了心跳新鲜度和服务端探测结果,而手动 curl 通常只验证了端口通。常见原因有三个。

第一,Agent 的心跳线程被业务代码阻塞了。比如 Agent 里有一段同步调用迟迟不返回,把心跳 goroutine 也拖住了,心跳续不上,评分自然归零。第二,Agent 注册的地址用了127.0.0.1或内网映射地址,Agent-Reach 服务端从外部探测不到。第三,云端环境对 ICMP 或 TCP SYN 限速,导致探测包大量丢失,但业务流量因为走了别的高速通道反而正常。

排查时不要只看最终状态,要先查last_heartbeat和last_probe_error。如果last_probe_error有内容,你需要在 Agent-Reach 所在机器上手动执行nc -vz <agent_ip> <port>,确认网络链路是否真的通。如果探测正常但心跳断了,那问题基本在 Agent 进程内部,需要去看 Agent 的日志和线程状态。

5.2 消息重复执行与幂等处理

Agent-Reach 默认是"至少一次"投递语义,所以重试不可避免地会带来重复消息。如果你收到的任务里有少量重复,这其实是正常现象;真正要做的,是保证消费端的幂等。

我常用的方案是在数据库里维护一张任务执行记录表,利用唯一索引来过滤重复任务:

INSERT INTO task_execute_log(task_id, agent_id, status) VALUES (?, ?, 'processing') ON CONFLICT(task_id) DO NOTHING;

如果插入影响行数为 0,说明这个任务之前已经处理过,直接返回成功,不再执行业务逻辑。这个方案比"先查再插入"要稳妥,能避免并发重复投递时两个人同时查到"不存在",然后一起执行。注意,唯一索引要建立在业务幂等键上,而不是 Agent 生成的随机 UUID。

5.3 多副本下的路由超时调优

如果你一个 Agent ID 挂了多个副本,但还是频繁出现路由超时,核心原因是 Router 选中的实例恰好没有消费能力。比如两个文档分析实例,一个空闲,一个已经积压了大量任务,但因为它们的 ReachScore 差距不大,Router 随机选中了积压实例,消息就卡住了。

解决办法有两个方向。第一,Agent 上报心跳时,务必把current_load这个字段填对。Router 在选择实例时,不仅看 ReachScore,还会参考current_load和max_concurrency,负载高的实例天然会被降权。第二,在 Agent 回调接口处设置合理的并发上限。如果并发数只设 1,那么来两个任务就会有一个端口响应变慢,导致 Router 误判为网络问题而触发重试。我在容器环境里通常把并发数设为 CPU 核数的两倍,效果不错。

5.4 排查速查表

症状可能原因检查方法
显示 UNREACHABLE注册 IP 外部不可达、心跳线程阻塞在 Server 端执行nc -vz <agent_ip> <port>
ReachScore 持续偏低网络 RTT 高、丢包严重查看/metrics中probe_rtt_ms和probe_loss_rate
任务积压但 Agent 不消费业务线程池占满、并发上限过低查看 Agent 上报的current_load和max_concurrency
重试次数飙升目标 Agent 应用层处理能力不足查看agentreach_router_retry_total指标变化趋势
数据目录增长过快死信队列积压过多调整死信队列保留策略,只保存必要字段

这张表是我在实际项目中的速查清单,覆盖了我遇到过的大部分问题。当然场景不同,细节会有点差异,但排查方向基本一致。

6. 同场景下的三个扩展方向

6.1 从单组网走向多组网

Agent-Reach 目前的多 Agent 协作还没有支持多租户联邦。如果你有多个互不相通的网络区域,每个区域内部部署独立的 Agent-Reach,区域之间默认是隔离的。我最近在尝试让不同的 Agent-Reach 服务之间建立联邦关系,通过一个轻量的目录同步机制,让跨区域调用可以像本地调用一样按 Agent ID 路由。这个做法的难点在于区域间网络往往不稳定,需要把探测和路由的粒度都加一层。

如果只是想做简单的多区域分发,可以先不用等待这个功能。你可以在上层编排 Agent 里做区域策略,通过 Agent-Reach 的标签能力把不同区域标记成不同标签,调用时指定标签,路由器自然只会选符合条件的实例。

6.2 从通用探测到任务级探测

当前 Agent-Reach 的可达性评分是通用的,只衡量网络和应用基础状态。但在真实业务里,同一个 Agent 可能对不同类型的任务响应差异很大。例如一个 Agent 既支持文本处理,也支持图片处理,GPU 实例的图片任务表现很好,文本任务却因为资源竞争响应很慢。

我下一步打算在注册数据里加入"能力资源"维度,让每个 Agent 能够声明某个方法预期的 RTT 和并发能力,Router 在投递不同任务时参考不同的评分权重。这样可以让调度更精准,但也会让配置和维护更复杂。对于现阶段大多数实验项目来说,通用评分已经足够用了。

6.3 一个可以马上动手的小优化

如果你想在现有 Agent-Reach 版本上立刻优化效果,我建议从心跳载荷入手。SDK 默认上报的心跳消息里,除了 Agent ID 和负载,还可以加两个自定义字段:last_task_duration和consecutive_failure_count。前者可以让 Router 感知到实例近期处理任务的平均耗时;后者可以用于在服务端做降级预判。一旦某个实例连续上报了 3 次失败计数,就可以提前把它从路由池中暂时移除,而不是等任务消息真正发过去之后再等超时。这个小改动不需要改服务端逻辑,只改 SDK 的心跳字段和服务端的权重规则,收益却很直接。

最后再分享一点个人体会。我在多 Agent 系统上折腾了这几年,最大的感觉是大家很容易把注意力放在"Agent 聪明不聪明"上,却忽略了底层的协作工程问题。Agent-Reach 解决的不是智能问题,而是稳定性问题。它让我在写业务逻辑的时候,再也不用担心这个 Agent 到底叫不应怎么办,因为这些都交给触达层去管了。如果你正在做类似架构,希望这篇文章里的思路能帮你少踩几个坑。

RELATED READING

延伸阅读

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