最近在复盘Agent-Reach这个项目,脑子里全是当初被AI Agent碎片化折腾到崩溃的场景。可以这么说,Agent-Reach不是我们设计出来的,而是被现实逼出来的——当团队里同时跑着七八个基于不同框架、不同部署方式、不同数据格式的AI智能体时,你总得有个东西让它们"够得着"彼此、够得着上游系统。这个平台的核心就一件事:把散落在各处的Agent统一接入、统一调度、统一对外提供能力。如果你也在为多Agent管理头疼,或者正准备搭一套Agent基础设施,这篇复盘应该能给你省下不少试错的时间。
1. 为什么Agent要"Reach":AI Agent碎片化带来的现实阵痛
1.1 我们遇到的Agent管理困境
事情的起因并不复杂。从去年开始,团队陆续上线了好几个Agent类项目,有做客服意图识别的、有做文档自动分类的、有做内部工单处理的,还有两个是基于开源框架二次开发的对话机器人。半年时间,线上Agent数量从1个变成9个,问题也随之而来。
首先是接入方式五花八门。有的Agent用FastAPI封装成HTTP服务,有的是Celery异步任务,有的干脆是一个常驻的Python进程监听消息队列。外部系统想调用这些Agent能力时,对接成本特别高——每接一个新Agent就要写一套新客户端,参数格式、鉴权方式、返回结构全都不一样。其次是没有统一的状态视图,哪个Agent还活着、哪个已经假死超过两小时,完全看运气。更棘手的是Agent之间相互调用,A要调用B的能力时根本没有标准协议,只能用"硬编码+私有HTTP接口"这种脆得离谱的方式凑合。
我们当时也评估过市面上现成的方案。老实说,那时候已经有一些Agent编排框架,也有不少可观测性产品能做Agent追踪。但这类方案要么太重,绑定特定的运行时和框架,要么只解决"编排"而不解决"接入"——对我们这种已经有大量存量Agent、需要渐进式纳管的团队来说,直接套上去等于推倒重来,业务风险完全不可控。
1.2 Agent-Reach的核心定位:做Agent的"总调度台"
所以Agent-Reach的定位从一开始就很清晰:它不是一个Agent开发框架,也不是一个业务流程编排引擎,而是一层"调度基础设施"。每个Agent无论底层用什么框架写的,只要实现一套极简的接入协议,就能注册到Agent-Reach上,由Agent-Reach统一对外暴露能力、统一做路由、统一管理生命周期。
打个比方,把Agent想象成各种家电——有洗衣机、有烤箱、有空气净化器,品牌不一、操作面板五花八门。Agent-Reach做的事情就是把它们全部接到一个智能插座面板上,面板后方统一走一套标准电路。你不需要关心烤箱到底是220V还是110V,面板会处理好。
这个定位帮我们避免了好几个大坑。它意味着我们不需要重写业务代码、不需要强迫所有Agent统一框架,只需要在Agent侧加一个很薄的适配层,Agent-Reach侧做好注册、路由、调度、监控四件事。
2. Agent-Reach整体架构:三层分段 + 统一协议
2.1 三层架构拆解
Agent-Reach的架构我画过很多遍,核心就是三段。
存储层是Agent元数据注册中心,负责记录每个Agent的能力描述、接入地址、当前状态、版本信息。这一层我们没有用太重的东西,就是etcd加一层本地缓存,原因后面细说。
调度层是Agent-Reach的主服务,承担三块职责:接收上层请求并解析意图、根据路由策略找到合适的Agent、把请求转发出去并等待结果回传。同时它还负责Agent的注册管理、健康检查和状态聚合。
接入层是Agent侧的适配组件,叫Agent-Adapter。它不是一个单独部署的进程,更多是一个SDK加一个可选的小型Sidecar,帮助各种技术栈的Agent快速实现协议接入。我们的Agent有的是Python写的、有的是Node.js写的,SDK不需要覆盖所有语言,协议本身就是最简单的HTTP + JSON,任何语言都能直接实现。
这里特别要说一下为什么不用消息队列做核心通道。一开始我们确实考虑过把调度层和Agent之间用Kafka串联,但很快放弃了这个方案。原因有两个:一是很多Agent本身就是同步响应的场景,比如在线客服机器人,用户等不了异步返回;二是消息队列会给部署和运维带来额外复杂度,和"轻量对接"的目标冲突。最后我们采用"同步HTTP调用为主 + 可选异步转同步"的混合模式,调度层通过HTTP把请求送达Agent,Agent如果能立刻返回就立刻返回,如果处理耗时较长就返回一个task_id,调度层轮询结果。
2.2 统一接入协议长什么样
协议设计是整个项目的地基,也是最费心思的部分。我们定义了一套叫AgP(Agent Protocol)的轻量协议,核心就三个接口:
- 注册接口:Agent启动后向Agent-Reach上报自身信息,包括能力ID、能力描述、接入URL、版本号、健康检查路径。
- 调用接口:Agent-Reach向Agent转发用户请求,请求体统一为
{"request_id": "...", "payload": {...}},响应体统一为{"status": "success" | "error", "data": {...}, "error_code": "..."}。 - 健康检查接口:Agent-Reach周期性调用该接口确认Agent存活,Agent也可以主动上报心跳。
这三个接口加在一起,协议规范文档不到十页。刻意控制协议规模是有意的——协议越小,别人接入意愿越高。回想一下我们当时的教训:团队早期曾经设计过一套"完善"的协议,包含鉴权、链路追踪、限流、序列化格式协商一大堆内容,结果推广三个月只有两个Agent接入,因为大家觉得太重了,为了接一个协议还要先学会一整套SDK。
后来我们把协议砍到极简,鉴权、限流这些全部下沉到Agent-Reach网关层统一处理,Agent侧只做最核心的注册、调用、健康检查三件事。接入成本从三天缩短到两小时。
2.3 注册与发现机制:为什么用etcd而不是Redis
关于注册中心的技术选型,这里值得多说几句。我们做Agent-Reach的时候,很多人第一反应是"用Redis不就行了",但权衡之后我们选了etcd。
Redis的问题是缺少强一致性和watch机制。Agent注册信息属于元数据,如果主从切换时数据丢了,可能导致调度层把请求路由到一个已下线的Agent上。etcd天生支持key的watch监听,Agent注册的状态变化(上线、下线、心跳超时)可以实时推送给调度层,不需要轮询查询。
但etcd也有一个很大的坑——它不适合高并发直读。所以我们做了两级缓存:etcd只存元数据,调度进程本地维护一份基于watch同步的内存态Agent列表。读请求全走本地内存,写操作直接写etcd,由watch机制同步到所有调度节点。
结构上,注册键的设计采用三级命名空间:
/agents/{agent_id}/meta:Agent描述信息,包括能力ID、地址、版本/agents/{agent_id}/status:Agent运行状态,由心跳协程定期续期/agents/{agent_id}/tags:自定义标签,用于灰度发布和分组路由
每个Agent启动后先PUT/agents/{agent_id}/meta写入描述信息,然后启动一个定时任务每10秒向/agents/{agent_id}/status写入带TTL(30秒)的租约,最后在/agents/{agent_id}/status上创建watch。调度层只需要watch/agents/前缀,任何一个Agent上下线都能在秒级感知。
3. 关键模块实现:注册、路由、任务回传的实战细节
3.1 Agent健康检查与三态判定
健康检查是整个系统的"体温计",设计不好会出两种情况:误杀(把正常Agent摘掉)和误活(把已经挂掉的Agent留在路由表里)。我们在这块吃了不少亏,最后总结出一套三层判定机制。
第一层是进程级心跳。Agent通过注册时返回的lease_token,每10秒向Agent-Reach续租一次。这一层只能证明"Agent进程还活着",不能证明"Agent服务可用"——比如进程在但线程池满了,心跳照样续得上,可请求就是处理不动。
第二层是HTTP健康探针。Agent-Reach每30秒调用一次Agent的健康检查接口,并规定健康检查接口必须在5秒内返回200。这一层能够暴露大多数服务不可用的情况,但我们很快发现,健康检查接口本身也可能被写成"假健康"——有些Agent的健康检查只是return OK,根本没有检查依赖的外部数据库或模型服务是否可用。
第三层是调用成功率统计。Agent-Reach实时统计每个Agent近5分钟请求的成功率,如果成功率低于60%,即使前两层都正常,也会把该Agent标记为degraded状态,路由权重自动降级。这一层虽然反应最慢,但最真实,因为它度量的是用户真实请求的结果。
这三层状态最终汇总为confirmed(健康)、suspect(疑似异常)、dead(确认下线)三种。只有confirmed状态的Agent会进入路由候选列表。
3.2 意图路由:规则优先,向量召回的"混合路由"
路由是Agent-Reach最核心的功能,也是最容易翻车的地方。我们的第一期实现是纯规则路由——每个Agent声明自己的能力ID,调度层根据请求里的skill字段精确匹配能力ID。这个方案简单直接,上线运行毫无压力,但很快暴露出一个问题:上游业务方根本不知道Agent的能力ID应该填什么。
举个例子,有个工单系统想调用"查重"能力,我们内部的能力ID定义是doc_dedup,但工单系统的研发想当然地传了document_similarity_check,路由直接匹配失败。你当然可以说"文档里写过规范",但现实是系统对接方经常不看文档,真实的调用就是会五花八门。
所以Agent-Reach第二期的路由升级为"规则 + 语义"的混合模式。每个Agent在注册时除了填能力ID,还要填一段自然语言能力描述,比如"对两篇文档进行相似度计算,返回重复率"。Agent-Reach启动时把能力描述离线向量化,存入内置的向量索引。在线请求来了以后,先用规则层精确匹配能力ID或别名(alias),匹配不到的时候,就把请求文本和参数描述做向量召回,取Top3候选再做一次关键词加权排序,最终确定路由目标。
这里有个成本小技巧:向量化不需要自建模型服务,直接用现成的Embedding接口就行。Agent-Reach设计时留了一个embedding_provider配置项,可以指向任意兼容的Embedding服务。
路由策略这一块强烈建议加一个"人工兜底"机制。Agent-Reach对路由置信度低于60%的请求不会自动转发,而是返回一个候选列表,由上游调用方二次确认。宁可多一次交互,也不能在业务上把请求送错地方——送错地方的代价远比多等几百毫秒大。
3.3 任务分发与结果回传的可靠性设计
同步调用模式下,Agent-Reach转发请求后必须等待Agent返回。但Agent处理时间可能很长,长到超过上游调用方的超时阈值。这里我们借鉴了异步任务系统的经典模式,设计了task_id回传机制。
整个调用链分三种情况:
- 快速响应(小于等于阈值,默认5秒):Agent直接返回完整结果,Agent-Reach透传给上游,调用链结束。
- 慢响应(大于5秒):Agent先返回一个
{"status": "pending", "task_id": "..."},Agent-Reach收到后返回给上游一个等待中的信号,同时后台协程开始轮询Agent的结果查询接口(/tasks/{task_id})。 - 推送回传(可选):如果Agent支持回调,Agent-Reach会提供一个回调地址,Agent处理完后主动POST结果到Agent-Reach,由Agent-Reach通知等待中的上游。
实现的时候需要非常注意一个细节:请求去重与幂等。调度层的请求可能因为网络超时被重试,Agent就可能收到两个一模一样的请求,所以Agent-Reach为每个请求生成全局唯一的request_id,并在转发给Agent时透传。Agent侧需要按request_id做幂等处理,相同request_id的请求在接收端只执行一次。
同时我们在Agent-Reach侧设计了请求状态机:pending -> running -> succeeded / failed / timeout。所有状态变更写一条审计日志,事后排查问题全靠这张状态流转表。
4. 性能与稳定性:上线后踩过的几个大坑
4.1 健康检查风暴打垮注册中心
这是Agent-Reach上线后发生的第一起生产事故,印象极其深刻。
事件发生在一次版本升级后,大量Agent进程在短时间内重启,每个Agent启动时都会向etcd写元数据和状态键,同时建立watch。注册中心同一时刻涌入了上千次写请求,etcd的写延迟从正常的2毫秒直接飙升到2秒,而Agent的续租请求也在不断超时,超时后触发Agent侧的重试,重试又进一步加剧了写压力——典型的健康检查风暴。
排查链路是这样的:先看Agent-Reach日志发现大量续租失败,返回错误码是etcd的"etcdserver: request timed out",然后看etcd的metrics发现write request的P99已经打满,接着抓一个Agent进程的goroutine发现它正在以指数退避重试续租,而且重试不是从10秒一次变成20秒一次,而是从10秒直接跳到500毫秒——因为这个Agent SDK里有个bug,退避算法实现反了。
各种原因叠加,最终形成了恶性循环。这个事故让我们做了三个修改:
- 注册中心从所有Agent直写etcd改为"Agent先写Agent-Reach内置代理,由代理做本地聚合批量写etcd",把底层写压力降了一个量级。
- Agent侧的重试策略统一改用固定间隔+抖动(fixed interval + jitter),禁止指数退避中的激进重试。
- 健康检查的续租TTL从30秒放宽到90秒,给处理链路留足缓冲。
那次事故之后我学到一个原则:任何涉及大量Agent的系统,都必须假设Agent会同时重启。上线发布、扩容、迁移这些动作引爆的并发,远比日常流量高得多,所有限流、聚合、缓冲措施都要围绕这个假设来设计。
4.2 语义路由冷启动期的"答非所问"
混合路由上线后第二周,我们收到了一个诡异反馈:有一个请求系统坚持要调用"文本审校"能力,Agent-Reach连续三次把请求路由给了"文本摘要"Agent。从向量相似度看,这两个能力确实接近——都处理长文本,参数里都有content字段。但业务语义差别其实很大,一个要挑错别字,一个要提炼中心思想。这个路由结果对业务来说完全不可用。
复盘下来,问题出在冷启动期的Embedding质量。刚上线的Agent描述文本一共就七八段,整个向量索引小得可怜,语义召回的区分度约等于零。我们临时把路由策略调成了"规则优先,向量辅助",没有规则命中就直接返回失败,让上游明确报错而不是默默送错,业务影响止损。
之后我们做了一个长期改进:从路由日志中不停收集被业务方人工纠正过的请求,存成"(query, correct_agent_id)"样本对,定期离线微调路由的排序权重。这个机制运行了两个月,向量路由的准确率从72%提升到94%。如果你想做同样的功能,建议从一开始就把路由日志的结构化存储做好,不然后面想收集正确的是无法弥补的。
这里也给你一个实操建议:语义路由一定要有"置信度护栏"。不要相信任何一次向量召回的绝对分数,要相信"相对差距"——第一名和第二名如果分数差距很小,就应该判为低置信度,宁可拒绝服务也不要瞎猜。
4.3 长时间任务与代理超时的冲突
Agent-Reach上线一个文档批量处理功能后,发现大量请求在Agent那边执行成功了,但上游收到的却是失败。日志对不上:Agent-Reach明明是收到了Agent的成功响应,但超时机制已经把请求标记为失败并返回给上游了。
这是同步调用架构最经典的问题——Agent侧的耗时超过了Agent-Reach配置的上游超时时间。Agent-Reach在等待Agent响应时设了一个默认15秒超时,但文档批量处理这种任务,正常执行就要30到60秒,15秒完全是拍脑袋拍出来的。
修复分两步。先做了快速处理:允许按能力ID配置不同的超时时间,文档处理类的超时调到120秒,在线问答类保持5秒。第二步就是前面提到的task_id机制上线,长任务全部走"先返回pending再轮询"的路线,让Agent-Reach和上游都不再死等。
还有配套的熔断逻辑:如果某个Agent近1分钟内连续超时超过5次,Agent-Reach会把该Agent标记为degraded并摘除路由权,防止请求继续涌入已过载的Agent。摘除后需要等待连续3次健康检查成功才恢复,避免"好了马上又被压死"的抖动。
5. 生产部署与落地经验
5.1 最小化部署拓扑
Agent-Reach对部署的要求并不高,生产环境我们会用三台2核4G的机器跑Agent-Reach主服务,两个节点组成etcd小集群,再加一台独立的监控节点。整体资源占用不到8核16G,这个配置可以支撑几百个Agent实例。
主服务是无状态的,节点之间通过etcd做状态协商。每个节点都会加载Agent列表到本地内存,任何节点收到请求都可以独立完成路由决策。这里需要注意的是Agent-Reach主服务之间不做请求转发——如果请求打到节点A,但节点A发现某个Agent只有节点B的内存态里有,节点A会直接向Agent侧的地址发起HTTP调用,不需要经过节点B中转。保持调用链路简洁,故障链路才不会复杂。
Agent-Reach的配置中心我们直接用了YAML文件加env覆盖,没有引入额外的配置中心组件。配置本身很简单:etcd地址、超时时间、路由置信度阈值、健康检查频率。真正的动态配置(比如Agent的权重调整、路由规则变更)走etcd,不会走配置文件。
5.2 可观测性:Agent-Reach自己也是一号Agent
调试分布式Agent系统,最怕的是"黑盒"。所以Agent-Reach在可观测性上投入了不少精力,日志、指标、链路追踪三件套都上了。
- 日志:所有路由决策必须留下完整上下文,包括request_id、源系统、目标Agent、路由模式(规则命中还是语义命中)、置信度分数。我强烈建议把路由决策和业务结果分开打日志,纯路由日志的量很大,但价值极高,训练路由排序样本全靠它。
- 指标:Agent-Reach暴露Prometheus标准指标,核心监控项包括各Agent的请求成功率、P99延迟、路由命中率、注册中心watch延迟、待处理task数量。我们自己会在Grafana上做一个"Agent健康总览"面板,一屏看完所有Agent三态分布。
- 链路追踪:接入OpenTelemetry的trace,Agent-Reach在创建request_id时同时生成trace_id并透传给Agent侧,Agent侧如果接入了SDK会自动上报子span,最终形成"上游 -> Agent-Reach -> 目标Agent"三段完整链路。
如果你连OpenTelemetry也不想引入,那至少做到前两项。没有链路追踪顶多排查慢一点,没有结构和上下文的路由日志,问题排查几乎是盲人摸象。
5.3 灰度策略:新Agent接入怎么做到"不炸业务"
最后讲讲Agent-Reach在推进业务接入时的灰度策略,这块经验非常实用。
新Agent注册到Agent-Reach后,默认状态是candidate而不是active。candidate状态的Agent不会接收线上流量,但Agent-Reach可以手动通过"影子请求"测试它——把请求同时复制一份发到线上Agent和candidate Agent,只返回线上Agent的结果,但是把两个Agent的响应差异记录下来供人工比对。
这个影子模式我们也叫"陪你跑三天"。新Agent至少陪跑三天,并且满足三个条件才能转正:影子请求的成功率在99%以上、平均响应时间不超过旧Agent的两倍、人工抽查差异报告确认输出质量不比旧Agent差。
灰度放量的另一个技巧是权重渐变。Agent-Reach支持按能力ID设置Agent组的流量权重,比如第一天旧Agent 90%新Agent 10%,第二天变成70%对30%,直到稳定后切到100%新Agent。实测下来这个"长尾切换"比一次性切换安全得多,即使新Agent有边缘case问题,影响面也在可控范围内。
写在后面的实际体会
Agent-Reach这套东西做下来,我个人最大的体会是:Agent基础设施的复杂性并不在AI本身,而在工程治理。模型能力再强,一个请求在两个Agent之间绕不清方向、三分钟没有返回值,用户感知到的就是"这系统不行"。所以做这类平台,首要目标永远是让接入简单、让路由可控、让故障可查,这三个目标做到了,AI能力才能安全地释放给业务。
如果你也正在做类似的Agent管理平台,我的首要建议是先把协议做薄、做稳,不要在一开始追求功能大而全。协议稳定了、接入顺了,后面加调度策略、加语义路由、加灰度能力都是水到渠成的事。Agent-Reach到现在迭代了快一年,当初定下的三个接口一次都没有改过,选型时的克制,会在后面省下很多很多麻烦。