1. 项目整体设计与定位
1.1 为什么需要Agent-Reach
过去一年里我陆续接手了好几个AI Agent项目,从简单的客服问答到复杂的多步骤任务处理。做多了之后你会发现一个尴尬的现象:Agent本身越来越聪明,但它跟用户之间的“最后一公里”反而成了短板。模型在后台规划得再周密,如果无法及时、稳定、可追踪地把结果送到用户面前,或者在用户反馈后无法顺利把信息带回给Agent继续迭代,整个系统就卡在那里。
我最早遇到这个问题是在一个内部效率工具上。后端用LangChain搭了一个Agent,负责分析财务报表并生成摘要。技术上跑得通,可到了触达环节就头疼了:一部分用户习惯用邮件接收报告,一部分人在企业IM里工作,还有几个领导指定要用短信收到关键指标。当时硬编码对接了三个渠道,每个渠道一套单独逻辑,消息格式各写各的,重试策略也不一样。两周后需求方加了一个“微信服务号推送”,我看了看那摊代码,差点当场辞职。
后来我在团队里发起了一个小项目,专门解决Agent结果触达、用户回传、以及多路复用的问题。项目代号就叫Agent-Reach。它不是某个单一大模型产品,而是一层轻量级的“触达编排层”,放在Agent后端与各类通讯渠道中间,负责把Agent产生的结构化消息,按照路由策略、优先级、频控规则,可靠地发送到目标用户,再收集用户在渠道上的回复并送回Agent上下文。
如果你也遇到过类似场景,可能瞬间就明白了Agent-Reach的价值:它把AI能力与用户之间的距离,变成了一套可配置、可观测、可复用的基础设施。适合谁参考?适合正在做企业级Agent应用、客服机器人、自动化报告分发,或者想把LLM能力接入办公协同软件的开发者和架构师。下面我会把Agent-Reach从设计思路到落地细节完整拆开,直接给出能抄作业的东西。
1.2 设计目标与核心思路
Agent-Reach立项第一天,我们定的目标不是“做一个消息推送工具”,而是“建立一套以Agent为中心的触达协议”。这句话听起来有点虚,翻译成白话就是:让Agent根本不关心用户到底在邮件里还是在IM里,它只需要说“把这句话发给张三,重要程度高,要求回复”,剩下的全部由Agent-Reach搞定。
整个设计围绕四个核心思路展开:
第一,抽象统一消息模型。无论目标是邮件、短信、企业微信还是钉钉,统一抽象成一个Envelope结构,包含消息ID、目标用户标识、渠道偏好、优先级、TTL、回传地址、内容片段。只要Agent按规范生成Envelope,Agent-Reach就负责把内容翻译成各渠道协议需要的格式。
第二,集中化路由策略。用户可能在多个渠道都活跃,但是不能每一条消息都全渠道轰炸。Agent-Reach维护一份用户渠道偏好表,按“用户自选 > 渠道活跃度 > 系统默认”的顺序解析投递目标。同时在路由层做一层“目标渠道可用性”判断,某个渠道故障时自动降级到次选渠道。
第三,全链路状态追踪。每一条从Agent发出去的触达,都以消息ID为核心记录状态流转:created、queued、sent、delivered、read、replied、timeout、failed。任何一环出了问题,都可以根据状态日志定位到具体阶段。这条对生产环境太重要了,后面我会专门讲。
第四,渠道适配器化。每个下游渠道(企业微信、钉钉、邮件、短信等)都是一套独立的Adapter,只干三件事:把统一Envelope转换为渠道私有格式、调用渠道API发送、轮询/接收用户回复并转为统一回调事件。新增一个渠道,大概率只需要新写一个Adapter,核心代码零改动。
听着是不是有点像“消息中间件”?确实。Agent-Reach本质上是MQ模式在Agent触达场景下的一次特化。但跟通用MQ最大的区别在于,它内置了用户画像与渠道策略,而不是纯按Topic分发。所以千万不要把Agent-Reach当成Kafka的替代品,它是站在Agent这侧解决“如何触达具体的人”的问题。
1.3 方案选型:为什么不做定制化集成
项目初期有个更朴素的选项:直接在Agent代码里按渠道包几个Client,每个渠道单独调用。市面上很多团队也就是这么干的,特别是在Agent数量少、渠道单一的时候,这种方案写起来快,看起来也没毛病。那为什么我们还要费力搞Agent-Reach这一层?
我用一张表对比过,后面维护成本差距相当明显:
| 维度 | 定制化硬编码 | Agent-Reach统一层 |
|---|---|---|
| 新增渠道成本 | 需要改动Agent核心代码,至少1~2天 | 新增一个Adapter,通常2~4小时 |
| 消息状态追踪 | 每个渠道自己维护,标准不一,很难统一查 | 全局统一消息ID,一张表看全部状态 |
| 渠道故障降级 | 很难做,各渠道错误码需要各自处理 | 路由层自动感知可用性,统一降级策略 |
| 频控与配额 | 基本靠各渠道自带,超限就乱套 | 集中式漏斗限流,破渠道限制先于调用发生 |
| 用户回复归属 | Agent要分别接收轮询,流程割裂 | 统一回调事件,自动绑定到原消息上下文 |
| 团队协作边界 | Agent开发要懂所有渠道API | 渠道对接由适配器负责,Agent专注业务逻辑 |
另外还有一个很实际的原因:企业IM渠道的鉴权、加签、消息类型限制,大多比较复杂。把这些复杂度全部塞进Agent的prompt和工具函数里,会严重污染业务逻辑。分开之后,Agent那边的代码只关心“生成什么内容,发给谁,期望什么反馈”,渠道那块的脏活儿累活儿全给适配器干。Agent-Reach作为中间层,也天然成了Agent与渠道之间的隔离边界,审计方便,权限也好收敛。
一句话:如果Agent触达只是临时脚本,硬编码无可厚非;一旦你想让它成为平台能力,那统一层迟早要建。Agent-Reach就是这个统一层的具体落地形态。
2. 核心细节解析与实操要点
2.1 三大核心能力:注册发现、路由策略、状态追踪
先说注册发现。Agent-Reach里不仅仅管理“渠道”这个维度,还管理“Agent”本身。每个Agent接入时,先调用注册接口,上报自己的名称、用途、可处理的消息类型以及目标用户范围。注册之后的Agent会拿到一个access_key,后续所有触达请求都要携带这个key。这样做有两个好处:一是Agent必须显式接入,杜绝无授权的消息乱发;二是Agent-Reach可以根据注册信息,自动给Agent分配默认路由策略,比如“只允许触达产品部用户”“每天触达上限100次”。
注册信息存放在一个轻量的配置表中,核心字段大概是这样:
| 字段 | 示例值 | 说明 |
|---|---|---|
| agent_id | agent_report_v2 | Agent唯一标识 |
| agent_name | 财务报告助手 | 展示名称 |
| allowed_channels | work_wechat, email | 允许使用的渠道白名单 |
| target_audience | finance, leadership | 目标用户标签 |
| daily_limit | 500 | 日触达总配额 |
| callback_url | http://agent-service/events | 用户回复事件回传地址 |
路由策略这块,我的经验是别一开始就设计成复杂的规则引擎,很容易过度设计。Agent-Reach第一版只支持两层判断:渠道白名单 + 用户渠道偏好优先级。Agent请求里可以设置channel_policy,取值有strict(只发指定渠道)、preferred(优先指定、失败自动降级)、broadcast(多渠道广播)。默认值是preferred,这是最贴合实际场景的选项。
具体解析顺序是这样的:先看Agent白名单是否允许该渠道,再看用户渠道偏好表里有没有硬性选择,都没有就用默认顺序work_wechat > dingtalk > email > sms。其中work_wechat和dingtalk这种企业内渠道,通常都绑定员工账号体系,用户标识直接用员工工号即可;email和sms这种外部渠道,需要用户在偏好表里显式备案地址或号码。所有配置都在一个简单的规则服务里,数据库里两张表就能解决,没必要上规则引擎。
状态追踪是整个Agent-Reach里最容易被低估的部分。我一开始也觉得能发出去不就行了,结果上线第一周就有一个渠道回调丢失,用户明明收到了消息,系统里却永远显示“发送中”。后来我们把每条触达的状态流转设计成了不可跳跃的链式结构:created -> queued -> sent -> delivered -> read -> replied,每个状态都带时间戳和上下文信息。特殊状态failed和timeout可以从中途任何一个状态迁移过去,但永远不能回退。这个设计让我们能非常快速地回答“这条消息现在卡在哪”的问题,排查效率提升了一倍不止。
2.2 消息模型与协议设计
Agent-Reach的消息模型,我直接放一个精简的JSON示例,这是所有Adapter的输入标准:
{ "message_id": "ar_20250122_001234567", "agent_id": "agent_report_v2", "conversation_id": "conv_8801", "user": { "user_id": "emp_1024", "display_name": "张三", "channels": ["work_wechat", "email"] }, "channel_policy": "preferred", "preferred_channel": "work_wechat", "priority": "high", "expire_at": "2025-01-22T18:30:00+08:00", "content": { "title": "2025年1月财务简报", "body": "本月营收同比增长12%……", "attachments": [ { "filename": "report_20250122.pdf", "url": "https://storage.internal/report.pdf", "type": "application/pdf" } ] }, "expect_reply": true, "reply_deadline": "2025-01-23T10:00:00+08:00", "callback_url": "http://agent-service/events/reply" }字段设计上,有几点是所有接入方必须遵守的约定。第一,message_id必须全局唯一,建议用服务端生成而不是Agent自己拼,否则很容易重复。第二,content必须是结构化的,不能是一段拼好的纯文本。像上面示例里拆成title、body、attachments,这样邮件Adapter可以渲染出富文本,IM Adapter可以展示卡片,同一份内容在多个渠道都能保留信息完整性。第三,expect_reply和callback_url决定了用户回复后Agent能不能接住。很多Agent应用发消息出去就没下文了,就是因为根本没有给用户留回传通道。Agent-Reach里只要expect_reply为true,所有能够接收回复的渠道都会绑定一个会话上下文标识,用户通过任意渠道回复,都会触发回调。
这里必须吐槽一下各渠道回调格式的不统一。企业微信的回调事件带一个EventKey,钉钉的回调是带conversationId,邮件则是靠Message-Id。Agent-Reach的统一处理方式是在发送阶段就把conversation_id及message_id写入渠道侧的自定义字段:企业微信用自定义key,钉钉用业务卡片ID,邮件则写入自定义Header。用户回复时,Adapter从回调数据里反解出这三样东西,再组装成统一的reply_event返回给Agent侧:
{ "event_type": "reply_event", "message_id": "ar_20250122_001234567", "conversation_id": "conv_8801", "agent_id": "agent_report_v2", "user_id": "emp_1024", "channel": "work_wechat", "content": "收到,谢谢", "reply_time": "2025-01-22T15:04:11+08:00" }Agent-Reach不存储用户回复的语义,它只负责把事件转交到Agent的callback_url。语义理解仍在Agent侧完成,这样可以保持层的轻量。
2.3 关键参数与配置详解
除了消息模型,Agent-Reach还有几个生产环境绕不开的关键参数。我按“必须调”和“建议调”两档列出。
必须调的参数:每个Agent的日触达配额daily_limit,路由超时时间route_timeout_ms(默认3000ms),渠道调用的单次超时adapter_timeout_ms(默认5000ms),以及消息全局TTLexpire_at。这四个参数直接关系到系统会不会被刷爆、会不会因为渠道慢而拖死Agent线程。Agent-Reach里所有上游调用都设计成异步请求,Agent提交Envelope后立即拿到accepted响应,真正的发送在worker线程池里执行。所以route_timeout_ms影响的是worker排队时的等待上限,而不是Agent的接口RT。
建议调的参数:重试次数max_retry(默认3次),重试退避基数retry_base_ms(默认1000ms,指数退避),频控的窗口大小rate_limit_window_sec(默认60秒),以及渠道降级开关allow_degrade。重试这里要特别提醒:像短信、邮件这类渠道,重复发送会造成用户打扰甚至资费浪费,所以重试必须结合消息幂等。Agent-Reach在Adapter层做了一件事:每个渠道发送前检查该message_id是否已在这个渠道成功发送过一次,成功过就不允许重发。宁可重试时多判断一次,也不要让用户收两遍一模一样的短信。
渠道配置中还有一个常被忽略的“通道隔离”概念。企业微信和钉钉这类渠道,一个应用下会有不同的Agent应用账号。Agent-Reach支持按agent_id绑定多个渠道应用凭据,这样财务Agent用企业微信应用A发消息,客服Agent用应用B发消息,哪怕两个Agent触达同一个用户,用户看到的是两个独立会话入口,回复路由不会串。这个配置在渠道管理页面上就是一张映射表,但部署时如果不提前规划,后面用户回复串到别的Agent会话里,排查起来极其痛苦。
3. 实操过程与核心环节实现
3.1 环境准备与服务部署
Agent-Reach本身是纯服务端应用,技术栈我们选了Python + FastAPI + Redis + PostgreSQL。选FastAPI的理由是异步原生、类型校验方便,适合做高并发网关;Redis用来做消息队列和频控计数;PostgreSQL存路由配置、消息状态和审计日志。实际部署用Docker Compose即可,三个服务一个redis一个postgres,加起来配置文件不到150行。
先说一下目录结构:
agent-reach/ ├── api/ # FastAPI入口 │ ├── main.py │ ├── schemas.py # Envelope/Event模型定义 │ └── dependencies.py # Agent鉴权、日志依赖 ├── core/ │ ├── router.py # 路由决策逻辑 │ ├── dispatcher.py # 消息分发worker │ └── tracker.py # 状态机管理 ├── adapters/ │ ├── base.py # Adapter抽象基类 │ ├── work_wechat.py │ ├── dingtalk.py │ ├── email_smtp.py │ └── sms_aliyun.py ├── store/ │ ├── models.py # SQLAlchemy模型 │ └── redis_client.py └── config/ ├── settings.py └── agents.yaml # Agent注册初始配置部署时最关键的一步是确保Redis的数据不丢。我们把消息状态和队列都放在Redis里,Redis如果宕机会导致消息状态丢失,所以必须开启AOF持久化。容器启动参数里加上--appendonly yes,配置Redis的maxmemory-policy为noeviction,避免内存满了直接淘汰消息key。这一点是运维运维期间血泪教训换来的,具体后面排查章节会细说。
3.2 配置数据源与连接器
启动服务后第一件事不是写代码,而是先建好PostgreSQL的表。核心表我直接给出建表语句的关键部分:
CREATE TABLE agent_registry ( agent_id VARCHAR(64) PRIMARY KEY, agent_name VARCHAR(128) NOT NULL, access_key_hash VARCHAR(256) NOT NULL, allowed_channels TEXT[] NOT NULL DEFAULT '{}', daily_limit INTEGER NOT NULL DEFAULT 500, callback_url VARCHAR(512), created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE user_channel_preference ( user_id VARCHAR(64) NOT NULL, channel VARCHAR(32) NOT NULL, contact_id VARCHAR(256) NOT NULL, priority INTEGER NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ, PRIMARY KEY (user_id, channel) ); CREATE TABLE message_envelope ( message_id VARCHAR(64) PRIMARY KEY, agent_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, channel_policy VARCHAR(16) NOT NULL, state VARCHAR(16) NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() );这里特别说明一下,为什么不把整个Envelope散列到多张表里去?因为消息内容本身是高度异构的,Agent生成的附件、卡片、按钮,结构各不相同。直接存JSONB最简单,查询的时候按message_id和state走索引,效率完全够用。PostgreSQL的JSONB支持GIN索引,如果以后要做“按内容关键字查历史触达记录”,再加索引就行。
Agent-Reach的Adapter连接器配置统一放在配置中心,每个channel对应一个连接器配置块:
channels: work_wechat: provider: work_wechat corp_id: your_corp_id agent_id: 1000012 secret_path: /run/secrets/work_wechat_secret send_url: https://qyapi.weixin.qq.com/cgi-bin/message/send allow_degrade: true email_smtp: provider: smtp smtp_host: smtp.internal.company smtp_port: 587 username: bot@company.com password_path: /run/secrets/smtp_pass default_from: bot@company.com allow_degrade: false这里有个安全细节:所有密钥不要直接写在yaml里,而是放到文件、环境变量或者密钥管理服务里。配置文件里只保存路径,程序加载时去读。我见过太多因为仓库里的硬编码密钥导致的事故,Agent-Reach从第一天起就强制这个规范。
3.3 打通Agent到用户触达通道
服务搭好了,配置也加载了,接下来就是最关键的一步:让Agent真正把消息发到Agent-Reach。我在项目里封装了一个极简的Agent SDK,核心就一个函数:
import httpx class AgentReachClient: def __init__(self, base_url: str, access_key: str): self.base_url = base_url self.headers = {"Authorization": f"Bearer {access_key}"} def send(self, envelope: dict): r = httpx.post( f"{self.base_url}/v1/envelopes", json=envelope, headers=self.headers, timeout=3 ) r.raise_for_status() return r.json()["message_id"]Agent侧使用时,甚至不需要知道下游是什么渠道:
client = AgentReachClient("http://agent-reach:8080", "key_report_v2") mid = client.send({ "conversation_id": "conv_8801", "user": {"user_id": "emp_1024", "display_name": "张三"}, "priority": "high", "content": { "title": "提醒:今天下午3点有项目评审会", "body": "会议室A302,材料已上传到wiki", }, "expect_reply": True, "callback_url": "http://agent-service/hooks/reply" })接入过程一共三个步骤:
- 在Agent-Reach管理端注册Agent,拿到access_key和允许的渠道。
- 在Agent代码中引入AgentReachClient,替换原来直接调用企业微信/邮件的地方。
- 在Agent侧写一个回调接口,接收Agent-Reach转发的reply_event,并把内容喂回LLM上下文。
整个替换过程我实测过,一个中等复杂度的Agent服务,从开始改到全部跑通,大概一个下午。前提是Agent侧已经有结构化输出,如果输出是一大段markdown文本,可能需要先让Agent按content模板输出,再交给触达层。
Agent-Reach内部dispatcher的简化逻辑大概是这样的:
async def dispatch_envelope(env: Envelope): await tracker.transition(env.message_id, "queued") channels = await router.resolve_channels(env) for ch in channels: if tracker.has_sent_on(env.message_id, ch): continue try: adapter = adapters[ch] await adapter.send(env) await tracker.transition(env.message_id, "sent", channel=ch) except ChannelUnavailableError: logger.warning("channel %s unavailable for %s", ch, env.message_id) if env.allow_degrade and ch != env.preferred_channel: continue await tracker.transition(env.message_id, "failed", reason=...) return await tracker.transition(env.message_id, "delivered")注意这里的has_sent_on幂等检查,是多渠道重复发送场景下的保命设计。
3.4 测试与灰度发布
Agent-Reach的测试,我习惯分成三级:Adapter级、Router级、端到端级。
Adapter级测试最好做,每个Adapter实现send方法后,先用沙箱凭据发一条测试消息,验证渠道API能通、格式没毛病。这个测试很容易被忽略,因为很多渠道的官方文档样例跟真实环境有出入。比如企业微信的markdown消息,官方说支持,实际上对某些字段有长度限制。我的做法是让每个Adapter维护一个smoke_test方法,注册渠道时自动调用一次,返回成功/失败+耗时。
Router级测试重点在偏好解析和降级策略。我会专门造一批“只有邮箱没有企微”“既有企微又选了邮件优先”“所有渠道都不可用”的用户数据,跑一遍路由逻辑,确认每个case的结论符合预期。这个阶段最容易发现的是用户渠道偏好表里contact_id为空的问题,很多用户只在表里登记了user_id,却没有对应的渠道地址,导致选了邮件却发不出去。
端到端测试就更贴近真实场景了。我在测试环境里准备了一个假的Agent,定时往Agent-Reach提交各类Envelope,然后用测试手机号和企业微信测试号接收。重点检查三件事:
- 消息能否在规定时间内到达;
- 在其中一个渠道不可用时,降级渠道是否正确生效;
- 用户回复后,callback是否能稳定触发。
灰度发布也有一个笨但有效的办法:先让某个低风险Agent(比如内部通知机器人)切到Agent-Reach跑一周,观察消息送达率和用户回复率是否与硬编码方案对齐。确认无异常后,再把有真实业务影响的Agent(比如财务报告助手)分批切过来。目前我所在的项目组就是按照这个节奏推进的,六周时间把所有自研触达逻辑全部收敛到了Agent-Reach上。
4. 常见问题与排查技巧实录
4.1 典型问题速查表
| 现象 | 可能原因 | 排查路径 | 解决方案 |
|---|---|---|---|
| 消息一直处于queued状态 | worker线程池饱和或Redis队列堆积 | 查看worker日志和Redis队列长度 | 增加worker数量,调大MAX_CONCURRENCY |
| 消息显示sent但用户没收到 | 渠道侧发送成功但投递失败,或格式不被渠道解析 | 检查渠道后台的发送记录 | 拉取渠道侧送达状态回传,更新到tracker |
| 用户回复了但Agent没收到 | callback_url没配或回调URL网络不通 | 查看Agent-Reach回调日志是否有超时 | 确认callback_url公网可达,增加回调重试 |
| 同一个用户收到两条相同消息 | 上游Agent重复提交或渠道重试未幂等 | 查询message_envelope表按message_id去重 | 确保message_id由Agent-Reach生成,检查幂等逻辑 |
| 触达全部failed | access_key过期或渠道凭据失效 | 查看adapter返回的error code | 更新密钥,接入密钥过期自动告警 |
| 企业微信消息带不出附件 | 企微消息类型不支持附件 | 查看企微消息格式要求 | 在work_wechat Adapter里将附件转存到素材库,再发卡片 |
| 邮件回复反复触发同一事件 | 邮件Adapter重复解析同一封回信 | 检查回调去重键是否取的是Message-Id | 在邮件Adapter里对Message-Id做幂等处理 |
这张表是我实践当中真实遇到过的场景,不是从文档里抄出来的。其中最坑的是“消息显示sent但用户没收到”,因为渠道接口返回了success,Agent-Reach只依赖这个结果更新状态,就漏掉了投递环节。初步的解决方法是work_wechat这类支持回调的渠道,以回调中的送达事件为准更新状态;不支持的渠道,至少要做到发送后延迟几分钟抽查一次渠道侧记录,尽量对齐真实状态。
4.2 踩坑经验:消息丢失、重复触达、链路追踪
先说消息丢失。第一次做联调时,我们碰到了Redis重启后消息状态消失的故障。因为当时只是把Redis当缓存用,没开持久化,结果Agent提交的消息全部排队,重启后从队列和状态表都查不到了。那次之后,我们把Redis AOF持久化和PostgreSQL双写做成了基础要求。队列里的消息,在入队前先写一条created状态的message_envelope记录,worker取出消息后更新状态为queued,这样哪怕Redis整个挂了,也可以从数据库恢复未完成任务。
再说重复触达。有一回客服Agent发消息,用户连续收到两条一模一样的内容。查下来发现是Agent侧重试机制和Agent-Reach机制叠加了:Agent因为上游超时,自动重发了同一个Envelope,Agent-Reach里两个请求产生了不同的message_id,触达链路本身没问题,但用户感知就是重复消息。这其实是分布式系统里典型的“端到端重复”。解决思路分两段:Agent侧负责生成业务侧唯一的conversation_id,Agent-Reach在收到同一conversation_id且内容哈希一致的Envelope时会拒绝重复创建;渠道侧靠每个channel上的has_sent_on幂等兜底。双保险之后,重复率基本降到零。
最后是链路追踪。Agent-Reach作为一个中间层,最怕的是出了问题各方互相甩锅:Agent说是触达层丢了,渠道说是Agent没发过来。我们最终用一套标准的trace_id贯穿始终。做法是Agent-Reach在入口生成trace_id,写入Redis和PostgreSQL的日志字段,同时在下游渠道API调用中加入透传标识。企业微信支持自定义消息ID字段,邮件则写在自定义Header的X-AR-Trace-Id里。排查问题时,只要把用户收到的原文或渠道回调里的trace_id拿出来,一条命令就能查全链路日志。这招极大减少了扯皮时间,强烈建议做同类系统的朋友抄走。
4.3 独家避坑技巧分享
再分享几个不写到官方文档里的土经验。
第一,别让Agent直接传HTML给IM渠道。很多Agent为了排版好看,直接输出富文本HTML,然后统一给所有渠道发送。企业微信的markdown还能勉强渲染一部分HTML,钉钉对HTML基本不认,最终用户看到的不是乱码就是原始标签。我们后来强制要求content里的body必须是纯文本或极简markdown,需要富文本展示的场景,统一用attachments附带文件或链接,宁可多一步点开,也不要样式错乱。
第二,邮件渠道一定要配置MX和SPF/DKIM。如果你通过自己的SMTP服务器发邮件,收件方反垃圾系统可能因为SPF不通过直接拒收。我们在Agent-Reach的email Adapter里加了一个启动自检项,自动检查发件域名的SPF记录是否有当前SMTP服务器的IP,没有就直接告警。这个检查看起来跟Agent无关,但在生产环境里,半夜发的关键报告被邮件网关吞掉,影响面远比你想象中大。
第三,路由降级不能盲目开启。有些消息内容在渠道间是不可对等的。比如有一次我们把包含下载链接的通知从企业微信降级到了短信,短信通道倒是发出去了,但用户手机上看到的是一串网址,几乎没法点开。后来给message_envelope增加了一个degrade_scope字段,标记该条消息允许降级到哪些渠道,像带复杂附件或长链接的消息,默认不允许降到短信,只在渠道白名单内降级。这个坑务必要在设计阶段就想清楚。
5. 从Agent-Reach到更大的触达网络
项目做到这里,我发现Agent-Reach能延展的方向其实很多。最初只做了“Agent主动找用户”,后来用户也会在某些场景里主动触发Agent,比如在IM里给Agent发一条“帮我查一下明天的日程”。这需要Agent-Reach把渠道侧的入口消息也管理起来,也就是把“触达”升级为“会话”。我们现在已经把消息回传事件扩展为包含会话开启、消息上下文改写、会话关闭等类型,Agent可以在这些事件之上构建完整的多轮对话。
另一个方向是触达效果分析。消息发出去了、用户看到了、回复了,这些数据其实非常值钱。Agent-Reach现在落库了所有状态流转和回调事件,后续可以基于这些数据做触达漏斗分析:哪类消息送达率高?哪个渠道的回复率最好?用户更倾向在什么时间点回复?这些信息反过来可以优化Agent的触达策略。
不过我想清楚的一点是,Agent-Reach绝对不会去碰“Agent智能决策”的领域。它不知道消息内容是催办还是祝贺,也不应该自己决定发给谁。它是一根稳定的管道,让聪明的大脑能把思考结果真正送到需要的人手里。这是我做这个项目最有价值的体会之一。
最后再回到实操层面说一句:如果你现在也准备搭一套类似的触达层,记住“统一消息模型、集中路由、全链路状态、渠道适配器”这四个核心就够了。第一版不要追求大而全,一个渠道、两个Agent先跑起来,把状态追踪和幂等做好,后面的路自然就顺了。Agent-Reach这个项目也是如此一点点长大的,希望这份拆解能帮你少踩几个坑。