☰
Agent-Reach:事件驱动智能触达编排层的设计与实践复盘
2026/10/28 22:51:14 网站建设 项目流程

做告警和用户触达这件事做了几年,我一直觉得“推送”和“触达”是两回事。推送是把一条消息发出去,触达是让一个该被通知的人,在合适的时机、通过合适的渠道、用合适的方式,真正get到信息并且产生反应。这两年Agent概念火了之后,我开始尝试把智能体的决策能力放进触达链路里,做了一个代号叫“Agent-Reach”的事件驱动智能触达编排层。这篇文章就把这套系统的设计思路、核心链路、参数计算和踩坑过程完整复盘一遍。

1. 先厘清一件事:为什么“通知”和“智能触达”是两码事

1.1 传统通知系统的三个死穴

我在早期做过不少推送系统,也接手过别人留下的通知中台。这类系统表面上看起来没什么问题——业务方调一个接口,传入用户ID和内容,系统把消息塞给推送服务。但用上一段时间,问题全冒出来了。

第一个死穴是模板固定。告警就是告警,催缴就是催缴,话术写死第一次之后,就永远不变了。用户请假时收到催办通知,深夜收到非紧急告警,周末收到运营推送,没人觉得哪里不对,因为系统根本不知道“此刻这个用户到底适合接收什么”。

第二个死穴是只发不收。消息推出去之后,用户有没有看、有没有处理、有没有因为这条消息产生后续动作,系统一概不关心。触达的闭环断在“发送成功”这一步,但发送成功和触达有效之间隔着十万八千里。

第三个死穴是渠道割裂。短信、App推送、邮件、站内信、IM机器人,每个渠道独立接入、独立管理。业务方如果想把一次告警同时路由到优先级高的渠道,只能自己写一堆if else。

这三个死穴叠加起来,导致的结果就是:消息发得越多,用户越烦;系统功能越全,维护越乱;数据沉淀越多,越不知道怎么用。

1.2 Agent-Reach 的能力边界

Agent-Reach 做的事情可以概括成一句话:把“发消息”升级成“由智能体决策的一次触达任务”。

它的核心流程是:接收业务事件,经过规则引擎和智能体联合决策,生成一个包含目标用户、渠道序列、话术内容、发送时机、幂等键的触达计划,然后交给渠道适配层执行,最后把用户反馈回收回来,进入下一轮决策。

这里要强调一下边界,否则很容易把系统做大做杂:

  • Agent-Reach 不替代业务系统,它只消费业务事件,不感知业务内部的复杂状态。
  • Agent-Reach 不做用户画像挖掘,画像数据由上游提供或从反馈中积累。
  • Agent-Reach 不替用户做最终决定,触达内容里涉及的钱、权、责仍然需要人工审核链路。

一句话定义:它是一个位于业务系统和用户之间的智能触达编排层,具备“感知事件-制定计划-执行触达-回收反馈”的完整能力环。

1.3 适合接入的场景清单

这套思路适合的场景远比告警系统广。我梳理了几个典型的接入场景,做了一个对比表:

场景传统做法Agent-Reach 做法
IT 运维告警固定模板推送,所有级别一视同仁根据告警级别、值班表、故障影响面决定通知谁、用什么话术、是否升级
电商挽回新客首单后无脑发券根据用户活跃轨迹和商品偏好生成个性化召回,自动选择低打扰渠道
SaaS 留存到期前固定发“续费提醒”结合使用频率和功能偏好,把提醒包装成“新功能建议”或“使用周报”
金融账单提醒到期日统一短信轰炸根据历史还款习惯和渠道偏好,选择安静的提醒方式,避免逾期焦虑
内部协同每件事都@所有人根据角色、日程、紧急程度路由给真正需要处理的人

关键判断标准就一条:这个场景是否同时存在“事件动态变化”“用户状态动态变化”“渠道可选”这三个特征。如果三个都满足,就值得用Agent-Reach来做;如果只是定时发固定内容,用传统定时任务就够了,硬上智能体反而增加复杂度。

2. 核心链路拆解:事件进来之后,五个步骤完成一次触达

2.1 事件网关:统一事件模型

Agent-Reach 的第一步是事件网关。所有上游系统的事件——无论是监控告警、订单状态变更、用户行为触发,还是定时任务触发——都会先进入这里,被转换成统一的事件模型。

我的做法是定义一个标准的TouchEvent数据结构,所有事件都必须符合这个结构才能进入决策流程。这样做的好处是,决策引擎不需要关心事件来自哪个系统,只需要关心事件本身的属性。

# 事件统一模型(简化版) from dataclasses import dataclass, field from datetime import datetime from typing import Any, Dict, List, Optional @dataclass class TouchEvent: event_id: str # 全局唯一事件 ID event_type: str # 事件类型,如 alarm_new / order_timeout / user_churn_risk priority: int # 业务优先级 1-5,5 最高 occurred_at: datetime # 事件发生时间 source: str # 来源系统标识 subjects: List[str] # 可能被触达的对象 ID 列表 payload: Dict[str, Any] # 业务载荷,决策引擎可读取 tags: Dict[str, str] = field(default_factory=dict) # 环境标签,如 env=prod

事件网关里还要做三件事:归一化、聚合、风暴抑制。

归一化指的是把不同系统的字段名、时间格式、级别标识统一成内部标准。比如A系统用“P0”表示最高级别告警,B系统用“fatal”,C系统用数字“1”,这些都要在网关层吃掉差异。

聚合是指把短时间内同一对象的同类事件合并成一个复合事件。比如一台服务器5分钟内触发了20次CPU告警,如果每条都进决策链路,用户会在一分钟内收到一堆相似消息。我在聚合窗口内保留第一次事件作为主事件,后续事件在载荷里累加计数,决策引擎看到的是一次“持续了X分钟的CPU告警”,而不是20条碎片。

风暴抑制是指在事件速率超过阈值时直接降级。这个逻辑虽然简单,但非常重要:当整个机房都在告警时,最先要做的是先阻断触达,再逐层放行。

2.2 决策引擎:规则优先,模型兜底

事件转换成统一模型之后,会进入决策引擎。这一层是整个Agent-Reach的核心,我的设计原则是“规则优先,模型兜底”,不是所有事情都交给大模型裁量。

规则引擎负责处理那些确定性强的场景。比如“优先级为5的告警必须立即通知值班负责人”“用户已经明确关闭了营销触达开关,任何营销类事件都不允许生成触达计划”“当天触达次数已达上限,只能走灰度渠道”。这类场景用规则引擎跑是毫秒级,并且行为可预期。

智能体模型则负责处理那些需要综合判断的场景。比如同一个用户连续三天收到同一类通知但没有打开,第四天是否换一种话术风格;比如用户刚下单但物流延迟,是否在致歉之外顺手推荐一个替代品。这些判断没有绝对的对错,需要根据上下文做推理。

我的决策流程设计成两条路径:

def decide(event: TouchEvent, user_context: Dict, rule_hits: List[str]) -> Decision: # 1. 先跑规则引擎 rule_result = rule_engine.evaluate(event, user_context) # 2. 如果规则引擎命中了“禁止”或“强制”类规则,直接返回 if rule_result.action == "block": return Decision(action="block", reason=rule_result.reason) if rule_result.action == "force": return Decision(action="notify", plan=rule_result.plan, reason="rule_force") # 3. 其余场景交给 Agent 决策,带超时控制 try: agent_result = agent_model.decide_with_timeout( event=event, user_context=user_context, candidate_channels=rule_result.candidate_channels, timeout_ms=2000 ) except TimeoutError: # 超时兜底:按规则引擎的保守建议执行 agent_result = fallback_decision(event, user_context) return agent_result

这里有一个非常关键的设计细节:Agent 决策结果必须是一个结构化对象,而不是一段自由文本。我第一次实现时让Agent直接返回话术,结果它偶尔会在话术里脑补出用户根本不存在的意图,后面我在踩坑部分会详细讲。现在的做法是限制Agent只能输出action、channel、template_id、delay_seconds、reason这几个字段,话术内容由确定的模板系统渲染,模型只做选择和润色参数。

决策结果Decision的数据结构大致如下:

@dataclass class Decision: action: str # block / notify / defer plan: Optional[TouchPlan] # 触达计划 reason: str # 决策原因,用于审计 @dataclass class TouchPlan: user_id: str channel_sequence: List[str] # 渠道序列,如 ["app_push", "email"] template_id: str # 话术模板 ID template_params: Dict[str, str] # 模板参数 delay_seconds: int # 延迟发送时间 idempotency_key: str # 幂等键,防止重复

2.3 触达编排:把决策变成一次可执行的计划

决策引擎给出了“要不要发、什么时候发、用什么模板发、走哪个渠道”这些关键判断,但真正执行前还需要一个编排步骤。

触达编排做的事情有四个:

  1. 确认触达目标。事件里携带的 subjects 可能是一个群体,比如“所有值班人员”。编排层要根据事件属性、用户状态、历史反馈记录,把群体缩到具体个人。比如某个值班人员已经在15分钟内收过同类告警,就跳过。

  2. 生成绩效键。幂等键是整个系统的灵魂。我的规则是:idempotency_key = md5(event_id + user_id + channel_first + template_id)。这个键要保证同一个事件的同一用户第一次触达后,不会因为系统重试或消息重复投递而产生第二次发送。

  3. 拆解渠道序列。决策结果里可能包含多个渠道,但绝不是同时全部上。默认的渠道序列是“先即时渠道、后缓冲渠道”:App推送属于即时渠道,邮件属于缓冲渠道。当即时渠道投递失败,或者用户一段时间未读,再走下一个渠道。

  4. 写入延迟队列。需要延迟发送的触达任务会先写入持久化队列,等时间到达后再进入投递流程。这里我用Redis的ZSet实现,score就是计划执行时间戳。

import redis import time import json r = redis.Redis(host="localhost", port=6379, db=2) def schedule_delayed_touch(plan: dict, execute_at: float): key = f"touch:scheduled:{plan['channel_first']}" r.zadd(key, {json.dumps(plan): execute_at}) def pop_ready_touches(): key_candidates = ["touch:scheduled:app_push", "touch:scheduled:sms", "touch:scheduled:email"] plans = [] for key in key_candidates: now = time.time() items = r.zrangebyscore(key, 0, now, start=0, num=50) for item in items: plans.append(json.loads(item)) if items: r.zremrangebyscore(key, 0, now) return plans

2.4 渠道适配层:真正的“Reach”

渠道适配层是Agent-Reach最贴近名字的那部分功能。它负责把触达计划里的渠道序列转换成各个渠道实际能识别的请求。

我对渠道适配层的设计要求是“接口统一,能力差异外显”。渠道适配接口是这样定义的:

class ChannelAdapter(ABC): channel_name: str @abstractmethod def send(self, request: ChannelRequest) -> ChannelResponse: pass @abstractmethod def check_status(self, message_id: str) -> DeliveryStatus: pass

你可能会觉得这不算什么,任何一个消息平台都这么设计。真正的差异在于能力描述。每个渠道注册进来的时候,必须上报自己的元数据:

@dataclass class ChannelCapability: max_batch_size: int = 1000 # 单次调用的最大条数 rate_per_second: int = 100 # 每秒最大投递速率 max_payload_length: int = 70 # 单条内容最大长度(短信按字符算) cost_per_thousand: float = 0.5 # 千条成本,用于路由决策 average_delay_ms: int = 300 # 平均投递延迟 supports_delivery_report: bool = True supports_reply: bool = True # 是否支持用户回复

这些能力属性会被路由决策读取,避免“决策层让短信渠道承载过长的营销文案”“明明用户只能收到站内信,却优先选了App推送”这种低级错误。

2.5 反馈回收:触达不是终点

一个很多人忽略的设计点是:触达的终点不是消息发出,而是反馈回收。用户看完消息后的行为——点开链接、回复短信、点击退订、忽略、投诉——全部需要回到系统里,成为下一次决策的依据。

反馈数据通过两种方式回来:

  • 渠道回执:App推送有送达和点击回调,邮件有打开和点击跟踪,短信有回执码。这类数据进入message_feedback表。
  • 用户回复:用户回复短信或IM消息是更高质量的反馈信号。Agent-Reach会把这些回复转成事件重新进入决策链路,形成一个“AI对话式触达”的循环。

反馈回收层要做的关键动作是把反馈数据归因到具体的那一次触达计划。每次发送时,我都在消息里携带touch_id和user_id的映射,回调回来后先查这两者对应关系,再更新触达计划的状态。

# 触达计划状态机简化版 STATUS_TRANSITIONS = { "scheduled": ["sending", "cancelled"], "sending": ["delivered", "failed", "timeout"], "delivered": ["read", "clicked", "ignored", "unsubscribed"], "clicked": ["converted", "ignored"], "unsubscribed": [], # 终态 }

有了这个状态机以后,每一个用户“为什么在某个时间收到某条消息、收到之后做了什么”就是完全可回溯的,后面排查问题会非常省事。

3. 触达策略的两个易错参数:时机与频控的计算逻辑

3.1 时机:为什么“立刻发”往往不是最优解

触达系统最容易犯的错误是“事件一发生就立刻发通知”。实际上,对很多场景来说,“立刻发”和“根本不发”一样糟糕。

要理解时机为什么重要,首先要意识到一条消息对用户的打扰成本和一个事件对用户的重要程度是不对等的。一次深夜的P1故障告警发给值班人是对的,但一条促销通知深夜推到用户手机上就会直接拉低App评分。

我设计了一套简单的“触达时机打分”逻辑。它不是机器学习的评分模型,而是一个可解释的加权计算,便于上线初期排查问题。

决策引擎会计算一个touch_urgency_score,综合三个维度:

维度说明权重
事件紧急度事件本身的优先级 1-5,分数归一化到 0-10.5
用户可用度预估用户现在是否方便接收,0-10.3
渠道适配度所选渠道在当前时段的效果,0-10.2

总分低于0.35时,默认进入延迟队列,等待更合适的窗口;总分高于0.8时,立即触达;介于两者之间,可以根据业务配置延迟时间和更新时间窗口。

用户可用度的计算来自三个信号:历史打开时间分布(比如用户习惯在午休和晚上9点后看消息)、用户当前在线状态、用户是否处于设置的静默时间段(晚上22点到次日8点降低打扰权重)。

这里有个实用经验:不要在“用户刚发生过负向反馈”的短时间窗口内触达。比如用户刚退订了某个频道的邮件,紧接着又收到同类型的App推送,那个退订动作的置信度会迅速被消耗光。我在用户状态里维护一个negative_feedback_at时间戳,触达决策时如果距上次负反馈小于72小时,营销类事件默认拦截。

3.2 频控:避免触达风暴的关键算法

如果说时机决定“对不对”,那么频控决定“够不够稳”。频控做不好,再聪明的Agent也会变成用户眼中的骚扰系统。

我遇到的第一个高频事故是:一个故障导致事件瞬间爆发,事件网关做了风暴抑制,但决策引擎的Agent在聚合事件之后仍然给同一个用户生成了多条不同模板的触达计划,用户在十几分钟内收到了一连串消息。

后来我在触达编排层做了三层频控:

  1. 用户级绝对次数限制:单用户24小时内触达次数上限(默认5次,可配置)。超过后所有非紧急触达直接拒绝。
  2. 渠道级加权限制:不同渠道对用户的“打扰系数”不同。短信打扰系数是App推送的3倍,这个系数作用在消费预算上。
  3. 优先级保护:紧急事件不受频控限制,但必须记录“本次触达属于超频豁免”,方便事后审计。

用户级频控的实现我用的是滑动窗口计数。为什么不用简单的固定窗口?因为固定窗口会出现边界效应——用户在23:59收到一条消息,00:01又收到一条,这两条在固定窗口视角下属于不同天,但实际上只隔了两分钟。

import time from collections import deque class SlidingWindowCounter: def __init__(self, period_seconds: int, max_count: int): self.period = period_seconds self.max_count = max_count self.events = deque() def allow(self) -> bool: now = time.time() # 清除窗口外的记录 while self.events and self.events[0] < now - self.period: self.events.popleft() if len(self.events) >= self.max_count: return False self.events.append(now) return True # 用法:每个用户维护一个计数器 def check_user_rate_limit(user_id: str): counter = user_counters.get(user_id) if counter is None: counter = SlidingWindowCounter(period_seconds=86400, max_count=5) user_counters[user_id] = counter return counter.allow()

这个实现是单机版的,线上要换成Redis的ZSet计数。思路完全一样:以当前时间戳作为score,删除窗口外的成员,统计窗口内的成员数。

另外还要加一个“静默期”概念。我配置的默认静默期是:同渠道同类型消息给同一个用户发送后,4小时内不允许重复发送。这样可以天然地把“用户刚看过一条订单超时提醒、又收到一条订单取消提醒”这种场景挡在门外。

3.3 渠道路由与成本权衡

渠道选择看起来简单——选“能发出去的”就行。但实际做起来,每个渠道都有成本、限制和用户体验差异。

我做过一个对比,各渠道的特性差异比想象中大:

渠道实时性千条成本打扰系数支持回复适合内容
App推送高低中有限高频、互动内容
短信高高最高支持强时效、强必要
邮件低极低低支持长内容、报告类
站内信中零极低支持所有非紧急消息
IM机器人高中中支持办公协同、订阅推送

路由决策的逻辑是:默认先评估站内信和App推送这两个“低成本基底渠道”,再评估“是否需要升级到高打扰渠道”。升级条件一般包括:用户长期不活跃、站内信触达率持续很低、事件紧急度高。

有意思的是,渠道的“闲置价值”也应该参与路由。我观察到一个现象:用户在自己的几个渠道里有一个固定的偏好顺序——有人永远先看微信,有人只看邮件,有人不装App但会回短信。这个偏好顺序可以从历史打开行为里统计出来,反馈给决策引擎作为先验权重。

4. 接入实操:从零跑通一个最小闭环

4.1 环境准备与目录结构

这一节以一个非常典型的场景为例:用户在系统内超过7天没有登录,Agent-Reach尝试把用户召回。我带你把最小闭环完整跑一遍。

依赖组件有三个:Python 3.10+、Redis(用于延迟队列和频控计数)、一个LLM API(用于Agent决策,本地可用任何兼容接口替代)。目录结构我习惯这样组织:

agent_reach/ ├── events/ # 事件接入层 │ ├── api.py # HTTP 事件接收入口 │ └── normalize.py # 事件归一化 ├── decision/ │ ├── rules.py # 规则引擎 │ ├── agent.py # LLM 决策封装 │ └── decision.py # 决策主流程 ├── channels/ │ ├── base.py # 渠道适配抽象 │ ├── app_push.py │ ├── email.py │ └── sms.py ├── feedback/ │ └── receiver.py # 回执与回复接收 ├── state/ # 状态存储 │ └── store.py └── config.yaml # 触达技能配置

4.2 用配置文件描述一个触达技能

我把“在什么场景下允许触达、用什么话术模板、走什么渠道、频控限制是多少”打包成一个“触达技能”,全部写在配置里。这样决策引擎的规则部分是数据驱动的,加一个新业务场景不需要改代码。

skill_id: user_churn_recall description: "7天未登录用户召回" trigger: event_type: user_inactive min_days: 7 max_days: 30 decision: priority: 2 channels: ["app_push", "email"] channel_sequence: ["app_push", "email"] # 先推送,次日未读再邮件 delay_seconds: app_push: 600 # 延迟10分钟,避免事件刚发生立刻打扰 email: 3600 # 延迟1小时后走邮件 freq_control: user_24h_max: 2 channel_quiet_seconds: 14400 unsub_grace_seconds: 259200 templates: app_push: "user_churn_push_v1" email: "user_churn_email_v1" fallback_channel: "email" audit: true

这个配置的意思是:只有user_inactive事件且天数在7到30天之间才会触发;24小时内最多给用户发2条这类召回消息;如果用户在72小时内反馈过“内容打扰我”,整个技能拒绝执行。

4.3 核心调度循环简化代码

配置准备好之后,核心调度循环比大多数人想象中简单。我用一个asyncio事件循环来监听延迟队列:

import asyncio import logging async def scheduling_loop(): while True: plans = pop_ready_touches() for plan in plans: asyncio.create_task(execute_touch(plan)) await asyncio.sleep(1) async def execute_touch(plan: dict): user_id = plan["user_id"] # 频控二次校验,防止编排阶段和投递阶段之间出现时间差 if not check_user_rate_limit(user_id): log_action(user_id, "rate_limit_blocked", plan_id=plan["plan_id"]) return collector = plan.copy() current_channel = plan["channel_sequence"][0] # 尝试按渠道序列逐个发送 for idx, channel_name in enumerate(plan["channel_sequence"]): if idx > 0: await asyncio.sleep(plan.get("channel_gap_seconds", 300)) result = send_by_channel(channel_name, collector) collector["delivery_results"].append(result.to_dict()) if result.status == "delivered": break if result.status == "hard_fail": continue save_audit_log(collector)

你需要特别注意一个细节:发布到延迟队列之后到实际执行之前,必须二次校验频控和用户状态。因为我曾经在压测时发现,频控在决策阶段已经通过了,但队列积压导致执行时已经发生了用户退订,消息仍然发了出去。二次校验就是给这种时间差上一道保险。

4.4 上线前要检查的四件事

最小闭环跑通之后,不要急着全量放量。我总结了一套上线前检查清单,每一条都是血泪换来的:

第一,幂等键是否覆盖了所有渠道。如果同一用户通过App推送投递失败,系统自动改走短信,但短信发送时没有重新生成包含新渠道的幂等键,用户就会同时收到两条内容一样的消息。正确做法是每条渠道的发送请求都携带同一个touch_id,由渠道适配层根据touch_id + channel_name去重。

第二,压力测试时统计“用户侧实际收到的消息条数”而不是“系统发送成功的条数”。之前压测报告显示成功率99.9%,但把数据按用户维度拉出来,竟然有用户收到了26条同类消息。原因就出在频控只按“发送条数”计数,没有按“送达且被阅读”来调整后续策略。

第三,话术模板必须经过内容审核链路。Agent可以决定用哪个模板和参数,但不能直接生成任意文本。尤其是涉及金额、时限、法律风险的场景,模板里必须留出人工审核位。

第四,灰度比例从1%开始。Agent-Reach虽然不是推荐算法,但LLM决策还是有可能在某个边缘场景产生所有人看起来都离谱的输出。灰度期内的所有触达行为都要保存完整审计日志,至少保留90天。

5. 我踩过的坑,和完整的排查链路

5.1 智能体“戏精”式乱触达:话术发散

第一次把LLM决策接入系统时,我给的约束只有“根据事件生成触达内容”。结果出了几个非常尴尬的事故。

有一次线上服务发生故障,事件本身的重心是“数据库连接池耗尽,影响支付接口”。Agent生成的触达话术里,竟然包含了“我们检测到您的账户可能被盗用,请尽快修改密码”这种内容——模型从“支付接口异常”脑补了账号安全问题,直接越过事实边界。用户看到短信后大量涌入客服,造成二次事故。

排查链路:

  1. 从客服工单反查触达表,定位到消息使用的content_source=llm_output。
  2. 从decision_log里调出当时的完整输入和输出,确认规则引擎给到Agent的上下文范围太宽,包含了大量原始指标字段。
  3. 复现Prompt,发现模型的指令是“生成一条触达消息”,没有限定“只能描述事件事实”。

修复方案:

  • 动作白名单:Agent只允许输出有限的action,包括notify_score、choose_channel、select_template、fill_params,不允许输出整段文本。
  • 事实约束:Prompt中明确给出“事件事实字段”和“禁止推测字段”。禁止推测字段包括用户意图、安全状态、法律后果等。
  • schema校验:Agent输出后,先经过pydantic校验,字段非法直接降级为规则引擎兜底结果,不进入发送队列。

那次之后我明白了一个道理:把Agent的权限限制到“决策建议者”而不是“内容创作者”,系统的可靠性会高一个数量级。

5.2 事件风暴触达风暴:一次故障,用户收到26条短信

有段时间我们做一个电商大促活动的定时任务,程序出bug后触发了一连串重复事件。事件网关本身做了5分钟聚合,但我低估了一个场景:Agent在一次决策里把同一个用户挂到了多个不同的运动计划模板下,比如“您关注的商品降价了”“您的优惠券即将过期”“您收藏的店铺上新了”,三个模板都没重复,但都指向同一个事实——程序bug。

结果用户在一小时内收到了26条短信。那一刻我意识到,频控不能只按“同一模板”或“同一事件”去重,还要按“同一根因”去重。

修复方案做了两个层面的调整:

  1. 事件指纹:在事件网关给每个事件计算一个root_cause_fingerprint,把可能的重复事件绑到同一个指纹下。指纹基于事件的subject + error_code + resource计算。Agent决策时不能只看单个事件,还要看同一指纹的事件在近一小时内被处理过多少次。
  2. 全局触达熔断器:当某个root_cause_fingerprint对应的用户触达次数在1分钟内超过阈值(比如10次),触发熔断,后续该指纹的所有事件只进入审计日志,不再生成触达计划。熔断状态自动恢复,但恢复后要重新评估“是否真的有用户需要被通知”。

5.3 被用户连续屏蔽之后的沉默与活跃推断

用户反馈回收这块有一个隐坑:用户没有响应,不等于用户没看到;用户屏蔽了你,但系统还在继续发。

我们曾做过一次复盘,发现某条渠道的送达率其实很差,但Agent仍然优先选择它。原因是Agent决策时把“上次触达无反馈”解读成了“用户可能没看到,应该再试试”,结果在用户已经屏蔽邮件的情况下连续发了5封。

修复方案是把“无反馈”和“负反馈”区分开:

  • 无反馈:触达后72小时无打开、无点击、无回复。系统会把该渠道的user_channel_score调低,减少该渠道曝光,而不是立即换渠道。
  • 负反馈:用户主动点了退订、回复“请别再发”或者在客户端里标记垃圾。系统把该用户在该渠道的状态置为hard_bounce,后续所有触达动作先查这个状态,命中即拦截。

拦截外还要做一件事:给用户一个沉默期。我配置了默认30天的沉默期,即使用户误点击了退订然后又重新授权,系统也会在沉默期内对这个渠道保持克制,防止用户情绪反弹。

5.4 一次“幽灵触达”的全链路排查案例

还有一种坑,是用户看到了消息,后台却记不到任何发送记录。我们内部叫“幽灵触达”。用户截图说收到了推送,但后台查不到对应的send_log,客服和技术都一脸懵。

完整的排查链路是这样的:

  1. 先从用户提供的截图里提取消息ID。正常情况下消息ID会写入日志,但那次没有。
  2. 反查touch_plan表,确实存在该用户的触达计划,但状态是scheduled,还没到发送时间。
  3. 把时间轴拉开,发现推送实际发出的时间比scheduled的预期执行时间早了几个小时。这是延迟队列的timeout机制在起作用——某个worker节点拉取任务后处理超时,另一个worker重新拉取了同一任务,但第一个worker并没有真正死掉,而是把任务发出去后才超时返回。
  4. 根因出在延迟队列的ZRANGEBYSCORE + ZREM操作不是原子的。两个worker同时读到同一个计划,都认为可以执行。

修复方案很简单:用Lua脚本把取任务和删任务合并成一个原子操作,把“执行中任务”和“已完成任务”的状态分离,执行前去状态表里检查touch_id是否已经处于sending/delivered状态。

-- Redis 原子取任务脚本 local key = KEYS[1] local now = ARGV[1] local limit = ARGV[2] local items = redis.call('ZRANGEBYSCORE', key, 0, now, 'LIMIT', 0, limit) if #items == 0 then return items end for i, item in ipairs(items) do redis.call('ZREM', key, item) end return items

那次之后,所有涉及“取走并删除”的操作我都一律改成Lua原子脚本,不给竞态留机会。

6. 从 Agent-Reach 继续延伸的三个方向

6.1 从“发送触达”到“对话触达”

当前版本的Agent-Reach还是一次性的触达决策,但用户反馈回收之后,实际上有很多用户会回复消息。回复内容如果只是落库就结束,太可惜了。

下一阶段的形态是“对话式触达”:用户回复之后,回复内容变成一个user_reply事件,重新进入决策链路。Agent根据用户回复的意图决定是继续追问、转人工、还是结束会话。这样,一次触达就从“单次推送”变成了“一个带状态的交互循环”。

这种模式特别适合服务补救场景。比如用户收到“您的订单因库存原因延迟发货”的通知后回复“那我要什么时候才能收到?”,如果系统能接着回答“预计下周一到,如果您急用,可以为您更换同价位的其他商品”,用户的体验完全是另一个层次。

6.2 从规则+模型到可学习的策略优化

目前的决策引擎还是规则加LLM的静态结合,但在积累了足够多的触达反馈数据之后,完全可以做策略优化。

具体思路是:把每次触达的user_id + event_type + channel + template_id + delay + feedback作为一条训练样本,训练一个“触达效果预估模型”,预测“如果这个用户在此时通过这个渠道收到这条消息,会产生正向反馈的概率”。这个模型可以作为决策引擎的一个评分器,排在规则评审之后、LLM决策之前,给Agent提供更精准的用户状态预估参考。

我建议从小流量验证入手,先选一个高频低风险的场景(比如活跃用户召回),用A/B实验对比“纯规则版本”和“带预估模型版本”的转化差异,再逐步扩展。

6.3 合规与权限边界

说到最后,这类系统必然会面对的一个问题:触达的尺度,不只是技术问题,也是责任问题。

我的底线是:触达前先查“用户是否给了渠道授权”,触达时永远携带退订入口,触达后在用户发出负反馈时第一时间停止并记录。每一次触达都要经过审计日志,谁决策的、依据是什么、有没有人工复核,全部留痕。

Agent-Reach这类系统越智能,它背后需要越克制。智能体现在“判断得更准”,而不是“发得更多”。把这句话想清楚,这个方向就不会跑偏。

我个人在使用中的体会是:触达系统做到后来,最大的瓶颈往往不是模型能力,而是对用户状态的敏感度和对系统自身行为的管理能力。Agent-Reach的路子本质上是在这两个维度上同时做工程化——让决策更聪明,让行为更克制。沿着这个方向往下走,能做的事情还有很多。

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

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

立即咨询