☰
Agent-Reach:智能体消息触达系统的架构设计与实践
2026/10/28 4:05:25 网站建设 项目流程

1. 项目概述:Agent-Reach 到底是什么

这几年大模型和智能体(Agent)的热度一直没降过,朋友圈里十个技术博主有八个在聊 Agent,但要真问一句“你做的 Agent 跑起来之后,靠什么去触达用户、触达系统”,很多人就答不上来了。Agent-Reach 这个项目,说白了就是专门解决这个“最后一公里”问题的——做一套让智能体能够稳定、高效、可控地触达目标对象的系统。

我在一线做服务端架构和自动化系统差不多十年了,接触过的 Agent 项目从简单的问答机器人到复杂的多智能体协作系统都有。看得多了就会发现一个规律:模型能力决定 Agent 的上限,但触达能力决定 Agent 能不能真正落地。你让 Agent 分析了一堆数据,最后要通知用户、调用接口、更新工单、推送消息,这些都是“Reach”的范畴。Agent-Reach 就是把我这些年沉淀下来的触达方案,整理成了一个可以独立部署、可扩展的系统,它适合正在做 Agent 产品化、运营自动化、或者想做消息触达中间件的团队参考。

这个项目的核心价值有三块:第一,统一了触达通道,不管你底层接的是邮件、短信、Webhook 还是内部系统接口,对上层 Agent 暴露的都是同一套语义;第二,内置了智能调度和失败重试机制,解决了“触达不可靠”这个老大难;第三,做到了全链路可观测,每次触达从产生到送达都能追踪到。对团队来说,上了这套东西之后,最直接的感受就是不再需要为每一种通知渠道单独写一套调用逻辑,Agent 的迭代效率明显提升。

2. 整体设计与方案选型思路

2.1 为什么选择“通道抽象 + 调度分离”的架构

最初设计 Agent-Reach 的时候,我其实纠结过两种路线。第一种是轻量方案,直接写一批工具函数,Agent 需要发消息的时候调一下,代码简单,但后续每加一个渠道就要改业务代码,链路追踪、重试、幂等这些横切逻辑也会散落在各处,属于典型的“能用但不好维护”。第二种就是现在采用的“通道抽象 + 调度分离”架构,把触达能力从业务里抽出来,独立成一个服务,上层 Agent 只需要描述“我想让谁、通过什么方式、收到什么内容”,剩下的由系统负责。

我最终选择第二种,核心原因是触达这个场景天然适合集中治理。大家可以类比一下快递物流:每个快递员(Agent)不需要自己开车去机场拉货,只需要把包裹放到驿站(Agent-Reach),驿站统一做分拣、路由、配送、签收确认。如果每个快递员都自己去跟航空公司、物流公司打交道,那整个系统早就乱套了。通道抽象层就是那个驿站,它屏蔽了底层各个渠道的差异,统一了消息格式、鉴权方式、调用协议,让上层 Agent 面对的是一个极简的接口。

调度分离的好处则体现在可靠性上。触达这个动作天然是不可靠的:网络抖动、第三方接口限流、对方系统宕机,这些都不是业务方能控制的。把调度独立出来以后,重试、退避、死信处理这些逻辑就有了统一的归属地,排查问题的时候只需盯着调度器的日志就能还原完整的时间线,不需要在多个业务服务里翻来翻去。

2.2 技术选型:求稳不求新

Agent-Reach 的技术栈我故意选得比较“无聊”。消息队列用了 RocketMQ,没有去追 Kafka;存储用了 MySQL 加 Redis,没有引入什么图数据库、时序数据库;核心语言是 Java 21,用 Spring Boot 3.x 搭的骨架。原因很简单,触达系统最看重的是稳定和可排查,团队招人也好交接也好,这些基础组件大家都有经验,踩坑的成本低。

RocketMQ 在这里承担的是削峰和异步解耦的角色。Agent 提交触达请求之后,请求直接写入队列,调度器异步消费。为什么不能同步调用?因为一旦某个渠道(比如短信服务商)响应很慢,同步阻塞会直接拖垮 Agent 的响应时长,用户等 3 秒还不见回复,体验就很糟糕。引入消息队列之后,Agent 的接口响应控制在 50ms 以内,真正的投递在后台完成,用户感知是“消息发出去了”,实际上后台还在排队、重试、选路,这在异步化方案里非常常见。

Redis 用在两个地方:一个是分布式锁,保证同一个任务不会被多个调度节点重复消费;另一个是频控计数器,记录每个用户在单位时间内收到的消息数量,用来防止触达风暴。这两个功能其实都可以用别的方案替代,但 Redis 实现起来最轻,性能也够用,就没必要上更重的组件。

2.3 数据模型设计的几个关键决策

数据模型是 Agent-Reach 的基石,这块设计得不好,后面做调度、做追踪都会很痛苦。我最终抽象出了三个核心实体:触达任务(Task)、触达记录(Attempt)和渠道账号(ChannelAccount)。

Task 表记录的是一次完整的触达意图,字段包括任务ID、接收方标识、渠道类型、消息模板ID、业务标签、优先级、创建时间等。Task 是幂等单位,同一个 Task 不管重试多少次,接收方只会收到一次业务效果(在支持幂等的场景下)。

Attempt 表记录的是每一次实际的投递尝试,和 Task 是一对多的关系。每次尝试的响应码、耗时、错误信息都会落在 Attempt 表里。这两张表分开是我从运维实践中总结出来的教训:如果只留一张表,Task 重试 5 次就会产生 5 条记录,业务查询“这个任务到底发出去了没”会变得很绕。拆开以后,Task 管业务视角,Attempt 管技术视角,各查各的,边界清晰。

渠道账号表这个设计可能有人会觉得多余,直接把账号密码写在配置里不行吗?我在多个项目里被“账号配置散落各处”坑过。渠道账号经常会有多个,比如你有 3 个短信签名、2 个邮件域名,如果不抽象成表,每次切账号就得改代码或配置,还要考虑账号透传怎么处理。Agent-Reach 把账号做成数据表之后,上层 Agent 可以指定“用电商组的短信签名”,也可以不指定,由系统按权重自动分配,灵活性高了很多。

3. 核心实现:通道适配器与智能调度

3.1 通道适配层:把“触达”这件事儿统一起来

通道适配层是 Agent-Reach 的核心抽象。我定义了一个统一的触达接口,所有渠道实现这个接口,上层代码对渠道的具体类型是无感的。接口大致是这样的:

public interface ChannelAdapter { String channelType(); boolean supports(ReachRequest request); ReachResponse send(ReachRequest request) throws ChannelException; }

channelType()返回渠道标识,比如 "email"、"sms"、"webhook"、"custom_api";supports()用来判断当前请求是否适合这条渠道,比如有些用户设置了“请用邮件联系我”,适配器就可以根据这个偏好做路由;send()是真正发送消息的方法。

每个渠道适配器内部要处理的内容比接口看起来复杂得多。以邮件适配器为例,它内部要处理的事情包括:根据用户画像选择发件域名(避免同一域名发太多被封)、根据消息内容选择文本模板或富文本模板、调用邮件服务商的 API、解析异步回调的送达回执。这些逻辑如果全部堆在业务代码里,那业务代码会膨胀到根本没法维护。

Webhook 适配器则稍微特殊一点,因为它的目标不是某个用户,而是某个系统接口。它需要处理签名、鉴权,还要根据目标系统的接口规范把消息体序列化成对应的 JSON 结构。很多系统接口很“挑剔”,字段名多一个少一个都会报错,Webhook 适配器就得把“消息语义”翻译成“目标系统的字段语义”。我在设计时把这个翻译动作单独抽了一个映射层,每接入一个新系统只需写一份字段映射配置,不用动代码。

3.2 消息编排:让 Agent 用自然语言描述触达意图

Agent 怎么调用 Agent-Reach?这是我在设计时反复打磨的地方。最开始我让 Agent 直接构造 JSON 请求体,字段多、含义晦涩,Agent 的幻觉率高,经常漏字段或者填错字段类型。后来我换成了“意图解析 + 模板填充”的模式。

具体做法是:在 Agent-Reach 中预置一批消息模板,每个模板定义了变量位和渲染规则。Agent 只负责传递结构化意图,比如“通知用户张三,他的订单 O20241201 已发货,预计 3 天到达”,系统通过模板解析,自动确定渠道是短信(因为用户偏好是短信)、内容格式是订单发货通知,然后填充变量,生成最终的消息。

这个模式的好处显而易见:模板即约束,Agent 的自由发挥空间被圈在模板范围内,出错概率大幅下降。模板管理本身也是做成配置化的,运营人员可以在后台页面里维护,不需要改代码。我在实际项目中看到过 Agent 生成了带有明显错别字或者奇怪标点的消息,模板化之后这类问题几乎绝迹了。

3.3 调度器:优先级、重试与退避策略

调度器算得上 Agent-Reach 里最容易写崩的一个模块。先说优先级,不同触达任务的紧急程度天差地别:验证码短信晚到 30 秒用户就会骂人,而周报推送晚 10 分钟没人会在意。所以我在 Task 里设计了优先级字段,调度器消费的时候按优先级排序,高优先级的先执行。

但优先级不能是绝对的,否则低优先级任务会被饿死。我参考了网络报文调度里的加权公平队列思路,给每个优先级配了一个权重,比如高优先级占 50% 的调度额度,中优先级占 30%,低优先级占 20%,保证低优先级任务在高峰期也能被分到处理机会。

重试策略这块我踩过不少坑。无脑重试会形成雪崩,重试太保守又会导致消息丢失。我的做法是把重试分成两个阶段:第一个阶段是快速重试,间隔 1 秒、2 秒、4 秒,最多 3 次,这个阶段主要应对瞬时抖动,比如目标系统短暂的 500 错误、网络闪断;第二个阶段是慢速重试,间隔 5 分钟、15 分钟、1 小时,最多 3 次,应对目标系统长时间的故障。

public class RetryPolicy { private static final int[] FAST_RETRY_DELAYS = {1, 2, 4}; private static final int[] SLOW_RETRY_DELAYS = {300, 900, 3600}; public long nextDelay(int attemptCount, boolean fastPhase) { int[] delays = fastPhase ? FAST_RETRY_DELAYS : SLOW_RETRY_DELAYS; int index = Math.min(attemptCount, delays.length) - 1; return delays[Math.max(index, 0)] * 1000L; } }

两次重试之间任务状态会先标记为“重试中”,然后由 RocketMQ 的延迟消息机制重新投递到队列。这个方案的好处是重试任务不占线程,调度器始终用少量线程处理大批量任务。为了这个延迟消息,我最初还用 Redis 过期监听来实现,后来发现 RocketMQ 自带延迟级别就直接切过去了,少维护了一套逻辑。

3.4 频控与防风暴:保护用户也保护下游

触达系统最容易犯的一个错就是“想发什么就发什么”。用户一天收到 20 条通知,第一反应不是觉得服务贴心,而是直接卸载应用。Agent-Reach 内置了多级频控:单用户频控、单渠道频控、全局频控。

单用户频控的规则是:同一个用户在一小时内最多收到 5 条触达消息,一天最多 20 条,超出部分直接进“静默队列”,等待下一个窗口再发。单渠道频控是为了保护渠道服务商的限额,比如某个短信服务商每分钟最多允许 100 条,超出就会被封号,所以 Agent-Reach 在渠道层面做了一个令牌桶限速。全局频控则是兜底,防止系统出现 bug 导致消息风暴把所有下游都打挂。

频控的粒度一开始是放在调度器层做 check,后来我发现高并发下会有竞态问题——两个线程同时检查到“剩余额度 1”,然后都放行,额度就变成负数了。这个问题的解法是 Lua 脚本原子递增,检查加扣减放在同一个脚本里执行,Redis 单线程特性保证了原子性。

4. 可观测性建设与稳定性治理

4.1 全链路追踪:从 Agent 意图到送达回执

可观测性是我在 Agent-Reach 上最舍得花时间的地方,因为触达系统出了问题,用户不会告诉你“你的消息没发出来”,他们只会说“为什么我没收到验证码”。每当这种客诉出现,如果没有链路追踪,排查起来就相当于大海捞针。

Agent-Reach 在收到触达请求时生成一个全局唯一的 RequestId,这个 ID 会贯穿整个生命周期:从 Agent 调用的入口、消息队列的 MessageId、Task 表的任务 ID、Attempt 表的每次尝试 ID,全部串起来。日志框架里都带上 RequestId,Redis 的 trace 模块也记录关键节点的耗时。这样,用户投诉“没收到短信”的时候,我拿手机号搜一下 Attempt 表,就能看到这条消息在 14:23:15 提交、14:23:16 第一次发送返回成功、14:23:17 收到短信回执确认送达。整个链路清清楚楚。

我还做了一个简单的实时大屏,把最近 5 分钟的触达成功率、平均延迟、各渠道的分布画成曲线图。它不是给管理层看的,而是给值班的人扫一眼用的——哪个渠道的曲线突然往下掉,马上能定位到是渠道故障还是消息量暴涨。有了这个界面之后,线上告警的数量反而降了,因为很多问题在大屏上提前露出苗头了。

4.2 熔断与降级:下游挂了不能把我拖死

触达系统面对的下游都是外部依赖:短信服务商、邮件服务商、企业内部系统的 Webhook。外部系统的稳定性不是我们能控制的,可能突然就大面积故障了。如果没有熔断机制,Agent-Reach 会在下游故障期间疯狂重试,既浪费自己的资源,又可能把本来已经脆弱的下游进一步打挂。

Agent-Reach 用的是经典的熔断状态机:关闭、开启、半开。在关闭状态下,连续失败次数达到阈值(默认 10 次),熔断器打开,直接拒绝所有请求,快速失败返回“渠道暂不可用”,同时启动一个定时探测任务;在开启状态下,每隔 30 秒允许一个探测请求通过,如果探测成功,熔断器半开,逐步恢复流量。这个逻辑用一个轻量级的 Hystrix 风格组件实现,结合 Redis 存熔断状态,多个调度节点共享熔断判断。

降级则是另一层保险。有些消息不是非要实时到达的:运营通知类的消息晚了 5 分钟发没人会在意,但在熔断期间如果直接丢弃又显得不专业。我的做法是降级到“异步缓慢发送”,把消息积压在本地的本地队列里,等下游恢复之后再慢慢消化。注意这里的本地队列是用磁盘存储的,不能放内存,否则进程重启就会丢消息。

4.3 死信队列与“消息黑洞”治理

即使做了重试和熔断,依然会有一些消息无论如何都发不出去,比如接收方的手机号是空号、邮箱域名不存在、Webhook 地址持续返回 401。这些消息如果一直占着重试队列,会拖垮调度器的效率,所以必须要有“放弃机制”。

我定义了最大重试次数(6 次),超过之后任务进入死信队列(DLQ),同时触发一个业务回执:通知上游 Agent 这条触达失败,并且把失败原因归类为“永久失败”或“临时失败”。永久失败的不再重试,由人工处理,比如运营人员把错误号码捞出来修正;临时失败的进入待人工确认的队列,运维看到告警之后去手动解决。

这套机制上线之后,“消息发出去了但用户说没收到”的情况大幅减少。有一个很重要的经验:不要相信第三方渠道的“成功响应”。很多短信服务商的 HTTP 200 只代表他们收下了消息,不代表用户手机上收到了。真正的成功标志是渠道异步返回的送达回执,Agent-Reach 在状态流转里专门处理了这个差异。这一点不踩一次坑是真的记不住。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

我把 Agent-Reach 实际运行中遇到的典型问题整理成了一个速查表,团队新同学接手排查时照着看就能省很多时间。

现象可能原因排查方法解决方案
消息发出后用户没收到渠道异步回执失败查 Attempt 表最终状态是否为 CONFIRMED配置回执回调接口,以回执为准
同一用户收到重复消息消费端重复消费查 Task 的幂等键是否唯一消费时先查 Redis 分布式锁再执行
高并发下频控失效竞态条件导致额度超扣检查频控脚本是否原子执行改用 Lua 脚本保证原子性
某个渠道突然大量超时该渠道被限流或故障看熔断器状态、渠道错误码分布触发熔断,切换备用渠道
消息延迟超过 10 分钟调度线程池被打满查调度器线程池活跃线程数和队列长度调大线程池,优化消费速度
重启后消息丢失缓存队列未持久化确认本地队列是否落盘本地队列改为文件存储或直接走 MQ

这个表格的价值在于它是从真实故障里提炼出来的,每一条背后都有对应的线上事故。比如“重启后消息丢失”那一条,是我在压测时发现杀掉进程之后有少量消息凭空消失,排查半天发现是把待发送消息放在了内存队列里。改成文件落盘之后再测试,重启恢复的时候从磁盘重新加载,消息一条都不会丢。

5.2 性能调优心得:从 200 TPS 到 2000 TPS

Agent-Reach 初版上线的时候,单机吞吐只有 200 TPS,调度器线程池怎么调都不见涨。后来我用 JFR(Java Flight Recorder)做了性能剖析,发现瓶颈根本不在调度逻辑本身,而是 JSON 序列化。每一条消息在发送前要做模板渲染、结构化日志、消息体序列化,框架用的 Jackson 在每次序列化时都会通过反射读取字段,这在高频调用下开销巨大。

优化的手段是引入了预编译的 JSON 序列化器,把消息体的数据结构用record定义,配上手写的序列化逻辑,性能提升了将近 3 倍。后来我又把模板渲染做了缓存,同样的模板和变量组合,渲染结果直接走缓存,不再重复跑字符串拼接逻辑。经过这两轮优化,单机吞吐稳定在 2000 TPS 以上,已经能满足绝大多数中大型团队的需求。

不过这里要提醒一句:性能调优要适可而止,不要为了快而牺牲可读性和可维护性。Agent-Reach 的核心目标是可靠,吞吐够用就行,多出来的性能指标对业务没有实际意义。我见过一些团队把精力都花在“每秒能扛多少条消息”上,反而忽略了“消息到底有没有准确送达”这个根本问题,方向就偏了。

5.3 Agent 与触达系统之间的契约设计

最后分享一个比较容易被忽略的点:Agent 和 Agent-Reach 之间的契约设计。早期版本里,Agent 直接往系统里塞自由文本,结果输出格式五花八门,解析经常出问题。后来我制定了严格的契约规范:Agent 必须输出结构化的触达意图,包含接收方标识、业务类型、模板编号、变量字典。只要结构正确,Agent 用什么模型、什么 prompt 都不重要。

这套契约相当于给 Agent 立了一条规矩:你可以自由发挥你的“思考”能力,但输出必须符合系统接口的约束。很多做 Agent 应用的团队会把精力都放在模型本身,忽略了系统边界对 Agent 的约束价值。实际上,跟 Agent 打交道的系统,边界越清晰,Agent 的出错率就越低。Agent-Reach 从设计之初就把“契约优先”作为一条铁律,这让后续接入各种不同的 Agent 框架都变得很从容。

契约还做了版本管理,每个模板和每个接口字段都有version属性。Agent 侧升级的时候,只需要带上的版本号,系统会自动路由到对应的渲染逻辑。这个设计在 Agent 频繁迭代的团队里特别实用,不用每次 Agent 改版都拉着 Agent-Reach 上线,两边各做各的,版本对齐即可。

6. 写在最后的一点经验

Agent-Reach 这个项目从立项到现在,我最大的感受是:做触达系统,真正的难点不在于把消息发出去,而在于把不可靠的外部世界包装成对上层可靠的服务。这需要你认真对待每一个失败的分支,认真设计每一次重试的间隔,认真记录每一条消息的轨迹。

我个人在实际操作中比较受益的一个习惯是,坚持每次触达都记录业务标签。比如一条短信带上“ORDER_SHIPPED”、一条邮件带上“WEEKLY_REPORT”,后续做数据分析时就可以按业务类型统计送达率、打开率、用户退订热力图。这些数据反过来又能指导 Agent 的触达策略优化——哪些用户不喜欢邮件,哪些渠道深夜送达率低,都是可以调优的点。

如果你们团队也在做 Agent 相关的产品,建议花点时间想清楚“触达”这个问题。Agent 能不能产生价值,最后一步一定落在用户真正感知到、系统真正执行到。Agent-Reach 的设计思路不一定适合所有团队,但其中关于通道抽象、重试治理、可观测性、契约规范的思考,是可以直接拿来用的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询