如果你管过二十个以上同时在协作的Agent实例,一定见过这种画面:Agent A处理完订单,要把结果交给Agent B继续做下一步,代码里写满了await和手动retry;Agent C需要查一下库存,先得从配置中心翻出D的地址,再拼一遍URL;某个实例凌晨滚动更新,整条链路就跟着抖一阵。我这次想聊的Agent-Reach,不是一个花哨的Agent编排框架,而是解决那个最朴素的问题:A要触达B,路径得短,过程得稳,结果得能确认。它本质上是一个Agent之间的通信触达层,把"谁在哪儿、谁能干什么、消息怎么送、送没送到"这件事集中管起来。适合正在做多Agent系统、被Agent互相调用搞得焦头烂额的团队参考,也适合想给现有机器人服务加一层可靠通信的开发者拿来类比。
1. 为什么"触达"成了Agent项目里最容易被低估的环节
1.1 我从点对点直连改到中间层的那段经历
最早我负责的项目里,Agent之间全是直连。A调用B,B调用C,每个调用方都自己维护目标服务的地址、接口协议、超时时间和重试策略。一开始只有五六个Agent,能跑,大家都挺满意。
等到Agent数量上到三十几个,问题就藏不住了。第一是拓扑混乱,谁依赖谁,只能打开代码一个个翻。第二是重试逻辑各写各的,有的Agent重试三次还带退避,有的失败就抛异常,有的压根不重试。第三也是最难受的:每次有人改接口字段,所有下游都要跟着动,部署顺序稍微错一步,线上就飘红。
后来我下决心做一个统一触达层。出发点不是"我们要上中间件",而是"再这样写下去,所有人都得耗在联调里"。Agent-Reach的第一版其实非常简陋,只有两张表、一个转发服务和一个重试任务,但上线之后,效果立竿见影——新Agent接入不再关心目标是谁,只关心发什么topic,经过什么路由找到目标实例,由触达层完成投递。这不只是少写代码的问题,而是把"Agent之间怎么通信"从业务代码里抽离出来了。
1.2 触达到底在解决什么:可靠性、拓扑解耦与"说人话"的接口
把触达单独做成一层,本质上是在回答三个问题。
第一是可靠性。直连时,一次调用失败,调用方往往不知道是该重试、该降级还是该放弃。有了触达层,消息先落库,再投递,失败就进重试队列,发送方只需要知道"我发出去了一条消息,它的状态在触达层里可以查"。这等于把"尽力而为"升级成了"有据可循"。
第二是拓扑解耦。触达层维护一份Agent注册信息,每个Agent上线时上报自己的身份和能力。调用方不需要知道B在哪,不需要知道B有几个副本,甚至不需要知道B是不是换了语言重写。只要topic没变,路由就能把消息送到。
第三是统一接口。我认为好的触达层应该让Agent之间的交互"说人话"。Agent A不需要给Agent B单独定义一个RPC接口,A只需要对外说"我发了order.created这个topic的消息",然后在注册中心声明自己能消费order.created。事件语义替代方法调用语义,好处是扩展性变强,坏处是事件格式需要约束,这个我后面细讲。
2. Agent-Reach的四层管线:消息从投递到确认的完整路径
2.1 接入层:HTTP、gRPC和MQ不该变成选择题
Agent-Reach的接入层把三种方式都收了进来:HTTP/JSON、gRPC、以及从消息队列里消费的事件。我见过不少项目在"该用同步还是异步"上吵个不停,其实没必要。同步接口用于急等结果的场景,比如Agent A要立刻知道Agent B有没有接下这个任务;异步topic用于可以慢慢处理的场景,比如通知类消息,B收到后自己入队做后续处理。
接入层真正要解决的是协议转换和身份认证。所有进来的消息,不管原始协议是什么,统一转成内部结构体,然后带着发送方的Agent ID一起往下走。这一步不做业务逻辑,纯粹是"收件"。
接入层的配置里,我建议把每个Agent的QPS配额放在这层做限制。
ingress: port: 8080 protocol: [http, grpc, mq] auth: mode: token token_ttl: 3600 quota: order-svc: 5000 # 每秒最大接入条数 payment-svc: 2000配额不是摆设。某个Agent一旦发起消息风暴,触达层如果照单全收,后面的路由和分发全都会被拖垮。在接入层拦住,等于给整个系统上了第一道保险。
2.2 路由层与分发层:谁负责"找到人",谁负责"送到手"
路由层拿到一条消息后,先解析topic,然后查注册表,看哪些Agent声明了消费这个topic。注意,这一步查出来的不是一个具体实例,而是一个候选列表。真正的"送信"动作,由分发层来做。
两层的职责一定要分开。路由层是"大脑",只管做决策;分发层是"手脚",只管把消息发给选定的实例。如果混在一起,会出现一种很尴尬的情况:路由决策和实际投递互相耦合,一方改成另一方跟着抖。分开之后,路由层可以独立测试,分发层也可以单独做连接池优化。
分发层的核心是连接管理。每个目标Agent保持一条长连接,连接池需要复用,不能每条消息都新建连接。我用的是gRPC长连接加连接池,连接空闲超过60秒就回收,避免大量空闲连接占用文件描述符。
2.3 确认层:让发送方拿到一个可信的回执
触达层收到路由分发的返回结果后,要把状态更新到确认层。我把它做成一张状态表,每条消息都有完整的生命周期:
| 状态 | 含义 | 触发时机 |
|---|---|---|
| PENDING | 已接入,未路由 | 接入层落库后 |
| ROUTED | 路由完成,待分发 | 路由层选出候选后 |
| DELIVERING | 正在投递 | 分发层发起连接前 |
| DELIVERED | 目标已确认收到 | 目标Agent返回ack |
| REJECTED | 目标明确拒绝 | 目标Agent返回错误码 |
| DEAD | 重试耗尽或过期 | 重试队列判定 |
发送方可以随时查消息状态。我在Agent-Reach里暴露了一个查询接口,GET /messages/{message_id},返回上面的状态和更新时间。这个接口的引用频率远比我预期的高,因为排查问题的时候,大家终于不用再逐台机器翻日志了。
3. 路由不是关键词匹配:能力声明、优先级与降级策略
3.1 注册中心里存的不只是地址,而是能力描述
路由如果只看topic做精确匹配,系统会非常脆。比如order.created这条消息,可能有两个Agent都能消费:一个负责履约,一个负责风控。如果路由只按topic分发,两条都发,会造成重复处理;如果只发给第一个,风控Agent就收不到。所以Agent-Reach在注册表里存的不是简单地址列表,而是能力描述。
每个Agent注册时,要上报自己的capabilities,结构里包含三个部分:
{ "agent_id": "risk-control", "instance": ["10.0.1.3:9090", "10.0.1.4:9090"], "capabilities": [ { "topic": "order.created", "mode": "subscribe", "tags": ["risk", "high-priority"] } ] }mode是subscribe还是handle,决定了消息是投递给它后由它自己决定是否处理,还是要求它返回处理结果。tags给路由提供了更细的分类能力。风控Agent想收order消息,但只想收高优先级的,这可以在路由条件里用tag过滤。
3.2 候选集打分排序与降级路径
有多个Agent都能消费同一topic时,路由需要决策。Agent-Reach的做法不是随机选或者轮询,而是给每个候选打分。分数由四个因素加权计算:
- 优先级(用户配置,默认权重0.4)
- 健康度(最近5分钟成功触达率,权重0.3)
- 负载(当前连接数/目标实例数,权重0.2)
- 就近性(同机房优先,权重0.1)
按分数从高到低排序,取最高分作为首选。这种策略的好处是灵活:集群A性能好但因为流量太高分数下降,消息就会自动流向集群B;某实例健康度从99%掉到85%,它的排序也会自然靠后。
降级路径是必须做的。首选投递失败时,Agent-Reach不会直接重试同一实例,而是把候选列表里第二个、第三个实例依次排上来。这条逻辑我看了很多线上数据才意识到有多重要——同一批实例往往是一起挂的,重试首选实例大概率还是失败,还不如早一点切换到其他候选。
3.3 匹配失败时去死信队列,还是踢回给发送方
路由层查不到任何匹配的Agent时,我在第一版的选择是直接丢弃,后来被线上教训改了。某次Agent C发了一条inventory.updated的消息,但消费方还没注册完,消息就被丢了,等消费方上线,数据已经对不上。后来我定了两个策略:
先判断消息是否设置了过期时间。如果消息设置了expire_at,说明可以容忍延迟,丢进死信队列,等消费方注册后由管理员手动回放。如果没设置过期时间,判定为关键消息,直接返回错误码给发送方,让发送方知道"这条消息没有送出去,你要自己决定是否补偿"。
死信队列不能只堆不清理。我安排了一个每日巡检任务,如果死信队列里某条消息超过24小时且消费方仍未注册,就把它的状态置为DEAD,并给负责Agent的开发人员发告警。这样至少保证每条消息都有下落,不会悄无声息地消失。
4. 超时、重试与幂等:把"尽力而为"变成"有据可循"
4.1 超时预算的设定逻辑:从一次真实的超时风暴说起
超时参数的设定,最忌讳拍脑袋。我第一版里随便设了个3秒,结果遇到目标Agent GC停顿,3秒挂在那里干等,触达层的连接池被占满,后续所有消息都开始排队。那次线上事故让我学到一个原则:超时要有总预算,每一跳要有独立时限,总时限不能超过预算。
Agent-Reach里对每条消息的端到端超时预算设为45秒,内部切分成四段:
| 环节 | 时限 | 说明 |
|---|---|---|
| 接入落库 | 1s | 本地磁盘写入,超过说明存储有问题 |
| 路由决策 | 2s | 查注册表+打分开销极小 |
| 分发投递 | 10s | 包含网络往返和目标处理时间 |
| 重试间隔 | 32s | 多次重试的总间隔上限 |
超过总预算后,消息状态直接置为DEAD,发送方会第一时间收到失败回执。宁可让它失败得干脆,也不要让一条消息在半死不活的状态里占用整个系统的资源。
4.2 退避公式里的数学:指数退避与抖动
重试必须带退避,这已经是常识了。但很多人不知道退避要加抖动(jitter)。没有抖动的退避有个经典问题:一批消息同时失败,同时等待2秒,又同时重试,重试流量又形成一波尖峰,可能再次击垮目标。这不是重试,这是自我攻击。
Agent-Reach里用的退避公式:
delay = min(base * 2^attempt, max_delay) * (0.8 + random(0, 0.4))base取200ms,max_delay取4s,每次重试attempt加1。抖动范围控制在±20%,既能错开流量尖峰,又不会因为抖动太大导致等待时间不可控。
重试次数上限,我建议设为3次。算上首次尝试,总共4次机会。再多也没意义——连续4次都投递失败,说明目标Agent大概率处于不可恢复的状态,继续重试只会加剧问题。
func nextBackoff(attempt int, base, max time.Duration) time.Duration { delay := base * time.Duration(1<<uint(attempt)) if delay > max { delay = max } jitter := time.Duration(float64(delay) * (0.8 + rand.Float64()*0.4)) return jitter }4.3 幂等不是"去个重"那么简单
触达层重试了,业务层就要做好重复处理同一消息的准备。幂等设计有个常见误区:只在前端判断"这条message_id我见过没有",用Redis set一下就算完。我一开始也这么干,后来发现两个问题:Redis里的key过期了怎么办?消息处理到一半进程崩了,第二次进来时Redis里已有标记,但业务其实没做完,怎么办?
Agent-Reach的幂等策略是双保险。第一道:message_id在Redis里以SETNX方式写入,TTL设为300秒,300秒内重复投递直接忽略。第二道:数据库里对message_id建唯一索引,确保即使Redis丢失,也不会出现两条相同消息同时被处理。
两道的触发场景不同。Redis挡住的是正常情况下的重复,数据库唯一索引挡住的是极端情况下的并发。只靠任何一道都有盲区,双保险也谈不上多大成本,但能省掉后面排查重复数据的精力。
5. 消息格式与兼容性演进:一个字段引发的连环事故
5.1 消息体里最值得较真的几个字段
消息格式设计得不好,触达层再稳也白搭。Agent-Reach对消息体有明确的字段约束,不是每个字段都要填,但下面这几个必须有:
{ "message_id": "rs-01HZXK8Q2R9Y3TQ7A1B2C3D4E5", "topic": "order.created", "trace_id": "trace-7f4a2c1e9b8d4f3a", "producer": "order-svc", "schema_version": 3, "created_at": "2025-01-15T08:30:00Z", "expire_at": "2025-01-15T09:30:00Z", "payload": {} }message_id必须是全局唯一,我用的是前缀+时间+随机数的组合,保证在分布式环境下不碰撞。trace_id贯穿整条链路,Producer发消息时生成,所有后续处理都会带着它,排查问题省事很多。
schema_version是我用一次事故换来的深刻教训。之前的Agent之间用一个共享的protobuf,消息里加字段用optional标记,觉得向后兼容没问题。结果有个Agent用新版本解析旧数据,因为某个枚举值变了,整个消息解析失败。
5.2 schema_version与明文payload的组合
后来我把payload改成不透明设计。消息体里只保留上下文信息,业务数据全部放到payload里,且Agent-Reach不解析payload的内容。它只是个搬运工,把整个payload原样送到目标Agent。这样最稳妥,触达层不需要理解业务字段,自然不会被业务字段的变更影响。
但随之而来的问题是:如果payload不解析,怎么保证消费者能正确解码?靠schema_version。消费者从消息里读到版本号后,按自己的兼容策略处理。我可以接受消费者有自己的兼容规则,但不能让触达层去维护这种兼容规则。
接收端我建议的JSON解析原则:
- 只取自己认识的字段,不认识的字段一律忽略
- 字段缺失时使用默认值,不直接报错
- 枚举值解析失败时降级为"未知",而不是终止处理
这样即使生产端先升级、消费端后升级,也不会出现阻塞。你要知道,Agent系统的发布节奏不可能完全一致,协议层的容错能力决定了系统能承受多乱的发布顺序。
5.3 元数据与业务数据分离的存储策略
消息存储如果全量存,数据库增长会非常快。Agent-Reach把元数据和业务数据分开存:元数据(message_id、topic、状态、时间戳、trace_id)放PostgreSQL,payload按需存对象存储或者直接放在消息队列里不落库。
出现故障需要回放时,我们大多只需要看元数据,就能定位问题。真正需要完整payload的情况很少。这个设计把存储成本降了一大截,查询速度也快不少。
我还给每张消息表加了分区,按月分区。查询历史消息时先按时间裁剪分区,避免全表扫描。等消息表积累到一个月以上的数据量,效果差距非常明显。
6. 可观测性不是事后诸葛:一次凌晨故障的排查复盘
6.1 凌晨三点报警,为什么我先看的是重试率而不是成功率
某天凌晨三点,值班告警突然响了。我爬起来第一件事不是看成功率,而是看重试率。因为成功率这个指标有个天然的骗局:重试成功也会被记成成功,只要重试次数够多,成功率看起来依旧漂亮,但系统实际已经处于抖动状态。
那次告警对应的重试率从平时的1%飙到了37%。这是非常典型的前兆:表面上看,消息都投递成功了,但背后有大量消息在反复重试,整个链路在临界点附近挣扎。
而成功率指标在那种情况下依然显示99.9%,因为触达层把重试也算作最终成功。这说明一个观测上的关键点:你要区分"第一次尝试就成功的比例"和"最终成功的比例",这两个数据缺一不可。
6.2 从超时日志反推故障链路的完整过程
我顺着重试率异常的告警开始追查。先看了路由层的时间分布,发现P99延迟从前一天的4ms涨到了220ms,这明显不正常。然后我去翻了目标Agent的实例列表,发现半夜它们在做滚动更新,更新窗口内注册中心里的实例数没有变化,但实例其实处于"接收新连接但不处理"的状态。
具体链路是这样的:某个Agent在更新前,需要先停止接收新消息。但更新脚本是先停服务,等实例把内存里的存量消息处理完再注销。按正常流程,这个实例应该先从注册中心下线,再停服务。结果脚本里的注销时机晚了一步。Agent-Reach的路由层仍然把这个实例当健康节点,消息发过去,连接刚建立就被拒绝,于是进入重试,重试又选到同一批正在更新的实例,恶性循环。
日志里最讽刺的一句是:connection refused和connection reset by peer交替出现,那条消息被重试了6次,最终也没成功。而按照Agent-Reach的设计,每轮重试应该选不同候选实例,但当时候选列表里全是正在更新的机器,没有其他可用实例,所以换谁都一样。
6.3 事后补上的三个告警项
这次故障给我的教训不是"滚动更新要写对顺序",而是"系统的自我保护要能识别这类场景"。事后我加了三个告警:
第一,重试率超过10%就告警,这是"系统正在挣扎"的第一信号。第二,路由层按实例维度的健康度低于某阈值时,自动将对应实例摘除出候选列表,不再投递任何新消息。第三,所有实例的连接池建立成功率低于80%时,触发一条P1告警,因为它说明目标Agent可能在更新或者网络分区。
这三个告警在不长的时间里陆续兜住了几次类似场景。团队后来开玩笑说,凌晨的告警从"会被吵醒"变成了"可以安心睡觉",因为大部分情况系统自愈了,只有真正需要人介入的才会响起。
7. 落地部署与容量预估:从压测数据谈阈值
7.1 最小可用部署长什么样
Agent-Reach的最小可用部署包含三个部分:三个触达层节点作为无状态服务,一个PostgreSQL存元数据和状态,一个Redis做幂等和缓存。三者都可以独立扩展,初期不需要上复杂的高可用方案。
触达层节点本身没有状态,挂了就摘掉,由负载均衡器负责切换。真正的状态在数据库和Redis里,所以核心是保证这两处的高可用。PostgreSQL用主从加自动故障转移就够,Redis用哨兵模式也能满足大多数场景。
注册表不搞独立服务。第一版里我曾经想做一个独立的注册中心服务,后来发现根本没有必要——注册表的数据量很小,存在PostgreSQL里,加上Redis做缓存加速,性能完全够用。少一个组件就少一个故障点。
7.2 容量估算公式和一组实测数据
容量规划我总结了一个粗略公式:
并发连接数 = QPS × P99延迟(秒)如果目标QPS是2000,P99延迟期望控制在100ms以内,那并发连接数约等于200。按照每个线程处理50个并发连接的水平,四个线程足够。这个公式不求精确,但能给你一个量级上的感知,不会被"高并发"三个字吓住。
我还做了一组压测,触达层单节点,3副本集群,目标是模拟100个Agent实例互相发消息的场景:
| 压测QPS | P99延迟 | CPU使用率 | 内存占用 | 成功率 |
|---|---|---|---|---|
| 500 | 2.8ms | 12% | 210MB | 100% |
| 1000 | 4.5ms | 24% | 245MB | 100% |
| 2000 | 9.1ms | 45% | 290MB | 100% |
| 4000 | 22.4ms | 78% | 365MB | 99.99% |
| 6000 | 48.7ms | 95% | 430MB | 99.9% |
从数据上看,单节点撑2000 QPS没有问题,瓶颈在8000 QPS附近开始出现。如果你的目标QPS超过5000,我建议直接横向扩到5个节点,而不是硬压单机性能。成本不高,效果立竿见影。
7.3 最后几条"早该知道"的工程经验
第一,不要做全链路阻塞。触达层如果目标Agent不可用,不能让链路卡死。凡是超过预算的消息,要么进死信,要么快速失败,绝不允许无限期挂起。我见过其他团队把触达层做成了ESB那种重中间件,引入复杂的事务管理和编排引擎,最后维护成本远高于收益。轻量、快、只解决触达问题,就够了。
第二,payload大小一定要限制。我设的是256KB上限,超过直接拒绝。大数据量的场景应该走对象存储,消息体里放引用地址,而不是把大块数据塞进消息里。
第三,先跑通一条topic再铺开。上线初期,我建议你只接入一条低频topic,跑一两个星期,把路由、重试、幂等、告警全验证一遍,再逐步接入其他topic。这会让你少踩很多坑,也方便你积累一套针对自己业务场景的默认参数。
实际运行这几个月,最让我感慨的是,Agent之间的触达问题虽然在架构图里只是一个不起眼的框,但它直接影响所有上游下游的稳定性和开发效率。把这块做扎实了,后续再做Agent编排、任务分发,地基不会晃。如果你也正在设计自己的Agent通信层,我建议把重心放在我反复强调的那几件事上:路由决策清晰、超时预算可计算、消息状态可查询、重试幂等双保险。这四个点踩实了,剩下的其实都是细节。