资讯动态

多Agent协作下的统一触达层设计:Agent-Reach路由与熔断实践

发布时间:2026/10/9 9:00:36 来源:尧图企业网站定制
前几个月我在搞一个多Agent协作系统Agent数量一多问题就变得特别现实意图识别要调NLU服务工具调用要连一堆第三方接口记忆模块要读向量库还要对接几个大模型供应商。每个服务各连各的配置散落一地超时、重试、限流这些横切逻辑每个Agent自己写一遍写出来的代码还不太一样。更头疼的是下游服务一抖动多个Agent会同时被拖死排查的时候根本不知道是哪个环节先出的问题。那段时间我就在想能不能做一个中间层把Agent要触达的所有东西统一收口让Agent侧的代码只管表达我想做什么至于下游在哪里、怎么连、连不上怎么办全部下沉到一个基础设施里。这就是Agent-Reach的由来。这个方案做完以后回头再看多Agent工程里那些乱七八糟的接入问题其实本质上是一层很标准的触达治理问题。我把整个设计思路、关键实现、还有踩过的坑记录下来如果你也在做Agent相关的基础设施或者正准备给现有Agent体系加一个统一的调用层这篇内容可以直接抄作业。1. 为什么选择做Agent-Reach多Agent协作最大的隐性成本是连接做Agent系统的人通常会把大部分精力花在Prompt设计、工具定义、Agent的推理逻辑上这个方向当然没错但一旦Agent的数量超过三五个协作方式开始变复杂你就会发现真正的成本大头其实不在推理侧而在连接侧。1.1 从N×M连接矩阵说起假设你有5个Agent3个模型供应商含本地部署的开源模型4个工具服务2个外部知识库。让每个Agent分别去处理自己的连接就是13条链路。每条链路都要处理鉴权、超时、重试、熔断、日志、参数格式转换。如果每个Agent里这些代码写一遍工程量翻了13倍而且质量参差不齐。我见过最典型的反面教材某个Agent调用向量库的时候没设超时下游一次索引重建把请求挂了两分钟这个Agent的所有会话全部阻塞。另一个Agent调用搜索服务时重试逻辑是死循环下游一旦响应慢线程直接被打满。这些都是连接层没有统一治理导致的连锁故障。从架构视角看Agent和下游服务之间的关系就是一张网。每个节点之间的联系越乱这张网就越脆。Agent-Reach做的事情就是把这N×M的网状连接收敛成一个星型拓扑Agent只连ReachReach去连下游。虽然多了一层跳转但换来的是所有连接策略的集中管理和故障隔离能力。1.2 这层代理不该和业务Agent混在一起这里我要先划个边界。Agent-Reach不是业务Agent它不做意图理解不调用工具去解决问题它就是一个纯粹的触达通道。你可以把它理解成快递站和物流干线的关系业务Agent是发件人下游服务是收件人Agent-Reach是那个负责分拣、运输、签收、退回的物流系统。发件人不需要知道收件人的具体仓库在哪个城市也不需要自己开车去送。这个分层把两类问题彻底拆开了业务层Agent只关心我要调用哪个工具、传什么参数、拿什么结果以及拿到结果之后怎么推理下一步。触达层Agent-Reach只关心这个工具当前能不能用、调用需要什么鉴权、耗时多久、失败是重试还是降级、有没有被限流、日志怎么记录。如果你做过单体应用向微服务演进的过程会发现这套思路和API网关如出一辙。但和传统API网关相比Agent-Reach多了一个很关键的差异点它服务的对象不是外部的HTTP请求而是内部Agent的意图。也就是说路由的维度不再是URL而是语义意图工具ID配置标签。2. Agent-Reach的核心设计注册、路由、执行三层拆解一个完整的Agent-Reach节点我把它拆成了三个核心模块注册中心、路由引擎、执行器。三个模块各管一段职责非常清晰。下面是我的具体设计。2.1 注册中心让下游服务自己报名而不是被硬连传统集成方式里Agent代码里写死的往往是下游服务的地址和鉴权信息。Agent-Reach反过来让下游服务启动的时候主动注册自己的元信息包括服务名、环境标签、健康检查接口、调用协议、支持的QPS级别、鉴权方式等。注册中心把这些信息整理成一张实时更新的路由表。注册中心我用的是etcd原因很简单它的watch机制特别适合这种场景。下游服务注册自己的节点信息后路由引擎可以实时感知节点的上下线不需要任何轮询逻辑。Agent侧的配置只需要写我要调market_search至于这个服务在全球哪个区域、当前有几个节点、健康不健康Agent不需要关心。注册中心的存储结构很简单{ service: market_search, version: 20240501, endpoints: [ { url: http://10.0.3.21:8080/v1/search, weight: 80, region: cn-beijing }, { url: http://10.0.5.18:8080/v1/search, weight: 100, region: cn-shanghai } ], timeout_ms: 2000, retry_policy: { max_retries: 2, backoff_ms: 300 }, auth: { type: oauth2, endpoint: http://iam.internal/token, scope: agent-reach } }这份配置的核心思路是Agent侧所有敏感信息全部剥离它们只需要知道一个逻辑服务名。鉴权、超时、重试这些横切关注点全部下沉到注册中心的策略配置里。2.2 路由引擎从哪个URL变成哪个Agent的手上活路由引擎解决的核心问题是一个调用请求进来之后应该匹配哪一条服务通道。我把它设计成了三级匹配规则按优先级从高到低排列精确工具匹配Agent直接指定工具ID这是最显式、最高优先级的路径命中后直接路由。语义意图匹配Agent没有指定工具只描述意图路由引擎结合预先配置的意图-工具映射表做匹配这适合内置工具不够、要从工具集市动态发现的场景。标签兜底匹配按业务标签路由比如所有数据查询类操作走统一查询通道这类规则适合不需要精细粒度、只想把流量分对大类的情况。实际运行中绝大多数请求走第一条和第二条路径。第三条主要用来应对那些新增工具没有提前配置路由规则的场景保证服务不中断。路由规则的配置示例routes: - id: customer-tag-taxonomy name: 客户标签查询 priority: 100 conditions: tool_id: get_customer_tag target_service: crm_tag_service timeout_ms: 3000 - id: semantic-order-status name: 订单状态语义查询 priority: 80 conditions: intent: 查询订单到哪一步了 target_service: order_tracking_service timeout_ms: 1500 - id: fallback-data-access name: 数据查询兜底通道 priority: 10 conditions: labels: [read-only, query] target_service: data_query_gateway timeout_ms: 5000这里有一个我在设计之初就坚持的原则路由规则要短而明确。每个路由规则最好一眼能看出它匹配什么、去向哪里。不要搞那种几百行、嵌套很多条件的大规则后期维护会非常痛苦。2.3 执行器把调用做成一等公民执行器是真正发出请求的模块它直接决定了Agent-Reach的稳定性和性能。我基于并发调度的思路实现了两层能力第一层是同步分发。Agent发起调用后执行器根据路由表选择一个具体端点发起请求等待结果返回。如果超时就根据重试策略换一个端点重试。这是最基础的模式适用于Agent需要拿到结果才能决定下一步行动的场景。第二层是策略化执行。当同一个Agent的一次行动需要并行触达多个下游服务时执行器会做扇出。比如Agent判断当前需要同时查库存、查价格、查物流时效它只需要发起一次批量触达请求执行器内部并行分发然后聚合结果返回。这层实现的核心价值是Agent侧不需要自己写并发编排逻辑省掉一大坨协程管理代码。执行器的执行链路接收Agent请求校验请求格式与权限标记。从注册中心拉取目标服务当前可用端点列表。按负载策略选择端点发起调用。监控调用状态超时或异常时执行重试逻辑。返回统一格式的响应结果给Agent格式中包含耗时、重试次数、端点标识等元信息。3. 触达层的关键机制动态路由、熔断降级与协议适配只有注册、路由、执行三个模块Agent-Reach还只是一个调用转发器。真正让它变成一个可以生产化的基础设施靠的是下面这三个机制。3.1 动态路由与灰度发布线网切换不需要重新发版Agent-Reach的路由表是动态的这个动态不只是服务上下线感知还包括流量切分。注册中心里每个端点的权重是可以实时修改的。你可以把新版本服务的权重从0逐渐调到100实现平滑发布根本不需要改动Agent侧的代码。我之前给搜索服务做过一次模型升级新版本老版本同时注册在etcd里我用权重从10调到50再到100整个过程用户侧零感知。配合权重的还有一个区域亲和策略。下游服务如果注册时带上了region标签执行器在选端点的时候会优先选与Agent实例同区域的这样可以少一轮跨地域的网络往返延迟能下降不少。这块对于延迟敏感型的Agent场景很重要因为Agent的推理链路本身就是串行的每一次工具调用多出来的几十毫秒最终都会叠加到让用户等待的总时长里。灰度发布时需要注意一个点Agent调用往往有会话上下文相关性同一个会话里多次调用同一服务最好都落在同一个版本上避免上下文割裂。我加了一个会话亲和参数默认开启用会话ID对可用端点做哈希保证同一个会话尽量走同一条链路。3.2 熔断、降级、重试的边界这三个词被很多人混着讲我在Agent-Reach里把它们完全分开因为它们的职责完全不同。重试解决的是本次调用因为网络抖动或瞬时故障失败的问题。它假设服务本身是可用的只是这一次没成功。重试的关键是次数控制和退避策略。我建议最多重试2次间隔逐步增大并且最好换端点重试。熔断解决的是下游服务已经在故障状态的问题。如果连续N次调用都失败继续重试只会让下游雪上加霜。熔断器的逻辑是连续失败达到阈值打开熔断开关后续请求快速失败不再真正打到下游。过一段时间放一点试探流量成功了就关闭熔断。降级解决的是即使有替代方案也要保证主流程能跑完的问题。比如下游搜索服务熔断了Agent可以直接返回一个预设的兜底结果或者从一个缓存版本里面取数据。降级策略可以在路由规则里配置它本质上是给Agent一个退路。这三者的边界如果没划分清楚最容易出现的问题是重试把熔断给冲垮了。比如熔断器还没打开的时候请求进来重试两次三次全部打到同一个故障节点上这个节点可能已经被压垮了。所以我实现熔断的时候统计的是**总请求失败率**而不是单次请求失败率把重试请求也纳入统计这样才能真实反映下游的健康状态。3.3 协议适配让Agent不需要关心下游是HTTP、gRPC还是消息队列下游服务不可能统一协议有的提供HTTP接口有的是gRPC服务有的更古老一点走MQ发消息。Agent-Reach如果强行统一到一种协议那等于让所有下游重写一遍接口不现实。我的做法是做一个协议适配器。对每个下游服务注册配置里写清楚协议类型和序列化方式执行器在选完端点之后调用对应的适配器发出请求。Agent侧完全不需要知道协议差异继续用统一的JSON格式发送请求。这样做还有一个额外好处内部服务的接口升级如果只涉及协议格式变化Agent-Reach的适配器层可以做格式转换Agent侧无感。我遇到过好几次下游接口从XML响应升级成JSON响应的情况全是适配器层消化掉的Agent代码一行没改。4. 从零搭建Agent-Reach的实操路径技术选型与核心代码骨架这一节我直接把可以跑起来的方案写出来。技术栈我选了Python主要是考虑Agent生态里Python的库最全而且协程并发处理这类的逻辑写起来直观。核心组件用httpx做异步HTTP客户端etcd做注册中心。4.1 环境准备与依赖运行Agent-Reach节点需要Python 3.10以上安装以下依赖pip install httpx etcd3 pyyaml注册中心我用etcd v3Agent-Reach节点通过etcd3客户端watch路由表和端点状态。如果你本地没有etcd直接用docker跑一个最简模式的实例docker run -d --name etcd-reach -p 2379:2379 quay.io/coreos/etcd:v3.5.5Agent-Reach本身的部署形态是无状态的多个节点可以水平扩展它们共享同一个etcd里的配置。所以你可以把它挂在Agent集群的前面后面加个负载均衡就行。4.2 路由引擎的核心代码骨架下面给出路由匹配的骨架代码核心思想就是按优先级遍历路由表匹配条件满足就返回目标服务信息class ReachRouter: def __init__(self, rules: list[dict]): self.rules sorted(rules, keylambda r: r[priority], reverseTrue) async def route(self, request: dict): tool_id request.get(tool_id) intent request.get(intent) labels request.get(labels, []) for rule in self.rules: if self._match(rule[conditions], tool_id, intent, labels): return rule raise ReachNoRouteError(fno route for request: {request}) staticmethod def _match(conditions, tool_id, intent, labels): if conditions.get(tool_id) and conditions[tool_id] tool_id: return True if conditions.get(intent) and conditions[intent] intent: return True if conditions.get(labels): if all(label in labels for label in conditions[labels]): return True return False路由规则从etcd里实时同步变更后不需要重启节点。实现思路是watch一个prefix数据变更之后直接替换内存中的路由表。4.3 执行器的核心代码骨架执行器负责发出真实请求含超时、重试、熔断判断class ReachExecutor: def __init__(self, circuit_breaker, registry): self.cb circuit_breaker self.registry registry self.client httpx.AsyncClient(timeouthttpx.Timeout(10.0, connect2.0)) async def execute(self, route, payload): if self.cb.is_open(route[target_service]): raise ReachCircuitOpenError(route[target_service]) endpoints await self.registry.get_endpoints(route[target_service]) max_retries route.get(retry_policy, {}).get(max_retries, 0) last_error None for attempt in range(max_retries 1): endpoint self._pick_endpoint(endpoints) try: response await self.client.post(endpoint[url], jsonpayload) if response.status_code 500: raise ReachUpstreamError(fupstream {endpoint[url]} 5xx) return response.json() except Exception as e: last_error e self.cb.record_failure(route[target_service]) await asyncio.sleep(0.3 * (attempt 1)) self.cb.record_failure(route[target_service]) raise last_error注意这里有一个容易被忽略的细节重试之间必须有退避延迟同时要把每次失败都记录进熔断器否则重试次数一多熔断形同虚设。4.4 熔断器的简化实现熔断器我不建议一开始就引入复杂的开源中间件先自己写一个计数窗口版本的足够覆盖大多数场景class CircuitBreaker: def __init__(self, threshold5, recovery_timeout30): self.threshold threshold self.recovery_timeout recovery_timeout self.fail_count 0 self.state closed # closed / open self.last_failure_time None def record_failure(self): self.fail_count 1 if self.fail_count self.threshold: self.state open self.last_failure_time time.time() def record_success(self): if self.state open: return self.fail_count 0 def is_open(self, service): if self.state open: if time.time() - self.last_failure_time self.recovery_timeout: self.state closed self.fail_count 0 return False return True return False这个版本够简单也够用。生产环境你后续可以替换成resilience4j或者hystrix的思路原理都一样。5. 上线之前必须先想清楚的指标我怎么衡量触达层做得好不好没有指标你就不知道这层中间件究竟给系统带来了多少稳定性收益。我上线Agent-Reach之后重点盯了四类指标你可以直接照抄。5.1 触达成功率与失败归因触达成功率是Agent-Reach最核心的KPI。它的定义是Agent发起一次工具调用最终能正常拿到结果或明确的降级结果的比例。这个指标不能只看整体必须按服务维度拆分并且把失败原因分类否则你连问题在哪都不知道。我把失败原因归成四类失败类型含义处理动作Timeout超过路由规则中配置的超时阈值检查下游响应耗时调大超时或优化下游CircuitOpen熔断器打开后快速失败检查下游健康状态触达层已自动降级NoEndpoint注册中心没有可用端点检查下游是否注册异常或全部下线UpstreamError下游返回5xx错误检查下游服务和最近发布变更这个维度看得多了你会发现很多系统故障其实根本不复杂。比如有一次线上出现批量触达失败我一看归因全是CircuitOpen说明熔断器已经做了它该做的事Agent-Reach成功挡住了下游故障的蔓延。这种故障如果发生在没有触达层的架构里多半要花半天时间去翻日志定位。5.2 延迟的P50、P95、P99Agent一次决策循环里工具调用是耗时最主要的组成部分。如果工具调用本身不稳定Agent推理再快也没用。我监控的是触达层端到端的P50、P95、P99延迟按路由规则维度切分。需要注意的一个坑重试会显著拉高P99的延迟。默认重试2次、间隔0.3秒和0.6秒加上超时时间一次失败调用的端到端耗时可能到3秒以上。所以在看延迟指标的时候必须把P99和失败率放一起看。如果P99高但失败率不高说明是少量的慢请求在拖尾巴如果P99高且失败率也高那说明是重试逻辑在给下游雪上加霜必须优先解决问题而不是调大重试次数。5.3 熔断器打开次数与恢复时间熔断器打开本身就是系统正在经历故障的信号。我统计两个值熔断器打开次数反映触达层的保护动作频率和平均恢复时间反映下游自愈速度。如果某个服务的熔断器每周都在打开那就不是一个偶发网络问题了而是下游服务的稳定性有结构性问题需要推动下游团队优化。Agent-Reach能帮你挡住故障但它不能替你修复故障。5.4 路由命中分布与配置健康度这个指标是用来做配置治理的。我会定期看路由规则的命中分布目的是找出那些从来没被命中过的死规则。死规则说明要么匹配条件写错了要么对应的服务根本没人用。留着这些死规则除了消耗路由表空间还会增加误匹配的概率。另外一个配置健康度指标是无路由直接报错的比例。这个值最好维持在0如果出现了说明Agent侧发起了没有预设路由的调用要尽快补规则否则就会出现线上某个Agent功能突然异常的情况。6. 踩坑实录关于超时、幂等、连接复用与配置漂移的四个教训Agent-Reach从开发到线上稳定运行并不是一路顺畅中间踩了不少坑。这四个是我觉得最有普适性的。6.1 超时配置不是越大越好刚上线的时候我把超时配置得比较宽松觉得这样能减少因为偶发变慢导致的失败。结果下游服务故障时Agent-Reach的线程全部被慢请求占用新的请求排队等待整体吞吐量断崖式下跌。后来我把超时改成阶梯式策略第一梯队数据读取类1.5秒第二梯队业务查询类3秒第三梯队重计算类5秒。超时时间应该由下游服务的合理响应时间决定而不是由我不想失败的愿望决定。什么样的下游配多大的超时需要根据压测数据和线上历史分布来定上线后至少观察一周再微调。6.2 幂等设计必须在第一版就做Agent调用工具经常是同一个操作会因为Agent内部的异常重试而触发多次。比如Agent已经拿到搜索结果但在等待结果的时候发生了网络抖动Agent侧重试时会重新发请求。如果下游不是幂等的就会造成重复扣费、重复发消息、重复建单这些事故且这种事故对用户的伤害极大。我的方案是在Agent-Reach里加了一个请求指纹机制。每次请求带一个request_id这个ID由Agent生成Agent-Reach和下游共用。触达层把最近N个request_id存到一个本地缓存里如果发现重复ID到达直接返回上一次的结果缓存而不是重新打到下游服务。这个设计解决的是结果丢失但请求本身成功的重复场景代价是要额外维护一份缓存和TTL相比重复消费事故造成的损失这点成本非常值。6.3 httpx的异步连接复用是默认的但别用完就扔Python里如果用httpx发异步请求它的连接池默认是复用底层TCP连接的。但如果你在代码里每次执行都new一个AsyncClient连接池的复用就被切断了实际效果等于每请求建立新连接延迟翻倍。正确做法是把AsyncClient作为一个长生命周期对象在Agent-Reach节点启动时创建整个进程生命周期内复用。可以顺便设置limits参数来限制最大连接数避免某个下游服务把连接池占满limits httpx.Limits(max_keepalive_connections20, max_connections50) self.client httpx.AsyncClient(timeouthttpx.Timeout(10.0, connect2.0), limitslimits)这个看起来是小优化但在高并发场景下连接复用能直接决定Agent-Reach节点的最大承载能力。6.4 配置漂移是对抗变更最大的敌人Agent-Reach的路由规则和下游服务的注册配置是分开维护的这就存在一个配置漂移的问题下游服务已经升级到v2版本但路由规则还指向旧版本或者某个服务换了鉴权方式但注册中心里没更新。我的解决办法是加了一个配置校验定时器。每5分钟扫描一遍注册中心和路由规则自动检测三类异常路由指向的端点不存在、注册服务未被任何路由引用、鉴权配置与下游实际要求不匹配。检测到异常就发出告警。这个机制上线以后帮我抓到了至少三次因为配置过期导致的潜在故障都是那种现在还不出问题但下次下游一重启就必然出问题的类型。7. Agent-Reach的边界与下一步触达层能做到哪一步不该做哪一步很多人在Agent工程里加中间件的毛病是什么都要往里塞。Agent-Reach也不是万能的我明确给它画了三条边界。第一它不做业务逻辑。Agent-Reach只负责把请求送到该去的地方拿到该拿的结果它不会解析业务语义不会替Agent做决策。一旦你把业务逻辑塞进去它就变成了一个自己写不动的胖代理每次Agent策略调整都要动中间层代码。第二它不做数据存储。调用结果不会持久化在Agent-Reach里触达日志只保留短期的诊断信息。数据层面的缓存和存储应该由Agent侧或下游服务自己管理否则中间层的存储会成为新的瓶颈和故障点。第三它不做模型推理。有人问我能不能用Agent-Reach统一转发大模型的请求。可以但不是它最擅长的场景。LLM推理有自己的特殊性需要流式传输、多轮上下文的token管理等能力这些应该放在专门的模型网关里。Agent-Reach更适合管工具调用、知识库查询这类短平快的触达操作。下一步我准备做的是把Agent-Reach的触达策略做得更感知-决策一点比如结合下游的实时健康度分数自动调整路由权重和超时参数而不是完全依赖静态配置。说白了就是让这层基础设施也具备一点点自我修复的意思而不是等故障告警之后人再介入调参。我自己的体会是Agent系统的落地难难点从来不在让一个Agent跑通一个Demo而在让十个Agent稳定协作一个星期不出事。Agent-Reach能把多Agent协作里最脏最乱的那部分触达链路收拢好你对整个系统的掌控力就会强很多。如果你也在被多Agent服务的不稳定折磨不妨按这个思路搭一层轻量的触达网关先跑通最基础的路由和执行再去打磨重试、熔断、灰度这些进阶机制。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑