ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

多智能体协作调度框架Agent-Reach:从注册到路由的工程实践

2026/10/8 11:32:40 拓冰建站 浏览量
多智能体协作调度框架Agent-Reach:从注册到路由的工程实践 Agent-Reach 是我最近在搞多智能体项目时落地的一套轻量调度框架核心解决的就是智能体之间的“找人”和“办事”问题一个 Agent 怎么发现自己需要的另一个 Agent怎么带着上下文把任务可靠地送过去再拿回结果。如果你已经跑通了一两个单个 Agent 的 demo正准备把它们拼成一个能干活的多智能体系统那么 Agent-Reach 这套设计思路和实操记录对你应该很有参考价值。1. 为什么需要 Agent-Reach多智能体协作的痛点其实很普遍1.1 单智能体走不到的地方才是协作的开始先说一个很现实的观察单个大模型 Agent 的能力边界往往不在模型推理本身而在职责边界。我自己最早写过一个“客服助手” Agent它能回答常见问题、能查订单、能退换货看起来功能很全可真把全部逻辑塞进一个 Agent 之后问题就来了——上下文窗口越撑越满工具调用越来越慢改一个业务规则要翻遍整个 prompt 和 tool 列表就算一个环节出错整个会话就跟着崩。更麻烦的是模型在单一上下文里处理多域任务时经常会出现角色混淆它一会儿是售后一会儿是物流最后给出的回复自己都圆不回来。后来我把客服拆成了三个 Agent意图识别 Agent、订单查询 Agent、售后处理 Agent。单个功能确实清爽了但新的麻烦立刻浮出水面——这三个 Agent 之间怎么通信谁来调度意图识别完以后订单查询的结果怎么传给售后处理如果只是用代码里的 if-else 硬写调用关系那还不如不拆因为每加一个 Agent 就要改一遍调用代码协作链路稍微一复杂代码里全是面条逻辑。这就是 Agent-Reach 出现的背景。它不是模型也不是提示词工具而是夹在 Agent 之间的“连接层”。所有 Agent 往框架里注册自己的能力其他 Agent 按名字和路由键去调用它调用方不需要关心目标 Agent 部署在哪台机器、什么语言写的、当前负载如何框架统一处理寻址、转发、超时和重试。本质上它做的事和公司内部工单系统很像你要报销不需要知道财务部有几个人、谁有空你只需要把工单往“财务报销”这个入口一交系统自然会分给当前有空的人。1.2 多智能体网络的四个老问题我在搭建多智能体系统时遇到的坑归纳起来其实就四个第一个是寻址问题。不同 Agent 可能部署在不同服务、不同端口上如果每个 Agent 都硬编码别人的服务地址那么任何一个服务迁移或扩容调用链都会断裂。第二个是通信协议不统一。有的 Agent 用 HTTP有的走 gRPC有的干脆只暴露一个命令行接口跨协议调用的时候封装代码比业务逻辑还多。第三个是上下文断裂。A 调用 B 的时候B 拿不到前面对话的背景这就像你找客服说“我上次那个订单”客服还要反问“哪个订单”来回拉扯效率极低。第四个是容错缺失。某个 Agent 挂了调用方要不要重试重试多少次超时了怎么处理这些如果不在框架层面解决就得每个 Agent 自己实现一遍而且实现得五花八门。Agent-Reach 的做法是把这四个问题收敛到它的注册中心、路由器和消息协议里。注册中心负责“谁能被找到”路由器负责“找谁最合适”消息协议负责“带什么上下文过去”。当时我把这个结构拿到团队里评审有人第一反应是“这不就是个微服务网关吗”严格说还真有点类似但对 Agent 场景来说有两个重要差异第一Agent 之间传递的不是结构化 API 请求而是带状态的会话上下文和任务描述第二Agent 的路由不只看负载还得看能力和职责标签你总不能把写作任务路由给一个画图 Agent。这些差异决定了 Agent-Reach 不能照搬普通的 RPC 框架必须在调度语义上为 Agent 场景做定制。2. 核心设计拆解Agent-Reach 是怎么组织智能体网络的2.1 整体分层接入、路由、注册三层各自管什么Agent-Reach 的逻辑架构分成三层。最外层是接入层它接收来自外部用户或其他系统的请求统一做鉴权、协议转换、限流中间是路由层负责根据目标 Agent 的名字、能力标签、当前负载和健康状态决定把任务转给哪个实例底层是注册中心每个 Agent 启动后在这里登记自己的服务名、节点地址、支持的路由键和负载信息。我最初在设计时犹豫过要不要把注册中心单独拆出来后来为了部署简单直接把注册中心做成了嵌入在路由节点里的模块用几张内存表加持久化快照来维护。一个集群里所有路由节点共享同一份注册数据Agent 节点只需要向任意一个路由节点注册即可路由节点之间会同步注册信息。这里有一个经验值得分享注册信息一定要带版本号或时间戳不然多个路由节点之间的同步一旦出现乱序就会导致旧数据覆盖新数据。当时我们没加版本号之前偶尔出现某个 Agent 明明已经更新了能力标签但调用方还是按旧标签路由排查了半天最后发现问题出在同步覆盖上。如果部署规模不大这套设计完全够用。如果 Agent 数量超过几百个建议把注册中心换成 etcd 或者 Redis本质上只是把存储后端做替换路由层的逻辑不需要变更。对中小团队来说先跑通最关键不要一上来就上重量级依赖。2.2 命名与寻址为什么每个 Agent 要有“服务名 路由键”Agent 的寻址设计是整个框架里最需要花心思的部分。我参考了微服务和消息队列两套思路最后定下来的是“服务名 路由键”双标识方案。服务名是 Agent 的稳定逻辑名称比如order.query或customer.service它不随实例地址变化天然支持扩容和迁移。路由键是能力维度的标签比如regioncn、priorityhigh、versionv2调用方发任务的时候可以不指定具体节点只声明需要的路由约束由路由器帮你挑。举个实际例子假设我有三个售后处理 Agent 实例两个部署在北京处理中文一个部署在法兰克福处理英文它们的服务名都叫support.ticket但路由键分别是regioncn和regioneu。调用方只需要写“发给support.ticket要求regioncn”路由器会自动筛出北京的两个实例再按负载策略选一个。以后北京扩容到五个实例调用方代码一行都不用改。这个设计本质上跟快递地址很像服务名是城市路由键是街道。你寄快递时不会关心负责这个片区的快递员是谁只要地址写对快递系统自动派单。当初最想提醒你的一点是服务名和路由键一旦定下来就不要随意改它等于 Agent 网络的公共契约。改路由键的影响面通常小一些但改服务名会导致所有依赖方都要跟着改建议改之前先在注册中心里做deprecated标记灰度一段时间再下线。2.3 消息协议与载荷约定协作上下文怎么包装才能不串线路由解决了“发给谁”协议则解决“怎么发才不丢信息”。Agent-Reach 的协议格式基于 JSON核心是两个部分任务信息和上下文快照。任务信息包括消息类型、目标服务名、路由约束、调用方标识、超时时间和重试策略上下文快照则是一段结构化的会话数据比如对话历史摘要、业务数据对象、链路追踪 ID。这套协议里上下文快照的设计我是花了很多功夫的。刚开始我把完整对话历史原样塞进每条消息里结果消息体积飞速膨胀而且一个跨三个 Agent 的长链路跑下来中间每个环节都要重新读一遍海量历史响应延迟直接翻倍。后来我改成“摘要 关键字段”的方式每个 Agent 在处理完任务后必须生成一个结构化的摘要结果下游 Agent 只需要读这个摘要不需要看完整历史。完整历史只保存到共享存储后端按链路追踪 ID 索引只有链路需要时再按需取出。这里举个消息载荷的示例结构{ message_id: msg_9f32d1c7e4b61, task: { type: agent.call, target_service: support.ticket, route_keys: [regioncn, prioritynormal], method: process_ticket }, origin: { agent_name: intent.classifier, trace_id: trace_7ab4c912 }, context_snapshot: { summary: 用户反馈订单 20240512 显示已签收但实际未收到要求退款, conversation_id: conv_88812, key_fields: { order_id: 20240512, user_id: u_3209184, current_state: awaiting_refund_confirmation } }, policy: { timeout_ms: 15000, max_retries: 2, retry_strategy: backoff } }注意context_snapshot里的summary和key_fields。summary是自然语言摘要给接收方 Agent 快速理解背景key_fields是结构化关键数据给接收方 Agent 直接引用避免从摘要里去解析。这种“双轨”方式我实测下来效果很好摘要保留语义丰富度结构化字段保证关键数据准确。如果你看过那些 AI 客服系统里“转人工之后还要复述一遍问题”的体验你就知道上下文精简保留有多重要。超时和重试策略我也统一在协议层约定。默认超时 15 秒重试最多两次重试采用指数退避。框架不区分“业务失败”和“系统失败”只有超时或网络错误才自动重试业务上明确返回失败的消息不会重试避免重复扣款之类的事故。2.4 调度策略就近、加权、标签路由怎么取舍调度策略是路由器的核心。Agent-Reach 默认支持三种策略按实际场景选配。就近路由最简单哪个实例网络延迟最低就选哪个适合所有 Agent 部署在同一内网、处理能力基本一致的场景开销最小。加权轮询则适合节点规格不一致的情况比如有些 Agent 实例是 8 核 16G有些是 4 核 8G你就给高配节点设大权重让它们多接任务。标签路由是我用得最多的场景它按路由键过滤候选集比如只在versionv2的节点里挑、只在regioncn的节点里挑配合加权轮询或就近策略形成两级决策先按标签过滤可用节点再在其中选具体节点。三种策略的对比在这里列一下策略核心逻辑适用场景注意点就近路由最小网络延迟或最小跳数内网同构部署、节点能力均衡延迟抖动可能造成调度倾斜加权轮询按权重比例分配任务异构节点混部、需要按容量分配权重需要定期校准不能拍脑袋标签路由按路由键过滤节点集多区域、多版本、多职责隔离标签要保持一致命名别用大小写混用实际跑下来我很少单独用某一种基本都是标签路由打底再加上加权轮询。比如先按regioncn过滤再按权重分配这样既控制了违规区域访问又保证了负载均衡。还有个细节路由器在选节点前会先检查所有候选节点的最近心跳时间超过 10 秒没有心跳的节点直接排除。这个机制看着不起眼但避免了把任务发给一个已经假死的 Agent。3. 实操落地从零跑通一个 Agent-Reach 集群3.1 环境准备一套 Docker Compose 就能起步Agent-Reach 的完整运行环境包括三部分路由节点、Agent 节点、共享存储。我这里是 docker-compose 编排的配置如下version: 3.8 services: router: image: agentreach/router:latest ports: - 8080:8080 environment: SYNC_PEERS: router:8080 STORAGE_BACKEND: redis REDIS_ADDR: redis:6379 redis: image: redis:7-alpine agent-one: image: agentreach/sdk:python-latest volumes: - ./agents/order_query:/app/agent command: [python, /app/agent/main.py, --router, router:8080, --name, order.query]路由节点是集群入口Agent 节点通过 SDK 向路由节点注册并接收任务。Redis 用来存上下文快照和注册数据的持久化副本如果只想做本地测试把STORAGE_BACKEND改成memory也可以不过不建议在生产环境这么做。用 Docker Compose 的好处是你做本地联调时环境一次性拉齐避免在“我这能跑你那边不能跑”上浪费时间。当时我把这套编排放到团队内部后新人从零开始到跑通第一条 Agent 链路的时间缩到了半小时以内。需要注意一个网络细节Agent 节点连接路由节点时不要用localhost要使用容器服务名或可被对方访问的地址否则注册信息里记录的地址是容器内地址其他 Agent 调用时根本连不上。这个坑我在第一次部署时就踩过后来在注册模块里加了“地址自检”注册时强制检查地址是否可达总算把这个麻烦解决了。3.2 写第一个 Agent 节点以 Python SDK 为例Agent 节点的核心逻辑很简单继承 SDK 的Agent基类实现一个处理函数然后启动。我以订单查询 Agent 为例完整代码如下from agentreach import Agent, Context, Task class OrderQueryAgent(Agent): def __init__(self): super().__init__( service_nameorder.query, route_keys[versionv1], tags[order, read_only] ) async def handle_task(self, task: Task, ctx: Context) - dict: # 读取上下文中的关键字段 order_id ctx.get_field(order_id) user_id ctx.get_field(user_id) # 这里简化了正常会是查订单库或其他外部系统 order_info fake_db.query(order_id, user_id) # 返回结构化结果并附带摘要 result { order_id: order_id, status: order_info[status], delivery_info: order_info[delivery] } ctx.summarize(f订单 {order_id} 当前状态为 {order_info[status]}) return result if __name__ __main__: agent OrderQueryAgent() agent.run(router_addrrouter:8080)这段代码里有几个点值得细说。第一service_name和route_keys是注册到路由中心的核心标识前者让别人能找到你后者让别人按条件筛选你。第二ctx.get_field从上下文快照里拿数据这里不需要读完整历史直接拿结构化字段即可。第三ctx.summarize会把结果摘要写入上下文快照这样链路下游的 Agent 无需重复解析你的完整返回。如果你有多个 Agent代码结构基本一致只是处理逻辑不同。我一般会把每个 Agent 打成独立镜像通过环境变量注入--router和--name部署时只改环境变量业务代码完全不用动。这样批量部署几十个 Agent 节点时运维成本能压到极低。3.3 注册、发现与调用一次完整的链路演示启动 Agent 后它会自动向路由节点发送注册请求包含服务名、路由键、地址和负载信息。注册成功后路由节点会周期性地向 Agent 发送心跳探测默认间隔是 5 秒。你可以通过路由节点的管理接口查看注册情况curl http://router:8080/admin/services返回结果会类似{ services: [ { service_name: order.query, instances: [ { id: agent-one-1, address: agent-one:9000, route_keys: [versionv1], health: healthy, last_heartbeat_ts: 1715594000 } ] } ] }调用方 Agent 只要向路由节点发起一条任务消息不必关心目标 Agent 的具体地址。比如意图识别 Agent 识别到用户想查订单发出调用client AgentReachClient(router_addrrouter:8080, agent_nameintent.classifier) result await client.call( target_serviceorder.query, methodquery_status, route_keys[versionv1], context{order_id: 20240512, user_id: u_3209184}, timeout_ms10000 )这条调用看起来是同步的内部实际经过三步先将任务提交给路由器路由器根据注册表和路由策略选定一个order.query实例把任务转发过去实例处理完成后将结果送回路由器路由器再把结果返回给调用方。这个中转设计在直观上多了一跳但换来的是调用方不需要知道任何实例地址而且路由器可以做统一的重试和超时管理。对于局域网内的系统多一跳的延迟基本可以忽略。第一次跑通这条链路时你会明显感觉到跟“代码里直接 requests.post 到某个 Agent”的区别——新增 Agent 时不用改调用方代码Agent 挂了路由会自动避开等心跳超时后标记异常调整路由策略时也只需要在配置中心改规则。这就像一个团队从“每人背一串别人的手机号”转变为“统一总机转接”大家都不用再记别人的号码了。3.4 压测观察小规模集群的真实性能表现链路跑通后我做了简单的压测。环境是三台容器一个路由节点三个order.queryAgent 节点两台中配一台高配用并发工具模拟调用方发任务。结果记录如下并发度总请求数平均延迟 (ms)P99 延迟 (ms)成功率50500018042099.98%2001000032078099.95%50020000480138099.90%100030000750210099.80%顺带说下失败请求的来源绝大多数是超时业务失败极少。当时压到 1000 并发时失败率开始上升分析下来不是路由节点的瓶颈而是 Agent 节点里的fake_db.query模拟了较重的查询逻辑处理不过来才导致超时。这说明在 Agent 场景下框架本身通常不是瓶颈Agent 和外部系统的交互才是需要重点关注的性能瓶颈。调优思路也很直接给 Agent 节点加实例数让路由器把压力摊开比单纯优化单节点代码见效更快。4. 常见问题与排查技巧实录4.1 问题速查表这些坑我替你先踩过了现象可能原因处理方式Agent 注册后调用方找不到路由键拼写不一致或服务名带有多余空格检查注册后台显示的实际服务名和路由键和调用方对齐调用偶发超时目标 Agent 负载过高或容器被 fgc 拖住先加实例数再观察单实例处理耗时某实例接收任务数量严重倾斜权重配置不合理或心跳延迟导致部分节点被误判异常校准权重检查网络延迟必要时重启异常实例上下文串线B Agent 拿到的是上一个会话的字段conversation_id未随上下文传递或调用方复用了同一个 context 对象规范调用入口强制每次调用创建新的上下文并写入 trace_id注册信息里地址是容器内地址Agent 启动时取错了本机 IP在 SDK 注册前显式传入对外可达地址消息队列积压但 CPU 不高路由节点单线程处理遇到网络 IO 等待调整路由节点连接池和并发数避免同步阻塞这个速查表是我和团队把过去两周的 oncall 记录整理出来的每一条都有对应的真实事故。最典型的是“上下文串线”那次当时两个会话共用了一个 context 对象导致 B Agent 在退单申请里读到了另一个用户的订单号还好是在测试环境发现得早不然上线后就是严重事故。从那之后我定了一条铁律每次新建调用必须生成全新的 context不要在调用间复用任何带状态的对象。4.2 一次真实排障路由倾斜是怎么一步步定位的有一次压测时发现三个order.query实例接收的任务量比例是 7:2:1明显不均匀。第一反应是权重配错了但翻配置发现权重本来就是 5:3:2理论上不该这么倾斜。后来检查实例状态发现权重最高的那个实例延迟最小而权重第二的实例延迟一直在抖动路由器按“标签过滤 最近最少连接”选择时会把更多请求分给响应最快的实例——这就解释了为什么倾斜程度超出权重预设。根因是那台实例所在宿主机的网络偶尔有抖动而路由器默认对延迟敏感。解决方法是把调度策略从“延迟优先”改成“加权轮询优先”并给延迟计算加了一个滑动窗口避免单次抖动导致误判。那次排障给我的一个经验是压测出现的异常往往不是某一层的单点问题而是各层策略叠加后的复杂行为这时候切忌上来就改代码先看路由日志、注册数据、节点负载这三件套基本能定位九成问题。4.3 三个只有实战才懂的细节最后分享三个不算显眼但极其影响体验的细节。第一个是健康检查的探针不能太脆弱。Agent 节点可能在处理一个长耗时的任务时无法及时响应心跳如果路由器一探不到就标记异常就会把还在处理中的任务强行重试到其他实例导致重复操作。我的做法是把心跳探测设置为独立线程与任务处理线程隔离并允许在任务执行期间设置“busy”状态标识路由器看到 busy 标识就不会重复派活。第二个是重试风暴问题。当某个公共 Agent 集体变慢时所有上游 Agent 会在同一时间段发出大量重试请求导致原本就过载的 Agent 雪上加霜。我给路由节点加了全局请求熔断和半开恢复机制当某 Agent 的错误率超过阈值时路由器会暂时拒绝新任务而不是盲目排队重试这比每个调用方各自控制重试次数有效得多。第三个是日志采样。在长链路上完整记录每一次调用的全量日志会带来很大的存储压力但不记日志又没法排查问题。最终的方案是默认只记录摘要和状态码只有在链路出现异常时才记录完整 JSON 载荷。为此给每个调用都添加了sampled字段路由器根据错误率和热点链路动态决定是否打全量日志。这个机制上线后日志存储成本下降了一半多而排障能力基本没受影响。回到最开始的问题多智能体系统的复杂度从来不在单个 Agent 的智能程度而在它们之间能否高效协作。Agent-Reach 这套框架给到我的价值就是把“谁是做什么的”“任务该发给谁”“上下文怎么传”“失败怎么办”这四件事变成了基础设施而不是每个业务模块各自为战。我个人在实际搭建过程中的体会是先别追求调度策略多花哨把注册、寻址、超时重试这三个基本功做扎实系统就稳了一大半。剩下的事等你的 Agent 数量真正多起来再去优化完全来得及。