☰
Agent-Reach:多Agent系统可靠触达层的设计与实践
2026/10/27 5:48:52 网站建设 项目流程

我最早开始折腾 Agent-Reach,是因为一个非常现实的问题:明明每个 Agent 单测都能跑,一旦把十几个智能体散落在不同机器、不同网段、不同模型供应商后面,任务就开始迷路。调度方不知道哪个实例还活着,Agent 也不知道自己该向谁回报,失败时大家互相等,等完就超时。Agent-Reach 这个项目最初的定位,就是专门解决“任务能不能可靠地触达正确 Agent 实例”这一层问题。说得直白点,它不是一个写业务逻辑的编排引擎,而是 Agent 和调度方之间的触达与可达性层。

很多人会把 Agent-Reach 理解成“又搞了一个消息队列”,其实不是。MQ 保证的是消息不丢、能消费,但 MQ 不关心消费者到底是谁、在哪里、还活着没有;Agent-Reach 关心的恰恰是这一层:全局有哪些 Agent 实例、各自在哪个网段、当前是空闲还是忙碌、一次请求能不能在预期时间内拿到响应。这篇文章把我从概念验证到落地踩坑的完整过程写下来,包括协议设计、代码实现、故障模拟和参数调优,适合那些正在做多 Agent 系统、被任务路由和超时重试折磨得不行的朋友参考。

1. Agent-Reach 不是编排引擎,而是触达层

1.1 任务“迷路”的三个根源:发现、保活、路由

我做第一个多 Agent Demo 时,架构非常简单:一个调度中心,几个 Worker Agent,调度中心拿着 Agent 的 IP 列表逐个发 HTTP 请求。当时能跑,但只要 Agent 数量超过五个,问题立刻暴露。

第一个问题是发现。Agent 实例可能动态扩容、缩容,IP 会变,功能版本会升级,调度方手里那张静态列表永远是过期的。第二个问题是保活。你以为某个 Agent 还活着,大概率是因为它没有任何主动上报机制,TCP 连接断了也没人知道。第三个问题是路由。任务带了一堆上下文,究竟该交给哪一个实例?是按能力匹配?按负载均衡?还是按数据就近原则?没有一层统一的“触达语义”,这些逻辑最后都会散落在调度代码里,越写越乱。

Agent-Reach 把这三个问题收敛成一个可维护的模型:所有 Agent 先注册,注册后持续心跳,调度时通过统一 API 拿到触达结果。它自己不做任务规划,不解析用户意图,不做模型调用,它只回答一个问题——这个任务,现在能不能可靠地送到某个 Agent 手上。

1.2 和消息队列、HTTP 网关的本质区别

有人会问,这些事 RabbitMQ 或 Nginx 网关也能做一部分。确实能,但各自的侧重点很不一样,我整理了一张对比表。

层面消息队列(MQ)普通 API 网关Agent-Reach
核心对象消息HTTP 请求Agent 实例及其状态
是否感知消费者状态弱,通常只关心队列堆积不感知,只做转发强,维护心跳、租约、能力集
超时语义异步消费,无强超时同步转发,超时即断开多级触达:直连、中继、挂起
重试策略依赖消费者重投网关层有限重试按路由规则和实例状态决策
负载感知不感知简单轮询/哈希按实例上报的忙闲程度动态分发

这张表不是说谁替代谁。实际系统里它们可以共存:Agent-Reach 负责“找到并确认触达”,MQ 负责“把大流量任务削峰填谷”,网关负责“统一入口鉴权”。但如果你的难点是任务经常发送到已经不存在的实例上,或者 Agent 状态一变调度方完全蒙在鼓里,那真正缺的就是 Agent-Reach 这一层。

2. 可达性模型与三级触达协议

2.1 Agent 注册表:一次注册,多次可达

Agent-Reach 的核心数据结构我称为 Reach Registry,也就是可达性注册表。每台 Agent 进程启动后,第一件事就是向 Registration Server 注册自己,并周期性地续租。注册信息不只是一个地址,我把字段拆成了五类。

字段示例作用
agent_idagent-web-search-01全局唯一标识,跨网络稳定存在
endpointhttp://10.0.3.8:9100/reach实际接收任务的回调地址
capabilities["web_search", "url_fetch"]声明自己具备的能力,供路由过滤
regioncn-east-1网络区域,路由时优先同区域触达
lease_until1700000000租约到期时间,超过则标记不可达

这份注册表用 SQLite 做持久化,用 Redis 做高并发场景下的缓存,代码量不大,但语义上解决了一个关键问题:调度方永远不需要硬编码 Agent 地址。它只需要带着任务特征查询注册表,注册表负责筛选出“满足能力约束且租约未过期”的实例列表。

注册请求的 JSON 我做得很简单,但字段命名会影响后续所有调试,建议一开始就想清楚。

{ "agent_id": "agent-web-search-01", "endpoint": "http://10.0.3.8:9100/reach", "capabilities": ["web_search", "url_fetch"], "region": "cn-east-1", "heartbeat_interval_sec": 5, "lease_duration_sec": 15 }

这个 JSON 里有两处容易被忽略。一是 heartbeat_interval_sec 和 lease_duration_sec 的比例,我的经验是租约时间至少是心跳间隔的三倍。心跳每 5 秒一次,租约 15 秒到期,意味着允许网络抖动两次,第三次心跳丢失才把实例踢下线。如果租约只有 8 秒,一次 6 秒的 GC 暂停就可能让大量 Agent 被误判为离线。

二是 capabilities 不要设计成字符串匹配,最好做成集合语义。比如任务要求“网页搜索且能访问指定域名”,你不能只靠一个字符串字段搞定,早期我在 capabilities 里用逗号拼接关键词,后来只能演进成扁平数组加额外的 attribute map,方便路由层做条件表达式匹配。

2.2 直连、中继、挂起:三级触达怎么配合

有了注册表,下一步是定义触达协议。我把一次任务投递分成三级:直连(Direct Reach)、中继(Relay Reach)、挂起(Pending Reach)。

直连是最理想的情况:调度方查到 Agent 实例后,直接把任务 POST 到该实例的 endpoint,等待 ack。这个过程和普通 HTTP 调用没有本质区别,但多了一个超时上限和租约校验。如果请求发出前,实例的租约刚过期,调度方就该停止投递,而不是发了再等。

中继是直连失败后的选择。举个例子,目标 Agent 在 cn-east-1,但当前这个调度服务节点在 us-west-2,网络延迟高且不稳定。直连超时后,调度服务会把任务转给距离目标更近的另一个调度节点或 Peer Relay,由它代为投递。这时候触达链路上多了一个 hop,所以必须给消息加 hop_count,防止转发成环。我在协议里约定最大转发次数是 3,超过就直接判定失败。

挂起是最后一道防线。当直连、中继都失败,且目标 Agent 并未永久注销时,任务进入待触达队列,按照指数退避重试。退避间隔可以是 1 秒、2 秒、4 秒,封顶 30 秒。挂起不是无限重试,我会给每个任务设置 max_pend_count,通常取 5,超过后把任务标记为 unreachable,并触发告警。

这三级的核心思想是:先尽力同步触达,再尝试绕路触达,最后转入异步等待。别看这个设计简单,它能避免一个非常常见的翻车场景——所有失败任务一股脑落进消息队列,结果 Agent 恢复后,队列里的任务又几何级堆积,造成重复消费混乱。

2.3 ack 链与租约:怎么确认“真的收到了”

只定义投递路径还不够,Agent-Reach 能在故障时给出明确结论,靠的是严格的 ack 链。我把它分成三段:

  • received:Agent 进程收到请求,完成了入站校验,但还没开始处理。
  • processing:Agent 确认接受任务,处理中。
  • finished:Agent 完成任务,返回结果或错误。

调度方只有在收到 received 之后才算“触达成功”,在此之前的所有重试都是安全的。收到 received 但一直没等到 finished,不应重复投递同一任务,只应做状态标记和超时升级。

实现上,每个任务都会生成一个 request_id,任何一次 ack 响应都必须回显它。我在 Agent 端做过一次血泪教训:处理函数里有异步子任务,子任务完成后把结果直接返回给客户端,却忘了携带原始 request_id,结果 Agent-Reach 把所有结果都当成孤儿响应丢弃,调度方这边一直显示触达超时。

# Agent 侧响应示例,务必把 request_id 原样带回 { "request_id": "reach_20240601_0930_001", "stage": "received", "agent_id": "agent-web-search-01", "timestamp": 1717223400 }

再说租约。租约不光是判断在线的依据,它同时是一个互斥边界。一个 Agent 同时只允许处理一个高优先级任务时,调度方要查看 lease_until 是否被占用写入了 task_id。如果被占用,就把该实例从候选集里剔除。把租约从“心跳存活”扩展为“处理任务占用”,你会发现并发控制变得异常简单,不再需要额外引入分布式锁。

3. 从零接一个 Agent:最小可用实现

3.1 技术栈选型与部署目录

Agent-Reach 的骨架我建议这样搭:Registration Server 用 FastAPI,Agent 侧用轻量 Python SDK,状态存储用 Redis + SQLite。选 FastAPI 是因为它的生命周期管理和异步接口很干净,而且两个节点之间做同步触达时,HTTP 的语义比自定义 TCP 协议透明得多。

我本地实验环境的目录结构如下。

agent-reach/ ├── server/ │ ├── main.py # FastAPI 入口与注册接口 │ ├── registry.py # 注册表与租约管理 │ ├── router.py # 候选实例选择与路由 │ └── deliver.py # 直连、中继、挂起三级触达 ├── agent_sdk/ │ ├── client.py # Agent 侧注册与心跳 │ └── handler.py # 装饰器和 ack 链封装 └── demo/ ├── agent_a.py └── agent_b.py

依赖方面,server 只需要 fastapi、uvicorn、redis、httpx。Agent 侧则尽量少依赖,只有 requests 就够了。有人可能想引入 Celery 或者更重的调度框架,我的建议是前期不要。Agent-Reach 的价值在于触达层的语义清晰度,业务编排越复杂,越难判断问题出在路由还是出在任务处理上。

3.2 服务端接入配置

服务端拿到一个注册请求后,做的第一件事不是写入数据库,而是校验 agent_id 和 endpoint 的合法性。很多小项目跳过这步,结果注册表里混入了一批格式错误的地址,路由时挨个超时,体验极差。

以下是 Registration Server 的注册接口实现片段。

from fastapi import FastAPI, HTTPException import time app = FastAPI() # 简化版注册表,生产环境换 Redis REGISTRY = {} @app.post("/v1/register") async def register(req: dict): agent_id = req.get("agent_id") endpoint = req.get("endpoint") if not agent_id or not endpoint: raise HTTPException(status_code=400, detail="agent_id and endpoint required") if not endpoint.startswith("http://") and not endpoint.startswith("https://"): raise HTTPException(status_code=400, detail="endpoint must be absolute URL") lease_sec = int(req.get("lease_duration_sec", 15)) now = int(time.time()) REGISTRY[agent_id] = { "endpoint": endpoint, "capabilities": req.get("capabilities", []), "region": req.get("region", "default"), "last_heartbeat": now, "lease_until": now + lease_sec, "status": "active", "pending_count": 0 } return {"ok": True, "agent_id": agent_id, "lease_until": REGISTRY[agent_id]["lease_until"]}

这里最关键的是 return 里的 lease_until。Agent 拿到这个时间后,会知道自己什么时刻之前必须续租,一旦服务器宕机恢复,注册表能根据时间戳自动清理过期实例。时间使用 Unix 秒,不要用字符串时间,字符串时间在跨时区对比时极容易出问题,而且调试时一眼看不出时间差。

3.3 Agent 侧注册与监听

Agent 接入时,我建议把注册、心跳、任务处理封装成一个装饰器,业务代码只需要关心真正的处理逻辑。这样可以保证 ack 链、异常捕获、request_id 回显这些细节不会在复制粘贴中丢失。

from agent_sdk.client import AgentClient from agent_sdk.handler import reach_handler client = AgentClient( agent_id="agent-web-search-01", register_url="http://127.0.0.1:8000/v1/register", endpoint="http://127.0.0.1:9100/reach", capabilities=["web_search"], region="cn-east-1" ) client.start() # 启动注册与心跳后台任务 @reach_handler(client=client) def handle_search(task: dict) -> dict: query = task["payload"]["query"] # 这里写真正的搜索逻辑 return {"results": [], "query": query}

装饰器内部做了三件事:验证 request_id、先返回 received ack、处理完后返回 finished ack。注意,先返回 received 不代表任务已经完成,它只是一个契约,告诉调度方“别再重发了”。如果你的 Agent 任务经常执行超过 1 分钟,调度方又希望感知进度,那可以在 processing stage 里再带一个 progress 字段,调度方可以用来做超时阈值动态调整。

我测试时发现一个很隐蔽的问题:AgentClient 的心跳线程和任务处理线程共用同一个 requests.Session,当任务处理是长请求时,连接池会被占满,心跳请求排队等待,最终导致租约过期、实例被误判离线。解决方案很简单,心跳单独建一个 Session,超时设成 2 秒,和业务请求彻底隔离。

3.4 本地验证:一台机器模拟两个实例

在没有多台服务器的情况下,可以通过两个不同端口在同一台机器上模拟两个 Agent 实例。我会把 agent_a 放在 9100 端口,agent_b 放在 9101 端口,然后启动 Registration Server,观察注册表里两个实例都变 active。

验证触达,我习惯用一个最小脚本直接调用 server 的触达 API:

curl -X POST http://127.0.0.1:8000/v1/dispatch \ -H "Content-Type: application/json" \ -d '{ "task_id": "task_001", "capability": "web_search", "region": "cn-east-1", "payload": {"query": "agent reach"} }'

如果 agent_a 在线,server 会返回类似下面的响应:

{ "result": "ok", "log": [ {"hop": 0, "action": "direct", "agent_id": "agent-web-search-01"}, {"hop": 0, "stage": "received", "request_id": "reach_20240601_0930_001"}, {"hop": 0, "stage": "finished", "duration_ms": 12} ] }

接着把 agent_a 杀掉,再调用同一条任务。注册表会在下一次心跳租约到期后把 agent_a 标成 offline,然后把任务路由到 agent_b。这个过程会输出一条 relay 路径,你在日志里能看到这行:

agent-web-search-01 lease expired, fallback to agent-web-search-02 via relay

到这里,最小闭环就跑通了。你别小看这个本地验证,它能确认协议、注册表、路由、触达四个环节是否正常,后续所有故障定位都以这套基本流程为参照。

4. 实战中那些坑:连接误判、重复投递与超时

4.1 离线误判:心跳正常不代表 reachable

这个坑我在 Agent-Reach 磨合期踩得最深。某个 Agent 的心跳一直正常,租约也时时续着,但触达就是失败。查了一整天,最后发现是 Agent 所在服务器有双网卡,心跳请求走了内网卡,但注册回调 endpoint 写的是外网卡 IP,外网防火墙把 9100 端口挡了,于是注册表永远显示 active,任务却永远送不进去。

从此我开始区分两个概念:liveness 和 reachability。心跳证明进程活着,跳动还说明能从内网访问注册服务器。reachability 则必须通过实际触达请求验证。所以 Agent-Reach 的注册表状态不能只有 active/offline,至少得加一个 suspect 状态,即“租约有效,但最近一次触达失败,需要降级”。

我的处理方式是加入探测机制:当路由时发现一个实例触达失败,立刻把该实例的 status 改为 suspect,并触发一次探活 ping。探活不算业务负载,只请求 Agent 的 health 端点。如果连续两次探活失败,才把它标记 offline。这样做的代价是探活请求占用很小带宽,收益是误判率大幅降低。

4.2 重复投递:ack 丢了不等于任务没做

同步触达最常见的故障模型是:Agent 收到任务,处理完了,结果返回时网络闪断,调度方没有收到 finished ack。调度方只凭“触达超时”就重试,同一个任务就被执行了两次。

Agent-Reach 对这种场景的答案是幂等。所有任务必须有 task_id,Agent 端要维护一张最近处理过的 task_id 表,收到重复投递时直接返回上一次的结果缓存。实现上我用 SQLite 表存(task_id, request_id, result_json, created_at),保留 24 小时即可。

CREATE TABLE task_result ( task_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, request_id TEXT NOT NULL, result_json TEXT, finished_at INTEGER );

如果你觉得数据库太重,单进程 Agent 也可以用一个 dict 加一个定时清理。核心逻辑只有一个:任务真正开始处理之前,先检查去重表;处理完之后,把结果写回去。我见过一些人只在入口处用 Redis SETNX,但任务处理过程中宕机,SETNX 的锁过期以后依然会重复执行。真正稳妥的组合是:入口幂等判断 + 结果落库 + 定时清理过期记录。

4.3 超时阈值:别拍脑袋设数值,用滑动分位数

早期我给触达超时设了固定值 3 秒。结果 Agent 换了一台性能较弱的机器之后,检索逻辑从 300 毫秒涨到 2.8 秒,再加上网络抖动,任务频繁被判定超时。后来我把超时策略改成动态计算:每个 Agent 维护最近 100 次 finished ack 的耗时分布,取 P95 时间作为基准,再加上 500 毫秒的余量,作为下一次触达超时。

这里有一个容易忽略的细节:P95 必须按 Agent 实例维度计算,不要按聚合维度。因为不同能力集的 Agent 处理耗时差异巨大,搜索 Agent 可能 1 秒完成,代码生成 Agent 可能要 20 秒。如果全局共用一个 P95,一定会互相拖累。实现上只需要在每个 ack 里上报 duration_ms,服务端把最近 100 次耗时塞进一个滑动窗口数组,每次路由前排序取分位数即可。

超时设置还有一个联动效应:如果触达超时变长,中继和挂起的整体耐心也要跟着变长。我建议直连超时 = P95 + 500ms,中继超时 = 直连超时的 2 倍,挂起等待上限 = 中继超时的 3 倍且不超过 2 分钟。

4.4 中继风暴:多个调度器同时让同一台机器兜底

三级触达中的中继机制有个坏味道:当一个区域里的多个 Agent 同时离线,区域内剩余那台健康的 Agent 会成为所有任务的中继目标。它不是真的能处理这么多任务,但只要它还能回 received ack,调度方就会继续往它身上堆。最极端的时候,我把 200 个任务全部导给了一台小 Agent,直接把它打挂。

解决办法是中继也要看负载。Agent 在上报心跳时带上 pending_count 和 recent_cpu,服务端在路由时过滤掉 pending_count 超过阈值(比如 10)的实例。同时,中继选择不做随机,而是按照“同区域、能力匹配、pending 最少、最近成功率高”四个条件排序,从集群里挑出最合适的一个。

我还加了一个每个中继路径只能转发一次的约束。也就是说,调度节点 A 把消息转给调度节点 B,B 只能直连目标 Agent,不能再转给 C。这个约束彻底避免了多条中继路径交叉形成环路。

5. 让每个 Agent 真实可见:观测与动态限流

5.1 链路标记:一条任务究竟走了哪条路

调试 Agent-Reach 最痛苦的时候,是调了半天发现任务根本不是你想的那条路径到达 Agent 的。为了快速定位,我要求触达链路上的每个节点都往响应里追加一个 reach_path 数组。每经过一个 hop,就在数组里追加一条 JSON 记录。

"reach_path": [ {"hop": 0, "node": "scheduler-cn-01", "action": "direct"}, {"hop": 0, "action": "received", "agent_id": "agent-web-search-01"} ]

这个链路标记看起来很简单,但价值极高。它不只在出问题时有帮助,日常调优时也能一眼看出大部分任务是不是都走了中继。如果你的系统里 direct 比例长期低于 70%,说明注册表的分区信息不准确,或者 Agent 的 endpoint 设置有问题,早发现早处理。

链路标记在请求失败时同时写进告警内容里,这样值班的人不需要登服务器查日志,看一眼告警里的 hop 列表就能判断是网络问题、超时参数问题还是 Agent 处理异常。

5.2 状态健康度与负载感知

注册表里的 status 字段我建议不要只存字符串,而是存一个健康度评分。这个评分每 30 秒计算一次,公式如下:

  • 基础分 100,每次触达失败扣 10 分
  • 每次 received ack 超时扣 5 分
  • 每一次 finished ack 正常回传加 1 分,上限回到 100
  • pending_count 超过阈值时额外扣 20 分

分数低于 60 的实例,路由时直接不被选中,但允许通过强制参数触达,方便排查单点故障。这个机制比纯 active/offline 可靠,因为它在连续正常和连续异常之间提供了渐变空间。

负载感知方面,我强烈建议 Agent 上报的 pending_count 是“正在执行的任务数”,而不是“队列里的任务数”。实现上用一个进程内计数,处理开始时加 1,结束时减 1。这个数字在 Agent 侧容易拿到,而且能真实反映瞬时压力。加上心跳里的 CPU 1 分钟均值,路由层就能做比较合理的负载均衡。

5.3 一张表看懂核心监控指标

做好观测的关键是知道该看哪些数字。我把 Agent-Reach 的监控指标整理成了一张表,告警阈值按照我的经验填在最后一列。

指标含义我们使用的告警阈值
direct_success_ratio直连触达成功率低于 90% 告警
relay_success_ratio中继触达成功率低于 80% 告警
ack_received_p95从投递到 received 的耗时大于 200ms 关注
lease_expire_rate每分钟租约过期次数大于 5 次告警
duplicate_incoming_rateAgent 侧收到的重复任务占比大于 2% 告警
pending_queue_length挂起队列深度大于 50 告警

其中 duplicate_incoming_rate 最容易被忽视,它的数值会直接提示你 ack 链和幂等设计是否有漏洞。正常场景下重复率应该在零点几个百分点,如果突然涨到 5%,多半是某条网络链路间歇性断连,导致大量 received ack 没有及时回到调度方。

6. 实际体会:什么场景才适合引入 Agent-Reach

我对 Agent-Reach 最大的体会是:它解决的是“规模带来的复杂度”,而不是“单机就能搞定的问题”。如果 Agent 和调度方跑在同一台机器上,数量不超过三个,任务多是同步调用,那老老实实写几行 requests.post 就够了,引入这套触达层反而是过度设计。

但一旦跨到多台服务器、多个网络区域、Agent 动态扩缩容,或者任务对可靠送达要求极高,Agent-Reach 的价值就显现出来了。它让调度方不用关心每个 Agent 的具体 IP,让 Agent 不用自己实现重试和状态上报,所有人都围绕“注册表 + 租约 + ack 链”这一套统一语义协作,出问题时能快速收敛到某个链路节点。

如果你想做得更极致,可以在注册表里再加一个 model_capability 维度,把模型供应商、模型名称、支持的工具集一起注册进去。这样“触达”就不只是触达一个 Worker,而是触达一个同时满足工具能力、模型能力、所在区域约束的逻辑实体。这个扩展方向花不了多少代码,但当系统里 Agent 的类型越来越多以后,省下的 Routing 逻辑复杂度是肉眼可见的。

最后分享一个落地过程中的小技巧:不管协议设计得多严谨,一定要在一开始就接上“故障注入”实验。你可以在注册表刷新循环里人为跳过某个 Agent 的心跳,在 Agent 处理函数里 sleep 超过触达超时,在 Gateway 层面随机丢弃部分请求。每个月跑一轮,比读十遍文档更能让你了解 Agent-Reach 在真实战场上的表现。我的环境跑完这些实验后,才发现自己的中继风暴、误判离线和重复投递全都是可以提前暴露的问题,而不是等线上事故来了才去翻日志。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询