尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

多智能体协同调度层Agent-Reach设计:从注册到重试的完整实践

多智能体协同调度层Agent-Reach设计:从注册到重试的完整实践 1. 先从“找不到智能体”这个痛说起我最早遇到“Agent-Reach”这个词是在做公司内部多智能体协同改造的时候。当时的痛点非常具体我们的系统里跑满了各种各样的智能体有的是客服意图识别有的是工单自动分类还有的是内容审核和报表摘要。每个智能体都是由不同小组独立开发的接口风格五花八门通信方式也各搞一套。到了联调阶段最常出现的对话就是——A组问“你们那个智能体怎么调”B组回“你用我们的 HTTP 接口啊”然后 A 组又说“可你们的服务怎么又超时了是不是挂了”。一天下来光是找智能体、对协议、排查超时就浪费了大半时间。这个状态持续了将近两个月直到我们决定抽一个公共层出来统一负责所有智能体的“触达”。这个公共层就是我们后来内部代号里的 Agent-Reach。它不是什么花哨的框架核心职责只有三件事把智能体质建成统一的登记入口统一各路调用的消息格式以及把路由、超时、重试、健康检查这些脏活累活全部收编。做完之后联调效率至少翻了一倍线上故障的定位时间也从小时级降到了分钟级。这篇文章就把 Agent-Reach 的完整设计思路、核心代码实现、以及我在实际落地过程中踩过的坑一条条拆开讲清楚。如果你也在做智能体编排、多服务协同或者只是想把内部系统里散落的调用统一收敛一下这篇内容可以直接当作设计参考核心代码我附了完整示例照着改就能用。2. 为什么需要一个调度层而不是继续直接调2.1 直接调用的混乱是慢慢累积出来的很多团队一开始都觉得智能体不就几个吗直接写代码调用不就完了。我承认智能体数量少于五个的时候这么做没问题。但等数量上来事情就开始变质了。首先是接口地址散落各处。有人把接口写死在代码常量里有人塞进配置文件还有人干脆写在前端页面里。某个智能体迁移了服务地址你得翻遍所有调用方才能改完。其次每个智能体的入参格式都不一样有的要 JSON 字符串有的要表单有的要求特定字段名。调用方为了适配不得不写一堆 if-else 和转换函数。还有一个被低估的问题是失败处理。直接调用的时候超时时间各写各的重试策略完全没有某个智能体抖动一下整条业务链路就跟着卡住。这些问题叠加起来最后表现为两个字难搞。Agent-Reach 要解决的就是把这个一团乱麻的现状收敛成一张清晰的表。每个智能体注册进来统一分配名字调用方不再关心具体地址和格式只需要按照 Agent-Reach 定义的消息模型发请求。触达哪个智能体、怎么触达、失败了怎么办全部由 Agent-Reach 负责。2.2 Agent-Reach 的边界管触达不管业务逻辑这里我必须强调一点Agent-Reach 不是业务流程引擎不负责编排智能体之间的先后顺序也不做复杂的条件判断。它的责任范围就是“触达”本身。就好比你打电话Agent-Reach 是那个接线员帮你找到正确的人、拨通电话、如果对方没接就按规矩重拨但电话接通之后聊什么那不是接线员的事。把这个边界划清楚非常重要。一旦边界模糊什么都往 Agent-Reach 里塞它就会从轻量调度层变成一个大泥球。我们在设计初期就定了一条规矩任何智能体的业务逻辑、提示词构建、结果解析都不允许在 Agent-Reach 中出现。它只负责把请求送出去把响应拿回来把过程记录好。2.3 统一收口后三个直接收益把触达统一收口之后最直观的收益是调用方代码大幅瘦身。原来一个调用方可能要写一百多行的接口适配现在只需要告诉 Agent-Reach“我要触达哪个智能体、传什么参数”剩下的地址管理、序列化、超时控制都不需要自己操心。第二个收益是排查问题变得顺畅。以前排查一次跨智能体调用失败要先确认是调用方的代码问题还是被调方服务挂了还是中间被超时切断。现在 Agent-Reach 会自动记录一次触达的完整轨迹——从收到请求、到路由决策、到发起调用、到响应返回每个环节都有时间戳和状态。只要看轨迹问题出在哪一段清清楚楚。第三个收益可能是我个人最看重的——新增智能体变得非常简单。新智能体上线只需要在 Agent-Reach 里注册一下提供健康检查接口调用方那边一行代码都不用改。灰度放量、全部切换、紧急下线都在 Agent-Reach 侧完成。这种“改一处全部生效”的爽感用过就回不去了。3. Agent-Reach 的核心设计与关键数据结构3.1 三层消息模型请求体、响应体、轨迹体设计 Agent-Reach 的时候我第一个确定下来的内容就是消息模型。消息模型是调度的地基地基不稳后面的路由和重试都白搭。我参考了业界一些消息总线的经验把消息拆成三层。第一层是请求体调用方发来的标准格式。长这样{ agent: intent_classifier, action: analyze, request_id: req_8f6a1c2e, payload: { text: 这个订单我要退款, language: zh-CN }, timeout_ms: 3000 }agent 字段标明要触达的智能体名字action 是动作名称payload 是业务参数timeout_ms 是本次调用的超时上限。这里的核心思想是——调用方只需要表达“我要什么”不需要关心“怎么去”。第二层是响应体统一从智能体那里收回来的格式{ request_id: req_8f6a1c2e, status: success, data: { intent: refund, confidence: 0.97 }, cost_ms: 212 }status 只有 success、fail、timeout 三种。data 放业务结果cost_ms 记录智能体实际处理耗时。第三层是轨迹体专门用来记录一次触达的完整过程不返回给调用方而是写入日志或监控系统。这一步是排查问题的关键。轨迹体包含路由决策、候选地址列表、每次尝试的耗时与错误信息、最终结果。后面排障那部分我会详细讲它怎么用。3.2 智能体注册表用健康检查筛选可用目标Agent-Reach 里面有一个核心组件叫注册表所有智能体必须先注册才能被触达。注册时至少需要提供三样信息字段说明示例name智能体唯一名字intent_classifierendpoints可用请求地址列表https://service-a.internal/intent, https://service-a-backup.intranet/intenthealth_check自定义健康检查函数lambda: requests.get(/healthz).ok这里最容易被忽略、但实际价值最大的是 health_check。Agent-Reach 会定期执行健康检查把状态异常的智能体自动标记为不可用。路由调度的时候只从健康的智能体里选目标这样就避免了很多“打到挂掉的机器上再傻等超时”的情况。注册表的实现我用的是 Python关键代码如下class AgentRegistry: def __init__(self): self._agents {} def register(self, name: str, endpoints: list, health_check: callable None): agent_conf { endpoints: endpoints, health_check: health_check, healthy: True, } self._agents[name] agent_conf return agent_conf def mark_health(self, name: str, healthy: bool): if name in self._agents: self._agents[name][healthy] healthy def get_healthy_agent(self, name: str): agent self._agents.get(name) if not agent or not agent[healthy]: return None return agent你可能注意到了健康检查函数是允许自定义的。默认情况下我们用 TCP 连接测试但不同智能体的健康度判断标准其实不同。有的是看进程活没活有的是看队列深度有的是看模型加载状态。如果统一用一种方式测误判率很高。这一点我在实践经验部分还会再提。3.3 路由策略的选择为什么先做轮询而不是加权路由策略是 Agent-Reach 的决策核心。同一个智能体注册了多个 endpoint 的时候到底选哪个我见过不少团队一上来就搞复杂的加权策略结果权重配不好反而把流量全部导到一个节点上。我的建议是先做最简单的轮询。轮询策略的问题在于它假设所有 endpoint 的吞吐能力相同这在很多场景下并不成立。但它的优点也很突出实现简单、行为可预测、不需要额外配置。等系统跑起来有了真实流量数据再过渡到加权轮询也不迟。Agent-Reach 的轮询实现基于一个计数器每次调度时取模选下标import itertools class RoundRobinPicker: def __init__(self): self._counter itertools.count() def pick(self, endpoints: list) - str: if not endpoints: raise RuntimeError(empty endpoint list) idx next(self._counter) % len(endpoints) return endpoints[idx]加权版本也很容易扩展关键是权重的数据来源。我们在生产里有了一套指标之后会把过去五分钟内每个 endpoint 的平均响应时间、错误率、负载情况换算成动态权重。这样流量就能自动向表现好的节点倾斜但我强烈建议这个功能在跑通基础版本之后再上不要一上来就追求“高级”。4. 触达调度的完整实现流程4.1 入口与超时控制从请求进来到发出调用的第一道门调度的入口是一个统一函数 reach。调用方传参进来Agent-Reach 负责后续所有逻辑。我把 reach 的骨架贴出来这段代码是整个 Agent-Reach 最核心的一部分import time import uuid class AgentReach: def __init__(self, registry, picker): self._registry registry self._picker picker def reach(self, agent: str, action: str, payload: dict, timeout_ms: int 3000): request_id uuid.uuid4().hex trace { request_id: request_id, agent: agent, action: action, spans: [], final_status: None, } deadline time.time() timeout_ms / 1000.0 agent_conf self._registry.get_healthy_agent(agent) if not agent_conf: trace[final_status] no_healthy_agent return {status: fail, request_id: request_id, reason: no_healthy_agent} endpoints agent_conf[endpoints] last_error None while time.time() deadline: endpoint self._picker.pick(endpoints) span self._do_call(endpoint, action, payload, deadline) trace[spans].append(span) if span[ok]: trace[final_status] success return { status: success, request_id: request_id, data: span[data], cost_ms: span[cost_ms], } last_error span[error] if span.get(type) business_error: break trace[final_status] fail return { status: fail, request_id: request_id, reason: flast_error{last_error}, }这里有两个容易出错的地方。第一个是超时控制必须在 while 循环内部检查剩余时间而不是简单地在每次请求里固定 timeout。为什么因为重试之后每次可用时间其实是递减的。如果用固定的每轮超时五轮重试就可能把整体耗时拉到数倍于预设上限这下就违背了触达的初衷。第二个容易踩坑的地方是 business_error 的判断。如果智能体本身处理不了业务逻辑返回的是一个业务错误比如“缺少必要字段”这种错误无论重试多少次都是同样的结果。所以 Agent-Reach 遇到业务错误就直接跳出重试循环不再空耗时间。这个判断通常依赖智能体在响应里带上一个错误类型标记我们约定error.type business就表示业务错误。4.2 实际调用如何优雅地发出一个请求do_call 是真正发起触达的函数。我在实现时特意把它跟路由逻辑分开这样可以单独给 do_call 写缓存、加 token、做限流不影响整体结构。第一版 do_call 我做得很朴素就是发起一个请求返回结构化结果import requests def _do_call(self, endpoint, action, payload, deadline): start time.time() remain_ms max(int((deadline - time.time()) * 1000), 100) try: resp requests.post( endpoint, json{ action: action, payload: payload, }, timeoutremain_ms / 1000.0, ) if resp.status_code ! 200: return {ok: False, error: fhttp_{resp.status_code}, cost_ms: int((time.time() - start) * 1000)} data resp.json() if data.get(status) success: return {ok: True, data: data.get(data), cost_ms: int((time.time() - start) * 1000)} if data.get(error, {}).get(type) business: return {ok: False, type: business_error, error: data[error][message], cost_ms: int((time.time() - start) * 1000)} return {ok: False, error: data.get(error, {}).get(message, unknown), cost_ms: int((time.time() - start) * 1000)} except requests.Timeout: return {ok: False, error: timeout, cost_ms: int((time.time() - start) * 1000)} except requests.RequestException as exc: return {ok: False, error: str(exc), cost_ms: int((time.time() - start) * 1000)}这段代码里面有几个细节值得展开讲。第一每个智能体接口的响应必须遵循我们定好的结构。这里包含“status”“error.type”“error.message”这几个字段缺一不可。你可能会问如果智能体是老服务响应格式改不了怎么办我的建议是加一层适配器在 do_call 里面做格式转换。适配器本质上是一个映射函数把老接口的响应格式转换成 Agent-Reach 约定的格式。转换这层逻辑放在 Agent-Reach 内部调用方不需要感知。第二我保留了 response.status_code 的判断200 之外一律视作失败。但实际生产里有些服务即使业务处理失败也返回 200所以在代码里我依然会检查 data.status 字段双重校验。只信 HTTP 状态码是很多系统误判的根源这个坑我刚开始也踩过。第三异常捕获时把具体异常信息塞进 error 字段。这样排查问题的时候轨迹里能看到完整的错误信息而不是一个干巴巴的 fail 状态。4.3 重试策略与指数退避的细节实现虽然上面的代码已经包含重试循环但我还要单独讲讲重试策略的设计。因为一旦处理不好重试不仅救不了失败还会压垮后端智能体。Agent-Reach 的重试策略有三个参数初始退避时间、退避倍数、最大退避时间。默认初始退避 200ms倍数 2最大退避 5s。这个参数组合是我在实测里试出来比较稳的能避免打爆服务又不至于让调用方等太久。重试之间为什么要退避可以举个例子。某个智能体因为瞬时流量过大处理能力下降如果你无脑地立刻重试它只会更忙甚至会从短暂的过载变成长时间的崩溃。加退避本质上是给后端一个喘息的机会。就好比跟人打电话对方占线说明正忙着你等一会儿再打才有意义不停打反而让对方烦躁。指数退避的时间计算逻辑如下import random def next_backoff(attempt: int, base_ms: float 200, factor: float 2.0, max_ms: float 5000): exp min(base_ms * (factor ** attempt), max_ms) # 加一点随机抖动避免多个重试同时发起 jitter random.uniform(0.8, 1.2) return exp * jitter随机抖动是很多人容易忽略的。假设有五十个调用方同时遇到同一批失败如果它们按完全相同的退避时间重试那退避就失去了分散流量的意义反而会形成新的流量峰。抖动本质上就是让每个重试的时间在合理范围内略微错开这样整体的重试流量会平滑很多。4.4 轨迹记录把一次触达完整地留在日志里Agent-Reach 区别于普通封装的一个重要特性就是轨迹记录。我在每次 reach 调用时生成了 trace它记录了路径上发生的每一件事。每个 span 包含请求的 endpoint、开始时间、耗时、是否成功、错误信息。而我要做的是把这些轨迹稳定地输出。我第一版的轨迹记录是直接写日志文件但很快发现问题一次触达可能有多轮尝试而日志是分散的。排查问题的时候我得人工把同一个 request_id 的日志捞出来再拼起来非常痛苦。后来我把轨迹改成了按 request_id 归组的独立 JSON 行每行包含完整 trace用 ELK 收集。这样拿到一个 request_id直接搜索就能看到完整链路。轨迹记录还有一个额外的好处统计真实耗时。所有智能体的自报耗时都不如 Agent-Reach 侧记录的总耗时可靠。因为总耗时里包含网络时间、排队时间、重试时间这才是调用方的真实体验。我用这些数据做了一份内部的智能体服务健康报告哪些智能体拖慢了业务一目了然。5. 常见问题与排查技巧实录5.1 排查问题先看轨迹不要猜Agent-Reach 上线两周后我们遇到过一个比较典型的问题。某智能体偶发性地超时调用方反复反馈“这次慢下次又好了”。按照以前的习惯我们可能会去后端服务翻日志看当时的负载和耗时折腾好一阵子才能定位。但用了 Agent-Reach 之后排查路径完全不一样了。直接根据 request_id 查轨迹看到的结果是短短几百毫秒内连续尝试了三个 endpoint前两个都失败错误信息分别是连接被拒和超时第三个成功了。所以问题的本质不是同一个服务偶发慢而是三个 endpoint 里有两个已经处于不健康状态健康检查没能及时把它们标记掉。这个案例教会我一件事健康检查的频率和判据必须认真设计。我们最初是每 30 秒检查一次但如果某个 endpoint 在两次检查之间挂掉了这 30 秒内的调用就仍会打到它头上。后来我把健康检查频率调整到 10 秒一次同时增加了一个快速失败开关——连续两次连接被拒的直接置为不健康。5.2 健康检查误判的一个坑只测端口不测服务就绪健康检查看上去很简单但做的时候很容易出幺蛾子。我们有过一个教训某智能体的健康检查只是检测端口是否能连通结果端口一直通着但后端进程已经死锁所有请求都处理不了。Agent-Reach 以为它正常实际它已经没法服务了。这种健康检查就变成了假的绿灯。真正靠谱的健康检查必须回到业务本身。比如一个智能体它的核心能力是文本分类健康检查就调用一个最简单的分类接口确认它能正常返回结果。如果它能响应我们就认为它是健康的。如果连这个最小能力都不行那就算端口通着也该被标记为不健康。不过这里也有一个平衡问题。健康检查越贴近业务检查本身的开销就越大。每个智能体每隔十秒就被打一次真实请求对服务也是一种负担。我的折中方案是端口检查保持高频比如五秒一次业务健康检查降低频率三十秒一次。两个结果一起决定健康状态既要活着也要能干活。5.3 常见问题速查表我把 Agent-Reach 上线以来遇到的典型问题整理了一张表方便你对照排查现象可能原因排查建议触达成功率低且多个 endpoint 失败健康检查周期过长后端挂掉后未被及时发现缩短端口检查周期增加失败快速标记调用成功率没问题但延迟很高重试次数过多每次失败都打到超时检查轨迹里的 span 数量和耗时考虑降低超时业务错误被当成失败一直重试没有区分业务错误与系统错误在响应体中增加 error.type 字段业务错误立即中止重试智能体正常但注册表里却显示不健康健康检查函数自身报错给 health_check 包裹 try/except健康检查异常时默认为不健康多个 endpoint 压力不均轮询策略未考虑实例差异过渡到加权轮询按响应时间和错误率动态调权重调用方收到了超时但后端日志显示请求已处理响应在超时后才返回但业务已执行幂等设计配合 request_id 做去重防止重复处理最后一项值得多说两句。超时后重试往往会带来重复执行的问题。如果某个智能体处理了请求但响应回传时超时了Agent-Reach 会重试。此时后端如果已经处理完这次请求又收到同样的请求就可能出现重复动作。这个问题的标准解法是幂等设计——智能体接口接收 request_id处理前先查重如果发现这个 request_id 已经处理过直接把上次的结果返回不再重复执行。5.4 经验从“能用”到“好用”的几个细节Agent-Reach 我写到了第四版才觉得顺手。前几版并不是不能跑而是在细节上总是差点意思。有一个细节是请求体里的 request_id这个字段一开始我没有强调后来发现它是排查一切问题的锚点现在所有调用方必须传。如果调用方不传Agent-Reach 会自己生成一个再返回给调用方但总归不如调用方统一生成更利于全链路追踪。还有一个细节是并发控制。Agent-Reach 作为一个集中调度层如果处理并发请求的能力不足反而会成为新的瓶颈。早期我们直接用同步的 requests 库发请求性能非常一般。后来接入了异步和连接池并发能力明显提升。但异步改造的复杂度比较高如果你当前的并发量不大同步实现也能应付先跑通再优化也不迟。另一个细节是配置化。Agent-Reach 的重试参数、超时时间、健康检查周期最开始都是写在代码里的。后来我改成配置文件统一管理避免改参数就要重新发版。随着接入 Agent-Reach 的团队越来越多大家经常要求针对单个智能体单独调整超时时间这个需求也是通过配置覆盖机制实现的。配置文件里默认值是一份针对特定智能体的覆盖项是另一份逻辑清晰维护也方便。这样的人也可以继续扩展Agent-Reach 这个设计思路从核心调度层出发后续能扩展的方向其实很多。比如现在流行的更细粒度编排可以把 Agent-Reach 接入到工作流引擎里让工作流定义文件里直接引用智能体名字实现可视化编排。再比如把轨迹记录标准化接入 OpenTelemetry 生态这样不只 Agent-Reach 内部能看到链路整个技术栈的统一监控也能看到。这一类扩展本质上都不需要推翻 Agent-Reach 现有的设计只需在外围加适配层收益却很明显。我自己的体会是Agent-Reach 最大的价值不在于代码写得多巧妙而在于它逼着我们所有人统一了对“智能体触达”这件事的理解。调用方不用再管怎么触达被调方不用再操心被谁触达中间的所有复杂情况由 Agent-Reach 兜底。这种职责划分带来的秩序感比任何花哨的算法都更有用。如果你也在被多智能体调用问题折磨不妨从一个小注册表加一个调度函数开始先把触达这件事理顺了再做更复杂的协同。
返回列表