先说个场景。我们团队有一阵子同时维护着几十个微服务,后来又陆续塞进去十多个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(endpoint="http://reach-server:8080", agent_name="dispatcher-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( endpoint="http://reach-server:8080", agent_name="weekly-report-agent", heartbeat_interval=10, enable_reachability_probe=True ) 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, timeout=15): route = client.route(intent=intent, payload=payload) 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}, timeout=timeout ) 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?hours=1 # 查看每个Agent的可达性分数趋势 curl http://reach-server:8080/api/v1/reachability/trend?agent_name=weekly-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 |
| 路由查询P99 | 12ms |
| 路由查询P50 | 3ms |
| 可达性探测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.9,TTL自动延长到45秒;如果分数波动较大或低于0.7,TTL会缩短到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_ttl | 30s | 路由缓存基础TTL |
heartbeat.adaptive_min | 2s | 自适应心跳最短间隔 |
heartbeat.adaptive_max | 20s | 自适应心跳最长间隔 |
probe.interval | 5s | 主动探测间隔 |
probe.timeout | 2s | 单个探测超时时间 |
probe.fail_threshold | 3 | 连续失败判定不可达 |
fence.enable | true | 网络分区围栏摘除开关 |
route.semantic_topk | 5 | 语义路由候选集上限 |
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里的Deployment,Pod被重新调度是很常见的事。Pod IP会变,但Agent每次启动时会把自己当前地址注册到Reach。听起来没问题,问题出在调用方的路由缓存上。
调用方在时刻A查到某Agent的Pod地址是10.20.1.5:9595,Reach把这个地址连同路由结果一起缓存。Pod漂移到新节点,新地址变成10.20.2.9:9595,Agent重新注册后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_pattern=None)方法,可以订阅Reach内部的路由决策事件。打开watch后,每来一条路由请求,控制台都会打印“意图→候选列表→最终选择→分数明细”。有一次业务方反馈“某个意图老是路由到另一个团队的低优Agent”,我开着watch盯了十分钟就发现了问题:那个高优Agent注册在非prod环境,名字里带了个-staging后缀,被意图匹配时的tag检索漏掉了。这种问题只靠看统计报表根本定位不了,必须得有实时决策流可以观察。
如果你现在也在搞多Agent编排,我的建议是:先别急着上重型的编排框架,把注册、语义匹配、可达性评估这层“触达层”做好,后面再加什么元学习、自动规划都会顺很多。Agent-Reach这种轻量中间件只是一个起点,但它把最让人头疼的“谁在哪、能干什么、现在能不能用”这三个问题固定成了一套可以持续演进的协议,这点我觉得是它给我最大的价值。