ARTICLE DETAIL

资讯详情

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

Agent-Reach:打造多Agent协作的可靠触达与调度基座

Agent-Reach:打造多Agent协作的可靠触达与调度基座 如果你最近也在折腾多智能体系统肯定有过这种体验单个Agent做点小工具挺顺一旦需要多个Agent配合干活任务怎么送出去、结果怎么收回来、中途挂了怎么办全成了麻烦。这个项目叫Agent-Reach是我把这些麻烦集中解决掉之后沉淀出来的一层“触达基座”——管的就是一件事把任务可靠地送到合适的Agent手里再把结果接回来。Agent-Reach不负责Agent的“脑子”不管它怎么推理、怎么生成内容只管智能体之间的通联与调度。它是整个多Agent系统里的毛细血管让上游系统不用关心下游Agent到底部署在哪、用什么协议、忙不忙、挂没挂统一按一套规则把任务发出去、把结果收回来。这篇文章适合正在做Agent工程化、想把多个独立Agent串成真实业务闭环的人读无论你用的还是自研框架这套思路都能直接落地。1. 项目定位为什么需要一层专门的“Agent触达层”1.1 多Agent协作最常见也最痛的三个问题我最早做多Agent系统时写的是一个串行流程A Agent写提纲B Agent写正文C Agent校对。代码看起来很简单就是一个个HTTP请求调过去。结果一上真实任务就出事。第一个问题是下游Agent卡住。某个Agent处理一个长文档时耗了30秒、60秒甚至直接不返回。上游HTTP客户端一直挂着线程池被打满后面所有任务全部排队整个协作链路从一个慢Agent开始雪崩。第二个问题是失败不可控。直接调用下游Agent时返回的错误千奇百怪有的是超时有的是内部异常有的干脆连不上。每个调用方都要自己写重试逻辑重试策略还不统一经常出现同一批任务被不同上游各重试一遍下游接口被冗余请求打到冒烟。第三个问题是没法追踪。任务在哪个Agent手里、跑了多久、状态是什么完全靠日志碰运气。一旦某个环节出错想定位是哪一步断的得翻好几套系统的日志对时间戳。这些问题不是靠写代码能压住的需要一个统一的抽象层来解决。这也是我做Agent-Reach的初衷。它把“给Agent派活”这件事从业务代码里抽出来变成一个独立的触达基础设施。业务方发任务时只需要关心三件事给谁、带什么数据、期望什么结果。至于怎么路由、怎么重试、怎么处理超时全部由触达层接管。1.2 Agent-Reach的边界只做触达不碰“脑子”在设计Agent-Reach之初我给自己定了一条铁律不沾Agent的业务逻辑。触达层不解析用户意图不做Prompt工程不参与内容生成。它只处理智能体之间的“物流问题”任务如何进、如何分配、如何确认送达、如何拿回结果。为什么这么划分因为Agent逻辑变化极快。Prompt改了、模型换了、工作流调了几乎每周都在变动。如果把这些易变逻辑和调度逻辑耦合在一起任何一次业务调整都可能导致调度链路返工。把触达层单独拆出来之后业务Agent可以随时替换只要它遵循统一的接入协议触达层根本不用动。注意我这里的Agent指的是智能体也就是一段能独立完成任务的程序实体可以是一个封装好的模型推理服务也可以是一个带工具调用的自动化脚本。Agent-Reach就是在这些实体之间建立一条稳定、可治理的“任务传送带”。1.3 架构选型轻量封装还是独立服务做触达层时第一个要决策的问题是做成一个独立的进程/服务还是在现有业务代码里做一个库我对比过两种方案。轻量封装方案最省事把触达逻辑写成一个库直接在业务进程里用部署成本几乎为零性能也最好。但问题在于它只能管单机内的Agent。只要你的系统需要跨进程、跨机器、甚至跨团队维护轻量封装的边界一下就破了每个业务方都得各自部署一份任务状态依然分散重试逻辑还是各写各的治理能力聊胜于无。独立服务方案牺牲了一点部署简洁性但换来的是统一治理能力。所有任务进出一个地方状态全局可见监控指标统一采集重试策略集中配置这就是Agent-Reach选择独立服务的原因。为了保持性能调度逻辑本身做得足够轻任务在内存里流转只在必要的时候写状态变更记录避免每次任务都走一次重量级持久化。方案部署成本跨进程能力全局可观测性适用阶段轻量封装库极低弱无单机原型验证独立服务中等强强生产级多Agent系统消息队列方案高强中已有底层MQ基础设施如果你的Agent数量少于三个、全在同一个进程里直接写个函数调用就行用不上Agent-Reach这种设计。但一旦Agent开始分布在不同的服务、不同的团队手里独立触达层几乎成了刚需。2. 触达机制核心细节状态机、关键参数与幂等控制2.1 任务触达的状态机设计任务从发起到完结我全程用一个状态机来跟踪。这个状态机是整个触达层最骨架的部分所有调度逻辑、重试逻辑、监控告警都围着它转。状态定义如下状态含义可流转到PENDING任务已受理排队中RUNNING, CANCELLEDRUNNING已发给目标Agent等待结果SUCCEEDED, FAILED, TIMEOUTSUCCEEDED目标Agent返回了有效结果终态FAILED目标Agent明确报错终态或按策略进入RETRYINGTIMEOUT等待结果超过阈值终态或按策略进入RETRYINGRETRYING等待退避后重新触达RUNNINGCANCELLED任务被人工取消终态设计时我特意把TIMEOUT和FAILED分开了。因为它们的语义完全不一样FAILED是目标Agent明确说“我做不了”说明任务本身有问题重试大概率也没用TIMEOUT是目标Agent“没来得及回话”常见原因可能是它忙、网络抖动重试价值高。如果混在一起处理重试策略就没法做到精准。这个状态机最大的价值是可恢复。任务跑到一半进程崩溃重启后扫描一遍所有非终态的任务把它们重新置为PENDING或RETRYING就能继续跑不会因为一次重启丢任务。2.2 超时、重试、限流参数怎么定参数设计是触达层最容易翻车的地方。我一开始用过拍脑袋式配置超时统一给30秒结果下游卡住导致整个链路瘫痪。后来总结出一套相对科学的参数计算方法。首先是超时。超时时间不能是全局统一的必须按任务类型拆分。我把任务分成两类轻任务查数据、调用工具、短问答和重任务长文档生成、深度检索、复杂规划。轻任务首超时给5秒重任务给30秒。这个数值不是我拍脑袋定的而是基于对下游Agent接口P99耗时的统计。做法很简单先在压测环境跑一周统计每个Agent的P99耗时首超时时间设为P99的1.5倍。500毫秒能完成的任务给它5秒超时已经足够容忍毛刺又不会让调用方无止境等下去。然后是重试。重试不是越多越好关键在退避策略。我采用指数退避加抖动第一次重试等1秒第二次等2秒第三次等4秒每次加上不超过200毫秒的随机抖动最多重试3次。抖动是为了防止多个任务同时失败后重试请求再次同时打到下游造成二次冲击。超时值则固定为首超时的一半也就是说重试时不再给完整30秒只给15秒这样能更快判断下游到底行不行。限流是最容易被忽略的。触达层必须清楚每个下游Agent能扛多大并发。我给每个Agent维护了一个令牌桶桶容量等于下游实例数乘以单实例并发数。比如下游Agent挂在4个实例上、每实例最大并发10那令牌桶容量就是40每秒补充速率按下游P95处理量来算。队列里的任务发现下游令牌不足时果断失败并进入RETRYING而不是无限排队。2.3 路由规则与幂等控制触达层要做的不只是“把任务发出去”还要决定“发给谁”。当同一类Agent有多个实例时路由策略就很关键。我实现了三种路由策略。第一个是轮询按权重轮流分配适合各实例能力对等的情况。第二个是亲和性路由同一个调用方的任务尽量发到上次成功处理过的实例上。这个策略对缓存友好的Agent特别管用能显著降低下游重复计算的成本。第三个是负载感知路由触达层定时探测每个实例的排队深度和响应延迟优先选择最空闲的实例。生产环境下我默认用负载感知它对突发流量最友好缺点是实现会稍微复杂一点。幂等控制是触达层不能偷懒的环节。发生重试时下游Agent可能已经处理过这个任务重复执行会产生脏数据。我统一靠幂等键解决每次任务生成一个task_id同时允许调用方额外传一个业务幂等键比如“订单修复-20250101-001”。在下游Agent的接入协议里强制要求支持幂等校验重复收到相同幂等键的任务时直接返回上次的结果不再执行。实测下来这套方案能让重复执行的概率从几乎必然降到零代价只是下游多存一个键值对。3. 实操完整实现一个最小可用Agent-Reach3.1 基础数据结构设计具体实现时我先从最小可用的核心开始不急着做管理界面先把调度通路跑起来。数据模型我用了一个Task类和一个Agent注册表。from dataclasses import dataclass, field from datetime import datetime from enum import Enum import uuid class TaskStatus(Enum): PENDING PENDING RUNNING RUNNING SUCCEEDED SUCCEEDED FAILED FAILED TIMEOUT TIMEOUT RETRYING RETRYING CANCELLED CANCELLED dataclass class Task: task_id: str field(default_factorylambda: uuid.uuid4().hex) agent_name: str payload: dict field(default_factorydict) priority: int 0 status: TaskStatus TaskStatus.PENDING retry_count: int 0 max_retries: int 3 timeout_seconds: int 10 created_at: datetime field(default_factorydatetime.utcnow) updated_at: datetime field(default_factorydatetime.utcnow)task_id是无条件生成的全局唯一payload是传给下游Agent的业务数据触达层只做透传priority用于调度排序我规定数字越小优先级越高。状态和重试计数是调度循环运转的依据。Agent注册表本质是一个字典记录每个Agent的接入地址、支持的协议、令牌桶参数和路由权重。dataclass class AgentEndpoint: agent_name: str protocol: str # http | grpc | local address: str weight: int 1 max_concurrency: int 10 p99_seconds: float 1.0 agent_registry {} def register_agent(endpoint: AgentEndpoint): agent_registry[endpoint.agent_name] endpoint注意Protocol我一开始只支持HTTP后来才加了gRPC和本地调用。把protocol作为一个字段而不是硬编码是为了后面扩展不同接入方式时不用改主流程。3.2 调度主循环与触达执行核心调度逻辑我写成一个可持续运行的主循环每100毫秒扫描一次待处理队列。扫描频率太高浪费CPU太低会增大任务延迟实测100毫秒在多数场景下足够平滑。import time import threading pending_queue [] running_tasks {} def submit_task(task: Task): pending_queue.append(task) def dispatch(task: Task): endpoint agent_registry.get(task.agent_name) if not endpoint: task.status TaskStatus.FAILED return task.status TaskStatus.RUNNING task.updated_at datetime.utcnow() running_tasks[task.task_id] task.start_time # 记录开始时间 # 按协议分发这里只写HTTP分支 response call_http_agent(endpoint, task) if response.status 200: task.status TaskStatus.SUCCEEDED running_tasks.pop(task.task_id, None) elif response.status 500: handle_failure(task, reasonserver_error) elif response.status 408 or response.status 504: handle_failure(task, reasontimeout) else: handle_failure(task, reasonunknown) def handle_failure(task: Task, reason: str): task.retry_count 1 if task.retry_count task.max_retries: task.status TaskStatus.FAILED running_tasks.pop(task.task_id, None) return task.status TaskStatus.RETRYING # 等退避时间后重新放回队列 backoff_seconds 2 ** (task.retry_count - 1) random_jitter() threading.Timer(backoff_seconds, lambda: pending_queue.append(task)).start() def schedule_loop(): while True: now time.time() # 先检查所有RUNNING任务是否超时 for task_id, start_time in list(running_tasks.items()): if now - start_time task_map[task_id].timeout_seconds: handle_failure(task_map[task_id], reasontimeout) # 处理PENDING队列 if pending_queue: task pending_queue.pop(0) dispatch(task) time.sleep(0.1)这个实现去掉了负载感知和令牌桶的细节但骨架是完整的。每次循环先扫一遍RUNNING任务有没有超时再从队列头部取任务派发。我把超时检查放在派发之前是为了尽早发现卡死的任务并触发重试而不是等队列空下来才处理。调度循环还有一个关键字状态集中管理。不管任务最终成功还是失败触达层的任务状态表里都会留下记录。这个记录就是全链路追踪的锚点业务方查任务进展时直接问触达层“这个task_id现在在哪个状态”不用再翻下游日志。3.3 接入现有Agent的两种形态让现有Agent接入Agent-Reach我试过两种方式各有各的适用场景。一种是包装器方式。不改动Agent内部逻辑在Agent前面加一个适配层把Agent的普通接口转换成触达层认识的协议。它的优势是无侵入老Agent改造成本极低适合快速接入存量系统。缺点是包装器本身也要维护而且包装器做不了太复杂的事只能做一下请求转发和幂等校验。另一种是网关方式。Agent暴露一个统一入口所有外部请求都走这个网关网关负责校验幂等键、做限流、把请求转给Agent实际处理单元。这种方式接起来更正规Agent团队能统一控制自己的出口策略适合作为长期基础设施。缺点是Agent侧要开发网关初期投入高一些。我生产上两种形态都保留了存量Agent用包装器过渡新开发的Agent直接按网关方式接入。等存量系统逐步改造完包装器会慢慢退场。反正触达层对外协议从一开始就定好了Agent换接入方式不影响上游调用。4. 应用场景Agent-Reach在实际系统里怎么发挥作用4.1 多Agent内容生产流水线第一个跑通的场景是一条内容生产流水线选题Agent产出大纲写作Agent扩写正文校对Agent检查语病和事实错误最后排版Agent生成成品。这条链路以前是硬编码串行调用一个环节失败整条链路重来。接入Agent-Reach之后每个环节变成了独立任务。比如写作Agent调度超时了触达层按退避策略自动重试重试三次仍失败任务直接进入FAILED并回调通知上游此时选题Agent的大纲结果还完好地存在任务记录里人工介入后可以单独把写作任务重新投递不需要从选题重跑。这里有一个实用经验在设计流水线时我把每个Agent任务的产出物存储路径固化在payload里。A环节把结果写到共享存储B环节的payload里带上这个存储路径而不是把整个内容体传来传去。触达层只搬运路径和状态内容体在存储层流转大幅减少了任务数据体积也降低了超时概率。4.2 系统故障自愈与可观测性Agent-Reach在运维场景里的用法更有意思。监控Agent发现某个服务实例响应变慢后触达层会自动把“执行诊断”任务发到诊断Agent把“执行重启”任务发到修复Agent并且要求修复Agent在执行前回传一次确认结果。这个场景对结果确认的要求很严格。修复Agent不能“发完重启命令就算成功”它必须等到服务健康检查通过才能返回SUCCEEDED。在触达层的超时设置上这类操作型任务的超时给到90秒重试次数反而只给2次避免连续的修复操作把服务再搞崩一遍。可观测性方面触达层天然提供了一个统一监控点。每个Agent的成功率、P99耗时、重试率、超时次数都能从触达层的任务记录里统计出来。这样一来运营团队不用在每个Agent服务里单独埋监控看一张大屏就能掌握所有Agent的健康度。我甚至把触达状态变更接入了告警系统某个Agent成功率连续三分钟低于阈值就直接告警比之前看日志高效太多。4.3 智能客服触达分流客服场景是路由策略发挥价值最明显的地方。我接手过一个客服机器人系统里面有十几个技能Agent售前咨询、售后退换、价格谈判、物流查询等等。用户问题进来后意图识别服务先判定类型然后把任务通过Agent-Reach发给对应技能Agent。触达层在这里主要做了两件事。优先级调度VIP用户的任务标记为高优先级调度队列里插队处理负载均衡多个相同技能的Agent实例按当前负载动态分流不再出现某个实例忙到崩溃、另一个实例闲到没事干的情况。还有一个实战细节当用户情绪负面时客服系统会把“安抚话术Agent”的任务优先级调高并且要求该Agent在30秒内必须返回结果。这个需求以前很难实现因为各Agent都是独立服务优先级没法跨服务传递。有了统一的触达层优先级变成了任务的标准字段调度策略自然就带上了业务含义。5. 常见问题与排查技巧实录5.1 超时风暴一次重试让下游彻底瘫痪Agent-Reach上线后遇到的第一次重大事故是超时风暴。某次下游数据库抖动一个关键Agent响应变慢大量任务超时。触达层按重试策略开始退避重试但因为任务基数大即便退避也产生了不小的冲击下游Agent在原本就吃紧的情况下又被重试请求压垮最终两个Agent一起雪崩。这次事故教会我一件事重试退避必须搭配熔断机制。现在触达层里增加了连续失败熔断器某个Agent连续失败达到5次后熔断器打开新任务直接进入RETRYING状态冷却90秒不再实际派发。冷却期过后先放一个验证任务成功了才关闭熔断。这个机制防住了后续所有类似场景再也没有因为重试造成二次故障。5.2 任务重复执行幂等键没落实的教训有段时间我接到反馈说同一个写文档任务被执行了两次生成了两份差不多的内容。排查到最后发现原因是下游Agent收到了重试请求但没做幂等校验老老实实又跑了一遍任务。这次教训让我把幂等校验往前提了。触达层在派发任务时不光把task_id传给下游还生成了一个Verification Code其实就是带哈希的幂等键要求Agent端按规范校验。对于来不及改造的旧Agent则通过包装器在Agent前面做一层缓存重复的幂等键直接返回历史结果。从这以后重复执行类的事故几乎绝迹。5.3 排查工具箱日志、状态变更、链路追踪三件套触达层出问题时我的排查顺序基本固定。第一步查状态机这个task_id现在是什么状态、转移时间是什么时候、重试了几次。状态机已经把80%的故障点了出来。第二步查触达日志任务派发到哪个Agent、返回了什么状态码、耗时多少。第三步才查下游系统日志。这个顺序比一上来就翻分布式日志高效得多因为触达层已经帮你做了第一层归因。链路追踪是最后兜底的。每个任务从进入触达层开始就带一个trace_id触达层在任务payload里透传给下游AgentAgent记录日志时把这个trace_id带上。整个链路串起来之后跨系统排查问题的时间从小时级降到了分钟级。对多Agent系统来说触达层把trace_id标准化价值不比一套APM低。这套东西我前后调了两周过程里有不少返工但最终沉淀下来的状态机设计、断路器机制和幂等协议直到现在都还在稳定支撑业务。最后再多说一句个人体会做多Agent系统别急着上花哨的功能先把任务进得来、送得到、状态查得见这三件基本功打牢后面怎么扩展都不慌。
返回列表