ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent-Reach:智能体协作的通信调度中枢设计解析

Agent-Reach:智能体协作的通信调度中枢设计解析

我刚接手第三个智能体项目的时候,差点没被"Agent之间互相找不到"这件事逼疯。三个Agent,A要结果给B,B又要回头校验A,C在旁边等着汇总,结果就是A发了消息B没收到,B回了A又没接住,日志里全是timeout和重试。后来我索性把这块单独的通信逻辑抽出来,做成了一个独立的触达层,这就是本文要聊的 Agent-Reach。

与其说它是一个库,不如说它是智能体协作里的"通信调度中枢"。它解决的不只是消息能到没到的问题,而是让一群本该各干各的Agent,在最少的耦合下,知道自己该把话说给谁听、以什么格式说、对方有没有回话、超时了该怎么办。这篇文章,我会从它出现的背景、核心路由设计、接入方式,再到我在真实部署里踩过的权限和循环调用这些坑,一块一块拆给你看。

1. 智能体协作为什么必须有一层"触达中间件"

1.1 我踩过的Agent协作乱象

先还原一下当时的具体场景。我们有一个内部的文档处理流水线,订了三个Agent:提取Agent负责从PDF里抽字段,审核Agent负责核对字段是否完整,归档Agent负责把数据写入知识库。最开始大家图省事,直接把另外两个Agent的HTTP接口地址硬编码进自己的代码里,然后彼此调用。

这种点对点直连在Demo阶段跑得很欢,一旦进入真实环境就变味了。第一次出问题是在某天下午,提取Agent连续输出格式错误,审核Agent拿到数据后直接抛异常,但异常信息没有回传给提取Agent,而是自己吞掉了,导致任务的最终状态一直挂在"运行中"。我后来翻日志才发现,定义异常处理的方式上,两个Agent用了完全不同的错误码体系,一个报500,一个报E40001,根本没对上。

第二个问题是认知不同步。我把审核Agent的接口路径改了一版,从/check改成了/verify,结果忘记通知提取Agent。上线后整整两个小时,提取Agent还在往老地址上发请求,日志里全是404。这个事虽然蠢,但是很典型:Agent协作一旦是点对点硬编码,任何一方的接口变化都会变成一场事故。

第三件更麻烦,是三个Agent都想同时改一份原始文档的缓存。归档Agent写完之后,提取Agent因为缓存还没失效,继续读了一份过期配置。这种并发和你争我抢的问题,根本不是单纯靠"调对接口"能解决的,它背后缺的是一个真正管住"谁在什么时候以什么身份触达谁"的中枢。

1.2 点对点直连为什么走不远

很多人会本能地说:用消息队列呗,Kafka、RabbitMQ摆上去,Agent之间通过Topic解耦,不就行了。这思路在传统的服务化架构里没毛病,但放到AI Agent协作里有三个适配性的落差。

第一,传统消息队列关注的事件转发,不关注"本次调用的上下文" 。Agent在处理同一个任务时,A发给B的结果可能带着trace_id、任务状态、截止时间、上下文摘要。这些东西如果拆成一条普通的Topic消息,审计和追踪都很难做,你得自己在消息体里塞一套规范,而每个团队塞的规范还不一样。

第二,Agent之间是交互式的,不是纯异步的。Agent A不仅要把消息发给Agent B,往往还需要B在几秒内"回话",甚至这个回话还会影响A下一步的动作。传统消息队列的异步模型不擅长做这种请求-响应的配对,你得额外写一堆reply_to和correlation_id的胶水代码。

第三,也是我个人体会最深的:点对点直连和消息队列都缺少一个"基于能力的发现机制"。我真正需要的不是"把消息发到某某Topic",而是发出这样一个诉求:"当前任务需要提取文档中的合同编号,哪个Agent能处理?请有能力者响应。"这种基于能力的柔性调度,是消息队列最不擅长、而Agent协作里最常用的模式。

1.3 Agent-Reach到底管哪一段

所以Agent-Reach在我的定位里,是专门做"智能体触达"这一层的。它管三件事:

  • 被调用的Agent是谁:基于能力注册表去发现,而不是基于写死的地址去指认。
  • 以什么格式触达:所有Agent统一使用同一套信封消息结构,发送方不用关心接收方的内部细节。
  • 触达之后的兜底:包括超时、重试、幂等、失败回执,保证一次协作要么成功,要么能被明确判定为失败,而不是悬在半空。

它不替Agent做业务决策,也不负责Agent内部的推理逻辑,它只做那些"让不同Agent之间能够顺利接上头"的通用脏活。如果你手头也有三五个Agent在互相打架,或者你正考虑把Agent的能力开放给公司内部其他团队,那Agent-Reach这套设计思路应该对你有参考价值。

2. Agent-Reach的触达协议与路由机制:核心设计拆解

2.1 信封消息:让所有Agent说同一种语言

Agent协作最大的沟通成本,不是技术选型,而是消息格式始终在对齐。我在内部推行Agent-Reach的第一件事,就是定死一套信封格式,所有Agent之间传递的必须是这种信封。

我把这个信封叫做Envelope,核心字段如下:

字段含义示例值
trace_id全局唯一追踪ID,贯穿一次完整任务trc_8f3a2b
sender发送方Agent名称doc-extractor
recipient / topic接收方Agent名称,或按能力匹配的Topicfinance-reviewer
task_type任务类型,用于路由匹配extract.contract
payload业务数据,JSON格式,具体Schema见decodedSchema{"file_id": 7}
schema_versionpayload的Schema版本v2
expires_at消息过期时间,超时未处理视为丢弃2025-06-01T12:00:00Z
idempotency_key幂等键,防止重复处理req_001_round_2

为什么信封里需要"接收方名称"和"Topic"两个概念同时存在?因为有些场景,发送方明确知道要找谁,比如提取Agent就指定要找"字段解析Agent";有些场景发送方只知道诉求,比如想查"文档里的金额汇总",不知道具体哪个Agent能干,那就发到一个叫做doc-amount-summary的能力Topic上,由路由层去匹配。

信封的价值,就是让协作的"协议"稳定下来。后来我们接入新的Agent时,不再需要逐家联调接口格式,只要让对方实现信封的解析和回执,剩下的内部Schema单独约定,用schema_version管理好版本就可以。这大大减少了"因为字段名不一致导致的协作故障"。

2.2 能力注册表:每个Agent能干什么、在哪找它

信封规定的是消息长什么样,而真正决定一条消息怎么走的,是Agent-Reach里的一张能力注册表。每个Agent启动时,会向注册表登记自己的能力,包括几个核心字段:

  • agent_name:全局唯一的Agent名称,比如contract-extractor-v2。
  • capabilities:能力标签列表,比如 ["doc.extract.contract", "doc.extract.amount"]。
  • endpoint:该Agent的请求地址,可以是HTTP、gRPC,也可以是进程内函数调用。
  • input_schema:这个Agent接受什么样的payload,用JSON Schema描述。
  • required_role:调用方至少要有哪个权限角色才能触达它。
  • health_check_url:健康检查地址,路由层会定期探测。

注册表加上心跳机制之后,路由层就能动态掌握"哪个Agent活着、哪个Agent负载高"。我比较建议心跳周期设成10秒到15秒,不要太频繁,否则高并发下健康检查自身会变成一种资源浪费。Agent下线时主动注销,异态宕机时靠心跳超时自动摘除。

有了这张表,路由过程就变成了一次查询加匹配。当收到接收方是financ-reviewer的消息时,路由表直接定位到该Agent的endpoint。当收到按Topic发送的消息时,路由层就把topic映射成capabilities,找出所有带该能力的Agent,然后按负载策略选一个出来——可以是轮询,可以是最少调用次数,也可以是最优响应时间。我在生产里最常用的是最少活跃调用数,它能相对自然地避免某个Agent被瞬时打爆。

2.3 超时、重试与幂等:触达不是把包丢出去就完事

在点对点直连时代,我们最烦的就是"对方到底收到没有"。Agent-Reach把这件事搞成了可量化、可控制的行为,核心是三个参数:超时、重试、幂等。

超时间的设定不能拍脑袋。我们有一个外部文档解析Agent,单次解析耗时波动很大,短则1秒长则15秒,最初把超时设成10秒,导致大量正常请求被误判为超时。后来我把超时设成基线值的120%加上波动余量,比如基线P95是12秒,那就设成15秒,并且允许调用方在信封里通过timeout_override覆盖默认值。生产实测下来,这个"基线+余量"的做法比固定值靠谱得多。

重试必须带指数退避,第一轮重试等1秒、第二轮等2秒、第三轮等4秒,同时设置最大重试次数为3次。这里有个关键点:重试要配合幂等键一起使用。如果接收方在处理消息后崩溃了,但处理结果其实已经写到了数据库,没有幂等键的话,重试就会导致数据被写入两次。我在Agent-Reach里要求发送方生成idempotency_key,接收方在收到消息时先查重,发现重了就返回已经存在的结果,而不是重新执行。

超时和重试后面要跟一个"失败回执"。我之前见过很多Agent,对方失败了它压根不知道,一直死等。Agent-Reach在执行完重试还是失败后,会把失败的详情作为一个回执消息发布到调用方监听的主题上,同时携带trace_id和失败阶段。这样发送方不是傻等,它每一步都知道消息到了哪、结果是什么。

3. 接入实操:从零跑通一个Agent-Reach节点

3.1 部署形态选型

Agent-Reach不是一个大而重的平台,它有几种部署形态,按团队规模选择就好。

小团队或单体应用阶段,我建议用进程内嵌入模式,把Agent-Reach当库引进来,Agent之间通过线程池加内存队列直接通信,零网络开销,调试也最方便。这个模式下触达延迟通常低于1毫秒,非常适合开发阶段的快速验证。

到了多服务部署阶段,就把Agent-Reach做成独立的Sidecar进程,跟Agent部署在同一台机器上,Agent只和本地Sidecar通信。Sidecar之间通过内部网络互通,好处是Agent代码完全不用关心别人部署在哪,只管往本地Sidecar发信封。Sidecar负责路由、超时、重试这些脏活。

到了更大规模,比如几十上百个Agent分布在多个集群,那就得是独立集群模式了。路由层单独部署,带自己的注册表和消息网关,Agent通过SDK连上来。这种模式下路由节点可以水平扩展,数据层用Redis或MySQL,具体取决于你的注册表量级。我们目前跑的是独立集群模式,路由节点两个,Redis里存注册表和死信队列。

3.2 用Python SDK注册一个触达节点

实际操作中,接入一个Agent大概就这几步,我用Python举例子,因为这块生态最顺。

先安装SDK,然后用一段代码把Agent注册上去:

from agent_reach import AgentReachClient client = AgentReachClient( gateway_url="http://agent-reach-gateway:8080", agent_name="contract-extractor-v2", heartbeat_interval=10, ) @client.register( capabilities=["doc.extract.contract", "doc.extract.amount"], input_schema={ "type": "object", "properties": { "file_id": {"type": "integer"}, "need_summary": {"type": "boolean"} }, "required": ["file_id"] }, required_role="document_processor", ) def extract_contract(request): file_id = request.payload["file_id"] # 这里写实际的抽取逻辑 result = {"contract_no": "HT-2025-001", "amount": 99800.0} return result

注册之后,Agent就有能力响应doc.extract.contract这个范围内的触达了。这里有个易错点:注册时的input_schema一定要和实际逻辑校验的一致,否则会出现"路由层觉得你能处理,实际一解析payload就报错"的尴尬情况。我们后来在注册阶段加了一步Schema自校验,启动时先用样例payload把注册的函数跑一遍,能过才允许注册。

3.3 让消息真正流转起来:一次串行协作的完整调用链

注册好了之后,一次实际的协作调用长什么样?继续用刚才的文档处理场景,提取Agent要把一份合同的金额提取结果交给审核Agent,逻辑大致如下:

result = await client.call( recipient="doc-reviewer-v1", task_type="review.contract_amount", payload={"contract_no": "HT-2025-001", "amount": 99800.0}, timeout=8000, idempotency_key="extract_ht001_round_1", ) print(result.score) # 审核Agent返回的置信分

调用方不用管doc-reviewer-v1部署在哪、当前是否在线,Agent-Reach会完成寻址、健康检查、转发、等待响应。如果8秒没等到,会触发重试;重试还是失败,就把失败详情投递到调用方注册的回执Topic上,并标记trace_id。

我在真实环境里喜欢把这条调用链的每个阶段都打点。怎么做呢?给每个信封加一个status字段,初始为pending,路由命中后变为routed,接收方确认收到后变成delivered,处理完成且回执发出后变成completed,任一步骤失败则变成failed。加上OpenTelemetry之后,你就能在链路追踪里看到一条清晰的"触达轨迹"。几十个Agent互相调用时,如果没有这种状态流转,想排查问题就是灾难。

4. 真实环境下的坑:权限边界、循环激活、死信

4.1 权限模型如果只做"可见性"不做"行为边界",迟早出事

Agent-Reach的初始版本,我只对Agent之间的可达性做了控制,也就是说,只要你有权限调用某个能力,你就能把payload传给那个Agent。上线第二周就出事了:某个测试Agent不知怎么获得了文档删除Agent的调用权限,它在一次失败的解析流程里,把一份测试库的原始文档给清了。原因就是它虽然知道接口,但没有人约束它"调用这个接口时可以做什么动作"。

后来我们把权限模型改成了两层。第一层是调用权限,即call permission,决定你能不能触达某个能力;第二层是行为边界,也就是action scope,决定你这次调用能对目标Agent的数据做到什么程度,读、写、还是删除,必须通过scope参数显式声明。同时,Agent的注册信息里增加了一个max_scope字段,目标Agent会校验调用方声明的scope是否在它允许的范围内。简单说:你不仅得知道门在哪,还得分清你是去送快递还是去拆房子。

4.2 两个Agent互相调用的死循环

这是我见过最头疼的线上事故之一。A是合规检查Agent,B是数据修正Agent。A发现文档里某个字段违法,于是调用B去修正;B修正后发现A那边的数据格式有问题,又调用A去重新检查;A检查后再次发现新问题,又调用B……两个Agent互相踢皮球,一直踢到消息量把系统打满。

单纯靠"谁的调用有错"来判断完全没用,因为每一步在单个节点上看都是合规请求。我们的解法是在Agent-Reach的链路追踪里加了一个深度计数器,每个信封从任务最开始的根请求算起,每经过一次Agent调用就加一跳。在转发前,检查当前跳数是否超过max_hop,默认设成20。超过就不再转发,直接把循环调用标记为gyre_detected,并同时给两个Agent发告警。

同时,Agent-Reach会维护一个"活跃调用链的指纹"。指纹由调用对(sender, recipient, task_type)按顺序构成,一旦发现同样的调用对组合在短时间内重复出现超过阈值,路由层会把它判定为循环热路径,自动进入熔断状态。这两个手段一起用,才真正把"踢皮球"给刹住了。

4.3 消息盲区与死信队列

还有一个很容易被忽视的坑,就是"消息进了黑洞"。假设A调B,B在处理时正常返回了成功,但这个回执在传输中被网络抖动丢了,那A会怎么认为?它以为B没处理,于是重试;B收到带同样幂等键的重复请求后,发现结果已经存在,返回了旧的执行结果;结果这条旧结果又被回执通道丢了,A只能按失败处理。

这种由回执丢失引发的"盲区",在分布式环境里很常见。Agent-Reach应对的办法是:接收方把处理结果和状态写入持久化的结果仓库,发送方的重试不是去催B重新执行,而是先去结果仓库查询是否有该幂等键的记录。有就直接返回,没有才真正重新转发。这样即使回执通道有损坏,发送方也能拿到正确结论。

彻底无法处理的消息,会进入死信队列。我规定死信队列里必须记录信封原文、失败原因、当前重试次数、最后一次错误时间。运营人员定期查看死信队列,区分哪些是配置问题、哪些是接收方真的崩溃了。处理好之后,从控制台一键重放即可。

5. 从5个Agent扩到5000个:性能和治理上的延伸问题

5.1 路由表是第一个瓶颈

Agent数量少的时候,能力注册表就在Redis里躺着,每次路由全量加载都没问题。但Agent上百之后,全量加载的开销和内存占用就上来了。更麻烦的是,Agent的能力注册经常变化,一台机器上的Agent挂了重启,注册表就要更新一次。

我们现在的做法是:路由节点各自持有注册表的本地快照,每5秒从Redis增量同步一次变更。查询全部走本地内存,只有同步时才访问Redis。这样单次路由耗时从毫秒级降到了亚毫秒级,路由节点数量扩展后,也只是共同分担同步压力。实测5000个Agent触达时,路由层的P95延迟稳定在3毫秒以内。

还有一个小优化细节:对高频触达的目标Agent,做端点级缓存,连本地快照都不查,直接按上次路由结果转发,同时保留一个异步校验位,定期确认缓存是否失效。这一套下来,吞吐量能再上一截。

5.2 触达不等于是自由的:配额与护栏

Agent规模上来以后,除了性能,更严峻的是治理问题。每个Agent都是调用者也是被调用者,谁都能调谁,系统很快就会失控。你的模型在里面可能被调得不成样子,预算流水一样往外走。

Agent-Reach在治理层加了配额机制,限制每个调用方Agent在单位时间内对某个目标能力的调用次数。实现上就是经典的令牌桶算法。我举个例子,内部让AI写作Agent对知识库查询Agent的调用配额是每分钟120次,超过的请求不会第一时间被拒绝,而是进入等待队列,如果队列排满才返回限流错误。这个设计对模型等待尖峰很有用,直接拒绝会让Agent内部的推理逻辑短路,而排队能稍微缓解一下。

每个Agent还应该有一个独立的预算标签,每次触达成功后,按目标能力的计费单价,从调用方Agent的预算里扣减。这样管理层能看到清清楚楚的"哪类Agent在烧钱、烧给了谁",而不是月底一看账单傻眼。我们在接入Agent-Reach之前,根本没法回答"上周AI的调用成本花在哪了",现在这个事是每个团队自己盯着的。

5.3 在Agent-Reach之上还能做什么

体系跑稳之后,我可以负责任地说,Agent-Reach能带来的价值不止通信。因为所有Agent的触达都会经过它,它就自然成了观测和编排的天然抓手。

我在上面挂了三个东西。第一个是调用审计,每一次触达都带trace_id留痕,合规审计可以查"谁在什么时间调用过删除类能力",直接一拉列表就出来了。第二个是流量回放,线上出问题时,可以把某条链路在过去一小时内的触达数据按时间轴回放一遍,用来复现那些稍纵即逝的偶发异常。第三个是策略中心,像刚才说的熔断、限流、循环检测的阈值、路由权重,都做成可动态调整的配置,不用重启服务,页面改完生效,安全可控多了。

有了这些之后,Agent协作的秩序感才真正立起来了,不再是我们去求着各个Agent别打架,而是平台能够预先划好边界,让它们在边界内自由发挥。

Agent-Reach这套东西,做起来并不复杂,但每一步都是在真实的痛苦中逼出来的。现在团队里新增一个Agent,接入工作量大概也就半天,大部分时间花在定义能力标签和输入Schema上,而不是在调通信、整超时、应付那些"你说你收到了但我没收到"的破事了。如果你也在做Agent协作,我建议你从最细的信封格式开始定,哪怕一开始只是五个Agent在本地互相调用,等以后规模上来了,你会感谢当初把这些边角料问题想清楚的自己。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进