
如果你同时维护着两三个 AI Agent大概很快会遇到这种事A Agent 跑得好好的想调用 B Agent 的能力结果发现 B 的地址是写在 A 的配置文件里的B 一换端口、一换部署环境A 立刻报错。我和团队前前后后重写过好几版这类协作代码最后一个叫Agent-Reach的小项目把我们从这个泥潭里拉了出来也让我对多智能体系统底层应该长什么样有了新的理解。Agent-Reach不是什么宏大框架它做的事用一句话讲清楚在多智能体系统里让任意一个 Agent 能可靠地触达另一个 Agent 或外部工具且不要求两边在代码层面互相写死地址。它适合正在自己搭多智能体编排、而不是靠全托管平台解决问题的团队适合那些 Agent 数量超过三个就开始觉得“处处都在硬编码通信地址”的人也适合想理解 Agent 与 Agent 之间消息路由、权限控制、心跳续租这些底层机制的人。这篇文章我会从问题根源讲起再给出 Agent-Reach 第一版的核心设计然后是一份可以照着操作的最小部署记录最后是真实环境里踩过的四类坑和对应的修复排查过程。全程是实际操作里的经验不带概念堆砌。1. 智能体孤岛是怎么来的以及为什么不能靠互相留 HTTP 地址解决多智能体系统一开始总是很美好一个 Agent 负责查资料一个 Agent 负责做规划一个 Agent 负责调工具。但功能拆分完真正的麻烦才开始——不同 Agent 之间怎么知道对方的存在怎么知道一个请求该发给谁以及谁有权限调用谁这三个问题不解决所谓多智能体协作就只是一堆孤立服务的拼盘。我把这种情况叫“智能体孤岛”每个 Agent 单独看都很强互相之间却像隔着一道墙通信基本靠人工把地址抄进配置。1.1 硬编码地址带来的连锁灾难最朴素的做法就是让 Agent A 直接调用 Agent B 的 HTTP 接口。配置里写B_ENDPOINThttp://agent-b:8080然后 A 循环请求这个地址。听起来还行但只要你经历过下面任何一个场景就会知道这条路走不远B 从一台机器迁移到另一台机器端口从 8080 变成 9090你得改 A 的配置并重启 AB 增加了一个新版本但 A 还是按老 schema 发消息两边字段对不上连接不报错、业务全错C 也想调用 BC 的开发者只能复制一份 A 的连接参数改了几个字段于是三个人维护着三份不同的“B 地址真相”B 临时升级A 没有任何退避机制直接请求超时超时后 A 又把同样的请求重发了一遍B 被重复请求打到崩溃。这些问题的本质不是“网络连接写得不好”而是把通信的稳定性建立在静态配置上。只要 Agent 的数量超过三个这种静态配置的维护成本就是指数级上升的。1.2 让所有 Agent 在同一个“群聊”里互相吼也不是答案有人说那好办用消息队列或者广播总线所有 Agent 都订阅一个 topic谁有能力谁就来处理。这个思路比硬编码好一点但会带来新的混乱。我在早期实验里试过这种方式最大的问题在于广播式协作根本不回答“该由谁处理”的问题。业务请求到达之后如果多个 Agent 都订阅同一个 topic你需要在每个 Agent 里写一套“我是否应该处理这条消息”的判断逻辑。这个判断逻辑一开始很简单后来会变得出奇地复杂。比如 Agent B1 和 B2 都声称自己能做“天气查询”但 B1 只支持返回 JSONB2 支持自然语言回答如果消息本身不带调用方的需求偏好两个 Agent 都会认为“我来处理”。等 B2 处理完B1 也处理了一遍消息被消费两次结果浪费且不一致。广播总线解决了“地址写死”却把意图判断的责任推给了每个 Agent也就是把路由逻辑重新散落到了各个业务角落。真正需要的是一个单独的角色负责“看请求、做判断、选目标、转发、看结果”——这个角色不能是某个 Agent它必须是基础设施。1.3 Agent-Reach 的三件底层事发现、路由、授权我们做 Agent-Reach 时定了一条原则业务 Agent 只负责自己的核心逻辑通信层的“谁在”“谁能干什么”“这次给谁”全部收拢到一层薄薄的中枢。这层中枢要做的核心事情只有三件发现DiscoveryAgent 启动后主动报告自己的能力、访问地址、存活状态。系统不再靠配置文件去记“谁在哪”而是让 Agent 自己注册。路由Routing当一个 Agent 发出语义请求中枢根据请求内容匹配到最合适的 Agent。这个匹配是动态的可以加权重、加优先级、加字段校验。授权Authorization就算 Agent B 能力再强也不是 A 随便就能调用。Agent-Reach 在路由前先检查调用方身份不符合授权规则就直接拒绝。这三件事对应的代码量其实不大但放在每个 Agent 里自己实现就会变成整个系统的灾难。Agent-Reach 把它变成一个独立的轻量服务所有语言都能通过 HTTP 协议接入核心库用 Go 编写部署方式只是一个二进制文件加一个配置目录。提示如果你的 Agent 总数只有两个直接用环境变量互调就好没有必要引入 Agent-Reach。这个项目真正的价值是在第三个 Agent 加入时开始的。2. 第一版 Agent-Reach 的三个核心概念能力卡、心跳租约、意图路由在设计第一版时我没有急着写路由算法而是先定义清楚三个数据模型。很多类似的框架失败不是因为代码写不出来而是因为这些数据模型一开始就糊里糊涂。Agent-Reach 的三个核心概念分别是能力卡Capability Card、心跳租约Heartbeat Lease、意图路由Intent Routing。2.1 能力卡让 Agent 从“一个地址”变成“一组可描述能力”传统服务注册中心里一个服务注册的就是地址。但 AI Agent 和普通微服务有个关键差别Agent 的价值不在于“它在”而在于“它能做什么”。所以 Agent-Reach 注册的不是单纯的服务实例而是一张能力卡。这里是我项目里一个典型的注册样例{ agent_id: weather-bot-v2, display_name: 天气查询Agent, capabilities: [ { name: query_weather, description: 根据城市名查询实时天气和未来三天预报, input_schema: { type: object, properties: { city: { type: string }, days: { type: integer, minimum: 1, maximum: 7 } }, required: [city] } } ], endpoint: http://weather-bot:8080/call, authentication: { type: token, token_endpoint: http://weather-bot:8080/auth }, advertisement_ttl: 120, metadata: { region: cn-east, version: v2.0.1 } }agent_id是全局唯一标识capabilities是这个 Agent 对外提供的所有能力的结构化描述其中input_schema借鉴了 JSON Schema让路由层可以提前做参数校验避免把明显不合法的请求转发给 Agentendpoint是可调用的实际地址advertisement_ttl则是这张能力卡的有效时间。我给每个能力都要求写一个清晰的人类可读描述这个字段很重要。后面做意图匹配时description往往比name更管用。比如两个能力一个叫get_weather另一个叫fetch_climate光看名字几乎无法判断语义接近程度但 description 里都提到了“天气”“城市”“预报”匹配算法就能很容易识别出来。2.2 心跳租约没有续租机制僵尸节点早晚吃掉你的系统Agent-Reach 里一张能力卡不是永久生效的。注册后它会获得一个租约时间就是advertisement_ttl。Agent 必须定期发送心跳续租否则到达 TTL 后注册中心会主动把这条记录标记为过期并清理掉。为什么必须这样做因为我踩过“节点明明挂了还在路由表里”的坑。之前用普通的服务发现组件服务下线时如果进程被 kill -9 杀掉没有机会主动注销。这些“僵尸节点”会一直留在路由表里请求被转发过去然后超时接着继续重试整个调用链被拉垮。心跳租约的逻辑很简单注册中心并不信任“谁声称自己活着”而是只信任“最近一段时间内我确实听到过它的心跳”。默认心跳间隔设为 30 秒advertisement_ttl设为 120 秒也就是允许 Agent 在极端情况下漏掉 3 个心跳周期后才被判定死亡。如果业务要求高可用可以把心跳间隔缩短到 5 秒但代价是注册中心收到的心跳请求会变多。对于中小团队30 秒是一个很好的默认值。2.3 意图路由不靠“话题订阅”而是靠“能力打分”当 Agent 发出一个请求比如 “帮我查一下上海明天的天气”Agent-Reach 不会直接去找某个weather_bot而是先做一次意图到能力的匹配。匹配分三步走第一步语义层面把调用方的请求文本和所有注册能力的description、name做匹配给每个候选能力算出一个文本相关度分。这一步我用的是加权的全文相似度没有上太重度的模型因为启动时的速度很重要。对中文文本来说需要先做分词再把能力描述和请求文本映射到同一个向量空间。如果团队没有现成 NLP 设施用最基础的 BM25 也能跑得通只是对同义词不友好。第二步结构层面把请求中的参数和候选能力的input_schema做一致性校验。比如请求带了city候选能力却要求location且没有提供别名这个候选就该被降分即使语义相关也没用。结构校验能挡住大量“语义看着像实际调不通”的情况。第三步人工优先级能力卡支持配置priority字段也可以由管理员在路由表里直接指定某类请求默认走某个 Agent。这个字段不是用来替代前面两步的而是在两个 Agent 语义相似度打分接近时做仲裁。这就是 Agent-Reach 第一版的全部路由逻辑先粗筛再校验最后裁决。它刻意不用复杂的规则引擎因为多智能体系统的路由核心是“快速找到对的人”而不是“精确推导所有可能性”。3. 把 Agent-Reach 跑起来最小网络的完整部署流水账理论说完来看看一个最小的 Agent-Reach 网络到底怎么跑起来。我假设你已经有一台 Linux 服务器或者本地 Docker 环境下面每一步我都是实际验证过的。3.1 准备目录与配置文件Agent-Reach 本身是一个 Go 编译的单一二进制。我从 GitHub Releases 页面下载了对应平台的可执行文件后只需要一个工作目录mkdir -p /opt/agent-reach cd /opt/agent-reach wget https://example.com/downloads/agent-reach-linux-amd64 chmod x agent-reach-linux-amd64项目主配置我习惯放在config.yaml。一个最简配置长这样server: listen_addr: 0.0.0.0:7800 public_addr: reach-centrql:7800 storage: type: memory retention_hours: 24 heartbeat: default_ttl_seconds: 120 check_interval_seconds: 10listen_addr是注册中心对外监听地址public_addr是告诉所有 Agent“你应该用哪个地址来访问我”的广播地址。这个字段在容器化环境里极其容易踩坑后面我会专门讲。storage第一版只做了内存模式保留 24 小时内的注册记录。如果想持久化路由表可以后续接 etcd 或 Redis但我们第一版特意不做因为内存模式在中小规模下已经够用少一个外部依赖就少一层运维负担。3.2 启动注册中心启动超简单./agent-reach-linux-amd64 -config ./config.yaml启动日志里会出现一行reach: listening on 0.0.0.0:7800。这就说明注册中心已经跑起来了。它的 HTTP API 长这样PUT /v1/agents—— 注册或更新能力卡POST /v1/agents/{agent_id}/heartbeat—— 发送心跳DELETE /v1/agents/{agent_id}—— 主动下线POST /v1/route—— 发起一次意图路由请求。注意这个注册中心本身是无状态的。重启之后所有注册记录消失所以 Agent 侧必须实现启动重连。实际项目中我让每个 Agent 在启动时先等几秒等注册中心起来了再注册而不是一失败就退出。3.3 接入第一个 Agent注册能力和给回调加监听接入 Agent 只需要做两件事启动时把能力卡发给注册中心启动一个 HTTP 服务接收 Agent-Reach 转发的调用请求。下面是一个最小化的 Python 示例使用标准库不依赖任何框架import json import time import threading import urllib.request from http.server import HTTPServer, BaseHTTPRequestHandler AGENT_ID weather-bot-v2 REACH_SERVER http://reach-center:7800 capability_card { agent_id: AGENT_ID, display_name: weather-bot, capabilities: [ { name: query_weather, description: 根据城市名查询实时天气, input_schema: { type: object, properties: {city: {type: string}}, required: [city], }, } ], endpoint: http://weather-bot:8080/call, advertisement_ttl: 120, } def register(): req urllib.request.Request( f{REACH_SERVER}/v1/agents, datajson.dumps(capability_card).encode(), headers{Content-Type: application/json}, methodPUT, ) urllib.request.urlopen(req) def heartbeat(): while True: req urllib.request.Request( f{REACH_SERVER}/v1/agents/{AGENT_ID}/heartbeat, datab{}, headers{Content-Type: application/json}, methodPOST, ) try: urllib.request.urlopen(req) except Exception: pass time.sleep(30) class Handler(BaseHTTPRequestHandler): def do_POST(self): length int(self.headers.get(Content-Length, 0)) payload json.loads(self.rfile.read(length)) city payload.get(city, 上海) result {agent: AGENT_ID, city: city, weather: 晴, 24°C} self.send_response(200) self.send_header(Content-Type, application/json) self.end_headers() self.wfile.write(json.dumps(result).encode()) def start_http(): server HTTPServer((0.0.0.0, 8080), Handler) server.serve_forever() if __name__ __main__: register() threading.Thread(targetheartbeat, daemonTrue).start() start_http()这段代码的关键点在于能力卡里的endpoint用的是${AGENT_ID}:8080这种服务名而不是127.0.0.1:8080。因为在 Docker 跨容器场景里如果填127.0.0.1A 容器里的路由请求到了 B 容器内部B 的进程监听着没问题但如果注册中心只把地址当成字符串返回调用方去访问127.0.0.1:8080访问的其实是调用方自己的容器。这个问题我在第四节里专门说。3.4 发起跨 Agent 调用并验证另一个 Agent 要查询天气时只需要向 Agent-Reach 的/v1/route发送一条请求curl -X POST http://reach-center:7800/v1/route \ -H Content-Type: application/json \ -d {intent_text: 上海明天天气如何, params: {city: 上海}, requester: planner-agent}注册中心会返回一个路由结果{ agent_id: weather-bot-v2, endpoint: http://weather-bot:8080/call, matched_capability: query_weather, confidence: 0.87 }拿到这个结果后调用方再向endpoint发起实际业务请求。整体是一个“先路由、再直连”的模型也就是 Agent-Reach 只负责任务路由决策不承担实际的业务数据转发。这样做的好处是减少了一层代理性能损耗也避免了回调数据在注册中心堆积。坏处是调用方和目标方必须处于同一个可直连网络。对于内部系统来说这完全够用如果要跨公网后面可以再接一层网关。3.5 最容易忽略的配置项TTL 和心跳周期跑通之后别急着走检查两个配置。第一确认advertisement_ttl与心跳间隔比例合理。我之前把 TTL 设成了 30 秒、心跳间隔也是 30 秒结果因为一次 GC 暂停导致心跳晚到了几百毫秒注册中心直接把 Agent 标记下线等了整整一个 TTL 才恢复。这个比例至少应该是 3:1我默认用TTL120s / heartbeat30s。第二确认网络里的每个 Agent 都能解析注册中心广播的public_addr。容器里经常出现注册中心向 Agent 返回一个无法从调用方容器访问的地址这个错误在最小部署里不会立刻暴露一旦跨集群就必现。4. 真实环境里踩过的四类坑修复过程比功能开发还值得复盘Agent-Reach 写出来后我没有立刻推广而是在一个内部项目上先跑了三个月。这三个月基本是在“踩坑 - 看日志 - 修复 - 再踩另一个坑”的循环里度过的。我挑四个印象最深的记录下来这些问题八成是你也会遇到的。4.1 坑一注册列表一切正常跨容器调用却全部超时现象很怪Agent 成功注册心跳正常路由也能返回正确的 Agent 地址但只要真正发起业务调用请求就超时。一开始我怀疑是防火墙问题排查了半天最后发现路由返回的地址是http://127.0.0.1:8080/call而调用方是另外一个容器。排查链路是这样的先看注册中心存的能力卡发现 endpoint 确实是http://127.0.0.1:8080/call这是在开发机上调试时留下的。在调用方容器里手动curl http://127.0.0.1:8080/call不出所料连接被拒绝因为 8080 端口在调用方容器里根本没有进程监听。在目标 Agent 容器里查监听日志一切正常。把能力卡里的 endpoint 改成容器服务名http://weather-bot:8080/call重新注册问题消失。这个坑的教训是能力卡里的 endpoint 必须填其他 Agent 真正能访问的地址而不是 Agent 自己本地认为的地址。容器里默认填127.0.0.1本质上是把“我自己能连自己”当成了“所有人都能连我”。在多 Agent 环境里endpoint 的填法应该遵循“从注册中心视角请求方网络视角能通性验证”三原则。4.2 坑二跨 Agent 调用之后上下文“失忆”了天气查询这种无状态调用还好等接入了任务型 Agent问题立刻出现我把一个重要任务的task_id放在业务请求的 JSON body 里同时把另一个字段conversation_id放在 HTTP header 里结果目标 Agent 在处理时一会儿从 body 里取任务 ID一会儿从 header 里取会话 ID两边一旦出现网络代理或消息队列介入元数据就乱掉。排查后发现根本原因是 Agent-Reach 的/v1/route在设计时已经把 HTTP header 作为“链路元数据”的承载体但业务代码却混合使用了 header 和 payload 传递上下文。在一个链路里跨节点传递的信息只能有一个明确的“信封”。我们后来约定所有与业务链路相关的元数据task_id、conversation_id、trace_id统一放在 HTTP header 的X-Reach-Meta-前缀下业务参数只放 body。这样 Agent 的处理逻辑只需要检查 header 就能拿到链路上下文不会再出现一半数据跟着 header 走、一半数据跟着 body 走的撕裂状态。4.3 坑三权限边界形同虚设注册中心成了“开放式对讲机”Agent-Reach 第一版的核心是“发现、路由”权限只是简单做了一层 token 校验Agent 只要持有注册中心下发的 token就能调用任意其他 Agent。这带来一个很严重的问题项目里的 Agent 分属不同信任级别。比如一个负责写周报的 Agent理论上不应该有权限调用生产环境部署的数据库查询 Agent。一开始我完全没管这事直到看到某个内部 Agent 在调试时误调用了另一个 Agent 的重置功能导致状态被清空。修复方案分两步第一步在能力卡里增加allow_requesters字段用来声明“我允许哪些调用方调用我”{ agent_id: database-query-agent, allow_requesters: [planner-agent, admin-agent], capabilities: [...] }第二步在 Agent-Reach 注册中心里增加授权检查逻辑每次/v1/route请求都必须带上调用方身份路由前先检查该身份是否在目标能力卡的allow_requesters白名单内。不在则直接返回 403而不是乱配。这个改动让权限模型从“谁能访问注册中心”下降到了“谁能访问具体能力”粒度完全不一样。4.4 坑四两个 Agent 抢同一个意图路由命中落到错误的一方有一回我把一个“查询天气”的需求接入系统结果注册中心把请求路由给了另一个专门做“天气播报文案生成”的 Agent虽然语义相近但调用方其实只是想要结构化天气数据。问题出在两个能力描述在文本相似度上太高query_weather和generate_weather_report都包含“天气”“查询”“预报”这些词。我把这种问题叫“能力混淆”。解决方式不是调模型而是给路由加结构化和优先级双重裁决请求里的params若包含city并且没有report_style则候选能力中缺少report_style字段的query_weather得分更高若调用方在请求里明确写target_style: narrative则直接路由到生成型 Agent 并跳过文本相似度比较在路由表里我给query_weather设了一个更高的人工优先级这样有歧义时默认走结构化接口。事后我把这种冲突收集成“能力混淆清单”每次新 Agent 注册时先检查它的能力描述和现有能力是否有过高的相似度有则提示开发人员调整描述或增加input_schema中判别性字段。这个检查机制强烈建议所有接 Agent-Reach 的团队都加上。5. 生产化之前最该补的三块短板可观测性、动态路由、信任模型跑通、踩坑、修复之后Agent-Reach 在我们的内部项目里已经能稳定工作。但离生产化还有三块短板我在这里给出我认为最值得投入的方向和具体做法。5.1 可观测性让每次路由决策都留下“决策日志”多智能体系统最让人头疼的就是“这个请求为什么去了那个 Agent”。没有路由决策日志排查只能用“靠猜”。我后来在 Agent-Reach 里为每次/v1/route请求增加了一条结构化日志内容包括请求的intent_text、params、requester候选能力列表及它们的原始匹配分经过input_schema校验后的调整分人工优先级设置最终选中的 Agent 和理由码。这样每次路由后我都能在日志里看到“因为什么原因选中了谁”。另外我加了一个带时序的指标reach_route_decision_seconds用来统计路由决策耗时。如果发现耗时突然上涨多半是能力卡数量太多导致相似度计算变慢这时需要引入索引或者把注册中心从内存模式迁移到支持索引的存储。5.2 动态路由给新版本 Agent 做流量灰度生产环境里最常用的运维动作是“升级 Agent”。如果没有动态路由机制升级就是停机更换地址风险极高。Agent-Reach 后来支持了在注册中心预设路由权重route_policy: - condition: capability: query_weather candidates: - agent_id: weather-bot-v2 weight: 20 - agent_id: weather-bot-v3-beta weight: 80这个策略的意思是当query_weather命中时20% 的流量进入 v280% 进入 v3-beta。灰度发布因此变得非常轻量先注册新版本调整权重到 10%观察错误率和延迟再逐步放大。我强烈建议把路由权重改成支持从存储动态更新而不是改配置重启。Agent-Reach 提供了/v1/policies的 HTTP 接口来更新策略灰度时我用一条 curl 就能完成一次流量切换比改代码重新发布快太多。5.3 信任模型从“共享 token”走向“能力卡签名”前面提到的权限白名单只是基础版。如果 Agent 分布在多个团队甚至多个组织共享 token 意味着拿到 token 的任何人都可以冒充任意 Agent。更进一步的做法是让每个 Agent 拥有一对公私钥注册能力卡时用私钥签名注册中心验证签名后再接受这张卡。这样攻击者即使拿到了能力卡内容也无法伪造一张“假 Agent”的能力卡。具体设计可以是在能力卡的顶层增加signature字段sig 的内容是所有核心字段按固定次序拼接后的哈希再用 Agent 私钥签名。注册中心在注册和心跳续租时都校验签名。签发私钥的时机一般放在 CI/CD 流水线里由部署系统生成不会出现在源码仓库。这个改动初期会带来一些密钥管理的麻烦但对多团队场景是值得的。比如我们的外部合作方接入系统时我只给了他们一套生成私钥的流程后续所有协作都基于签名信任再也没有因为 token 泄露导致的问题。最后再分享一个我验证 Agent-Reach 是否正常工作的土办法每次改完路由策略我会故意注册一个“只会返回错误结果”的坏 Agent再把它在策略里的权重设为零然后向注册中心发送一批测试意图检查是否有任何一条请求被路由到它。如果有说明路由策略没有真正生效。这个“坏 Agent 探针”成本极低但能非常直观地暴露路由表、缓存、策略热加载之间的不一致问题。我在 Agent-Reach 上跑这个探针的半年里至少抓到过三次缓存未失效的 bug算是整个项目里性价比最高的测试手段。