☰
Agent-Reach:打通多Agent协作的通信路由层
2026/10/29 4:39:06 网站建设 项目流程

如果你也在做多Agent应用,大概率会遇到这种场景:单独调试每个Agent都正常,一旦把它们放进同一条业务链路,就开始互相找不到、重复调用、甚至消息石沉大海。我最近就陷在这种混乱里折腾了半个多月,代码越写越厚,Agent之间的连接却越来越脆。后来我痛定思痛,不再写那种“手动指定每个Agent地址和调用顺序”的胶水代码,而是给我的Agent集群加了一层通信基础设施。这套方案就是今天要聊的 Agent-Reach。

Agent-Reach 本质上是一层面向智能体的通信路由层。它解决的问题不是“怎么让智能体变聪明”,而是“怎么让智能体之间互相可发现、可调用、可容错”。它适合任何正在让多个Agent协作的团队:不管是做多角色辩论、工具调用编排,还是让Agent充当业务系统的子服务,都能从中找到一套通用的通信范式。下面我不会画概念图,也不做PPT式的解释,而是直接把我从设计到落地过程中认为最关键的部分拆开来讲。

1. Agent-Reach 到底解决什么问题

1.1 多Agent协作的“通信混乱”现状

先说我从实际项目里感受到的痛点。早期系统里只有两三个Agent时,互相调用其实很朴素:Agent A知道Agent B的HTTP地址,直接请求就行。但Agent一多,比如达到七八个,事情就开始失控。首先是“能力发现”问题:你想让Agent A调用一个能查天气的Agent,但A怎么知道有哪个Agent能查天气?如果靠配置文件枚举地址,每加一个Agent都要改一大堆调用方,几乎没法维护。其次是“路由决策”问题:一个请求可能由多个Agent都能承接,选择哪个?按什么优先级?然后是“容错”问题:某个Agent宕机或超时了,请求是重试、丢弃、还是换一个同类Agent?这些都是业务系统里早就解决过的问题,但在Agent场景里,因为每个节点的逻辑天然带有不确定性和巨量上下文,处理起来会格外别扭。

我把这段时期的混乱总结成三个字:不透明。调用关系是隐式的,容错逻辑散落在业务代码里,链路日志几乎没有。每次出问题只能挨个看日志,根本不知道请求在哪个环节丢的。这种体验让我意识到,Agent的通信能力不能靠某个Agent自己实现,它应该被抽取成基础设施。

1.2 我从Service Mesh得到的启发

如果对后端技术比较熟,你会立刻联想到微服务里的Service Mesh。Service Mesh的理念是把服务间通信能力从业务代码里下沉到代理层,用Sidecar做流量管理,业务开发只管业务逻辑。Agent-Reach正是借鉴了这个思路:每个Agent不需要自己维护“该找谁、怎么找、失败了怎么办”,这些事统一交给Agent-Reach的Router节点处理。Router负责维护一份Agent注册表,接收来自任意Agent的请求信封,再根据信封里的目标意图把请求转给真正能干活的Agent。

当然,Agent不是微服务,不能完全照搬。微服务的通信基于固定接口和确定参数,但Agent的输入输出经常是自然语言或半结构化数据。所以Agent-Reach在转发层之上,额外做了“能力匹配”这一步:注册中心里保存的不是简单的服务名和地址,而是每个Agent的能力描述、输入输出schema、以及可被调用的Tool清单。这就像给一个客服中心建了完整的技能树,而不是只给一个总机号码。

1.3 Agent-Reach 的五条设计原则

在动手实现前,我给自己定了五条原则,后面所有代码都围绕这五条展开:

  • 客户端无感:Agent内部尽量不感知路由细节,只知道自己发出去一个消息信封,然后收到回执;具体被谁处理由Router决定。
  • 能力声明优先:Agent注册时必须明确声明能处理什么任务、不能处理什么任务,避免Router瞎猜。
  • 请求追踪贯穿全链路:每个入站请求都带一个唯一traceId,日志和错误信息必须能串联。
  • 可替换性:任何Agent实例都可以随时下线并由同类实例顶上,Router要能自动摘除故障节点。
  • 机制与策略分离:路由策略和超时策略都写成配置,不改核心代码就能调整。

这五条原则贯穿了整个项目的设计。说白了,Agent-Reach不是又一个“Agent编排框架”,它更偏底层;你可以通过它构建自己的编排逻辑,也可以把它嵌进LangGraph这类框架里当通信底座。这也是为什么我最终选择了走“路由层”而不是“工作流引擎”的原因。

2. 核心链路:Agent发现、路由与回退

2.1 注册中心:Agent Profile 与声明式能力清单

Agent-Reach的起点是注册。每个Agent启动时,要向Router注册自己的Profile,里面除了标识符和回调地址,最关键的是能力描述。以下是一个典型的Agent Profile配置,用YAML表示:

agent: id: price-query-agent display_name: "价格查询Agent" network: protocol: "http" endpoint: "http://price-agent:8080/reach" capabilities: - task: "query_product_price" description: "查询指定商品的当前售价" input_schema: product_id: string currency: string output_schema: price: number currency: string updated_at: string routing_weight: 10 - task: "query_promotion" description: "查询商品促销信息" input_schema: product_id: string output_schema: promotion_name: string discount: number routing_weight: 5 health_check: path: "/healthz" interval_seconds: 15 timeout_ms: 5000

这里有个很容易被忽略的点:routing_weight不是优先级,而是路由评分的加权值。Router在决定把请求交给谁时,会综合三个维度来打分:语义匹配度、历史成功率、路由权重。这个后面会细讲。

注册之后,Router会在内存里维护一张实时路由表,同时把Agent的状态标记为“可用”。如果Agent长时间没上报心跳,路由表会自动把它的状态改为“可疑”或“离线”,这个机制和微服务注册中心类似,但粒度更细。

2.2 消息信封:ReachRequest / ReachResponse 协议

Agent之间传递的消息,不是一个裸JSON,而是一个统一信封。信封包含三部分:元信息、载荷、回执约定。我在设计时参考了HTTP和消息队列的折中方案,既保留请求-响应语义,又支持异步延后响应。

一个ReachRequest长这样:

{ "version": "1.0", "traceId": "8f1a3c2d9e4b", "conversationId": "conv-20240617-001", "sourceAgent": "user-proxy", "targetTask": "query_product_price", "targetAgent": "", "payload": { "product_id": "SKU-88231", "currency": "CNY" }, "preferences": { "timeoutMs": 4000, "retryCount": 2, "filterAgentIds": ["price-query-agent"] } }

targetTask是必须的,targetAgent是可选的。如果调用方明确知道要调哪个Agent,可以直接填targetAgent;如果只知道任务类型,就把targetAgent留空,让Router做路由决策。这是Agent-Reach最有特色的地方:它同时支持“指名道姓”和“按能力寻址”两种调用方式,后者是Agent协作里最常见的需求。

ReachResponse则会在 envelope 里带上status、handlerAgentId、durationMs和result。调用方通过handlerAgentId能知道到底是谁处理了这个请求,方便复盘。

2.3 路由策略与错误回退机制

Router的职责不只是转发,而是要在多个候选Agent里选出最优解。我实现了三种策略,可以用配置项切换:

策略名决策依据适用场景风险
exact_match严格匹配task字段相同且Agent能力列表声明完整系统内部规范化任务无法兜底未知任务
semantic_route对task和Agent能力描述做文本向量余弦相似度自然语言任务、任务命名不固定相似度判定不准
weighted_round_robin按历史成功率、响应延迟、路由权重做加权轮询多个同能力Agent做负载分担对异常实例反应滞后

实际运行时,Router会先看请求里有没有targetAgent;如果没有,就先执行能力过滤,把所有声明包含 targetTask 的Agent拿出来,再按选中的策略打分。这里要特别说明,semantic_route不是用某个大模型在线推理,而是用离线计算好的能力向量。每个Agent注册时,Router会把它的能力标签和描述文本发给一个小型Embedding服务,生成向量存入内存索引;请求进来时,同样把targetTask向量化,再做余弦相似度排序。这样整个路由过程几十毫秒就能完成,不会把一个转发动作变成一次烧钱的模型调用。

错误回退是我最初没有重视的部分,后来被线上问题狠狠教育了一顿。目前的设计是三层回退:

  1. 如果首选Agent返回网络错误,Router会在超时内自动重试同Agent,重试次数默认1次。
  2. 如果首选Agent在一段时间内连续失败,Router会把它加入半开熔断名单,请求自动漂移到次优Agent。
  3. 如果所有候选Agent都失败,Router不会抛出笼统异常,而是返回一个带候选列表的NO_AVAILABLE_HANDLER响应,让调用方决定下一步。

回退不是简单换个Agent继续请求,因为Agent响应可能携带上下文状态。所以Router在漂移前会做一次“载荷预检”:如果请求里的内容涉及会话级上下文,就必须先检查目标Agent是否有对应的会话缓存,否则宁可失败也不能乱接。这个约束让我少踩了很多脏数据坑。

3. 本地跑通 Agent-Reach 的完整步骤

3.1 环境初始化:Python 3.11 与 Docker Compose

Agent-Reach的运行时由三部分构成:Router节点(核心进程)、状态存储(我用etcd做元数据持久化)、以及若干业务Agent。本地调试我建议全部用Docker Compose起,避免污染开发机。以下是最小化的 docker-compose.yml:

services: etcd: image: quay.io/coreos/etcd:v3.5.5 ports: - "2379:2379" command: etcd --listen-client-urls http://0.0.0.0:2379 --advertise-client-urls http://etcd:2379 reach-router: image: agent-reach/router:0.4.2 depends_on: - etcd environment: REACH_ETCD_ENDPOINTS: "etcd:2379" REACH_ROUTER_PORT: "7200" REACH_EMBEDDING_URL: "http://embedding-service:8080/embed" ports: - "7200:7200" embedding-service: image: agent-reach/embedding-service:0.2.0 ports: - "8080:8080" price-agent: image: agent-reach/demo-agent:latest depends_on: - reach-router environment: REACH_SELF_ID: "price-query-agent" REACH_ROUTER_URL: "http://reach-router:7200" REACH_CALLBACK_URL: "http://price-agent:8081/reach" ports: - "8081:8081"

注意环境变量里的REACH_CALLBACK_URL,如果少配了这个变量,Agent能注册成功,但Router回调时无法触达。最初我习惯性写了localhost:8081,结果容器内Router访问的是它自己的localhost,白白排查了半天。这个后文会专门说。

启动命令很简单:

docker compose up -d docker compose ps

看到所有服务都处于Up状态后,先别急着调接口,我们需要检查etcd里是否已经有Agent的注册探针。

3.2 编写第一个 Agent Service 配置

在启动Agent前,你需要准备好Profile文件。我不建议把这些配置写死在代码里,而是放在Agent工作目录的reach_config.yaml里,启动时加载。上面的YAML示例就是标准模板。重点是让capabilities和真实能力完全一致,如果Agent实际上只会算价格,你偏给它声明一个query_inventory的能力,后续所有路由只会给你带来莫名其妙的失败。

配置完成后,启动Agent时设置好环境变量。DemoAgent启动后会自动读取配置文件,向Router发起注册。你可以在Agent日志里看到如下内容:

[reach-client] registering agent: price-query-agent [reach-client] capabilities: query_product_price, query_promotion [reach-client] callback endpoint: http://price-agent:8081/reach [reach-client] registration successful, lease id: 0x6e2d1a

Registration是用带租约的方式,租约时间默认60秒,Agent必须持续续约。这里有个经验:如果你修改了Agent的代码,但没有修改配置,重启后注册信息会和旧状态混在一起。我在开发时踩过一个坑,旧进程退出前没有主动注销,新进程注册时被Router拒绝,原因是agentId冲突。后来加了优雅退出钩子,在收到SIGTERM时先注销再退,这个坑才算填平。

3.3 启动注册中心并验证注册状态

Router启动后,自带一个简易管理接口。通过它能看到所有Agent状态:

curl http://localhost:7200/admin/agents

响应是一个Agent列表,我用jq简单格式化后看到类似结果:

[ { "id": "price-query-agent", "status": "available", "capabilities": ["query_product_price", "query_promotion"], "lastHeartbeat": "2025-01-15T10:24:18.302Z" } ]

这里status是available,说明Agent已经成功进入可用状态。如果显示unhealthy,要优先检查Agent的健康检查接口是不是真实可达。健康检查路径千万别写一个只返回200的空接口,至少做一次内存状态检查。

3.4 调用一次跨Agent请求并观察链路日志

为了验证通信链路,我通常会准备一个简单的客户端脚本,用Python模拟一个“用户代理”发起请求:

import requests import json payload = { "version": "1.0", "traceId": "trace-demo-001", "sourceAgent": "user-proxy", "targetTask": "query_product_price", "payload": { "product_id": "SKU-88231", "currency": "CNY" } } resp = requests.post("http://localhost:7200/reach", json=payload, timeout=10) print(json.dumps(resp.json(), ensure_ascii=False, indent=2))

正常情况下,Router会返回一个ReachResponse:

{ "status": "SUCCESS", "handlerAgentId": "price-query-agent", "durationMs": 238, "result": { "price": 199.9, "currency": "CNY", "updated_at": "2025-01-15T10:25:01.442Z" } }

此时去Router的日志里看,会看到一条完整的链路记录:

[trace-demo-001] route: user-proxy -> query_product_price [trace-demo-001] candidate agents: price-query-agent, promotion-agent [trace-demo-001] select price-query-agent by exact_match [trace-demo-001] forward: price-query-agent, duration=238ms

看到这三行,链路就走通了。Router已经能发现Agent、解析请求、完成路由、成功回传。整个流程核心代码量其实不多,但把链路日志做全才是这个项目真正值钱的地方。

4. 配置清单里的坑:我踩过的几个真实例子

4.1 超时设置与重试风暴

我第一次给Agent-Reach配全局超时的时候,只设了一个timeoutMs=1000,心里想的是“超过一秒就快速失败”。结果某个Agent在处理一份稍大的文档时耗时变成2秒,Router就触发重试;重试后第二个副本也压到2秒,Router又发起第三轮重试。三个副本同时处理同一个请求,把下游数据库连接池打满了。

问题的根源在于我把“单次网络超时”和“整体请求预算”混为一谈。后来Agent-Reach里引入了分层超时:

  • connectTimeoutMs:建立连接的超时,默认1000ms。
  • readTimeoutMs:等待Agent响应第一个字节的超时,根据任务复杂度动态设置。
  • overallTimeoutMs:整个请求从入站到返回的最长耗时,默认是 connect + read + retry 的总和。

同时在配置里增加了一个硬限制:单个请求的总体重试次数不得超过2次,且全局并发重试任务数量受限。如果你也在调这个系统,请务必把“重试预算”当作一等公民来对待。

4.2 路由策略误选,导致请求“边缘化”

有段时间我把路由策略配成了semantic_route,想着让Agent能够应对灵活的自然语言请求。某个用户输入的是“当前芒果多少钱”,Embedding模型将其转换成了向量,和query_product_price的相似度只有0.79,而另一个无关Agent能力描述里包含了“价格”这个词,相似度竟然达到0.82。Router于是把请求发给了一个只能做价格历史趋势分析、不能查实时售价的Agent,最后返回了一堆数字趋势图,完全没有回答用户问题。

这个问题不在向量模型,而在路由策略缺少约束条件。修法也很简单:在语义路由前,先用约束规则做硬过滤。比如,强校验请求里的targetTask是否是Agent能力列表的精确子集之一;如果用户侧任务映射表里有准确映射,就优先走exact_match。我最终采取的做法是双保险:任务映射表优先,表里没有的才走语义匹配,同时把相似度阈值提高到0.9以下一律拒绝。

4.3 回调地址与容器网络地址不一致

这是所有容器化部署新手都会撞上的问题。Agent在注册时,会把自己的回调地址告诉Router。如果你在配置里写的是http://localhost:8081/reach,而Router运行在另一个容器里,那么Router访问localhost时访问的是它自己的环回地址,永远打不到Agent。

正确的做法是使用Docker网络内的服务名:

environment: REACH_CALLBACK_URL: "http://price-agent:8081/reach"

这里有个更隐蔽的坑:如果Agent和Router不在同一个Docker网络里,或者Agent是在宿主机上直接跑的,那么Callback地址需要写成宿主机在局域网内的IP,不能写localhost,也不能写容器名。我的通用建议是:把回调地址单独抽成环境变量,不同环境用不同值,不要在配置文件里写死。

4.4 状态探测与请求流量混合

为了节省资源,我起初让Router使用业务端口去探测Agent健康状态。结果有一个Agent在处理某个大数据量请求时CPU飙高,健康检查请求迟迟得不到响应,Router误判它为宕机,开始把它的流量切给其他Agent。原Agent其实还在运行,只是有点忙而已。这种“活着的忙”和“死了的静”是两码事。

现在我在每个Agent侧多开了healthz专用端口,这个端口只做内存占用、goroutine数量、最近一次Agent轮询结果等轻量检查,绝不参与业务请求处理。健康检查路径也必须是隔离开的,否则一旦业务阻塞,健康检查也跟着阻塞,整个集群会进入不断摘除和重新注册的抖动。

5. 进阶玩法:把 Agent-Reach 接入现有智能体框架

5.1 与 LangGraph / AutoGen 的对接方式

Agent-Reach不只适合从零自建系统,也可以嵌进LangGraph这类编排框架。思路不复杂:把Agent-Reach的Router当成一个可被调用的工具节点,或者把整条Agent链路当成一个LangGraph节点。

拿LangGraph举例。正常情况下,你会在图里写多个节点分别调用不同Agent;有了Agent-Reach后,你可以在图里只放一个reach_call节点,这个节点内部包装了Router的客户端逻辑。开启一个新任务时,LangGraph把任务摘要封装成ReachRequest发给Router,Router再调度具体Agent。Agent执行的中间结果回传给Router,再由Router统一返给LangGraph节点。

这样的好处是,后端的Agent集群无论是拆分、合并还是扩容,LangGraph层完全不用改。我做过一个测试:把原本两个独立的Agent节点合并成一个Reach调用节点后,图的复杂度明显下降,而且由于Router自带重试和熔断,LangGraph层面的失败回退逻辑也变简单了。

5.2 基于 Agent-Reach 做多Agent沙盒演练

Agent系统最怕的是上线后才发现路由策略在极端情况下会导致雪崩。我在本地搭了一套沙盒环境,专门用来模拟各种故障:

  • 网络延迟:在Agent侧引入sleep(1000),观察Router是否会超时并按预期重试。
  • Agent崩溃:手动kill掉一个Agent进程,等待租约过期,观察Router摘除节点耗时。
  • 请求风暴:用并发请求脚本打到Router,观察全局熔断器是否按配置生效。

这套沙盒演练帮我把Router的熔断参数调到了一个相对合理的区间。比如熔断器在连续失败5次时会打开半开状态,每10秒放一个试探请求;如果试探成功,熔断器关闭;否则继续保持半开。这个参数不是套别人的,就是通过沙盒压测得来的。

5.3 监控指标与可观测性拓展

最后,Agent-Reach不同于普通HTTP转发层的一点,是必须为Agent轨迹提供可观测性。我在Router里内置了几个Prometheus指标:

  • reach_requests_total:按任务类型、Agent ID、状态打点。
  • reach_route_match_score:路由评分直方图,观察语义匹配的置信度分布。
  • reach_agent_circuit_trips:熔断触发次数,快速定位脆弱Agent。
  • reach_end_to_end_latency:整体耗时直方图。

我还会把每个traceId记录到结构化日志里,同步到日志中心。在排查问题时,我只需要拿着一个traceId去查询,所有环节的日志就都连成了一条线。如果你之前没有这种习惯,请一定在最初就加上,否则后续做任何优化都会像盲人摸象。

就我自己这段时间的实际体验来说,Agent-Reach最大的价值不是某段精妙的代码,而是逼着我把“Agent之间如何通信”这个原本模糊的问题,拆成了注册、路由、容错、可观测四个清晰的工作域。只要这四个工作域有协议可依,后面无论接多少Agent,心里都不会慌。真要说有什么遗憾,就是一开始配置回调地址的时候多花了一整晚,这种低级错误,希望大家看到这篇记录后能够一次跳过。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询