如果你的项目里已经开始出现七八个 AI Agent,而你还靠手动写死 URL、轮询结果、到处补超时重试,那这篇文章应该能帮你省不少事。Agent-Reach 是我最近从内部 Agent 调度系统里抽出来的一个轻量组件,专门解决“智能体触达”这个很容易被忽略的问题:一个 Agent 怎么被发现、被路由、被可靠地调用,以及在它挂了或变慢的时候怎么不把整个系统拖垮。它不搞流程编排,不碰 Prompt 模板,只负责让任意 Agent 在任意时刻都能被准确找到并调用,定位类似于微服务时代的注册中心加网关,只不过服务的定义变成了带 LLM 上下文的智能体。适合后端开发、AI 应用工程师,以及所有正在被“多个 Agent 互相拼 URL”折磨的人。
1. Agent-Reach 是什么:为什么需要一层专门的触达层
1.1 当 Agent 数量超过五个,手动编排就开始失控
我先说一个很具体的场景。早期我做多智能体协同,只有两个 Agent:一个写文案,一个做校对。写文案的 Agent 调校对 Agent,直接在代码里写死内部 HTTP 地址,跑通就交差。后来加入第三个做 SEO 分析的 Agent,开始有调用关系,但还能撑。真正崩盘是在第五个 Agent 上线后:有的 Agent 要按能力路由,有的要求会话保持,有的调用一次要跑几十秒,还有两个在凌晨三点悄悄重启导致调用方全部超时。那段时间每天干的事情就是改代码里的地址、加 if-else、延长 timeout。本质上就是在用手工方式维护一张不稳定的调用拓扑。
后来我意识到,这个问题和十年前微服务遇到的问题一模一样:服务多了,就需要服务发现、负载均衡、超时控制、熔断降级。Agent 也逃不掉这套规律,而且 Agent 还更麻烦。普通接口调用通常是“请求-响应”,几百毫秒内结束;Agent 调用可能是异步的,一个 Agent 要思考、调工具、生成多轮内容,几秒、几十秒甚至几分钟都不稀奇。调用方如果不知道对端是死是活,就会在“超时重发”和“重复计算”之间反复踩雷。Agent-Reach 就是把这一层公共问题收拢成一个独立组件:负责 Agent 注册、健康检查、路由选择、触达请求分发、超时重试与结果回收。
1.2 和 LangChain、LangGraph、Coze 的定位差异
很多人一听到 Agent 调度,第一反应是 LangGraph 或者各种 Workflow 平台。但我得说清楚:这类框架解决的是“智能体内部流程怎么编排”,比如你的 Agent 是先调用搜索工具还是先读数据库,状态怎么流转,分支怎么走。Agent-Reach 不碰流程,它解决的层更靠下:编排框架把流程画好了,最终执行时还是要发起一次真实的跨进程调用,而这次调用的目标节点是谁、状态是否健康、超时了要不要换一个节点重试,这些事它们默认不做或做得不够细。
我自己的用法是两层叠加:外层用 LangGraph 之类的编排工具描述业务逻辑,内层把每个 Agent 注册到 Agent-Reach,所有跨 Agent 的实际触达都打到触达层上。相当于编排框架负责“怎么走”,Agent-Reach 负责“怎么把话递到对面且对面真的能接住”。这样的分层也让 Agent 可以脱离特定流程框架独立存在——同一个质检 Agent,既能被 A 流程用,也能被 B 流程用,只要它按触达协议暴露能力就行。
1.3 适合谁看,能解决哪些痛点
如果你最近开始做多 Agent 项目的架构设计,或者你的系统里已经出现以下症状,Agent-Reach 的思路可以直接抄作业:
- 调用 Agent 的地址散落各处,改一个节点的端口就要全链路改配置。
- 同能力 Agent 有多副本,但始终只有固定一台在干活。
- 调 Agent 经常超时,调完又不知道是不是真的执行了,不敢重试。
- 想给 Agent 加限流、熔断,却发现没有一层统一的流量入口。
- Agent 内部使用不同的协议(gRPC、WebSocket、普通 HTTP),调用端需要为每种协议写适配。
这套东西做完之后,最大的感受是调用方再也不关心“我要找谁”,只声明“我要什么能力”,路由的事交给 Agent-Reach。
2. 整体架构:注册发现、触达协议与路由策略的取舍
2.1 注册中心不选 etcd 和 Redis,我用的是“中心节点 + 本地快照”
最初考虑过引入 etcd 做注册中心,毕竟微服务领域成熟方案很多。但仔细盘算后发现,在这个场景里没必要。常见业务系统的 Agent 数量撑死几百个,只有极少数平台级项目会超过几千,存储和一致性的压力都不大。引入 etcd 意味着所有 Agent 和调用端都要依赖一个额外中间件,部署复杂度立刻抬升,在某些内网隔离环境里还是负担。
Agent-Reach 的注册中心是中心节点进程内的一个注册表,数据组织结构大概是:
- agent_id:全局唯一标识。
- descriptor:能力标签、版本、元数据。
- endpoint:实际可调用的地址和协议。
- status:Pending / Ready / Suspect / Out。
- 心跳时间戳、连续失败次数、当前权重。
中心节点把注册表定期持久化为本地快照文件。中心节点重启后,Agent 重新上报心跳就能恢复,快照只是用来降低恢复期间的“空窗期”。这个设计在 Agent 数量不大时非常稳定,单机就能扛住,也少了一个中间件的运维成本。如果你的场景确实是上万个 Agent 分布在多个机房,再把注册中心替换成 etcd 或 Nacos 也不迟,前面定义的抽象接口能直接平移。
2.2 触达协议:六个核心语义,把“调一下”变成可靠事件
Agent-Reach 的核心是一套轻量触达协议,我用纯 JSON 消息实现,编码简单,跨语言也好对接。协议里最重要的六种消息:
| 消息 | 方向 | 作用 |
|---|---|---|
| REGISTER | Agent → Center | 携带 AgentDescriptor 完成注册 |
| HEARTBEAT | Agent → Center | 默认 15 秒一次,更新活跃状态 |
| INVOKE | Caller → Center | 携带目标能力、入参、超时预算,请求触达 |
| INVOKE_ACK | Center → Caller | 立即返回,表示已接受并开始路由 |
| DELIVER | Center → Agent | 把调用任务推送给目标 Agent |
| RESULT | Agent → Center | 返回执行结果或错误码 |
关键点是 INVOKE 和 DELIVER 分离。调用方发出 INVOKE 后,中心节点只负责路由和投递,不傻等 Agent 的最终结果。后续结果通过 RESULT 消息异步回传,调用方通过 request_id 关联。这样做的好处是调用方不会被一个长时间运行的 Agent 任务阻塞住,可以同时触达多个 Agent、做超时管理,也方便中心节点在结果返回前换节点重试。
握手流程上,Agent 启动后先发 REGISTER,中心节点会把它标为 Pending;只有完成一次探活(一般是发一条最小化的 echo 请求)后,状态才转成 Ready。这个细节直接避免了后面要讲的“幻影触达”问题。
2.3 路由策略:轮询、一致性哈希、能力路由怎么选
路由是触达层区别于普通消息队列的核心。Agent-Reach 内置三种策略,按 Descriptor 里的 route_hint 字段切换:
轮询适合同能力多副本。比如有三台机器都部署了“内容审核”Agent,流量直接均摊,简单粗暴,效果也不错。代价是没有任何亲和性,每次都随机挑一台。
一致性哈希专门服务有状态 Agent。有些 Agent 内部维护了上下文,比如长期 memory 或工具会话状态,同一个用户最好每次都触达同一个节点。Agent-Reach 用 agent_id 或调用方传入的 session_key 当哈希键,节点增减时只影响相邻一小部分映射,不会把会话打散。
能力路由是默认策略:调用方不指定“具体是谁”,而是写 agent_id 或者能力标签(比如 capability=summary,tags=zh)。中心节点维护了一张“能力标签 → 存活 Agent 列表”的索引,按权重和优先级挑节点。这个策略是 Agent-Reach 真正区别于普通网关的地方——它路由的维度是“能力”,不是“URL”。
2.4 关键参数到底怎么定:超时、重试、限流的计算逻辑
所有触达参数都不能拍脑袋写死,我总结的公式如下:
超时预算:取该 Agent 历史 TP95 耗时 × 3,再加上一个固定余量(通常 5~10 秒)。为什么是 TP95 而不是平均值?平均耗时会掩盖最慢的 5% 请求,而 Agent 的耗时波动通常比普通 API 大得多。见过很多事故都是“平均耗时 2 秒,超时设 5 秒,结果高峰期 TP99 突然跳到 8 秒,整个调用链跟着崩”。
重试次数:取决于操作的幂等性。Agent 任务大致分三级:只读类(查询、分析)可以放心重试;幂等写类(按 request_id 去重后的更新)允许重试;非幂等写类(付款、下单、发消息)默认不重试,或者只做“人工确认式重试”。重试退避用指数退避加抖动:base * 2^n + random(0, 500ms),避免多个调用端同时重试把系统打炸。
限流:给每个 Agent 设置 token bucket 阈值。桶容量取 Agent 每秒能稳定处理的最大并发数,补充速率按 TP95 反推。比如单 Agent 处理一个请求平均耗时 10 秒,最大并发 5 个,那么每秒补充速率就是 0.5 个 token,相当于限制每秒最多接受 0.5 个新任务,超出直接返回 429 错误码。这个设计能防止上游疯狂投递任务把一个处理能力有限的 Agent 撑爆。
3. 落地实操:用 Agent-Reach 把三个 Agent 串起来
3.1 先定义 AgentDescriptor:让机器看得懂“你是谁、能干什么”
我通常用一个独立的 descriptor JSON 描述 Agent 对外能力,注册时原样提交。下面是一份实际使用的配置片段:
{ "agent_id": "reviewer-01", "name": "内容质检员", "version": "1.2.0", "capabilities": [ { "name": "review", "weight": 80 }, { "name": "comment", "weight": 20 } ], "tags": ["zh", "strict"], "endpoint": "http://192.168.1.31:7311/agent/invoke", "protocol": "http_json", "route_hint": "hash", "max_concurrency": 5, "heartbeat_interval": 15, "os": "linux", "runtime": "python3.11" }这里的 weight 是我在实战中加的:同一个 Agent 声明自己能做 review 和 comment 两种能力,但 review 是主业,权重更高。中心节点路由时会优先把它当 review 节点用。tags 用来支持灰度:先给新版 Agent 打上 canary 标签,路由时按比例把少量流量导过去,验证没问题再把权重调高。这个能力非常实用,多 Agent 系统的升级再也不用停机了。
3.2 启动中心节点并注册 Agent
Agent-Reach 中心节点启动只需要一条命令,默认监听 9000 端口。我一般在启动参数里指定快照文件的保存路径和心跳超时上限:
agent-reach center --listen 0.0.0.0:9000 --snapshot /data/agent-reach/snapshot.json --heartbeat-timeout 45Agent 侧注册也非常简单,我封装了一个 SDK 客户端,核心逻辑就是启动后带着 descriptor 上报:
from agent_reach import AgentClient descriptor = load_descriptor("descriptor.json") client = AgentClient(center_url="http://127.0.0.1:9000") client.register(descriptor) client.start_heartbeat() # 默认每15秒上报一次 # 阻塞住,保持进程存活 client.serve()如果你的 Agent 业务逻辑不是常驻进程,而是定时任务或一次性脚本,可以改成注册后执行主逻辑,最后主动client.unregister(),否则节点状态要等心跳超时才会被摘除。
3.3 调用端发起一次带重试的触达请求
调用端最核心的体验是:我不需要知道目标 Agent 在哪台机器,我只需要描述“我要找谁、能等多久、可不可以换人重试”。下面是 Python 调用示例:
from agent_reach import InvokeRequest, CallerOptions import time req = InvokeRequest( capability="review", # 按能力路由 tags=["zh"], # 可选条件:只找中文质检能力 payload={"text": "需要审核的文章正文"}, request_id=f"req-{int(time.time()*1000)}", # 幂等去重键 timeout_budget=30, # 总超时预算30秒 ) opts = CallerOptions( retry_count=2, # 允许重试2次 retry_backoff_base=2.0, # 指数退避基数 retry_on_timeout=True, # 超时了允许换节点再试 retry_on_error_code={500, 503}, idempotency_level="idempotent_if_id_match", # 用request_id去重 ) resp = client.invoke(req, opts) print(resp.result_id, resp.status, resp.data)这里的重点在 idempotency_level 和 request_id。重试机制要安全,关键在于目标 Agent 必须遵守约定:如果收到的任务 request_id 已经在自己的处理记录里,直接返回上一次的结果,而不重新执行。我在内部 Agent 的 Prompt 工具层加了一个“消息去重表”来实现这一点,效果很好。如果没有这层保障,重试次数宁可设成 0。
中心节点收到 INVOKE 后会立刻回 INVOKE_ACK,然后开始路由、投递、等待结果。因此client.invoke()实际上是异步模型:它先拿到 ACK,再在后台等 RESULT。上面代码里我只展示了同步封装接口,内部就是一次 ACK + 长轮询等待 RESULT 的组合。
3.4 不同延迟场景的触达参数推荐表
不同 Agent 的耗时天差地别,我给不同场景整理了一份起始配置,实测调优后整体可用性提升明显。
| 场景 | 典型耗时 | 超时预算 | 重试次数 | 退避基秒 | 说明 |
|---|---|---|---|---|---|
| 本地纯计算 Agent | 50~200ms | 2s | 3 | 0.5 | 重试可激进,成本低 |
| 本地工具调用链 | 500ms~3s | 8s | 2 | 1.0 | 需要关注下游工具可用性 |
| 云端 LLM 生成 | 3~15s | 45s | 1 | 2.0 | 重试一次已经是极限 |
| 异步长任务 Agent | 30s~5min | 90s | 0 | / | 建议改成轮询结果而非同步等待 |
| 多Agent协同链路 | 与最慢节点一致 | 按Per Pod计算 | 0~1 | / | 链路末端不建议无限重试,会产生放大效应 |
这个表格是我从真实项目里抽出来的平均值。核心思路是:节点的耗时越长,重试越要克制。因为一次超时可能只是 LLM 服务抖动,也可能说明节点已经过载;过载时再重试,等于把请求再往一个快不行的节点上压。
4. 我踩过的坑:消息乱序、幻影触达、重试风暴
4.1 消息乱序:请求还没完成,结果先回来了
早期版本里,我为了让“触达”更快,允许中心节点在收到 INVOKE 后直接转发给 Agent,然后再补 ACK。结果出现了一个很隐蔽的问题:Agent 执行很快时,RESULT 可能会先于 ACK 到达调用端,调用端如果没有做好状态机,会把一次请求的结果当作另一个请求的响应,或者直接丢弃。
这个坑的教训是:任何异步分布式系统都必须把“消息到达顺序不等于消息发送顺序”当成前提。后来我在所有消息里强制带上 monotonic 序列号,调用端用一个简单的状态机维护 request_id 和 var(sequence_id) 的映射,只接受当前期望序号的结果,迟到的结果一律进入“迟到队列”做审计,不参与业务判断。有状态 Agent 节点上还得维护会话版本号,每次会话状态修改都递增版本,路由时优先选版本号最新的节点,旧节点上的迟到消息不会覆盖新状态。
4.2 幻影触达:Agent 还没就绪,任务已经派过去了
这是最坑的一次线上事故。一个小助手 Agent 的模型加载比较慢,启动后要 40 秒才能完全就绪,但它注册后立刻被标记为活着。结果有几秒窗口期里,路由把所有触达请求都派给了这个“假活着”的 Agent,请求全部积压在它的请求队列里,等模型加载完才依次处理,直接导致前端用户看到整整一分钟的空白等待。
修复办法就是我前面提到的“Pending 探活”机制。Agent 注册后必须先进入 Pending 状态,中心节点立刻发送一条探活消息,Agent 必须真实完成一次最小任务回执之后,状态才切换到 Ready,此时才进入路由候选池。这个机制也适用于“模型热加载”类 Agent,你可以把就绪回调放在模型加载完成后调用。探活消息一定要足够轻,通常就是一个空 token 或一次内存表查询,不要触发完整模型推理,否则就绪检测本身变成另一个超时源。
4.3 重试风暴:一次 LLM 抖动,打挂了整个触达层
有一次中心节点的路由表显示三个 Agent 全部在线,但其中两个已经被上游限流。结果其中一个 Agent 返回错误码,调用端的重试机制立刻把请求切到另一个 Agent,那个 Agent 也抵挡不住,返回错误,再重试切回来。两边开始了互相伤害,中心节点的投递队列也越积越多。
这个事故的核心问题是:重试决策判断的是“失败”,而不是“整个集群是否过载”。修复措施有三板斧:
- 中心节点对每个 Agent 做连续失败计数,失败超过 3 次,节点状态自动降级为 Suspect,默认路由时跳过。
- 重试队列独立于主调用链,重试请求不再直接打回原 Agent,而是先进入带延迟的重试队列,用恒定速率释放。
- 对每个 Agent 的能力路由做“最大在途限制”,超过并发上限直接返回 429,调用端收到 429 时不做重试,只做更长周期的冷却。
这三条加在一起,系统从“故障时疯狂重试”变成“故障时主动减负”,效果非常明显。我后来查了下,其他网关系统也有类似“快速失败 + 慢速重试”的设计,但很多人做 Agent 系统时会想当然认为 LLM 偶尔出错,多试几次就好,结果反而把可控差错放成了下游雪崩,这点值得反复讲。
4.4 常见问题速查表
| 症状 | 可能原因 | 定位方法 | 解决方案 |
|---|---|---|---|
| 调用偶尔成功偶尔超时 | 节点没做熔断,过载节点仍被路由 | 看监控里单节点 TP99 与并发数 | 开启最大并发限制,连续失败摘除 |
| Agent 明明活着,却收不到任务 | 注册中心与调用端网络隔离 | 检查中心节点路由日志 | 确认 INVOKE 是否被 ACK,是否投递中丢失 |
| 同一任务重复执行 | 消息重试但未做幂等 | 查 Agent 方日志里相同 request_id 出现次数 | 实现 request_id 去重表,或调用端降重试次数 |
| 节点重启后内存路由表瞬间清空 | 中心节点重启,快照恢复为空 | 查看快照文件时间戳 | 恢复期间触达一律排队,等首轮心跳完成 |
| Agent 处理慢,但路由照样往它身上塞 | 权重未与实时负载动态关联 | 看是否命中 target 负载过高 | 启用基于 CPU/队列深度的动态权重下调 |
| 下线 Agent 后流量还在打过去 | 调用了 unregister 但连接未断开 | 看 Agent 侧是否还残留已 ESTABLISHED 连接 | unregister 后主动关闭连接池,Center 强制断开 |
第 4 行的情况我实际遇到两次:中心节点发布时重启,快照文件写得太晚,导致路由表全空。后来我改成“快照不覆盖新启动后的首次心跳”,并且中心节点启动后前 5 秒内不路由任何请求,只接收心跳和注册,靠这 5 秒静默把窗口期补掉。
5. 版本迭代方向与我的取舍建议
Agent-Reach 目前还在持续打磨,我自己最想做的两个方向是“动态权重”和“协议回调”。动态权重是让中心节点根据 Agent 上报的实时队列深度,动态调整它在路由表中的比重;队列深了权重自动下降,本来就慢的节点不用等人去配置。协议回调则是让 Agent 端可以由中心节点反向触发一个内部回调 URL,适合那些无法常驻端口、只有出站能力的脚本类 Agent。这俩做完之后,很多边缘场景的 Agent 注册就不再受网络限制。
如果你也想在自己的项目里落地这套触达层,我给三个实操建议。第一,不要一开始就追求大而全,先做注册、心跳、能力路由、超时重试四条主线,够用了再补熔断和限流。第二,所有 Agent 任务定义里必须带 request_id,这是安全重试的唯一前提,丢了它整个系统都没法做可靠触达。第三,把中心节点的路由日志记全,起码保留 request_id、目标节点、路由原因、耗时、结果状态五列;以后排查故障,这些日志就是唯一的线索。
我个人的体会是,Agent 系统发展到某个规模后,瓶颈往往不是在模型效果上,而是在“不同 Agent 之间能不能稳定地互相找到、互相通信”这件基础设施小事上。Agent-Reach 的价值就在于给这一层提供了确定性和可观测性。你不需要直接照搬我的代码,只需要理解并复刻这里的几个核心决策:注册发现分离、路由按能力不按地址、超时重试必须配合幂等、节点状态必须有探活门禁。把这四条想明白,你的多智能体系统哪怕没有这层框架,也已经比大多数人可靠一大截。