大量做多智能体系统的朋友,都会在某个阶段遇到同一个烦心事:单个 Agent 跑得好好的,一旦让几个 Agent 互相协作,就开始疯狂“掉链子”。我前阵子接手一个内部服务,现象特别典型——客服 Agent 明明测出能准确回答退货政策,可真到了让它去查订单状态、再流转给售后 Agent 时,消息不是送丢,就是对方 Agent 根本“听不见”。排查到最后发现,问题既不在模型推理,也不在业务逻辑,而是 Agent 之间的触达层基本是裸奔的:直接写死了 IP 端口、用最朴素的 HTTP 调用,没有任何注册与分发机制。这篇文章就围绕一个我称之为 Agent-Reach 的触达中间层设计展开,聊一聊 Agent 之间到底该怎么“互相找到对方、说上话、把状态接住”,以及我在实际落地过程中踩过的坑和最后沉淀下来的实现方案。无论你是在做客服编排、自动化运维,还是想把内部工具链改成 Agent 协作,这篇应该都能给你一些能直接抄作业的参考。
1. 一个典型的失败现场:两个 Agent “相撞”的代价
先说那个让我决定动手做 Agent-Reach 的具体故障。服务的架构不复杂:一个客服 Agent(我们内部叫它 CS-Agent),一个订单查询 Agent,一个售后 Agent。CS-Agent 收到用户消息之后,先调用订单查询 Agent 拿订单状态,如果状态异常,再通知售后 Agent 介入。没上触达层之前,这三个 Agent 之间的依赖用的是最简单的方式——直接在代码里写好 IP 和端口,用同步 HTTP 互相 POST。
1.1 灾难复现:CS-Agent 为什么找不到订单 Agent
故障发生在订单查询 Agent 一次例行发布之后。发布脚本把新版本部署到了新的一批机器上,IP 变了,但 CS-Agent 里还硬编码着旧地址。结果就是:CS-Agent 每次发起查询,都打到一个已经不存在的端口上,连接超时,重试三次,然后直接给用户返回“系统繁忙”。最麻烦的是,从日志上看,CS-Agent 完全不知道对方“不存在”了——Promise 挂起十几秒才 timeout,前面排队的请求全部堵死,一条线上用户的咨询全卡住。
这类问题的根子在于:Agent 之间的通信被当作“两个固定进程之间的点对点调用”来处理,而不是当作“两个具备动态身份的实体之间的消息交换”。一旦环境发生变化,拓扑就成了一堆断掉的血栓。Agent 不同于普通微服务的地方在于,它往往处于一个更长的任务上下文里,触发它的入口通常是自然语言意图,而不是固定的 RPC 方法签名。所以,我们不能用“host:port/method”这种方式去定义它,而应该用“谁能处理某类能力”来定义它。
提示:我在复盘时给团队提了一个硬性要求——所有 Agent 之间的调用,一律不准再写死 IP 和端口。地址必须通过服务注册发现来获取,且调用必须带超时和上下文 ID。
1.2 从事故里提炼出的三项触达要求
那次事故之后,我给 Agent-Reach 定下了三个必须满足的核心指标,后面所有设计都围绕着它们展开:
- 可发现性:任何一个 Agent 上线之后,其他 Agent 要能问一句“谁能处理这类任务”,然后拿到一张真实的候选清单,而不是靠配置中心手工维护一份列表。
- 可协商性:A Agent 找到 B Agent 之后,双方得先“握手”——B 得告诉 A 自己当前负载如何、需要什么输入格式、能接受多长的上下文,然后 A 再决定是否把任务交给它。
- 可延续性:一次触达结束之后,双方对会话状态的认知必须一致。比如 B 处理到一半挂了,A 得知道任务没有完成,而不是傻等;A 如果中途放弃,B 也得收到一个明确的终止信号,而不是继续处理一个没人要的任务。
这三个点听起来很平常,但把它们放在一起实现的时候,复杂度就不小了。直接拿 HTTP 请求来硬做的话,会发现“可发现性”需要额外一套注册中心;“可协商性”需要定义协议格式;“可延续性”需要引入会话状态节点。而这些,恰好就是 Agent-Reach 想统一解决的问题。
2. Agent-Reach 的分层设计:身份、意图、会话三层怎么分工
Agent-Reach 的设计核心,是把一次完整的跨 Agent 触达拆成三个层次:身份注册层、意图匹配层、会话维持层。每一层解决一类问题,层与层之间用明确的数据结构解耦。这样做的直接好处是:你可以单独升级匹配算法,或者单独替换会话存储,而不需要动其他层的代码。
2.1 身份注册层:让“存在”成为可查询的事实
身份注册层是整个触达体系的地基。每个 Agent 实例启动时,会向注册中心发送一个 Identity 消息,里面包含:
- Agent 名称和实例 ID(同一名称下可以有多个实例做负载均衡);
- 能力列表,用语义标签表示,比如
order.query、after.sales.handle; - 通信入口信息,比如
agent://node-01:9527; - 健康检查探针地址。
注册中心维护一个全量的目录,每个实例每 30 秒做一次心跳续约。如果某实例 90 秒没有心跳,就自动标记为“离线”,并从匹配候选里剔除。这个机制本身不复杂,但它解决的是我在第一章节描述的“IP 变了就找不到”的问题——因为所有查找都通过名称和能力标签进行,物理地址变成了注册表里的一个属性,而不是耦合在调用方的代码里。
2.2 意图匹配层:要解决的其实是“语义触达”问题
注册层解决的是“这个 Agent 存在”,但还存在一个问题:怎么知道它“合适”处理这条消息?微服务时代我们用接口签名来约束,但 Agent 之间传递的往往是自然语言任务描述,或者半结构化的 JSON。
在 Agent-Reach 里,我设计了一个匹配规则:注册时每个 Agent 声明自己的能力模式,既可以是一组显式标签,也可以是一条自然语言描述(比如“能够处理退款与退货相关咨询”)。当调用方发出触达请求时,会携带一个 Intent 对象,里面是社会化的任务描述和目标限制条件(比如“超时 5 秒”“必须返回中文结果”)。
意图匹配层会做两件事:先用标签做一次粗筛,再用某种向量相似度做细排。我在实际实现中,没有一开始就上大模型去搞语义匹配,而是先用了关键词权重加一个小的嵌入模型,因为绝大多数内部 Agent 的任务描述固定,粗筛已经能拿到 90% 以上的命中率。
2.3 会话维持层:把“断开”变成明确的信号
最后一个层次解决的是“消息送达之后”的问题。一次对话会被分配一个 Session ID,所有触达消息都带着这个 ID。会话维持层需要记录当前状态:INIT(初始)→NEGOTIATING(协商中)→RUNNING(执行中)→COMPLETED/FAILED/CANCELLED。
之所以要显式维护状态机,是因为 Agent 协作里最常见的失败模式不是“消息寄丢了”,而是“双方对结果的认知不同步”。比如 A 发出一个查询请求,B 处理完并写入了结果库,但 A 在等待中已经超时,不再关心这个结果——那么 B 做的事情就完全是浪费。有了会话状态,A 在超时后主动提交一个CANCEL事件,B 就能及时中止工作。这个机制防的是“重复劳动”和“脏数据”。
3. 手把手落地一个最小 Agent-Reach 节点
理论讲多了容易飘,直接上一个可以跑起来的最小实现。我选型的时候,刻意避开了重框架,只用 Python 标准库加少量依赖来实现一个轻量级节点。这样做的原因是:Agent-Reach 的接入方可能有好几种语言写成的 Agent,越少依赖,接入门槛越低。
3.1 注册中心与触达协议的最小实现
注册中心其实可以抽象成一张内存表,配合心跳接口。下面这段代码是一个极简的注册中心核心逻辑:
# registry.py import asyncio import time from dataclasses import dataclass, field @dataclass class AgentRecord: name: str instance_id: str endpoint: str capabilities: list updated_at: float = field(default_factory=time.time) class Registry: def __init__(self, ttl=90, heartbeat_interval=30): self.records = {} self.ttl = ttl self.heartbeat_interval = heartbeat_interval async def register(self, name, instance_id, endpoint, capabilities): rec = AgentRecord(name=name, instance_id=instance_id, endpoint=endpoint, capabilities=capabilities) self.records[instance_id] = rec return {"ok": True, "instance_id": instance_id} async def heartbeat(self, instance_id): if instance_id in self.records: self.records[instance_id].updated_at = time.time() return {"ok": True} return {"ok": False, "error": "unknown instance"} async def lookup(self, capability): now = time.time() results = [] for instance_id, rec in self.records.items(): if now - rec.updated_at > self.ttl: continue if capability in rec.capabilities: results.append({"name": rec.name, "instance_id": instance_id, "endpoint": rec.endpoint}) return results这段代码没什么高深的,重点在于定义了一个明确的目录模型。需要注意的点是:lookup的返回结果不带权重,真正的排序发生在意图匹配层,而不是注册中心,尽量不要让注册中心做太重的计算,否则它自己就会成为瓶颈。
3.2 把一个现有 Agent 接进 Agent-Reach
现有 Agent 接入,有两种方式:一种是侵入式的,在 Agent 代码里直接调用注册和收发消息接口;另一种是用 Sidecar 模式,写一个适配进程,把 Agent 监听的本地端口包装成一个符合 Agent-Reach 协议的节点。
我在实际改造过程中,优先推荐 Sidecar 方式,理由很实在:很多内部 Agent 代码已经跑了一两年,你不可能为了一个通信改造去动它的核心逻辑。Sidecar 进程负责三件事:注册身份、接收远端消息并转成内部调用、把内部响应转换回协议消息。
伪代码看起来大概是这个样子:
# sidecar.py import asyncio import json from registry import Registry class Sidecar: def __init__(self, agent_endpoint, registry: Registry): self.agent_endpoint = agent_endpoint self.registry = registry async def start(self, name, capabilities): await self.registry.register( name=name, instance_id=f"{name}-{id(self)}", endpoint=self.agent_endpoint, capabilities=capabilities, ) # 启动一个 HTTP 服务,接收 Agent-Reach 协议消息 # ... 省略服务器启动细节 ...接入完成之后,就可以做一次端到端测试了。我习惯的验证三步法是这样的:先单独调用注册接口,确认目录里有记录;再分别 mock 消费者的行为,确认 lookup 能查到目标;最后跑一条真实业务链路,看会话状态是否从INIT走到了COMPLETED。这三步全部过了,基本可以认为触达链路是通的。
4. 实测里的意外情况:回调风暴、幽灵注册与超时参数的博弈
只用最小实现跑通 Demo,跟真正在高负载场景里稳定运行,中间差了十万八千里。我在试运行阶段遇到了几个很有意思的问题,不把它们拿出来说,感觉这篇分享就是不完整的。
4.1 回调风暴:一个 Agent 触达失败带来的雪崩
第一个问题是触达失败后的重试策略。最开始我把超时设成了 3 秒,重试 3 次,间隔 500 毫秒。看起来合理,对不对?但当目标 Agent 瞬时不可用时,所有等待的任务几乎同时超时,然后同时发起第一次重试,瞬间把目标进程的日志系统打满,然后超时,再同时发起第二次重试,形成一波又一波的同步浪涌。
因为所有调用方用的都是同一个默认重试参数,等于大家约好了一起制造流量尖峰。我后续在 Agent-Reach 的配置里增加了随机抖动 jitter,让每次重试的间隔在 500ms 到 1500ms 之间随机,并且在调用方的代码里做了快速失败:如果目标 Agent 连续三次心跳失败,接下来的请求不再进入队列,直接返回UNAVAILABLE。这个改造听起来简单,但对稳定性的提升非常明显。
4.2 幽灵注册:过期实例为什么还在被命中
第二个问题是“幽灵注册”,这是我自己起的名字。场景是这样的:测试环境里某个 Agent 实例已经被 kill 掉了,但注册中心里它的记录还没过期,于是另一个 Agent 进行 lookup 时依然把它作为候选,然后把消息发过去,接着在连接时碰了一鼻子灰。
问题出在我前面写的清理逻辑:清理动作只在收到心跳的时候触发,如果实例已经死了,心跳永远不会来,记录就一直躺在那里。后来我把注册中心的清理逻辑改成了惰性检测——每次 lookup 的时候扫描一遍过期记录并主动清除,同时把每个实例的updated_at设为当前时间加心跳周期的偏移量,相当于是把“判定死亡”的主动权交给了注册中心,而不是依赖实例自己通知。
注意:这里有个工程上的经验——不要在注册中心里设置一个专门的“清理线程”去扫描过期实例。节省不了多少资源,反而容易在扫描时段和业务请求高峰重叠,造成不必要的锁竞争。放在 lookup 路径里惰性清理已经足够了。
4.3 超时参数的博弈:谁应该负责兜底
还有一个老生常谈但值得展开的问题:触达超时到底该由调用方设,还是由被调用方设?Agent-Reach 里的做法是,双方各自设超时,但被调用方的任务执行 TCP 时间要大于调用方的等待时间,否则会出现调用方已经放弃了,被调用方还在继续执行的严重不一致。
我把这个约束直接写进了协商阶段:协商时,被调用方会返回一个max_execution_ms字段,调用方把它跟自己这边的 wait 超时上限比较之后再做决定。如果发现被调用方要跑很久,调用方要么走异步任务模式,要么换一个更合适的 Agent。把这个校验前置到握手阶段之后,我们在实际生产里因为“超时不同步”导致的重复执行问题大幅下降。
5. 用什么标准判断“该不该引入 Agent-Reach”,以及我现在的建议
写了这么多,可能会有人问:那我现在手头的小项目是不是也要马上搞这么一套?我的答案是,看情况。Agent-Reach 不是万能药,它解决的问题有明确的适用范围,超出这个范围,它就是不必要的复杂度。
5.1 适用范围:直接用硬编码也没问题的三种情况
先说不该用的情况。如果你只有两个 Agent,它们都由同一个进程部署和运维,输入和输出格式也永远不变,那么 Agent-Reach 就是多余的。这种静态链路用 HTTP 调用反而更清晰,引入注册中心只会增加故障点。
还有一类情况也不建议用:如果你在做一个不涉及跨团队协作的本地自动化脚本,比如每天固定从数据库拉数据、丢给一个 Agent 做摘要、再把结果发到飞书通知群——这种单机线性链路,用一个简单的 Python 脚本就能完成,不要为它建注册中心。
第三种是任务模式完全同步、调用链永远不超过两跳的场景。Agent-Reach 的价值主要体现在网络规模变大、链路变长之后,如果你的调用链永远是 A→B 条路径,那直接点对点请求是最优解,别为了架构上的“优雅”牺牲简单性。
5.2 我的取舍经验:复杂度只应该出现在“变动”和“分流”的地方
我个人的判断标准其实可以浓缩成一句话:只有当 Agent 之间“变动频繁”或“需要分流”的时候,触达层才值得抽象。
什么算变动频繁?Agent 实例会经常增减、会发新版、会换部署位置。什么算分流需求?同一种能力有多个 Agent 提供,比如一个负责快速响应、一个负责深度处理,调用方想按上下文选择合适的对象。如果你既没有频繁变动,也不需要分流,那硬编码就好,完全不需要 Agent-Reach。
反过来,如果这两点里中了一条,我的建议是优先采用最小实现版本的 Agent-Reach——注册表加意图匹配加会话状态,不要一开始就追求分布式一致性、消息持久化和重放机制。后面这些,等真的出现“消息必须不丢”的业务需求时,再逐步加上去都来得及。而且因为 Agent-Reach 把触达的各个层次分开了,你后加消息队列或者后加向量匹配,都不需要推翻之前的设计。
这算是这轮工程改造让我最受益的一点:在大型协作系统里,通信层做对了,后续很多心智负担都会被摊薄。无论你最后是沿用我这套分层思路,还是自己从零设计一套更适合业务的协议,我都建议先把“注册、匹配、会话状态”这三件事想清楚,再让 Agent 们去自由发挥。