1. Agent-Reach 是什么:我为什么在折腾"AI Agent 互相找不到"这个问题
最近半年我一直在做多智能体协同的项目,说白了就是让几个不同职责的 AI Agent 配合干活:一个负责规划,一个负责检索知识库,一个负责调用外部工具,最后还有个总结汇报的。想法很好,可真到落地的时候,最让我头疼的不是模型本身的推理能力,而是这些 Agent 之间怎么稳定地找到对方、怎么把消息可靠地送过去。
你想想看,一个 Agent 在 VPS 上跑,另一个在本地 Docker 里,还有一个在另一台机器的容器中,它们各自有各自的地址和端口,谁也不知道谁在线。如果其中一个 Agent 因为内存爆了重启了,IP 变了,其他 Agent 还按照老地址去连它,要么超时、要么直接报错。这种"明明服务都部署了,但系统就是跑不通"的问题,比调 prompt 恶心多了。
所以我做了一个小框架,名字就叫Agent-Reach。它的定位很简单:给一套分布式环境下的 AI Agent 提供注册、发现、健康检查、路由转发的能力。你可以把它理解成 Agent 界的"通讯录+传话人+门卫"—每个 Agent 启动后先来报到,报上自己的地址、能力标签、运行状态;其他 Agent 要调用它的时候,不需要写死 IP 和端口,只要按能力名字问 Agent-Reach 要一个可用目标就行。
这个框架使用过一个内部项目,后来我把核心逻辑抽出来开源了,发现不少同行也有同样的痛点。如果你也在搞多 Agent 系统、Agent 工具链、或者是想把零散的模型服务编排成一个整体,那这篇笔记应该能帮到你。我会把设计思路、核心实现和实际操作都讲透,顺便把我踩过的坑也一并列出来。
2. 整体设计思路:为什么不能直接抄服务发现那套老方案
2.1 传统服务发现体系的"水土不服"
最早我考虑过直接用 Nacos 或者 Consul 这类成熟的服务注册中心,毕竟它们已经解决了微服务的注册发现问题。但真上手之后发现,Agent 场景和普通微服务有本质区别。
普通微服务接口是固定的,调用方知道我要调什么接口、传什么参数,服务按 URL 暴露,注册中心只需要管地址和负载均衡。但 Agent 不是这样:Agent 的能力是动态的,比如一个"搜索 Agent"今天能接搜索请求,明天你可能给它挂了新的数据库检索插件,它对外暴露的调用协议也跟着变了。你没法用一个固定 URL 去描述它。
另一个问题是,Agent 调用往往带语义上下文。同样的"帮我查一下 XX",落到不同的 Agent 上,需要的参数结构完全不一样。如果注册中心只管地址,那调用方拿到地址之后还得自己去猜怎么组包调用,这显然不行。
2.2 Agent-Reach 的关键取舍
在设计 Agent-Reach 时,我没有贪多求全地搞一套复杂中间件,而是抓了几个核心场景:
- 注册:Agent 启动时把自己暴露的服务名、协议、元数据报上来
- 发现:调用方按"能力标签 + 版本 + 路由策略"找到目标 Agent
- 心跳:Agent 定期上报存活状态,异常自动摘除
- 转发:调用方不直接连 Agent,而是通过 Agent-Reach 做透明转发,统一处理超时和重试
这样做最大的好处是调用方逻辑被大幅简化。Agent 之间不需要知道对方的实际地址,只需要记住"我要找什么能力的 Agent",剩下的交给 Reach 层搞定。
做个对比你就明白了:
| 能力 | 普通注册中心 | Agent-Reach |
|---|---|---|
| 地址注册发现 | 有 | 有 |
| 能力语义描述 | 弱,需要额外约定 | 基于能力标签和元数据 |
| 动态协议适配 | 不支持 | 支持插件化协议适配 |
| 转发与代理 | 不负责 | 内置,默认启用 |
| 智能路由(按成本/延迟/能力) | 基本没有 | 支持 |
| Agent 状态管理 | 只看存活 | 区分活跃/空闲/异常,结合会话负载 |
这里有个取舍是刻意的:我不追求极致的吞吐,而是追求多 Agent 协作下的可靠性和灵活性。Agent 之间调用频率远低于 RPC 内部调用,但每一次调用都更"重",失败重试成本也高,所以宁可让请求多走一跳,也要保证"能找到、找得对、能接通"。
2.3 三个核心抽象:Endpoint、Registry、Reach Policy
整个 Agent-Reach 的核心可以拆成三个抽象概念:
Endpoint(端点):一个可被调用的 Agent 实例。包含 agent_id、host、port、protocol(如 JSON-RPC、gRPC、HTTP 流式)、capabilities(能力标签集合)、weight(权重)、metadata(扩展信息,比如支持的模型名、地区、优先级)。
Registry(注册表):存储在内存和持久化后端中的端点集合。负责维护端点的完整生命周期,从注册、更新、续约到摘除。它还维护着能力标签到端点的倒排索引,方便按能力快速检索。
Reach Policy(路由策略):决定"当有多个满足条件的 Agent 时,选哪一个"。默认实现是加权轮询加最小连接数混合,但我把接口留了出来,你可以自己实现按会话亲和、按成本最低、按区域就近等策略。
这三个抽象合在一起,就构成了一套完整的 Agent 可寻址基础设施。后面我会逐个展开讲实现细节。
3. 核心实现拆解:注册、心跳、路由这条链路如何真实工作
3.1 注册中心的数据模型与存储
Agent-Reach 的注册数据我采用了"内存为主、后端持久化兜底"的方式。内存中直接放了一组并发安全的 Map,key 是 agent_id,value 是 Endpoint 对象;另外维护了一个capability -> map[agent_id]Endpoint的二级索引。这样查询某个能力下有哪些 Agent 时,几乎是 O(1) 的复杂度。
持久化方面,我默认用了 SQLite,字段就三个核心表:agents(端点基础信息)、capabilities(能力标签表)、heartbeats(心跳记录)。
CREATE TABLE agents ( agent_id TEXT PRIMARY KEY, host TEXT NOT NULL, port INTEGER NOT NULL, protocol TEXT NOT NULL DEFAULT 'jsonrpc', weight INTEGER DEFAULT 100, status TEXT NOT NULL DEFAULT 'active', metadata TEXT, updated_at INTEGER ); CREATE TABLE capabilities ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, capability TEXT NOT NULL, version TEXT, FOREIGN KEY(agent_id) REFERENCES agents(agent_id) ); CREATE TABLE heartbeats ( agent_id TEXT PRIMARY KEY, last_seen INTEGER NOT NULL, lease_time INTEGER NOT NULL );你可能会问:为什么不直接用 Redis?我的答案是——Agent 注册数据根本没多大,几十上百个 Agent,SQLite 完全扛得住,而且部署时少依赖一个中间件,对很多小团队来说更友好。如果你已经有 Redis,我也提供了 Redis 后端实现,把存储接口切一下就行。
3.2 心跳与"三态健康机"
心跳这块是 Agent-Reach 和普通健康检查差异最大的地方。普通注册中心只有"活着/死了"二态,但 Agent 的状态远不止这么简单。一个 Agent 可能进程活着,但正在处理一个长任务,此时你给它派新任务,它要么拒绝要么排队,与其这样不如让路由层提前知道。
所以我在 Agent 侧引入了一个"三态健康机":
- idle(空闲):可立即接受新任务
- busy(忙碌):正在处理任务,但还能接收少量低优先级请求
- draining(排空):正在优雅退出前,不再接收新请求
Agent 每次心跳除了报存活,还会带上当前状态和负载指标。
POST /reach/heartbeat { "agent_id": "agent-search-01", "state": "idle", "load": 0.3, "active_tasks": 2 }Reach 收到心跳后会更新内存中端点的状态,同时同步更新持久化记录。如果连续 3 个心跳周期都没收到心跳,就会把状态标记为offline,路由时直接跳过。这个阈值我写成了配置项,默认heartbeat_interval=5s,expiration_multiplier=3。
提示:不要把心跳间隔设得太短,我试过 1 秒一次,本地没问题,一到公网环境就疯狂误报。5 秒是一个比较稳妥的默认值,既能保证感知及时性,也不会产生太多无用请求。
3.3 路由策略:不是随机选,而是"按需择优"
路由算法是最值得细说的地方。默认情况下,当调用方请求某个 capability 时,Reach 会做以下几步:
- 按能力标签
capability找到候选端点集合 - 过滤掉状态为
offline和draining的端点 - 过滤掉不满足协议要求的端点(比如请求方是 HTTP,就不选纯 gRPC 的)
- 从剩余集合中按当前路由策略打分
默认的HybridPolicy打分公式我写得很简单:
score = weight * idle_bonus * latency_factor其中idle_bonus在空闲时为 1.0,忙碌时降为 0.6,排空时直接不参与;latency_factor是最近 30 秒的平均调用耗时的归一化倒数,用来把那些响应慢的 Agent 权重降下来。
这个打分并不完美,但在我的实测场景里效果很好。有一次我故意给其中一个 Agent 节点加了两倍的人工模拟延迟,默认策略很快就自动把流量切到另一个健康节点上,整个过程不需要人工干预。
除了默认策略,我还实现了两个内置策略:StickyPolicy(会话粘滞,调用方带 session_id,同一会话始终路由到同一 Agent,适合有状态对话场景)和CapabilityPriorityPolicy(按能力版本优先,比如优先选择支持最新工具协议的 Agent)。如果你的场景有特殊需求,继承RoutingPolicy接口实现score()方法即可。
3.4 消息协议与超时控制
Agent-Reach 没有自造一套新的序列化协议,而是把传输层做成了可插拔设计。默认支持 JSON-RPC 2.0 和 HTTP REST 两种格式。JSON-RPC 更适合 Agent 之间结构化调用,REST 则方便外部系统接入。
转发流程是这样的:
Caller Agent | | sendRequest(capability, payload, timeout) v Agent-Reach Router | 1. 查询 capability 候选 | 2. 执行路由策略,选定 Target Agent | 3. 建立到 Target Agent 的连接请求 | 4. 等待响应或超时 v Target Agent在超时控制上,我借鉴了 HTTP 的 Header 机制,每次调用都会附带x-reach-timeout字段。值得注意的是,这个超时包含三段时间:Reach 内部寻址耗时 + 转发请求耗时 + 目标 Agent 处理耗时。如果只设一个总超时,前面寻址花太多时间会导致目标 Agent 的处理时间被压缩。所以我把超时拆成了两段:
reach: lookup_timeout: 200ms # 寻址和路由计算 call_timeout: 30000ms # 转发到 Agent 的总调用时间这个拆分我是在一次线上事故后才加进去的。当时一个 Agent 注册列表因为网络问题更新很慢,路由寻址就卡了 2 秒,请求总超时只有 3 秒,目标 Agent 实际上只拿到了 1 秒的处理时间,很多任务直接被误判为超时。后来把两段超时分开,配合一个简单的"寻址缓存",问题就消失了。
4. 实操部署:5 分钟从零跑通一个 Agent-Reach 集群
4.1 环境准备与安装
Agent-Reach 目前提供 Python 和 Go 两套实现,我用的是 Python 版本做示例。它依赖fastapi、uvicorn、sqlite3,安装很简单:
pip install agent-reach如果要运行 Reach 路由节点,一行命令就能启动:
agent-reach serve --config config.yaml --port 9000启动后你会看到路由节点的 HTTP 服务监听在 9000 端口。默认的管理端点在/reach/status可以查看当前注册的 Agent 列表。
4.2 写一个 Agent 注册示例
下面这段代码是让一个"搜索 Agent"注册到 Reach 上的最小示例。我用的是官方 Python SDK:
from agent_reach import Agent, run_agent agent = Agent( agent_id="agent-search-01", capabilities=["search", "web_extract"], protocol="jsonrpc" ) @agent.on_request("search") def handle_search(query: str, top_k: int = 5): # 这里做真实的搜索逻辑 return {"results": [], "query": query, "top_k": top_k} # 启动 Agent,会自动完成注册和心跳 run_agent(agent, reach_url="http://localhost:9000")注意看,我注册了两个 capability:search和web_extract。这样做的好处是复用同一个 Agent 进程提供不同能力,路由层按能力匹配即可。另一个 Agent 想调用搜索能力时,不需要知道这个搜索 Agent 的地址,只需要发起如下请求:
from agent_reach import Client client = Client(reach_url="http://localhost:9000") resp = client.call( capability="search", payload={"query": "Agent-Reach 是什么", "top_k": 5}, timeout=10.0 ) print(resp)整个调用过程,客户端只接触 Reach 节点,不接触真正干活的 Agent。这个模式还有一个额外好处:Reach 节点可以做统一的日志记录、审计和限流,对后续观测整个系统的行为非常有帮助。
4.3 配置路由策略与健康检查参数
实际部署时,我会推荐一个稍微完整一点的配置:
server: port: 9000 workers: 4 registry: backend: sqlite sqlite_path: ./reach.db heartbeat: interval: 5s expiration_multiplier: 3 cleanup_interval: 30s routing: policy: hybrid hybrid: idle_bonus: 1.0 busy_bonus: 0.6 latency_weight: 0.4 sticky: session_ttl: 300s proxy: lookup_timeout: 200ms call_timeout: 30000ms max_retries: 1这里的max_retries我通常设置为 1,也就是最多在另一个候选端点上重试一次。不要设置太高,因为如果你本身的目标 Agent 是"执行写操作"或"触发外部 API",重试可能导致重复执行。幂等性不好的 Agent 一定不要开自动重试。
4.4 验证:看日志、看指标、跑一次完整调用
启动 Reach 节点后再启动两个不同能力的 Agent,你会看到类似这样的日志:
[Reach] Agent agent-search-01 registered: capabilities=search,web_extract, state=idle [Reach] Agent agent-analyze-01 registered: capabilities=summary,report_generate, state=busy [Reach] Heartbeat received: agent-search-01, load=0.3然后模拟一次调用,Reach 会输出路由决策日志:
[Reach] Routing request(capability=search) -> agent-search-01, policy=hybrid, score=0.85 [Reach] Target agent-search-01 responded in 120ms, status=ok如果你想看更细粒度的指标,可以打开 Prometheus 采集端点/reach/metrics,里面会暴露注册 Agent 总数、心跳超时次数、路由决策分布、调用耗时直方图。这些都是排查问题时最有用的第一手资料。
5. 常见问题与排查实录
5.1 心跳丢失导致 Agent 被误下线
这是最频繁的问题,尤其是把 Agent 部署在宿主机之外、经过 NAT 或防火墙时。表现为:Reach 端收到的心跳偶尔断一下,Agent 被标记 offline,然后系统下一轮调用全部失败,过一会 Agent 恢复又自动注册回来。
后来排查发现,真正原因不是心跳代码有问题,而是 Agent 所在环境的网络对短连接不友好,TCP 连接被中间设备静默回收。解决办法有两个:一是把心跳机制改成"同一个连接上复用",也就是 Agent 与 Reach 之间建立一个长连接,定时走这个连接发心跳;二是加大expiration_multiplier,比如从 3 调到 6,允许短暂的网络抖动。
顺带一提,我还遇到过极其诡异的现象:两个 Agent 在同一台机器上,一个心跳正常,另一个每隔几分钟就掉线。最后发现是这个 Agent 注册时用了 IPv6 地址,而 Reach 节点只监听了 IPv4。这个问题在容器化环境里尤其容易踩,所以 Agent 启动时我会强制检查注册地址的类型。
5.2 路由选择不均衡
默认的 HybridPolicy 理论上能保证按权重均衡调度,但实际运行中我发现一个隐蔽问题:如果某个 Agent 的响应速度极快,latency_factor会持续偏高,导致它的分数一直领先,流量几乎全部涌到它身上,其他 Agent 成了摆设。
调整方式是给latency_weight设定一个上限,或者干脆关掉延迟因子,只依赖权重和空闲状态。我在实际生产配置里是把latency_weight从默认的 0.4 降到了 0.2,并采用"每 10 秒全局重新归一化权重"来避免某个节点被持续压榨。
5.3 Agent 能力标签冲突
能力标签是自由字符串,意味着写的时候很随意。我见过有人把同一个能力写成search,另一处写成Search,结果路由找不到候选,报出No reachable agent for capability: Search。这个问题看起来很蠢,但多人协作时极难避免。
强烈建议建一个capabilities.yaml统一维护能力目录,Agent 注册时要么从文件读取,要么在 CI 阶段做校验。另外在 Reach 端,我会把 capability 的匹配规则默认设置成大小写不敏感 + 忽略首尾空格,算是从框架层面降低这类事故的发生概率。
5.4 超时与重试的陷阱
前面我在超时拆分那里讲过一坑,这里再补充一个:如果目标 Agent 是 LLM 接口,它的响应时间波动非常大,有时 2 秒,有时 15 秒。你如果设置了一个 10 秒的固定调用超时,在高峰期几乎必然失败。但如果你设置的是 30 秒,又会拖慢整个链路的响应速度。
我的方案是引入自适应超时:每次调用完成后,Reach 记录本次耗时,并把该 Agent 的动态超时值更新为最近 10 次调用耗时的 P95 加上 20% 余量。这样既能接受正常波动,又能自动避开高延迟时的无限等待。初始值配置为 10 秒,运行几分钟后就完全自动了。
6. 个人心得:Agent 协作的基础设施,越早搭越省心
这次把 Agent-Reach 从零做出来的过程,让我重新想明白了一件事:很多人把 Agent 系统的成败寄托在模型能力上,但在实际生产环境里,让多个 Agent 稳定协作的"连接层"才是真正的瓶颈。模型推理再强,找不到对的人、消息传不过去、状态管理混乱,整个系统照样跑不起来。
如果你也在做类似的多 Agent 编排,我建议不要一开始就上特别重的框架,先把"注册、发现、心跳、路由"这四个最基本的机制搭好。Agent-Reach 的价值在于它把这四个机制收敛成一个简单的接口:Agent 只要注册,客户端只要按能力调用,剩下的复杂逻辑全部由框架托管。实测下来,即使只有三五个 Agent,这套机制也能让系统的稳定性和可观测性提升一个档次。
最后再分享一个小技巧:Agent-Reach 的协议层是插件化的,如果你后续需要接入 WebSocket 或 MQTT 这类异步传输协议,只需要实现一个ChannelAdapter接口,注册到框架里,所有上层逻辑都不用动。这个扩展点是我一开始就预留的,现在团队里已经有人用它把事件驱动的 Agent 接进来了,效果比预想中好很多。