搞大型模型应用这一年多,我有个很深的体会:单个智能体写得再聪明,也只是孤岛。真正麻烦的是多个智能体凑到一起之后,任务怎么准确送到该干活的那个手里,消息丢了怎么办,某个智能体假死怎么办,消息重复了怎么办。这些乱七八糟的问题,本质上都指向一件事——触达。我最近把这块从业务里单独抽了出来,做了一套叫 Agent-Reach 的东西,专门负责智能体之间的注册、路由和可靠送达。这篇文章就是这套系统的完整复盘,从设计思路到落地实现,再到我在实际运行里踩过的坑,全部摊开说。
1. 项目拆解:为什么需要 Agent-Reach
1.1 多智能体场景下的“触达困境”
先还原一个典型场景。你有一个智能体负责自然语言理解,一个负责查数据库,一个负责生成报表,还有一个负责发通知。用户说了一句“帮我把上周的销售数据整理成周报,发给项目组”。这句话一旦被拆解,就需要至少三个智能体接力完成。
问题来了。这些智能体平时是独立部署的,各自维护API、各自监听消息,谁也不知道谁在哪、谁现在活着、谁已经卡死。最简单粗暴的做法是让每个智能体都把自己收到任务的Webhook地址写死在配置里,互相直接调。早期确实能跑,但一旦任务变多、智能体变多,你马上会发现三个问题:地址变了没人知道,一个服务重启后队列里的任务全部失效,消息明明是成功发出去的,但接收方说自己根本没收到。
说白了,智能体之间缺一个“快递驿站”。寄件方不需要知道收件人的具体位置,只需要把包裹送到驿站,驿站负责任务到底由谁签收、有没有送达、送不到怎么退回。Agent-Reach 干的就是这个事。
1.2 Agent-Reach 想解决的核心问题
这套系统的核心不是“调度”,而是“触达”。一字之差,区别很大。调度解决的是“这件任务应该给谁做”的问题,比如查数据要去找数据库智能体,写周报要去找报表智能体。而触达解决的是“既然决定了要给谁,怎么保证对方真的接到、真的开始做、做完之后结果怎么回来”的问题。
Agent-Reach 的设计目标很明确,就四条:
- 动态注册:智能体上线后自动登记,下线后自动摘除,不再需要手工维护地址表。
- 可靠路由:任务按路由规则准确投递到目标智能体,不丢不重。
- 状态感知:能准确判断一个智能体是“忙但活着”还是“已经假死”,并据此调整投递策略。
- 结果回收:任务的执行状态、回执、失败原因都能追溯,而不是发完就石沉大海。
这四个目标定下来之后,整个系统的边界就清楚了。Agent-Reach 不做智能体的业务逻辑,也不替智能体做决策,它只保证“消息从 A 到 B 这件事本身是可靠的”。边界清楚,代码才不会越写越混乱。
2. 整体架构与关键选型
2.1 消息层设计:注册中心、路由表与连接保持
Agent-Reach 的架构从下层往上数,有三块基础组件。
第一块是注册中心。我直接用 Redis 的 Hash 结构存储智能体元信息,Key 是 agent_id,字段里放地址、能力标签、当前状态、最近心跳时间。为什么不用独立注册中心?因为对一个中小规模的智能体集群来说,Redis 已经足够,还能省掉额外运维负担。每个智能体启动时把自己的能力列表和地址写进去,后续心跳续租定期更新,这个 Hash 就是全系统最核心的“户口本”。
第二块是路由表。路由表不能被简化为单一映射关系,同一个智能体可能具备多种能力,多个智能体也可能具备相同能力。我更愿意把它看作是“能力到智能体集合”的倒排索引,查询时先按能力筛选候选集合,再根据负载和状态打分,选出一个最合适的目标。在实现里,接口地址和通道做分离处理,方便后续扩展。
第三块是连接保持层。每个智能体启动时主动连上 Agent-Reach 的网关,通过 WebSocket 建立长连接。这套设计有个好处——任务投递不再是“你随时来问我有没有新任务”,而是“我有一个任务马上塞给你”。你可以在连接层直接感知智能体的在线状态,比心跳更及时、更准确。心跳作为兜底,用来发现那些连接还在、业务已经卡死的“假活”实例。
2.2 触达通道:为什么不用中心化调度器
很多人在设计这类系统时会本能地想到中心化调度器,任务来了先进调度队列,调度器决定给谁、再调用对应服务接口。Agent-Reach 没有走这条路,核心原因是我始终想保持“触达层”的简单。
调度器一旦引入,就会不自觉做起“决策”的事,负载均衡策略放在这、优先级安排放在这、任务分片也放在这。组件越做越重,最后变成一个大泥球。而我想要的触达层,本质上是透明的信息管道,决策逻辑应该由上游业务或独立的策略模块来做,而不是由管道来做。
但“不做调度”不等于“完全平推”。Agent-Reach 在触达层做了三个很轻量的判断:第一是目标是否存在,第二是目标当前是否在线,第三是目标是否处于忙碌状态。这三项判断是保障送达率所必须的,不涉及“该不该由它做”的业务决策。如果目标离线或繁忙,系统会按策略缓存或者回退,而不是自作主张换人。
2.3 技术组件选型对比
下面是这套系统里核心组件的选型逻辑,供你们参考。
| 组件 | 选型 | 选型理由 | 踩过的备选方案 |
|---|---|---|---|
| 注册中心 | Redis Hash | 轻量、自带过期能力、无需运维新组件 | 独立注册中心,太重了;数据库直接存,频繁读写不划算 |
| 消息队列 | Redis Stream | 支持多消费者组、消息持久化、ACK机制 | RabbitMQ 胜在成熟,但多一层运维负担 |
| 连接保持 | WebSocket 网关 | 实时性好、双向通信,能感知连接状态 | 纯轮询方式实时性差,消息量上来之后浪费严重 |
| 去重表 | Redis Set + 数据库唯一索引 | 双重保险,避免消息重复 | 只靠业务幂等,风险太大 |
这个选型组合还有一个隐藏优势:全部组件都可以在裸机上跑,不需要额外依赖云服务。对于很多需要在隔离环境里私有化部署的项目来说,这是比较稳妥的方案。
3. 核心机制与实操要点
3.1 智能体注册与心跳机制
注册这件事,做的越靠后越麻烦。我第一次设计时就偷懒,把注册写成了“启动时调用一次”,结果智能体重启、扩容、迁移之后,地址全是旧的,消息发到了无人接听的端口。
正确的做法是把注册设计成“周期续租”。智能体启动后先写一次注册信息,然后每 20 秒发一次心跳,心跳时更新最近活跃时间戳并附带简单状态。Agent-Reach 每隔 60 秒做一次过期扫描,超过 TTL 没续租的智能体自动标记为离线,不再参与路由。
为什么心跳间隔定 20 秒,TTL 定 60 秒?这要算清楚。假设心跳丢失一次,服务端要等 TTL 时间才判定离线,也就是最坏情况下,从智能体真实宕机到被摘除,最多需要 60 秒多一点。对绝大多数业务场景来说,这个时间窗口可以接受。如果把 TTL 压得太短,比如 30 秒,那么一次网络抖动就会导致海量误摘除;压得太长,任务又会不断发给僵尸。
这个时间参数没有标准答案,要结合你的网络稳定性和业务容忍度去调。我自己的经验是先按“心跳间隔 × 3”设置 TTL,再观察一周的误摘率,慢慢微调。
3.2 任务路由与去重策略
任务进入 Agent-Reach 之后,会分配一个全局唯一的 message_id,然后按路由策略投递。投递语义我选的是 at-least-once,意思是“保证至少一次”,但因为网络异常、应用重启导致重复投递几乎必然发生,所以去重是刚需,不是可选项。
去重做两层。第一层在消息入口,用 Redis Set 做短期去重,把 message_id 存进去,设定过期时间,同一 ID 的重复投递请求直接拒绝。第二层在消费端,智能体收到消息后先查自己的去重表,处理过的直接返回已完成。这两层职责不同:入口去重是为了减少无效网络消耗,消费端去重才是真正的幂等保护。
这里有用户会问,App 层内存哈希不就行了吗?不行。智能体会重启,重启后内存里的去重记录全部丢失,之前处理过的消息会又被处理一遍。所以消费端的去重记录一定要落盘或者落到持久化存储里,至少也得是重启后能恢复的本地存储。
3.3 超时重试与死信队列
一个任务投出去了,怎么算成功?我的判断标准是:智能体收到后,必须在业务完成时提交一个回执,回执里包含执行结果或错误信息。如果 Agent-Reach 在超时时间内没有收到回执,就会进入重试流程。
重试不是简单地把消息重发一遍。要点在于退避策略和次数上限。我实测下来的一个稳妥序列是:失败后 1 秒重试第一次,然后 5 秒,然后 15 秒,之后每 60 秒重试一次,最多重试 5 次。为什么要逐渐拉长间隔?因为如果智能体只是瞬间卡顿,太长的等待会让任务悬置太久;如果它已经彻底挂了,频繁重试只是在浪费资源。
超过重试上限的消息,会进入死信队列。死信队列里的消息不能再自动重试,而是留给运维人员或者另一个补偿智能体去人工处理。这一步很反直觉——很多开发者会倾向于无限重试,觉得终有一天会成功。实际上,无限重试会让大量消息积压在队列里,新消息迟滞,旧消息又无法处理,最终整个智能体集群被“僵尸任务”拖垮。
死信队列的命名建议带上业务标识,方便定位,比如agent-reach:deadletter:report。每一条死信都要保留完整的原始数据和失败原因,排查问题全靠这些信息。
4. 落地实现:从零搭一套最小可用版本
4.1 基础组件与工程结构
这一节不讨论复杂的生产方案,只讲如何快速搭一套能跑的骨架,后续按自己的业务填充。工程上我分成四个部分:网关服务、注册中心服务、路由服务、消息队列。
- 网关服务:维护 WebSocket 连接,接收智能体的注册、心跳、回执,也用同一个通道推送任务。
- 注册中心服务:读写 Redis Hash,负责过期扫描和状态标记。
- 路由服务:根据能力标签和路由表选目标智能体。
- 消息队列:选 Redis Stream,生产者把消息写入队列,消费者组负责分发到目标智能体。
为了让最小版本跑起来,消息队列这一层其实可以先用 Redis Stream 顶住。智能体如果接收消息不走长连接,也可以改成消费者组轮询拉取,两种模式并存反而更适合扩展。
4.2 核心代码骨架
下面这段是注册和心跳的核心逻辑,用 Python 的 Redis 客户端演示。注意这不是完整工厂代码,只抓主干。
import time import redis r = redis.Redis(host="localhost", port=6379, db=0) AGENT_KEY = "agent-reach:registry" HEARTBEAT_TTL = 60 def register_agent(agent_id, address, capabilities): # 注册时将能力和地址写入 Hash r.hset(AGENT_KEY, agent_id, address) r.hset(f"{AGENT_KEY}:cap:{agent_id}", "capabilities", ",".join(capabilities)) r.hset(f"{AGENT_KEY}:state:{agent_id}", "status", "online") r.hset(f"{AGENT_KEY}:state:{agent_id}", "last_heartbeat", time.time()) # 设置 Hash 整体的 TTL,相当于自动续租 r.expire(f"{AGENT_KEY}:state:{agent_id}", HEARTBEAT_TTL) def heartbeat(agent_id): r.hset(f"{AGENT_KEY}:state:{agent_id}", "last_heartbeat", time.time()) r.expire(f"{AGENT_KEY}:state:{agent_id}", HEARTBEAT_TTL)这段代码看着简单,实际上藏着一个很关键的技巧:expire是每次心跳都调的。也就是说,只要智能体还在正常心跳,这个 Key 就一直存在;一旦它停止心跳,TTL 一到,Key 自动消失,服务端扫描时发现没有这个 Key,就判定离线。
路由投递的骨架大致是这样的:
def dispatch(capability, payload): # 1. 按能力找候选智能体 candidates = find_by_capability(capability) if not candidates: return {"status": "no_agent"} # 2. 过滤在线且不忙的实例 available = [c for c in candidates if is_online(c) and not is_busy(c)] if not available: return {"status": "queue_full"} # 3. 选一个目标并投递 target = choose_by_load(available) return deliver(target, payload)这段代码展示的是思路,真正生产环境里,find_by_capability需要支持多标签匹配,choose_by_load需要读取智能体的实时负载信息,并考虑消息优先级。
4.3 关键参数怎么定
参数设置直接决定系统在故障场景下的表现,下面是我推荐的一组初始值。
- 心跳间隔:20 秒。太密浪费带宽,太疏影响离线检测速度。
- 注册 TTL:60 秒。等于心跳间隔乘以 3,留足网络抖动的缓冲。
- 超时等待:30 秒。任务下发后等待回执的最长时间。
- 重试序列:1 秒、5 秒、15 秒、60 秒、60 秒,共 5 次。
- 死信队列保留时间:7 天。保留太久占存储,太短不好排查。
这组参数适合内部网络环境。如果是跨机房或者公网传输,心跳间隔建议放宽到 30 秒,而超时等待建议拉长到 60 秒以上。网络越不稳定,参数就越要“松”,但代价是故障感知变慢。
我曾经犯过一个错误,为了追求实时性把心跳间隔调到 5 秒,结果一次瞬断导致几百条消息发给了离线智能体,所有任务全部超时重试。从那以后我就明白了,参数不是越小越好,而要在延迟和误判之间找平衡。
5. 实战中的坑与排查实录
5.1 智能体“假活”:心跳正常但任务不响应
这是最让人头疼的问题。你检查系统状态,智能体明明在线,心跳也一直在续租,但任务投过去就是没有回执,直到超时重试。
排查下来最常见的原因是智能体内的事件循环被阻塞了。比如某个智能体在处理任务时,同步调了一个外部接口,这个接口响应非常慢,把整个事件循环卡住,心跳是定时器触发的,它也被卡住了。但是因为心跳走的是另外一个专用线程,或者连接层和业务层混跑导致阻塞堆积,系统侧的感知就滞后了。
解决办法是给连接层和业务处理层做隔离。心跳和消息接收放在独立的线程或进程,业务处理只消费队列里的任务,不要在回调函数里做重活。我在架构上把连接层和业务执行层拆成两个进程,中间用本地队列连接,这样即使业务层完全卡死,连接层依然能上报心跳,系统不会误判为离线。
但这里要注意,假活问题下“在线”并不等于“可用”。所以在路由筛选时,我会结合一个更细的状态字段,这个字段由智能体自己在每轮任务处理后更新,比如last_task_duration。如果这个值持续过大,路由服务的负载打分就会自动下调这个智能体的优先级,把新任务引到更健康的实例上去。
5.2 消息重复:从入口到消费端的完整防线
我遇到过最夸张的一次重复触达,发生在消息队列消费端。当时智能体在处理完一个长任务后提交回执,回执写库成功,但网络回应超时,Agent-Reach 判定失败并重试。同一套任务又被投了一遍,智能体把报表生成任务、提数任务全部又执行了一次,才在去重表里发现已经处理过,追悔莫及。
根因很简单,消费端只做了内存去重,智能体一重启就失效。后来的完整防线有三个节点:
- 入口去重,保证同一 message_id 不能重复进入队列。
- 消费端幂等键,在业务表里加唯一索引,重复插入直接失败或返回已有结果。
- 业务级补偿,比如生成报表前先按业务幂等键查询,已有结果直接复用。
这里建议大家优先用数据库唯一索引,不要只依赖代码里“先查再写”的逻辑。只要涉及并发,先查再写永远存在竞态窗口,唯一索引才是硬约束。
5.3 消息洪峰:触达通道被打崩之后
智能体集群在承接一次大促活动时,任务量能飙到平时的几十倍。触达通道如果还是单网关硬扛,很快会出现连接数打满、消息积压、心跳超时误判离线,整个系统陷入雪崩。
洪峰到来时的第一道防线是限流。Agent-Reach 在每个智能体连接上加了滑动窗口限流,单连接每秒最多接收 N 条任务,超出部分返回“忙碌”状态,由上层决定是延迟重试还是找备用智能体。
第二道防线是消息积压感知。Redis Stream 的消费者组天然支持积压,我实时监控pending数量和消费者组的延迟信息,一旦超过阈值,会自动把一个智能体的状态标记为“过载”,路由服务就暂时不往这个实例投递新任务。
很多人会问,为什么不直接扩容?扩容当然可以,但扩容有代价,而且容器调度需要时间。触达通道要解决的是扩容完成前这段时间的自我保护,而不是把一切都寄托在扩容上。
5.4 网络分区与脑裂:谁才是主节点
Agent-Reach 里如果引入了多个网关实例,就一定要考虑脑裂问题。比如两个网关同时认为自己是主网关,就会同时向同一个智能体投递同一批任务,造成大量重复消息。
我的处理方式是对网关节点做选主,只有主节点可以投递任务,从节点只保留连接和心跳数据。选主用 Redis 的分布式锁或者 etcd 都能实现,但更重要的是投递时带上全局递增的 epoch 号,从节点发现自己收到一条比自己 epoch 小的消息,直接丢弃。
这套机制在生产里效果还不错,唯一要提醒的是,选主本身要有超时保护。网络分区持续太久时,旧主节点要能自动退位,否则等分区恢复后会出现双主,整个系统的消息一致性就崩了。
6. 后续扩展与个人心得
6.1 给系统装上“监控眼”
Agent-Reach 跑起来以后,我越来越意识到可观测性的重要性。核心指标我建议至少盯四块:送达率、重试率、死信数量、智能体离线时长。
送达率是最重要的健康指标,低于 99% 就要立刻关注。重试率能侧面反映任务匹配度,如果某个智能体重试率特别高,多半是它的处理能力不足或者依赖的外部服务不稳定。死信数量单独看没意义,要结合死信原因分布来看,是超时居多还是业务报错居多,处理方式完全不同。
接入链路追踪之后,每个任务从入口到智能体再到回执的完整路径都能串起来。排查一次“用户说收到周报但实际没收到”的问题,原来要翻好几个系统的日志,现在直接按 trace_id 拉全链路即可。这个投入产出比非常高,值得做。
6.2 让触达策略规则化
早期版本的路由策略是写死在代码里的,改一个优先级都要重新发版。后来我把路由策略从代码里抽成了规则,支持在控制台上配置。
举个例子,消息分两类:一类是用户实时触发的,要求秒级响应;另一类是后台定时任务,可以容忍分钟级延迟。两条规则对应的路由策略完全不一样。实时类任务只投递给在线且空闲的实例,定时任务则优先投递给资源利用率低的实例,哪怕它当前有点忙。
规则化的本质,是把“触达什么”“怎么触达”变成配置而不是代码。但我不建议一开始就过度设计,先把硬编码跑通,摸清了业务模式,再抽象规则,否则你根本不知道该抽象哪些维度。
6.3 我在实操中的体会
做完 Agent-Reach 之后,我最深的感受是:触达这个东西,远没有看起来那么简单。消息传输的可靠性、网络抖动、智能体状态的一致性和消费端的幂等性,每个点拆开都是一门学问。真正做的时候,排错过程中最大的成本往往不是写代码,而是定位“消息到底卡在了哪个环节”。
如果再让我重新做一次,我会从一开始就把可观测性做进去,而不是等系统跑起来出问题了再补。另外就是去重表设计要提前和业务团队对齐,哪些字段是真正的幂等键,需要他们从业务角度来定义,而不是由技术侧单方面拍板。
目前 Agent-Reach 在我这边的业务里承担了十几个智能体的通信底座,日常运行还算稳定。后续我计划把路由规则做成白盒可视化,让同事们能自己在界面上调整触达策略,而不是每次都要来找我改配置。这个系统仍然在演进,但核心思路我认为已经很稳固了:把通信和数据传输问题与智能体业务解耦,让每一方都只关心自己该关心的事。
如果你也在做多智能体系统,我建议你先别急着上复杂的调度框架,先把手里的触达问题梳理清楚。把送达、去重、超时、重试这些基本功做扎实,你会发现后续加什么功能都稳很多。