我们团队最近在跑一套多智能体协作系统,最初大家各自调用API、各自维护上下文,结果数据到处都是,Agent之间互不相认,用户提一个跨模块的需求,系统要绕好几圈才能找到真正该干活的模块。后来我们把“让Agent之间能触达彼此”这件事抽出来做了专门的一层,这套东西我们自己内部叫它“Agent-Reach”。简单说,它解决的不是“单个Agent怎么变聪明”,而是“一堆Agent怎么找到对方、把消息送过去、还能安全地拿到结果”。这篇文章就把我们设计这套机制时的思考、踩过的坑和最终落地的方案完整写出来,希望对正在做类似事情的你有点帮助。
1. 真正难的不是对话,而是“找到并触达”
1.1 先给“Agent”和“Reach”定个坐标
在开始之前,有必要先统一一下概念。我这里说的Agent,不单指某个大语言模型对话机器人,而是泛指一切能独立完成某个任务单元的软件实体——它可以是一个调用外部API的脚本、一个封装了业务逻辑的微服务、一个带记忆和规划的LLM应用,甚至是一套自动化工作流。它们共同的特点是:具有一定自主性,能接收指令,能产生行为,并且返回结果。
而“Reach”,在这个语境下就是“触达”的意思。触达不等于通信。通信只是把字节从A搬到B,触达是让A能按需发现B的存在、确认B的能力边界、将消息按B能理解的方式送达,并且在B异常时知道如何处理。换句话说,Agent-Reach是把“消息传递”这个动作,升级成“能力匹配与业务交付”这个完整链路。
1.2 为什么传统的服务发现和网关不够用
很多朋友第一反应是:这不就是注册中心加网关吗?我们用Nacos、Consul,再加一层Gateway,不就都有了吗?老实说,最初我们也这么想,但真正一跑就发现不对劲。
传统微服务发现解决的是“哪个实例存活的IP和端口”,它是面向静态接口的。而Agent触达面对的是“某类任务应该由哪个Agent来处理”,它是面向语义能力的。举个例子,用户说“帮我总结一下这份合同的风险点”,传统网关只能把请求路由到固定的服务URL,但我们的系统里可能有三个Agent都声称自己能处理“合同分析”,可它们的输入格式、上下文要求、擅长的合同类型完全不同。让网关根据URL做路由解决不了这个选择问题。
此外,Agent之间通常需要多轮来回,甚至互相嵌套调用——Agent A发现自己需要Agent B的能力,B又需要C,这种动态形成的调用链,不可能在部署的时候靠静态配置全部写死。触达必须是运行时动态发生的。
1.3 三种基本触达模型:点对点、广播、共识协调
在设计Agent-Reach时,我们先把触达模式收敛成三种,避免每个场景都造一个轮子:
- 点对点触达:调用方明确知道要找谁,只需按Agent身份精确投递。适合日志上报、定时任务分发、已知依赖调用。
- 广播触达:调用方不知道谁合适,先把任务发给某个分组的所有Agent,由收到消息的Agent自己判断是否响应。适合任务分配、选举类场景。
- 共识触达:多个Agent都参与处理同一个任务,最后对结果做裁决或融合。适合需要交叉验证的生成任务、多角度分析类场景。
这三种模式的差异主要体现在路由策略和结果汇聚逻辑上。点对点最简单,广播和共识则需要引入分组、优先级和投票机制。后面我们会详细讲每一种在落地时要注意的细节。
2. 核心设计:把触达拆成四个层次
在我们内部,Agent-Reach不是一个独立的大平台,而是一套分层机制。我们把它分成了身份、注册、寻址、投递这四个层次,每一层解决一个独立的子问题,这样各自的演进和容错都能独立进行。
2.1 身份层:Agent-ID不是简单的名字
让Agent可触达的第一步,是给每个Agent一个稳定的身份标识。有些开发者习惯直接用“agent_name”,比如“contract_analysis_agent”,但这样管理一旦规模上来就会乱套——重名、改名、多版本并行,全都会变成灾难。
我们采用的是三元组身份结构:
| 字段 | 示例 | 说明 |
|---|---|---|
| Namespace | legal | 用于隔离不同业务域,避免权限越界 |
| Agent-Type | contract-review | Agent的能力类型,用于语义匹配 |
| Instance-ID | uuid-v7 | 每个运行实例的唯一标识 |
这样,即使同一个Agent-Type启动了多个实例,每个实例也有独立的身份。路由时,我们可以按Namespace限制可见范围,按Agent-Type做能力匹配,按Instance-ID做精确投递。同时,这个三元组也会体现在日志和链路追踪里,排查问题的时候一眼就能看出是哪个域、哪类Agent的哪个实例出了问题。
在设计身份时最好提前考虑版本问题。不同版本的同一个Agent能力会有细微差别,比如v1版本不支持PDF解析,v2版本支持。我们会在Agent-Type后面附加一个可选的version字段,匹配时优先找最新版本,找不到则回退到旧版本,并给调用方打一个warning。
2.2 注册层:能力、状态与有效期
身份解决“我是谁”,注册层解决“谁在、谁能干什么、状态如何”。
2.2.1 能力描述与会话入口
每当一个Agent启动,它就需要向所在群组内的注册中心提交一份能力描述文件。这里我们不建议填写自然语言长文本,而是用结构化的JSON Schema来声明自己的输入、输出、可执行动作和约束条件。举一个例子:
{ "agent_id": "legal:contract-review:ins-01", "capabilities": [ { "name": "summarize_risk_points", "input_schema": { "contract_text": { "type": "string", "max_length": 100000 }, "language": { "type": "string", "enum": ["zh", "en"] } }, "output_schema": { "risk_items": { "type": "array" } }, "execution_duration_hint": "30s" } ], "lease_time": 120, "availability": "online" }有了这套结构化描述,路由层才能做匹配,而不是靠猜测。另外还要声明一个重要的字段——lease_time(租约时间)。这个字段决定了Agent需要多久续约一次。现实中一个Agent可能因为负载过高而失联,如果注册信息长期有效,路由层就会把任务发给一个已经失联的实例,造成不必要的等待。
2.2.2 状态模型
我们定义了四种状态:
- ONLINE:可接受新任务
- BUSY:当前负载饱和,可接受紧急任务但会降速
- DRAINING:正在处理完存量任务,不再接受新任务
- OFFLINE:不可触达
路由层在做任务分配时,会优先过滤掉DRAINING和OFFLINE状态的实例,BUSY状态则视任务优先级决定是否分配。这套状态模型在后续实现流量控制和优雅下线时非常有用。否则,直接从注册中心摘除实例,会丢掉正在处理的任务上下文。
2.3 寻址层:语义路由不是关键词匹配
寻址是Agent-Reach最核心的一层。它的任务是:给定一个消息,找到最合适的Agent实例。
2.3.1 从分组到打分
很多初学者会先做分组,比如“合同分析组的Agent都试试”,但这相当粗糙。我们在分组之上加了一层多因子打分路由。每个候选Agent会接受四个维度的评估:
- 能力匹配度:消息中的任务类型与Agent声明的能力是否吻合,权重最高。
- 上下文相关度:当前消息与Agent已有的会话记忆是否相关,比如如果Agent之前已经在处理同一合同,新消息交给它就不用重新加载上下文。
- 负载因子:实时压力与租约剩余时间,避免把任务塞给快掉线的节点。
- 业务亲和性:例如同租户、同地区、同语言的偏好。
最终总分就是四个维度的加权和。具体权重需要结合业务调整,初期建议按 0.5 / 0.2 / 0.2 / 0.1 起步。
2.3.2 基于Embedding的能力匹配
纯靠人工维护“消息类型 -> 能力”的映射表也能跑,但覆盖度有限。后来我们引入了Embedding方案——将消息文本向量化,同时把每个能力描述也向量化,先算一遍余弦相似度,筛选出TopN候选,再结合上面的四维打分做精排。这样系统面对没有见过的说法时,也能从语义上匹配到可能合适的能力,而不是只能命中关键词。
这并不是说关键词完全没用。在寻址层里,我们刻意保留了“关键词硬规则”作为护栏。比如涉密任务,只能在声明了相应安全级别的Agent里找,即使Embedding相似度再高,越权的都不能选。语义做召回,规则做约束,两者互补,效果比较稳。
2.4 投递层:消息的可靠性与副作用控制
寻址完成后,剩下的问题是:消息怎么送过去,以及怎么保证送过去之后的结果可控。
2.4.1 同步与异步的选择
Agent触达不全是HTTP请求/响应那套模型。有些任务耗时几毫秒,直接同步调用就行;有些任务比如“生成一份行业调研报告”,可能要跑几分钟,这时候同步调用就是灾难。
我们约定:
realtime:同步模式,适用于单次交互、零散问答,调用方阻塞等待。task:异步模式,适用于耗时任务、批量任务,调用方先拿task_id,之后回调或轮询。stream:流式模式,适用于输出长文本或持续推送状态。
2.4.2 幂等与副作用
Agent调Agent和人类调API还不太一样,Agent可能会因为模型幻觉而在失败后重试,但重试不能导致业务上的副作用重复执行。比如一个Agent要做“扣款”操作,如果网络抖动导致它重复投递,用户就被扣了两次钱。这个问题的解法其实很传统——在消息头上加全局唯一的message_id,并让接收方做去重表。非技术读者可以这样理解:你给对方寄快递时,在包裹上贴一个唯一的运单号,收件人那边登记“这个运单号我签收过了”,下次再收到同一个运单号就直接拒收,这样同一批货就不会被收两次。
此外,当一个Agent触达另一个Agent并产生修改类操作时,建议设计补偿动作。我们在消息schema里增加了compensable标志,若为true,接收方需要同时暴露一个“撤销”入口,允许发起方在后续发现结果异常时发起回滚。
3. 轻量落地:从零到可用的Reach协议实现
讲完整体架构,说说我们到底是怎么实现这套机制的。我们没有一开始就上重型中间件,而是先用一套轻量协议跑通流程,再逐步加固。这套实现方式非常适合中小团队参考。
3.1 注册与心跳:基于TTL过期和租约机制
每个Agent实例启动后,向注册中心发送注册请求,并在自己的进程内开一个后台任务做心跳续约。心跳间隔设置为租约时间的1/3,比如租约120秒,心跳就40秒一次,留足网络抖动的缓冲余地。
# 伪代码:Agent 端心跳续约 import asyncio async def heartbeat_loop(agent_client, agent_id, interval=40): while True: try: await agent_client.lease_renew(agent_id) except Exception: # 续约失败,可能是注册中心不可达,进入降级模式 pass await asyncio.sleep(interval)如果注册中心超过租约时间没收到续约,就主动将该实例标记为OFFLINE。这里要注意网络分区的情况——Agent没挂,只是注册中心联系不上。所以我们会区分“宕机”和“临时失联”:失联实例先标记为SUSPICIOUS,任务调度暂时跳过,但保留上下文一段时间,等恢复后可以继续承接原本的会话。
3.2 路由策略:分层打分与递归触达
路由模块接收消息后,执行流程如下:
- 根据Namespace做第一层过滤。
- 校验硬规则(安全级别、数据合规约束)。
- 基于Embedding召回Top10的候选Agent。
- 对Top10做四维打分排序。
- 取Top1直接投递;如果Top1超过一定时间未响应,则按候选队列顺序顺延投递。
递归触达是这套机制里比较有趣的地方。当Agent A收到任务后,如果发现还需要Agent B的支持,它可以以同样的协议向路由层发起一次触达请求,并把A自己的身份作为上下文附带上。这天然支持了多Agent协作,但随之而来的风险是调用链可能变得很深,甚至出现A->B->C->A这种循环。所以在每次递归触达时,消息头里要带上深度阈值和各节点累积的痕迹标识,超过深度直接拒绝并返回可读错误。
3.3 重试、幂等与补偿:Agent与Agent之间的通信不是HTTP那样简单
这部分是我们在测试环境里踩坑最多的。
3.3.1 重试策略
Agent触达失败的原因可能有很多,但不一定真的需要重试。我们采用的是自适应重试:
- 网络超时或瞬时错误:最多重试2次,间隔递增(1秒、3秒)。
- 业务上明确拒绝:不重试,直接转人工或降级。
- 接收端返回“BUSY”:重试1次,并降低发送频率。
一个比较重要的经验是:重试要带随机抖动,不然多个Agent同时失败,大家一起在同一秒重试,能把注册中心打满。给重试间隔加random(0, 50ms)的抖动,就能有效避免这问题。
3.3.2 补偿设计
异步task模式下,如果发起方认为结果不可信,它可以调用接收方暴露的cancel_task或rollback_action接口进行补偿。我们给所有修改类能力都加了标识,没有这个标识的Agent不能申请执行写操作。这样才能守住数据一致的底线。
3.4 安全边界:白名单、令牌与资源隔离
Agent之间互相信任是危险的。我们在触达协议层面做了几道防线:
- 白名单:只有注册在案的Agent才能参与触达,任何未注册实体一律拒绝。
- 短时效令牌:每次触达请求附加token,令牌绑定调用方Agent身份、目标Agent身份和有效期。令牌最好使用JWT这类自包含格式,减少注册中心在每次调用时的在线签发压力。
- 资源配额:给不同Agent设定每分钟的触达配额,防止某个异常Agent无限广播拖垮整体。
4. 实测路上的四个坑:从碰撞到稳定的排查与修复
方案听起来不错,但真正跑起来时,我们先后遇到四个比较典型的坑。说说完整的排查思路,避免你重复走弯路。
4.1 循环触达与消息风暴
第一次全量联调时,我们发现系统整体吞吐异常,CPU飙升。排查时先看了链路追踪平台,发现大量A触达B、B又触达A、A又触达B的循环消息,消息量以指数级增长。
根因是:业务上A和B互相需要对方的能力,但在路由打分时,双方的能力描述过于宽泛,导致互相匹配时分数一直很高。单纯靠深度阈值虽然能防止无限循环,但消息风暴已经产生了大量无效负载。
修复方案有两步。第一步,在能力描述中增加“触发条件”字段,明确什么语义下该能力适用。第二步,在嵌套触达时增加消息血缘校验——如果一条消息的操作链上已经出现了同一种类型的Agent两次,后续触达直接拒绝,并返回“需要人工干预”的提示。
4.2 部分失败:Agent返回了,但不是你要的东西
有一次,我们让一个Agent去另一个Agent那里取“合同中的违约金额”,结果接收方返回了“合同已归档”。业务上它确实响应了,但并没有执行我们想要的动作,这属于部分失败。
这个问题很难完全靠协议层面解决,但可以通过输出Schema的校验层来拦截明显不合格的结果。在投递层里加一个输出校验组件,把接收方的返回结果与调用方声明的期望Schema做结构比对,不通过则自动重路由到下一个候选Agent。这牺牲了一点点延迟,但大幅减少了坏结果的传播。
4.3 注册信息滞后导致的寻址漂移
Agent因为异常被重启后,新实例注册成功,但路由层缓存中还保留着旧实例的信息。这个时间窗内,部分消息仍然被路由到旧实例,造成404或超时。
排查后发现我们的缓存时间设置得比租约时间长。解决办法很简单——让路由层的本地缓存时间严格小于注册中心的租约时间,并且收到404时主动清除本地缓存条目。另外在Agent重启时,尽量复用相同的Instance-ID,会让下游的上下文关联更容易。
4.4 没有贯穿的Trace,排查问题两眼一抹黑
多Agent链路的复杂度比单体调用高一个量级。一次任务可能经过四五个Agent,任何一个环节慢了,整体表现都会拉胯。最初我们没有做统一Trace,结果每次出问题只能挨个看日志,效率极低。
后面我们强制所有Agent在触达时带上trace_id,并把它透传到所有下游调用。每个Agent启动时,初始化一个全局Trace上下文,在任何日志、任何上报数据里都带上这串ID。现在排查一条慢调用,只需要在可视化系统里按trace_id搜一次,就能看到全程的耗时分布和每一步的出入参摘要。
5. 与现有生态共存的落地路径
不少团队已经有一部分Agent能力跑在现有框架里,完全推到重建不现实。我们当时的落地路径是“增量接入”,边跑边改,效果比较平滑。
5.1 对接MCP:把工具语义包进注册资料
如果你的Agent通过MCP协议暴露工具,那么Agent-Reach和MCP并不冲突。我们做了一个适配层:定期扫描MCP server中声明的tools,自动转换成Reach的能力描述,并注册到能力目录里。这样原本走MCP的工具也能参与语义路由。
这个操作可以想象成给一个已有的工具箱贴上网购式的商品标签——工具还是那些工具,但有了统一分类、统一描述和统一检索入口,别的Agent才能精准地找到它们。
5.2 对接事件总线:广播与订阅的折中
团队里还有一些Agent依赖Kafka或Redis Pub/Sub进行消息通信,它们的模型是“发布-订阅”,和Reach的“点对点寻址”不太一样。我们的处理是保留事件总线的通道,但在事件头上增加一个reach_id字段。当某个订阅方Agent收到数据后,如果发现自己需要更专业的处理,它可以把这个reach_id再投递给更合适的另一个Agent,这样就完成了从总线模式向寻址模式的平滑切换。
5.3 网关模式与点对点模式的取舍
很多人会问:既然有了Agent-Reach,还需要API网关吗?我们目前的答案是都需要,但分工不同。网关继续负责对外部客户端提供统一入口、鉴权和限流;Agent-Reach负责内部Agent之间的动态触达。外部请求进入网关后,网关只把事情交给一个初始Agent,后续的Agent间协作全部由Reach层接管。
6. 未来的演进方向:从按能力路由走向业务意图感知
最后说几个我们接下来想优化的点,也算给这个机制留出进化空间。
第一,从“按能力路由”升级到“按意图规划”。现在路由是根据当前消息去匹配能力,但复杂任务往往需要多个能力的组合。未来我们计划在路由层引入目标分解能力:收到一条高层指令后,先拆出子任务清单,再为每个子任务寻找Agent触达。
第二,加入信任反馈机制。目前的打分因子没有“历史完成质量”这个维度。同一个Agent可能上次完成得不错,这次就不太行。后续我们会在寻址打分中加入长期信任评分和近期表现评分,让系统学会“谁更靠谱就把活儿派给谁”。
第三,异步触达的补偿机制要做得更完整。当前我们支持基础的取消操作,但跨多个Agent的长链路回滚还需要一套编排机制来保证一致性,这是我们下个里程碑的重点。
我在实际开发和调试里最深刻的体会是,Agent-Reach这类机制,成败往往不在“通信”本身,而是在“语义”和“信任”这两件软性的事上——你怎么描述能力,决定了别人能不能找到你;你怎么记录可信度,决定了别人敢不敢用你。所以建议你从第一天起就把能力描述写得严谨一点、把日志记录做得完整一点,后面会省下非常多半夜查问题的精力。