Agent-Reach:智能体消息触达系统的架构设计与实践 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_apisupports()用来判断当前请求是否适合这条渠道比如有些用户设置了“请用邮件联系我”适配器就可以根据这个偏好做路由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全部串起来。日志框架里都带上 RequestIdRedis 的 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 TPSAgent-Reach 初版上线的时候单机吞吐只有 200 TPS调度器线程池怎么调都不见涨。后来我用 JFRJava 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 的设计思路不一定适合所有团队但其中关于通道抽象、重试治理、可观测性、契约规范的思考是可以直接拿来用的。