如果你跟我一样,最近半年在做多 Agent 系统,八成也遇到过这种场面:Agent A 要调 Agent B 的能力,两边明明都部署在同一个内部网络里,却因为注册方式不一致、消息格式不统一、超时重试策略各写各的,最后只能人肉加一个又一个适配器。我上个月把系统里的 Agent 数量从 2 个加到 7 个之后,这种“连接混乱”直接被放大了好几倍。所以我花了两周做了一个轻量层,叫 Agent-Reach,专门解决 Agent 之间“谁能触达谁、怎么触达、触达失败怎么办”的问题。这篇文章就是把 Agent-Reach 从设计到落地踩坑的过程完整梳理一遍,给正在被类似问题折磨的人一个可直接参考的方案。
1. 为什么我会去做 Agent-Reach:多智能体协作里的“连接混乱”
1.1 用部门协作的比喻看多 Agent 之间的问题
多 Agent 协作这件事,本质上很像一个公司里十几个部门互相配合干活。销售部门需要财务部门给报价,财务部门需要技术部门拉数据,技术部门又需要运维部门查接口状态。表面上看大家都有明确的分工,但真到了执行层面,问题就出来了:通讯录不统一,你存的是张三的手机号,我这里只有张三的座机;口径不规范,你说“尽快”,我说“今日内”,最后谁都没按时交付;再加一层,张三今天请假了,你还按老办法找他,事情就卡住了。
我的系统里当时就是这么个状态。每个 Agent 是不同团队用不同框架写的,有些暴露 HTTP 接口,有些走消息队列,有些干脆只支持本地函数调用。为了让它们配合起来,我们每个人都在自己负责的那一段里写“私人对接代码”:A 团队写一段代码去调 B 团队的接口,B 团队再写一段代码去适配 C 团队的返回格式。Agent 少的时候还好,一旦数量上来,这种点对点的对接方式就变成了蜘蛛网,牵一发而动全身。
真正让我下决心做 Agent-Reach 的,是一次上线事故。某个 Agent 升级后把接口路径改了,直接调用它的另外两个 Agent 全部报错,而那两个 Agent 各自又连着后面的依赖链。我花了大半个下午,顺着调用关系一路排查,才搞清楚是哪里断了。当时我就想,如果有一个统一的触达层,让所有 Agent 都通过它来互相对接,而不是彼此之间私拉专线,这种问题就不会这么狼狈。
1.2 Agent-Reach 解决的核心问题清单
我把这段混乱拆成了四类问题,Agent-Reach 的设计目标就是逐项解决它们:
- 寻址问题:Agent A 怎么知道 Agent B 当前有哪些可用实例?一个 Agent 扩容到 5 个实例后,调用方需不需要改配置?
- 协议问题:不同 Agent 的消息格式不一致,谁来做一个标准化的“公共语言”?HTTP、消息队列、gRPC 混在一起时,怎么统一处理?
- 状态问题:一次跨 Agent 调用,到底是同步等结果,还是异步拿回调?中间断了怎么恢复?重试怎么做到不重复执行?
- 治理问题:谁有权限调用谁的什么能力?调用链路出问题后,怎么快速定位在哪一跳?
这四类问题,单独看都不算难,难的是它们同时出现在一个系统里。Agent-Reach 不是一个业务框架,它不帮你写 Agent 的业务逻辑,它只做一个事:把 Agent 之间的触达方式标准化。接入成本控制在一两天之内,已有的 Agent 不需要大改,新接入的 Agent 也不需要关心对方团队用了什么技术栈。
2. 核心模型设计:注册、寻址与基于能力的触达路由
2.1 每个 Agent 先有一份“身份与能力描述”
Agent-Reach 的第一个设计决策,是要求每个接入的 Agent 提供一份“描述文档”,我用 JSON 格式,长这样:
{ "agent_id": "agent-pricing-v1", "display_name": "报价服务智能体", "capabilities": ["pricing.quote", "invoice.query"], "transport": "http", "endpoints": [ { "name": "quote", "uri": "http://10.20.30.41:8080/v1/quote", "method": "POST", "timeout_ms": 3000 } ], "health_check": "/healthz", "security": { "account": "agent-pricing-02", "roles": ["service-caller"] } }这份描述文档有三个关键信息:身份标识、能力列表、接入地址。身份标识是全局唯一的,类似每个人的身份证号;能力列表是这个 Agent 对外提供的能力编码,命名规则是“业务域.动作”;接入地址是 Agent-Reach 真正去触达它时要用的实际地址和超时配置。
为什么要有这一层?因为我不想让调用方关心“报价服务”部署在哪台机器上。它只需要知道,某个 Agent 提供了pricing.quote这个能力,至于背后是一个实例还是十个实例、今天有没有扩容,全部由 Agent-Reach 去处理。
2.2 基于能力的路由:调用方不需要关心对方 IP 和实例数
有了描述文档,Agent-Reach 内部就维护了一张“能力路由表”。这张表不是简单的 IP 映射,而是把“能力编码”和“提供方实例列表”关联起来。当一个 Agent 发起请求时,它直接说“我要调用什么能力”,而不是说“我要调用哪个地址”。
我举个例子。Agent 甲要发起一次报价请求,它只需要构造这样的调用:
{ "request_id": "req-8f23a91c", "target_capability": "pricing.quote", "payload": { "sku_id": "SKU-10086", "quantity": 2 }, "timeout_ms": 5000 }剩下的事情都交给 Agent-Reach:查路由表,找到提供pricing.quote的 Agent 实例列表,按负载情况选一个,把请求转发过去,然后返回结果给 Agent 甲。整个过程里,Agent 甲完全不需要知道 Agent 乙的 IP、端口、集群规模,甚至不知道对方用的是 HTTP 还是消息队列。
这个设计的价值在做故障切换的时候特别明显。某个 Agent 要重启,提前把状态标记成“排空”,Agent-Reach 就不会再给它转发新请求,等它优雅退出后再切换给其他健康实例。调用方没有任何感知。如果哪个 Agent 需要扩容,新实例起来之后注册一下,路由表自动多一个可用的目标。这就是“触达”和“点对点硬编码”的本质区别:可发现、可切换、可治理。
2.3 生命周期状态机:从注册到会话关闭
Agent-Reach 里,每个接入的 Agent 都有一套生命周期状态,我梳理成这样的流程:
未注册 → 注册中 → 就绪(READY)→ 会话中(CONVERSING) ↑ ↓ 恢复(RECOVERING)← 隔离(QUARANTINED)当一个 Agent 首次接入时,它处于“注册中”,Agent-Reach 会校验描述文档的完整性,比如能力编码是否合法、地址是否可达、健康检查接口是否能正常返回。校验通过后进入“就绪”状态,此时它开始接收来自其他 Agent 的触达请求。
一旦有请求正在处理,状态变为“会话中”。这里我有意区分了“就绪”和“会话中”,因为这两者对应的运维策略完全不同。“就绪”状态可以随时切换流量,“会话中”状态则需要等当前事务处理完才允许摘除。如果你做个定时发布,凌晨两点发布时没有流量,直接切;白天发布时,就得等会话自然结束,或者给一个宽限时间强制结束。
如果一个 Agent 连续健康检查失败,我会把它标记为“隔离”,不再给它分发新请求,同时保留现场日志。等它恢复健康,再重新进入“就绪”。这套状态机看着简单,但让我处理发布、故障、恢复这类日常操作时有了统一的依据,不需要再临时协商“这个 Agent 现在能不能动”。
3. 最小接入方案:把一个 Agent 接入 Agent-Reach(含配置与代码)
3.1 部署:先用单实例模式跑通链路
Agent-Reach 本身是用 Go 写的,编译出来就是一个二进制文件,设计上尽量少依赖外部存储。部署拓扑我习惯叫“实例组”,一组 Agent-Reach 实例共享同一份配置,互相之间通过选举确定谁是主实例。
最小化验证时,单实例就够了。我是用 Docker 跑起来的:
docker run -d --name agent-reach \ -p 9000:9000 \ -v /etc/agent-reach/config.yaml:/etc/agent-reach/config.yaml \ agent-reach:v0.4.2配置文件里有两段内容比较关键。第一段是实例组自身的标识:
cluster: name: "cluster-default" bind_port: 9000 peer_discovery: "static" peers: ["10.20.0.11:9000", "10.20.0.12:9000"]第二段是存储配置。我在早期版本里把注册信息存在本地文件里,单实例验证很顺手;后来增加了内置的强一致存储,三个实例组成一组就能扛故障。如果你只是做内部验证,本地文件模式最省事,配置文件里写storage: "local"就行。
单实例部署完,先访问一下/healthz确认服务起来了。注意检查两件事:端口在安全策略里是否已放行;健康检查返回的结果是不是200 OK。这一步反正别跳过,因为后面接 Agent 时,很多“连不上”的问题最后都追溯到安全策略没放行。
3.2 注册与握手,然后发出第一次触达请求
接入一个新 Agent 的流程,我建议按三步走:注册、握手、发请求。
第一步是注册。用下面的命令把一个 Agent 的描述文档提交给 Agent-Reach:
curl -X POST http://127.0.0.1:9000/v1/register \ -H "Content-Type: application/json" \ -d @agent-capability.json第二步是握手。注册成功不代表能立刻被调用,Agent-Reach 会主动去请求这个 Agent 暴露的健康检查接口,同时做一次“握手校验”,确认双方对协议版本和认证方式的理解一致。握手通过后,你会发现这个 Agent 的状态从“注册中”变成了“就绪”。
第三步才是真正发请求。我在验证环境里直接用命令行模拟一个调用方:
curl -X POST http://127.0.0.1:9000/v1/reach \ -H "Authorization: Bearer ae-token-xxx" \ -H "Content-Type: application/json" \ -d '{ "request_id": "req-verify-001", "target_capability": "pricing.quote", "payload": {"sku_id": "SKU-10086", "quantity": 2} }'看到返回值里带了status: "success"、agent_id、server_cost_ms这些字段,链路就算通了。第一次跑通之后,我强烈建议你直接在监控里看一眼整个链路的日志,确认 Agent-Reach 的访问日志、目标 Agent 的应用日志、健康检查日志三处的时间点能对应上。这一步做好了,后面再上多实例,排障就会轻松很多。
3.3 多实例部署时最重要的三个配置点
单实例跑通只是热身,真实环境里我一上来就是三个实例起步。多实例部署时,有三个配置点特别容易踩坑,我逐一说明。
第一个是实例发现方式。我在中小规模的部署里用静态配置,直接把对端地址写进配置文件的peers列表里。这种方式简单直观,但好处坏处都明显:扩容需要改配置。如果你希望扩容更自动化,可以考虑基于名称的服务发现,让实例通过注册中心自动找到彼此,代价是多一个要维护的外部组件。
第二个是主实例的切换策略。Agent-Reach 内部所有注册数据都要求强一致,所以同一时间只有主实例能处理注册和路由变更,其他实例提供只读查询和转发。主实例挂掉后,剩余实例会在几秒内完成新主实例选举。这个时间窗口很关键,客户端 SDK 那边必须要有重试逻辑,否则会看到短暂的“注册暂时不可用”报错。
第三个是高可用模式下“转发”与“查询”两类能力的区分。路由查询分布在所有实例上,让调用方总能就近拿到路由信息;而转发动作可以发生在任意实例上,不要求必须经过主实例。这能避免所有跨 Agent 请求都挤到同一个进程里。我最初把所有功能都压在主实例上,一压测就发现瓶颈,后来才把转发拆出来分散到各实例。
4. 会话与重试:把“触达”从碰运气变成可预期
4.1 分层超时与心跳设计
一个跨 Agent 的调用,从发起到结束,中间要经历多跳:调用方到 Agent-Reach,Agent-Reach 到目标 Agent,目标 Agent 内部可能还要再继续往下游触达。任何一跳出现问题,答案要么是“快速失败”,要么是“超时等待”。关键是,超时不能只有一个总时长,而是要分层。
我在 Agent-Reach 的默认配置里使用三层超时:
| 层级 | 默认值 | 说明 |
|---|---|---|
| 连接超时 | 1s | Agent-Reach 建立到目标 Agent 连接的最长等待时间 |
| 等待响应超时 | 3s | 连接建立后,目标 Agent 处理并返回结果的最长等待时间 |
| 总超时 | 5s | 从发起请求到收到结果的最长总时长,包含排队和转发耗时 |
为什么要分层而不是一刀切?我举个实际体验:如果只设一个 5 秒总超时,当目标 Agent 连续不可达时,调用方每一跳失败都得傻等满 5 秒才会报错。一次失败 5 秒,用户重试三次就 15 秒没了。而连接超时 1 秒快速失败,能第一时间把问题暴露出来;等待响应超时 3 秒,则给正常处理留足时间。总超时 5 秒兜底,避免三层加一起被拖到无法接受。
心跳的节奏也要配套调。我用的心跳间隔是 30 秒,目标 Agent 连续 3 次没回应(也就是 90 秒内没有心跳响应)就判定失联。这里有个容易犯的错:心跳间隔设得比超时还长,或者隔离阈值设得太短,导致一个 Agent 只是 GC 停顿了一下就被误杀。我的原则是,心跳间隔要远小于服务端负载异常的持续时间,但又不能太频繁到浪费资源,30 秒是我试过很多组参数后比较顺手的一个值。
4.2 重试策略:指数退避适合重试请求,但要注意幂等
跨 Agent 触达一定会遇到瞬时故障,所以重试机制必须有。但重试绝对不是一个简单的循环“失败就再来一次”,它需要考虑两件事:退避策略是否合理,以及目标 Agent 是否支持幂等。
退避策略我建议用“固定递增 + 抖动”,而不是教科书里的纯指数退避。举例说,第一次重试等 200ms,第二次等 400ms,第三次等 800ms,然后给每次等待时间加上一个随机抖动,把等待时间上下浮动 20%。抖动的作用是防止多个调用方同时重试时产生“惊群效应”——大家同一时刻一起打进来,把本来就脆弱的下游直接打瘫。
真正让我栽过跟头的是幂等。当时某个业务 Agent 实现了重试机制,但下游的“创建订单”接口本身不支持幂等。上游第一次请求超时,重试又发了一次,结果同一个订单被创建了两条记录,对账的时候才发现。排查到根因之后,我在 Agent-Reach 的调用协议里强制要求:任何可能产生副作用的请求,调用方都必须携带request_id,目标 Agent 也应该对相同request_id做去重。用request_id去重,比传“用户 ID + 商品 ID + 时间戳”这类业务字段可靠得多,因为它和调用一一对应,重试自然也不会撞车。
4.3 我遇到过的慢消费者级联问题
这是一个特别隐蔽的坑,我花了不少时间才定位清楚。当时我给一个 Agent 加了“自动报价能力”,结果上线后没几天,Agent-Reach 的日志里开始出现大面积超时。一开始我以为是下游太慢,仔细排查后发现,问题出在那个调用方 Agent 自己身上:它处理响应的时候,内部有个串行队列,上游告诉它能并发处理 20 个请求,但它实际只能消化 5 个,结果队列越积越长,响应迟迟不消费,Agent-Reach 这边等不到结果就判断超时。
这种慢消费者问题最麻烦的地方在于:错误信息看起来是“下游超时”,但根因在上游消费能力不足。我当时做了两件事去修它。第一,在 Agent-Reach 里加了“在途请求数”指标,能直观看到每个 Agent 当前挂起的请求数量。第二,给每个 Agent 之间的触达增加并发度协商,调用方声明自己能接收多少并发,目标 Agent 按这个窗口来控制发送速度。这两个改动上去之后,超时率明显下降。
我还吸取了一个教训:任何 Agent 的处理能力和消费能力都应该作为描述文档的一部分登记进来。这样 Agent-Reach 在路由时就知道该往哪些目标多塞流量、往哪些目标限量分配。不然就算基础设施再稳,业务层的处理瓶颈一样会把整个链路拖垮。
5. 权限、链路追踪与审计:Agent 数量上来了以后的核心体验
5.1 最小权限模型的实际做法
Agent 数量少的时候,权限管理完全可以靠“大家互相相信”。等系统里有了几十个 Agent,你就得承认:不是所有 Agent 都有权限调用所有能力。调度 Agent 可以调用报价 Agent 的需求查询能力,但不应该能调用它的配置修改能力;外部对接 Agent 只能进入指定的入口,不能直接在内部任意触达。
Agent-Reach 的权限模型我做了两层:身份层和策略层。身份层为每个 Agent 签发一个唯一的访问凭据,Agent 发起触达请求时在请求头里带上它。策略层则是一组规则,明确“哪个身份可以调用哪个能力编码”。实际的配置长这样:
policies: - identity: "agent-orchestrator-01" allow: ["pricing.quote", "invoice.query"] - identity: "agent-billing-ops-v1" allow: ["invoice.query"] - identity: "agent-external-gateway" allow: [] deny: ["pricing.quote.*"]这个策略看着简单,但我建议你在设计阶段就思考两个细节。第一,能力编码最好按层级来,比如用pricing.quote和pricing.quote.batch这样的结构,方便用通配符做批量授权。第二,要有“默认拒绝”的思维。新接入的 Agent 默认没有权限,谁要加权限就明确提申请,而不是新 Agent 一进来就能调用所有能力。我见过不少系统,权限规则写了一大堆,但初始配置是“AllowAll”,结果权限规则形同虚设。
5.2 从日志到调用链,再到按会话审计
权限只解决“能不能调”的问题,真正出问题时,你还需要知道“谁调的、调的什么、结果如何”。Agent-Reach 在设计上从一开始就内置了三个层级的可观测信息。
第一层是基础日志。每次触达请求都记录来源 Agent 身份、目标能力编码、目标 Agent ID、耗时、结果状态。这一层比较简单,但做故障定位的地基。
第二层是调用链。我在所有触达请求里都要求透传一个链路 ID,也就是一个全局唯一的追踪编号。调用方在发起请求时生成,Agent-Reach 转发时原样透传,目标 Agent 处理时记录在日志里。这样当用户说“刚才那个报价请求特别慢”时,我可以拿起一个链路 ID,把完整的调用路径拼出来:谁调的、在哪跳慢的、返回结果是什么、是否有重试。排查效率比翻日志快十倍不止。
第三层是会话级审计。我将“一个请求 + 它的所有重试尝试”归为一个会话。审计表里记录这个会话的完整生命周期:首次发起时间、每次重试时间、每次重试命中哪个实例、最终结果。之所以要做会话级,是因为单个请求的日志只能看到一次失败,而会话级的视角能让你看到“这个请求总共试了几次、每次的下场是什么”,这对判断是网络抖动、下游偶发故障还是幂等设计缺陷特别有价值。
6. 我认可的部署拓扑,以及不建议使用 Agent-Reach 的场景
6.1 两个我实际在用的部署拓扑
Agent-Reach 的定位是一个内嵌在业务内部的触达编排层,不是公网基础设施。实际部署时,我主要用下面两种拓扑。
第一种叫“共享实例组”。所有 Agent 共用一个 Agent-Reach 实例组,实例组本身部署在独立的资源池里。这种模式适合中小规模系统,Agent 数量在几十个以内,团队之间信任度也比较高。优点是运维包袱小,一个实例组统一管权限和路由;缺点是变更影响面大,比如升级 Agent-Reach 本身,所有接进来的 Agent 都会受影响。
第二种叫“按域隔离的实例组”。每个业务域(比如“交易域”“财务域”“库存域”)各自部署一套 Agent-Reach 实例组,域内调用走域内组,跨域调用再走上一级的聚合路由。这种模式适合团队多、发布节奏快、故障域要求清晰的系统。优点很明显:某个域的 Agent-Reach 升级不会影响其他域;缺点是要多维护一套跨域路由策略,前期的规划工作多一些。
我自己的经验是,如果系统还在快速演进阶段,先用共享实例组跑起来,把 Agent 接入流程、权限规则、可观测方法都打磨顺了,再根据实际的故障隔离需求决定要不要拆域。一上来就做复杂的多级隔离,很可能陷入过度设计。
6.2 哪些情况不应该强行套这一层
Agent-Reach 不是万金油,我见过一些场景强行引入之后,反而把事情变得更复杂。
如果你的系统里只有两三个 Agent,直接互相调用完全够用。这时候引入一个统一触达层,相当于给两个人的小团队立了一整套会议室预约制度,纯属自我感动。等 Agent 数量真正多到连接关系失控的时候再上不迟。
另一种不建议的场景是低延迟本地调用。如果两个 Agent 实际上部署在同一个进程里,直接函数调用延迟是亚毫秒级,走一遍 Agent-Reach 的网络转发,先序列化、再走 TCP、再反序列化,白白增加几毫秒开销。这种情况下应该优先考虑进程内调用,或者把两个能力合并到一个 Agent 里。
还有就是链路关系已经固定、且不打算变化的情况。如果调用关系极其稳定,两三年都不会变,点对点硬编码虽然不好看,但胜在链路最短、排障最直接。统一触达层的价值在于“变化”,如果根本没有变化,这层价值就体现不出来。
最后说点个人的体会。做完 Agent-Reach 之后,我最大的感受是,这类连接问题从来不是单个技术难点造成的,而是粘合方式的问题。先定义清楚谁负责身份、谁负责路由、谁负责状态,剩下的工作就是把约定严格执行起来。如果你正在做多 Agent 系统,先别急着给每个 Agent 写对接代码,停下来想一想:是不是该有一个统一的地方,来处理它们之间“怎么触达、怎么发现、怎么治理”这件事。等你接完第三个 Agent,会感谢自己当初做了这个决定。