ARTICLE DETAIL

资讯详情

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

Agent-Reach:轻量级多Agent通信与路由组件实战复盘

Agent-Reach:轻量级多Agent通信与路由组件实战复盘 先交代一个背景。我手上有几个业务系统去年开始做多Agent协作实验最早是拿LLM API硬拼几个Agent各写各的prompt各调各的接口跑通demo很容易但一上量就崩。后来咬着牙把通信层抽出来重做这个项目就是Agent-Reach——一套面向多Agent场景的轻量级触达与路由组件。简单说它解决的是Agent之间怎么互相发现、怎么把任务可靠送出去、怎么触达外部工具和数据源这三件事。如果你正在搭多Agent系统、写Agent工作流或者只是想把一堆自动化脚本变成能互相调度的Agent集群这篇复盘应该对你有用。我不会按时间线流水账讲而是把Agent-Reach从设计思路到核心实现、再到上线后踩的坑拆成几个相对独立的部分。那些代码细节、协议字段、压测数据都是我在真实环境里跑过、改过的版本你可以直接参考不用再绕我走过的弯路。1. 做Agent-Reach之前我到底被什么问题逼疯了1.1 三个Agent各说各话的现场先说一个具体场景你就能理解Agent-Reach解决的是什么。当时我手上有三个Agent一个负责读订单邮件并提取结构化数据一个负责查库存和生成补货建议一个负责把建议发送到企业微信通知群。单看每个Agent能力都不复杂麻烦在于它们之间要协作。订单Agent处理完邮件之后需要通知库存Agent这批SKU缺货了你去查一下有几个渠道在售库存Agent查完之后又要告诉通知Agent这是今天的补货清单请你推送给仓库负责人。一开始我把这种协作写成了硬编码订单Agent的代码里直接调库存Agent的HTTP接口库存Agent的代码里直接调通知Agent的Webhook。三个Agent还能转起来问题是每次加一个新Agent或者某个Agent改了接口参数另外几个Agent的代码就要跟着改。到了第八个Agent的时候整个调用链已经变成一团乱麻一个小改动要牵连四五个文件。我意识到这不是Agent能力的问题是缺一个能让它们互相找到对方、用统一方式说话的中间层。1.2 现成方案的别扭之处当时我评估过几条现成路线都有点别扭。一条路线是直接用事件总线比如Redis Pub/Sub或者Kafka。这个方案的问题在于事件总线本质上是广播每个Agent都要订阅一堆主题消息一旦多了消费者那边要自己做大量过滤和路由判断本质上又把路由逻辑塞回到了各个Agent身上。另一条路线是用工作流编排框架。但工作流框架适合流程确定的场景比如先做A再做B再做C而Agent协作往往是动态的订单Agent并不知道应该找哪个Agent处理库存它只知道我需要一个能查库存的能力。这是能力路由不是流程编排。还有人建议把所有人的上下文都塞到同一个大prompt里让大模型自己决定谁干什么。这个在小规模时很爽Agent数量一多上下文窗口根本扛不住而且大模型偶尔会跳过某个Agent直接凭记忆瞎编结果。这种灵性在闲聊时是好玩的在订单处理这种场景里就是事故。1.3 Agent-Reach的边界只做触达所以我在设计Agent-Reach时给自己画了一条很清楚的边界它不做模型调度不做Agent的思考逻辑只做触达。英文里Reach这个词有伸手够到的意思我要的就是这个感觉——让一个Agent能可靠地够到另一个Agent够到自己需要的工具够到外部数据源。具体来说Agent-Reach承担三件职责注册与发现每个Agent上线时向注册中心登记自己的能力和调用地址其他Agent不需要预先知道对方的存在。路由与派发根据任务描述里的能力标签把消息派发给匹配的Agent支持点对点和一对多扇出。触达适配把外部HTTP API、数据库、文件系统等资源封装成Agent能调用的统一工具形态。至于Agent内部怎么推理、怎么用prompt组织回复Agent-Reach完全不关心。这个边界很重要它保证了Agent-Reach本身可以保持很轻接入新Agent的成本也低——你不需要改通信层只需要让Agent实现一套统一的对外接口就行。2. Agent-Reach核心架构一次调用是怎么走完全程的2.1 三个核心组件注册中心、路由网关、触达适配器Agent-Reach由三个核心组件组成我会分开描述它们的职责。注册中心registry负责维护Agent的元信息。每个Agent启动时会向注册中心发送一条注册消息内容包括Agent名字、能力标签、调用地址、版本号、权重信息。注册中心会定期检查Agent的心跳超过阈值没心跳的Agent会被标记为离线。什么叫能力标签简单来说就是一个Agent能处理什么类型任务的描述比如inventory.queryorder.extractnotify.push路由的时候就是靠这些标签找到目标的。路由网关router是真正处理消息转发的部分。它接收上游Agent发来的任务消息解析消息里声明的目标能力或目标Agent去注册中心查匹配的实例然后按负载均衡策略派发同时记录一条完整的路由日志。如果目标Agent不可达网关会尝试重试重试仍失败就把消息丢进死信队列。触达适配器adapter是Agent-Reach的独特之处。Agent本身不直接面对各种外部系统的SDK而是把外部资源声明成工具由适配器统一执行调用、统一返回格式。这样Agent的代码里就不用写curl不用考虑各种接口的鉴权差异只要说调用工具inventory_api查一下SKU-1024的库存适配器会处理剩下的事情。2.2 消息体从TaskDispatch到TaskAckAgent-Reach的通信协议是JSON消息类型分四类Register注册、Dispatch任务派发、Ack确认回执、Result结果回传。其中Dispatch是最核心的消息。下面是我实际在用的一个简化版本{ schema_version: 1.2, message_id: d2js9fk2-8d21-4f0a-b7a2-35bc9d1a0f17, type: task.dispatch, timestamp: 1730347605231, source_agent: order_agent, target_capability: inventory.query, target_agent: null, payload: { task_id: task_20241101_003, input_data: { skus: [SKU-1024, SKU-2024], channels: [self_operated, third_party] }, callback: { mode: return, return_to: order_agent } }, routing: { max_hops: 3, timeout_ms: 10000 } }几个字段的作用我解释一下。message_id是全局唯一的消息编号用来做幂等和追踪target_capability是这次任务想要的能力比如inventory.querytarget_agent如果填了就是指定Agent不填则由路由网关根据能力匹配routing.max_hops限制了消息最多经过几个Agent这是后面防止Agent互相死循环的关键参数callback.return_to标明结果要送回给谁。Ack消息则比较简单接收方Agent收到Dispatch后立即回一条Ack表示任务我收下了这样发送方就不用傻傻等一个没有回音的请求。真正的结果数据通过Result消息异步返回。2.3 一次完整的消息旅程把上面这些拼起来一次调用的完整链路是这样的订单Agent构造Dispatch消息指定target_capability为inventory.query发到路由网关。网关去注册中心查找所有声明了inventory.query能力的Agent可能查到三个实例按权重和当前负载选一个转发过去。库存Agent收到消息先回Ack给网关网关把Ack转回给订单Agent订单Agent就知道消息对方收到了。库存Agent处理完之后构造Result消息通过网关回传给订单Agent。整条链路里消息每经过一跳网关都会追加一段路由日志包含时间戳、节点名、耗时方便之后排查问题。2.4 为什么用JSON而不是更高效的二进制协议这是开发时一个朋友问我的问题他建议用Protobuf或者MessagePack说性能更好。我当时是这么考虑的Agent-Reach面对的Agent生态里有大模型API、有Python脚本、有Node服务甚至还有同事用Excel里的VBA在调接口JSON是包容性最强的格式任何语言都能零成本解析。至于性能在一次任务派发里真正耗时的是大模型推理和外部API调用消息序列化的开销连5%都不到为了这点性能牺牲跨语言兼容性不值。如果你确实遇到极端高通量场景再在内部节点之间做二进制压缩层也不迟但入口出口保持JSON对生态最友好。3. 关键实现细节协议、状态机与工具触达3.1 让每个Agent学会自我介绍Agent-Reach接Agent的第一步是让Agent实现一个描述接口。无论你用什么语言写的Agent只要能在启动时吐出一份AgentInfo就能被Agent-Reach纳管。我的AgentInfo长这样{ agent_id: inventory_agent, display_name: 库存查询Agent, version: 0.3.1, capabilities: [ { name: inventory.query, description: 查询商品SKU在多个渠道的库存状态, input_schema: { type: object, properties: { skus: {type: array, items: {type: string}}, channels: {type: array, items: {type: string}} }, required: [skus] } } ], endpoint: http://inventory-svc:8000/agent/invoke, auth_token_ref: token:inventory, load_weight: 3, heartbeat_interval_s: 30 }capabilities里每一项的input_schema其实就是JSON Schema格式路由网关不会解析你的业务语义但会根据这个schema对入站消息做基础校验避免一个不完整的payload被丢到一个Agent上才报错。auth_token_ref指向密钥存储里的某个keyAgent和Agent之间调用时网关会自动附加鉴权信息这个机制省去了在每个Agent里硬编码token的麻烦。3.2 任务状态机别让消息悬在半空消息派发出去之后如果接收方Agent直接崩溃了或者网络闪断这条任务该怎么办Agent-Reach为任务定义了一个简单但严格的状态机。整个生命周期是PENDING - ROUTED - ACCEPTED - RUNNING - COMPLETED / FAILED另有一条特殊路径PENDING - ROUTED - RETRY - DEAD_LETTER。PENDING状态表示消息刚被提交网关路由成功进入ROUTED接收方回Ack进入ACCEPTED开始处理是RUNNING处理完回传Result是COMPLETED。如果说好超时时间内一直没收到Ack网关会把消息标记为RETRY并重新选择目标Agent最多重试3次。3次都不行消息进入DEAD_LETTER队列等人工介入。这个状态机看起来简单但它帮我拦住了一个大坑以前没有状态机时消息发出去了就没人管失败了也不知道经常出现订单Agent以为自己查过库存了其实库存Agent根本没收到的情况。有了明确状态和解耦的Ack机制每个任务在任意时刻处于什么状态都能拿出来给业务方面对。3.3 触达适配层把外部API变成Agent手里的工具Agent要调用外部系统最原始的做法是Agent的代码里直接请求外部API。但这样有一个问题外部API的参数格式五花八门响应格式也是各家的习惯Agent的代码里会堆满各种字段映射和容错逻辑。Agent-Reach的触达适配器把这种情况收敛成统一的工具调用模式。我在代码里定义一个工具元数据比如tools [ { name: inventory_api, type: http, url_template: https://{host}/api/v2/inventory/batch, method: POST, auth: header:Authorization, request_mapping: { skus: product_codes, channels: warehouse_ids }, response_mapping: { items: $.data.list, total_stock: $.data.total }, timeout_ms: 5000, retry: on_5xx }, { name: notification_webhook, type: webhook, url: https://notify.internal/webhook/agent, method: POST, request_mapping: {}, response_mapping: {}, timeout_ms: 3000, retry: on_network_error } ]执行时Agent会发起一个工具调用请求inventory_api(skus[SKU-1024], channels[self_operated])适配器负责把参数映射成外部API需要的product_codes和warehouse_ids发起HTTP请求再把响应里的深层字段映射回统一的结果格式。适配器还负责超时控制、重试策略、以及调用失败时的标准化错误信息。这样Agent的业务逻辑里不会出现任何外部系统特有的字段切换外部服务商时只需要改适配器配置Agent代码一行都不用动。这里要特别强调一点任何Agent能触达外部URL的设计都必须把安全边界做严。我的适配器里维护了一份内网域名白名单Agent只能调用白名单里的服务外部公网URL默认禁止。否则一旦Agent被prompt注入攻击者就可能借Agent之手探测内网。这个意识一定要有。3.4 路由网关的实现骨架路由网关是Agent-Reach里我最常改动的地方核心逻辑其实不复杂我用FastAPI实现了约两百行核心代码。核心路由函数大约是这样的逻辑接收Dispatch消息检查版本号查注册缓存选择目标转发记录日志返回Ack。async def dispatch_task(message: DispatchMessage): if message.schema_version MIN_SCHEMA_VERSION: raise SchemaTooOld(fneed schema {MIN_SCHEMA_VERSION}) candidates registry.lookup( capabilitymessage.target_capability, agent_idmessage.target_agent ) if not candidates: dead_letter.put(message) return DispatchResult(statusNO_AGENT, message_idmessage.message_id) target select_target(candidates, strategyweighted_least_conn) ack await send_to_agent(target, message, timeoutmessage.routing.timeout_ms) if ack is None: retry_queue.put((target, message, 3)) return DispatchResult(statusRETRY_SCHEDULED, message_idmessage.message_id) trace_log.record(message.message_id, target.agent_id, latency_msack.latency_ms) return DispatchResult(statusACCEPTED, message_idmessage.message_id)实际生产里我还加了内存缓存避免每次路由都去注册中心查一次注册信息变更通过订阅推送实时更新。路由日志是全链路追踪的基础每条消息经过网关都会记录一条结构化日志包含消息ID、源Agent、目标Agent、耗时、状态码。排障的时候按message_id一查整条调用链一目了然。4. 部署方式与一个小规模压测实验4.1 手动部署一套最小的Agent-ReachAgent-Reach我打成了三个Docker镜像registry、router、adapter。本地调试可以用Docker Compose一键拉起一个最小环境。下面是一个裁剪过的编排配置services: registry: image: agentreach/registry:1.2.0 ports: - 8500:8500 environment: REGISTRY_HEARTBEAT_TIMEOUT_S: 90 REGISTRY_STORAGE_DRIVER: memory router: image: agentreach/router:1.2.0 ports: - 8600:8600 environment: ROUTER_REGISTRY_ADDR: registry:8500 ROUTER_DEAD_LETTER_DRIVER: file ROUTER_DEAD_LETTER_DIR: /var/lib/agentreach/dlq adapter: image: agentreach/adapter:1.2.0 ports: - 8700:8700 volumes: - ./tools.yml:/etc/agentreach/tools.yml:ro启动之后你只需要让自己的Agent程序向registry:8500的注册接口发一个Register消息然后通过router:8600派发任务就行了。整体来说Agent-Reach不强制依赖数据库默认状态下注册信息存内存、死信落本地文件适合中小规模Agent集群。如果你要支撑几十个Agent以上的规模再把它底层的存储换成Redis或Postgres接口不需要变。4.2 压测中的数据表现我在测试环境跑了一轮压力测试8个模拟Agent分别注册了不同的能力标签每个Agent的响应时间设定在50毫秒左右消息生成速率从100条/秒逐步提高到2000条/秒连续压测10分钟。结果如下平均路由耗时约3毫秒P95约8毫秒P99约25毫秒这个耗时是纯网关转发不含Agent处理。到2000条/秒时消息积压开始出现网关进程CPU约45%内存稳定在180MB左右。模拟了1%的Agent随机崩溃消息落死信队列的比例约0.8%重试成功挽回约0.6%最终真正丢失的只有剩余0.2%左右且全部有日志可查。这个表现对我来说足够了。原因也很简单Agent场景的瓶颈本来就不在消息路由本身而在Agent内部的大模型推理和外部API响应。路由器保持极致的轻量反而不会成为瓶颈。4.3 我建议的最小部署范式如果你的Agent集群还不到十个其实不需要把三个组件拆开部署一个单进程版Agent-Reach就够了。只有当Agent实例变多、需要独立扩展网关吞吐时再拆成独立服务。拆的时候要记住一个原则路由网关必须是无状态的所有状态放注册中心或缓存里这样才能随便水平扩容。5. 实战中踩出来的坑比代码更难啃5.1 坑一Agent之间真的会说个没完我最早在Agent-Reach里没有设置跳数限制时出现过一次事故。一个审计Agent要找财务Agent拉数据财务Agent发现数据格式不对把请求转给归一化Agent归一化Agent发现自己没权限又把请求转回审计Agent审计Agent觉得这不是我该干的又转给财务Agent……三个Agent互相踢皮球消息在系统里转了几十圈把消息队列堵了。后来我做了两道保险。第一道是routing.max_hops字段消息每经过一个Agent网关就把跳数减一减到零直接进死信队列并告警。第二道是循环指纹检测网关会计算消息关键载荷的哈希并记录在缓存里如果同一哈希的消息在短时间内再次经过同一Agent直接判定为循环终止流转。这道保险在后面Agent还没有那么智能时特别管用网上很多人叫它防死循环探测本质上就是个有状态缓存。5.2 坑二超时重试和消息乱序差点弄丢任务早期版本里Agent-Reach的重试逻辑是这样的发送给Agent A超时了重试发送给Agent B。有一次Agent A其实已经收到任务了只是处理慢Ack回传的时候超时了网关这边立即重发给了Agent B。结果就是同一个task_id被两个Agent同时处理。解决办法是引入两级幂等网关在生成的message_id上做去重Agent在payload.task_id上做业务幂等。也就是说Agent收到一个task_id时先去自己的处理表里查一下有没有处理过处理过就直接回Result不再重复执行。自那以后同一任务的重复执行率从接近于零的控制住了极端情况下会出现重复投递但不会出现重复扣库存这类业务事故。做异步系统的人常说at least once是无法避免的业务上能接受重复投递但Agent侧必须有幂等处理。5.3 坑三配置分发不齐Agent用不同版本的协议聊天这个问题是在一次版本升级后暴露的。当时我把AgentInfo里的capabilities字段从数组改成了对象结构结果没有同时升级所有Agent实例老Agent还在用旧协议新网关按新协议解析直接解析失败注册和路由都异常整个集群服务了大约四十分钟不可用。后来我建立了两个机制。一是schema version握手注册时和派发时都带上schema_version网关发现版本高于自己支持的版本立即拒绝并提示请升级网关而不是强行解析。二是灰度升级策略升级Agent协议时先升级网关再分批升级Agent观察一段时间注册和路由成功率没有异常后再全量切流。这套协议版本管理机制我现在已经沉淀成Agent-Reach的默认配置再没出过同类事故。5.4 踩坑方法论线上问题排查思路几次事故之后我总结出一条经验多Agent系统排障第一件事永远不是去翻Agent的代码而是先把消息链路的日志拉出来。看看message_id经过哪些节点、每个节点的耗时、在哪一跳状态变成异常基本就能把问题范围缩小到一个具体Agent上。所以我强烈建议你在Agent和Agent之间传递消息时务必保留下游返回的原始错误信息而不是只返回一个ERROR状态码。否则一个Agent报错上游Agent只能对着ERROR干瞪眼排查链路会非常痛苦。6. Agent-Reach还能怎么走远Agent-Reach做到现在核心功能已经稳定我最近在思考几个扩展方向。一个是事件溯源记录每个任务的完整时间线方便事后回放和复盘这对业务审计场景很关键。另一个是跨网段路由目前它只能在同一网络内工作后续打算加一层网关对网关的联邦协议把多个网络里的Agent集群连接起来类似物联网里的网关级联。还有一个是配额与优先级现在所有Agent的任务都是平等对待的但实际业务里核心链路的任务应该比边缘任务有更高的优先级不能让一个批量通知任务占用了订单处理的路由带宽。最后说一句自己的体会。Agent-Reach不是一个智能的系统它做的事很笨——登记、转发、确认、记录但正是这些笨而稳定的机制撑起了上层Agent之间聪明的协作。我见过太多做Agent的朋友把精力全花在让单个Agent更聪明上忽略了Agent和Agent之间的连接质量结果单个Agent再聪明协作时也是鸡同鸭讲。基础设施本来就应该是钝的、稳的、无趣的这是它最该有的样子。如果你也在搭多Agent系统我建议你先花点时间思考Agent怎么可靠地触达彼此这个问题再决定要不要用Agent-Reach或者自己写一套这笔投入值得的。
返回列表