Agent-Reach 是我在搭多智能体系统时被一队"连不上的 Agent"逼出来的内部组件。多 Agent 系统听起来高级,但真正跑起来之后,最折磨人的往往不是某个 Agent 的推理效果,而是它们之间怎么互相找到、怎么递任务、怎么把结果收回来。我们当时的场景里有意图识别、订单处理、物流查询、售后决策这几个 Agent,单独跑全部正常,一放到一起就各种"找不到人""传不回来",日志翻到半夜也定位不到根因。后来我把这块能力整个抽出来,做成一层独立的连接层,起名就叫 Agent-Reach。这篇文章我会从架构设计讲到踩坑实录,把"为什么这么做"和"怎么做"都交代清楚,给同样在折腾多代理连接问题的人一个能直接抄作业的方案。
1. 先搞清楚 Agent-Reach 到底在解决什么问题
1.1 多智能体系统里最容易被低估的"连接断层"
先聊一个很多人做架构选型时根本不会注意的问题。你可以用 LangChain 或者自写 Prompt,在三天内拼出一个能完成特定任务的单 Agent;但当你把它扩展到三五个 Agent 时,真正的复杂度并不是"每个 Agent 的脑子",而是它们之间的连接。
拿我们当时的订单链路举例。用户说"我的东西到哪了",意图 Agent 判断这是物流查询,于是它要通知物流 Agent;物流 Agent 要去订单系统拿订单号,拿到之后可能还要找售后 Agent 确认有没有异常;整条链走完,原始对话上下文还留在入口 Agent 那里。如果每个 Agent 之间都是手写的 HTTP endpoint,每加一个 Agent 就要改一圈配置,每换一次部署环境就要动一遍地址,而且没有任何地方能告诉路由方"当前谁在线、谁负载高、谁能干这个活"。
这就是连接断层。模型推理能力没问题,但 Agent 之间、Agent 与外部系统之间的触达链路全靠零散配置硬拼,出了问题只能一个个服务翻日志。Agent-Reach 这个名字里的 Reach,说的就是这件事——让每个代理的"可达性"变成系统里被统一管理的一等公民,而不是散落在各处的硬编码地址。
1.2 Agent-Reach 的定位:连接层,不是重框架
设计这套组件时我给自己定过两条死规矩。
第一条,不绑架 Agent 的实现方式。我不要求所有 Agent 必须用同一个框架、同一种语言。你完全可以有一个 Python 写的意图 Agent,配一个 Node.js 写的工具 Agent,只要能注册、能消费消息、能按约定回传结果,就能接进来。所以 Agent-Reach 本质上是一层协议加一组基础设施,而不是一个强约束的 Agent 运行时。
第二条,只解决"触达"这件事,不替 Agent 做决策。某个请求要不要调某个 Agent、多个候选 Agent 之间怎么挑,这些交给上层策略;Agent-Reach 只保证"注册了就能被发现,能被发现就能被调用,调用出去了能收到结果或者失败原因"。
这两条定下来之后,所有模块的边界就清晰了。后来我们验证过,这种轻量定位做加法非常容易:不管 Agent 数量翻几倍,还是突然要接一个外部 SaaS 工具,都不需要改 Agent 内部代码,只需要在连接层登记一份能力和入口描述。这一点在实际运维里省下来的时间,远比当初多写的那些基础代码值钱。
2. 整体架构三件套:注册中心、路由层、工具网关
2.1 注册中心:Agent 的第一份通讯录
第一个核心组件是注册中心。每个 Agent 启动时向它登记身份,内容包括:Agent ID、能力标签(比如order.create、logistics.query)、内部服务地址、当前负载状态、可接受的上下文格式。注册中心维护一张全量映射表,路由层拿到它才能做后续转发。
这里有个非常容易被忽略的细节:多 Agent 场景下,注册发现不能只按"服务名"做,必须支持"能力语义"。因为同一个 Agent 可能具备多种能力,同一个能力也可能由多个 Agent 提供。比如"售后处理"这个能力,售后 Agent 做主流程,客服 Agent 能做简单兜底。按服务名发现只能找到"售后 Agent",按能力语义发现才能找到"所有能处理售后的候选者",再把选择权交给路由策略。这也是 Agent-Reach 和普通微服务注册中心最大的差异点。
2.2 路由层:语义发现加决策,而不是简单转发
路由层拿到请求后做四件事:根据能力标签筛出候选 Agent 集合,剔除不可用或健康状态差的节点,按策略打分选出目标,转发请求并等待结果。
打分策略可以做得比较简单,但逻辑要公开透明。我最初用的公式是:候选者得分等于基础权重乘以健康系数,再减去当前队列压力。基础权重来自注册表里人工维护的等级,健康系数由最近心跳和错误率算出,队列压力来自 Agent 每次心跳时上报的待处理任务数。这套方案让运维可以不改代码,只调配置就能控制流量走向。
路由层设计里我坚持了一个原则:路由失败必须返回结构性错误,而不是字符串错误。比如"没有可用候选者"和"候选者处理超时"是两个完全不同的错误码,上层策略需要靠它们决定是换一个候选者重试,还是直接告诉用户系统繁忙。如果什么错误都返回一句"调用失败",上层就只能一脸懵。
2.3 工具网关:所有外部调用走一个口子
第三个核心组件是工具网关。这一步很多人会犹豫,觉得"每个 Agent 自己调外部 API 不就行了"。短期行,但 Agent 数量一多,散点直连的问题立刻爆发:同一个库存 API 被三个 Agent 各调一遍,超时配置还不一样;密钥散落在每个 Agent 的配置里;外部系统改一次接口,要跟着改一堆地方。
工具网关把所有 Agent 对外的调用收口,统一提供鉴权、限流、熔断、超时和审计。Agent 不直接知道外部系统在哪,它只需要请求网关,告诉网关"我要调库存查询工具,参数是什么",网关负责真正发出调用并返回结果。安全边界因此非常清晰,外部接口变更时的人工改动量也大幅下降。
对应的,工具本身也要在网关里注册,注册表记录工具名称、入参 Schema、出参格式、默认超时和调用方归属。看到这里你会发现,Agent 的注册表和工具的注册表其实是同一套思路,前者服务 Agent,后者服务外部能力,维护成本非常低。
3. 注册发现与心跳机制的具体实现
3.1 存储选型:为什么我选了 Redis
注册中心最核心的要求就三条:低延迟读取、容易实现租约过期、部署简单。对比过 etcd 和 MySQL 之后,我选了 Redis。
原因有三个。读操作要求毫秒级响应,Redis 天然满足。心跳场景本质是"续租",Redis 的过期键机制正好用得上:Agent 每次心跳就重写一个 Key 的 TTL,如果 Agent 挂了,TTL 一到键自动消失,注册表同步完成摘除。再者,Agent 系统往往本身就在使用 Redis 的 Stream 做消息队列,可以少维护一套组件。
有人问过我等比选择 etcd 不是更严谨吗?etcd 的强一致性确实好,但多 Agent 场景下的一致性要求没到分布式数据库那种程度。短时间读到旧状态影响很小,因为路由层还有健康检查兜底。为了强一致去引入一套更重的部署和运维成本,我觉得不划算。
3.2 心跳、租约和过期清理的参数设定
心跳机制的参数是这类系统最值得花心思的地方。我们的配置是每 15 秒心跳一次,租约 TTL 设 45 秒。三倍关系不是拍脑袋定的:TTL 太短,网络抖动一次 Agent 就被误摘除;TTL 太长,Agent 真挂了之后残留时间越久,路由层越可能把请求发给根本不存在的节点。
Redis 操作上我用HSET保存 Agent 元数据,再单独维护一个带 45 秒 TTL 的 Key 代表活跃状态。路由筛选候选者时直接判断这个 Key 是否存在。不过上线后我们发现一个隐蔽问题:Agent 进程活着但内部某个工作线程长期阻塞时,心跳照常上报,路由照常派活,任务却在 Agent 内部越积越多。后来我们在心跳内容里加入了"最近处理任务数"和"最近错误数",注册侧做滑动窗口统计,路由打分也会参考这两个指标,而不是单纯看 Key 在不在。
# 注册 Agent 元数据 HSET agent:meta:logistics_agent capability "logistics.query" endpoint "http://10.0.2.11:8081" weight 10 # 心跳续租,TTL 45 秒 SET agent:heartbeat:logistics_agent "ok" EX 453.3 健康状态与权重的关系
健康状态不仅要影响"可用/不可用",还要影响"优不优先"。我把节点健康分拆成三档:健康、过载、亚健康。健康节点权重不变;过载节点权重减半,但保留,避免流量瞬间全部转移后打爆其他节点;亚健康节点只接收探测请求,不接收正常业务转发。
摘除节点要有一点延迟,千万别在第一次异常报告时就摘。Agent 偶尔会有一次超时,但下一次就恢复了。我们设置的是连续三次心跳周期内错误率超过 30%,才把节点从活跃名单里摘掉。这个"三次确认"机制让注册表稳定了很多,不会因为偶发抖动导致整个集群频繁变动。
4. 消息通道与任务交接的实操要点
4.1 同步调用的超时与重试怎么配
注册发现解决"找得到",消息通道解决"叫得动"。同步和异步两种模式,我讲一下各自的参数思路。
同步调用适合处理时间短、请求方必须立刻拿结果的场景,比如意图识别。默认超时我给 2 秒,失败重试两次,间隔用指数退避:第一次等 500ms,第二次等 1 秒。为什么是 2 秒?因为这类轻量任务如果 2 秒还没结果,通常不是模型慢,而是链路出了问题,继续等只是浪费时间。
重试必须配合幂等,否则一个任务处理两遍,下游可能产生两条重复订单,这个问题后面单独说。另外有个非常容易踩的细节:超时和重试的总时长要小于上层调用方的超时。如果上层预算总时长是 5 秒,你第一轮用 2 秒,退避间隔共 1.5 秒,第二轮 2 秒,加起来 5.5 秒就超了。正确的做法是倒着算,先确定总预算,再切分给每一轮和退避间隔。这种预算思维在 Agent 链路里非常重要,因为经过的跳数多,每层都按自己的心情配超时,整体耗时必然失控。
4.2 异步任务回调的可靠送达
异步模式适合重活,比如售后 Agent 要调三个外部系统、整体处理要十几秒的流程。请求方把任务发到消息队列,服务方消费处理,完成后通过回调通知请求方。
异步方案的核心问题只有一个:回调不可靠。回调消息可能丢,服务方处理完但回调地址也可能暂时不可用。我的做法是给每个异步任务分配一个任务 ID,服务方完成后先把结果写入"任务结果表",再尝试回调。回调失败就由定时任务扫描结果表里"回调未确认"的项,重新投递,最多重试五次。这个方案把可靠性压力从"网络回调成功"转移到"数据库落库成功",后者比前者可靠得多。
任务结果不能只留最终状态,中间状态同样要留。物流 Agent 处理到一半发现自己缺订单号,它需要把"等待补充信息"这个状态写回去,请求方收到才知道是自己去补信息,还是转给其他 Agent。很多团队栽在"异步任务只有成功和失败两个状态"的坑里,一旦出现等待类状态,整条链路就僵住了。
4.3 上下文贯穿:一套 ID 走到底
多 Agent 协作最经典的翻车事故是上下文串号。A 用户在问订单问题,B 用户也在问订单问题,两个请求并发转发,只要请求方把 A 的上下文错误绑到 B 的回调上,后果就非常隐蔽,单条日志看每步都是正常的。
解决思路是贯穿式追踪 ID。每一轮用户请求进入系统时生成一个request_id,在路由、Agent 处理、网关外部调用、日志输出、回调消息里都带上它。判断上下文归属时不搞任何基于猜测的匹配,只认这个 ID。我甚至建议把request_id作为回调消息的结构化字段,而不是日志里的一句话,这样后续做灰度分析、问题回溯都省力得多。
实战里还有一个容易被忽略的技巧:LLM 的 Prompt 里也带上这个 ID。不是给模型看的,而是让 Agent 内部产生的工具调用、子任务都能溯源到原始请求。我们排查过几次特别诡异的幻觉问题,最后全靠这条 ID 链把"用户输入、中间推理、工具返回、最终回答"串成一条线,才发现问题出在某一跳的工具结果缓存上。没有这条链,你根本不知道从哪里断的。
5. 工具网关:安全边界和调用稳定性
5.1 为什么外部系统调用不能直接散在 Agent 里
工具网关这层我花的时间几乎和注册中心一样多,因为外部调用的坑最毒。早期版本里每个 Agent 直连外部系统,很快浮出几个问题:库存服务偶发慢,三个 Agent 同时调它,各自触发自己的重试,外部系统负载被放大了三倍;同一个密钥放在四个 Agent 的配置里,轮换一次要同时改四处,漏改一处直接全线失败。
网关化之后,外部调用只有一条路径。Agent 侧只保留网关地址,外部系统的密钥只存在于网关的密钥管理里。每次工具调用都在网关记录发起方、参数摘要、耗时和结果状态,这就等于免费获得一份审计日志。外部系统抖动时,运维只需要在网关层面限流,不需要挨个改 Agent。
5.2 工具注册 Schema:给 LLM 一份"说明书"
工具网关的注册表里,我花最多功夫的是工具描述 Schema。现代 Agent 的核心是 LLM 做工具调用,LLM 能不能正确地调工具,很大程度上取决于注册表里给它的说明书够不够清楚。
一个工具描述应该包含四部分:工具名字、一句话用途说明、入参 JSON Schema、最典型的调用示例。用途说明要写明"这个工具在什么情况下用、什么时候不要用";典型示例能显著提高 LLM 的调用准确率,因为它给了模型一个贴近真实场景的参照。
这里有个常见误区:很多团队把工具描述写成技术文档风格,全是字段类型和取值范围。但 LLM 不是按文档规范调工具的,它是按语义理解去调工具的。举个例,查天气工具的技术文档写"城市编码、日期格式、返回温度区间",模型还是容易传错参数;示例写成"用户问'上海明天热不热'时,调用 city=101020100,date=2026-05-20,返回体里的 temp_max 就是答案",模型一眼就明白了。别怕示例让 Schema 变得冗长,对 LLM 来说,一个具体示例顶得上一大段类型说明。
5.3 限流熔断在服务链路里的位置
网关稳定性靠限流和熔断两个机制,一个针对"调用方",一个针对"被调用方"。
限流按两层做:每个 Agent 在网关里有一个固定的每分钟配额,防止某个异常 Agent 把网关出口打爆;网关对外部系统也有总配额,防止外部系统压力反噬。Agent 侧配额用完就返回"配额不足"的结构化错误,路由层看到这个错误就不再给这个 Agent 派活。
熔断用标准三态:正常、开启、半开。连续失败率达到阈值打开熔断,直接拒绝后续调用;过一段时间进入半开状态,放少量探测流量,探测成功就关闭熔断。细节在于阈值不能只看绝对次数,要看比例。低频调用十次里三次失败可能只是偶发;高频调用同样的失败率可能意味着系统已不可用。我用的是滑动窗口内的错误率,窗口长度 60 秒,错误率超过 40% 打开熔断,半开状态下放 10% 的探测请求出去。
6. 上线之后踩过的坑与排查速查表
6.1 代理失联的第一大元凶:心跳线程被阻塞
最早上线时遇到过很多次"注册表显示 Agent 在线,但请求过去全部超时"的情况。反复排查后发现,心跳上报和实际业务处理跑在同一个执行线程里。某次外部系统卡住,业务线程阻塞,心跳也跟着停发;因为 TTL 还有余量,注册表没有立刻摘除;等 45 秒 TTL 过期,注册表才把它摘掉,可这 45 秒里已经有大量请求发到了僵尸节点上。
解决办法是把心跳上报挪到独立线程,甚至独立成一个轻量进程,只负责上报状态,不做任何业务。这样即使业务线程全挂,注册表也会在下一次心跳超时之前失去信号。这个检查项后来写进了 Agent 启动自检清单:每次上线新 Agent,先杀掉业务线程,观察注册状态是否在预期时间内变为不可用。
6.2 回调丢失与消息积压
异步回调丢失这个坑,几乎每个做异步任务系统的人都踩过。我们一次上线后发现售后流程有百分之几的订单迟迟不更新状态,查了半天才发现回调通知发出去时,接收方恰好处于发布窗口期,消息被静默丢弃,而回调逻辑没有任何重试机制。
后来加了"先写结果表、再发回调、失败进重试队列"三步流程,才算补上。这里还有个更隐蔽的问题:消息队列消费速度赶不上生产速度时,积压会让异步链路延迟越来越高。我们在消费者侧加了"落后指标"监控,消费位置和生产位置的差值超过阈值就报警。异步系统就是这样,每个环节看着都正常,整体却可能悄悄劣化,必须靠指标而不是靠感觉。
6.3 重复执行与幂等
重试机制的副作用就是重复执行。我们的下单链路早期出现过一单下两遍的事故:路由层第一次调用订单 Agent 超时,触发重试,第二次重发,但第一次调用其实在超时前已经成功,只是结果回传慢了。订单 Agent 没有做幂等判断,同一条用户请求就产生了两个订单。
解决办法是引入幂等键。请求方生成全局唯一的idempotency_key,在路由转发和 Agent 处理过程中原样传递;Agent 在执行真正的业务操作前检查这个键是否处理过,处理过就直接返回上一次的结果,不再重复操作。虽然多了一点存储开销,但它让整个系统从"最多一次"变成了"正好一次"级别的可靠性。任何涉及资源变更的 Agent 链路,我认为幂等都是必选项。
| 线上现象 | 常见原因 | 排查路径 | 最终解法 |
|---|---|---|---|
| 注册表显示在线,请求全部超时 | 心跳线程被业务阻塞 | 查看最近心跳时间戳与业务线程状态 | 心跳独立线程或独立进程 |
| 异步任务长期无状态更新 | 回调丢失且无重试 | 查任务结果表和回调重试队列 | 先落库再回调,失败进重试队列 |
| 同一请求执行了两次 | 重试引发重复调用 | 查 idempotency_key 是否贯穿传输 | 幂等键检查,已处理则返回旧结果 |
| 外部系统整体变慢 | 多个 Agent 各自重试放大压力 | 查网关日志中各发起方调用量 | 网关统一限流和熔断 |
最后说句掏心窝的话。把 Agent-Reach 从零搭起来的过程中,我最大的体会不是架构多重要,而是"触达层"这种听起来不带感的问题,恰恰决定了多 Agent 系统能不能从 demo 走向生产。很多团队花大力气调 Prompt、调模型,最后卡死在 Agent 之间互相找不到、任务流转丢状态这些土味儿问题上。我在实际维护这套系统时养成了一个习惯:每加一个新 Agent,先不看它的推理效果,先看注册、心跳、路由、幂等这几项有没有过。这些基础能力扎实了,上面跑的智能体才有资格谈效果。