多 Agent 协作的通信乱象我用一个叫 Agent-Reach 的中间层解决了先说说我自己的经历。过去大半年我一直在一套多 Agent 系统里折腾任务拆解、意图识别、工具调用这些上层逻辑其实都还好说真正让我崩溃的是 Agent 之间的通信——A 代理用 gRPCB 代理走 WebSocketC 代理干脆只暴露一个 HTTP 接口地址靠配置文件硬编码谁下线了没人知道整个链路断在哪个环节全靠猜。后来我换了思路干脆在代理层和底层传输中间加了一层轻量级基础设施项目代号就叫 Agent-Reach。它的核心使命一句话就能说清楚让任何 Agent 都能通过统一的方式被找到、被连接、被可靠触达。这篇文章不是学术综述就是把我整个设计和落地过程摊开来讲包括结构决策、接口定义、踩坑记录适合正在做多 Agent 编排、或者被 Agent 互联问题烦得不行的朋友参考。1. 多 Agent 协作的真正痛点不是模型选型1.1 我那套最早的多 Agent 系统是怎么卡住的一年前我搭第一版多 Agent 工作流的时候想的特别简单三个 Agent 分别负责信息抽取、知识检索、报告生成串起来不就行了结果第一个 demo 就让我见识到了什么叫分布式系统的复杂度不会消失只会在你没注意的地方冒出来。三个 Agent 之间传数据我当时用的是各自依赖的思路。Agent A 直接拿 Agent B 的 HTTP 服务地址拼 URLAgent B 又通过配置文件去连 Agent C 的 RPC 端口。看上去每个调用都很清晰但跑起来之后问题接踵而至某个 Agent 扩容了端口变了配置文件没同步直接把整个链路打断。Agent C 暂时不可用Agent A 完全没有感知还在闷头重试一个已经死亡的地址。我想在中间加一层鉴权或者日志发现每个调用点都要改一遍改动范围大到不敢上线。最要命的是Agent 之间的能力匹配完全靠我人肉记忆。新增一个 Agent我还得手动去改动所有可能调用它的地方。说白了我当时缺的不是模型能力而是零件之间的插座标准化问题。每个 Agent 就像一个只有特殊接口的电器想组合起来工作必须依赖我这个人肉万能转换头。1.2 拨开表象Agent 之间缺的不是功能是触达方式后来我跟一个做消息中间件的朋友聊了一次他一句话点醒了我你们遇到的问题本质上和几十年前电话交换系统是一模一样的——你需要的不是电话号码本身而是能让电话接通的总机。对问题就出在触达这个动作上。我的 Agent 们都在功能也都正常但彼此之间缺少一套统一的寻址拨号接通反馈机制。每个 Agent 的通信协议、地址格式、健康状态、能力范围都是各自为政的这就导致链路的建立高度脆弱任何一个小变动都要触发一连串人工调整。从那一刻开始我把目标从实现 Agent 之间互相调用改成了实现 Agent 之间标准化的可触达能力。这听起来像文字游戏但实际指导意义完全不同前者是点对点解决具体调用问题后者是解决全局的发现、连接、通信问题。2. 从 Agent-Reach 三个核心概念聊起可达节点、可达图、可达协议2.1 Reach Node让每个 Agent 都有统一的门牌号Agent-Reach 的第一个底层概念是 Reach Node也就是可达节点。任何想对外提供服务、或者想被其他 Agent 调用的实体只要接入 Agent-Reach就会被抽象为一个 Reach Node。每个 Reach Node 拥有一个全局唯一的身份标识比如agent:customer-serviceprod。这个标识有三个部分类目、名称、命名空间。过去我们的 Agent 地址长这样http://10.0.8.12:30080/v1/api/query而用 Reach Node 之后对外暴露的寻址信息变成了agent:knowledge-queryprod你不用关心后面是 HTTP 还是 gRPC不用关心 IP 和端口只要知道这个节点能做什么、叫什么名字就可以发起通信。我为什么坚持用这种语义化地址因为 Agent 层面的调用本质上应该是按能力找服务而不是按 IP 找进程。当系统里只有 3 个 Agent 时IP 直连挺省事当 Agent 数量到 20 个、50 个的时候地址裸奔就是灾难。Reach Node 的注册信息包括这么几个字段身份标识全局唯一不允许冲突。能力声明Capability Declaration该节点能处理的任务类型比如ticket.classify。传输端点Transport Endpoint实际的服务地址和协议。存活状态Liveness由心跳机制动态上报从注册起就由 Agent-Reach 持续追踪。这种设计让节点注册变成比服务部署更前置的一步。一个 Agent 只要注册成功它的门牌号就算立住了后续整个系统都在围绕这个门牌号做文章。2.2 Reach Graph代理之间的拓扑不再是靠脑子记第二个概念是 Reach Graph也就是可达图。如果说 Reach Node 解决的是你是谁、你在哪那么 Reach Graph 解决的是谁能触达你、通过什么路径触达你。一开始我其实不确定要不要做这个图。后来跑了几次大规模任务编排发现一个实际问题当 N 个 Agent 互相都可能调用时链路规划不能每次都在请求分发中心写死而应该让请求方知道我能触达谁、最优路径是哪条。Reach Graph 本质上是一个有向加权图每个节点是 Reach Node。每条边表示一个可用的触达关系边上标注了协议类型、平均延迟、最近成功率。权重可以动态变化。某个节点最近老是超时边的权重就变大某个节点新增了一条更快的内网通道权重就变小。这个图的价值主要体现在路由决策上。比如 Agent A 想调用 Agent B如果 B 当前负载很高Reach Graph 能反馈一条到达 B 的备用路径比如通过 C 转发或者给出B 当前不可达请稍后再试的明确答复而不是让 A 自己傻乎乎地重试。当然Reach Graph 本身的构建也没那么玄乎。每个 Reach Node 注册时会声明自己的可达网络信息Agent-Reach 会维护一个全局路由表再通过周期性的探测包更新各条边的质量。用生活化的类比来说Reach Node 相当于你家门牌号Reach Graph 相当于导航软件里计算出的路网你不需要知道每一条路具体长什么样但任何时候你开车它都能告诉你走哪条路大概率最快。2.3 Reach Protocol一种轻量、可插拔的通信协议描述第三个概念是 Reach Protocol这个是最容易让人误解的部分。它不是一个全新的通信协议比如你要是问我Reach Protocol 和 gRPC 怎么比我会告诉你它们不在同一个层次。Reach Protocol 解决的是在已经确定用任意传输协议的前提下Agent 之间的消息格式和交互语义怎么统一。我设计了四层消息头Message Header: schema_version : 协议版本 request_id : 全局唯一请求 ID trace_id : 链路追踪 ID src_node : 发送方 Reach Node dst_node : 目标 Reach Node msg_type : request | response | event | ack capabilities : 期望匹配的能力标签可选这个设计背后有一条很深的考虑统一消息格式远比统一传输协议容易落地。很多 Agent 实际上跑在异构技术栈上你要让所有 Agent 服务都改成 gRPC根本推不动。但如果你要求大家只要遵守一套消息头规范底层仍然走你熟悉的 HTTP 或 RPC那落地的阻力就小很多。所以 Agent-Reach 在传输层是什么都能接在消息层是大家都按一个格式说话这个定位让整个中间层看起来轻巧很多不大而全。3. 核心 API 实测注册、发现、发送全流程3.1 注册代理Capability 声明比 endpoint 更重要注册实现是接入 Agent-Reach 的第一步也是最容易踩坑的一步。我先来展示常用的注册方式基于 SDK 调用from agent_reach import AgentReachClient client AgentReachClient( bootstrap_serverreach://internal-reach-a:16880, namespaceprod ) node_info await client.register( node_namecustomer-service, capabilities[ {type: ticket.classify, version: 1.0}, {type: sentiment.analyze, version: 0.9} ], transport{ protocol: grpc, endpoint: grpc://10.20.3.15:50051 }, heartbeat_interval_seconds10 ) print(node_info.node_id) # 输出: agent:customer-serviceprod这里我有一个花了很长时间才想明白的要点注册节点时的能力声明优先级大于传输端点声明。也就是说Agent-Reach 允许你注册一个端点地址还没有完全配好但能力已经明确的节点。为什么要这样因为服务和端点经常漂移但能力是相对稳定的。我的一个 Agent 曾经从裸机迁移到容器IP 全变了但能力没变。如果系统在注册时强制绑定端点那么这次迁移会波及所有调用方而按能力注册的模式中分布式系统只要拿到这个 Agent 具备某能力的元信息就能在端点更新后自动重新发现。注册的时候还有一个参数值得关注heartbeat_interval_seconds默认值 10 秒。实际跑下来我建议按场景调整场景建议心跳间隔原因内网低延迟、Agent 数量少5 秒故障感知更快跨机房、Agent 数量多15-20 秒避免心跳风暴边缘节点或资源受限30 秒资源消耗可控3.2 发现服务走能力索引而不是走地址簿注册完成之后真正好用的是发现这部分。我在第一版 Agent-Reach 里做过一个错误的设计把发现做成一个简单的地址簿按节点名返回 IP:Port结果绕了一圈又回到了配置文件时代。后来我改成了按能力发现。拿实际代码说话from agent_reach import classify # 按能力发现不按名字找进程 matches await client.discover( capabilityticket.classify, min_version1.0, strategyLOAD_AWARE # 可选: FIRST_AVAILABLE / LOAD_AWARE / ROUND_ROBIN ) # 返回结果是一组可用的节点描 # [ReachNode(agent:customer-service-aprod), ReachNode(agent:customer-service-bprod)] for node in matches: print(f发现节点: {node.node_id}, 当前延迟: {node.avg_latency_ms}ms)这一层带来一个本质上的变化调用方不再关心找谁而是关心谁能干这个活。谁在线、谁负载低、谁版本满足要求这些都是 Agent-Reach 在内部完成的。这种方式从运维角度来看特别舒服。当我新增一个提供相同能力的 Agent 副本时不需要改动任何调用方代码新节点注册的一瞬间就自动进入了发现候选池。这个过程跟 DNS 域名解析有点像但 Agent-Reach 的能力索引比 DNS 多了一层语义匹配它知道这个服务能做什么而非只知道这个 IP 叫什么名字。3.3 发送消息sync 与 event 两条链路发现完节点接下来就是触达的动作。Agent-Reach 支持两种消息模式同步请求-响应请求链路和异步事件广播事件链路。同步模式适合一个 Agent 调用另一个 Agent 并等待结果的最常见场景resp await client.send( node_idagent:knowledge-queryprod, request{ method: query, params: {keyword: AgentReach} }, timeout15.0 # 秒 ) if resp.is_success(): result resp.data else: reason resp.failure_reason # 可能是 TIMEOUT / NODE_DOWN / ROUTE_NOT_FOUND异步模式适合事件驱动场景比如一个 Agent 完成某操作后通知其他 Agentawait client.emit( event_typeticket.created, payload{ticket_id: T20250312001}, target_tags[notification, audit] )两个路径的考虑其实代表了两种不同的协作模式。同步请求缓存了我等你帮我做事的强依赖关系异步事件则让各个 Agent 之间的耦合更松散、吞吐更高。在 Agent-Reach 中我刻意保留了这两种模式而不是统一成一个。因为实际业务中两者都有大量场景。比如在客服工单处理链路里分类和质检之间的调用必须同步否则逻辑没法往下推进。而工单创建之后触发的通知、归档、统计更新这些完全可以用异步事件让消息在后台一条条投递。3.4 平时容易忽视的参数配置接口不多但参数一旦配错运行效果天差地别。我挑三个实际影响比较大的参数说首超时时间connect_timeout。注意这不是请求总超时而是建立连接这个动作的超时。我见过有人把总超时设了 30 秒但 connect_timeout 没设置结果某次网络异常时所有请求都卡在网络栈内部的默认超时上45 秒毫无反应。建议 connect_timeout 单独设置为 2-3 秒快速失败远比慢失败有价值。最大重试次数可见性retry_visibility。Agent-Reach 默认支持自动重试但很多人在调试期被幽灵超时困惑明明日志里只报了一次超时任务却成功了其实是因为某个请求被透明重试了多次。我建议在 Debug 环境打开retry_visibilityTrue把所有重试过程暴露到追踪日志里把眼不见为净变成每一次重试都可解释。上下行消息压缩阈值compression_threshold。默认 1KB阈值以下的消息不做压缩。一开始我以为这个参数无关紧要后来我跑了一个大量传长文本的业务负载从 18MB 降到了 4MB效果极其夸张。如果你的 Agent 之间经常传 JSON 大文本这个参数建议调到 512 字节收益会很明显。4. 一个三代理协作的真实案例工单分类、质检、回访4.1 结构设计理论讲了半天还是得上一个具体的案例。我在内部做了一个自动客服工单处理的 Demo三个 Agent 协作工单分类 Agent负责判断工单类型和紧急程度、质检 Agent负责检查分类置信度是否合格、回访 Agent负责生成回访文案。这三个 Agent 用 Agent-Reach 串联之后整体数据流是这样的外部 Webhook 收到新工单触发ticket.created事件。分类 Agent 监听该事件消费工单内容产出分类结果。分类结果通过同步请求发给质检 Agent 复核。质检通过之后回访 Agent 通过异步事件拿到工单信息生成回访文案。整个过程通过 Reach Protocol 统一追踪。4.2 注册与发现配置三个 Agent 的注册配置如下我用 YAML 声明式配置描述agents: - name: classifier capabilities: - type: ticket.classify version: 1.0 transport: protocol: grpc endpoint: grpc://agent-classifier.internal:50060 - name: quality-checker capabilities: - type: quality.review version: 1.2 transport: protocol: http endpoint: http://agent-quality-checker.internal:8080/review - name: follow-up-composer capabilities: - type: reply.draft version: 2.0 transport: protocol: grpc endpoint: grpc://agent-follow-up.internal:50065这里有意采用了两个不同的传输协议分类 Agent 和回访 Agent 用 gRPC质检 Agent 用 HTTP。在 Agent-Reach 里这完全不构成障碍因为通信的统一性已经被 Reach Protocol 消息头承接了底层传输的差异被透明化处理。4.3 编排代码与执行结果编排逻辑由一个轻量工作流服务承担它不参与具体的业务判断只做路由。async def handle_ticket(webhook_payload: dict): # Step 1: 触发事件分类 agent 异步处理 await client.emit(ticket.created, webhook_payload, target_tags[classifier]) # Step 2: 分类 agent 完成分类后汇报结果工作流服务等待分类结果 classification await client.wait_event( ticket.classified, timeout10.0, filterlambda ev: ev[ticket_id] webhook_payload[ticket_id] ) # Step 3: 同步请求质检 agent 复核 review await client.send( node_idagent:quality-checkerprod, request{ method: review, ticket_id: classification.data[ticket_id], category: classification.data[category], confidence: classification.data[confidence] }, timeout5.0 ) if not review.is_success(): await alert_admins(f质检代理不可达: {review.failure_reason}) return # Step 4: 如果质检通过触发回访 agent 生成回复 if review.data[pass]: await client.emit(quality.approved, review.data, target_tags[follow-up])上面这段逻辑看起来简单但真实运行的时候有几个地方很体现 Agent-Reach 的作用。首先是步骤 2 的wait_event我不需要主动去轮询分类 Agent 的 API而是通过 Agent-Reach 的事件分发机制拿到结果。这避免了两个大坑一是不用在意分类 Agent 的物理地址二是不用手动处理事件已经发生但我还没开始监听这个竞态条件——Agent-Reach 会默认对事件做短暂缓存。最终实测下来单条工单从进入到回访文案产出端到端耗时大约 320ms其中绝大部分时间花在真正的模型推理以及质检 HTTP 调用上Agent-Reach 自身的路由事件分发损耗压到了 15ms 以内。在 200 个工单并发的压测下没有出现请求丢失的案例整体链路稳定。4.4 这个案例里比较关键的两处设计决策回头分析这个 Demo有两个决策我觉得是关键中的关键。第一个决策是分类 Agent 和工作流服务之间用事件解耦而不是直接同步调用。这意味着分类 Agent 即使短暂重启工作流服务也不会崩掉因为事件会被暂存在消息层分类 Agent 苏醒后能去消费数据。在 Agent 系统里重启不丢数据带来的心理安全感是巨大的。第二个决策是质检 Agent 的调用必须走同步。分类结果的置信度如果不经过复核回访文案写得再漂亮也可能在前端闹笑话。质检同步调用严格把控了这个逻辑闸门从流程上保证了正确性。这两处决策也对应了 Agent-Reach 里同步/异步两套机制的选择路径具体用哪种模式取决于这个调用步骤的失败容忍度而不是看哪个 API 更好写。5. 跑起来之后的那些坑认证、超时、镜像灾备5.1 代理身份伪造只在内部网络里跑远远不够接入 Agent-Reach 的头两个星期我只做了内网网络隔离没有在 Agent 通信层面加入身份验证。结果内部安全巡检的时候被问了一句如果某个 Agent 节点被攻破它能不能冒充分类 Agent 向质检 Agent 发送伪造的通过指令这个问题直击要害。Agent-Reach 的消息头里带有src_node字段但这个字段在没有认证机制时是可以随意伪造的。为了修复这个问题我给 Reach Protocol 增加了一层简单但能落地的 Token 认证每个节点在注册时拿到一对 Access Token / Secret Key。发送消息时src_node字段必须附带 Token 签名的请求摘要。接收端在验签通过前绝不处理业务数据。代码层面就是在发送端加一个签名头await client.send( node_idagent:quality-checkerprod, requestpayload, authclient.auth_context(tokenREGISTERED_TOKEN, secretREGISTERED_SECRET) )当然严格的方案可以用 mTLS但在 Agent-Reach 阶段先上轻量签名是最务实的做法。原则只有一条别让你的 Agent 用裸身份裸奔。5.2 消息超时和重试请求幂等性必须提前定好Agent 之间的调用和普通微服务调用有一个很大区别Agent 执行任务往往不是查一下数据这种原子操作而是可能触发一连串副作用——比如生成文档、发送邮件、修改数据库状态。如果这类请求在超时后自动重试而又没有幂等保证那么一次不确定的成功调用就会变成灾难。我实际遇到过一次回访 Agent 生成文案并发送邮件由于网络抖动发出指令后的响应丢了Agent-Reach 自动重试了一次结果邮件连发两封。这个问题从设计上解决更靠谱。我在 Agent-Reach 的消息头中强制带上了request_id并要求所有执行方在处理请求前先查重。映射到代码里就是if await idempotency_store.already_seen(request_id): return previous_response # 直接返回之前的结果这个改动在业务 Agent 侧会稍微增加一点代码量但必须做。对于所有可能产生副作用的方法建议强制开启幂等控制入口对于只读操作则不需要这个机制减少开销。5.3 可观测性与调试技巧最后说说运维层面的体验。Agent-Reach 从第一天起就把链路追踪做进了消息头里这让我调试多 Agent 问题时比过去省心十倍。过去的排查姿势是这样的日志散落在各 Agent 的 stdout 里我只能靠时间戳拼接上下文。Agent-Reach 加入之后我可以直接用trace_id一次性拉出某条工单在分类、质检、回访三个 Agent 之间的完整流转记录。reach-cli trace T20250312001 --follow输出的内容会按时间轴展示每个节点接收消息的时刻、耗时、是否重试、返回状态10:00:01.002 [event] ticket.created → classifier 10:00:01.056 [received] classifier got ticket_idT20250312001, waiting... 10:00:01.850 [emit] ticket.classified → workflow 10:00:01.910 [sync] workflow → quality-checker, timeout5s 10:00:02.310 [response] quality-checker returned passtrue, conf0.87 10:00:02.411 [event] quality.approved → follow-up-composer 10:00:02.850 [emit] reply.drafted → notify-service这个 10 行的输出放在以前我可能得翻五六个日志文件才能凑齐。6. 想让 Agent-Reach 跑得更远我目前看到的边界6.1 协议转换层的设计取舍Agent-Reach 目前做的是多协议兼容、统一路由入口的模式但没有在传输协议层面做完全的协议转换代理。也就是说如果一个 Agent A 只支持 HTTP另一个 Agent B 只支持 gRPCAgent-Reach 不会负责把它们之间协议转换得毫无感知。实际上你需要在 Agent 侧或者单独部署适配器来解决差异。当时我很快做了这个取舍协议转换层太重做进去会拖慢整体进度偏离轻量级中间层的定位。现实中等 Agent 数量多到一定程度重新规划标准传输协议可能比做协议适配更简单这是后面可能要研究的方向。6.2 接下来想继续做测试的扩展方向目前这套 Agent-Reach 方案稳定跑在内部工单系统上整体链路已经超过四个月没有出现过因通信层而导致的故障了。我个人真心觉得多 Agent 系统的通信层不应该变成一台人肉拼接线缆的交换机反而应该像这里写的一样做到注册即发现、能力即路由、消息皆可追踪。接下来的迭代我现在比较想尝试的方向包括让 Reach Graph 更细粒度地反映不同时间段节点的负载特征给事件分发加一个延迟优先级以及考虑对海量 Agent 节点的分层命名空间做更细致的权限隔离。这些需求现阶段还没有那么迫切但每一条都是从实际运行中冒出来的真需求。最后分享一个小技巧如果你也想搭类似的基础设施别人怎么设计方案再宏大也没用一定要先找到一个很小的场景三个 Agent 就够先把 Agent-Reach 的设计思路和核心代码跑通。第一个版本不要一步到位设计上留好接口后面扩展就顺了。