最近几个月我一直在折腾一个项目,代号Agent-Reach。起因特别简单——我手上有几个由不同框架搭出来的 AI Agent,各自都能跑,但彼此根本不“认识”:Agent A 需要查资料时,不知道 Agent B 能帮忙;Agent B 处理完数据后,想通知 Agent C,也只能靠我自己写胶水代码。这种状态做 Demo 还行,一旦进入真实业务,你会发现大量时间都浪费在“让 Agent 之间怎么找到对方”这件事上。Agent-Reach 就是为了把这些孤岛连起来而生的一套轻量级 Agent 互联互通中间件,通过统一的注册与发现、消息路由、能力适配,让不同框架的 Agent 可以互相看见、互相调用。这篇文章会把完整的架构、核心代码、落坑过程都整理出来,适合正在做多 Agent 系统、或者准备把 Agent 从单机流程升级成协作网络的开发者参考。
我不敢说它是终极方案,但它确实解决了我实际项目里最痛的那几个问题。更重要的是,这套思路不绑定任何特定框架,也不要求你把自己的 Agent 重写一遍——它做的是“连接”和“翻译”的活。
1. 项目背景与核心痛点拆解
1.1 AI Agent 生态的碎片化现状
这两年 Agent 框架层出不穷,LangChain、CrewAI、AutoGen、Dify、Coze,甚至自己手写的 State Machine Agent,每个框架都有自己的消息格式、工具调用约定、运行生命周期。框架本身没有对错,但落到一个真实项目里,问题就来了:你不可能用一个框架解决所有问题。
比如我在一个项目里同时用到了 LangChain 做 RAG 问答,用 AutoGen 做多角色讨论,还自己写了几个轻量 Agent 处理定时任务。每个 Agent 单独跑都很稳定,可一旦需要它们协作,比如“从知识库检索资料 -> 让写作 Agent 生成报告 -> 再让翻译 Agent 转成英文版”,整个链路就得靠我手动传递数据。最开始我用的是脚本硬编码,把上一个 Agent 的输出保存成 JSON 文件,下一个 Agent 读文件。麻烦不说,Agent 一多,依赖关系很快就乱成一团。
这个痛点在业内其实很普遍。很多团队做了十几个 Agent,但实际是“十三个独立的小系统”,根本没有形成“网络效应”。单个 Agent 的能力再强,也只能在它自己的数据孤岛里打转,价值大打折扣。
1.2 Agent-Reach 究竟要解决什么问题
我定义这个项目时给自己提了三个问题:
第一个,发现:Agent A 怎么知道系统中存在 Agent B?Agent B 又能做什么事?这需要一套“能力注册与发现机制”,类似服务注册中心,但注册的不是 IP 和端口,而是 Agent 的“能力描述”。
第二个,沟通:Agent A 和 Agent B 用不用“同一种语言”?现实情况是它们大概率不用同一种语言。所以 Agent-Reach 需要做“协议转换”,让各方用自己的母语说话,由 Reach 负责翻译和路由。
第三个,信任:Agent A 凭什么让你把任务转给 Agent B?这就需要身份标识、权限控制、调用审计。连接越方便,安全问题就越不能忽略,否则 Agent 之间乱调用,出事的风险比单机时代大得多。
Agent-Reach 本质上就是在解决这三个问题,它的定位不是另一个 Agent 框架,而是一个Agent 互联互通网关层,处于 Agent 和 Agent 之间,负责撮合、转发、翻译、审计。
1.3 常见方案的对比与选型思路
在动手之前,我调研过当时已有的方案。
一个是 MCP(Model Context Protocol),它的核心是解决“应用怎么调用外部工具”的问题,从 Model 到 Tool 方向,是一套很标准的工具调用协议。但 MCP 本身不解决“Agent 之间互相发现和动态路由”的问题,它更多是单向上下的调用关系。
另一个是新出的 A2A(Agent2Agent)协议,方向完全正确,就是冲着 Agent 互联去的。但它规范还在快速迭代阶段,协议细节和 SDK 都不算稳定,直接用于生产环境有点冒险。
我还考虑过直接用消息队列做中转,比如用 Redis Stream 或 RabbitMQ。消息队列能解决通信问题,但“能力发现”和“动态路由”依然得自己实现,还得额外处理协议层的东西。
综合评估下来,我的选择是:自研一个轻量协议,核心借鉴 JSON-RPC 2.0,同时兼容 MCP 的工具调用格式。这样既能满足 Agent 互联的需求,又能蹭上 MCP 生态里已有的工具调用习惯,老 Agent 接入成本很低。
2. 整体架构设计与关键决策
2.1 第一版架构:Registry + Router + Adapter
Agent-Reach 的架构用一个公式就能说清楚:Agent-Reach = Registry + Router + Adapter。
Registry 是注册中心,负责记录当前系统里所有 Agent 的身份、能力、地址、状态。Router 是路由层,根据调用方给出的“能力需求”或者“明确的 Agent 名字”,找到最合适的接收方,然后把请求转发过去。Adapter 是适配器,负责把不同 Agent 的接口形态包装成统一的协议格式。
这三个部分我设计成了两个进程:一个 Agent-Reach Server(中心服务),负责注册、路由、认证、审计;还有一个 agent-reach-client SDK,嵌入到各个 Agent 进程内,负责注册上报、接收请求、返回响应。这样设计的好处是:中心服务只做“转发”和“管理”,不直接参与 Agent 的业务逻辑,性能瓶颈不会出现在最前端。
第一版我没搞微服务拆分的花活。所有模块放在同一个服务里,数据用 Redis 存,因为注册信息有天然的时效性——Agent 会上下线,心跳过期就要摘除。Redis 的 TTL 机制正好能解决这个问题。
2.2 协议层选型:为什么选择 JSON-RPC 与 MCP 兼容层
Agent 之间通信需要协议,我花了挺长时间纠结这个。直接用 REST 行不行?行,但难受。REST 的语义偏向“资源操作”,而 Agent 之间的调用本质是“请求执行一个任务”,任务可能有返回结果,也可能是异步执行、稍后回调。用 REST 来表达这层语义需要额外约定很多东西:任务状态查询、结果回传、幂等控制、超时取消。这些做多了,REST 就会变成“披着 REST 外衣的 RPC”。
所以第一版定了 JSON-RPC 2.0。它有request、response、notification三种消息形态,天然支持请求-响应模式和异步通知模式;有id字段做消息关联,有error字段统一错误结构,轻量且足够用。
同时我增加了一层 MCP compatibility:如果一个 Agent 的某个能力本质上就是调用一个 MCP 工具,那注册的时候可以直接声明type: mcp,并附上 MCP 工具声明。Agent-Reach 收到转发请求后再按 MCP 协议去调远端工具,这样等于把 MCP 生态的工具也接入了 Agent 协作网络。
2.3 通信链路与消息模型:同步调用 vs 异步任务
设计消息模型时,我特意区分了两种模式:同步调用和异步任务。
同步调用适合“马上要有结果”的短任务,比如查天气、算数学、检索资料。调用方发出请求后阻塞等待响应,超时时间一般在 10 到 30 秒。
异步任务适合“执行时间很长”的流程,比如生成一份完整报告、批量处理一批数据。调用方发出请求后立刻拿到一个task_id,之后可以通过轮询或者回调方式拿结果。Agent-Reach 在这里做了一个很关键的设计:把异步任务的执行状态统一管理起来,不依赖某个 Agent 自己的存储,这样即使接收方 Agent 重启了,任务状态也可以恢复。
消息模型的统一非常关键。不管是同步还是异步,消息头都包含以下几个字段:trace_id(全链路追踪)、from_agent、to_agent或target_capability、expire_at。这套消息头在后面排查问题的时候帮了我大忙——哪个环节慢、哪个任务断了,trace_id 一查就清楚。
3. 核心模块实现与实操细节
3.1 服务注册与发现模块
Agent-Reach 的注册模块核心数据结构长这样。
一个 Agent 启动时,会把以下信息上报到服务端:
agent_id:全局唯一的 Agent 标识,我用UUID4生成。agent_name:人类可读的名称,比如customer-service-agent。endpoint:这个 Agent 接收回调消息的地址,可以是 HTTP 地址,也可以是 WebSocket,甚至是本地函数回调。capabilities:能力列表,每一项都包括能力名、描述、输入输出 JSON Schema。ttl:心跳时长,默认 30 秒。
服务端拿到注册信息后会写入 Redis,key 是agent:{agent_id},value 是 JSON 序列化的 Agent 元信息。同时给每个 Agent 维护一个agent:{agent_id}:capabilities的 Set,存放它提供的能力名列表,方便按能力检索。
关键就在“心跳”这里。Redis 的EXPIRE机制天然适合做健康检查:Agent 每次心跳就将 TTL 重置,如果服务端在 TTL 时间内没收到心跳,这个 Agent 的 key 就会自动消失,路由时就不会再把它作为候选对象。第一版我用redis.setex实现,很简单稳定。
3.2 能力描述与技能路由
能力描述是整个路由的基石。我参考了 Function Calling 里流行的 JSON Schema 方式,每个能力描述包含四部分:能力名、一句话说明、输入参数 Schema、输出结果 Schema。
例如一个“知识库检索”能力:
{ "name": "knowledge_search", "description": "在企业知识库中检索与关键词相关的文档片段", "input_schema": { "type": "object", "properties": { "query": { "type": "string", "description": "检索关键词或自然语言问题" }, "top_k": { "type": "integer", "description": "返回的文档片段数量,默认 5", "default": 5 } }, "required": ["query"] }, "output_schema": { "type": "array", "items": { "type": "object", "properties": { "content": { "type": "string" }, "score": { "type": "number" }, "source": { "type": "string" } } } } }路由模块的核心逻辑,是先把调用方的请求意图映射到一个能力名上。这个过程我做了两层:第一层是精确匹配,如果调用方明确写了target_capability=knowledge_search,就直接找到对应 Agent;第二层是语义匹配,如果调用方只写了一段自然语言描述,比如“帮我查一下公司今年的营收数据”,系统会先用嵌入模型把描述和所有 Agent 的 capability description 做向量相似度比对,返回候选 Agent 列表,再由调用方选择一个最合适的。
这个语义匹配是我做得比较值的一项功能,虽然它增加了一点复杂度,但它让 Agent 之间的协作从“我自己知道找谁”升级成了“我只需要说我要什么”。
3.3 身份认证与权限控制
Agent 之间调用必须有边界。我不能让一个只应该查天气的 Agent 去调一个能删数据库的 Agent,这太危险了。
第一版的认证方式采用 API Key 机制,每个 Agent 注册成功后会拿到一个client_id和一个client_secret。后续所有 RPC 请求都要在 Header 里带上这两个字段,由服务端做验证。为了防止密钥在网络传输中被截获,所有通信我都要求走 TLS。
权限控制我用了 ACL 白名单。每个 Agent 的注册信息里可以声明一个allowed_caller_ids字段,只有白名单里的 Agent 能调用它的能力。如果这个字段为空,表示默认拒绝所有调用,需要显式配置allow_all: true才会开放给所有 Agent。
这套机制在第一版已经够用。生产环境如果有更强的安全需求,可以后续扩展 mTLS 双向认证或者基于 OAuth 的授权流程,我留了接口。
3.4 Agent 适配器与消息转换
这是整个项目里最“接地气”的部分。不同 Agent 的接入方式千奇百怪,有的是 HTTP 接口,有的走 WebSocket,有的是本地 Python 函数,还有的是 LangGraph 里的节点。
Adapter 的作用就是把这些差异屏蔽掉。我定义了一个统一的 AgentBackend 抽象,每个后端只需要实现两个方法:handle(request)和health_check()。
class AgentBackend(ABC): @abstractmethod async def handle(self, request: dict) -> dict: """处理一次 RPC 请求,返回统一响应格式""" raise NotImplementedError @abstractmethod async def health_check(self) -> bool: """健康检查,由注册中心周期性调用""" raise NotImplementedErrorHTTP 后端就负责把请求体解析成内部字典、调用远端接口、再解析响应;WebSocket 后端就维护一个连接池,通过连接发送和接收消息;本地方函数后端最简单,直接通过函数名和参数调用。
实际做的时候我发现,适配器最大的坑不在“怎么调”,而在“错误怎么映射”。HTTP 接口返回 500,到底对应 RPC 的哪种错误?超时了是返回错误还是重试?这些细节得在 Adapter 层统一处理好,否则每个 Agent 的错误五花八门,调用方根本没法做异常处理。
4. 环境搭建与关键代码实现
4.1 技术栈与安装
Agent-Reach 服务端用了 Python 3.11 + FastAPI + Redis,SDK 也是 Python 包。选 Python 的原因很直接:现有 Agent 生态大部分是 Python,接入门槛低,改造成本小。FastAPI 自带接口文档,调试和对接都非常舒服。
依赖安装就三行命令:
pip install fastapi uvicorn redis httpx pydanticRedis 我直接用了本机 Docker 跑,避免污染系统环境:
docker run -d --name agent-reach-redis -p 6379:6379 redis:7-alpine4.2 注册中心的数据结构与核心代码
注册中心是整个项目的心脏。我先把 Agent 的注册模型写好:
from pydantic import BaseModel, Field from typing import List, Optional class CapabilitySchema(BaseModel): name: str description: str input_schema: dict output_schema: Optional[dict] = None class AgentRegistration(BaseModel): agent_id: str agent_name: str endpoint: str endpoint_type: str = Field(default="http", description="http/websocket/local") capabilities: List[CapabilitySchema] allowed_caller_ids: List[str] = [] allow_all: bool = False status: str = "online"注册接口的路由处理函数长这样:
from fastapi import FastAPI, HTTPException from redis import asyncio as aioredis import json app = FastAPI() redis = aioredis.from_url("redis://localhost:6379/0") @app.post("/v1/agents/register") async def register_agent(reg: AgentRegistration): key = f"agent:{reg.agent_id}" reg.status = "online" # 注册信息写入 Redis,TTL 30 秒 await redis.setex(key, 30, reg.model_dump_json()) # 为每个能力建立倒排索引 for cap in reg.capabilities: await redis.sadd(f"capability:{cap.name}", reg.agent_id) # 记录 Agent 与能力名集合的映射 cap_names = [cap.name for cap in reg.capabilities] await redis.delete(f"agent:{reg.agent_id}:caps") if cap_names: await redis.sadd(f"agent:{reg.agent_id}:caps", *cap_names) return {"status": "ok", "agent_id": reg.agent_id}心跳接口更简单,就是续期:
@app.post("/v1/agents/heartbeat") async def heartbeat(agent_id: str): key = f"agent:{agent_id}" if not await redis.exists(key): raise HTTPException(status_code=404, detail="Agent not registered") await redis.expire(key, 30) return {"status": "ok"}注意这里的setex和expire,它们是整个注册发现机制能稳定工作的基石。每个 Agent 必须每 15 秒左右打一次心跳,留足余量,避免网络抖动就掉线。
4.3 消息路由的代码实现
路由模块的职责是:收到 RPC 请求后,决定把请求转给哪个 Agent。这里给出最核心的路由匹配逻辑。
@app.post("/v1/rpc/call") async def rpc_call(req: RpcRequest): # 1. 先查调用方是否注册 if not await redis.exists(f"agent:{req.from_agent}"): raise HTTPException(status_code=401, detail="Caller not registered") # 2. 确定目标 Agent if req.to_agent: target_agent = req.to_agent if not await redis.exists(f"agent:{target_agent}"): raise HTTPException(status_code=404, detail="Target agent not found") elif req.target_capability: agent_ids = await redis.smembers(f"capability:{req.target_capability}") if not agent_ids: raise HTTPException(status_code=404, detail="No agent provides this capability") target_agent = await route_to_most_suitable(agent_ids, req) else: raise HTTPException(status_code=400, detail="No routing target specified") # 3. 检查权限 target_info = json.loads(await redis.get(f"agent:{target_agent}")) if not target_info["allow_all"] and req.from_agent not in target_info["allowed_caller_ids"]: raise HTTPException(status_code=403, detail="Permission denied") # 4. 转发请求(伪代码,实际走 AgentBackend) response = await dispatch_request(target_agent, req) return responseroute_to_most_suitable在这里是最有意思的函数。当多个 Agent 提供同一个能力时,我用了“加载评分”策略:每个 Agent 上报心跳时,顺带上一个当前 pending 任务数,路由时选择 pending 数最小的那个。这个策略实现简单效果却很好,相当于一个轻量负载均衡。后来我加了基于响应时间的 EWMA 指数加权移动平均,选路就更有谱了。
4.4 对接演示:让两个异构 Agent 互相调用
理论讲再多,不如看一次实际对接。我拿“翻译 Agent”和“工单 Agent”做例子,翻译 Agent 用 FastAPI 自研,工单 Agent 用 LangGraph 里的节点封装成 HTTP 服务。
第一步,翻译 Agent 启动后注册到 Reach:
from agent_reach import AgentReachClient client = AgentReachClient( server_url="http://localhost:8000", agent_id="translator-agent", agent_name="翻译助手" ) client.register( endpoint="http://translator:9001/translate", capabilities=[ { "name": "translate_text", "description": "将文本翻译成指定目标语言", "input_schema": { "type": "object", "properties": { "text": {"type": "string"}, "target_lang": {"type": "string"} }, "required": ["text", "target_lang"] } } ] )第二步,工单 Agent 在处理用户请求时,需要把一段回复翻译成英文。它自己并不“认识”翻译 Agent,只知道“需要一个翻译能力”,于是向 Reach 发起调用:
response = client.call_capability( capability="translate_text", params={ "text": "您的工单已经处理完成,感谢您的耐心等待。", "target_lang": "en" }, timeout=10 )整个过程工单 Agent 没有直接拼接翻译 Agent 的 URL,也没关心翻译 Agent 是用什么框架写的。它只需要告诉 Reach“我要什么”,Reach 去查注册表、过权限、路由、转发,然后把结果拿回来。这就是 Agent-Reach 的核心价值:调用方与实现方解耦。
这个演示跑通后,我确认了整套设计是成立的。后面再接入新 Agent,流程就完全机械化了:写一个注册文件 -> 写一段业务代码 -> 启动后自动加入协作网络。
5. 生产环境避坑与问题排查实录
5.1 常见问题速查表
我整理了一张问题排查表,都是我在迭代过程中真遇到过的,直接列出便于对照解决。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 路由时报“Agent not found” | 心跳中断,Redis key 过期 | 检查 Agent 心跳任务是否正常运行,看 RedisTTL剩余时间 |
| 调用方刚注册就被拒绝 | ACL 白名单未配置 | 检查目标 Agent 的allowed_caller_ids,确认调用方在名单内 |
| 请求超时但目标 Agent 明明处理完了 | 响应体过大,或链路有缓冲导致延迟 | 用trace_id排查每一跳耗时,必要时增大内部接口超时 |
| Agent 频繁上下线 | 心跳间隔与 TTL 设置不合理 | 心跳间隔设置为 TTL 的 1/2,避免网络抖动导致误判 |
| 语义路由选错 Agent | 能力描述写得太模糊 | 重写 capability description,用具体业务词汇,避免通用大词 |
| 异步任务丢失 | Agent 重启后任务状态丢失 | 中心维护任务状态表,Agent 重连后自动拉取未完成任务 |
5.2 三个让人印象深刻的坑
第一个坑,是 Redis 大 key 问题。第一个月跑着没事,后来某个 Agent 挂了十几个小时没上报,它的 capability 集合里存了太多历史 Agent ID。每次路由要遍历整个集合做过滤,Redis 响应慢到几十毫秒,整个路由链路被拖垮。解决方法是给集合键加上业务前缀,并且定期清理离线 Agent 的残留索引。这个坑让我明白了:注册索引和业务数据一样,不做生命周期管理就一定会出问题。
第二个坑,是语义路由的“幻觉”。有次一个 Agent 想调“生成回复”能力,语义匹配居然选到了“知识库检索”Agent,因为两个能力的描述都含有“用户问题”这个词。从那以后,我引入了路由置信度机制:向量相似度低于阈值的请求,一律不自动路由,返回候选列表让调用方自己选。机器觉得“应该对”和业务上“确实对”之间,永远要留一个人工确认的缓冲带。
第三个坑,是超时和重试的幂等性。早期有些 Agent 收到请求后处理超时,调用方立刻重发,导致同一个翻译任务被处理了两次,客户收到重复邮件。后来我在消息模型里强制加上了idempotency_key,由调用方生成,服务端和接收方都做去重。这个字段是全局唯一的,处理完后存入缓存,重复请求直接返回第一次的结果。任何涉及外部调用的系统,幂等性都是跑不掉的课题。
5.3 性能与稳定性调优建议
Agent-Reach 单机版用 FastAPI + Redis 能扛住每秒几百次的 RPC 转发,单机不够时再考虑加一层 Nginx 负载均衡和 Redis 集群。
性能优化我做了三件事。第一,把路由时用的 Agent 元信息加了一层本地缓存,Redis 的 key 过期回调只能保证最终一致性,但本地缓存的命中率能到 95% 以上,实际延迟从 5 毫秒降到了 1 毫秒以内。第二,把日志从同步改成异步批量写入,避免高并发下日志拖累接口响应。第三,把转发请求的超时时间设置成分层管理:网络超时 3 秒、调用方业务超时 15 秒、整个链路总超时 30 秒。这样既不会让调用方无谓等待,又给了长任务足够的执行空间。
稳定性方面,最重要的是给了 Server 一个优雅停机机制:收到 SIGTERM 信号后,先摘掉注册列表里的 Agent 标记(将 status 置为 offline),再等待当前 in-flight 请求处理完毕,最后退出进程。这个过程能避免服务升级期间“请求发到半开却没人接”的尴尬。
6. 后续扩展方向与个人体会
Agent-Reach 目前的状态,对我来说已经是很顺手的基础设施了。不过我脑子里还有一些明确想继续做的方向,这里也列出来给想做类似事情的朋友参考。
第一个方向,是支持联邦注册。多个团队可以各自部署一套 Agent-Reach,然后通过联邦机制互相注册“上游 Agent”,这样不同团队甚至不同公司的 Agent 也能安全地互相调用,有点像 Agent 界的“域名解析”。这个方向的价值在于让 Agent 协作从小规模内部试点走向生态级别。
第二个方向,是加强任务编排能力。现在 Agent-Reach 只负责路由和转发,不编排任务。但实际业务里,一个请求可能需要串行调用多个 Agent,甚至要根据前一个 Agent 的结果决定下一个调谁。把这种编排逻辑做成可视化配置,是很多非技术用户的需求。
第三个方向,是更深度的 MCP 集成。现在只是协议兼容,后续可以直接做成“MCP Registry 网关”,把任意 MCP Server 自动包装成一个 Agent 能力,让 Agent 调用 MCP 工具像调用一个本地函数一样简单。
最后分享一下我个人的实际操作体会。Agent-Reach 这个项目做下来,我最大的感受是:Agent 互联互通的技术难点,根本不在网络通信,而在于“怎么描述能力”和“怎么建立信任”。能力强描述得不好,路由就不准;权限设置得太严,协作就死掉;设定得太松,出事又没人担责。这两件事,值得花 80% 的时间去设计和打磨。
另外一个很实用的小技巧:给每个 Agent 的能力描述开头加一个“常用触发场景”段落,例如“当用户需要查询天气时”,而不是只写“查询天气”。这样的描述无论对语义匹配还是对人工维护,都友好得多。这个细节是我在一次选错路由的故障中总结出来的,现在每次写 capability 都会用到。
Agent-Reach 不会取代任何 Agent 框架,它只是让不同的 Agent 有个共同的“社交广场”。如果你手上的 Agent 也开始多到你记不清谁有什么能力,不妨也搭一个这样的中间层。连接本身不会创造智能,但连接的广度,决定了 Agent 能力的边界。