1. 为什么需要Agent-Reach:多智能体协作的触达难题
去年我在做一个多智能体协作的中台项目,最头疼的不是某个Agent的推理能力,而是Agent与Agent之间到底能不能稳定地找到对方、递上正确的请求。我们内部把这个触达层命名为Agent-Reach——它不是一个模型,也不是工作流引擎,而是专门解决智能体“可达性”的基础设施。单Agent应用里你只需要关心模型和工具,但在多Agent系统里,真正的复杂度会转移到“谁能找到谁、谁来调用谁、失败之后怎么办”。这篇文章就是把Agent-Reach从零到落地的完整过程摊开讲,包括架构决策、核心实现和压测阶段踩过的两个大坑。不管你是刚开始拆多Agent服务的架构师,还是已经在维护一堆互相调用的智能体、正被链路问题折磨的开发者,这篇都值得你花十分钟看完。
1.1 单Agent的舒适区与多Agent的混乱
单个Agent的时候,所有工具都挂在本地函数表里:写死一个get_weather()、create_ticket(),模型在推理时直接用 function calling 做选择,根本不存在“触达”这个概念。但当你按业务域拆成多个Agent——客服Agent、订单Agent、报表Agent、库存Agent——问题立刻出现:客服Agent需要订单数据时,怎么知道订单Agent在哪?能力边界是什么?消息该怎么发?
我见过一个项目,团队把下游Agent的HTTP地址直接硬编码在上游配置里,三个Agent时还能跑通,等拆到十二个Agent之后,调用链深度达到了六层。改一次下游地址要发四次发布,排查问题只能逐层翻配置,简直是维护噩梦。更麻烦的是,Agent能力的语义不是“服务名”能表达的:服务名order-service只能说明这是订单服务,但“订单汇总”“订单异常检测”“订单预测”是三种完全不同的能能力,都挂在同一个服务上时,你根本没法做精准路由。多Agent系统一旦活跃节点超过十个,就必须有一个类似目录服务的组件,解决“谁声明了哪些能力、这些能力现在是否可用”的问题。
1.2 三个核心问题:发现、路由、故障转移
把多Agent协作抽象成一次“智能体间调用”,本质上绕不开三件事。
第一是发现。调用方Agent只知道“我需要一份按区域聚合的订单周报”,但它不知道哪个Agent能生成。发现机制就不能只是服务名匹配,而是要能做语义匹配:请求意图和Agent能力描述之间算相似度,找到“语义上能完成这件事”的候选。
第二是路由。往往同时有好几个Agent都能完成类似任务,只是有的擅长快速返回摘要,有的擅长深度分析但费时间费钱。路由需要综合延迟、成功率、成本、当前负载等维度做决策,而不是简单轮询。
第三是故障转移。首选Agent过载了怎么办?超时后重试几次才不会把系统打垮?是否需要换一个候选Agent?这也不是单Agent时代需要考虑的问题——单Agent调用工具失败,顶多影响一次回复质量,多Agent场景里一个慢Agent会把上游Agent的线程池全部拖死,雪崩起来非常快。
1.3 现成方案为什么差一口气
一开始我们天真地以为可以直接复用微服务那套基础设施。后来发现差距很明显。
服务注册中心(比如Consul、Nacos)注册的是服务名和IP端口,面向RPC接口,不面向语义能力。你可以注册order-service:8080,但不能注册“我可以把订单数据按任意维度汇总成自然语言摘要”,更没法做能力级别的权限控制。
消息队列(比如Kafka)只解决异步投递,但它不会帮你判断“这个消息到底应该由哪个Agent消费”。每个消费者都抢消息,结果就是多个Agent重复处理同一份数据,排重和治理成本极高。
RPC框架(比如gPRC、Thrift)倒是做了服务发现和负载均衡,但同样缺少语义层——它认为OrderService.Summarize(OrderQuery)是个接口方法,而我们的请求往往是自然语言描述,比如“帮我看看最近一周华东区的订单变化趋势”。两者之间的语义鸿沟,需要一个额外触达层来填平。
至于LLM function calling,它解决的是“单个Agent内部如何调用工具”,不是“Agent与Agent之间如何互相发现和调用”。两者定位完全不同,不能混为一谈。
2. Agent-Reach的总体架构与核心概念
想清楚问题边界之后,我们设计的Agent-Reach包含三块核心:目录服务、触达协议、链路质量模型。这一层我刻意做得比较轻——它只负责“发现”“协商”“保护”,业务数据流不强制穿透它,这也是后面性能测试能撑住的关键。
2.1 目录服务:能力声明与租约
每个Agent启动时向Agent-Reach注册,除了上报运行地址,更重要的是上报一份“能力声明”(Capability Manifest)。能力声明不只是几个字符串标签,而是结构化、机器可读的元数据。
下面是我当时用的一个精简版示例:
{ "agentId": "order-summarizer@prod", "displayName": "订单汇总智能体", "capabilities": [ { "id": "summarize_orders", "operation": "summarize", "objectType": "sales_order", "params": [ {"name": "dateRange", "type": "string", "required": true}, {"name": "groupBy", "type": "string", "enum": ["day", "month", "region"]} ], "semantic": "对给定时间范围内的销售订单进行多维度汇总统计,输出自然语言摘要和表格数据", "costTier": "medium", "idempotent": false, "timeoutHintMs": 5000 } ], "region": "cn-beijing", "healthy": true, "load": 0.35 }semantic字段是给语义匹配用的自然语言描述,embedding向量由Agent-Reach在注册时计算好存进索引,不放进manifest里,避免Agent端重复算。idempotent字段非常关键:它决定了调用失败时能否重试,非幂等请求一旦重试就可能造成重复扣款或重复下单,后面踩“级联雪崩”的坑时你会体会到这个字段的重要性。
注册采用租约机制:Agent每隔一定时间续租,超过租约时间未续租的Agent会被移出目录。这样能有效清理异常退出的僵尸节点,不至于让调用方拿到一个根本连不上的地址。
2.2 触达协议:面向智能体的“握手-心跳-调用”
我们一开始纠结过要不要自己定义一套二进制协议,后来果断放弃,直接基于HTTP/2做了四类消息:Register、Discover、Invoke、Ack/Nack。
Register:Agent注册/更新能力声明。Discover:调用方发起语义查询,携带请求意图、需要的参数、可接受的最大延迟。Invoke:直接调用目标Agent的业务接口(这一步可以选择走触达层中转,也可以直连)。Ack/Nack:反馈调用结果。
一个容易被忽略的设计决策是:业务流量不经过Agent-Reach中转,触达层只负责“让调用方拿到候选Agent的可达地址”,拿到之后调用方与目标Agent点对点直连。好处很明显:Agent-Reach不会成为集中式瓶颈,链路延迟只增加一次目录查询;坏处是触达层失去了全链路洞察能力,所以我们在SDK里做了采样上报,把部分调用链的时序数据异步回传控制台,既能观测,又不压垮中心。如果你希望每笔调用都被审计,中转模式更适合;如果主要矛盾是性能,直连模式几乎是唯一选择。
心跳在设计里也踩过不少坑,这个放到第四部分单独讲。先给一个我最终调稳的参数:心跳间隔10秒,租约30秒。10秒的心跳能保证故障最迟在30秒内被感知,同时不会产生过高请求量。很多人一开始习惯用5秒心跳,但在上千个Agent的时候,连接不复用就会出事。
2.3 链路质量不是单一数字
触达层除了“让双方连上”,还要让调用方在多个候选Agent中做出好决策。我们把质量计算分散成了两层:Agent-Reach只做粗过滤,剔除不健康节点;调用方SDK基于返回的候选指标做细排序。
每个Agent周期性上报自评指标:P50延迟、成功率、当前负载、成本档位。调用方按自己的场景计算代价分:
score = w_latency * (500 / observedLatencyMs) + w_success * successRate - w_cost * costTierScore - w_load * currentLoad不同调用方对得分的诉求可以完全不同:一个实时问答Agent可能把延迟权重拉到0.7,把成本权重压到0.05;一个离线报表Agent则会把成本权重提到0.3。中心只提供事实指标,由调用方自行权衡,这个设计我觉得比集中式打分更灵活。实际调参时,我们先用一组平衡系数(延迟0.5、成功率0.3、成本0.1、负载0.1)跑通,再根据压测数据逐项调整。
3. 关键实现:从能力声明到智能路由
这一部分我给到可以直接抄作业的实现思路:能力描述怎么写、语义匹配怎么做、路由决策和失败控制怎么落地。代码我精简成伪代码,但核心逻辑都保留。
3.1 能力声明怎么写才算机器可读
很多人写能力声明时只给一句话:“能做订单汇总”。这对LLM以外的系统来说毫无意义。我们的经验是,能力声明至少要有四层信息:
- 操作语义:
operation+objectType,描述“对哪类对象做什么操作”。比如summarize+sales_order精确表达了“订单汇总”。 - 入参约束:参数名、类型、必填性、枚举范围。没有参数约束的Agent能力在被调用时大概率参数错误。
- 自然语言描述:给语义匹配模型看,方便把用户请求向量化和能力描述向量做相似度计算。
- 调用属性:是否幂等、超时建议、成本档位。这些属性决定了后续重试策略和资源分配。
另外,我强烈建议给每个能力加timeoutHintMs。因为Agent的能力千差万别,有的内部要调三个数据源,有的一个内存查询就回来了,统一给一个超时时间会非常粗糙。调用方SDK里默认使用这个字段,再允许调用方覆盖。
3.2 双层筛选:关键字召回加语义排序
纯向量召回有个毛病:语义相似度高的结果往往“看起来像但根本不是”,比如用户问“订单删除”,向量可能被召回为“订单列表”,因为它们在句子结构上太接近了。纯关键字匹配又解决不了同义改写,比如“销售情况”和“订单趋势”不是同一个词,但意图几乎一样。
我们最终采用的是“关键字粗筛 + 语义精排”双层策略:
def discover(request_semantic: str, keywords: List[str], top_k: int = 5): keyword_matched = index.search_keywords(keywords) semantic_matched = index.search_vector(embed(request_semantic), threshold=0.75) # 先求交集保证精度,交集为空时扩大为并集再过滤 candidates = keyword_matched & semantic_matched if not candidates: candidates = top_n(keyword_matched, 10) | top_n(semantic_matched, 20) return rank_candidates(candidates, request_semantic)这个流程跑了大半年,召回准确率稳定在90%以上。需要提醒的是,语义阈值0.75不是拍脑袋定的,我们用了两周线上Query日志做回归测试:阈值从0.6调到0.75的过程中,准确率上升明显,召回率下降很小;再往上调(0.8)召回率就开始掉得太厉害。每家业务数据不同,这个值最好自己测一遍,别直接抄。
3.3 路由决策与失败控制
路由决策在调用方SDK里做,核心是一个很简单的排序函数:
def rank_candidates(candidates, query): def score(c): return ( weight_latency * (500 / c.p50LatencyMs) + weight_success * c.successRate - weight_cost * cost_score(c.costTier) - weight_load * c.currentLoad ) ranked = sorted(candidates, key=score, reverse=True) return [c.agentId for c in ranked[:3]]关键不只是排序,还有失败控制。我直接给出我们调稳的参数表:
| 阶段 | 超时设置 | 说明 |
|---|---|---|
| TCP连接建立 | 500ms | 本地机房内50ms内就该连上,超过500ms基本说明网络异常或目标过载 |
| 首字节返回 | 1s | 超过1s说明目标Agent可能在排队,早失败比晚失败好 |
| 总调用时间 | 5s | 目标Agent的timeoutHintMs可能更长,但调用方最多等5s |
重试只对idempotent=true的能力启用,且最多重试1次;对非幂等能力,默认立即返回失败并附上“可重试原因”。很多系统挂掉不是第一次调用失败,而是重试风暴,所以这块约束我一再跟团队强调:“宁可一次失败,不要三次雪崩”。
4. 压测踩坑:心跳风暴与级联雪崩的完整排查链路
Agent-Reach开发完进入容量压测阶段,我们连续踩了两个大坑,这两个坑非常有代表性:一个是因为连接管理不仔细导致的基础设施被打爆,另一个是典型的超时级联雪崩。我完整复现排查过程,比直接给结论有用得多。
4.1 坑一:连接没复用,心跳把注册中心打爆了
第一批压测计划模拟1000个Agent连接Agent-Reach,提前预设的QPS是每秒几千,理论上不会出问题。结果压测开始不到十分钟,注册中心CPU从15%一路冲到90%,大批Discover请求超时。
第一反应是服务端处理能力不足,立即加了机器,但CPU依然降不下来。接着看监控才发现:注册中心本身QPS并不高,但TCP连接数异常飙升,比正常值高了五十倍。再看连接状态,全部是TIME_WAIT堆积。再看两个端日志:每个存活Agent每5秒发一次心跳,心跳里的TCP连接居然是新建的——SDK里每次client.heartbeat()都new了一个HTTP Client,等于每5秒重复一次“三次握手+发送心跳+挥手”(HTTP/2长连接根本没有被复用)。
复现方式很简单:用200并发模拟心跳压测,1分钟内连接数从2000涨到6万,TIME_WAIT数量呈线性上升。定位结论是客户端连接管理问题,不是服务端压力。
修复措施有三条:
- 把心跳改成单例HTTP Client,连接池复用长连接。
- 心跳合并上报:每个Agent每30秒上报一次,数据包里包含5个采样点,而不是每5秒发一次请求。这样注册中心的心跳请求量直接降为原来的六分之一。
- 租约还是30秒不变,心跳虽然是30秒一次,但两个心跳之间只要有一个采样失败,Agent端会立刻发起紧急续租,所以故障感知速度并没有变慢。
修复后重测,注册中心CPU稳定在12%,连接数稳定在500左右。这个教训让我意识到:在网络密集型基础组件里,连接复用和第二级缓存都要前置考虑,别等服务端被打爆了才回去翻SDK。
4.2 坑二:连坐式超时雪崩,根因在线程池
第二个坑更隐蔽,也更致命。业务侧的调用链是:客服Agent -> 订单汇总Agent -> 报表Agent -> 数据库。某天数据库一条慢SQL把报表Agent打慢,很快订单汇总Agent也慢了,最后客服Agent的错误率飙升,入口成功率掉到82%。
刚开始所有人都在查数据库慢SQL,但kill掉慢查询后系统依然没有恢复。我看线程dump时发现:客服Agent的200个Tomcat线程全部处于BLOCKED状态,等在一个HTTP调用上;订单汇总Agent的线程池也全部被占满,等在下游报表Agent的响应上。数据库其实已经恢复,但每个请求的排队等待时间并没有消失。
根因不是单一节点故障,而是触达层的“重试+超时”策略放大了故障。客服Agent原来设置的调用超时是10秒,重试3次,于是每个上游请求会最多产生4个下游请求;下游报表Agent能处理的并发数是200,一旦被慢SQL打满,每个新请求都在排队,上游所有线程只能干等。
排查链路是这样的:先看上游Agent错误率陡增,再通过线程dump确认阻塞在HTTP调用;顺着调用链找到下游Agent线程池全忙;最后看下游日志发现所有线程都阻塞在JDBC请求上。很明显,数据库慢SQL是导火索,但真正把故障放大的,是“无差别重试+同步阻塞”。
修复做了四方面调整:
- 信号量隔离:引入
maxConcurrentRequests=20,超过并发阈值直接快速失败,不让调用方无限排队。 - 超时缩短:改为连接500ms、首字节1s、总时长5s,早失败避免线程被占用太久。
- 重试限制:只有幂等能力才允许重试,且最多重试1次;报表Agent在过载响应头里返回
Retry-After: 30,让上游Agent看到后主动冷却。 - 快速失败:调用方向Agent-Reach查询时,如果候选Agent健康度低于0.8,直接不返回该节点,而不是等超时。
最终P99从8.5秒降到950毫秒,入口成功率回到99.1%。这次之后我们做了一条硬性约定:任何能力声明必须写清idempotent,不确定的都按非幂等处理。
4.3 还有几个不致命但很烦的坑
除了上面两个大坑,还有几个小问题值得记录:
- TTL设置太短误杀正常Agent。一开始租约设了8秒,公网有抖动时,Agent只是晚了两秒续租就被移出目录,调用方拿不到地址。调成30秒后问题消失。
- 能力声明字段升级不兼容。老版本SDK把未知字段当错误,新字段一加上网就炸。后来SDK统一
ignore_unknown_fields,并做了前后兼容测试。 - 时区问题导致参数错乱。不同Agent所在的容器时区不一致,传递“本周”“最近30天”这类相对时间时结果对不上。统一改为带UTC偏移的时间戳,相对时间在入口Agent侧解析。
5. 从单机房到跨区域:Agent-Reach的运维与扩展
Agent-Reach稳定跑了三个月后,我们开始把它从单机房部署扩展到多区域,同时做了安全加固。这个阶段的经验比较零散,但每一条都是实际运行中总结出来的。
5.1 部署形态:嵌入式还是独立服务
第一版Agent-Reach是作为Client库嵌在每个Agent进程里的,所有目录状态维护在Agent本地。好处是部署简单,坏处是每个Agent都要实现一遍算法,而且多语言环境很难统一。
最终我们改成了独立服务模式:Agent-Reach本身运行在三个节点上,状态存储使用内嵌的强一致KV,调用方SDK通过HTTP/2访问它。业务调用仍然走点对点直连,所以Agent-Reach的流量很轻,只承载注册、发现、心跳、策略下发。这个形态下,加一个新语言的SDK只需要实现协议就可以,不用重复实现路由算法。
独立服务模式下的配置要点大概是:
agent-reach: registry: nodes: 10.10.1.11:8100,10.10.1.12:8100,10.10.1.13:8100 lease: heartbeat_interval: 30s ttl: 90s semantics: embedding_model: bge-large-zh similarity_threshold: 0.75 auth: mtls_enabled: true cert_path: /etc/agent-reach/client-certs注意,这里的lease.ttl我设成了90秒,是心跳间隔的三倍,给网络波动留足余量。
5.2 安全边界不能后补
多Agent系统最容易被忽略的是权限边界。如果每个Agent都能调用任意其他Agent的能力,出事的概率会指数上升。我们把安全做在触达层,主要做了三件事:
- 双向mTLS:每个Agent有独立证书,Agent-Reach和调用方之间双向校验身份。
- 能力级ABAC策略:不再是“谁能访问哪个服务”,而是“哪个调用方在什么条件下可以调用哪个能力”。
- 令牌最短生命周期:访问Token每30分钟轮换,避免长期Token泄露。
一条简单的策略示例:
{ "action": "invoke", "resource": "capability:summarize_orders", "subject": "agent:customer-service@prod", "conditions": { "region": ["cn-beijing", "cn-shanghai"], "time_window": ["09:00:00+08:00", "21:00:00+08:00"], "required_claims": ["tenant:abc-corp"] }, "effect": "allow" }这套ABAC在初期看着繁琐,但一旦接入几十个Agent,如果没有策略边界,协调成本会高到你怀疑人生。安全一定要前置。
5.3 下一步:意图触达与灰度流量
Agent-Reach目前的语义匹配已经能满足“表达意图 -> 找到Agent”。下一步我想把决策也变成意图驱动的:调用方只声明“我要什么效果”,触达层通过意图模型动态选择最优Agent组合,而不是让业务方关心具体调谁。灰度发布方面,我正在给能力声明增加版本号和流量权重,让新版本Agent只承接测试流量,稳定后再逐步放量。
我们在Agent-Reach上跑了大半年,最大的感受是:多智能体系统的复杂度不会因为你用了某个AI模型而消失,它只会从提示词工程转移到系统设计。如果让我再重新做一次,我会先花一周做容量规划和故障注入,而不是急着把Demo跑起来。希望这篇里的架构、代码和那些坑,能帮你少走几个弯路。