Agent-Reach:智能体服务发现与语义路由中间件实战
发布时间:2026/10/7 11:42:47 锦皓数字建站

先说个场景。我们团队有一阵子同时维护着几十个微服务后来又陆续塞进去十多个LLM Agent、自动化工单Agent、数据巡检Agent。最开始每个Agent都自己去订阅消息队列手工配置一堆目标地址互相之间能调用但每次有人改端口、换实例光同步配置就能耗掉小半天。后来我抽了一个周末写了个小工具专门解决“Agent之间怎么互相找得到、敢不敢调、调不通了怎么办”这三个问题就叫 Agent-Reach。这篇文章不聊愿景就讲我为什么造它、核心机制是什么、怎么五步接进现有系统以及我实际跑下来踩过的坑和调优参数。适合正在搞多Agent平台的后端开发、架构师以及研究多智能体协作又不想被复杂框架绑架的朋友。1. 为什么我会造这个轮子从服务注册中心到Agent触达的演进先说结论Agent-Reach本质上是一个轻量级的智能体服务发现与语义路由中间件但如果你只把它当成Nacos的平替那就低估了Agent场景的复杂度。传统微服务注册中心解决的是“我这台机器IP和端口是什么、我还活着吗”而Agent要解决的是“我这个智能体能干什么、适合处理什么任务、现在的负载和可达状态怎么样”。这两者差的不是一星半点。1.1 传统注册中心解决不了Agent的三个层面第一个层面是语义缺失。服务注册中心里注册的是order-service这种像URL一样的东西但Agent之间的调用往往不是order-service/updateOrder这种写死的接口而是“帮我生成一份上周销售趋势摘要”这样带意图的描述。注册中心可以匹配IP但不能匹配“能力”。第二个层面是生命周期差异。普通服务实例相对稳定挂了就重启位置基本固定。但Agent经常是任务驱动的可能白天副本扩到十个晚上缩到两个甚至一个Agent在处理完某个任务后临时迁移到离数据更近的节点。这要求注册信息频繁变更而注册中心的缓存策略通常不适合这种高频漂移。第三个层面是健康检查的粒度。传统健康检查只要进程活着、TCP能连上就行。但Agent“活着”不代表“可用”可能模型服务已经超时可能该Agent正在处理一个长任务而无法接受新任务。健康检查必须结合负载、存活状态、队长度来综合判断。1.2 三个真实痛点与其等方案不如自己动手去年年中我们的多Agent系统开始出现三个特别烦人的问题。第一找得到但不敢用。Agent A能通过K8s DNS解析到Agent B的地址但到底能不能调用成功完全靠猜。有一次调度系统把一个分析请求路由到了一个正在做模型微调的Agent上结果请求等了半分钟直接超时。后来发现那个Agent在注册时写了“支持文本分析”但当时它所有算力都拿去训练了根本没有余量响应外部请求。第二用得上但不知道该调谁。有七八个Agent都声称自己“能处理Excel表格”有的擅长格式转换有的擅长数据透视实际效果差别很大。调用方只能一个接一个试试错成本非常高。这本质上是缺少语义级的描述和筛选能力。第三断线之后路由还在。Agent容器漂移后注册信息更新不及时路由表里还保留旧地址调用方拿到一个失效的IP重试三次之后还是失败整个工单链路就卡死了。我们当时的排查方式只能在日志里翻效率极低。这三个问题加在一起我们意识到需要的不只是一个注册表而是一套带“触达能力”评估的Agent网络协议。Agent-Reach就是在这样的背景下从最简单的注册心跳开始一步步长出来的。1.3 为什么没用Istio或消息队列直接改我也想过要不要直接用服务网格或者消息代理。对比下来这几个方案都有不适合的地方。Istio这类服务网格解决的是东西向流量治理它管的是网络层不知道业务意图也不理解“Agent能力描述”这种应用层语义。你可以在Istio上做金丝雀、重试、熔断但你没法让它根据一个Agent的输入输出schema来决定该把请求路由给谁。RabbitMQ、Kafka这类消息代理则相反它只负责传递消息不负责“决定这段消息该由哪个Agent处理”。虽然可以用Topic做粗粒度的分组但一旦Agent数量多起来Topic层级会变得越来越难维护而且消息代理本身不支持“动态探测某个Agent当前是否真正可用”这种语义。所以Agent-Reach最终定位成“控制面轻量路由”的组合它不接管实际的业务数据流只负责能力注册、语义路由决策和可达性探测业务消息仍然走你原来的调用通道Reach只告诉你“该调谁、怎么调、这个目标现在是否够得着”。这样已有的Agent代码不需要大改接入成本能压到最低。2. Agent-Reach的核心设计能力声明、语义路由与可达性探测Agent-Reach有三个核心设计分别是能力声明Capability Manifest、语义路由Semantic Routing和可达性探测Reachability Probe。这三个模块必须配合使用缺一个都会让整个体系回到“拿着通讯录却不敢打电话”的状态。2.1 能力声明给每个Agent一份“简历”每个Agent接入Reach网络时先要提交一份Capability Manifest相当于给Agent做一份“简历”——明确告诉网络自己能干什么、接收什么输入、输出什么格式、依赖哪个模型服务、当前可以接受多少并发。这份manifest不是固定不变的Agent可以根据自身状态动态更新。下面是我在项目里实际用过的一个简化版manifest示例{ agent_name: weekly-report-agent, capabilities: [ { id: generate-weekly-summary, name: 生成周报摘要, input_schema: { type: object, properties: { kpi_data: { type: string, description: JSON格式的关键指标数据 }, language: { type: string, enum: [zh, en], default: zh } } }, output_schema: { type: object, properties: { summary: { type: string } } }, tags: [report, analysis, kpi], priority: 80 } ], dependencies: { llm_model: gpt-4o-mini, max_concurrency: 5 }, health_endpoint: /healthz, metadata: { team: data-ai-team, env: prod } }设计这份manifest时我踩过的最大教训是不要用单纯的“标签列表”来描述能力一定要带输入输出schema。只有标签的话排序逻辑没法做无法判断哪个Agent更适合某个请求。有了schema之后Reach可以计算输入数据的字段覆盖度进行粗粒度的匹配评分这也为后面的语义路由提供了基础。2.2 语义路由把“调用指定Agent”变成“声明你的意图”第二个模块是语义路由。调用方不需要指定“我要调 weekly-report-agent”只需要告诉Reach自己的意图比如“生成本周销售周报摘要”Reach会在所有已注册的Capability里做匹配。匹配过程分两步先做关键词和tag的粗筛选出候选集再做schema兼容性校验和权重计算。关键词匹配这一步很简单直接基于ES或者内存中的倒排索引都可以。关键是第二步Reach会结合几个维度来打分能力名称和意图的余弦相似度如果配置了向量模型输入schema与载荷字段的覆盖率该Agent当前的可达性分数当前pending任务数量manifest里声明的priority权重实际接口用起来大概是这样的from agent_reach import ReachClient client ReachClient(endpointhttp://reach-server:8080, agent_namedispatcher-agent) result client.route( intent生成本周销售周报摘要, payload{ kpi_data: [{region: east, sales: 12345}, ...], language: zh }, prefer{reachability_score: 0.5, latency: 0.3, affinity: 0.2} ) # result 里会带有 target_agent、address、route_id、score 等信息 print(result.target_agent) # weekly-report-agent简单说调用方从“我要连谁”变成了“我要做什么”Reach负责找到最合适的Agent。这个转变让Agent网络变得非常灵活新增一个能力更强的Agent后不需要改任何调用方代码Reach会自动把新Agent的分数排上去。随着实例变化路由决策也会动态调整。2.3 可达性探测区分“活着”和“够得着”只做注册和发现还不够。Agent-Reach第三个核心是可达性探测这也是它区别于普通注册中心的关键设计。我最初的实现是每个Agent每隔10秒上报一次心跳。只要心跳正常就认为Agent可用。但生产环境很快告诉我这个想法太天真。心跳正常只能说明Agent进程没挂但可能出现两种“活着却用不了”的状态Agent正在执行一个恶性的长任务CPU已经打满对外请求排队长达几秒钟。Agent依赖的下游数据库连接池耗尽进程没死但实际能力已经退化到无法响应新任务。所以在心跳之外Reach还会主动发起轻量探测。探测的路径不是随便ping一下而是调用Agent注册时声明的health_endpoint或者发一条带trace标记的极小消息让Agent回包。Reach会记录三个指标响应时间RTT、探测成功率和Agent自报的负载值。综合算出一个reachability_score范围0到1。这个分数的计算方式我调过好几版最后稳定在这样一个经验公式reachability_score 0.4 * health_success 0.3 * (1 - 当前pending队列饱和度) 0.2 * (1 - 归一化RTT) 0.1 * (1 - 错误率最近5分钟)公式不复杂关键是这些数据来源必须是实时的而不是依赖Agent自己上报。比如RTT就是Reach直接发起一次HTTP GET到/healthz测出来的不能由Agent在心跳报告里写“我很健康”。如果依赖自报遇到故障时Agent根本来不及更新状态数据参考价值就大打折扣。2.4 为什么这三个机制必须合在一起可能有朋友会问能力声明、语义路由、可达性探测拆开来用好像也都能用拆开来确实能用但合在一起才是一个闭环。能力声明是“静态描述”告诉Reach能做什么语义路由是“动态匹配”根据调用方的意图选出候选可达性探测是“实时修正”确保选出来的目标当前确实能用。没有可达性探测语义路由经常会把请求发给一个已经积压成山的Agent没有语义路由能力声明只是一堆没人用的元数据没有能力声明可达性探测也不知道该探测什么、该对齐哪个服务的健康标准。这个闭环也是Agent-Reach名字的由来不仅让Agent“可见”还要保证“可触达”。我始终认为多Agent系统里面“谁能做”和“现在能不能做到”是两码事优秀的编排系统必须同时回答这两个问题。3. 部署与上手五步把现有Agent接进Reach网络下面进入到实际操作环节。我们以一套不太复杂的场景为例已经有了三个Python编写的Agent其中两个跑在K8s里一个跑在裸机环境现在想让它们通过Agent-Reach互相发现并调用。3.1 第一步拉起Reach控制面Reach控制面目前可以用Docker Compose方式启动依赖一个后端存储。我建议存储先用Redis后续实例数超过500再考虑加持久化的SQL后端。下面是我们的docker-compose片段services: reach-server: image: agent-reach/reach-server:0.9.2 ports: - 8080:8080 environment: REACH_STORAGE_DRIVER: redis REACH_REDIS_ADDR: redis:6379 REACH_ROUTE_CACHE_TTL: 30 REACH_PROBE_INTERVAL: 5 REACH_PROBE_TIMEOUT: 2 depends_on: - redis redis: image: redis:7-alpine ports: - 6379:6379启动后访问/health能看到控制面状态。这里要提醒一句Reach控制面本身是无状态的路由数据全部存储在Redis里所以控制面可以水平扩展。但我们不建议一开始就搞多副本单副本反正在生产环境跑了好几个月也没有成为瓶颈。3.2 第二步安装SDK并初始化客户端Agent接入Reach不需要改了现有RPC框架只需要在进程里多一个库。Python SDK安装很简单pip install agent-reach然后在一个Agent的主进程里做初始化from agent_reach import ReachClient client ReachClient( endpointhttp://reach-server:8080, agent_nameweekly-report-agent, heartbeat_interval10, enable_reachability_probeTrue ) client.start() # 启动后台心跳和探测响应服务这个start()做了什么它会在你所在机器上开一个小的HTTP服务默认端口是9595监听/healthz和/probe两个路径。Reach的探测请求会直接发到这两个路径上。这里有个细节如果Agent跑在K8s里记得把9595端口暴露到Pod的生命周期探针里否则Reach控制面访问不到。3.3 第三步声明能力初始化之后调用client.register_manifest(...)提交能力声明。拿第二节那个JSON作为示例把内容读成dict传进去就行。import json with open(weekly_manifest.json) as f: manifest json.load(f) client.register_manifest(manifest)注册成功后控制面会返回一个manifest_version。这个值很关键。后面每次能力描述有更新比如新增了一个技能、调整了并发上限都要重新注册并且把版本号递增。不递增版本号的话Reach会认为还是同一份manifest旧的缓存不会失效很容易造成路由到过期能力的问题。3.4 第四步发起语义调用写一个dispatcher Agent作为调用方通过Reach找目标Agent。上面的client.route(...)方法已经演示过。实际项目里我们通常包一个更常用的函数def call_agent_by_intent(intent, payload, timeout15): route client.route(intentintent, payloadpayload) if not route: raise RuntimeError(no reachable agent for intent: %s % intent) # 这里的地址是Reach从注册表里查到的实际IP:Port # 业务请求还是走原来的HTTP调用Reach不代理业务流量 resp requests.post( http://%s/execute % route.target_address, json{task_id: route.route_id, payload: payload}, timeouttimeout ) resp.raise_for_status() return resp.json()route返回的对象里面有target_address这个地址是Reach根据被调Agent上报的“接收地址”动态生成的。如果被调Agent有多网卡或者Pod IP会变化这块需要额外做一点映射配置后面踩坑章节再展开。3.5 第五步观察与治理接入之后Reach提供了一组管理接口我日常用得最多的有三个# 查看网内所有Agent及其能力 curl http://reach-server:8080/api/v1/agents # 查看某条意图最近30分钟的路由日志 curl http://reach-server:8080/api/v1/routes/export?hours1 # 查看每个Agent的可达性分数趋势 curl http://reach-server:8080/api/v1/reachability/trend?agent_nameweekly-report-agent刚开始接的时候建议先只接入1到2个Agent跑几轮curl确认路由日志和reachability分数都正常再慢慢扩大规模。我们第一次直接把十几个Agent全接进来结果控制面负载快速增长排查起来非常麻烦。渐进式接入看着慢实际是最快的方式。4. 实测性能与调优路由缓存、心跳周期与网络分区Agent-Reach毕竟跑在业务链路里不是论文里的框架性能和稳定性必须有数据说话。这一章我给出我们集群的实测基准以及我调过几次的关键参数。4.1 集群性能基线测试环境是三台8核16G的虚拟机控制面单实例Redis单实例Agent实例数从20递增到100。业务调用方式是HTTP同步调用每个请求经过Agent-Reach做一次路由决策但实际业务流量不经过Reach转发。在100个Agent实例、每个Agent注册5个能力的规模下实测数据如下指标值注册平均耗时80ms路由查询P9912ms路由查询P503ms可达性探测CPU占用控制面1%心跳每秒消息数100实例10条路由缓存命中率96.7%这个数据背后有一个事实路由决策本身很快真正的小概率慢请求大部分是缓存过期后并发查询Redis造成的。所以调优重点不是控制面的并发能力而是怎么安排好缓存刷新和探测节奏。4.2 路由缓存TTL不能太长也不能太短Reach默认把每个“意图候选集”的路由结果缓存在内存里。TTL默认30秒。太短会让控制面频繁查Redis和重算分数太长又会让实时状态被缓存掩盖。我试过把TTL调到60秒路由查询P99确实下来了但出现了一次比较严重的“路由延迟”——一个Agent已经不可达缓存里仍然认为它可达导致部分请求在调用阶段失败。后来我把TTL调到10秒情况反过来控制面负载上升路由查询P99飙升到30ms以上。最终我们选择了自适应TTL如果某个能力的reachability_score持续大于0.9TTL自动延长到45秒如果分数波动较大或低于0.7TTL会缩短到5秒。这个策略在代码里配置起来也比较直接cache: base_ttl: 30 max_ttl: 45 min_ttl: 5 shrink_threshold: 0.7 grow_threshold: 0.9算下来的逻辑就是越不可用越要频繁刷新越稳定越能容忍长缓存。4.3 心跳周期不要对所有Agent用同一个值心跳是所有注册中心都有的机制但Agent-Reach的默认设计是“自适应心跳”。原因很简单有的Agent是核心链路必须秒级感知状态有的Agent是低优度的离线分析服务10秒上报一次也没有问题。统一用5秒或10秒要么浪费资源要么发现故障太慢。自适应心跳算法核心就一句根据过去N个周期的状态漂移程度来调整上报周期。状态稳定且reachability_score高慢慢把心跳间隔从10秒拉到20秒。状态不稳定比如RTT波动大、错误率升高间隔主动缩短到2到3秒。控制面接受到心跳后会把下一条心跳的建议间隔放在响应里Agent照做就行。这套机制在100个Agent实例下平均心跳间隔大约是7.8秒比固定5秒的方案节省了大概36%的心跳消息量。4.4 网络分区防止路由黑洞比较隐蔽的一个问题是网络分区。某个Agent所在的网络分区和Reach控制面断开它实际上已经不可达了但因为心跳包从Agent发不出来控制面会在超时后才移除它。这个时机通常要等好几个心跳周期期间路由会把请求发给一个黑洞。Reach的默认处理是“连续3次探测失败判定不可达”但如果正好赶上网络分区恢复判定又没生效会让Agent在分区两边的状态互相打架。我后来加上了一个fence机制当Reach发现某个Agent的实时探测失败但心跳仍然在收可能是分区导致探测路径拥堵但TCP心跳还能过会主动给目标Agent发一条“暂停接流量”的信号。相当于先把Agent从路由候选集里摘掉去观察它的自我恢复情况。这个信号不是强制关闭进程只是让Agent把对外的/execute入口暂时挂起避免调用方继续往里灌请求。这个机制在生产里帮我们避免了很多次因网络抖动引起的流量雪崩。4.5 调优参数参考表如果你也要在生产环境跑Agent-Reach下面是我最终沉淀下来的推荐参数覆盖了若干个关键节点。注意这些数值要结合你集群规模做调整不要盲抄。参数推荐值说明cache.base_ttl30s路由缓存基础TTLheartbeat.adaptive_min2s自适应心跳最短间隔heartbeat.adaptive_max20s自适应心跳最长间隔probe.interval5s主动探测间隔probe.timeout2s单个探测超时时间probe.fail_threshold3连续失败判定不可达fence.enabletrue网络分区围栏摘除开关route.semantic_topk5语义路由候选集上限semantic_topk是我后期加的参数。语义匹配如果候选集太大每次路由决策要同时计算很多个schema的覆盖率耗时和内存都会上去。限制前5个实际准确率并没有下降因为真正匹配的Agent通常就一两个。5. 我踩过的三个坑和修复思路这部分才是真正值钱的。Agent-Reach从初版到现在我在生产环境踩过不少坑其中三个最有代表性也最容易在其他人接类似系统时复现。5.1 坑一广播风暴从“一个Agent下线”到“整个控制面被打挂”现象很典型某个K8s节点在做滚动更新一批Agent同时下线又同时上线。正常情况下Reach只需要更新路由表即可。但在初版中只要任何一个Agent离线Reach就会向所有订阅了该能力变更的调用方推送一次全量路由表更新。当几十个Agent同时上下线时短时间内产生了上万条推送消息直接把Reach控制面CPU打满网卡丢包率飙升。排查链路是这样的先看到控制面CPU持续100%然后用tcpdump抓包发现Reach的8000端口出方向流量异常大追踪到推送逻辑时发现每次变更都执行了publish_all_route_snapshots()。代码里原本是想简化实现上来就广播全量路由快照结果在大规模Agent变动下变成了广播风暴。修复方式是在两个地方动刀。第一推送内容从全量路由表改成“增量变更事件”事件里只包含变化的Agent ID和新旧状态。第二推送频率做了合并窗口250ms内的事件统一聚合成一个变更集再推送。这样从“一次变化推一次”变成“一段时间内合并推一次”。上线后同一场景下推送消息总量下降了超过90%。5.2 坑二Manifest序列化不兼容老Agent悄悄被踢出路由现象是某一天数据平台的老Agent突然被Reach判断为“不可达”但进程日志显示它一直心跳正常。更奇怪的是这个Agent在业务高峰期前还能接到流量高峰期后突然就接不到了。排查的时候我先怀疑是探测挂掉了但手动curl它的/probe路径是完全通的。然后我回看Reach的日志发现注册接口在高峰期前后返回了MANIFEST_SCHEMA_MISMATCH。原来有同事升级了SDK版本新版SDK在注册时多写了一个manifest_schema_version字段而老Agent没有这个字段。Reach在更新路由缓存时拿新老版本的能力声明做了一次兼容性校验结果发现老Agent的schema版本号是null直接认为它不合法把它从候选集里移除了。这个坑的本质是注册中心如果在语义上过度严格就会误伤还没有升级的存量Agent。修复方式分两步。第一步manifest里必须显式带上schema_version字段默认值填1。第二步Reach在升级能力时做前后兼容判断如果新版本只是增加了可忽略的字段就不视为断兼容只有删除字段或改字段类型时才视为不兼容。这样老Agent不会因为缺字段被整体移除只会因为真正破坏性的变更被标记。5.3 坑三Agent漂移导致路由失效缓存里存了“僵尸地址”这个坑最隐蔽。我们的Agent是跑在K8s里的DeploymentPod被重新调度是很常见的事。Pod IP会变但Agent每次启动时会把自己当前地址注册到Reach。听起来没问题问题出在调用方的路由缓存上。调用方在时刻A查到某Agent的Pod地址是10.20.1.5:9595Reach把这个地址连同路由结果一起缓存。Pod漂移到新节点新地址变成10.20.2.9:9595Agent重新注册后Reach知道新地址。但缓存仍然抱着旧地址直到TTL过期。在高峰期如果Agent频繁漂移缓存里极可能长时间保留多个已失效地址调用方就会一直打到旧Pod轻则超时重则把错误请求打到已被新Pod占用的IP上这种情况比较少见但确实会发生。我修复这个问题的核心思路是路由结果缓存里不能只缓存Agent标识和流程分数还必须缓存“地址绑定版本”。每当Agent重新注册地址Reach给这个Agent生成一个新的addr_version。调用方拿到路由结果时会校验这个addr_version与自己缓存中的是否一致。不一致则立即失效并重新查询。另外Agent侧也要做配合。Pod漂移前最好先调用SDK里的client.drain()给Reach发一个“我要离开”的通知。Reach收到后立即把该Agent从路由表里摘掉并把新地址的查询压力转移到新的Pod之上。这个流程配合下来我们没有再遇到因为Agent漂移导致超过30秒的调用失败。最后再分享一个让我省了很多事的小技巧这些坑踩完之后Agent-Reach已经在我们后台稳定跑了两个多月。日常维护时我最常用的是一个调试模式SDK提供client.watch(intent_patternNone)方法可以订阅Reach内部的路由决策事件。打开watch后每来一条路由请求控制台都会打印“意图→候选列表→最终选择→分数明细”。有一次业务方反馈“某个意图老是路由到另一个团队的低优Agent”我开着watch盯了十分钟就发现了问题那个高优Agent注册在非prod环境名字里带了个-staging后缀被意图匹配时的tag检索漏掉了。这种问题只靠看统计报表根本定位不了必须得有实时决策流可以观察。如果你现在也在搞多Agent编排我的建议是先别急着上重型的编排框架把注册、语义匹配、可达性评估这层“触达层”做好后面再加什么元学习、自动规划都会顺很多。Agent-Reach这种轻量中间件只是一个起点但它把最让人头疼的“谁在哪、能干什么、现在能不能用”这三个问题固定成了一套可以持续演进的协议这点我觉得是它给我最大的价值。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。