ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent-Reach:构建高可用多Agent系统连接底座的核心设计

Agent-Reach:构建高可用多Agent系统连接底座的核心设计 我最早做多Agent系统的时候对“连接”这件事的理解非常天真。总觉得只要把每个Agent的接口地址整理成一张表格剩下的事就交给提示词和模型了。真正把项目从实验变成线上服务之后我开始认真设计一套叫 Agent-Reach 的独立底座才发现问题完全不是“能不能找到地址”而是“找到地址之后你能不能信任它、能不能知道它此刻有没有能力干活、能不能在它挂掉的时候马上绕开”。如果你也正在搭多Agent系统并且已经被“两个Agent经常断连”“一个节点重启就拖垮整条链路”这类问题折磨过我这篇就把 Agent-Reach 的架构思路、核心设计以及我踩过的坑完整拆开讲一遍。1. 为什么我放弃了“维护一份 Agent 通讯录”的做法1.1 手动维护代价低只是前三十天的事很多团队接触多Agent的第一反应是维护一份静态通讯录每个Agent一个名字、一个IP、一个端口谁要调用谁就查表。早期Agent只有三五个这个方案完全没毛病。但大概跑到十几个Agent、并且环境开始拆分成开发、测试、生产之后手工表就变成了一颗定时炸弹。我遇到过的真实场景是这样的某个负责图像搜索的Agent在夜里被发布系统重新调度了一次IP变了它自己也按要求把新地址写进了配置文件。但另一个调用方Agent的代码里还残存着旧IP于是每次执行任务都先跑到一个已经不存在的地址上等超时。日志里看不到明显的报错只看到任务一直pending。更麻烦的是这种故障往往不是全挂而是百分之二的请求失败看起来特别像正常抖动很难触发告警。Agent-Reach 要解决的问题就是把这个“通讯录”从静态变成动态。每个Agent启动时主动登记运行中持续报心跳当状态变化或者地址变动时Agent-Reach 会立即把新信息广播给所有需要的调用方。这个机制虽然多了一层依赖但换来的是“地址永远不过期”的确定性。1.2 真正需要的是“路由时可用”不只是名单里有静态通讯录还有一个很容易忽略的问题它只能告诉你“某个Agent曾经存在”完全无法回答“这个Agent现在还有没有能力干活”。多Agent系统里经常出现一种情况进程活着端口也通但Agent内部的业务队列已经堆了几万个任务或者模型服务的并发已经打满。这时你拿着地址去调用对方当然能建立连接但会一直排队或者频繁超时。从“能找到”这个视角看通讯录是正常的从“能不能在可接受时间内完成任务”这个视角看它已经不可用了。所以我对 Agent-Reach 的定义不是“地址簿”而是“路由决策时的可行集过滤器”。每个Agent不仅要注册自己的地址还要声明它当前的活力和就绪度Agent-Reach 在收到调用请求时会把这些实时状态作为过滤条件只把请求路由到真正可用的实例上。这个思路和负载均衡里的“健康检查”类似但多Agent环境里还要叠加一层“能力匹配”所以不能简单照搬传统注册中心。1.3 Agent-Reach 的边界它不是一个调度引擎在动手写代码之前我给自己画了一条红线Agent-Reach 不参与业务推理不做任务分发不决定“这个任务该给哪个Agent”。它只负责三件事。第一件事是注册与发现。Agent加入或退出时能自动被记录和感知。第二件事是连接管理包括心跳、租约、健康检查以及传输层的认证和加密。第三件事是路由与失败转移当一个Agent被标记为不可用Agent-Reach 能把请求导向备用实例或者返回一个明确的“无可用目标”错误。至于谁应该调用谁、怎么拆解任务、用哪个模型那是上层编排框架的事。把这个边界划清楚以后Agent-Reach 的定位就非常干净它是一个连接底座不是一个大脑。2. 核心抽象把每个 Agent 变成可发现、可验证、可路由的节点2.1 注册表是长期记忆端点只是一条索引Agent-Reach 的核心组件是一个注册表但它的数据模型比普通的“IP端口”要丰富得多。我给每个Agent一个全局唯一的 agent_id这个ID不随部署环境变化。一个Agent不管搬到哪台机器、换成什么端口agent_id 都不变变的只是 endpoints 里的具体地址。为什么一定要这样拆分因为多Agent系统里调用方最不希望在代码里写死IP。如果调用方记住的是 agent_id那么Agent迁移、扩容、缩容都不会影响调用方。Agent-Reach 内部维护一张“agent_id - 当前端点集合”的映射并且每次心跳都会刷新这个映射。这相当于给每个Agent做了一层稳定的逻辑标识把物理地址的波动全部隔离在底座里。我见过不少项目图省事直接用“服务名:端口”作为唯一标识刚开始确实简单但一旦需要灰度发布、A/B测试、多区域部署这种扁平命名就撑不住了。Agent-Reach 选择的模型是名称可以有多个但ID只能有一个所有路由都基于ID进行。2.2 三条协议注册、心跳、调用Agent-Reach 对外暴露的协议不多我把它收敛成了三条。第一条是注册协议。Agent启动后向 Agent-Reach 发送注册请求内容包含agent_id、能力列表、端点、认证信息。注册不只是一次性的Agent每次重启都要重新注册所以这条协议必须支持幂等。同一个agent_id重复注册时新的注册会覆盖旧的但必须返回一个版本号让Agent知道自己的注册是否被接受了。第二条是心跳协议。Agent按照固定间隔向 Agent-Reach 发送“我还在”的信号。心跳里不只有ID还应该包含一个简单的负载指标比如当前待处理任务数、最近一秒收到的请求数。Agent-Reach 可以利用这些信息判断是否要把某个实例从路由表里摘掉。心跳的间隔不能太短也不能太长我一般取10秒到30秒具体值取决于Agent对延迟的敏感度。第三条是调用协议。调用方并不直接去请求目标Agent而是先向 Agent-Reach 获取一个“路由凭证”。这个凭证里包含了目标Agent当前可用的端点、传输方式、有效期。拿到凭证后调用方再直接和目标Agent通信。加这一层中转看起来多了一次跳跃但好处是Agent-Reach 可以集中控制策略也能在凭证过期时强制调用方重新获取避免调用方长期缓存旧地址。2.3 一张注册记录里到底放什么字段我最初设计字段时犯过贪多的毛病恨不得把所有环境信息都塞进去。后来线上出了问题发现字段多不等于管理好反而容易出现“配置和实际不符”的漂移。现在我的注册记录只保留了下面这些字段每个都有明确用途。字段作用示例是否必填agent_id全局唯一逻辑标识agent-103.image-search是display_name可读名称方便排查问题图像搜索-生产否capabilities能力声明用于路由匹配[image:search]是endpoints路由端点集合{primary:http://10.20.3.15:8712}是transport传输协议grpcmtls / httptoken是liveness_probe存活探针定义{url:/healthz,interval_ms:15000}是readiness_probe就绪探针定义{url:/readyz,interval_ms:5000}是lease_ttl_ms租约过期时间30000是auth证书或令牌来源{source:vault:path}是labels环境标签用于分组路由{env:prod,zone:a}否每一个字段都有明确用途没有“备用字段”这类说法。我特别想把 capabilities 拆出来强调一下这决定了 Agent-Reach 能不能做能力级路由。比如一个代理声明了 image:search另一个声明了 image:describe调用方只需要说“我要找能做 image:search 的Agent”Agent-Reach 就能从一堆实例里筛出合适的一组而不是靠调用方自己猜名字。3. 活 ≠ 可用心跳、租约和健康探针的设计3.1 liveness 与 readiness 的差别是很多故障的分水岭一开始我把健康检查做得特别粗糙只要Agent进程还在就认为它可达。后来一个线上故障让我彻底改了思路。当时有一个Agent因为底层数据库抖动所有请求都在等锁每个任务都要卡几十秒。它的进程活着进程里的线程也活着健康检查接口也能返回200但业务耗时已经不可接受了。调用方还傻傻地继续往它身上压请求最终把雪崩引到了整条链路上。所以 Agent-Reach 里必须区分两种探针。liveness 探针只判断“这个进程要不要被重启或摘除”它反映的是基本的存活状态检查频率可以低一些比如15秒一次。readiness 探针判断的是“这个Agent现在能不能接受新请求”它必须反映业务层面的就绪度比如队列深度、并发占用量、最近请求的成功率。只有两种探针同时通过Agent才会被放进路由表。打个比方你可以把Agent想象成一个餐厅。liveness 相当于“餐厅还开着门”readiness 则是“现在还有空桌后厨也来得及做菜”。如果只看前者你会发现餐厅开着但顾客进去要等两个小时。多做一层判断就是对请求负责。3.2 心跳、租约 TTL 和过期清理心跳和租约是配套设计的。Agent向 Agent-Reach 注册的时候会获得一个租约租约的有效期就是 lease_ttl_ms。只要Agent在租约到期前持续发送心跳注册记录就一直有效一旦心跳停止租约到期Agent-Reach 会把这条记录标记为过期并立即从路由表里摘除。租约时间怎么定太短容易出现抖动误判比如网络闪断一下Agent就被摘除了太长又会让系统反应迟钝真正的故障要等很久才被发现。我自己的经验是心跳间隔取租约TTL的三分之一左右。比如租约30秒心跳就10秒一次。这样即使一次心跳丢失系统还能等到下一次连续三次丢失租约肯定过期可以比较确定地判定Agent异常。过期清理不能只靠定时扫描因为Agent-Reach可能会管理上百个实例每次全量扫描会浪费不少资源而且扫描间隔本身会放大故障发现时间。我采用的方式是惰性过期检查也就是每次路由查询的时候顺手检查目标记录的时间戳另外用一个低频的巡检协程兜底全量清理真正过期的记录。这样既快又稳还不用频繁唤醒整个注册表。3.3 参考配置一台 Agent 的探针怎么设如果你准备自己实现类似机制下面这份配置可以当作底稿。我按生产环境常用的方式写的不同场景可以再调整。registry: address: https://reach.internal:7443 lease_ttl_ms: 30000 agent: id: agent-103.image-search capabilities: - image:search - image:describe transport: httptoken endpoints: primary: http://0.0.0.0:8712 external: http://10.20.3.15:8712 probes: liveness: type: http url: /healthz interval_ms: 15000 timeout_ms: 2000 readiness: type: http url: /readyz interval_ms: 5000 timeout_ms: 2000 client: request_timeout_ms: 30000 connect_timeout_ms: 3000 retry_initial_backoff_ms: 500 retry_max_backoff_ms: 8000 retry_multiplier: 2.0 jitter_ratio: 0.2readiness 探针我建议做得更敏感一些5秒查一次不算冒险因为它的目的就是尽早发现“能连上但干不动”的Agent。而 liveness 不用太激进15秒一次足够避免高频探测给Agent带来额外负载。4. 一条真实调用链路从注册到安全执行4.1 最小部署单元一个注册中心加一套客户端库Agent-Reach 不是一个重型平台最小部署单元就是两个部分一个注册中心服务多个集成在Agent里的客户端库。注册中心自己可以独立部署也可以嵌在已有的基础组件里。我做的是独立进程因为这样能让Agent和底座解耦Agent故障不影响注册中心注册中心升级也不要求所有Agent停机。客户端库负责三件事启动时注册、周期心跳、发起调用前获取路由凭证。把这三件事封装在库里以后使用方只需要在Agent启动时传入一个配置文件后续的连接逻辑就不需要业务代码操心了。这样做的好处是后续底座的通信协议升级只需要换库不用动业务逻辑。4.2 从注册到执行一次调用注册这个过程本身很简单。Agent启动后客户端库会向注册中心发送一个注册请求我一般用下面的命令调试curl --fail --silent --show-error \ -X POST $AGENT_REACH_API/registry/agents \ -H Content-Type: application/json \ -d agent-registration.json注册成功之后Agent按固定间隔发送心跳表明自己仍然存活。心跳请求可以复用同一个客户端连接这样还能减少握手开销curl --fail --silent \ -X POST $AGENT_REACH_API/registry/heartbeat \ -H Authorization: Bearer $(cat token.txt) \ -d {agent_id:agent-103.image-search,load:{queued:2}}当另一个Agent要发起调用时流程又分成四步。第一步调用方先询问 Agent-Reach请求目标Agent的当前可达集合。Agent-Reach 会根据注册信息、健康状态、能力声明返回一个或多个候选端点。第二步调用方从候选端点里选择一个连接目标Agent并发起业务请求。第三步如果连接失败或者请求超时调用方重新向 Agent-Reach 获取新的候选端点而不是重试旧地址。第四步整个请求结束后调用方把执行结果异步回报给 Agent-Reach用于后续路由策略优化。我特别想强调第二步到第三步的变化。很多调用方在失败之后的第一反应是“立即重试同一个地址”但这在多Agent环境里是大忌。目标可能已经缩容、重启、或者进入了亚健康状态继续重试旧地址不仅浪费资源还会拖慢自己的任务。正确做法是重新做一次路由决策。4.3 超时与重试把“连接失败”当作正常现象多Agent系统里单个Agent故障不是“如果发生”而是“什么时候发生”。所以调用侧的参数设计远比业务代码的逻辑更重要。我常用的一组基准值是连接超时3秒整体请求超时30秒重试次数上限3次重试间隔采用指数退避加随机抖动。指数退避公式可以这样表达attempt min(current_attempt, 8) backoff min(max_backoff, initial_backoff * multiplier^attempt jitter)为什么加抖动因为如果所有调用方都在同一时刻对同一个列表重试它们会产生完全相同的退避节奏形成周期性的冲击波。抖动就是在退避时间上随机加减20%到30%把请求散开避免共振。这个细节看着小但在几十个Agent同时重试时效果差异极大。参数建议值说明connect_timeout_ms3000建立连接的最长等待时间request_timeout_ms30000整个业务请求的最长等待时间retry_initial_backoff_ms500第一次重试前的等待时间retry_max_backoff_ms8000重试等待的上限retry_multiplier2.0每次重试间隔翻倍jitter_ratio0.2在退避时间上加减20%随机值这套参数不是万能的。如果你的Agent本身对延迟非常敏感可以缩短 request_timeout如果下游Agent稳定性较差可以降低重试次数让上层更早介入。关键是参数不能拍脑袋必须结合业务线的SLO来定并且要在压测里反复验证。5. 三个让我搞到凌晨的问题僵尸节点、重试风暴和配置漂移5.1 僵尸节点进程活着但业务队列已经堵死这是我把 readiness 探针加进 Agent-Reach 前遇到的最严重的故障。当时所有健康检查都只看“能不能ping通”于是一个已经被业务队列塞满的Agent依然会被路由到。外部调用方进来一个任务它就排进一个可能3000长的队列然后超时然后重试再排进去形成恶性循环。这个故障最坑的地方是看起来“一切正常”进程在端口通日志没有报错甚至健康检查端点也返回200。只有把内部队列长度和任务平均耗时打印出来才发现它已经消化不良了。顺着这个教训我把 readiness 探针改成了“复合判断”首先检查内部任务队列是否超过阈值其次检查最近一分钟的任务完成率最后还会模拟一次最小业务调用确保从网络到业务层的整条链路都通。排查僵尸节点有一条经验可以分享当系统里出现“间歇性超时但没有任何进程崩溃”的现象先别怀疑网络去检查每个Agent的就绪度指标。僵尸节点通常藏在业务层不藏在网络层。5.2 重试风暴好心重试差点打挂下游有一段时间Agent-Reach 路由到某个目标Agent时偶尔会失败调用方触发重试这本是好事。但问题在于多个调用方共享同一个目标Agent时它们会在几乎同一秒内纷纷重试。一个目标Agent只是短暂抖动却在瞬间收到了平时五六倍的请求直接把负载拉满最终从“轻微故障”演变成“完全不可用”。那次事故以后我把重试策略整体重新设计了一遍。第一单次请求的重试次数限制在3次以内超过后立刻向上层返回错误不能无限尝试。第二在 Agent-Reach 的调用侧引入一个简单的熔断器如果某个目标Agent在30秒窗口内连续失败超过10次就直接熔断不再路由新的请求进去。第三重试必须使用路由中心返回的新端点而不是盯着失败过的地址。引入熔断器之后最明显的变化是故障影响被隔离了。一个Agent坏了受影响的是它自己的那部分请求其他Agent不会因为重试而跟着遭殃。5.3 配置漂移测试环境好用的配置在生产环境失效第三个坑是配置漂移。Agent在测试环境运行得好好的到生产环境就频繁出现“注册失败”。一开始我怀疑是网络隔离问题后来发现根本不是。测试环境用的是HTTP明文生产环境要求mTLS但Agent的注册配置还是从测试环境同步过来的。它确实一次次尝试注册但加密握手都没通过自然失败。这个问题的根源在于配置文件没有和环境做严格的参数绑定。解决方式分两层。第一层Agent-Reach 在注册时会校验传输协议、认证字段和当前环境的安全策略不匹配就直接拒绝并返回明确的错误码而不是让Agent在那里反复重试。第二层我在客户端库里加了一个注册前自检工具会用环境变量里的配置生成一个测试请求连到注册中心做一次预检。预检不过就不启动Agent避免“重启了一个看起来正常但永远不可达”的实例。如果你不想让配置漂移问题继续咬人建议把所有环境相关的参数收敛到环境变量或者配置中心里而不是散落在代码注释和外部文档中。Agent-Reach 的注册中心可以做最后一层拦截但根本解法是让配置来源单一化。6. 看得见风险才能谈稳定遥测与后续演进6.1 四个信号决定我要不要介入把 Agent-Reach 跑起来以后我最关注的不是单个Agent的状态而是四个聚合信号。只要这四个信号稳定整个连接底座就在可控范围内。信号指标来源我的预警值发现链路是否正常registry.request_total / registry.failed_total失败率超过1%心跳是否健康heartbeat.accepted_total / heartbeat.missed_total同一个Agent连续漏心跳3次路由链路是否稳定route.attempt_total / route.success_total成功率低于99.5%延迟是否恶化route.latency_p50 / route.latency_p95p95超过30秒这四个信号本质上在回答四个问题注册中心自己还正常吗Agent们还活着吗路由决策是否正确业务调用是否够快任何一个问题回答不上来其他指标再漂亮也不能说明系统是健康的。特别是延迟必须同时看p95和p50只看平均值会被少数长尾请求拉偏。6.2 把路由决策记录成结构化事件Agent-Reach 不能只听命令它还得说话。我在注册中心里给每一个路由决策都输出一条结构化事件格式大致是{ event: agent_reach.route, time_ms: 1734567890123, requester: agent-105.layout-designer, target: agent-103.image-search, resolve_endpoint: http://10.20.3.15:8712, attempt: 2, result: success, latency_ms: 1200, trace_id: trace-9f21c5 }有了这些事件排查问题就不再是翻无尽日志而是直接按 trace_id 过滤出一条完整的调用链路。调用方发起请求时如果带上了trace_idAgent-Reach 就把自己的路由记录和业务日志关联起来后面无论哪一层出了问题都能快速定位到“是路由器选错了还是Agent本身处理慢了”。6.3 下一步演进从“找到端点”到“按意图路由”Agent-Reach 现在做的事情是把Agent变成“可发现、可验证、可路由”的节点。但下一步我不会急着加太多新功能而是先补足两个方向。第一个方向是基于能力的路由。现在的路由还偏重于“找到目标Agent”但调用方更关心的是“找到一个能满足这组需求的Agent”。当系统里出现两个能力完全相同的Agent时Agent-Reach 应该能按负载、成本、区域等因素自动选择。这个方向需要更多实验数据支撑所以我把它排在后面。第二个方向是权限隔离。随着Agent数量增长不能让每个Agent都有权限调用其他所有Agent。我计划在注册中心里增加一个基于身份的访问控制层每个Agent只能看到并调用自己被授权的目标。这个功能在安全性要求高的场景里几乎是刚需但实现之前必须先保证注册表的数据足够干净。我现在的体会是先别急着做智能路由、动态调度这些听起来高级的能力。把连接底座的可观测性和故障隔离做好系统已经会稳定一大截。等你能看清楚每个路由决策是怎么做出的再往上层加策略才有依据。
返回列表