1. 为什么会有Agent-Reach这个项目
Agent-Reach是我近期在推进Agent类应用落地时,顺手沉淀下来的一个内部项目代号。名字里的"Agent"指的是AI Agent,也就是那种能自主规划、调用工具、执行任务的智能体;"Reach"取的是"触达、可达、覆盖"的意思。整个项目想解决的事情很纯粹:当Agent完成了一次分析、巡检、审批或者异常处置之后,它到底怎么把人、群、系统这三类目标真正地触达到位。你要是正在做AI Agent应用开发,或者在做自动化运维、IM机器人集成,这篇文章应该能给你不少可以直接抄作业的东西。
先说一个我实际碰到的场景。有个舆情分析Agent,每天晚上十点跑定时任务,把当天的热点话题、负面情绪数据汇总成报告。Agent本身跑得没问题,数据也整得挺漂亮,但报告生成之后,推送通知的逻辑散落在好几个脚本里,有的走企业微信机器人,有的走邮件,有的干脆写死了某个群机器人的Webhook地址。改一次配置要翻三四个文件,某天群机器人被误移除之后,报告连续发了两天都没人发现。这就是触达层的失控。
再说第二个场景。多个Agent需要往同一个群里面推消息,一个做告警的,一个做数据日报的,一个做流程审批的。三条通道各自维护自己的Webhook配置、重试逻辑、错误处理,代码重复率接近八成。一旦要增加一个新的通知渠道,比如加一个飞书机器人,就得把所有Agent改一遍。这种模式下,Agent的能力越强,触达侧就越乱。
第三个场景是异步触达的反馈问题。Agent发起一条耗时较长的任务,比如让另一个Agent去深挖某个话题的上下文,等到结果回来,用户可能已经离开对话了。任务到底成功触达没有,用户有没有看过,要不要重推,这套逻辑过去完全依赖人的经验,系统层面没有任何反馈机制。这三个问题叠在一起,我意识到Agent缺的不是更聪明的大脑,而是一层统一、可靠、可观测的触达基础设施。Agent-Reach就是在这样的背景下立项的。
1.1 从三个真实需求说起
第一个需求是统一入口。我想要的不是再写一套通知库,而是让Agent的上层逻辑只关心"我要触达什么内容、触达给谁、有多紧急",至于走飞书还是钉钉还是邮件,由触达层去路由。这样Agent侧的代码可以收敛到只负责业务决策,不再关心渠道细节。
第二个需求是可靠投递。过去Webhook调用经常是发完就忘,网络抖动、渠道限流、Webhook地址失效,这些异常根本没被捕获。Agent-Reach要做的是一套带重试、带退避、带回执解析的投递机制,让每条消息都有明确的投递结果。
第三个需求是可观测和可运营。谁在什么时候推了什么消息,触达率是多少,哪个渠道的失败率最高,这些数据要能从系统里拉出来。否则触达问题永远只会在用户抱怨"没收到消息"的时候才暴露。
1.2 这个项目适合谁来参考
如果你正在做Agent类产品的开发,这个项目的定位和架构可以直接迁移。如果你在维护一套自动化运维系统,里面会有大量巡检告警、工单通知的触达场景,Agent-Reach的分层思路、幂等去重、退避策略同样适用。如果你只是想在IM群里接入一个机器人,做一些自定义通知,那消息信封的设计和渠道适配器示例也能帮你少走弯路。这个项目不依赖任何特殊的硬件或者独家资源,所有组件都是开源通用的,单机部署也能跑起来。
2. 整体架构与方案选型
Agent-Reach的架构可以分为五个逻辑层:接入层、策略层、执行层、渠道层和触达对象。接入层面向调用方,提供HTTP API和Python SDK;策略层处理幂等、去重、限流、优先级调度;执行层负责异步任务消费、重试和回执管理;渠道层以适配器模式对接各个IM平台和通知系统;触达对象就是最终要触达的人、群和外部系统。
一次典型的触达流转是这样的:Agent调用接入层API,提交一条消息信封;策略层先做幂等判断,如果这条消息在时间窗口内已经投递过,直接返回重复结果;接着把消息写入消息队列;执行层的工作进程从队列里拉取消息,调用对应渠道的适配器完成发送;适配器把渠道返回的回执解析成统一结构写回数据库。整个过程对Agent的调用方来说,就是一次HTTP请求的事。
2.1 分层架构与各层职责
接入层是整个系统的门面,统一暴露/v1/send接口,接收JSON格式的消息信封。这一层最重要的原则是"薄"——它只做参数校验、消息入队和基本的权限校验,不做任何业务逻辑。厚逻辑如果放在接入层,会让接口的响应时间变得不可控,也容易把渠道相关的问题提前暴露给调用方。
策略层是Agent-Reach里最有价值的部分。幂等去重在这个项目里不是可选项,而是必选项,因为Agent场景下同一个业务事件经常被多个环节触发,比如流程审批中状态变更会同时触发回调、Webhook和定时轮询三条路径,如果不去重,用户会收到三条一模一样的通知。限流和优先级调度也在这里完成,优先级高的告警消息优先出队,低优先级的日报类消息在高峰期主动降速。
执行层用的是Celery的prefork workers,任务消费从"每类消息一个专门脚本"变成了"一个通用消费者管所有触达"。它的好处是让重试、回执、死信处理这些逻辑只写一遍,而不是每个Agent各写各的。
渠道层每个适配器负责一类外部系统,飞书、钉钉、企业微信、邮件、自定义Webhook各一个。适配器只做三件事:把统一信封翻译成该渠道的报文格式、发送请求、把渠道返回结果解析成统一回执。渠道相关的异常,包括限流码、鉴权错误、网络超时,都在这一层被归类成标准错误类型。
2.2 为什么选Redis Streams做消息队列
选型时对比过RabbitMQ、Kafka和Redis本身的List结构。RabbitMQ功能完整但部署和运维成本高,Kafka适合高吞吐日志类场景,对Agent触达这个量级来说属于杀鸡用牛刀。Redis List的LPUSH/BRPOP简单,但缺乏消费确认机制,消息拉走后Worker崩溃就会丢失。
Redis Streams是折中的完美方案。它支持消费组,多个Worker可以并行消费同一个Stream,每条消息只有被XACK确认之后才算处理完成,Worker崩溃未确认的消息会自动重新投递给其他消费者。对于Agent触达这个量级,Redis Streams提供了足以媲美专业消息中间件的可靠性,同时部署成本几乎为零。
如果触达消息量突然从几百条涨到每天几十万条,Redis Streams仍然能扛住,只是消费组的表现会稍有下降,到时候再平滑迁移到RabbitMQ成本也不高。消息队列的选型主要看团队运维能力,选一个当前阶段最顺手、数据最可靠的方案,比一步到位上重型组件更实际。
2.3 执行层的任务消费模型
Celery的prefork worker模式非常契合Agent触达的流量特征:平时消息量很低,一天几百条;一旦发生告警风暴,会在几分钟内涌入几千条。prefork模式下消息并行处理,单条消息的阻塞不会拖垮整个队列,配合Redis Streams的消费组机制,可以水平扩展Worker实例。
Worker消费消息之后会先执行业务过滤逻辑,再调用渠道适配器。一个容易忽视的细节是,Worker必须做"慢渠道隔离"——邮件渠道的耗时往往在几百毫秒甚至几秒,如果让它和IM渠道共用同一个Worker进程,IM消息的延迟会被邮件拖慢。我的做法是给邮件渠道单独的Worker队列,用不同的路由键区分。这个细节在消息量小的时候看不出来,一旦邮件服务商抖动一次,整个触达链路的延迟都会飙升。
3. 核心模块设计与关键参数推导
这一节是Agent-Reach的技术核心。消息信封、幂等去重、重试退避、渠道适配器这四个模块,几乎决定了整个触达系统的可靠性和可维护性。每个模块里的参数都不是拍脑袋定的,我尽量把推导过程写清楚,方便你根据自己的场景调整。
3.1 消息信封设计
所有触达请求统一走消息信封结构,这是整个系统最基础的数据契约。信封里不直接塞Webhook地址,而是声明触达意图,由策略层去路由。这样做的好处是,将来要更换渠道或者增加渠道,上游Agent的代码一行都不用改。
{ "version": "1.0", "msg_id": "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5", "biz_fingerprint": "review_flash_20260610104000", "channel": "feishu_group_notice", "topic": "alert", "priority": 1, "payload": { "title": "巡检异常", "content": "节点A连续三次健康检查未通过", "link": "http://ops.example.com/alert/12345" }, "policy": { "idempotent_ttl_seconds": 86400, "max_attempts": 5, "backoff_base_seconds": 1, "backoff_cap_seconds": 600 } }msg_id是每次请求的唯一消息标识,用于链路追踪,但注意它不承担幂等职责。biz_fingerprint是业务指纹,是幂等去重的核心字段。topic用于消息分类和策略路由,比如alert、report、approval各有不同的去重窗口和优先级。payload是真正要展示的内容,适配层会把它翻译成对应渠道支持的消息格式。policy里配置了这条消息的投递策略,每条消息可以单独指定重试次数和退避参数,这样告警消息和日报消息可以采取完全不同的投递策略。
信封设计还有个细节:不要把业务数据以外的内部字段塞进payload,比如渠道的Webhook地址、密钥、回调参数,这些属于路由信息,应该在策略层解析信封后注入。否则一旦日志泄露或者消息体被打印到终端,安全风险会很大。
3.2 幂等去重的实现机制
重复触达在某些时候比不触达更可怕。舆情报告推两遍用户还能忍,但告警消息重复推三遍,值班人员很可能把真正的问题当成噪声忽略。Agent-Reach的幂等策略抛弃了简单的消息ID去重,改用"业务指纹 + 时间窗"的组合。
import hashlib import json def build_fingerprint(channel: str, topic: str, payload: dict) -> str: raw = json.dumps({ "channel": channel, "topic": topic, "content": payload.get("title", "") + payload.get("content", "") }, ensure_ascii=False, sort_keys=True) return hashlib.sha1(raw.encode("utf-8")).hexdigest()指纹计算样本里的关键点是:sort_keys=True保证字段顺序不影响指纹结果,ensure_ascii=False保证中文字符的哈希计算稳定。指纹生成后,用Redis的SETNX命令写入去重键,带上过期时间。如果SETNX返回1,说明是首次触达,放行;如果返回0,说明在时间窗口内已经投递过同类事件,直接返回"重复跳过"。
去重窗口按消息类型区分,不是一刀切。告警类消息窗口短,一般300秒,防止同一个告警在持续期间反复触发后全部被吞掉;日报类消息窗口长,24小时,同一份日报重复触发时只推一次。审批类消息的窗口居中,取3600秒。窗口长短的影响我实测过:窗口太短,重复消息容易穿窗;窗口太长,用户主动要求重推时会碰到被去重拦截的情况。针对后者,我在接入层加了一个allow_duplicate开关,人工触发重推时绕过指纹去重。
3.3 重试退避的参数推导
重试策略是所有调度系统里最容易拍脑袋的部分,而参数定得不合理,要么造成消息风暴,要么在渠道临时故障时直接放弃。Agent-Reach采用指数退避加全抖动的方式,公式是:
delay = min(backoff_cap, backoff_base * 2^(attempt - 1)) * random(0.8, 1.2)
从第2次尝试开始计算退避。假设base=1秒、cap=600秒,第一次失败后延迟在0.8到1.2秒之间,第二次失败后延迟在1.6到2.4秒之间,第三次3.2到4.8秒,第四次6.4到9.6秒,第五次以后封顶在480到720秒之间。加全抖动的好处是防止多个Worker同时重试时形成惊群效应,把渠道的限流阈值再次撞上;纯指数退避没有抖动,同一时间失败的消息会在同一时刻集中重试,第二次打挂渠道的概率很大。
不同的渠道类型需要不同的最大重试次数,我实测后定下的配置是:
| 渠道类型 | 最大重试次数 | 说明 |
|---|---|---|
| 飞书群机器人 | 5 | 第1、2次成功率极高,重试价值大 |
| 钉钉群机器人 | 5 | 同上 |
| 邮件SMTP | 3 | 重复投递容易被服务商标记垃圾 |
| 自定义Webhook | 按业务配置 | 内部系统可以放宽到8次 |
重试次数达到上限之后,消息进入死信队列,同时向运维群推一条告警,而不是直接丢弃。死信队列里的消息可以人工重推,也可以等渠道恢复后自动补投。这个机制在渠道长时间故障时非常重要,至少消息不会无声无息地丢失。
3.4 渠道适配器的统一接口
每个渠道适配器实现三个方法,这是所有渠道的公共契约:
class ChannelAdapter(ABC): @abstractmethod def send(self, envelope: dict) -> Receipt: """发送消息,返回统一回执结构""" @abstractmethod def parse_receipt(self, response) -> bool: """解析渠道返回结果,判断是否投递成功""" @abstractmethod def health_check(self) -> bool: """渠道健康检查,供监控系统定期调用"""以飞书自定义机器人为例,send方法负责把信封翻译成飞书富文本消息结构,发出POST请求;parse_receipt读取飞书返回的code字段,code=0表示成功,非零码则映射到自定义异常类型,比如限流码触发重试,鉴权码触发凭据刷新;health_check则每隔5分钟调用一次飞书的获取应用信息接口,探测渠道连通性。
适配器的价值在于隔离变化。以后飞书改版也好,新增一个新的IM平台也好,上层逻辑完全不用动,只要新增一个Adapter类并注册到适配器工厂即可。我用类名做路由键,读取配置文件动态加载,实现新渠道接入时零修改。
4. 三个典型场景的实测记录
4.1 定时巡检通知
第一个压测场景是定时巡检通知。一个Agent每5分钟检查一批服务节点的健康状态,异常时触发告警消息。实测跑了三天,处理消息总量2048条,投递成功率99.8%,端到端延迟P50为1.8秒,P95为4.1秒。其中4条失败消息全部是目标群机器人短时不可用导致的,退避重试后在第2次成功。
巡检场景对延迟的容忍度较低,所以我在策略层为巡检告警设置了最高优先级,优先于日报和审批消息出队。这也是整个系统里优先级参数第一次真正发挥作用的地方。如果没有优先级队列,大批日报消息会挤在同一个队列里,直接把告警的延迟拉到10秒以上。
4.2 审批流提醒
第二个场景是审批流提醒,消息总量758条,投递成功率100%,P50延迟2.2秒,P95延迟3.5秒。这个场景最值得说的数据是去重率21.3%,也就是说每5条触达请求里超过1条是重复事件。原因是同一个审批实例在状态流转中存在驳回、同意、撤回等多个节点,每个节点都会触发一次Webhook回调,而业务事件本质是同一条。指纹去重在这里发挥了关键作用,否则用户会被同一审批的通知烦到崩溃。
审批流场景还暴露出一个时间显示问题。某个Agent推送的截止时间显示比实际时间早了8小时,查了老半天发现是时区处理不一致:Agent生成内容用的东八区时间,适配器发送时却按UTC时间做了格式化,两边没对齐。后来我统一了规范,所有内部存储统一用UTC时间戳,仅在展示层根据接收方时区转换。
4.3 高频舆情推送
第三个场景是高频舆情推送,消息总量1520条,投递成功率99.5%,P50延迟4.6秒,P95延迟12.8秒。这个场景的延迟明显高于前两个场景,原因是舆情推送的目标账号在IM平台有一定频控限制,短时间内大量消息触及了账号维度的限流。退避机制在这里的作用非常明显,前两次发送失败后,适配器捕捉到限流特征码,自动进入指数退避,第三次或第四次发送成功,没有一条消息最终进入死信队列。
实战中我还发现,同样是限流,有的是Webhook维度,有的是账号维度,有的按分钟计数,有的按小时计数。适配器如果只处理一种限流,其他限流码会漏网。现在每个渠道适配器都维护一张"限流特征码表",把渠道可能返回的各种限流情况全部映射到统一的重试策略上。
5. 踩坑记录与排障速查
跑Agent-Reach的过程中,踩过的坑不少,很多问题表面看是触达系统本身的问题,实际上却是隐藏在链路深处的其他环节。这里挑四个最典型的,按踩坑的先后顺序说。
5.1 延迟尖刺:DNS解析才是隐藏瓶颈
上线头几天,P95延迟偶尔会突然从2秒飙到15秒以上,重启Worker后又能恢复正常。一开始怀疑是Redis队列堆积,但监控数据显示队列深度只有几十条;怀疑是渠道侧波动,但IM平台的状态页显示正常。折腾到后面开了网络抓包,才发现每次发送前DNS解析耗时从500毫秒到8秒不定。问题出在部分Worker节点使用系统默认DNS配置,解析外部域名时走了慢路径。解决办法是在Worker内部做一次启动时的域名预解析,同时用连接池长连接复用,之后P95降到4秒以内,再也没出现过15秒的尖刺。
5.2 消息乱序:并发消费者惹的祸
第二个坑是消息乱序。按时间顺序产生的两条触达消息,接收方有时会先看到后面那条。追查后发现,有两个Worker同时消费同一个Topic,其中一个Worker处理第一条消息时失败了,消息重新入队,而另一个Worker已经处理完了第二条,于是顺序反转。解决方案是不再让多个Worker自由竞争同一个Topic,而是按Topic做一致性哈希,把同一个Topic的消息稳定绑定到同一个Worker实例上。这样牺牲了一部分并发度,但业务场景里大多数Topic的消息量很小,绑定带来的吞吐损失可以忽略,换来的是严格的顺序保证。
5.3 Webhook鉴权静默失败
第三个坑是渠道Webhook鉴权静默失败。飞书自定义机器人的访问凭证没配过期提醒,结果某天凭证到期之后,所有推送请求都返回了鉴权错误,但因为适配器当时只判断了HTTP状态码为200就认为成功,错误码被当成正常响应处理了,整天的报告都推了,但一条都没真正送达。修复方式很直接:适配器的parse_receipt增加对业务错误码的检查,凡是返回的code不为0且不在重试白名单里的,一律标记为失败;同时在适配器层加了凭据到期时间的预警任务,提前三天给运维群推提醒。静默失败比失败更可怕,失败至少能触发告警,静默失败则是数据黑洞。
5.4 排障速查表
经过这些踩坑,我整理了一份排障速查表,遇到问题可以快速定位出发方向。
| 故障现象 | 排查方向 | 处理方法 |
|---|---|---|
| 延迟偶发飙升到10秒以上 | Worker节点DNS解析、连接池复用 | 域名预解析,使用连接池 |
| 消息到达顺序错乱 | 并发消费者数量、Topic分配策略 | 按Topic一致性哈希绑定Worker |
| 偶发丢消息,日志无异常 | 渠道业务码未解析、回执判定过宽 | 补全错误码映射,严格判断回执 |
| 时间显示比本地早/晚若干小时 | 时区处理不统一 | 内部统一UTC存储,展示层转换 |
| 同一业务事件收到多条通知 | 幂等窗口过期、指纹生成不稳定 | 调大去重TTL,检查指纹字段排序 |
| 渠道恢复正常后旧消息丢失 | 死信机制缺失 | 加入死信队列和人工重推入口 |
6. 后续扩展与个人体会
Agent-Reach目前的形态已经能稳定支撑我这边几个Agent的全部触达需求,但它还有很明显的扩展空间。我下一阶段准备做三件事:一是把触达结果作为一个信号回传Agent,让Agent根据"用户有没有点击、有没有回复"调整后续策略;二是做一个简单的触达编排层,支持把一条触达拆成多个步骤按顺序发送,比如先推摘要、等用户确认后再推全文;三是给不同Agent之间提供一条共享触达总线,让多个Agent可以通过同一条消息链协作,避免各自为战。
聊一个最后的小体会。做Agent-Reach最大的收获不是技术上的,而是认知上的:Agent类系统的价值,很大程度取决于它对外部世界的触达能力。一个规划得再完美的Agent,如果它的输出只能躺在日志里,那它对真实业务就没有任何影响。把触达层当做一个独立的基础设施来建设,在项目初期看起来是"多做了一层",等到Agent数量多起来、渠道复杂起来、消息量涨起来之后,你会发现当初这个决定省下了无数重复劳动和线上事故。