
做多智能体系统最头疼的一件事不是让单个 Agent 变聪明而是让一堆 Agent 能真正互相找到、说上话、把事情办完。你写好了 A也写好了 B结果 A 不知道 B 活着B 回了消息 A 没收到重试三次直接把下游打挂——这种问题在 Agent 项目里太常见了。Agent-Reach 这个名字字面意思就是“智能体触达”我把它做成了一套面向多 Agent 协作的触达与调度基础设施专门解决 Agent 之间的注册发现、可靠通信、智能路由和全链路观测问题。这篇文章就把这个项目的完整拆解、工程取舍和踩坑记录写出来给准备做多智能体编排或正在被 Agent 通信问题折磨的开发者一个可直接落地的参考。Agent-Reach 不是一个大而全的 Agent 框架它更像是一层地基不管你的 Agent 是用 LangChain、AutoGen 还是纯手写的大模型调用只要你把 Agent 接进来它就能帮你搞定“谁在哪、怎么找、怎么达、怎么确认”这一整条链路。适合谁看如果你正在做 AI 客服、RPA 流程编排、多角色写作机器人或者任何需要多个 Agent 协作完成任务的工程这篇内容应该能帮你少走不少弯路。1. 项目拆解Agent-Reach 的核心问题域1.1 从单 Agent 到多 Agent到底难在哪单个 Agent 的工程化已经不算难了。模型调用、Prompt 管理、上下文窗口控制成熟方案一抓一大把。但当你把三个、五个甚至几十个 Agent 放在一起协作时问题性质就变了——从“让一个 Agent 做对事”变成“让一群 Agent 协作不出事”。第一类问题是发现。Agent 是动态的可能随时扩容、缩容、更新版本A 怎么知道 B 当前可用并且是最新版本没有注册发现机制就只能写死地址写死地址就意味着每次发布都要改代码这在工程上不可接受。第二类问题是通信。Agent 之间说话用什么协议同步 HTTP 请求还是异步消息队列消息格式怎么定义要不要支持流式返回这些选择直接决定了系统的实时性、吞吐量和代码复杂度。第三类问题是可靠性。你给下游 Agent 发了一个任务它到底收到没有处理到哪一步了如果它挂了任务是重试、丢弃还是转交其他 Agent如果重试重试策略是什么这些如果不在基础设施层解决最终都会变成线上事故。第四类问题是可观测性。多 Agent 协作链路通常很长上游 Agent 调用路由 Agent路由 Agent 分发到三个执行 Agent执行 Agent 又要回调结果。任何一个环节超时或者出错如果没有链路追踪排查问题就是大海捞针。Agent-Reach 就是围绕这四个问题做的设计。它不强求 Agent 内部怎么实现只要求接入方遵循一套通信契约用最小的侵入代价换取整个系统的可控性。1.2 Agent-Reach 的设计目标与边界项目立项时我给自己定了几条硬约束。第一条接入成本必须低。如果一个 Agent 接入框架需要改大量现有代码团队一定不会用。所以 Agent-Reach 采用 SDK 加 Sidecar 两种接入模式重视性能的团队用 SDK 内嵌异构系统通过 Agent-Reach Gateway 接入两边都不用侵入业务逻辑。第二条通信契约必须明确。所有 Agent 消息统一封装为 JSON Format包含消息 id、来源 Agent 标识、目标 Agent 标识、消息类型、载荷、超时时间、幂等键。这一层强制统一很大程度上避免了“A 认为发的是 textB 认为是 markdown”这种低级但致命的格式事故。第三条异常处理必须在基础设施层兜底而不是依赖业务代码自觉。网络抖动、进程崩溃、消息积压这些事不能指望每个 Agent 作者都考虑周全Agent-Reach 在通信层内置超时控制、自动重试、死信队列和降级策略。边界同样重要。Agent-Reach 不做业务编排——不替你去定义“先让意图识别 Agent 判断再让搜索 Agent 执行”这种流程也不做模型调用的统一抽象你用什么模型、怎么调 Prompt 是你自己的事。Agent-Reach 只做一件事让一个 Agent 发出的消息能够可靠、高效、可观测地抵达目标 Agent。边界划清楚之后后面每个模块的设计都有明确衡量标准注册中心要支持多少节点、路由耗时不能超过多少毫秒、消息可靠投递要达到什么级别全部量化而不是空泛地喊“高可用”“高性能”。2. 关键技术选型与架构思路2.1 注册中心让 Agent 能被“找到”Agent 注册中心是整个触达链路的第一环。没有它一切通信无从谈起。业界有一些现成方案比如 Consul、etcd、Nacos但它们都是为微服务设计的Agent 场景有两个额外诉求是这些通用框架没有完全覆盖的。第一是 Agent 的能力描述。一个微服务注册只需要暴露 ip:port 和健康检查接口而一个 Agent 除了网络地址还需要告诉注册中心它擅长干什么、接收什么类型的消息、当前负载水位如何。这就意味着注册数据不能只是简单的 key-value而是要有结构化的能力元数据。第二是运行状态感知。Agent 不是稳定运行的服务它可能在等待 LLM 返回可能在执行工具调用也可能在处理用户输入时阻塞。注册中心需要区分“进程活着”和“Agent 可以接收新任务”是两件事。Agent-Reach 的注册中心在 etcd 之上做了一层语义扩展数据存储大致是这样的结构{ agent_id: intent-classifier-v2, host: 10.0.3.21:9001, capabilities: [intent_classification, slot_filling], accepted_message_types: [text, structured_intent], status: ready, load: 0.35, version: 2.1.0, last_heartbeat: 1737000000000, max_concurrency: 16 }这里尤其是capabilities和accept_message_types两个字段是路由模块判断“消息该往哪送”的关键依据。status字段比简单的healthy更细化——ready表示可以接收任务busy表示在途任务已满draining表示正在优雅退出、不再接收新任务。这些状态由 Agent 主动上报同时注册中心也做被动探测两者结合来判断一个 Agent 的真实可用性。心跳设计上有个容易被忽略的坑心跳频率和过期时间必须成比例。如果 Agent 每 5 秒心跳一次注册中心 15 秒未收到心跳就判定下线那偶尔一次 GC 停顿或者网络抖动就会造成假下线。实践中我建议心跳间隔和失活时间的比例设为 1:10比如心跳 3 秒失活阈值 30 秒给偶发长耗时留足空间。同时心跳里要带上负载信息这样路由模块看到的永远是最新数据而不是上一次注册时的静态状态。2.2 触达模式同步调用、异步消息与事件流的取舍Agent 之间的通信模式直接影响整个系统的复杂度和用户体验这是 Agent-Reach 里我花最多时间权衡的部分。没有银弹只有基于场景的选择。同步调用用于“必须拿到结果才能继续”的场景比如意图识别 Agent 完成后编排 Agent 需要立刻拿到识别结果再决定下一步动作。这种模式最简单用 HTTP/gRPC 就能实现但问题是它天然耦合调用方和被调用方的生命周期。如果下游 Agent 处理一次请求需要 30 秒LLM 调用往往如此上游连接就占着 30 秒在流量上来之后连接池会迅速耗尽。异步消息用于“发完就不管结果了”的场景。Agent 把任务丢进消息队列由下游 Agent 自己消费处理完再回调通知。这种模式解耦效果最好适合流水线式的 Agent 协作数据清洗 Agent 处理完一批数据丢到队列里特征提取 Agent 消费再丢到下一个队列。缺点是端到端时延比同步高而且排查问题时需要追踪消息的流转轨迹对可观测性要求更高。事件驱动用于事件触发的 Agent 协作场景。比如用户产生了一条新工单工单创建事件被广播多个订阅了该事件的 Agent 同时开始工作——一个是分类 Agent一个是大纲生成 Agent还有一个是情绪分析 Agent它们互不依赖各干各的。这种模式在系统里扩展性最好但也是幂等性挑战最大的场景因为事件往往会被重复投递。Agent-Reach 把这三种模式统一抽象为“触达协议Reach Protocol”同一个消息格式通过不同的投递语义来实现。消息头带delivery_semantics字段值为at_least_once、at_most_once或exactly_once由发送方声明自己的诉求路由层根据这个字段选择对应的底层机制。这里我想强调工程上不要追求绝对的 exactly_once——分布式系统做到真正的精确一次成本极高95% 的场景用 at_least_once 加幂等消费就能覆盖剩下的才需要引入分布式事务或者基于消息唯一 id 的去重表。2.3 可靠性设计超时、重试、熔断与背压Agent 系统里最典型的事故就是级联超时。上游 Agent 调下游 Agent下游 Agent 又要调另一个 Agent任何一个环节响应慢了整个链路都在等。Agent-Reach 从三个层面控制这种风险。第一层是超时预算。每次消息在请求里带上deadline时间戳每一跳都检查剩余时间。比如整条链路预算是 10 秒路由 Agent 处理花了 2 秒那么投递到执行 Agent 时执行 Agent 最多只能花剩余时间的一半——剩下的要留给自己返回结果和上游接收的超时余量。这样每一跳都有限制不会出现某个环节无脑等到超时才往上传。第二层是重试策略。不是所有失败都值得重试。对“下游忙碌暂时没响应”这类瞬时故障重试是有意义的对“消息格式错误”“Agent 版本不兼容”这类确定性失败重试只会放大问题。Agent-Reach 的重试模块按错误类型分类默认策略是瞬时错误最多重试 3 次采用指数退避加抖动初始间隔 100ms系数 2抖动 50ms确定错误直接进入死信队列等待人工介入。第三层是熔断与背压。如果一个下游 Agent 持续失败不能再继续往它那里发消息了。Agent-Reach 内部维护每个下游 Agent 的滑动窗口错误率连续一段时间错误率超过 50%或者响应时延超过阈值熔断器就打开但注意不是彻底关闭——熔断器打开期间每隔几秒放一个探测请求过去探测成功后逐步恢复流量。这层逻辑像一个依赖保护网保证一个 Agent 出问题不会拖垮整个编排链路上的所有上游。后面我会在实操部分给出具体的熔断参数配置和代码示意这里先理清思路可靠性不是某一个单一机制而是超时预算、重试、熔断、背压四件事的联合设计。2.4 可观测性触达链路可视化多 Agent 协作系统排障之所以难是因为没有“链路”这个概念。一个任务从进入系统到最终完成中间可能经过 5 个 Agent、3 次消息流转、多次重试。如果每一步的日志都是散的排查一个失败任务往往要查十几个服务实例的日志。Agent-Reach 在消息投递的每一跳都注入 Trace Context这借鉴了 OpenTelemetry 的思路。每个任务进来时生成一个全局的 trace_id每次 Agent 之间触达都记录 span数据统一上报到 Agent-Reach Console。在 Console 上你可以看到这样的信息粒度任务从 Agent A 路由到 Agent B 用了多少毫秒消息在队列里积压了多久才被消费重试发生在哪一跳、为什么重试某个 Agent 的耗时分布轮廓P50 / P95 / P99 分别是多少每个 Agent 当前在途任务数、队列深度、失败率我自己的切身体会是有了链路数据之后故障定位时间从“小时级”降到“分钟级”。以前一个消息丢了要找半天现在直接看链路就知道是 B 收到了没处理完还是 B 压根没收到这完全是两个方向的排查路径。3. 核心模块的实操实现3.1 Agent 注册与心跳维护代码拆解注册模块我用 Python 实现因为团队侧栈是 FastAPI接入 SDK 用 Python 最顺。Agent-Reach SDK 给 Agent 提供register方法内部逻辑分三步填写元数据、建立租约、启动心跳协程。import asyncio import aiohttp from agent_reach import AgentSDK sdk AgentSDK( registry_urlhttp://registry.agent-reach.internal:2379, agent_idintent-classifier-v2, capabilities[intent_classification, slot_filling], accepted_message_types[text, structured_intent], ) async def heartbeat_loop(): while True: await sdk.report_status(statusready, loadcompute_current_load()) await asyncio.sleep(3) async def main(): await sdk.register(ttl30) asyncio.create_task(heartbeat_loop()) # 业务启动逻辑...这里有几个需要特别说明的细节。第一register的时候传的ttl30不是一次心跳的间隔而是租约有效期也就是说注册中心最多容忍 30 秒没有心跳超过就认为 Agent 失联。如果心跳间隔是 3 秒那么允许连续 10 次心跳丢失才判定下线这个冗余是必要的。第二SDK 内部会自动检查注册中心返回的租约剩余时间当剩余时间低于某个阈值比如 8 秒时下一次心跳会携带renew操作而不是简单的heartbeat。如果 Agent 因为长 GC 或者其他原因超过 30 秒没续租注册中心删除了这条记录Agent 心跳续租时会收到lease_expired响应。SDK 捕获到这种情况后会主动重新注册而不是让 Agent 一直带着失效身份在系统里裸奔。第三健康状态一定要分为“服务可用”和“业务可用”。Agent 进程活着但它的模型接口可能因为上游限流不可用。所以心跳上报时status必须是真实处理能力反馈而不是默认 toldready。我在实战中就是靠这个字段避免了很多无效路由。3.2 智能路由与负载均衡策略路由模块负责根据消息的目标 Agent 标识或者能力描述决定把消息发送给哪个实例。最简单的方式是轮询但 Agent 场景轮询的缺点很明显不同 Agent 处理同一个消息的耗时差异很大轮询会让快的等慢的整体吞吐被最慢实例拖死。Agent-Reach 采用基于权重的负载均衡权重由三个因子实时计算实例当前负载load值越低权重越高响应时延滑动平均值最近 5 分钟的 P50 时延越低权重越高失败率滑动窗口最近 1000 次请求的失败率越低权重越高路由伪代码如下def select_target_candidates(message): candidates registry.query( capabilitiesmessage.required_capability, statusready, versionmessage.target_version, ) weighted_candidates [] for c in candidates: load_score max(0, 1.0 - c.load) latency_score 1.0 / (1.0 c.p50_latency_ms / 1000) failure_score 1.0 - c.failure_rate weight load_score * 0.5 latency_score * 0.3 failure_score * 0.2 weighted_candidates.append((c, weight)) return weighted_random_select(weighted_candidates)这个公式是我在几个项目迭代后定下来的load是最直接的信号占比最高时延代表综合性能也包括了网络开销失败率是最保守的信号如果有实例连续出错权重会快速下降。三个因子的权重分配可以按实际场景调整但核心思想是避免单一指标带来的盲区。另外路由模块必须处理版本兼容问题。很多 Agent 更新后旧版本就不再兼容了如果路由还按能力去匹配可能匹配到旧版本实例导致处理异常。所以 Agent 注册时除了capabilities还要带compatible_versions信息路由查询条件必须同时过滤版本。3.3 可靠投递与确认机制投递模块是 Agent-Reach 的重头戏它决定消息是否“真的到达了”目标 Agent。我们默认使用at_least_once语义配合目标 Agent 的幂等消费来保证最终效果。具体实现上Agent-Reach 使用的是 Redis Stream 作为默认消息中间件。选择 Redis Stream 而不是 Kafka 或者 RabbitMQ是出于两点考虑一是部署简单很多团队本来就有 Redis二是 Stream 天然支持消费者组、待处理消息列表和消息确认机制足够满足 Agent 场景的量级。投递流程如下发送方 Agent 调用sdk.send(message)SDK 将消息写入目标 Agent 对应的 Stream 队列目标 Agent 的消费者通过XREADGROUP读取消息目标 Agent 处理完业务后调用sdk.ack(message_id)确认完成若消息在pending列表里超时未被确认投递模块会再次把这条消息加入消费队列这里最需要注意的坑是确认必须发生在业务处理成功之后而不是“收到就确认”。我之前见过一个团队把 ack 放在消息接收函数的第一行看起来消费很快实际上业务失败时消息已经确认丢了数据再也找不回来。正确的做法是仅在业务成功处理完成之后调用 ack同时整个处理函数包一层异常捕获任何异常都视为未确认让消息回到重试队列。消息幂等键的设计同样关键。每个消息携带idempotency_key目标 Agent 在消费时先去去重表检查是否处理过这个 key。这个 key 可以是业务逻辑里的唯一业务编号比如工单 ID、用户会话 ID而不要用消息 id——因为同一条业务消息在超时重试时生成的消息 id 不同但业务编号相同幂等性要靠业务编号判断。3.4 同步调用模式下的请求追踪实现虽然异步消息覆盖了大部分场景但总有一些交互需要同步拿到结果。Agent-Reach 在异步消息之上封装了一层“同步 RPC 模式”发送方发送消息后挂起等待SDK 内部监听目标 Agent 的回调通知通过 request_id 对消息做关联。async def sync_call(agent_id: str, payload: dict, timeout: float 10.0): request_id uuid.uuid4().hex result_future asyncio.get_event_loop().create_future() pending_requests[request_id] result_future await sdk.send( agent_idagent_id, payloadpayload, request_idrequest_id, return_topicsdk.get_callback_topic(), ) try: result await asyncio.wait_for(result_future, timeouttimeout) return result except asyncio.TimeoutError: pending_requests.pop(request_id, None) raise TimeoutError(f调用 {agent_id} 在 {timeout}s 内未返回)这个实现里有几个细节值得说。第一pending_requests必须是一个带锁的字典因为回调协程和调用协程在不同任务里运行。第二超时后一定要清理pending_requests否则请求量一大内存会持续上涨这个泄漏很隐蔽排查一次耗时很久。第三回调消息里必须带request_id不能只靠agent_id关联因为同一个 Agent 可能并发处理多个请求。同步 RPC 模式还有一层保护逻辑当调用协程超时返回后目标 Agent 仍可能继续执行完并回传消息。所以 SDK 收到迟到的回调时会先查pending_requests发现已经超时移除就直接丢弃。但这不代表业务可以完全无视超时因为超时返回并不等于目标 Agent 没处理所以必须由业务根据request_id做幂等或补偿。4. 常见问题与排查技巧实录4.1 Agent 注册了但路由一直匹配不到这类问题发生的频率很高典型现象是 SDK 日志显示注册成功但发消息时路由模块报“无可用目标”。原因通常不是注册中心的问题而是路由查询条件太严格。排查时我会让团队按这个顺序检查注册的capabilities是否和消息请求的required_capability完全一致。如果注册用的是intent_classify请求用的是intent_classification必然匹配不到。这类问题最好在 Agent SDK 注册时做能力 name 的校验或者引入一个共享的枚举常量包不要两边手写字符串。status是否为 ready。如果 Agent 注册后一直没有发送心跳或者上次心跳时负载过高被标记为 busy路由会自动跳过它。版本兼容声明。Agent 注册时的compatible_versions是否包含当前请求的消息版本。版本不匹配也会导致不可达。检查方法也是一句话在 Agent-Reach Console 里搜目标 agent_id看当前注册的完整元数据。如果元数据正常再看 Agent-Reach 的日志确认路由阶段输出没有候选结果的告警而不是投递失败。4.2 消息重复消费引发重复执行这是 at_least_once 投递语义下最常见的问题。现象是下游 Agent 收到了同一条任务两次导致重复调用模型 API 或者重复写库。解决思路不是改变投递语义而是让消费端幂等。我推荐两层方案消息消费前检查幂等键。用一个 Redis 集合记录最近 24 小时已处理的消息 key处理前先SISMEMBER判断。消费中涉及的外部副作用写库、调外部接口本身就支持幂等。比如写库用业务唯一约束外部接口用请求方生成的幂等 token。排查这类问题时先别急着改代码。到 Agent-Reach Console 上看消息轨迹如果消息确实投递了两次查看第一次消费时有没有 ack如果消息 pending 超时未确认投递模块会重新入队这种情况表面上看是重复实际是第一次消费其实失败了。所以重复消费排查的第一条永远是第一次到底成功没有没有成功就不叫重复叫及时重试。4.3 同步调用超时和异步队列积压如何权衡一个常见的发愁场景是下游 Agent 处理一次任务就要调用大模型接口单次耗时可能 5 到 15 秒。同步调用模式时间太长容易触发上游超时异步队列模式号码又不敏感一个任务发进去半天不回。我的建议是采用半同步半异步的混合模式。用户等待的核心路径用同步 RPC但不要等一次大模型的完整耗时而是让下游 Agent 先回一个“已接收”的中间响应处理完成后再通过 WebSocket 或者轮询接口推送最终结果。这种模式在 Agent-Reach 里实现起来比较简单同步 RPC 返回的是目标 Agent 的任务受理回执而最终结果通过事件通知或者回调地址传给发送方。队列积压问题也要从上游控制。如果队列深度持续上涨不要只加消费者先看是单条消息处理时间太长还是消息总量突增。前者需要优化下游 Agent 的调用链路比如是不是可以用缓存是不是部分任务可以合并后者才考虑扩容消费者实例。只加消费者不开上游控制最终只会把压力转嫁给下游模型接口。4.4 多 Agent 协作里的上下文传递问题这个坑在实操中出现频率极高而且不像前几个那么明显。多个 Agent 协作时每个 Agent 除了要处理自己的业务载荷往往还需要共享一份全局上下文比如用户信息、会话历史、中间结果集。Agent-Reach 推荐的做法是上下文和业务消息分离业务载荷走消息通道全局上下文统一存到 Context Store消息里只带context_id。{ message_id: a1b2c3, context_id: ctx-8899, type: task, payload: { query: 帮我写一篇周报 } }目标 Agent 收到消息后用context_id从 Context Store 拉取上下文处理完毕再把增量上下文写回去。这样有两点好处一是消息体体积会小很多不会因为上下文太大被消息队列限流二是上下文不是消息的一部分即使消息重试也不会把历史脏数据再次带上。有个容易出现数据竞争的场景多个 Agent 并发修改同一个 context 的字段。Agent-Reach 的 Context Store 提供字段级版本号写回时携带base_version如果版本不一致说明被其他 Agent 改了SDK 会返回冲突信号由调用方决定使用最新版本重读再改还是追加写而不是覆盖写。这个设计能避免“数据清洗 Agent 覆盖了情绪分析 Agent 刚写入的标签”这类事故。5. 踩坑记录与实战体会5.1 三个容易被忽略的工程细节第一个是消息体大小限制。Agent 之间的消息如果包含超长文本比如整份文档直接塞进 Redis Stream 会导致内存暴涨和网络耗时增长。我踩过这个坑之后在 SDK 里加了自动分块逻辑消息体超过 256KB 时拆成多段目标 Agent 侧按 message_id 和 sequence 组装。这个逻辑业务方无感知但大大减少了单条大消息导致的队列阻塞。第二个是重试风暴。如果同一个上游 Agent 对多个下游 Agent 同时发起同步调用且都采用默认的指数退避重试当下游 Agent 集体故障时重试请求会像雪崩一样叠加。规避方案很简单给每个 Agent 的消息设置一个全局max_total_retries并且在 SDK 层面对同一 agent_id 的并发重试做限制一般建议最大并发重试 8 个其他直接快速失败回到队列等待人工。第三个是时钟假设。路由模块计算耗时、超时预算必须用单调时钟time.monotonic()而不是墙上时钟time.time()。一个是墙上时钟可能被 NTP 跳变另一个是容器环境下系统时间可能不准确。排查过一台机器因为时间回拨导致所有消息瞬间超时的现象后我把这个写进了团队的代码规范。5.2 Agent-Reach 的后续扩展方向这个项目目前跑通了注册发现、可靠投递、同步 RPC、链路观测四条主线。后续我想继续加两块一是 Agent 的自动伸缩建议基于历史消息流量和队列深度给出扩缩容提示二是多租户隔离让不同的业务团队在同一个 Agent-Reach 集群上注册自己的 Agent但彼此数据完全隔离。如果大家在自己的项目里实现了类似方案欢迎多交流。多智能体系统离“好用”还有很长的路要走但先把通信这层地基打好后面的事情会顺很多。