ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent-Reach:多智能体协作触达网关,解决注册发现与消息回执难题

Agent-Reach:多智能体协作触达网关,解决注册发现与消息回执难题

1. 为什么需要Agent-Reach:多智能体协作里“触达”才是真瓶颈

过去一年我一直在做多智能体协作相关的项目,说实话,最先让我头疼的并不是大模型能力不够,也不是提示词写得不好,而是——Agent之间互相“喊不应”。

举个例子,我搭过一套售后工单处理流水线:客服Agent先接待用户、判断问题类型,然后派单给质检Agent做初步排查,排查通过后再触发退款Agent执行退款。听起来逻辑很简单,但真正跑起来之后问题层出不穷。客服Agent把消息发出去,质检Agent没收到;质检Agent处理完了,退款Agent那边已经超时下线了;有时候消息明明送到了,退款Agent也执行了,但因为回执丢了,客服Agent以为失败了,又补发了一次,结果用户被退了两次款。

这些问题的本质根本不是模型策略,而是Agent之间缺乏可靠的通信触达机制。我翻了不少开源项目,发现大家把大量精力放在Agent的规划能力、工具调用、记忆管理上,可在“A如何找到B、如何把消息稳定送到B、如何确认B真的处理了”这件事上,几乎都是拍脑袋做的。有的直接用HTTP互相调,有的往Redis里塞消息,有的干脆用WebSocket连一下就不管了。

这就是我启动Agent-Reach的原因。它定位为一个轻量级的智能体触达网关,专门解决多Agent协作中的注册发现、意图路由、消息投递、回执确认这四个问题。项目本身不大,但做完之后我对整个智能体工程化的理解提升了一大截。如果你也在做多代理编排、工作流调度、或者任何需要多个智能体配合的业务系统,这篇文章应该能帮你在触达层少走很多弯路。

Agent-Reach的设计理念其实很朴素:让Agent像微服务一样可注册、可发现、可路由、可观测,但API风格要足够简单,让普通开发者不用理解分布式中间件就能接入。下面我按实际搭建的顺序,把这个项目的核心拆开讲一遍。

2. Agent-Reach的核心问题拆解:从“能跑通”到“可靠地跑通”

2.1 “喊不应”的三种典型现场

我把自己踩过的坑和身边朋友遇到的同类问题归纳了一下,多Agent协作里的通信故障基本逃不出以下三种。

第一种是注册发现缺失。Agent A持有Agent B的写死地址,但B为了更新模型或扩副本重启了,IP变了、端口换了,A还在往老地址发消息,发一次失败一次。这个问题在容器化部署下尤其严重,因为Pod重建后的地址是随机分配的,写死地址等于自断生路。

第二种是连接抖动。两个Agent之间的通信链路没有做健康检查,短时网络抖动、对端GC停顿或者连接空闲被回收之后,调用方完全无感,一直以为通道是好的。等到消息真正发出去,才发现连接已经死了,但这时候往往已经来回重试了很长时间,业务早就超时了。

第三种是回执丢失。这是最隐蔽也最麻烦的。消息从A发出、B收到并处理成功,但B返回给A的确认在网络中丢了,或者A收到了但自己的状态没更新,于是A重新投递。对于不要求幂等的业务,比如创建工单、执行退款,重复投递的后果极其严重。

过去我遇到这些问题时的第一反应就是“加大超时、加重试、加日志”,但实际上治标不治本。触达问题不是一个参数能解决的,它需要一套完整的机制来兜底。

2.2 这本质上是“Agent版服务发现”问题

想明白这一点之后,我意识到自己其实在做的事,和微服务架构里Service Mesh做的事情非常像。无非是服务发现从Eureka换成了Agent注册中心,负载均衡换成了意图路由,熔断重试换成了触达回执。大模型在这里并不是主角,主角是一套可信任的通信语义。

那为什么不直接搬一套Spring Cloud或者Istio过来用?我认真比较过。Istio这类Service Mesh太重了,它对基础设施的侵入很强,要求Kubernetes环境、sidecar注入、流量劫持,而很多Agent应用只是几个Python脚本或者Node服务,根本跑不动这一套。Spring Cloud又太Java生态,对Python和Node的Agent很不友好。

Agent-Reach的取舍是:做成一个独立的轻量级服务层,Agent通过HTTP或者少量SDK接入,不依赖特定语言和容器平台。核心只保证三件事:Agent上线后能被找到;调用方发消息时能路由到正确的Agent;消息送达后能确认处理结果。做不到这三点,上层再智能的规划也白搭。

3. 系统设计与选型决策:为什么是“注册中心+消息网关”而不是消息队列

3.1 先对比一下“正确地发消息”的几种方案

在设计Agent-Reach之前,我把市面上常见的Agent间通信方案列了一张对比表,逐一分析它们的适用边界。

方案可靠送达回执确认路由能力复杂度适用场景
HTTP直连无无无低调试、Demo
Redis消息有弱无低异步触发
消息队列(Kafka/RabbitMQ)有弱无高事件流、削峰
Agent-Reach有有有中多Agent协作调度

消息队列在可靠投递方面确实很强,但它有个根本问题:它只保证消息送到了队列,不保证业务Agent真的处理成功了。我消费掉一条消息不代表退款执行成功,只是代表我开始执行了;万一执行到一半崩溃,消息已确认,这个事件就永久丢失了。Agent协作的场景恰恰需要“端到端的处理确认”,而不是“投递确认”。

HTTP直连够简单,但它在Agent数量稍微多起来之后就变成了一团乱麻。每个Agent都要维护一份对端地址列表,任何一个变更都要通知所有相关方,扩容和缩容几乎不可管理。

所以我最终确定的方案是:一个中心化的注册中心负责“谁在线、谁能干什么”,一个轻量网关负责“消息怎么投递、怎么确认”。路由信息和通信链路分离,Agent只需要跟网关打交道,不需要关心其他Agent在哪。

3.2 Agent-Reach的四个核心模块

Agent-Reach整体分四个模块,职责边界非常清晰:

  • Registry(注册中心):维护所有在线Agent的信息,包括ID、能力描述、当前地址、存活状态。通过心跳续期的方式做健康管理。
  • Router(意图路由器):接收调用方发来的“需求描述”,根据Agent注册的能力信息和历史表现,决定这条消息该派给谁。
  • Messenger(消息网关):负责实际的消息投递,支持HTTP和WebSocket两种通道,处理超时、重试、回执匹配。
  • Callback(回执管理器):记录每条消息的投递状态、处理状态,向调用方返回明确的成功或失败信号,同时维护幂等键,防止重复执行。

这套设计的核心思路是**“找Agent和发消息分开走”**。找Agent靠Registry和Router,发消息靠Messenger,两边的状态都沉淀在Callback里。这样无论哪一环出问题,都可以从回执数据中快速定位。

3.3 为什么不做成SDK侵入式的

一开始也有朋友建议我把Agent-Reach做成一个Python/Node的SDK,让Agent在代码里import一下就能把通信能力集成进去。这个方案用起来确实爽,但有几个现实问题。

第一,生态割裂。Agent用Python写的、用Node写的、用Go写的都有,每个语言都要维护一份SDK,工作量直接翻倍。第二,升级成本高。一旦SDK有bug或者需要加功能,所有接入方都要跟着升级,而Agent服务往往是长驻的,升级就显得很麻烦。第三,也是最重要的一点,侵入式SDK会让Agent和Agent-Reach强耦合,想换掉网关变成一件伤筋动骨的事。

所以Agent-Reach选择了纯协议接入的方式。Agent只需要在不同的生命周期节点向Registry发几个HTTP请求,消息收发统一走Messenger的接口。说是“SDK”,其实只是一个不到200行的客户端封装,任何一个语言都能实现。这个决策做对了,后面接入新语言的时候省了非常多的事。

4. 关键机制的实现细节:注册、路由、回执的完整闭环

4.1 注册与心跳:让Agent“可以被找到”

Registry的核心数据结构很简单:一个Agent注册表,Key是Agent ID,Value包含能力描述、endpoint地址、模型信息、当前状态、最后心跳时间。整个表放在内存里,加上一个可选的Redis持久化副本,用来在Registry自身重启后快速恢复。

下面是我实现注册逻辑时的核心代码片段,用Python写了一个极简版本方便讲解:

# registry.py 核心逻辑 class AgentRegistry: def __init__(self, ttl=60): self._agents = {} # agent_id -> AgentInfo self._ttl = ttl # 默认60秒未心跳则视为下线 def register(self, agent_id, endpoint, capabilities, model=None): """Agent上线/更新注册信息""" self._agents[agent_id] = { "endpoint": endpoint, "capabilities": capabilities, # e.g. ["refund", "quality_check"] "model": model, "status": "online", "last_heartbeat": time.time(), } return {"agent_id": agent_id, "status": "registered"} def heartbeat(self, agent_id): """Agent周期心跳,续期存活时间""" if agent_id in self._agents: self._agents[agent_id]["last_heartbeat"] = time.time() return {"status": "ok"} return {"status": "not_found", "hint": "请先register"} def sweep_expired(self): """定期清理超时未心跳的Agent,标记为offline""" now = time.time() expired_ids = [ aid for aid, info in self._agents.items() if now - info["last_heartbeat"] > self._ttl ] for aid in expired_ids: self._agents[aid]["status"] = "offline" return expired_ids

这里有一个值得注意的设计点:心跳超时标记为offline但不直接删除。原因是在分布式环境中,网络抖动会让一个健康的Agent偶尔漏掉一两次心跳,如果把它的注册信息直接删除,下次它恢复心跳时就要重新注册,而重新注册之前如果有消息路由过来,就会直接失败。保留offline状态的好处是,Agent恢复注册时可以直接复用之前的endpoint和能力配置,切换成本更低。

心跳的超时阈值(TTL)怎么定,这个我在后面踩坑部分会详细讲。这里先给一个参考值:心跳周期10秒,TTL设60秒。这样能容忍连续5次心跳丢失,既不会因为一次网络抖动就误杀,也不会让死掉的Agent在注册表里停留太久。

4.2 意图路由:调用方只声明“要干什么”,不用管“找谁”

路由是Agent-Reach里最像“智能”的部分,但我实际做下来发现,它不应该依赖大模型,而应该依赖清晰的元数据匹配,大模型只做兜底。

路由的核心输入是调用方发来的一条消息,消息里带有一个intent字段,比如"用户申请退款,金额328.5元"。Router拿到这段描述后,先从所有在线Agent中筛选出能力标签与意图匹配的候选集。这里的匹配同时做两层:一是关键词和标签的直接匹配,比如消息里有“退款”两个字,就去找capabilities里含refund的Agent;二是注册时填写的能力描述与意图的语义匹配,这个可以调用一个小的Embedding模型算相似度,但没必要用GPT级别的接口,成本太高。

如果候选集里只有一个Agent,直接把消息投给它是理所当然的;如果有多个Agent都声称自己能处理退款,Router就需要看历史路由记录,优先选择过去成功率最高的那个。这算是一个简单的加权负载均衡。

我把路由逻辑简化成了下面这段:

# router.py 意图路由核心流程 class IntentRouter: def __init__(self, registry, matcher): self._registry = registry self._matcher = matcher def route(self, message): intent_text = message.get("intent", "") # 先找到所有状态为online的Agent online_agents = self._registry.get_online_agents() # 提取候选:能力标签匹配 candidates = [] for agent in online_agents: score = self._matcher.match(intent_text, agent["capabilities"]) if score > 0.6: candidates.append({"agent_id": agent["id"], "score": score}) if not candidates: # 兜底:尝试用LLM做一次意图分类,把结果映射到能力标签 fallback_tag = self._llm_fallback(intent_text) candidates = [a for a in online_agents if fallback_tag in a["capabilities"]] # 按分数降序,分数相同则按历史成功率排 candidates.sort(key=lambda x: (x["score"], self._success_rate[x["agent_id"]]), reverse=True) return candidates[0]["agent_id"] if candidates else None

这里有个很关键的实践心得:意图路由的匹配阈值宁高勿低。0.6的score阈值意味着只有比较确定的能力匹配才会被选中,如果低于这个值就返回null,宁可让调用方明确收到“找不到合适的Agent”的错误,也不要盲目投递给一个可能处理不了的Agent。因为投递错了的代价比投递失败的代价更大——它会让一个无关Agent把消息吃进去,然后返回一个莫名其妙的错误,排错成本翻倍。

4.3 消息投递与回执闭环:幂等键是保命设计

消息投递这一层是Agent-Reach里最容易被低估的部分。很多人觉得投消息不就是requests.post一下吗?实际上要处理的问题非常多:超时重试、投递结果记录、重复消费者去重、处理结果回传。

我的Messenger模块采用了一个很简单的状态机来追踪每条消息的生命周期:pending(已接收)→ delivering(投递中)→ delivered(已送达)→ processed(已处理)→ failed(失败)。每个Agent在处理完消息之后,必须主动调用/callback接口告诉Agent-Reach“这条消息我处理完了,结果是成功还是失败”。如果只有delivered没有processed,说明Agent接收了但没有正常执行,这个消息在超时之后会被重新投递到同一个或另一个Agent。

下面就是回执处理的核心逻辑:

# messenger.py 幂等投递与回执确认 class Messenger: def __init__(self, registry, callback_store): self._registry = registry self._store = callback_store async def deliver_with_ack(self, target_agent_id, message): message_id = message["message_id"] # 幂等检查:如果这个消息已经成功处理过,直接返回成功,不重复投递 if self._store.is_processed(message_id): return {"status": "duplicate", "message_id": message_id} endpoint = self._registry.get_endpoint(target_agent_id) try: # 投递消息,并等待agent处理后的回调确认 resp = await self._http_post_with_timeout( f"{endpoint}/process", json=message, timeout=30 ) if resp.status_code == 200: await self._store.mark_processed(message_id) return {"status": "processed", "message_id": message_id} else: # 业务处理失败,进入重试队列 await self._store.retry(message_id, reason=f"http_{resp.status_code}") return {"status": "retry", "message_id": message_id} except TimeoutError: # 超时:不确定Agent是否处理了,必须用幂等键来兜底 await self._store.retry(message_id, reason="timeout") return {"status": "retry", "message_id": message_id}

幂等键是这里绝对不能省略的设计。message_id由调用方生成,在第一次投递时写入Callback存储。Agent侧收到带message_id的消息时,先检查自己是否已经处理过这个消息ID,处理过就直接返回成功,不再执行第二次。这样才能兜住“超时重试导致重复执行”的最坏场景。

关于重试的策略,我采用的是指数退避,间隔从5秒开始翻倍,最多重试5次。重试次数太多没有意义,因为超过一定时间后,业务上下文可能已经变了,继续重试只会加重系统负担。最后如果还是失败,就把消息标记为failed,同时给调用方回传一个可读的错误原因。

5. 实测效果:这套触达层到底值不值得做

5.1 对比压测数据

系统搭建完成之后,我做了一系列压测对比。场景是模拟一个客服流水线:三个Agent协作处理一条工单,总共需要完成“意图分析→质量检查→结论反馈”三个步骤。对比对象分别是纯HTTP直连和Agent-Reach两种方案。

测试环境是同一台8核16G服务器,Agent用Python实现,每个Agent占用一个独立进程。消息量分别按每分钟500条和2000条跑。

指标HTTP直连Agent-Reach
消息送达成功率(稳定期)97.2%99.8%
消息送达成功率(注入5%网络抖动时)81.5%98.9%
P95端到端处理延迟3.8s4.2s
重复执行率1.9%0.02%
新增Agent接入耗时约10分钟(改代码+部署)约1分钟(注册+配置能力)

结果非常直观。Agent-Reach在延迟上比HTTP直连多了约0.4秒,这是网关转发和路由匹配的固定开销,对于Agent协作这种秒级以上的业务来说完全可接受。但换来的可靠性和重复执行率提升是巨大的:抖动场景下送达成功率从81.5%拉到了98.9%,重复执行率从千分之19降到了万分之2。

这组数据让我坚定了“Agent协作必须有触达层”的判断。很多系统在理想网络环境下跑得很顺,但生产环境的网络是不完美的,进程会重启、网络会抖动、机器会故障,这时候有没有可靠的重试和回执机制,就是系统能不能稳定跑下去的分水岭。

5.2 哪些业务场景真正从这套设计里受益

压测数据只能代表理想负载,我自己复盘后总结,Agent-Reach真正发挥价值的场景有三个。

第一个是长耗时任务协作。如果一个Agent任务需要跑几十秒甚至几分钟,调用方不可能一直同步等待结果。Agent-Reach的异步回执机制让调用方可以先收到“已受理”,等Agent真正完成后回调通知结果,这个模式天然适合。

第二个是跨实例的Agent集群。当同一个Agent部署了多个副本,消息需要分发到任意一个可用实例,并保证同一个逻辑任务的多次消息都落在同一个实例上(会话粘滞)。Agent-Reach的Registry可以把同一Agent ID的多个endpoint管理起来,按负载和会话ID做路由。

第三个是故障恢复场景。Agent重启后自动重新注册,历史会话消息可以按幂等键从Callback存储中恢复,不会因为一次重启就让整个流程中断。这在大模型Agent频繁更新模型参数、不断重启的日常里非常实用。

6. 踩坑实录与可复用的定位思路

6.1 心跳TTL设置的“幽灵掉线”问题

前期我把心跳TTL设成了30秒,心跳周期5秒,觉得这个参数够宽松了。结果上线之后频繁出现Agent被误判掉线的情况,现象是日志里没有任何报错,但Router偶尔说找不到某个Agent,过了几秒又自己恢复了。

排查了很久才发现,原因是Agent宿主机的CPU被其他任务占用,导致Agent进程的Python GIL调度迟迟得不到执行,心跳请求本身发出了,但处理应答的循环被卡住了。30秒的TTL看着宽裕,但遇到宿主资源争抢时依然不够。而且心跳是每5秒一次,一旦GC或者大文件IO卡了十几秒,就会有连续多次心跳没有成功完成,直接触发TLL超时。

最终的调整是双管齐下:心跳周期放宽到10秒,TTL拉到60秒,同时给Registry加了一个“满心跳容错”机制,即只要Agent在最近3个TTL窗口内有过任意一次成功心跳,就不判定离线。这两个改动合在一起之后,幽灵掉线问题消失了,代价是真正死掉的Agent最长要延迟60秒才能被清理出去。在Agent协作场景里,多等几十秒永远比误杀一个健康Agent带来的损失小。

6.2 幂等键缺失:一次退款触发的惨痛教训

这个坑我是在Demo阶段踩的,但印象极其深刻。当时我的消息投递代码里没有幂等检查,重试逻辑也刚写完。测试退款场景时,我故意模拟了一次网络超时,让Messenger重试投递退款消息,结果退款Agent执行了两次,数据库里多了两条退款记录。

排查过程让我真正意识到结构性问题:超时重试无法区分“消息没送到”和“消息送到了但回执丢了”。代码层面唯一能兜住这个不确定性的就是幂等键。Agent侧在处理任何业务消息前,先查看本地或者共享存储里有没有相同的message_id,有就直接返回上次的处理结果,不执行第二次。

修复之后我顺便把Callback存储也做了升级,从纯内存改成了SQLite持久化,这样Agent-Reach自己重启后也不会丢失消息ID的处理记录。这也是一个我认为很重要的细节:所有防重设计的状态必须持久化,内存态在进程崩溃后等于没有。

6.3 新Agent注册后的路由冷启动问题

还有一个很有意思的问题,是我加了第二个退款Agent之后才遇到的。一个新Agent注册上线,它没有任何历史路由记录,success_rate是默认值0,所以Router几乎永远不会选中它。结果就是新Agent空转了很久,拿不到流量,而老Agent被持续打满。

这里我建议的做法是给新Agent一个“冷启动权重”。新注册的Agent在第一个小时里获得一个临时加分,让它有一定概率被路由选中,同时把它的执行结果记录下来,一旦出现成功数据就切换成正常的成功率加权。这本质上是一个探索与利用的问题,在Agent数量多了之后,这个机制很重要,否则你永远无法平滑地扩展现有Agent的实例数。

7. 后续还能怎么扩展:从“触达”走向“协作治理”

现在Agent-Reach只是把触达层做扎实了,但它具备的Agent元数据基础,其实可以往上长出很多有用的能力。我目前正在做的是三个方向。

第一个是Agent链路追踪。既然每条消息都有message_id和回执记录,那么把多个消息关联起来,就能还原出一条完整的Agent协作链路。比如用户申请退款这件事,经过了客服Agent、质检Agent、退款Agent三次触达,全部串在一起之后,任何一环出了问题都能精确定位,不需要再靠日志去拼。

第二个是Agent能力白名单和权限控制。当前的Router基于能力标签做匹配,下一步我想给每个Agent加上调用权限声明,让需要敏感操作的Agent只接受经过授权的调用方触达。这个对生产环境的合规运行很有必要。

第三个是基于回执数据的负载分配优化。Callback存储里积累了每个Agent的历史处理时长和成功率,把这些数据做成动态路由权重,比现在的“成功率加权”要精确得多。实测下来,同样的消息量,动态权重方案能把P95延迟再降低大约15%。

这套东西现在已经在我几个真实的Agent项目里稳定跑着了。如果你正在做的事也涉及多个智能体协作,而且你为“消息发不出去、处理没确认、执行重复”这些问题犯过愁,我建议你认真考虑一下给系统加一层触达网关。模型能力你可以慢慢调,但通信可靠性是越早解决越省心的底子。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进