构建多智能体触达层:Agent-Reach核心设计与生产实践
去年九月我接手了一个多智能体调度平台二十多个功能各异的 agent 节点分布在好几台机器上有做 OCR 的有做向量检索的还有几个专门跑大模型推理。第一周我就被一个问题反复折磨任务队列里经常出现“等待某个节点回应”的假象明明节点已经死了调度器还在傻傻地往它那里派活。后来我把这套“谁能接活、现在能不能接、接到哪去”的能力抽出来做成了一个独立组件代号 Agent-Reach。下面不会画架构图只讲我是怎么一步步把它落到生产环境里的以及那些踩过之后才明白的坑。如果你也在搞多智能体协作或者手上有几十个 AI 服务需要互相调用这些内容应该能帮你省点时间。1. 为什么多智能体系统总卡在“互相找到”这一步1.1 一次没有触达层的调度事故印象特别深的一次事故是用户报障说票据识别任务阻塞在“等待增强服务”的状态。我打开状态面板任务已经发给“增强服务-Agent”了那个 agent 一直不回。查了一圈它其实在昨天凌晨发版本的时候被误杀了但是调度器不知道。当时我们用的是静态配置文件里面写了两个实例地址其中一个旧实例地址早就没人监听另一个新实例地址又因为上线顺序的问题没被写进另一台机器的配置里。结果消息发到两个地址一个黑洞一个正常但没人消费。我们几个人翻日志翻了快一个小时最后登录那台机器才发现进程根本不存在。这个事故让我意识到一个很基本的问题调度器眼里的“可用”和现实中的“可用”严重脱节。配置里存在不代表这个 agent 还活着它还活着不代表它现在能接任务。我们需要一层东西专门回答“我能触达哪些 agent它们现在到底在哪里、状态如何”。这套能力直接决定了所有上层调度、路由和重试策略的可靠性。1.2 传统服务发现思路在多智能体场景下的错位一开始我自然想到了微服务那套方案注册中心加健康检查服务名加负载均衡。做了个 Demo 之后发现传统服务注册中心和多智能体系统之间有几个很微妙的差异。第一智能体是有状态的。它不是无状态 API 服务它可能有会话上下文、正在执行的任务、临时锁定的资源。所以“可用”不是一个静态标签而是一个会随业务状态瞬间变化的属性。服务注册中心里的“健康”是“进程还活着”但多智能体关心的“可触达”是“现在能不能接这个类型的活”。第二智能体之间的交互是多样化的。A 找 B 不只是调用一个接口可能是委派一个任务可能是问个问题等回答也可能是让 B 在完成后主动回调。这意味着触达层需要支持的不只是“找到一个地址”还包括后续的确认、租约、回调地址校验。第三拓扑变动极其频繁。智能体会按任务扩缩容会临时休眠会在处理完当前任务后主动退出。微服务注册中心默认节点是长期稳定的而多智能体场景下节点来来走走才是常态。所以我把 Agent-Reach 定位成一个独立于消息传输层的“触达层”而不是又一个服务注册中心。它要解决的核心问题不是“网络怎么通”而是“谁值得被触达”。2. Agent-Reach 的核心设计触达不等于通信2.1 触达层和消息通道的边界划分最早我考虑用一个 Redis 做注册表再加点 TTL 就完事。后来发现不行因为触达层关心的东西比注册表多得多。同一个 agent 在训练阶段不能接活在等待人类确认时不应该再接新任务在本地资源被占满时也不该被路由。这些状态如果全部交给注册表记录会让它变成一个频繁更新的状态库最后并发一高就崩。后来我们把边界划得很清楚Agent-Reach 只负责回答“这个 agent 在不在、在哪、能干什么、多久没活了”不负责传输消息内容也不负责做业务路由决策。消息传输还是交给你现有的 NATS、gRPC 或者 HTTP路由策略还是业务方自己写。打个比方触达层是通讯录加在线状态消息通道是电话网络。很多项目失败就是因为把通讯录和电话网络粘在一起每次换一种通信协议就得把所有触达逻辑重写一遍。这个边界划分的好处是Agent-Reach 可以非常轻。它不引入完整消息中间件不需要保证业务消息不丢失它只需要在正确的时间提供正确的触达信息。哪怕某一次状态更新晚了几百毫秒也只影响任务调度不会影响已经在跑的业务消息。2.2 注册表数据结构按“能力声明”而不是“服务名”索引第二版我重新设计了注册表的数据结构。传统微服务里一个服务名对应一组实例但多智能体系统里一个 agent 节点可能同时具备多个能力比如同一台机器上跑了一个进程它既能 OCR 也能做文本去重还能调用本地模型推理。如果按服务名索引就得把一个 agent 注册成三个服务管理起来特别别扭。Agent-Reach 用的是“能力声明”模型。每个 agent 注册时带一个 capabilities 数组内部为每个能力建立倒排索引。查询的时候按能力点名查比如/query?capocr.extract返回所有声明了这个能力且当前可用的 agent 列表。数据结构是这样的字段类型说明agent_idstring全局唯一进程重启后不变endpointstring消息投递地址通常是 base URL 或 NATS subjectcapabilities[]string能力声明统一小写点分格式lease_ttlint租约秒数心跳续约窗口versionstring注册信息版本防止旧进程覆盖新注册metadatamap[string]any额外信息比如输入输出 schema、区域、模型类型实际注册时报文长这样{ agent_id: agent-ocr-7f3a, endpoint: nats://agent-hub:4317/ocr, capabilities: [ocr.extract, nlp.textclean], lease_ttl: 9, version: 2025-03-12T10:00:00Z, metadata: { input_schema: image/jpeg|image/png, max_payload_mb: 24, region: cn-east } }按能力索引有一个隐藏好处上层 agent 在寻找协作对象时不需要知道具体节点名它只需要描述“我要什么能力”和“有什么限制条件”。这和多智能体协作的本质是一致的协作靠能力匹配不靠名字匹配。2.3 心跳、租约和状态机该怎么判断“它挂了”健康检查的实现我改过很多版最终用的是租约加三态状态机而不是简单的在线/离线两态。每个 agent 注册时会携带 lease_ttl之后每隔一段时间主动心跳续约。注册中心如果发现某个 agent 超过租约时间没心跳不会立刻把它摘除而是先把状态改成 Probation观察期。在观察期内查询接口仍然可能返回它但会在响应里标记不稳定。如果连续错过心跳超过 evict_after 时间才正式标记为 Evicted并对外发布“节点摘除”事件。为什么不用两态因为分布式环境下的网络毛刺太常见了。机器 GC 暂停、交换机抖动、跨地域网络拥塞都会导致一次心跳晚到几百毫秒。如果一超时就摘除会产生大量误摘误摘的代价比摘除延迟高得多摘除延迟只是让新任务稍微等一下误摘则会让已经派发的任务直接失败。最终参数我定的是心跳间隔 3 秒lease_ttl 9 秒连续超过 18 秒没心跳才摘除。直观理解就是允许你漏掉大约 5 次心跳。正常情况下摘除中位时间在 12 秒左右对调度系统完全够用。还有个细节是摘除不能直接删除记录要保留一段“影子记录”。因为可能已经有任务在途对方的回调地址还是这个节点。保留影子记录配合后续的补偿路由能避免消息直接掉进黑洞。影子记录保存 5 分钟后会自动清理。3. 从零实现 Agent-ReachGo NATS 的最小内核3.1 为什么是 Go为什么是 NATS第一个版本我图快用了 Python 加 FastAPI存储直接丢 Redis。二十个节点的时候没什么问题后来某段时间每天要跑五千多个批处理任务频繁查询一个大 key开始出现明显卡顿。第二版我下决心重写选了 Go 加 NATS。选择 Go 的原因很简单并发模型干净goroutine 处理心跳和查询非常省心而且性能足够好。单实例内存存储加读写锁处理几百个 agent 的心跳和查询毫无压力。不用 etcd 是因为我需要的不是强一致的分布式键值存储而是一个高性能查询加简单订阅通知的组合。NATS 刚好提供这两样发布订阅事件、轻量、单机部署也方便。如果你已经有一套 etcd 或者其他注册中心也没必要换Agent-Reach 的核心逻辑是可以移植的关键是接口抽象。3.2 注册、查询、心跳的核心接口我把接口收敛成 5 个注册、心跳、注销、查询、订阅事件。HTTP API 用来给非 Go 服务接入SDK 方法用来给 Go agent 内嵌使用。type AgentInfo struct { ID string json:id Endpoint string json:endpoint Capabilities []string json:capabilities LeaseTTL int64 json:lease_ttl Version string json:version Metadata map[string]any json:metadata } type Registrar interface { Register(ctx context.Context, info AgentInfo) error Heartbeat(ctx context.Context, agentID string) error Unregister(ctx context.Context, agentID string) error Query(ctx context.Context, capability string) ([]AgentInfo, error) Subscribe(ctx context.Context, eventType string, handler func(event Event)) error }注册逻辑的核心是按 agent_id 做幂等覆盖。这个看似简单的决定后面救了无数次命。Agent 重启、网络重连、容器重建都会带着同一个 agent_id 再次注册。如果做不到幂等注册表里就会同时存在多条记录其中旧的 endpoint 早就不可达。幂等覆盖规则是先查旧记录如果旧记录的 version 比新记录的 version 新就拒绝覆盖防止旧进程把新进程的状态盖掉。func (r *Registry) Register(ctx context.Context, info AgentInfo) error { r.mu.Lock() defer r.mu.Unlock() if old, ok : r.agents[info.ID]; ok { if old.Version info.Version { return ErrStaleRegistration } } caps : normalizeAndValidate(info.Capabilities) r.agents[info.ID] AgentRecord{ AgentInfo: info, Capabilities: caps, LastSeen: time.Now(), State: StateActive, } for _, c : range caps { r.capIndex[c] append(r.capIndex[c], info.ID) } return nil }这段代码省略了索引去重但核心逻辑就是这些覆盖、更新能力索引、记录最后心跳时间。查询的时候先用能力名查倒排索引再过滤掉非 Active 状态最后把 endpoint 等必要信息返回给调用方。3.3 健康检查与摘除循环的坑实现 sweep 循环的时候我踩过一个特别低级的 bug。最早用 map 存储sweep 协程和 heartbeat 请求同时跑没加锁结果跑压测直接 panic。后来改成 RWMutex查询用 RLock注册和 sweep 用 Lock。优先级更高的方案是分片 map但当前场景用不到。摘除循环的伪代码很直白func (r *Registry) sweepLoop(ctx context.Context, every time.Duration) { ticker : time.NewTicker(every) for { select { case -ctx.Done(): return case now : -ticker.C: r.evictExpired(now) } } } func (r *Registry) evictExpired(now time.Time) []string { r.mu.Lock() defer r.mu.Unlock() var evicted []string for id, rec : range r.agents { if rec.State StateEvicted { if now.Sub(rec.EvictedAt) shadowTTL { delete(r.agents, id) r.removeFromIndex(id, rec.Capabilities) } continue } idle : now.Sub(rec.LastSeen) switch rec.State { case StateActive: if idle time.Duration(rec.LeaseTTL)*time.Second { rec.State StateProbation r.publish(EventAgentUnstable{AgentID: id}) } case StateProbation: if idle evictAfter { rec.State StateEvicted rec.EvictedAt now r.publish(EventAgentEvicted{AgentID: id}) evicted append(evicted, id) } } } return evicted }摘除事件发布之后所有订阅者要做两件事把本地缓存里这个 agent 的地址标记为不可用并触发一次补偿路由。过了影子租约后记录才真正删除。如果 agent 在被标记为 Evicted 之后又回来心跳了处理方式是恢复 Active 状态并清理过期索引同时发布一个恢复事件让调度器重新把它纳入候选。4. 实测记录50 个智能体互触达的完整复盘4.1 测试场景与指标测试配置是三台 8C16G 的普通服务器跑了 50 个 agent 进程其中 30 个模拟 OCR、文本抽取20 个模拟知识库检索。Agent-Reach 注册中心单独部署在一台 1C2G 的机器上。每个 agent 启动时先注册然后每 3 秒心跳运行时随机执行任务并定期上报状态。我关注四个指标指标含义注册收敛时间从第一个 agent 启动到所有节点查询可见的时间P95触达成功率查询返回可用 agent 地址后实际发送测试消息并收到确认的成功比例故障摘除时间kill 掉一个 agent 后到它不再被查询结果返回的耗时中位数错误摘除次数在运行 1 小时内被误摘除的活跃节点数第一轮跑下来触达成功率是 97.6%听起来还行但距离生产可用差得远。而且错误摘除次数竟然有 7 次这意味着有 7 个本来很健康的 agent 被无脑踢出了候选它们已经在处理的任务全部需要人工干预。4.2 测试中踩到的三个典型坑第一个坑是心跳参数太激进。初始配置是 1 秒一次心跳3 秒租约。某台机器在跑本地模型推理时发生了一次 2 秒左右的 GC 停顿心跳没发出去结果 3 秒租约一过就被标记成 Probation再来一次停顿就直接 Evicted。后来我把心跳间隔放到 3 秒租约放到 9 秒摘除阈值放到 18 秒。摘除时间确实变长了但误摘次数从 7 次降到了 0 次。对智能体系统来说误摘的代价远远高于延迟摘除这个交换非常值得。第二个坑是重复注册导致的双活记录。Agent 进程重建后会带着同一个 agent_id 重新注册。第一版代码没有做幂等覆盖注册表里同一个 agent_id 对应两个 endpoint查询结果同时返回新旧两个地址其中旧地址实际已经不可达。压测脚本随机选地址选了旧地址就超时触达成功率直接掉了两个点。修法就是我前面说的按 agent_id 覆盖并且用 version 防止旧进程覆盖新注册。第三个坑是能力名大小写不统一。测试里有些 agent 注册的是OCR.Extract查询用的是ocr.extract字符串不匹配明明节点活着却查不到。我们最后统一要求能力名必须是小写点分格式注册时自动归一化并校验只能包含小写字母、数字和下划线。这件事看起来小但在真实环境里经常发生因为不同团队写代码的习惯不一样。4.3 调优之后的效果调优后的数据我很满意指标调优前调优后触达成功率97.6%99.8%注册收敛时间 P95约 3.4s1.8s故障摘除时间中位数约 4.2s11.6s误摘次数/小时70触达成功率提升的关键不是网络更好了而是彻底消灭了“选中一个已经不存在的地址”的情况。故障摘除时间变长在业务上是可以接受的因为调度器本来就不是毫秒级响应多等几秒并不会造成任务丢失。真正不能接受的是把一个还活着的节点误杀那会导致已经派发的任务瞬间失败损失比延迟大得多。5. 从 Agent-Reach 看更高阶的问题和 Agent 互联协议的配合5.1 触达层与语义层的分工做完这版之后我开始关注当前 AI Agent 互联领域的一些协议类似 MCP、A2A 这类关注语义交互的标准。它们解决的是“两个 agent 之间怎么描述工具、怎么交换信息、怎么完成任务”核心在语义层。但无论语义层做得多好底层始终要回答“这个 agent 现在在不在、地址在哪、能干什么”。Agent-Reach 补的正是这个底层。在我的集成实践里流程是两条线并行。语义层定义任务描述、请求格式、响应结构Agent-Reach 维护节点在线状态、能力索引和触达信息。当一个编排器准备调用某个 agent 时先通过 Agent-Reach 查询出一个候选列表按 metadata 中的区域、模型类型、资源限制过滤再交给语义层发起请求。这样语义层可以保持干净不需要关心节点发现和存活判断。5.2 在 LLM 编排场景中使用 Agent-Reach 的注意点和 LLM 编排器集成的时候有几个细节值得注意。第一能力声明里尽量带上输入输出 Schema。Agent-Reach 本身不理解语义它只能告诉你“这个节点活着并且声明了某能力”。如果你在 metadata 里放了输入输出 Schema上层 LLM 做工具选择时就能精准过滤避免把一个明明只能处理图片的 agent 分配给文本任务。第二回调地址必须动态查询不能写死。很多任务要求 agent 完成后回调调度器如果这个回调地址是在启动时从环境变量读的节点漂移之后就会失效。我加了一个 lookupByTask 接口在发起任务时记录目标 agent 的当前 endpoint回调前再确认一次目标状态如果不稳定就走重路由。第三把 Probation 状态反馈给大模型。给 LLM 编排时我会把“该服务当前不稳定”作为上下文注入让模型选择其他可用节点。这不是拍脑袋而是实测很有效模型知道某个节点不稳定后会主动避开它整体任务失败率下降明显。触达层的状态信息对大模型的决策质量有直接影响。6. 留给自己的三条经验6.1 先做单机可跑再谈分布式如果让我重新开始我会先做一个最简单的版本内存 map 加 HTTP 心跳先跑通 20 个节点。不要一上来就上 etcd、Raft、分布式一致性。多智能体触达最初的痛点不是一致性而是可见性。单节点注册中心在几百个节点的时候完全够用Agent-Reach 单实例撑几百个 agent 没有任何问题。真到了需要横向扩展的规模再考虑把注册表拆成一致哈希分片也比一开始就引入复杂分布式协议划算。6.2 触达层要和业务解耦最早我犯过一个错误把“正在处理的任务数”也放进注册表每次心跳都带。结果注册表变成了状态库更新频率是纯心跳的好几倍并发一高就开始卡。有一次压测时注册中心 CPU 占用突然飙到 90%查半天发现是因为几十个 agent 每 1 秒上报一次负载而我用了一个大 map 存所有字段查询时还要遍历计算平均值。后来我把实时负载拆到独立的 metrics 接口触达层只保留“在线、可用、有能力”这几个枚举状态。查询时如果需要负载信息业务层自己去拉 metrics。这样 Agent-Reach 的职责更纯粹性能也更稳。6.3 把“不可用”当正常状态而不是异常多智能体系统里一个节点不响应是常态不是异常。所以从第一天起就要为摘除设计补偿流程发送前查询、发送后确认、超时后重路由、对不可达地址做负面缓存。测试调优的时候也别老想着怎么让节点永不掉线那不可能。把“不可用”当作一个正常分支处理系统的整体稳定性反而上来了。现在我甚至把告警规则改成了只有“大面积不可用”或者“摘除后恢复失败”才报警单个节点掉线不再打扰值班人员。这是我这次重构里最重要的一条体会。