做多智能体系统的这半年,我反复被同一个问题折腾到失眠:模型明明能给出漂亮的计划,Agent 进程就在线上跑着,心跳也正常,可一旦要真正调用另一个 Agent 的能力——要么超时,要么拿到一个已经死掉的实例地址,要么三个 Agent 同时抢同一个任务导致全盘失败。
后来我把这些问题收敛成一句话:Agent 之间的问题,大多不是"智能"问题,而是"可达"问题。
Agent-Reach 就是在这个背景下折腾出来的。它不是一个模型框架,也不做推理编排,它只专注一件事——让任何一个 Agent 在需要另一个 Agent(或工具、知识库、外部服务)时,能快速、稳定、可观测地"到达"对方。这套东西最初只是我项目里的一个注册中心加几段重试代码,后来逐渐演化成了一套包含地址注册、意图路由、心跳探活、故障降级和链路追踪的最小框架。
如果你也在做多智能体系统,遇到过"进程活着但请求失败"、任务分发到不存在的 Agent、或者多个 Agent 协作时链路断在半路这类问题,这篇文章值得看完。我会把 Agent-Reach 的设计思路、最小实现、我在线上踩过的三个大坑,以及最后沉淀下来的一组参数建议,全部摊开来讲。
1. Agent-Reach 到底解决什么问题:多智能体协作的"最后一公里"
1.1 "明明在线,却联系不上"的典型现场
先还原几个我真实遇到的现场。
第一个场景是任务分发。系统里有三个负责不同领域的 Agent,调度器通过配置文件写死了每个 Agent 的地址。某天其中一个 Agent 因为发布新版本重启了一次,IP 变了,但调度器的配置没跟着变。结果是所有分给它的任务全部失败,而且报错信息非常暧昧——"connection refused" 和 "timeout" 交替出现,看起来像网络问题,实际上就是个过期地址问题。
第二个场景是能力调用链。主 Agent 需要调用一个"数据分析 Agent"来算指标,但在我的设计里,数据分析这件事有两个团队各自实现了一版,一版在预发环境,一版在生产环境。调度器只记得一个地址,就把任务发给了预发环境的实例,算出来的数据自然带着环境差异。
第三个场景是协作风暴。三个 Agent 同时感知到"某个下游服务变慢了",于是同时发起对同一个恢复 Agent 的调用请求。恢复 Agent 自己没有做并发保护,直接把系统打到更慢,形成雪崩。
这三个场景看起来各有各的原因,但本质上指向同一个问题:在一个多 Agent 系统里,"谁能干什么、现在在哪、是不是还活着、把任务给谁最合理"这些信息,缺少一个统一的、有生命周期的管理体系。
1.2 可达性(reachability)和传统健康检查有什么不一样
传统微服务架构里也有服务注册与发现,也有健康检查,Agent-Reach 和它们有什么本质区别?我的理解是,传统健康检查回答的是"进程活没活",而 Agent 场景需要回答的是"这个 Agent 当前有没有能力、愿不愿意接这个类型的工作、接了能不能在限定时间内回话"。
举个例子。一个 Agent 进程可能活着,但它依赖的模型服务已经超载,每次推理耗时 30 秒;或者它被设计成"只在白天处理业务请求",晚上虽然在线但不接受任务。这些信息,一个 TCP 探活端口是给不出来的。
所以 Agent-Reach 把"可达"拆成了三个递进的层次:
- 连接层可达:网络路径通,HTTP/gRPC 端口能连上。
- 实例层可达:进程活着,且它自己认为自己状态正常,愿意进入接收任务的状态。
- 能力层可达:它明确声明自己能处理某类任务,且当前的负载和配置允许它接新任务。
传统注册中心大多只覆盖前两层,Agent-Reach 的关键是补上了第三层。
1.3 设计目标:注册、路由、超时、降级四件事
把问题收敛完之后,我给 Agent-Reach 定了四个设计目标,后面所有实现都围绕这四个目标展开。
- 注册:每个 Agent 启动时主动上报自己的地址、能力列表、租约(lease)期限,并持续续租。不上报 = 不存在。
- 路由:调用方不直接指定"打到某个地址",而是声明"我需要某类能力",由 Agent-Reach 负责把请求路由到当前最适合的实例。
- 超时:每一次可达性尝试都有明确的时间预算,超过预算立即失败并返回原因,绝不无限等待。
- 降级:当一个 Agent 不可达时,系统要能自动尝试同类备选实例,并把这个过程记录成可审计的日志。
这四个目标听起来朴素,但每一项落实下来都会碰到细节问题。比如"路由到当前最适合的实例",什么叫"最适合"?负载最低?历史成功率最高?离调用方最近?我这里采用的策略是:优先选择同能力组内评分最高的实例,评分由实时心跳新鲜度、最近任务成功率、当前并发数和网络延迟四部分加权得到。
2. 核心架构:地址簿、路由平面与心跳三层解耦
Agent-Reach 的核心架构可以看成三个相互独立又协作的平面。把这三层拆开,是最重要的一条设计决定,因为线上出的大多数问题都可以归因到某一层的职责错位。
2.1 第一层:Agent 注册表
注册表是一个轻量的目录服务,理论上可以挂在 Redis 上,也可以用内存表加持久化备份。Agent 启动时向注册表发送一个注册请求,内容包括:
- agent_id:全局唯一标识;
- capabilities:语义化能力标签列表,比如 ["data_analysis", "report_generation"];
- endpoint:当前实例的访问地址;
- ttl:本次注册的有效期。
注册之后,Agent 必须周期性地续租。如果超过租期没续租,注册表自动把这条记录标记为过期并最终清理。这就是所谓的"租约制",它是解决"进程死了但地址还在"问题的关键。
2.2 第二层:意图路由
调用方不关心具体实例,它只发一个"意图",比如 task = { "capability": "data_analysis", "payload": {...} }。Agent-Reach 的路由器会做三件事:
- 根据 capability 找到候选实例集合;
- 用评分公式对候选实例排序;
- 选最优实例发起调用,同时启动超时计时。
这套机制的收益是,新增一个 Agent 实例时,只要它注册了同样的 capability,它就有了被分到任务的机会,不需要改动任何调用方代码。这直接解决了我前面说的"配置写死地址"问题。
2.3 第三层:心跳与探活
心跳很容易做成摆设——很多系统有心跳,但没人真拿它做决策。Agent-Reach 里心跳和租约是绑定的:心跳不仅用来"证明在线",还捎带上报当前并发数、最近失败率、处理队列深度三个指标。这些指标会被路由评分公式实时消费。
也就是说,一个 Agent 哪怕心跳正常,如果它上报的并发数已经超过阈值,路由评分就会自动把它排到后面。这样就把"能连上"和"该不该把任务给它"真正区分开了。
2.4 核心组件速览
下面这张表是我项目里 Agent-Reach 最精简的组件清单,实现时直接照着建模块就行。
| 组件 | 职责 | 关键接口/参数 | 存储/依赖 |
|---|---|---|---|
| RegistryServer | 注册、续租、过期清理 | register, renew, deregister | Redis + 内存缓存 |
| IntentRouter | 能力匹配、评分排序、发起调用 | route(task) | RegistryServer |
| HeartbeatWorker | 周期上报状态指标 | report(agent_id, metrics) | 各 Agent 内嵌 |
| CircuitBreaker | 单实例熔断保护 | failure_count, reset_timeout | 内存滑动窗口 |
| TraceCollector | 记录每次可达性尝试的完整链路 | trace_id, duration, result | 日志文件/时序库 |
这个表看起来简单,但每一行在线上都会有它的故事。比如 CircuitBreaker,如果没有它,路由风暴(我后面会讲)能把整个系统拖垮;TraceCollector 如果没有,遇到"时好时坏"的问题就只能靠猜。
三层之间的关系一定要理清:注册表负责回答"谁存在",路由负责回答"把任务给谁",心跳负责回答"谁现在可以接"。三层各干各的事,互不越权,这是后面排查问题时能快速定位的前提。
3. 从零搭一套最小可用的 Agent-Reach
这一节直接上代码。我的实现语言是 Python,注册表和路由器用 FastAPI 写,Agent 端用普通线程做心跳。你要是用其他语言,思路完全一样,照着替换技术栈就行。
3.1 环境准备与选型理由
需要的东西很少:
- Python 3.10+;
- FastAPI 和 uvicorn(注册表和路由器跑这两个);
- httpx(Agent 端发 HTTP 请求);
- 一个 Redis 实例(不想要 Redis 也可以用内存字典顶替,单机演示够用)。
选 FastAPI 是因为它写这类内部工具最快,文档也好。选 httpx 是因为它支持连接池和超时控制,这在后面的半开连接问题里会派上用场。你要是习惯 Go 或 Node,用对应的 HTTP 框架完全等价,核心逻辑不受语言限制。
安装依赖就三行:
pip install fastapi uvicorn httpx redis3.2 把注册表跑起来
注册表的核心就两个接口:register 和 renew。我贴一个简化版,只保留关键逻辑,去掉了鉴权和多租户部分。
# registry.py import time import redis r = redis.Redis(host="localhost", port=6379, decode_responses=True) def register(agent_id: str, endpoint: str, capabilities: list[str], ttl: int = 45): key = f"agent:{agent_id}" r.hset(key, mapping={"endpoint": endpoint, "capabilities": ",".join(capabilities), "ttl": ttl, "registered_at": time.time()}) r.expire(key, ttl) def renew(agent_id: str, ttl: int = 45): if r.exists(f"agent:{agent_id}"): r.expire(f"agent:{agent_id}", ttl) def scan_active(): result = {} for key in r.scan_iter("agent:*"): data = r.hgetall(key) result[key.removeprefix("agent:")] = data return result这里我把"过期"直接委托给 Redis 的 expire 机制,Agent 每次续租都会刷新过期时间。Redis 的 key 过期是惰性删除加定期删除,对这种低频场景足够了。scan_active 每次全量扫一次,Agent 数量到几百个时也没压力,真到了几千个再换索引结构,不必过早优化。
3.3 让 Agent 注册并持续心跳
Agent 端要做两件事:启动时注册一次,然后开一个后台线程周期续租。我习惯把心跳周期设为租约的三分之一,比如租约 45 秒,心跳就每 15 秒一次。这样即使偶尔丢两个心跳包,也不会导致误判下线。
# agent_side.py import threading import time import httpx REGISTRY_URL = "http://localhost:8000/register" def _beat(agent_id: str, ttl: int): while True: time.sleep(ttl / 3) httpx.post(f"{REGISTRY_URL}/renew", json={"agent_id": agent_id}) def start_agent(agent_id: str, endpoint: str, capabilities: list[str], ttl: int = 45): httpx.post(REGISTRY_URL, json={ "agent_id": agent_id, "endpoint": endpoint, "capabilities": capabilities, "ttl": ttl, }) threading.Thread(target=_beat, args=(agent_id, ttl), daemon=True).start()注意一个容易忽略的点:注册和续租的请求必须带重试,而且重试要有退避。如果注册中心正在动荡(比如它自己重启了),Agent 会陷入"疯狂重连"状态。我的做法是每 15 秒最多重试 3 次,指数退避,最大间隔不超过心跳周期。
还有一件事必须做:进程收到退出信号时,主动调用一个 deregister 接口,把自己标记为"下线中"。这个动作看着小,实际能避免掉后面要讲的幽灵路由问题,属于花一分钟救十小时的那种操作。
3.4 发起一次可达性调用
路由器收到一个任务后,先按 capability 查候选实例,打分排序,再发起调用。打分公式我放在下面。
# router.py import time import httpx def route(capability: str, payload: dict, timeout: float = 5.0): candidates = registry.scan_active() scored = [] for agent_id, data in candidates.items(): if capability not in data["capabilities"].split(","): continue freshness = time.time() - float(data["registered_at"]) load = int(data.get("concurrency", 0)) score = 0.6 * (1 - freshness / float(data["ttl"])) - 0.4 * load scored.append((score, agent_id, data)) scored.sort(reverse=True) if not scored: return {"status": "no_available_instance", "capability": capability} endpoint = scored[0][2]["endpoint"] resp = httpx.post(f"{endpoint}/invoke", json=payload, timeout=timeout) return resp.json()评分公式我简化成了两项:新鲜度和并发数。实际线上版本还要加历史成功率和网络延迟,但核心逻辑就是这个——按综合评分而不是随机或轮询来决定把任务交给谁。
调用这一步还有两个关键设置。一是 timeout 参数必填,很多 HTTP 库默认是无限等待,一个卡死的 Agent 能把整个调度线程拖住;二是路由侧要捕获连接异常而不是让异常往上抛,捕获后把该实例的失败计数加一,留给熔断器判断。
3.5 端到端验证
最小系统跑通后,验证流程要覆盖三个场景:正常调用、Agent 下线后的自动剔除、多副本时的负载分配。
我建议用这种方式验证:先起一个 Agent,发起任务,确认返回结果;然后 kill 掉 Agent,等一个租约周期(45 秒),再次发起任务,确认路由返回"无可用实例"而不是超时挂起;最后起两个相同能力的 Agent,连续发 20 个任务,确认它们都会被分配到,而不是永远打在第一个实例上。
这三条验证过了,"最小可用"才算成立。我见过不少人跳过第二步直接上线,结果下线一台机器后,系统里还有大量请求往旧地址打,靠运气等 Redis 过期才恢复,这是完全可以避免的。
4. 线上踩坑实录:半开连接、幽灵路由与路由风暴
架构和 demo 都能跑通之后,真正折磨人的问题都是上了真实业务流量才浮出来的。我挑了三个影响最大、也最值得复盘的。
4.1 坑一:心跳正常但请求超时——半开连接
现象:Agent 心跳一直在续租,状态面板显示全部正常,但调用方时不时报 timeout。
排查链路:我先怀疑网络,但不同机器上的调用方都有类似现象,时间点又不固定。再看 Agent 日志,发现请求其实根本没到 Agent——连接建立失败。可心跳为什么没暴露?因为心跳走的是 Agent 主动外发的连接,而调用方到 Agent 的连接是另一个方向。Agent 的机器上,有个中间网络设备静默丢弃了长时间空闲的连接,导致调用方手里持有的连接池连接已经死了,但双方都不知道。
修复:在调用方连接池里加"预检"机制——每次从连接池取连接前,先判断该连接的空闲时长,超过阈值就主动丢弃重建。同时给所有 HTTP 调用加上 keepalive 检查和 tcp_keepalive 参数。这个问题的本质是:在线状态的判断不能只看一方的心跳,还要看连接另一侧的实际状态。
4.2 坑二:Agent 重启后旧路由缓存导致的幽灵连接
现象:一个 Agent 每次重启后,系统里总有一部分请求还会打到它的旧 IP 上,直到旧 IP 上的服务彻底没人监听,错误才消失。
排查链路:我查了路由器的代码,发现候选实例列表是从注册表实时拉取的,理论上应该只包含新 IP。那旧 IP 是哪来的?后来发现是调用方 SDK 里自己缓存了最近成功调用的实例地址,缓存过期时间 10 分钟。Agent 重启期间,调用方的 10 分钟缓存没失效,就继续往旧地址打。
修复:两条腿走路。第一,Agent 进程收到 SIGTERM 时,必须主动向注册表发 deregister,把实例标记为"下线中";第二,调用方侧的地址缓存不能超过租约周期,而且拿到地址后要再做一次轻量确认。现在我把租约设成 45 秒,缓存 TTL 压到 10 秒,两者绑定后幽灵连接基本消失了。
4.3 坑三:多个 Agent 同时失败引发的路由风暴
现象:某次下游模型服务抖动,导致 20 个 Agent 同时调用失败。正常情况下失败应该被熔断吸收,但我看到的现象是,20 个 Agent 全部在瞬间发起重试,重试的目标又是同一个备选实例,直接把备选实例打挂。
排查链路:看调用链日志,发现每个 Agent 的重试策略都是"失败后等 1 秒重试",20 个 Agent 的时间轴完全对齐,形成了一波肉眼可见的"重试潮"。这是典型的雷同退避问题——所有客户端用相同的固定间隔,等于没有退避。
修复:给每个 Agent 的重试间隔加上随机抖动(jitter),让它们在 [0.5s, 1.5s] 区间内随机取值。同时在路由器侧加熔断器:某个实例连续失败 5 次后,直接把它从候选列表里摘掉 30 秒,让其他实例承担流量。修复后,同样的抖动场景,重试产生的峰值流量下降了 80% 以上。
4.4 这三个坑背后的共同教训
把这三个坑放在一起看,会发现它们其实都是同一个问题的不同侧面:多 Agent 系统里,任何一个"假设"都可能被时间或环境打破。你觉得 Agent 会一直在线,它可能重启了;你觉得心跳正常就代表连接可用,中间设备的静默丢弃不会通知你;你觉得大家都按自己的节奏重试,结果所有人的节奏完全一样。
所以 Agent-Reach 最后沉淀下来的核心动作就三句话:一切状态都要有租约和过期时间;一切连接都要有预检和超时;一切重试都要有随机抖动和熔断。
5. 把 Agent-Reach 用稳的经验参数与调优建议
这套框架单独跑起来容易,但参数调不好,线上一样出事。下面是我在各种负载条件下压出来的一个经验基线,供参考,不要直接照抄——每个人的网络环境、任务耗时、Agent 数量都不一样,基线是起点不是终点。
5.1 心跳、租约与超时的取值逻辑
| 参数 | 我常用的值 | 取值逻辑 |
|---|---|---|
| 心跳间隔 | 15s | 租约的 1/3,容忍偶发丢包 |
| 租约 TTL | 45s | 心跳间隔 × 3,留出两次失败余量 |
| 单次任务超时 | 5s(同机房)/ 10s(跨区域) | 超过排队等待上限即放弃 |
| 地址缓存 TTL | 10s | 必须小于租约 TTL,避免幽灵路由 |
| 重试退避 | 1s/2s/4s + 20% 抖动 | 指数退避,最多 5 次 |
我特别想强调地址缓存 TTL 这一行。很多系统的注册和心跳都做了,唯独调用方侧的缓存没管好,导致"注册表是对的,但调用方用的是旧数据"。缓存 TTL 小于租约 TTL,是一个纪律性约束,不是性能优化。
5.2 路由降级策略:同能力多副本的优先级
当一个 capability 有多个实例时,我的路由降级策略是:
- 第一优先:评分最高的同机房实例;
- 第二优先:评分最高的同区域(其他机房)实例;
- 第三优先:任何可达的同能力实例;
- 全不可达:立即返回"无可用实例",并附上当前候选列表快照和失败原因。
第四点很重要。很多人会把"无可用实例"做成无限重试,结果任务堆积。正确的做法是快速失败并显式上报,让上层业务决定是排队还是换方案,而不是在路由这层把所有时间耗尽。
5.3 可观测性:链路追踪与状态面板
Agent-Reach 里每一次可达性尝试都应该有一条完整记录,至少包含:trace_id、源 Agent、目标 Agent、capability、耗时、结果、失败原因。
我一开始偷懒没做这块,结果排查上面三个坑全靠翻日志拼时间线,效率极低。后来补了一个极简的状态面板,实时展示每个 Agent 的:上次心跳时间、当前并发数、最近 1 小时成功率、熔断状态。就这几张卡片,已经能覆盖日常 80% 的排查场景。
状态面板和链路记录不必一上来就做得很重,能回答"现在谁活着、谁在接活、成功率多少"就够了,后续再按需加维度。
5.4 什么时候不该用这套东西
最后说点反的。Agent-Reach 不是银弹,有些场景用它纯属过度设计:
- 你只有一个 Agent,所有能力都在一个进程里。此时引入注册表除了多一层故障点,没有任何收益;
- 你的 Agent 数量长期是个位数,且地址几乎不变。写死配置加个健康检查,可能比全套注册路由更省心;
- 你的业务场景是离线批处理,任务一次性丢进去,不需要实时协作。这时应优先保证队列可靠性,而不是在线可达性。
我的判断标准很简单:当"Agent 找不到对方"已经以每周超过一次的频率出现在你的故障复盘里,才值得引入这套体系。在此之前,先用最简单的方式跑起来。
Agent-Reach 从最初的一百多行脚本,演化到现在这个带注册表、路由器、熔断器和状态面板的架子,中间踩的坑基本都写在上面了。如果让我重来一次,我会更早地把租约制和随机抖动加进去——这两个东西成本极低,收益却最大。你如果要搭自己的多智能体底座,我建议也从这两件小事开始。