YAOTU INSIGHTS

Agent-Reach实战:构建多Agent触达与编排的调度中间层

Agent-Reach实战:构建多Agent触达与编排的调度中间层
1. Agent-Reach 到底是什么先把这个名字拆开看Agent-Reach 这个项目名第一眼看起来像个工具库的代号实际上它解决的是一个我踩过很久、一直想彻底解决的问题当系统里不止一个 Agent 时怎么让它们稳定、可控、低延迟地找到彼此并且完成一次调用。我最早做多 Agent 自动化时套路很简单把所有 Agent 塞进一个进程按顺序调用。功能能跑通但一加业务场景就崩。比如需要同时调用三个不同能力的 AgentA 挂了 B 还傻等C 的结果没人收最后整个任务直接报废。Agent-Reach 就是在这种情况下被逼出来的它的目标很直接把“Agent 之间如何互相触达”这一层单独抽象出来做成一个清晰的调度中间层。这个名字我拆成两部分来理解。Agent 指业务里的智能体单元它可能是一个本地脚本、一个 API 服务、一个模型封装甚至是一段可执行的命令。Reach 强调的是“可达性”也就是让调用方知道目标 Agent 在不在线、能不能响应、响应要多长时间。所以 Agent-Reach 本质上是一个面向多 Agent 场景的触达与编排层负责代理的注册、发现、调用路由、超时重试和结果回传。这个项目适合谁写 AI 应用的人、做自动化流水线的人、维护一堆内部服务但不想引入全套微服务框架的团队还有那些对“单体 Agent 够用但扩展吃力”有明确体感的开发者。它不是要替代流程编排工具而是补齐它们没做好的那层基础能力。下面我把整个项目从设计到落地完整复盘一遍。2. 整体设计与核心模块拆解2.1 为什么不能继续用最原始的硬编码调用一开始我的做法是硬编码。每个 Agent 在代码里被直接 import 或者用 HTTP 地址写死调用关系是一张静态图。这种做法在只有两三个 Agent 的时候其实挺好出了问题好查调用链短定位快。但当 Agent 数量增长到十几个而且启动时间还不一样情况就变了。有的 Agent 是常驻进程有的是按需拉起有的只在特定时间段可用。硬编码调用就会出现几个很蠢的问题新加一个 Agent 要改调用方代码某个 Agent 更换了地址所有依赖它的地方都要跟着改上线顺序错了先启动的调用方找不到后启动的服务。Agent-Reach 把这些东西全部打散重新抽象成三个模块注册中心、触达路由器、回传聚合器。调用方不直接依赖某个 Agent 的地址而是依赖 Agent-Reach 提供的统一入口。这样即使底层 Agent 换了端口、换了机器、甚至从 HTTP 换成进程内调用对上层业务来说只是触达路由里的一个配置项变化。2.2 注册中心让 Agent 自己上报状态注册中心是 Agent-Reach 的地基。每个 Agent 启动后先向注册中心登记自己的身份和能力标识。我一开始做过一版“静态注册”也就是启动时从配置文件里读取所有 Agent 列表结果上线一团糟——漏配置、写错端口、机器换 IP 后配置没有同步更新。后来我改成动态注册。Agent 启动时主动上报三个信息Agent ID、触达地址、能力标签。注册中心收到后写入内存表同时记录最后心跳时间。这里有个关键设计注册中心不主动轮询 Agent而是等 Agent 自己上报心跳。主动轮询看似可控但实现起来要额外维护一套同步机制而且轮询频率很难定快了浪费资源慢了发现故障不及时。让 Agent 自己报心跳逻辑简单Agent 之间天然解耦这个方向从一开始就是对的。内存表不是无限增长的每条注册记录都有 TTL。如果某个 Agent 超过 N 秒没有心跳注册中心会把它标记为失联不再分配给调用方。这样做的好处是有些 Agent 崩了之后根本来不及发下线消息TTL 机制能自动兜底。2.3 触达路由器调用不直接打向 Agent触达路由器是 Agent-Reach 里最核心的一层。调用方发起请求时不关心目标 Agent 具体在哪台机器上、用的什么协议它只需要告诉路由器“我要调用具备某能力标签的 Agent”。路由器拿到请求后先查注册表找到所有匹配的 Agent然后根据负载策略选一个把请求转发过去。这里有个设计取舍路由器是一个独立进程还是嵌入在调用方进程里我试过两种方案。独立进程的好处是全局统一所有调用都能汇聚到一个地方做监控和限流坏处是多了一层网络开销本地调用要绕一圈。嵌入进程的方案延迟更低但如果业务进程太多每个进程都要各维护一份注册表连接一致性要自己保证。最终我采用的是“混合式”同机部署时走进程内路由跨机调用走独立路由节点。业务侧感知不到区别路由器对上层暴露的接口是一致的。这个方案在实际项目中稳定性最好后面我会在实操章节给出具体实现。2.4 回传聚合器一次请求可能对应多个 Agent很多多 Agent 场景不是一问一答而是“一次请求要同时触达多个 Agent最后把结果拼起来”。比如生成一份行业分析报告需要让信息收集 Agent 抓数据、让分析 Agent 算指标、让文案 Agent 出摘要。三个 Agent 是并行调用的响应时间不一致谁先谁后不确定。如果业务代码里写死顺序等待整体耗时就会被最慢的那个拖死。回传聚合器就是干这个的。它接收路由器转发回来的多个结果按请求 ID 分组允许部分 Agent 先返回、部分还在处理中。聚合器会维护一个等待窗口窗口内收到的结果立刻合并窗口超时后还没返回的 Agent 走降级逻辑直接标记为超时失败。调用方最终拿到的是一个统一的结果对象里面既包含所有成功返回的结果也包含失败 Agent 的 ID 和失败原因。这里我总结过一个经验聚合器不要试图做太复杂的流程编排比如分支判断、条件跳转那些应该交给上层业务或专门的编排引擎。聚合器只负责“收集、对齐、汇总”三件事职责越单一越不容易出错。3. 核心环节的原理与实现细节3.1 Agent 注册与心跳的完整链路Agent 启动时注册这个动作看起来简单就是往注册中心塞一条数据但实际实现里有几个细节值得注意。注册数据我设计成如下结构{ agent_id: agent-news-collector-01, capabilities: [news_crawl, html_parse, deduplicate], endpoint: http://127.0.0.1:9101, transport: http, heartbeat_interval: 10, timeout: 5 }agent_id 是全局唯一标识capabilities 是能力标签列表触达路由器靠它做匹配。endpoint 是 Agent 的实际访问地址transport 标明协议类型。heartbeat_interval 告诉注册中心这个 Agent 大概多久心跳一次注册中心据此算 TTL。心跳协议我用的很简单Agent 每隔固定时间上报一次POST /heartbeat HTTP/1.1 Content-Type: application/json {agent_id: agent-news-collector-01}注册中心收到心跳后更新最后活跃时间。如果超过 heartbeat_interval 的 3 倍时长没收到心跳就判定失联。为什么不直接用 1 倍或 2 倍因为网络抖动是现实存在的3 倍能容忍一两次心跳丢失又不至于让故障发现时间拖得太久。这个倍率是调参后确定的太灵敏会误杀正常 Agent太迟钝会降低调度有效性。3.2 路由器如何做能力匹配与负载分配路由器收到调用方的请求时请求里带的是能力标签而不是具体 Agent 地址{ request_id: req-20250101-001, capability: news_crawl, params: {url: https://example.com/news} }路由器先在注册表里过滤出所有带 news_crawl 标签且状态存活的 Agent然后进入选路环节。我实现过三种分配策略各有适用的场景策略逻辑适用场景注意点轮询按顺序轮流分配各 Agent 性能均衡慢节点会被平均拖累最小负载选择当前积压任务最少的 Agent各 Agent 性能差异大需要 Agent 上报负载信息一致性哈希按请求里的业务键哈希取模需要相同请求落到同一 Agent增加节点时只迁移少量请求实际线上环境里我用的最多的是最小负载策略。Agent 每次心跳时顺带上当前任务数路由器选路时扫一遍注册表把请求分给任务数最少且存活的那个。这里注意一个坑任务数是 Agent 自己上报的可能滞后只能作为参考不能当精确指标。所以我给最小负载策略加了一个“连续失败熔断”的兜底如果某个 Agent 连续失败超过阈值路由器会暂时不分配新请求给它直到它恢复心跳。3.3 超时与重试机制怎么设计才不踩坑多 Agent 调用和普通 API 调用最大的不同在于Agent 执行任务通常耗时更长。普通 HTTP 接口 1 秒超时很正常但让一个 Agent 去分析一份长文档几十秒都可能不够。所以 Agent-Reach 的超时不是简单设置一个统一的 timeout而是分级处理。第一级是连接超时这个必须设短通常 2 秒以内。如果目标 Agent 根本不响应没必要在这上面耗时间。第二级是等待超时也就是请求已经发给 Agent但 Agent 迟迟不返回结果这个时长根据任务类型可调从几秒到几分钟不等。第三级是聚合器等待窗口我在前面说过它控制的是多个 Agent 并行返回时的最长等待时间。重试机制我只建议在两类场景开启一类是目标 Agent 返回明确的可恢复错误码比如 503、429另一类是网络层直接超时请求可能根本没到达对方。对于业务逻辑错误比如参数不合法、数据格式不对重试没有意义只会放大故障。而且重试必须有次数上限和退避策略我采用的就是经典的指数退避第一次重试等 0.5 秒第二次等 1 秒第三次等 2 秒上限 3 次。3.4 结果回传的幂等与顺序保证回传聚合器接收多个 Agent 的并发返回最怕的问题是“同一个结果被处理了两遍”。原因可能出在网络重传也可能出在路由器重试后旧请求的响应和新请求的响应都到了。解决方式就是给每个请求分配全局唯一的 request_id聚合器内部维护一张表以 request_id 为键。同一个 request_id 的结果即使收到多次也只有第一次会被合并其余直接丢弃。顺序问题也有一个坑。多个 Agent 的结果依次到达聚合器如果按到达顺序合并最后生成的汇总结果就可能出现“上一秒还在用旧数据下一秒翻页变成新数据”的割裂感。我的处理方式是每个 Agent 的结果里带一个时间戳字段聚合器按 agent_id 加时间戳判断是否过期。如果某个 Agent 后续返回了时间戳更晚的版本聚合器会以新结果覆盖旧结果保证同一个 Agent 的结果单调更新。4. 实操过程从零搭建一个最小可运行版本4.1 目录结构与依赖Agent-Reach 的最小版本我建议用 Python 实现依赖极少只需要 Flask 和 requests。整个项目分四个文件注册中心、路由器、聚合器、一个示例 Agent。目录结构如下agent_reach/ ├── registry.py # 注册中心 ├── router.py # 触达路由器 ├── aggregator.py # 回传聚合器 ├── agent_demo.py # 示例 Agent └── config.json # 全局配置我先解释一下为什么拆成四个独立进程而不是一个进程里的四个模块。因为实际部署时注册中心和路由器通常是公共服务Agent 是业务进程各自独立才能灵活扩缩容。本地调试阶段四个进程也可以同时跑互不干扰。核心配置文件长这样{ registry: {host: 0.0.0.0, port: 9000}, router: {host: 0.0.0.0, port: 9001}, aggregator: {host: 0.0.0.0, port: 9002}, heartbeat_ttl_multiplier: 3, default_timeout: 5, max_retries: 3 }4.2 注册中心的实现代码注册中心逻辑很直接维护一个内存字典提供注册、心跳、查询三个接口。import time from flask import Flask, request, jsonify app Flask(__name__) # agent_id - {info: {...}, last_heartbeat: timestamp} agents {} TTL_MULTIPLIER 3 app.post(/register) def register(): data request.get_json() agent_id data[agent_id] agents[agent_id] { info: data, last_heartbeat: time.time() } return jsonify({ok: True}) app.post(/heartbeat) def heartbeat(): data request.get_json() agent_id data[agent_id] if agent_id not in agents: return jsonify({ok: False, error: not_registered}), 404 agents[agent_id][last_heartbeat] time.time() return jsonify({ok: True}) app.get(/agents) def list_agents(): now time.time() result [] for agent_id, record in agents.items(): info record[info] ttl info[heartbeat_interval] * TTL_MULTIPLIER alive (now - record[last_heartbeat]) ttl result.append({ agent_id: agent_id, info: info, alive: alive }) return jsonify(result) if __name__ __main__: app.run(port9000)注意几个细节TTL 判断是实时算的没有用后台定时任务清理这样实现最简单。查询列表时把 alive 状态一并返回路由器和聚合器都不用各自维护状态。如果后续数据量大这个实现可以加一个定时扫描删掉过期记录但现阶段内存字典完全够用。4.3 路由器与重试逻辑的实现路由器是请求入口收到调用请求后匹配 Agent并将请求转发过去。下面是核心转发逻辑重试部分写在一起。import requests import random import time from flask import Flask, request, jsonify app Flask(__name__) registry_url http://127.0.0.1:9000 def fetch_alive_agents(capability): resp requests.get(f{registry_url}/agents, timeout2).json() return [a for a in resp if a[alive] and capability in a[info][capabilities]] app.post(/invoke) def invoke(): data request.get_json() capability data[capability] request_id data[request_id] candidates fetch_alive_agents(capability) if not candidates: return jsonify({ok: False, error: no_available_agent}), 503 # 简单的轮询选路每次随机选一个保证基本负载均衡 target random.choice(candidates) endpoint target[info][endpoint] timeout data.get(timeout, 5) last_error None for attempt in range(3): try: resp requests.post( endpoint /execute, json{request_id: request_id, params: data.get(params, {})}, timeouttimeout ) if resp.status_code 200: return jsonify({ok: True, result: resp.json()}) last_error fagent_error_{resp.status_code} except requests.exceptions.Timeout: last_error timeout except requests.exceptions.ConnectionError: last_error connection_error time.sleep(0.5 * (2 ** attempt)) return jsonify({ok: False, error: last_error}), 502 if __name__ __main__: app.run(port9001)这里我把选路逻辑简化为随机选择只在单节点模式下够用。实际生产环境如果要按最小负载分配需要在 Agent 心跳信息里附带任务数量字段这里为了代码清晰先不做展开。另外注意重试循环里我只对网络超时和连接错误做重试HTTP 返回错误码直接跳出循环这个逻辑和我前面讲的“只重试可恢复错误”一致。4.4 聚合器与并行请求的实现聚合器负责并行分发请求并汇总结果。我用 concurrent.futures 来做并发调用代码会比异步框架简单很多。import requests from concurrent.futures import ThreadPoolExecutor, as_completed def invoke_single(candidate, request_id, params, timeout): endpoint candidate[info][endpoint] try: resp requests.post(endpoint /execute, json{ request_id: request_id, params: params }, timeouttimeout) if resp.status_code 200: return {agent_id: candidate[agent_id], ok: True, result: resp.json()} return {agent_id: candidate[agent_id], ok: False, error: fhttp_{resp.status_code}} except Exception as e: return {agent_id: candidate[agent_id], ok: False, error: str(e)} def aggregate(candidates, request_id, params, timeout5): results {ok: True, request_id: request_id, success: [], failed: []} with ThreadPoolExecutor(max_workerslen(candidates)) as executor: futures { executor.submit(invoke_single, c, request_id, params, timeout): c for c in candidates } for future in as_completed(futures): ret future.result() if ret[ok]: results[success].append(ret) else: results[failed].append(ret) return results这个实现是“全等所有结果返回后再汇总”的模式。如果某个 Agent 特别慢整个聚合过程会卡住所以我在实际版本里给 as_completed 循环加了超时控制到达等待窗口后就不再等待剩余未完成的任务直接标记超时。超时窗口的默认值我设成所有候选 Agent 超时时间的最大值这样至少能给正常 Agent 足够的执行时间。4.5 一次完整的运行演示把注册中心、路由器、聚合器分别启动后再启动两个 Agent。Agent 的核心逻辑就是接收 execute 请求并返回模拟结果from flask import Flask, request, jsonify import requests import threading import time app Flask(__name__) agent_id agent-demo-01 heartbeat_url http://127.0.0.1:9000/heartbeat def heartbeat_loop(): while True: requests.post(heartbeat_url, json{agent_id: agent_id}, timeout2) time.sleep(10) app.post(/execute) def execute(): data request.get_json() # 模拟一个耗时任务 time.sleep(1) return jsonify({agent_id: agent_id, echo: data[params]}) if __name__ __main__: threading.Thread(targetheartbeat_loop, daemonTrue).start() app.run(port9101)实际跑起来后我验证了三条链路单 Agent 调用成功、多 Agent 并行调用成功聚合、把某个 Agent 进程 kill 掉之后路由器在下一个心跳周期内就能把它从存活列表里摘除请求自动转发到另一个 Agent。第三点最重要说明心跳失联机制真的起到了高可用的作用而不是停留在纸面设计。5. 常见问题与排障实录5.1 路由器明明查到 Agent调用却一直失败这个问题我排查过很多次最后发现大概率出在注册信息里的 endpoint 写的是 127.0.0.1。当路由器和 Agent 不在同一台机器时127.0.0.1 指向的是路由器自己当然连不上。解决办法很粗暴注册信息里的 endpoint 必须写 Agent 实际对外可达的地址不能用回环地址。为了避免再犯我在注册中心接口里加了一个参数校验注册时如果 endpoint 以 127.0.0.1 开头就返回警告。另一个类似的问题是 Agent 改了端口但没重新注册。Agent 进程重启后如果代码里写死了注册一次那么新端口不会被同步。解决办法是 Agent 启动时先注册同时注册接口收到相同 agent_id 的新数据时直接覆盖旧记录而不是报冲突。5.2 心跳还在但 Agent 已经不干活了心跳机制只能证明进程还活着不能证明它还能正常处理任务。常见情况是 Agent 陷入死循环或数据库连接池耗尽心跳照常返回但 execute 请求全部超时。这个问题的根本解法是在心跳之外增加一层健康检查也就是路由器在分配请求给 Agent 之前额外检查 Agent 上报的任务队列长度。如果长度超过阈值即使进程存活也不分配新任务。我试过在心跳接口里返回当前任务数和最近一次任务执行状态路由器选路时把这两个信息纳入判断。这一招在应对“假活”问题上效果很明显。代价是 Agent 端要多维护一个任务数计数器实现成本不高收益却不小。5.3 多 Agent 并发后结果对不齐并行调用三个 Agent返回结果的时间点各不相同聚合的时候经常出现“ A 的结果已经合并了B 的结果又覆盖了 A 的一部分”这种问题。表面看是并发问题本质是结果版本管理缺失。我在聚合器里给每个 Agent 的结果带上一个 sequence 序号由 Agent 自己递增。只接受比当前已记录序号更新的结果旧序号直接丢弃。这个策略和乐观锁的思路一样实现成本低能解决大部分乱序问题。5.4 排查故障时用的一张速查表现象可能原因快速排查命令 / 思路注册后查询不到 Agent注册请求未到达注册中心看注册中心日志直接 curl /agents 确认内存表Agent 心跳正常但请求超时Agent 假活处理线程阻塞查看 Agent 进程线程数检查任务队列长度重试后请求重复执行网络超时但服务端已处理在 Agent 端按 request_id 去重多 Agent 结果总缺一个聚合器等待窗口设置太短把窗口时间调成最慢 Agent 超时时间的 1.5 倍更新 Agent 代码后调用老接口Agent 进程启动了两份用端口占用排查工具确认实际监听进程排查这类分布式调用问题我的经验是不要一上来就看业务代码先看注册中心里的存活状态再看路由器选路日志最后看实际请求有没有到达 Agent。逐层排查能少走很多弯路。5.5 对后续扩展的方向建议Agent-Reach 现在这个版本还比较朴素我后续计划加三个能力一是持久化注册信息重启注册中心后 Agent 不用重新注册而是支持更多的传输协议目前只有 HTTP后续想接入消息队列三是加上可视化的调用链路追踪方便排查多 Agent 协作时的问题。这几个方向其实都是根据实际踩坑总结出来的优先度很明确先把注册中心的持久化做掉因为现在每次重启服务都要手动让 Agent 重新注册太影响体验。最后分享一个实际运维中的体会Agent-Reach 这类触达层工具真正价值不是让代码变多而是让代码里的“谁去找谁”变得清晰。每次改 Agent 的时候只需要看配置和注册中心不再需要翻遍所有调用方代码去检查有没有漏改的地址。在我手上这几个项目里它确实把多 Agent 协作的维护成本降了一个台阶。