Agent-Reach:轻量互操作协议,拆掉Agent之间的孤岛墙
如果你手里同时维护着好几个 Agent 服务大概率也遇到过这种别扭局面每个智能体都能把自己的工具链调得很好但想跨系统协作就得手写一堆胶水代码。我写 Agent-Reach 就是想解决这个“Agent 孤岛”问题——它是一层轻量互操作协议让不同运行时、不同团队维护的 Agent 可以通过统一方式彼此注册、发现和调用能力。简单说它给 Agent 之间打通了一条“标准电话线”而不是各自拉一根专线。这个项目很适合正在自建 Agent 平台、或者想把多个模型服务和业务系统串成一条完整链路的开发者参考它不是要替代业务系统而是让系统之间多一种可靠的对话方式。1. 为什么我会写一个叫 Agent-Reach 的东西1.1 先说说我碰到的 Agent 孤岛问题之前我带的一个项目里同时跑着三类 Agent一类负责客服问答内部接了订单查询和 CRM 接口一类做内容生成维护了十几套 Prompt 模板和写作工具还有一类是内部运维助手能调监控平台和发布系统。每个 Agent 单独拿出来都挺好用一旦要联动就头疼。客服 Agent 需要让内容 Agent 帮忙写一篇活动通告却没有直接途径调用对方能力运维 Agent 查完监控发现应用响应慢了想把结论自动转给客服 Agent 去安抚用户还得靠中间人去数据库里同步。代码里最早全是硬编码的 HTTP 调用每接一个 Agent 就要写一套独立的鉴权、超时、错误处理逻辑最后变成了蜘蛛网。当时最直接的感受是工具调用本身并不难难的是让双方按同一套“规矩”说话。Agent 之间的协作本质上不是接口对接而是能力语义的对接。A 怎么描述自己有什么能力B 怎么知道该找谁、用哪个能力、传什么参数最后能拿到什么结构、什么含义的结果——这才是真正需要标准化的东西。1.2 为什么不直接用现成的方案开始我也想过直接搬一套成熟标准比如业界常见的 Agent 工具协议。后来实际调研发现这些方案都有自己的适用场景。大型模型平台配套的协议通常和自家生态绑定较深接入门槛不低而且重量级依赖一拉就是好几个而我自己要处理的场景并不需要把大模型推理过程暴露给外部只需要在一个受控的集群里让已知的 Agent 服务互调工具能力。更重要的是我手里的服务有不少是旧系统改出来的运行方式五花八门——有 FastAPI 写的有 Node.js 起的甚至还有两个是命令行工具套了个常驻进程。我需要的是一个轻、薄、能嵌入式接入的协议层而不是要推倒重来。Agent-Reach 的定位就是薄薄的一层它不做 Agent 的额外部件不强行绑定编程语言也不要求接入方重跑模型。它只负责三件事把能力描述登记到注册表把调用请求按语义路由到正确的 Agent把结果和错误按统一格式返回。剩下的业务逻辑完全留在各自的 Agent 内部。这个取舍换来的是接入成本非常低旧的 HTTP 服务包一层就能进来。1.3 名字里“Reach”的含义项目取名 Agent-Reach核心词是 Reach我理解的意思是“够得着”。单个 Agent 的能力通常被限制在自己的进程和环境里要让它的工具触角伸到另一个系统、另一个平台的内部服务上去就需要一个能跨越边界的机制。我的目标不是做一个“万能中枢”把所有人的所有工具都集中起来而是让每个 Agent 保持自治的同时把愿意开放的能力暴露到一个共享的目录里。别人来调我的接口我不需要让对方理解我的内部实现只要他能看懂“搜索订单”“生成文案”“查询监控”这些能力描述并且按格式传参就能把事办成。这个“够得着”的感觉是这套设计的灵魂。2. Agent-Reach 的核心设计注册、发现、调用2.1 注册中心能力描述比端点更重要Agent-Reach 的第一个核心组件是注册中心Registry它的职责是维护一张动态的能力表。最开始我以为注册表里最重要的字段是 endpoint地址真正用起来才发现能力描述比端点更重要。原因很简单调用方真正依赖的是“这个 Agent 能干什么”而不是“这个 Agent 在哪儿”。集群里一个 Agent 实例挂了另一个地址顶上调用方的配置并不需要变。所以注册表里的每个条目除了 agent_id 和 endpoint必须带一份 capability 数组每一项至少包含能力名、参数 Schema、可选示例和超时预算。参数 Schema 我用的是 JSON Schema 的子集不搞额外格式这样无论是 Python、JavaScript 还是其他语言的库都能直接解析校验。{% raw %}{ jsonrpc: 2.0, id: 1, method: registry.register, params: { agent_id: content-agent, endpoint: http://content-agent:9102/call, capabilities: [ { name: article.generate, schema: { type: object, properties: { topic: { type: string }, word_count: { type: integer, minimum: 300 } }, required: [topic] }, example: { topic: 智能体协作实践, word_count: 1500 } } ], ttl: 120 } }{% endraw %}这里为什么特意加 example因为光有 Schema调用方还是不知道这个数据应该长成什么样。example 字段提供的是一个“最小可用样例”调用方拿到后可以直接展示成调用卡片或者用于自动生成测试请求能省掉大量联调时的来回确认。注册表自身的去留判断靠租约机制TTL。每个 Agent 定期发心跳续租注册中心发现一个条目超过阈值没有心跳就自动摘除。这个机制看上去简单却是整个系统不产生僵尸节点的关键。我第一次实测时把心跳间隔设成了 60 秒结果节点优雅退出后调用方还能在一个租约周期里命中旧地址白等了一个超时。后来心跳调整到 15 秒TTL 120 秒问题就没再出现过。2.2 调用协议一个容易理解的 JSON 信封Agent 之间传递消息我选用了基于 JSON-RPC 2.0 的风格但做了一些面向场景的小改动。协议层不搞二进制格式、不搞自定义编解码全部用 JSON 文本原因很直白出问题时可以直接抓包看内容调试成本最低。一次完整的调用分三步。第一步调用方向注册中心发起 discover 请求携带要调用的 capability 名注册中心返回当前可用的 agent_id 和 endpoint 列表。第二步调用方向目标 Agent 的 /call 接口发送 agent.call 消息。第三步目标 Agent 处理后返回带同一 id 的 result 或 error。为了支持异步场景协议里还保留了 notify 类型表示不需要返回结果适合日志上报、事件通知这类场景。{% raw %}{ jsonrpc: 2.0, id: call_20250117_001, method: agent.call, params: { target_agent: ops-agent, capability: monitor.query, payload: { metric: response_time, window: 5m } } }{% endraw %}响应体里我一定会带 exec_ms 字段记录目标 Agent 内部执行耗时。这个字段一开始没有后来排查性能问题才发现少了它。有了 exec_ms调用方就能快速区分出“网络花的时间”和“业务执行花的时间”不用再去目标端翻日志。错误处理统一放在 error 结构里code 分配了三组协议错误1000-1999、注册发现错误2000-2999、业务执行错误3000-3999。必须承认一个笨办法的价值再简单的错误码体系也要在文档里配一个“错误码速查表”否则同事在联调时永远会问你 3002 到底是什么意思。2.3 连接器接入新运行时只需实现五个方法Agent-Reach 把接入方需要实现的接口收敛成了一个 Connector 抽象只有五个核心方法start、stop、register、unregister、dispatch。start 负责拉起服务register 把本机能力宣告到注册中心dispatch 接收来自外部的调用请求并把结果写回。我给两个最常用的环境各写了一个参考实现。Python 版基于 FastAPI 做 HTTP 入口Node.js 版基于 Express。实现上的关键点在 dispatch 里不要在主线程上同步执行耗时任务先把请求放队列用线程池或异步任务去执行同时把并发上限卡死。这个设计直接决定了高并发下服务会不会被打挂。{% raw %}class SimpleConnector: def __init__(self, tools: dict): self.tools tools async def dispatch(self, message): capability message[params][capability] payload message[params][payload] tool self.tools.get(capability) if tool is None: return {error: {code: 3101, message: funknown capability: {capability}}} result await tool(payload) return {result: result, exec_ms: 0}{% endraw %}这里有一个很容易被忽略的经验tool 执行函数最好统一返回 dict而不是列表或字符串。因为协议里所有结果都要进 JSON 信封如果业务函数返回字符串你还要在外层包一层。统一约定 dict 之后Connector 到协议层之间就不用做任何转换了。3. 实操从零搭起一个可用的 Agent 互操作链路3.1 快速启动Docker Compose 起来两个 Agent纸上谈兵没什么意思我日常验证方案的习惯是先拉一个能跑的最小拓扑。项目仓库里提供了一个 compose 文件会拉起四个服务一个注册中心、两个示例 Agentalice 和 bob、一个简单的调用模拟器。直接跑起来几分钟之内就能看到两个 Agent 互相调用。{% raw %}docker compose up -d{% endraw %}容器起来之后注册中心会跑在 8600 端口。打开日志观察十秒钟能看到 alice 和 bob 各自上报了能力列表。alice 上报了一个 calculator.add 能力bob 上报了一个 echo.repeat 能力。然后我用 curl 模拟一次从 alice 调到 bob 的过程。先向注册中心查 bob 的能力拿到 endpoint 之后直接 POST 到 bob 的 /call 接口。{% raw %}curl -X POST http://localhost:8600/registry/discover \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:1,method:registry.discover,params:{capability:echo.repeat}} curl -X POST http://localhost:9102/call \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:call_1,method:agent.call,params:{target_agent:bob,capability:echo.repeat,payload:{text:hello,times:3}}}{% endraw %}如果你看到这里直接跑起来了说明链路基本通畅。不过我要额外提醒一句实际项目里不要省掉 discover 这一步。曾经有人图省事把 endpoint 硬写在配置文件里结果后面 bob 实例迁移所有调用方全部开始超时。动态发现这个环节看似多了一次 RTT实际省掉了大量人为维护地址的麻烦。3.2 核心参数与配置说明Agent-Reach 的配置项不多但每一个都值得认真设置。我把常用参数列成了一张表照着填基本就能应付多数场景。参数名默认值作用与建议agent_id无Agent 全局唯一标识建议按“业务模块-实例”的格式命名便于日志检索。registry_urlhttp://127.0.0.1:8600注册中心地址多副本可配逗号分隔列表。heartbeat_interval15 秒心跳周期。不要低于 5 秒避免给注册中心造成无谓压力。ttl120 秒租约有效期。必须大于 3 倍心跳间隔否则小抖动就会导致误摘除。call_timeout10 秒调用方等待响应的超时时间。业务工具本身耗时长的话调到 30 秒以上。max_call_queue256目标 Agent 的并发队列上限超过后直接返回 3004 忙碌错误保护后端。audit_logtrue是否记录全部调用审计日志。生产环境建议保留。token_ttl3600 秒访问令牌有效期配合 API Token 使用。这里值得展开说的是 ttl 和 heartbeat_interval 的倍数关系。第一次部署我把 ttl 设成了 45 秒心跳 20 秒理论上相当于允许丢两次心跳。但实际运行时网络抖动让注册中心连续漏掉了两次心跳alice 被误认为下线路由表短暂“丢”了一个服务直到下一次心跳才恢复。后来我把 ttl 调整到心跳间隔的 8 倍这个问题彻底消失。租约的松弛度和故障发现的及时性天然矛盾你要在两者间找一个适合自己网络环境的平衡点。3.3 一次完整调用的链路与日志我挑一次比较典型的调用过程和大家完整过一遍。场景是alice Agent 接到了一个用户提问问“帮我算一下 15 和 27 的和”alice 自身没有计算能力但它通过 Agent-Reach 发现 bob 有 calculator.add于是把请求转发过去。alice 侧先向注册中心发 discover返回 bob 的 endpoint。随后 alice 把业务参数包进 agent.call 信封POST 给 bob。bob 收到后校验参数执行内部工具返回 result。全程我开了审计日志抽几条关键记录出来看{% raw %}[registry] 09:00:00.123 discover capabilitycalculator.add - agent_idbob endpointhttp://bob:9102/call [alice] 09:00:00.145 call idcall_20250117_089 targetbob capabilitycalculator.add payload{a:15,b:27} [bob] 09:00:00.201 receive idcall_20250117_089 capabilitycalculator.add payload{a:15,b:27} [bob] 09:00:00.204 invoke tool calculator.add success [alice] 09:00:00.236 response idcall_20250117_089 result{sum:42} exec_ms35{% endraw %}执行耗时 35 毫秒其中网络和协议开销约占 30 毫秒真正算数只占了极小一部分。这个数据在预期内Agent-Reach 的设计目标本来就不是为了省那几十毫秒的序列化时间而是为了省掉“接一个系统就要写一套新对接代码”的工程时间。日志里还有一个细节值得注意payload 中原始参数会被完整记录下来。如果参数里含手机号、身份证这类敏感字段就需要在连接器里做脱敏或者直接关闭 payload 的日志输出。我见过不止一个团队在这个问题上栽过跟头后面聊安全时还会再提。4. 避坑指南我踩过的问题和排查方法4.1 高频问题速查表项目从开发到现在我在实践中遇到最多的问题其实集中在几个点上。我把它们整理成一张速查表按“症状、可能原因、排查步骤”的思路来列遇到同类问题可以照方抓药。症状可能原因排查步骤注册中心发现节点频繁“消失”TTL 设置过短网络小抖动导致心跳超时把 TTL 调到心跳间隔的 5-8 倍检查注册中心日志确认是超时还是主动注销调用返回 3002 超时目标 Agent 的并发队列已满请求堆积调大 max_call_queue或检查目标端是否出现慢查询、死锁discover 返回空列表Agent 没有注册成功或注册后未续租被摘除先看注册中心日志再手动调 register 接口确认 capability 名称是否一致部分调用失败但错误码是 3101调用方请求的能力名与注册名不一致对照两边的 capability 定义重点检查大小写和空格。这个坑最蠢也最常见调用了旧的 endpoint调用方绕过了 discover自己缓存地址临时设置短 TTL 清掉旧路由长期方案是强制走注册中心发现第四个问题值得多说一句。开发阶段为了省事我在写调用模拟器时把 capability 写成了 calculate.add而 bob 真实注册的是 calculator.add。整整联调了半小时排查日志才发现在错误码和地址上绕了半天最后问题出在“少写了一个 tor”。能力名一旦发布后就是公开契约改名字要像改接口一样谨慎否则所有调用方都得跟着返工。4.2 安全与权限Agent 也要讲边界Agent-Reach 最初的版本在安全上非常朴素只有一层 API Token 校验。后来在内部分享时被同事问了一个问题一个 Agent 能调另一个 Agent 的能力那 A 能调 B 的管理能力吗比如客服 Agent 如果拿到运维 Agent 的全部能力调用权理论上它可以把发布系统也触发了——没人想看到客服机器人不小心重启了生产环境。这个问题让我重新设计了权限模型。现在注册中心维护一套允许/拒绝规则表规则按 source_agent、capability、target_agent 三个维度组合。默认情况下没有规则就是拒绝只有显式加入 allow 列表的调用才被放行。规则逻辑放两层注册中心在 discover 阶段就按能力做粗粒度过滤调用方拿不到无权调用的 endpoint目标 Agent 在 dispatch 入口再做一次校验防止调用方绕过注册中心直接访问。审计日志在这里的作用是防患于未然。所有调用的请求方、目标方、能力名、参数摘要、结果状态、耗时都会记录。我之前遇到过线上 Agent 的某个工具被高频调用查了审计日志才发现是另一个团队在拿它当爬虫接口用。没有日志的话这种问题基本不可能定位。4.3 性能调优从 P99 120ms 到 48ms 我做了什么最初跑压测的时候注册中心的性能相当一般。100 个并发调用本地回环环境下 P99 延迟到了 120 毫秒作为内部工具虽然能忍但我觉得还有优化空间。用 profile 工具看了热点发现问题不在消息解析上而在注册表的数据结构和日志打印上。第一次优化把注册表从简单的数组扫描改成了基于哈希索引的按名称逆索引。discover 请求不需要再遍历全部节点直接按能力名查表。第二次优化把心跳处理和请求处理解耦心beat续租放到独立的定时器循环避免高频调用阻塞心跳。第三次优化是减少 DEBUG 级别日志——这个虽然技术上不太“优雅”但效果立竿见影因为每次调用成功都打两条结构化日志规模上去以后日志序列化和 IO 开销非常可观。调优之后同一台机器、同样的并发规模P99 降到了 48 毫秒。这三次优化里最实用也最容易被忽视的是第三条。生产环境的日志要分级平时不开 DEBUG出问题再临时调高用完后立刻降回去。留在日志里的不是越多越好而是越精准越好。5. 后续计划与个人经验总结5.1 想做但还没做的事Agent-Reach 当前版本能满足我的内部需求但我知道它离一个真正成熟的项目还有距离。列几个自己想做但还没推进的功能供大家参考。第一个是协议版本协商。目前握手过程没有版本字段升级协议时必须全集群灰度没有平滑过渡的手段。后面计划在注册请求里带上 protocol_version服务端根据版本选择兼容模式。第二个是局域网内自动发现。现在每个 Agent 都要手动指定注册中心地址对于小规模自组网场景还是略繁琐。计划加入基于 mDNS 的自动发现能力让同一网段内的 Agent 启动后自动寻找注册中心把“配置依赖”降到最低。第三个是流式调用支持。目前 agent.call 是一问一答模式但很多工具场景实际上是流式的比如长文本生成、日志实时推送。计划在协议层增加 stream 会话类型用 SSE 做服务端推送先从日志流场景试起。5.2 用 Agent-Reach 之后的变化与心得接入 Agent-Reach 到现在最直观的变化是新 Agent 接入的时间成本大幅下降。以前接一个新系统可能要按周算现在只要把工具函数封装一下实现一个 Connector填完能力描述基本一天内能打通。能力描述的复用价值也在逐步体现团队里新来的同事看能力表就能知道集群里有什么工具不用再翻一堆接口文档。如果让我总结一条最值得分享的经验那就是协议设计别贪多。Agent-Reach 从头到尾只解决了“注册、发现、调用”三件事但也正因为克制它才保持了足够的简单和稳定。你可以随时在协议外面加想要的工具但如果一开始就塞进一大堆特性项目很可能僵在方案设计阶段。最后再分享一个小技巧我在每个能力描述里都保留了 example 字段刚开始只是为了方便测试后来发现它带来的收益远超预期。联调时对方不用问“这个参数到底长什么样”把 example 直接变成 POST body 就能跑通全链路。这大概是整个项目里投入产出比最高的一个设计决策。这套结构里注册中心、调用协议和连接器是三个可以独立替换的部分。如果只想拿其中一部分完全可以把协议定义抄到自己项目里只保留本地能力注册表跳过远端发现。Agent-Reach 本身就是我用最笨的方法试出来的一套协作方案它解决的问题是通用的具体实现上大家大可以根据自己的场景裁剪。