ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent-Reach:为AI Agent构建可靠统一的消息触达层

Agent-Reach:为AI Agent构建可靠统一的消息触达层 1. 为什么我会动手写 Agent-Reach 这个项目先聊一下背景。过去一年我做了好几个大模型 Agent 相关的应用从简单的 RAG 问答助手到能调用外部 API 的复杂工作流 Agent过程中发现一个特别尴尬的问题Agent 本身再聪明最后总得有个“出口”去触达真实世界里的用户。你可以让 Agent 在后台分析数据、生成文案但如果它没法把结果主动推给需要的人那这整套系统就只是个高级玩具。我最初的做法是给每个 Agent 单独接一个 IM 机器人或者邮件发信接口。第一个 Agent 用了 Slack Webhook第二个 Agent 又接了钉钉机器人第三个项目客户要求走短信网关结果每个渠道的逻辑都散落在业务代码里。等 Agent 数量变多之后情况彻底失控每个渠道的发送限制不一样、消息格式五花八门、失败重试逻辑写得天差地别更别提回执同步和送达状态统计这种细节几乎全靠运气。这个项目 Agent-Reach 就是为了解决这个问题才动手做的。它的定位很简单一个面向 AI Agent 的统一触达层。你可以把它理解成 Agent 与外部世界之间的消息网关Agent 只需要把一个结构化消息交给 Agent-Reach剩下的事情选择渠道、参数拼接、发送、重试、状态回传全部由它接管。如果你正在做智能外呼、私域触达、定时提醒这类 Agent 应用或者正在被多渠道消息通知折磨这篇文章应该能给你一些参考。我会把这套系统从设计到落地的关键环节拆开讲包括整体架构、适配器设计、可达性判断以及我踩过的几个典型坑。需要提前说明的是Agent-Reach 这个名字里“Reach”强调的不是“到达”这个瞬间动作而是“可触达能力”这个整体概念。判断一条消息是成功送达还是只是发出去了这才是 Agent 触达场景里最需要认真对待的问题。2. Agent-Reach 的整体设计与核心思路2.1 从“代理发送”到“统一触达”的架构转变很多人第一次接触 Agent-Reach 时会误以为它只是一个消息转发代理就是把 Agent 的请求转给微信或邮件网关。实际上如果只是转发那思路还是太浅了。传统的消息代理只有一个输入口和一个输出口而 Agent-Reach 更像一个中枢神经系统它需要同时面对“上游 Agent 发送请求”和“下游各个渠道服务商”两层复杂性。我设计这个系统时列了一个核心等式Agent 只关心“发给谁、说什么、期望什么时间送到” Agent-Reach 负责“选哪个渠道、怎么拼消息体、如何处理失败、如何上报结果”举个例子一个销售线索触达 Agent 产生的请求可能是{ user_id: u_10023, channel: auto, template_id: welcome_lead, template_data: { customer_name: 陈晨, product_name: 企业版订阅 }, priority: high }这里channel写成auto意思是让 Agent-Reach 根据用户历史偏好和渠道可用性自动选择发送通道。Agent 不需要知道自己是通过邮件还是短信发出去的它只需要表达“我想触达这位用户”的意图。这个设计把业务语义和渠道细节彻底解耦。增加一个新渠道时Agent 侧的代码零改动只要在 Agent-Reach 里注册一个新的渠道适配器就行。这套思路和我之前做支付系统时用到的支付网关模式很像关键在于“统一抽象、插件扩展”。2.2 技术选型背后的三个理由技术栈上我最终选了 Node.js TypeScript搭配 Redis 做消息队列数据存储用了 PostgreSQL。其实最初也考虑过用 Python 写毕竟团队里不少人更熟 Python但我最终坚持用 TypeScript 有很现实的原因。第一这套系统未来大概率要暴露一组 HTTP API 给各个 Agent 服务调用。TypeScript 的类型系统能让 API 请求体、响应体、错误码的定义变得非常严格比裸写 Python dict 要可靠得多。尤其是消息对象这种字段多、嵌套深的协议类型约束能避免很多低级错误。第二Node.js 的异步 I/O 能力很适合同时管理大量连接。邮件发送虽然是基于 SMTP 协议但现代邮件服务商一般提供 HTTP API短信和 Webhook 更是天然走 HTTP。在 Node.js 里并发处理几百个 HTTP 出站请求非常轻松不会像同步阻塞模型那样轻易把线程池打满。第三Redis Stream 是天然的轻量级消息队列。每条 Agent 请求进来之后我会先写到 Redis Stream 里再由 worker 消费。这样即便瞬间涌入上千条触达请求也不用担心服务被压垮。PostgreSQL 则是用来存消息状态、模板、渠道配置的这类结构化数据用关系型数据库最顺手。架构简化之后是这样的Agent 应用 → Agent-Reach API 入口 → 消息校验与模板渲染 → 渠道选择器rate limit 检查 → Redis Stream 队列 → Worker 消费消息 → 对应渠道适配器 → 外部渠道API → 回执状态更新 → 事件回调给 Agent2.3 统一消息模型为什么选了“意图 模板 数据”三段式很多消息系统喜欢直接让调用方传入最终文本比如 “发送短信陈晨你好你订阅的企业版即将到期”。这么做看起来省事但后续会遇到一堆麻烦。首先是同一条触达逻辑往往需要同时支持邮件和短信两种渠道而是邮件要求标题、正文、纯文本版本短信要求70字以内的简洁文案同一个内容根本没法直接用。所以 Agent-Reach 采用了“媒体模板”的思路。每个模板是有版本的模板里允许配置不同渠道下的不同渲染结果。一套触达意图对应一个模板模板内部可选择为不同终端定制不同文案。仍然用上面的例子welcome_lead这个模板可能这样定义{ template_id: welcome_lead, versions: { email: { subject: 你好{{customer_name}}, body: p欢迎关注我们你关注的企业版订阅有专属优惠.../p }, sms: { body: {{customer_name}}企业版订阅专属优惠已为你预留 } } }调用方传入的template_data会填充模板变量每个渠道拿到渲染后的独立文本。这套三段式模型的好处是业务意图用户 ID 模板 ID和呈现层完全分开同时 Agent-Reach 内部还可以对渲染后的消息做合规检查、敏感词过滤、字数限制校验这些能力不需要调用方去关心。3. 渠道适配器的设计与核心实现3.1 适配器接口每个渠道只做四件事Agent-Reach 的渠道适配器接口被设计得极其简单。它只要求每个渠道实现四个能力配置校验、消息发送、送达状态查询、渠道配额检查。TypeScript 接口大致长这样interface ChannelAdapter { readonly channelName: string; validateConfig(config: Recordstring, unknown): Promisevoid; send(message: OutboundMessage): PromiseSendReceipt; queryStatus(messageId: string): PromiseDeliveryStatus; checkQuota(): PromiseQuotaInfo; }之所以把接口收敛得像这个很关键是因为渠道差异真的非常大。邮件渠道可能需要处理附件、退信解析短信渠道需要处理号码前缀、发送频率限制Webhook 渠道又要处理目标鉴权。如果接口定义得太具体适配器的公共部分就很难抽取。在实现邮件适配器时我优先使用了 Amazon SES 和 SendGrid 这类 HTTP API 服务而不是直接基于 SMTP 协议发邮件。主要原因是 HTTP API 天然支持返回 messageId、事件回执、退信通知这些对 Agent-Reach 的可达性判断非常重要。原始 SMTP 协议虽然不花钱但很难拿到精准的送达回执。短信渠道则优先对接国内主流云服务商的 HTTP 接口再包装一层本地接口保证上层代码调用统一。短信服务商之间的差异更多体现在签名、模板审核、发送限频上。我在适配器里特意加了“渠道限速器”用的类似 token bucket 算法确保每个渠道的 QPS 不会被打爆。Webhook 适配器是最灵活的它适用于一些内部系统间的触达。举个例子Agent 发现某条订单需要人工审核可以往内部工单系统的 Webhook 地址推一条消息。这个渠道的送达判断只看 HTTP 响应码是否在 200-299 区间之后是否真的被处理往往需要配合业务系统的回调去判断。3.2 渠道选择器根据历史到达率动态决策如果说适配器是触达系统的手脚那渠道选择器就是它的大脑。Agent-Reach 里有一个ChannelSelector模块它的输入是用户 ID、期望到达时间、消息优先级输出是一个排好序的渠道列表。这个模块在做动态渠道推荐时依赖一个比较粗粒度的评分模型。每个渠道都有基础权重然后叠加用户历史渠道行为因子。比如某个用户过往邮件打开率一直很高那邮件的分数会高一些如果短信渠道最近退订率高对应权重就会被降低。我用的评分公式没有搞得很复杂大概是这样score baseWeight(channel) userChannelAffinity(user, channel) - channelPenalty(channel) // 当前限流剩余比例这个公式里userChannelAffinity是从 Redis 里的用户触达画像中读取的每隔一段时间异步更新。channelPenalty则反映该渠道当前的健康度也融入了限流因子。例如某个用户最近 30 天内通过邮件打开了 8 封营销邮件、点击了 3 次评分就会高。但如果 SMS 渠道今日已发送 1200 条接近配额上限则该渠道的channelPenalty会明显上升自动排到更后面去。这个选择器解决了此前一个很头疼的问题永远只发一种渠道导致用户在一个渠道上触达疲劳。引入多因子评分后Agent 的触达行为变得自然了一些用户不会一而再再而三地收到同一种渠道的通知。3.3 模板渲染引擎中的变量注入与渠道适配模板渲染引擎看着简单做深了才知道坑很多。第一版我直接用了字符串替换结果没多久就出事了模板变量里如果包含 HTML 特殊字符渲染出来的页面很容易被注入脚本如果模板变量包含了{{customer_name}}这种字面量又可能导致变量替换错乱。后来我换用了严格的两阶段渲染方案。第一阶段做“变量分离”模板里的变量名会被解析成 AST然后从template_data中提取值第二阶段按渠道进行输出编码。邮件正文走 HTML 场景时所有文本字段会被转义短信场景则会做纯文本清洗并确保最终短信长度在商用渠道限制范围内。同时我在模板数据校验入口做了一层 JSON Schema 校验。每当调用方传入template_dataAgent-Reach 会先对照模板声明里的字段类型、必填项、长度上限做一次验证。这样做能提早暴露问题而不是在消息快要发出时才因为缺字段而失败。这里有值得注意的一点模板变量名必须禁用to、channel、template_id这类保留字段。有一次我遇到一个特别诡异的 Bug调用方在template_data里传了一个to字段结果消息被路由到了错误的目标地址。排查了很久才发现是模板引擎把保留字段覆盖掉了。现在我在引擎最前面加了保留字段清理逻辑发现即报错。4. 实操过程从零搭建 Agent-Reach 的关键环节4.1 消息生命周期与 Redis Stream 队列的设计Agent-Reach 里每条消息的状态流转是很明确的。我把一张消息表里维护了这几个状态PENDING、SENT、DELIVERED、FAILED、EXPIRED、CANCELED。新请求进来之后消息先写入 PostgreSQL主键是系统生成的message_id状态为PENDING。随后这条信息的 ID 会被推入 Redis Stream 的一个队列中消费者从 Stream 读取消息再执行渠道发送。之所以这样拆是因为 PostgreSQL 写入是强一致的而 Redis Stream 是为了削峰填谷。消费者拿到消息 ID 后会去数据库重新加载完整消息体避免 Stream 里带着大字段导致 Redis 内存压力。然后进行渠道选择、模板渲染、发送前检查。整个过程如果成功消息状态变为SENT再等渠道回执来了处理线程会更新为DELIVERED或FAILED。还有一类消息是定时触达。比如 Agent 决定在第二天早上九点给某个用户发一条提醒。我不会直接让 Agent 自己 sleep 到那个时间而是把消息的expected_at字段设置为未来的时间戳通过一套定时扫描机制把到期的消息推入 Stream。实现方式倒不复杂PostgreSQL 里加了expected_at索引每分钟扫描一次到期记录配合FOR UPDATE SKIP LOCKED防止并发重复拉取。实际跑下来这套设计比较平滑。即使 Redis 短暂故障只要 PostgreSQL 消息还在重启消费者后可以继续从数据库捞取到期消息不会导致大量消息丢失。4.2 重试机制指数退避与合理重试窗口消息发送失败是常态。渠道服务商不稳定、用户取消订阅、网络抖动都会导致发送失败。Agent-Reach 里的重试不是我一开始想象的那样无脑重试三次而是分了两类一类是瞬时失败另一类是永久失败。瞬时失败比如 HTTP 503、超时、限流系统会给重试机会。我设置了最多 5 次重试间隔依次为 30 秒、2 分钟、10 分钟、30 分钟、2 小时。这么设计是为了避开高峰拥堵也避免在短时间造成大量无效请求。重试是持久化的消息在数据库里记录了attempt_count和next_retry_at消费者扫描到期消息时如果发现这条记录重试次数没超会重新拉起来发送。永久失败则直接标记为FAILED并触发事件回调。比如邮件主题被判为垃圾邮件、短信目标号段不存在、Webhook 目标返回 410 GONE都属于永久失败。这些消息不会进入重试队列而是进入“死信表”方便人工或者后续 Agent 介入。模块里还有一个“失败收敛”的细节同一种模板、同一种渠道连续失败多次时会自动把模板状态标记为“不健康”。这时候 Agent 再拿该模板发起请求Agent-Reach 会返回一个 400 错误并提示模板异常不让无意义的请求继续流入队列。这个设计在早期版本里没有后来遇到邮件模板被内容服务商严格限制时系统一大堆任务在排队失败才逼着我加了这层保护。4.3 可达性判定送达不等于真正“Reach”Agent-Reach 里最重要、也最容易被忽略的概念是Reach可达。很多人以为发出去了就算触达成功但一个邮件在收件人垃圾箱里躺着对业务来说什么都没发生。我做了三个层次的判定第一层是“发送成功”SENT指请求被渠道服务商接受。对邮件来说这代表 SMTP 请求发出并被服务商接收对短信来说代表运营商网关拿到了消息。第二层是“投递成功”DELIVERED指渠道服务商确认消息已送达到目标。邮件这里靠邮件服务商的送达回执短信靠运营商回执。这一层已经比 SENT 更可靠但仍不能代表用户一定看到了。第三层是“交互完成”INTERACTED这一步需要对接业务侧的回传。比如用户点击了邮件里的链接或者短信里附的 H5 页面被打开这些事件通过回执 API 回调给 Agent-Reach最终标记为一次真正的 Reach。我特别想强调前两层只能证明“送达链路没问题”第三层才决定业务成功。现在我做触达系统时都会建议调用方尽可能接入交互回传否则 Agent 永远不知道自己是有效触达还是纯属骚扰。实现上Agent-Reach 提供了一个POST /v1/events的回执接收接口。外部系统比如客户端的 SDK可以上报message_id event_type timestamp。系统中的事件聚合器会对同一message_id的去重处理和状态更新。这样 Agent 就能通过极简的 API 查询“这个用户触达成功了没”。4.4 完整的请求处理链路示例这里给出一个最简化的调用示例帮助你把上面的流程串起来。假设 Agent 想给某用户发送一条订单到期提醒curl -X POST https://agent-reach.internal/v1/messages \ -H Authorization: Bearer $TOKEN \ -H Content-Type: application/json \ -d { user_id: u_10023, intent: order_expiry_notice, channel: auto, template_data: { customer_name: 陈晨, order_id: ORD-20240218-001, expiry_days: 3 }, metadata: { agent_session_id: as_9f8d7c } }这个请求进入 API 层后会先通过校验。intent字段对应系统中一个模板模板里定义了不同渠道的渲染文本。然后渠道选择器根据分数选出最优渠道假设选择的是 email。系统会从模板表中加载邮件主体渲染得到一封信然后通过邮件 HTTP API 发出。发送成功后的回执里有provider_message_idAgent-Reach 会把该 ID 和本地message_id关联起来后续事件回调都用这个关系对应上。外部用户点击了邮件里的链接邮件服务商发送 webhook 事件到 Agent-Reach 的/v1/events系统将状态更新为INTERACTED。此时 Agent 服务可以通过轮询收到最终状态或者订阅 Agent-Reach 提供的 webhook 回调。整个链路最复杂的部分始终是状态同步。而 Agent-Reach 把所有复杂性封装在内部调用方感知到的只是“发起请求 - 查询最终状态”这一点正是我认为它作为中间件最大的价值。5. 常见问题与排查技巧实录5.1 为什么明明发送成功用户却收不到消息这个是我被问得最多的问题基本每个月都会遇到。场景通常是邮件发送显示成功SENT 状态也更新了但用户死活收不到。排查时核心要看两个方向一是被服务商过滤二是进入了垃圾箱。我在 Agent-Reach 里加了送达状态回执后排查效率提升明显。如果渠道服务商返回了DELIVERED但用户还是看不到那就是收件端过滤问题多半是域名信誉不够或者邮件内容触发反垃圾规则。此时需要注意几个点检查发件域名有没有配置 SPF、DKIM、DMARC检查邮件的 message-id 和发送时间是否正常避免在文本里使用“发票”“点击领取”“免费”这类高频垃圾词。对于短信渠道类似问题是“信号到达但目标号码收不到”。很多时候是因为发送号码本身状态异常或者运营商服务商做了内容审核拦截。我的经验是发送失败时不要只盯着接口返回码要主动查看渠道侧的错误摘要比如是否有“退出 complaint”标记规则、号码是否处于 DND 状态。5.2 渠道被限流或 IP 被拉黑的应对做营销类 Agent 触达时渠道限流和 IP 信誉下降都是无法回避的问题。尤其是大批量发送时邮件服务商会对新域名有一个信任建立过程俗称“预热”。如果一上来就跑几十万封几乎必然会收到一堆退信和投诉渠道配额也会被收紧。我的做法是系统中加入“发送节奏自动控制”。新部署的域名前 3 天只发送平时 10% 的量之后逐步递增。这个功能完全实现于 Agent-Reach 里通过渠道配置里的ramp_up_rate字段控制状态存储在数据库里。这样做虽然看起来牺牲了一点效率但从长期来看渠道稳定性远比单次送达率重要得多。另外多渠道之间要做“压力分散”。假设每天需要送达 2 万条消息我不会全部走同一家服务商而是按配置比例分散到两三家。虽然这样会增加一部分成本但能明显降低单一渠道拉黑的概率。Agent-Reach 里每个渠道有一个每日配额字段到达配额后会自动切到下一个备选渠道。5.3 模板渲染时数据为空或长度爆掉的坑模板渲染时最容易踩的坑就是变量为空。比如customer_name为空字符串最终渲染出来的邮件可能是“你好”——这既不专业也容易被垃圾邮件过滤器扣分。系统里我强制要求在模板定义中声明哪些变量是必填的不满足就直接拒绝发送。而短信渠道对长度限制特别敏感。国内短信签名加内容超长部分有可能会被拆分甚至被服务商截断。我在模板渲染后加了一个字符统计步骤短信超过上限时优先自动精简固定短语比如把“尊敬的客户您好”压缩为“您好”如果精简后仍超过上限就触发渠道切换转而从邮件渠道发送完整版内容。5.4 定时任务积压导致消息延迟定时触达场景下消息积压会导致“该 9 点到的消息 10 点才到”这对强时效性的业务比如验证码、抢购提醒打击很大。原本我用每分钟扫描一次数据库后来迅速遇到性能瓶颈。后来我把扫描逻辑改成“分层扫描”短期任务每 10 秒扫一次长期任务每 5 分钟扫一次。定时消息的expected_at距当前越近扫描频率越高。同时扫描 SQL 里用了FOR UPDATE SKIP LOCKED这是解决多消费者并发拉取同一批消息的关键。如果没有这个细节多个 worker 会同时拉到同一条消息导致重复发送。那之后积压情况好了很多。但还是建议在业务侧约定定时触达的精度控制在 1 分钟以内即可不要强求秒级触发因为很多渠道通道天然会有 5-10 秒的链路延迟强求秒级只是给自己添堵。5.5 日志追踪全链路 message_id 贯穿排查任何问题都离不开日志。Agent-Reach 从入口开始就有一个不可变字段message_id所有内部日志、外部渠道请求参数、回执事件回调里都会带上这个 ID。这样在查问题时只需要用一条 message_id 就能把整条生命周期串起来。我在项目中养成了习惯只要用户反馈“消息没收到”第一时间在日志系统里搜 message_id看卡在哪一层。如果请求都没发出去那多半是队列消费或模板解析的问题如果发出去了没回执则是渠道服务商侧的问题如果回执显示送达但没点击则是内容或者目标用户的问题。6. 实测下来的经验总结与扩展方向Agent-Reach 第一版从设计到上线差不多花了两个多月中间重构了两次。第一次重构是因为我发现统一模板模型比我想象中复杂渠道适配器的接口设计太细导致每个渠道都要复制大量重复代码。第二次重构则集中在限流与渠道选择器的整合最初的限流逻辑分散在每个适配器里后来统一收敛到中央模块整体代码量反而少了 30%。我个人建议如果你也想做类似系统不要一上来就追求支持十个渠道。先打磨好邮件和 Webhook 两个走通“模板渲染—发送—回执—状态更新”这条主链路。等到核心链路稳定了再往里面加短信、IM 机器人这类渠道。因为渠道每多一个不只是多一个适配器那么简单还会引入配额管理、内容策略、不同语言习惯适配等一堆周边问题。Agent-Reach 后续的扩展空间还挺大。比如把模板渲染的结果接入 A/B 测试框架对不同触达文案做版本控制或者在渠道选择器里引入更完整的用户触达疲劳度模型避免过度打扰。另一个方向是把触达效果数据反馈给 Agent 本体让 Agent 根据历史触达率动态调整提醒策略。比如说如果某类用户对邮件毫无反应Agent 就应该少发类似提醒多尝试其他沟通方式这套闭环其实比单纯做触达网关有意思多了。如果你已经有一个 AI Agent 应用在跑建议尽早考虑把触达这一层独立出来。很多问题当下看只是发送失败的小事等业务量上来之后会迅速放大成渠道管理混乱和消息状态不可查的困境。统一化处理虽然前期要多写一些代码但长期绝对是值得的。
返回列表