☰
Agent-Reach:智能体接入与调度实践——健康检查与动态路由
2026/10/6 4:41:16 网站建设 项目流程

做业务系统这几年,Agent-Reach 这个名字其实是我在一次凌晨三点排查故障时临时起的。当时线上的一个分析流程需要串联三个智能体服务,结果模型调用、任务分发、结果回写各走各的配置,页面上的一个指标异常预警,追到最后发现是其中一个Agent服务悄悄挂了,调用方一直用固定的请求重试,整整拖了四十分钟才有人发现。那天修完故障,我随手在记事本上写下三个词:Agent、Reach、Route——项目的名字和核心诉求就这么定了。

Agent-Reach 本质上是一层独立的智能体接入与调度服务,它把所有要被业务方调用的 Agent 当成一批可以被发现的资源来管理。你可以把它理解成一个总机:每个Agent是一部分机,有不同的号码、忙闲状态和工作能力;业务方不需要记住每个号码,只需要拨打总机,由总机帮你选择当前最空闲、最健康的那条分机线。它解决的问题非常具体:一是让AI服务之间不再互相写死地址,二是把节点故障从“事故”变成“普通告警”,三是让每个Agent的调用成本、成功率、延迟都有据可查。

这东西适合谁?如果团队里只有一两个Agent服务,直接用内网域名就够了,没必要上全套。但如果工作流里已经有三五个或更多的智能体节点,或者正在做类似LangChain、CrewAI这类多智能体框架的落地,那Agent-Reach这套思路至少值得参考。文章里我会把设计取舍、核心代码、参数经验和踩坑记录都写出来,想直接复现也行,想只摘几段思路也可以。

1. 项目背景与设计思路

1.1 一团乱麻的Agent调用现状

先说问题现场。当时我服务的平台里,不同业务方各自维护了一套调用Agent的逻辑:有人直接把IP和端口写死在环境变量里,有人启动时拉一次Agent列表就缓存一个月不再更新,还有人更直接,把全部Agent地址拼成一个逗号分隔的字符串,轮询时取第一个。

这种写法在一两个节点的时候没毛病,节点一多就难受。我数了一下,当时线上有七个Agent实例分属三个团队,有的跑在K8s集群里IP经常变,有的跑在独立的物理机上,还有两个是给外部供应商做的二次封装,地址本身就带路径参数。结果就是:每次有节点扩容,都要发一轮邮件周知所有人刷新配置;每次某个Agent进程卡死但端口还通着,调用方拿到的错误信息千奇百怪;每次想统计哪个Agent成功率低,都得去各自的日志平台里翻半天。

更麻烦的是调用关系混乱。一个总任务可能要经过Agent A、Agent B、Agent C,而Agent B自己又要去调A,形成循环依赖。节点一抖动,整条链路都跟着抖,定位问题的时候大家都说“我这块是好的,是别人那边挂了”。这类问题在AI应用越来越多之后只会更严重,因为Agent的调用天然是长耗时、高并发、跨服务的,传统的RPC调用治理那套又太重,引入全套注册中心加网关,对小团队来说性价比不高。

1.2 要解决的三个核心问题

后来我把需求收敛成三个,写在便利贴上,贴在了工位显示器边框上。

第一个是可用性管理。我需要一个系统能在Agent挂掉的时候自己感知,而不是靠业务方去重试到超时。探测、健康状态标记、自动下线,这三件事必须是自动的;恢复之后最好也能自动回来,不需要人工介入。

第二个是弹性路由。业务方调用Agent不应该自己选节点,而应该由调度层按健康状态和负载情况去选。而且要支持多种路由策略,不能只有随机或者轮询。不同场景愿意接受的延迟和安全边界不一样,比如面对外部调用可能更看重最少连接,面对内部批量任务更看重权重分配。

第三个是可观测性。我不想再靠人肉翻日志统计成功率了。每一个Agent的存活状态、平均延迟、QPS、失败原因、谁在调用它,都应该有一个统一视图。这个诉求听着很基础,但在异构环境里其实很麻烦——因为不同Agent的健康定义不一样,有的是看端口连通,有的是看某个接口的返回值,还有的是要模拟一次完整的小请求才能确认。

这三个问题都指向同一个结论:需要一层独立的、专门负责Agent接入与路由的中间服务。它不能太胖,不应该去理解业务参数,但必须够快、够稳,能处理网络抖动,也能容忍自己的重启。

1.3 技术选型:为什么是FastAPI + Redis + Docker

选型的时候我纠结了一阵子。团队当时主语言是Python,Agent框架也大多是Python写的,继续用Python维护成本最低,所以语言层面选了FastAPI。它的Async支持在高并发IO场景下表现不差,而且生态里有现成的SSE、JSON-RPC这些和AI调用相关的库,后面好扩展。如果团队主力是Go或者Java,完全可以用同样的思路换成Gin或Spring Boot,Agent-Reach的核心逻辑和语言不绑定。

状态存储我选了Redis。原因只有一个字:快。Agent的健康状态和元数据都是短生命周期信息,需要频繁读写,落数据库会让事情变重。Redis的Hash和ZSet正好能覆盖“按ID查详情”和“按分数排序选节点”两类操作。网络拓扑变化时,通过发布订阅广播一下,业务方就能在毫秒级收到变更。

部署形态选了Docker Compose。这是个务实选择:大多数中小团队的服务器环境没到K8s的程度,直接一个compose文件拉起来三个容器(Agent-Reach、Redis、示例Agent)是最快的验证方式。等到节点规模真的上去了,再改成K8s Deployment不迟。这里我踩过一个坑,后面会详细说。

2. Agent-Reach 的架构拆解

2.1 四个模块的分工

Agent-Reach 在功能上分成四个模块,各管一摊。

第一个是注册中心模块,负责Agent实例的注册、注销和元信息管理。每个Agent启动时往中心注册自己的ID、名称、地址、权重、协议类型和一组自定义标签。注册动作不做复杂鉴权,只要拿着分配好的token能对上就行,因为这套系统还是在内部网络里跑。

第二个是健康探测引擎,这是Agent-Reach的地基。它按照配置好的周期去探测每个Agent,记录探活结果,并更新Agent的在线状态。探测方式必须支持多种协议,除了常见TCP握手和HTTP心跳,还要支持JSON-RPC调用、gRPC健康检查,以及自定义的命令行探针。为什么这么强调?因为Agent服务五花八门,你没法要求每个团队都实现同一套健康检查协议。

第三个是动态路由模块,负责在业务方发起调用时选择一个候选Agent。这个模块牺牲了一点实时性,不是每次请求都临时去问Redis,而是维护一份本地可用Agent缓存,定时刷新。好处是吞吐高,坏处是存在“缓存窗口期”,这需要靠路由参数去权衡。

第四个是观测与配额模块,负责把每一次调用的开始、结束、超时、错误都记成结构化日志,同时做简单并发控制。这个模块我最晚做,因为它本身不参与链路改造,但跑起来之后,运营排查问题的效率提升最明显。

2.2 健康状态流转模型

状态机是整个系统的核心。最初只设计ONLINE和OFFLINE两种状态,上线没几天就发现不够用。因为你得承认,现实中一个Agent卡在死循环里,端口还通着,TCP探活是成功的,但实际业务已经没法用了;还有一种情况是Agent刚重启完,运行时还在预热,前几十次请求延迟特别高,如果立刻放进去接流量反而拖慢整条链路。

所以状态机改成了四态:NEW、ONLINE、DEGRADED、OFFLINE。

NEW是刚注册或刚恢复时的状态,此时Agent进入一段观察期,只接收小流量,用来验证真实可用性。ONLINE是健康且平时的那种状态,可以正常接流量。DEGRADED是探活正常但业务指标明显变差的中间状态,比如平均延迟超过设定阈值,或连续失败率上升,这时候路由会把权重降下来,但不会完全切断。OFFLINE就是已经被连续探活失败打入冷宫的状态,不再进入路由候选。

状态转换不是一步到位的。比如从OFFLINE恢复到ONLINE,必须先经过NEW,并在NEW状态里连续N次探活成功,再观察一小段时间,才能正式转回ONLINE。这个“先隔离再观察”的策略让我少挨了不少骂,后面会列个真实案例。

2.3 动态路由策略的取舍

路由策略我实验了三种:轮询、加权轮询、加权最少连接。

轮询最简单,但最大的问题是不考虑节点差异。两个Agent配置一模一样时没问题,一旦性能有悬殊,快的节点空转,慢的节点排队。加权轮询好一点,但权重是静态配置的,节点一抖动就失效。最终还是用加权最少连接作为默认策略,它会在候选集合里按活跃连接数动态分配,再叠加权重修正。

这个选择的理由很务实:Agent调用大多是长耗时请求,一个请求可能在Agent上跑几十秒甚至几分钟,按连接数分配最能反映真实负载。而轮询和加权轮询本质上是在假设所有请求耗时差不多,这个假设在多智能体场景里基本不成立。另外,我把“最少连接”实现成了“最少预估并发”,因为HTTP连接和请求不是一一对应的,用信号量记录当前正在执行的请求数,比记录TCP连接数准确得多。

3. 关键实现细节

3.1 状态数据层设计

数据层我直接用Redis的两个数据结构叠出来,没有引入ORM。

Agent的静态和动态信息放在Hash里,key是agent:{id},字段包括name、addr、weight、tags、protocol、status、last_seen、last_latency、current_load。status字段虽然和路由判断关系密切,但我没有单独拆出来,原因是我们几乎所有查询都是先拿到整个Agent对象再判断状态,放一起反而少一次网络往返。

候选列表用ZSet维护,key是agents:online,member是agent_id,score是当前负载。这里有个细节:score存的不是负载绝对值,而是“权重修正后的活跃请求数”,即current_load / weight。这样权重大的Agent在同负载情况下分数更低,会优先被选到。排序的时候直接取score最小的几个ID,就能得出候选列表。

路由结果产生以后,会异步给当前Agent的current_load加1,调用结束再减1。这个计数器存在Agent的Hash字段里,更新用Redis的INCR/DECR命令,天然原子。这里我故意没做分布式锁,因为分布式锁在这种场景下只会增加延迟和复杂度,INCR本身就能防止并发覆盖。

3.2 健康探测与探针设计

Agent-Reach的探测逻辑很简单,伪代码如下。我不写完整工程,只讲关键部分。

async def check_agent(agent_id: str, probe_spec: dict) -> ProbeResult: protocol = probe_spec.get("protocol", "http") if protocol == "http": async with httpx.AsyncClient(timeout=probe_spec.get("timeout", 2.0)) as client: resp = await client.get(probe_spec["url"], headers=probe_spec.get("headers", {})) healthy = resp.status_code == 200 and not resp.text.startswith("ERROR") elif protocol == "json_rpc": # 发一个轻量ping方法,检查返回的id和jsonrpc字段 pass elif protocol == "tcp": # 用asyncio.open_connection做简单握手 pass return ProbeResult(agent_id=agent_id, healthy=healthy, latency=elapsed)

这段代码看起来简单,但有几个容易踩坑的点值得提醒。

第一个坑是HTTP探活不要只查200状态码。很多Agent的入口虽然返回200,但内部依赖的模型服务已经连不上了。有条件的话,探活URL最好指定一个轻量的内部接口,这个接口会做一次最小依赖检查,而不是用首页或favicon这种路径。

第二个坑是探活并发数要控制。如果同时探活50个Agent,每个请求等待2秒,直接一口气全发出去,会把探活引擎本身的连接池打爆。我会设置一个信号量,限制并发探活为10个左右,再用asyncio.Queue把探活任务排进去,这样既有吞吐又不会把自己搞死。

第三个坑是探活间隔要有随机抖动。固定间隔会让所有Agent的探活请求形成一个稳定节拍,容易在部分网络设备上形成同步效应,也会掩盖局部网络波动。类似指数退避加抖动的方式,每隔几个周期稍微随机偏移一下,效果会好很多。

3.3 路由决策算法实现

路由候选的选择逻辑我放在一个独立函数里,单元测试好写。

async def select_agent(req: AgentRequest, cache: AgentCache) -> AgentInstance: candidates = [] for agent in cache.get_live_agents(): if agent.status != ONLINE or not agent.matches(req.required_tags): continue candidates.append(agent) if not candidates: raise NoAgentAvailableError("no healthy agent for tags=%s" % req.required_tags) # 加权最少连接:按 current_load / weight 排序 candidates.sort(key=lambda a: a.current_load / max(a.weight, 1)) chosen = candidates[: min(3, len(candidates))] # 在头部候选里做小概率随机,避免微热点 return random.choice(chosen)

这里有两个设计细节。

一是排序后取前三个再随机,而不是直接拿第一个。直接拿第一个在高并发场景下会出现“惊群”效应:多个请求同时算出同一个最优节点,然后全涌上去。头部随机化能让流量在最优的几个节点之间散开,代价是额外一点点延迟,非常值得。

二是matches方法对标签的匹配一定要在本地完成,不能每次查Redis。这里用了一个本地缓存,Agent-Reach启动时把Agent列表加载进内存,收到Redis的变更通知时再做增量更新。否则路由本身会成为瓶颈。

3.4 超时、重试与熔断参数

下面这组参数是我压测后调出来的,适合大部分内部AI服务,可以直接作为起始值。

参数推荐值说明
connect_timeout800ms连接建立时间超过即放弃,别拖到默认的2秒
read_timeout30s~120s根据单个Agent任务耗时调整,文本生成类给大值
max_retries2次只重试幂等请求,非幂等绝不自动重试
retry_backoff100ms起,指数退避+抖动防止重试风暴
熔断阈值连续失败3次达到后立刻把状态改为DEGRADED
熔断恢复半开窗口30s放1个探活请求,成功即恢复
探活周期10s常规场景够用,要求高的改5s

为什么connect_timeout要那么小?因为绝大多数超时是因为对方进程卡死而不是网络慢。连接超时给太长,调用方感觉到的就是“每次都好慢但不知道哪一步慢”。800ms是保守但合理的值。read_timeout主要看Agent能力,不适合全局统一,所以在注册元信息里给每个Agent单独配。

熔断没法用固定值解决所有问题。如果Agent偶尔抖动,连续失败3次可能误伤;如果Agent彻底宕机,3次又显得太慢。我的做法是把阈值做成可配置,常规内部服务给3次,外部供应商节点给5次,宁可多容忍一点,也不让路由频繁开合。

4. 部署落地与故障排查实录

4.1 一套可复制的部署结构

部署我用一个compose文件搞定。核心服务就三个:redis、agent-reach、一个示例agent。下面这段是简化版,但足够跑通主链路。

services: redis: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes"] ports: ["6379:6379"] agent-reach: build: . ports: ["8080:8080"] environment: REDIS_URL: redis://redis:6379/0 AGENT_PROBE_INTERVAL: "10" AGENT_PROBE_CONCURRENCY: "10" ROUTER_STRATEGY: "wlc" depends_on: - redis sample-agent: image: your-registry/sample-agent:latest environment: AGENT_ID: demo-agent-1 AGENT_ADDR: http://sample-agent:9000/agent AGENT_WEIGHT: "1"

有几个环境变量值得解释一下。AGENT_PROBE_CONCURRENCY是探活并发数,默认10。如果线上Agent超过30个,可以适当调到20,但别太高。ROUTER_STRATEGY支持round_robin、weighted_rr、wlc三种,默认wlc最省心。

部署时最容易被忽略的是网络规划。Agent-Reach和业务方之间要有稳定网络路径,Agent-Reach和Agent之间要能互相访问。如果你用Docker Compose把Agent-Reach和Agent放在同一个网络里,但业务方服务在另一个网络里,注册地址就不能只写容器内地址。通常做法是注册时同时上报内网IP和容器网络别名,路由返回给调用方的地址按照调用方所在网络自动适配。这个逻辑不需要搞太复杂,一个地址翻译函数就够了。

4.2 压测数据与效果对比

我自己做了一次不算严谨但足够说明问题的压测。用一台8核16G的云主机,部署Agent-Reach和三个规格不同的模拟Agent:一个性能强、一个中等、一个经常抖动。请求模拟150并发、每请求平均3秒的Agent调用。三种策略的对比结果如下:

路由策略平均延迟(秒)P99延迟(秒)成功率强节点利用率弱节点利用率
轮询9.621.494%68%52%
加权轮询7.316.296%83%61%
加权最少连接5.812.599%91%64%

轮询策略下,弱节点被塞了太多长请求,队列越堆越长,强节点反而在空转,所以平均延迟飙升。加权最少连接让每个节点始终保持“正在处理的请求数不超过可承受范围”,整体吞吐自然就上去了。成功率提升除了路由策略,也有健康探测的功劳——OFFLINE节点在几秒内就被剔除,不会像以前那样反复打到一个挂掉的IP上。

需要强调一点:这个压测只代表我的环境和场景,不能照搬数字去做容量规划。参考价值在于趋势,不在于绝对值。如果要做生产容量规划,我建议至少压两轮:一轮纯压力测试看峰值吞吐,一轮长时间浸泡测试看内存和连接池泄漏。

4.3 常见问题速查表

我把上线后运维中被问得最多的几个问题整理成一个速查表。都是真实踩过的坑,有些排查了很久才找到原因。

症状可能原因排查/解决方式
Agent列表全部OFFLINE探活端口填成了业务端口,而业务端口不响应探活请求检查注册元信息里的probe_url,不要用对外服务地址,用专用探活路径
路由偶尔打到不健康节点本地缓存窗口期太长,节点刚标记OFFLINE但缓存没刷新缩短refresh_cache_interval,或者让调用侧监听Redis变更通知
并发上来后Agent-Reach延迟升高路由选择时同步查询Redis次数过多把候选列表放进本地缓存,Redis只做状态变更广播
节点恢复后仍被排除在外半开窗口期太短,探活还没成功就结束调大半开窗口,恢复后先走NEW状态观察
多个Agent同时恢复,流量瞬间集中所有Agent一起从NEW转ONLINE,都被当成空闲节点给恢复节点加权重渐变期,恢复初期权重是0.1,逐渐升到1
探活请求把Agent打崩探活并发太高,或探活URL太重降低并发到5,探活接口只做最小化依赖检查

这条速查表我打印出来贴在了团队墙上。后面另一位同事要写自研组件时,也直接拿这张表当排查手册用,省了不少事。

5. 和主流AI框架的集成扩展

5.1 让Agent-Reach成为多智能体框架的外层调度

Agent-Reach做得越久,我越觉得它应该是现有AI框架的外层调度,而不是替代它们。以CrewAI为例,CrewAI内部会把任务分给不同的角色Agent,但如果你有多个Crew实例并行跑,实例之间的负载如何均衡,CrewAI本身不关心。Agent-Reach可以充当这层调度:每个Crew实例对外注册成一个Agent,业务方调用时,Agent-Reach选择当前负载最低的Crew实例,把请求打过去。

和LangChain集成时也类似。LangChain的DAG和工具调用逻辑保持不变,但执行器Worker节点可以注册到Agent-Reach上下线。业务低谷时可以手动把Worker缩到1个,高峰时直接扩到20个,Agent-Reach会根据健康状态自动把流量路由到扩容出来的节点,不需要改业务代码。

这种集成不是写一堆胶水代码,而是把Agent的“实例管理”这件事抽离出来。业务方永远只面对一个稳定的入口地址,后端怎么扩缩容都不影响它。

5.2 后续可以深耕的方向

Agent-Reach的v1版本解决了我最痛的问题,但如果继续做,有几块我会优先补上。

第一块是多租户与Token级鉴权。现在内部用没问题,一旦接入外部团队,不能所有Agent共享同一套探测和路由规则,需要按租户隔离。第二块是调用成本画像。AI Agent的调用成本不只是机器费用,还包括模型API费用。如果把每次路由都打上成本标记,沉淀一段时间后,能直接分析哪个业务方最烧钱,哪种路由策略最省钱。第三块是故障自愈,比如探测到Agent磁盘快满时,自动触发清理脚本或通知运维替换节点,而不只是把状态切到DEGRADED就完了。

这些方向里,成本画像是我最想做但暂时排期的。Agent资源池化之后,计费和优化是天然需求,早做早受益。

最后分享两个我自己总结的经验。第一个,调度类组件别追求功能大而全,先保证“状态准确”和“路由不崩溃”这两条底线,再加其他能力,否则上线第一天就会被复杂配置拖垮。第二个,任何KV缓存、健康探测、路由算法都要有本地兜底策略,Redis挂了Agent-Reach至少要能降级成直连模式,哪怕准确率下降,也比全链路不可用强。

按这个思路迭代了大半年,手上由Agent-Reach管理的智能体实例从7个扩大到了40多个,而夜间告警电话反而少了。这件事让我更加确信:当AI组件越来越多,真正决定系统稳不稳的,不是某一个模型多聪明,而是它身边管调度、管状态、管观测的基础设施有多完整。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询