最近半年我一直在折腾多智能体协作,试过不少框架,也踩了不少坑。手头的项目里有一堆AI Agent:有负责解析邮件的,有管日程的,有做文档检索的,还有几个专门调外部API的。单拎出来每个都挺好用,可一旦想让它们配合着干活,问题就来了——它们之间根本“说不上话”。有的Agent根本不暴露接口,有的暴露了却没法动态发现彼此,还有的消息格式各讲各的,联调一次能改三版协议。后来我干脆自己写了个轻量级调度层,取名叫Agent-Reach,核心思路就一个字:让Agent之间能“触达”。这篇文章就把这套东西的设计思路、核心代码和落地经验完整梳理一遍,尤其是几个坑,都是在生产环境里一步步踩出来的。
1. Agent-Reach要解决的核心问题:智能体之间“说不上话”的协作困境
先说说我为什么要自己造轮子。市面上的Agent框架不少,有的偏重单Agent的推理链路,有的则是重量级的工作流编排系统。但我的场景有点特殊:团队里已经有了一批独立开发、独立部署的Agent服务,它们有的用FastAPI写的,有的是Node.js的进程,还有一个是Python的CLI工具套了个常驻服务壳。我不想把它们的内部实现全部推倒重来,只想在上层加一个统一的触达与调度层,让它们能互相发现、协作、传消息、领任务。
所谓的“触达”(Reach),本质上包含四件事:发现(Discovery)、通信(Communication)、调度(Scheduling)、回执(Acknowledgement)。很多人在搭多Agent系统时,上来就纠结用什么大模型、怎么写Prompt,结果忽略了这个最底层的问题:A Agent的任务结果怎么送到B Agent手里?B Agent执行失败了A知不知道?如果B正在忙于处理其他任务,新的请求是排队还是拒绝?
我用Agent-Reach解决的就是这些问题。它的定位不是又一个大模型编排框架,而是一层轻量级的Agent通信与调度基础设施。你可以把它理解成Agent世界的“通信录+传话筒+任务分发板”。每个Agent启动时向Reach注册自己的能力标签,需要别的Agent帮忙时,只需把任务投递给Reach,Reach负责找到合适的Agent、转发任务、跟踪状态、返回结果。
这套思路特别适合下面几种情况:
- 团队里已经有若干个独立开发、技术栈不统一的Agent服务
- 不想把Agent的代码全部改造成某个特定SDK的风格
- 需要Agent之间支持异步任务、回调结果,而不是单纯的同步HTTP调用
- 希望以后新增Agent时可以“即插即用”,不修改已有Agent的代码
如果只是两个Agent之间偶尔同步调用,直接HTTP请求就行,确实不需要Agent-Reach。但当Agent数量超过三五个,开始出现“A要调用B,B要调用C,C的结果又要回传给A”这种链路时,没有一层统一的触达机制就会非常痛苦。我最初就是被这种“网状调用地狱”折磨到不行,才决定把Reach写出来的。
2. Agent-Reach的架构拆解:注册中心、触达协议与调度引擎
Agent-Reach的整体架构不复杂,核心就三个组件:ReachHub(注册中心)、ReachLink(触达协议)、ReachScheduler(调度引擎)。下面逐个讲清楚它们各自负责什么,以及设计时的取舍。
2.1 ReachHub:Agent的通信录与状态看板
ReachHub是所有Agent的注册和发现中心。每个Agent启动时,会向ReachHub发送注册请求,内容包括:Agent的ID、能力标签(capabilities)、通信地址(endpoint)、当前负载状态等。ReachHub把这些信息存在一张动态路由表里,其他Agent请求协作时,ReachHub负责根据能力标签找到合适的Agent列表,再按某种策略选择目标。
这里我踩过的一个关键坑是:Agent的能力标签不能只写一句话描述。早期我的Agent注册时,capability字段填的是自然语言,比如“can handle email tasks”,结果调度时ReachHub根本没法做精确匹配。后来我改成了能力标签加参数Schema的二元结构:
{ "agent_id": "email-agent-01", "capabilities": [ { "name": "email.parse", "input_schema": { "type": "object", "properties": { "raw_content": {"type": "string"}, "source": {"type": "string", "enum": ["outlook", "gmail"]} }, "required": ["raw_content"] }, "ttl": 600 } ], "endpoint": "http://10.0.1.24:9001", "heartbeat_interval": 15 }能力标签里的name是一个结构化的点分标识,类似email.parse、schedule.create、doc.search。这样ReachHub在做能力匹配时就是确定性的字符串匹配,而不是模糊的语义匹配。input_schema则告诉调用方“你要传什么格式的参数”,这一点在做跨团队Agent协作时尤为重要,因为不同团队对“格式”的理解总是有偏差的。
ReachHub还要维护每个Agent的健康状态。Agent每隔一段时间发送心跳,如果超过阈值没收到心跳,ReachHub就把这个Agent标记为“不可用”,调度时自动跳过。心跳间隔的配置需要根据Agent的实际负载调整,比如处理长耗时任务的Agent,心跳间隔就要放宽,否则容易被误判失联。我一开始统一设15秒,结果有个Agent跑一次文档解析任务就要40秒,期间心跳被阻塞,ReachHub连续两次没收到心跳就直接把它摘除了,当时排查了好久才发现是这个原因。
2.2 ReachLink:不追求极端性能,稳定和简单才是关键
ReachLink是Agent之间消息传递的协议层。设计时我纠结过要不要用消息队列,比如RabbitMQ或者Kafka。后来权衡利弊,决定先做成简单的HTTP JSON协议,把异步和回调机制一并设计进去。为什么这么选?
第一,团队里Agent的技术栈五花八门,HTTP JSON是兼容性最好的方式。第二,引入MQ意味着要运维额外的中间件,这对一个刚开始跑通流程的项目来说负担太重。第三,Agent-Reach的定位是“轻量触达层”,不是“高吞吐消息总线”。大多数Agent协作请求的体量一天也就几千次,HTTP完全够用。
ReachLink定义了三种消息类型:
reach.request:任务请求,从调用方发给目标Agentreach.reply:同步回复,目标Agent处理完后直接返回结果reach.callback:异步回调,目标Agent先返回“已收到”,处理完后通过回调URL通知调用方
同步调用流程下,调用方发送的请求体大致长这样:
{ "msg_type": "reach.request", "request_id": "req-20241107-001234", "target_capability": "schedule.create", "payload": { "summary": "与客户确认需求", "start_time": "2024-11-08T10:00:00+08:00", "duration_minutes": 60 }, "reply_to": { "callback_url": "http://agent-orchestrator:8080/reach/callback/schedule-create" } }每个请求都带一个全局唯一的request_id,这是贯穿整个触达链路的追踪ID。出问题排查时,靠这个ID在ReachHub的日志里一查就能看到完整的流转路径。这个习惯强烈建议保留,没有request_id的Agent协作系统,排查问题会原地爆炸。
2.3 ReachScheduler:从“找谁干”到“怎么干”
ReachScheduler是任务调度引擎,负责接收调用方的任务投递,在ReachHub的路由表里找到合适的Agent,然后决定投给谁、什么时候投、投了之后怎么跟踪。
调度策略我目前实现了三类,用互不打扰的插件方式挂载:
| 策略 | 适用场景 | 说明 |
|---|---|---|
| 轮询(round-robin) | 同能力多副本做负载均衡 | 轮流分发,实现简单,但不考虑Agent当前负载 |
| 最少负载(least-loaded) | Agent处理时长差异大 | 根据Agent上报的当前队列长度/CPU/内存综合打分 |
| 亲和路由(affinity) | 有状态Agent / 需要幂等的任务 | 同一个任务ID或同一个调用方,尽量路由到同一个Agent |
实际跑下来,最少负载策略的效果最稳定。特别是处理两类耗时差异极大的Agent时,轮询会导致慢任务堆积在一台机器上,而最少负载会把新任务尽量压到空闲Agent上,整体吞吐明显更好。
调度引擎里还有一个容易忽略的点:超时控制。网络请求超时、Agent处理超时、回调超时,这三个超时值不能拍脑袋定,要根据实际任务的P95耗时来推。我的经验是:先跑一周无超时限制的灰度,把每个能力的时间分布统计出来,然后以P95加上缓冲时间作为超时阈值。比如email.parse任务的P95耗时是3.2秒,那超时就可以先设成6秒,后续再收紧。
3. 手写一个最小可用的Agent-Reach:从注册到触达再到任务回调
光讲架构太虚,我直接贴一个可以跑起来的最小实现。语言选Python,不依赖任何重型框架,方便理解核心逻辑。下面是ReachHub的最小实现:
# reach_hub.py import asyncio import json from datetime import datetime, timezone class ReachHub: def __init__(self): self.agents = {} # agent_id -> (agent_info, last_heartbeat_ts) self.capability_index = {} # capability_name -> list of agent_id async def register(self, agent_info: dict): agent_id = agent_info["agent_id"] self.agents[agent_id] = (agent_info, time.time()) for cap in agent_info.get("capabilities", []): self.capability_index.setdefault(cap["name"], []).append(agent_id) return {"status": "registered", "agent_id": agent_id} async def get_available_agent(self, capability: str): candidates = self.capability_index.get(capability, []) for agent_id in candidates: info, last_heartbeat = self.agents[agent_id] dead_interval = time.time() - last_heartbeat if dead_interval > info.get("heartbeat_deadline", 45): continue return info return None这个Hub类是单进程内存版,生产环境可以换成Redis或者数据库,但核心逻辑不变:注册、心跳校验、能力匹配。
再看Agent端,封装一个最简基类:
# agent_base.py import httpx class ReachAgent: def __init__(self, agent_id: str, hub_url: str): self.agent_id = agent_id self.hub_url = hub_url self.capabilities = [] def register_capability(self, name: str, handler): self.capabilities.append({"name": name, "handler": handler}) async def start(self): payload = { "agent_id": self.agent_id, "capabilities": [ {"name": c["name"], "input_schema": {}} for c in self.capabilities ], "endpoint": "http://localhost:9001", "heartbeat_interval": 15 } async with httpx.AsyncClient() as client: await client.post(f"{self.hub_url}/register", json=payload) async def dispatch(self, request: dict): cap_name = request["target_capability"] for cap in self.capabilities: if cap["name"] == cap_name: return await cap["handler"](request["payload"]) raise ValueError(f"capability {cap_name} not found")使用方式很简单,两个互不相识的Agent通过ReachHub互相触达:
# agent_a.py (邮件解析Agent) agent_a = ReachAgent(agent_id="email-agent-01", hub_url="http://localhost:8000") async def email_parse(payload): # 实际逻辑:调用NLP模型解析邮件正文 return {"sender": "customer@example.com", "intent": "scheduling"} agent_a.register_capability("email.parse", email_parse) await agent_a.start()# agent_b.py (日程助理Agent) agent_b = ReachAgent(agent_id="schedule-agent-01", hub_url="http://localhost:8000") async def schedule_create(payload): # 实际逻辑:创建日历事件 return {"calendar_event_id": "evt_123456"} agent_b.register_capability("schedule.create", schedule_create) await agent_b.start()当第三个编排Agent需要“解析邮件后直接创建日程”时,通过ReachHub先定位email.parse的Agent、调用拿结果,再定位schedule.create的Agent、把邮件解析结果作为入参投递过去,整条链路就通了。
这个最小实现虽然粗糙,但把Agent-Reach最核心的“注册-发现-触达”闭环跑通了。后续要做的优化非常多,比如:
- 把注册信息落到Redis等共享存储,ReachHub才能水平扩展
- 增加请求重试和幂等控制,防止网络抖动导致重复执行
- 增加安全认证,至少每个Agent带个Token,防止内网里的陌生Agent乱注册
4. 触达链路中的主要故障场景与排查思路
Agent-Reach上线后,我在生产环境里陆续遇到了不少问题。选几个代表性的故障,把完整的排查链路写出来,比直接给答案更有价值。
4.1 故障一:Agent状态频繁“假死”,调度全部超时
现象是某天开始,调度引擎频繁报错:no available agent for capability email.parse。但直接访问那个邮件Agent的接口又是通的,日志也正常。一开始我怀疑ReachHub和Agent之间的网络有问题,结果检查心跳日志才发现,Agent发给Hub的心跳包全在45秒之后才到达,而我把心跳失效阈值设成了45秒。等于Agent每次心跳都赶在“生死线”上,偶尔晚一点点就被判死。
根因是Agent进程里有个同步阻塞操作——一次本地JSON文件全量解析,耗时接近20秒,期间事件循环被卡住,心跳请求根本发不出去。解决办法有两个:一是把心跳发送放到独立线程里,不跟主业务逻辑抢占事件循环;二是把那个全量解析改成增量解析,把单次阻塞时间降到1秒以内。两个方案我都做了,之后“假死”故障再没出现过。
注意:Agent的心跳必须和业务逻辑隔离,不能共用一个阻塞的事件循环。这是所有Agent框架里最容易忽略的细节。
4.2 故障二:回调地址不对,结果全部丢失
异步任务模式里,Agent A把一个耗时任务丢给Agent B,B处理完通过callback_url回调A。上线第二天就发现,B的回调全部失败,但B的日志里显示处理成功了。
一查,问题出在callback_url上。Agent A在注册时填的endpoint是http://localhost:9001,但A跑在容器里,B跑在宿主机上,这里的localhost是B自己。回调请求发到了B本机的9001端口,自然找不到A。
排查链路其实很常规:先看ReachHub转发日志,确认task被正确投递到B;再看B的执行日志,确认B确实调了callback_url;然后看B所在机器的访问日志,发现它请求的IP是127.0.0.1;再查A在ReachHub注册的endpoint,发现是localhost。问题一目了然。
修复方案是:所有Agent注册时,endpoint必须用容器外可访问的地址。同时回调URL里的host不要从Agent自己上报的endpoint里拼,而是由ReachHub在调度时根据注册表里的真实地址动态拼出来。Agent上报的endpoint在互联网场景下基本都是错的,除非你有完善的服务发现机制。
4.3 故障三:幂等没做,重试导致重复执行
有一次业务流程里,一个编排任务连续创建了三个重复的日程事件。查日志发现,ReachScheduler在投递schedule.create请求时,第一次HTTP超时了,于是触发了重试。Agent B第一次其实已经成功创建了日程,但响应在网络上堵了一下,超时后调度器重发了一次,B又创建了一个。
问题本质不是网络,而是任务执行缺少幂等语义。解决办法是在ReachLink协议里增加幂等键机制:每个请求可以根据业务语义生成一个idempotency_key(比如schedule-create-{order_id}-{line_item_id}),Agent在处理时先检查这个键是否已经处理过,处理过就直接返回上次的结果,不再重复执行。
# agent_side idempotency check processed_keys: dict = {} async def schedule_create_with_idempotency(payload, idempotency_key): if idempotency_key in processed_keys: return processed_keys[idempotency_key] result = await schedule_create(payload) processed_keys[idempotency_key] = result return result这个改动加完之后,不仅是重试导致的问题消失了,之前因为回调丢失导致的下游重复拉取也顺便解决了。
4.4 故障四:能力标签命名不规范,路由匹配错乱
某个新加入的Agent把自己的能力标签写成了Email.Parsing,而其他Agent注册的是email.parse。ReachHub的匹配逻辑是精确字符串匹配,大小写敏感,导致新Agent一直接不到任务,还让人误以为是它本身代码有问题。
后来我做了两件事:
- 在注册入口加了一个能力标签归一化校验,统一转成
domain.action格式,强制小写 - 在ReachHub里加了一个启动自检任务,定期扫描能力标签里是否存在疑似重复但有大小写差异的条目,打印警告日志
规范往往是出问题之后才被重视的。能力标签就是Agent协作的系统接口,给它建立严格的命名规范怎么强调都不过分。
5. 生产环境落地Agent-Reach时容易忽略的细节
以上故障大体解决后,Agent-Reach算是稳定跑起来了。但生产环境跟试验环境完全两码事,还有几个细节是在落地时反复踩到、又很容易被教程忽略的。
5.1 并发控制与背压机制
Agent能处理的并发任务数是有上限的。如果ReachHub只顾着投递任务,不考虑Agent的实际承受能力,很快就会出现大量超时和失败。我给ReachAgent加了一个简单的槽位(slot)机制:Agent启动时声明max_concurrent_tasks,ReachScheduler投递前先检查目标Agent当前占用槽位数,达到上限就选择其他Agent或返回排队。
实现上不复杂,Agent端每接一个任务就占用一个槽位,任务结束释放,定期把当前槽位占用率随心跳上报给ReachHub。
# agent status payload appended to heartbeat { "agent_id": "email-agent-01", "slots": {"current": 3, "max": 10, "queue_depth": 2} }有了这个数据,调度引擎的“最少负载”策略才算真正有据可依,而不是瞎猜。生产环境经常会遇到某台机器CPU跑满但Agent的队列很短的情况,单纯看CPU是不可靠的,槽位占用率和队列深度更接近真实压力。
5.2 可观测性与链路追踪
Agent-Reach投入使用后,最痛苦的是定位“任务到底卡在哪”。我建议把所有关键环节的日志集中在一个统一格式里,至少要包含:request_id、agent_id、capability、event、timestamp、duration_ms。这样配合日志平台,直接按request_id搜就能看到完整链路。
下面是我常用的日志事件表:
| event | 含义 | 关键字段 |
|---|---|---|
reach.request.received | Hub收到请求 | request_id, target_capability |
reach.agent.selected | Hub选定目标Agent | request_id, agent_id, strategy |
reach.request.forwarded | 请求转发给Agent成功 | request_id, agent_id, status_code |
reach.callback.received | Agent的结果回调到达 | request_id, agent_id, duration_ms |
reach.timeout.triggered | 等待结果超时 | request_id, timeout_value |
reach.task.success/reach.task.failed | 任务最终状态 | request_id, result |
生产环境里我还会给关键指标配监控告警,比如“Agent心跳成功率低于95%”就要告警,“task超时率高于5%”也要告警。尽早发现Agent群里不健康的成员,避免它拖垮整条协作链路。
5.3 安全与身份校验
Agent相互触达时,默认要互相信任。但在真实的企业环境里,不同团队维护的Agent安全级别可能不一样,甚至有的Agent还会挂在公网上,完全没有身份校验是很危险的。
Agent-Reach里我增加了一个最简单的Token认证方案:每个Agent注册时分配一个密钥,所有请求都带Authorization: Bearer <token>,ReachHub校验通过后才转发。跨部门协作时,还可以用类似API网关的机制做细粒度权限控制,精确到某个Agent只能触发哪些capability。这个工作越早做越省钱,否则等Agent数量上来之后,再挨个做认证改造成本会高得多。
5.4 部署形态的选择
Agent-Reach的Hub节点需要独立部署,可以用一个轻量级的进程服务,也可以跟已有的API网关集成。Agent端则在原有服务上增加一个注册启动逻辑即可,不需要单独抢机器。我在生产里的形态是:
- ReachHub:独立部署,一个Docker容器,存储用Redis
- ReachScheduler:与ReachHub合并部署,作为ReachHub内部的调度模块
- Agent代码:在各自的服务进程里增加一段启动时注册逻辑
整体架构部署起来非常轻,没有引入任何新的重量级中间件,这是它能快速在团队里落地的重要原因。
6. 给跳进Agent协作坑的朋友几句实在话
最后聊几句题外话。Agent-Reach这个项目做下来,我最大的体会是:多Agent系统的难点往往不在大模型能力,而在工程侧的“连接”与“可靠”。很多人一听到多Agent就兴奋地聊规划、决策、反思这些“高阶智能”话题,却忽略了最基础的通信协议、超时控制、幂等重试、健康检查这些工程细节。Agent-Reach正是补齐了这些基础能力,才让上层那些复杂的协作逻辑有了跑起来的前提。
维护这套基础设施一段时间后,你会发现Agent协作的世界跟微服务有大量相似之处:服务注册发现、负载均衡、熔断、链路追踪、权限控制……把微服务领域的成熟经验平移过来,就能避免很多一拍脑袋的设计。区别只是服务变成了具备一定自主性的Agent,通信协议从内部RPC变成了更动态的“能力触达”。
如果你也在跑多Agent项目,建议顺序是这样的:先确定Agent能否被稳定发现,再定义能力触达的协议格式,然后是任务的可靠投递和回调,最后再考虑怎么编排复杂流程。有Agent-Reach在处理前两层,后面几层你会省下大量时间。
如果你手头已经在用其他Agent框架,也可以参考这篇文章里的几个设计点自查一下:你有request_id吗?超时重试会重复执行任务吗?Agent心跳会阻塞业务吗?如果这三个问题的答案是否存在隐患,那Agent-Reach的设计思路或许正好对你有用。