1. 为什么 AI Agent 任务比普通脚本更需要通知机制
前一晚我往本地队列里丢了一批文档解析任务,交给 AI Agent 去跑,自己去睡了。第二天早上起来,发现它凌晨两点就卡死在一个文件的编码判断上,后面十几个任务全被拖住。这个场景让我意识到:当你把任务交给 Agent 以后,它到底跑完没有、跑挂了没有、结果是不是合理,这些信息不会自动跑来找你。而 Agent 类任务和普通脚本最大的区别就是运行时间不确定,你不能猜它几分钟能结束。后来我写了一个微信推送服务,专门用来接收 Agent 跑完任务的状态。这篇文章把整个选型、实现、集成和踩坑过程都写出来,给同样在折腾 Agent 通知的同学一个参考。适合谁呢?自己在本地跑 Agent 脚本的、用 LangChain 或 LlamaIndex 做批量处理的、做自动化流程不想一直盯着终端的,应该都能从里面找到点有用的东西。
1.1 一个让我失眠的真实场景
那次任务本身不复杂,就是一个文档批量解析的 Agent 流水线。Agent 需要读取几十个 PDF、抽取关键字段、做简单清洗、再写入数据库。单看每个步骤都不难,但步骤之间依赖 LLM 抽取结果,模型偶发输出格式不对,代码就得重试。我一开始觉得“跑多久都行”,睡前只看了眼前几个任务正常,就放心去睡了。
结果凌晨两点,某个 PDF 的内嵌字体有问题,解析器抛了个异常,Agent 连续重试了几次之后卡在死循环里。后面的任务被排着队堵死,没有一个能走到发送结果的那一步。我第二天早上打开终端才发现,那一刻真的很憋屈:不是任务多复杂,而是“任务已经挂了,我却不知道”。
更常见的情况是,你以为 Agent 会在十分钟内结束,实际上它因为一次工具调用超时、一次上下文窗口溢出、一次外部接口限流,硬生生拖了两个小时。你不可能每五分钟盯着终端看一眼。就算在本地开发时可以用终端日志盯,一旦任务部署到服务器上,或者跑在凌晨的定时流程里,传统的“人肉盯梢”模式根本不成立。
1.2 Agent 运行的不确定性是通知需求的根源
普通脚本的耗时基本可以用历史和日志估算,但 AI Agent 的运行路径是动态的。同样是“分析一份合同”的任务,这次模型一步就给出了结论,下次可能要先调用搜索、再读文件、再重试两次。每一步还依赖外部 API 的响应速度。这些不确定性让一个 Agent 任务的结束时间成了一件“猜不准”的事情。
正因为结束时间不可预测,通知的价值就不是“方便”,而是“必须”。你需要 Agent 在真正结束的时候主动告诉你,而不是让你反复去查。只是很多人在搭 Agent 的时候,把精力全放在 prompt、模型选型、工具调用这些“上游”环节上,忽略了收尾时“结果怎么送达”这一步。绕过这个问题的做法往往是打印一段日志,然后就没有然后了。
所以我在给 Agent 加通知机制时,给自己定的需求清单就三条:第一,任务处于终态(成功、失败、超时)时要有消息;第二,通知要能到手机上,不能只停留在服务器日志里;第三,通知内容必须一眼能看出“这个任务值不值得我马上处理”。带着这三条需求,我开始选型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 微信通知方案横评:我为什么锁定了企业微信应用消息
2.1 我试过的几种通知方式
最早我试的是本地通知,那东西在电脑前用还行,人在外面或者任务跑在服务器上,就完全失效。然后我试过邮件,结果和失败摘要确实能写得很详细,但邮件没法做到“秒级提醒”,而且邮箱里一堆低优先级通知,看着看着就麻木了。
接下来是各类“推送中转”服务。它们的方式很统一:你往一个 URL 发 HTTP 请求,服务端转成 App 推送或者微信模板消息。优点是接入快,五分钟就能跑通;缺点是依赖第三方服务的配额和稳定性,消息格式、发送对象、历史记录这些能力还得看服务商脸色。对我这种想把 Agent 通知做成内部基础设施的人来说,定制空间有点不够。
我也认真考虑过直接用企业微信群机器人,就是在群里拉一个机器人 Webhook,然后用 curl 就能发消息。这个东西最大的优点是真的简单,几行代码就能通。但问题也明显:消息是发到群里的,如果你是一个人跑任务,还得专门建个群;群机器人做不到“一对一私聊”,也不方便按任务订阅。你可以用 @所有人 来提醒,但群里的噪音很快会让人关掉通知。
后来我把目光放到企业微信自建应用。它属于企业微信内部应用,调用官方 API 给指定成员发送应用消息。接收人会在企业微信客户端里收到消息,如果绑定了微信插件,还能在微信里收到同步提醒。对我这种生活和工作都在微信生态里的人,这是最顺滑的路径。
个人微信协议机器人我也了解过,就是模拟个人微信登录去发消息。这东西看着方便,但实际上有封号风险,而且核心协议不公开,稳定性全看运气。我劝一句:做个通知功能而已,别拿自己的微信去赌。
2.2 最终选择基于三个理由
我最终选择企业微信应用消息,并不是因为它的代码写起来最简单,而是因为它在“稳定性”和“定制能力”之间最平衡。
第一,到达率高。企业微信作为官方产品,消息通道是稳定的。它不依赖任何第三方中转,也不会因为别人服务器抖动就丢消息。只要我自己的服务没写错,消息基本秒到。
第二,接收体验好。消息可以推到企业微信客户端,绑定微信插件以后也能在微信里收到。我可以把正常 Agent 状态发到企业微信,把特别严重的问题再额外标记出来,手机端弹窗提醒。这种“分级触达”是群机器人做不到的。
第三,API 能力够用。企业微信消息接口支持文本、markdown、图文、文件等多种消息类型。我不仅可以推送“任务完成”的文字说明,后续还可以把 Agent 生成的报告文件直接推给用户。接口免费,频率限制对个人开发者也够用。下面是几个主要方案的对比:
| 通知方式 | 接入成本 | 到达速度 | 定制空间 | 稳定性 | 适合场景 |
|---|---|---|---|---|---|
| 本地通知 | 极低 | 即时 | 差 | 高 | 本地开发调试 |
| 邮件 | 低 | 分钟级 | 中 | 高 | 详细报告、日报 |
| Server酱/PushPlus 等 | 低 | 秒级 | 中 | 依赖第三方 | 个人小项目 |
| 企业微信群机器人 | 低 | 秒级 | 低 | 高 | 群公告、团队播报 |
| 企业微信应用消息 | 中 | 秒级 | 高 | 高 | 一对一通知、通知网关 |
| 个人微信协议机器人 | 中 | 秒级 | 高 | 低 | 不推荐,有封号风险 |
对于我的场景——多个 Agent、不同任务类型、需要按用户订阅、消息还要能扩展成文件卡片——企业微信应用消息是唯一一个不用“绕路”的方案。
3. 推送服务搭建:从零实现一个微信通知网关
3.1 服务整体结构
定下方案后,我在 Agent 和微信 API 之间加了一层“通知网关”。这样设计的原因很简单:Agent 不需要关心 access_token 怎么缓存、微信接口报错了怎么重试,它只需要在最合适的时机告诉我“任务跑完了,你帮我发一条消息”。
整个结构大概是这样的:
code复制Agent 任务结束
└─> 内部通知服务 HTTP API
├─ 校验请求来源
├─ 获取/刷新缓存 access_token
├─ 组装消息内容
├─ 调用企业微信消息发送接口
└─ 返回发送结果给 Agent 方
Agent 端不直接持有企业微信的 CorpSecret,而通知服务统一保管密钥和发送逻辑。这样后面如果我接入多个 Agent,每个 Agent 只需要知道通知服务的地址,改动成本非常小。
3.2 获取 access_token 的正确姿势
企业微信每个自建应用都有独立的 CorpSecret,用它换取 access_token。这里第一个坑是:access_token 有效期只有 7200 秒,过期以后再用会返回错误码 40014。如果每次发消息前都重新获取,虽然能跑通,但会白白消耗接口频率限制,而且并发高时容易触发刷新竞争。
我用了最简单的进程内缓存,把 token 和过期时间存在内存里,只在即将过期时才重新获取。代码大概长这样:
python复制import time
import requests
_token_cache = {
"token": None,
"expires_at": 0,
}
def get_access_token(corpid: str, corpsecret: str) -> str:
now = time.time()
# 提前 300 秒刷新,避免临界点请求失败
if _token_cache["token"] and _token_cache["expires_at"] > now + 300:
return _token_cache["token"]
resp = requests.get(
"https://qyapi.weixin.qq.com/cgi-bin/gettoken",
params={"corpid": corpid, "corpsecret": corpsecret},
timeout=5,
)
data = resp.json()
if data.get("errcode") != 0:
raise RuntimeError(f"gettoken failed: {data}")
_token_cache["token"] = data["access_token"]
_token_cache["expires_at"] = now + data["expires_in"]
return _token_cache["token"]
如果你用的是多进程或多实例部署,进程内缓存就会失效。这种情况推荐把 token 存到 Redis,并加一个分布式锁,保证只让一个实例去刷新。个人项目单进程足够,但如果你打算跑在多个 worker 上,务必把这一步提前考虑。
3.3 发送文本消息的最小实现
有了 access_token,发送消息就很直白了。企业微信消息发送接口的地址是 /cgi-bin/message/send,参数里最关键的是 touser、msgtype、agentid 和消息内容。
python复制def send_wechat_message(agent_id: int, user_ids: list[str], content: str) -> dict:
token = get_access_token(CORP_ID, CORP_SECRET)
resp = requests.post(
"https://qyapi.weixin.qq.com/cgi-bin/message/send",
params={"access_token": token},
json={
"touser": "|".join(user_ids),
"msgtype": "text",
"agentid": agent_id,
"text": {"content": content},
"safe": 0,
},
timeout=10,
)
data = resp.json()
if data.get("errcode") != 0:
raise RuntimeError(f"send message failed: {data}")
return data
这里有两个细节。第一,touser 支持多个用户 ID 用竖线 | 拼接,所以代码里把列表 join 了一下。第二,agentid 必须和企业微信后台创建的应用保持一致,不能拿群机器人的 Webhook 来比。
发 markdown 消息也很简单,只要把 msgtype 换成 markdown,text 改成 markdown 字段,内容里支持基础的 markdown 语法,比如加粗、链接、引用块。我后来把成功、失败、警告几种状态用颜色和引用做了区分,一眼扫过去就知道当前任务是什么状态。
3.4 服务化封装:接口设计、鉴权与重试
直接内部调用函数也行,但为了多个 Agent 都能复用,我把它包成了一个内部 HTTP 服务。对外只暴露一个接口,例如:
http复制POST /notify
Authorization: Bearer <notify_token>
Content-Type: application/json
{
"task_id": "doc-parser-20250312-001",
"title": "财务报告生成",
"status": "success",
"cost_seconds": 754,
"summary": "共处理 12 个文件,输出 report.pdf",
"link": "http://internal.example.com/tasks/doc-parser-20250312-001"
}
服务端收下请求后,先校验 Authorization 头,避免外部随便刷消息;然后把 status 映射成不同的文案和背景色;最后调用发送函数。发完以后返回一个固定结构,Agent 侧可以根据返回值决定是否要重试。
通知服务里我加了最简单的重试机制:当企业微信接口返回 errcode 为 45009(频率限制)或 -1(系统繁忙)时,退避重试最多三次。重试之间用 time.sleep 或者 asyncio.sleep 隔一下,避免集中重试把频率限制打得更死。
要提醒的是,重试要小心“消息重复推送”。如果网络超时导致第一次请求其实已经成功,第二次重试就会让用户收到两条一模一样的消息。我的处理方式是在客户端生成 task_id,服务端把最近发送过的 task_id 存在内存缓存里,短时间内遇到重复 task_id 就直接跳过。这个幂等逻辑放在生产环境不是可选的,是必须的。
4. Agent 端接入:三种方式对比与我的选型
4.1 最简单的方式:任务结束钩子
如果你是自己写的 Agent,最直接的办法就是在任务收尾的地方加一个钩子,用 try...finally 保证不管成功还是失败都会通知。
python复制def run_agent_task(task):
notifier = NotifierClient()
try:
result = task.run()
notifier.notify(
title=task.name,
status="success",
cost_seconds=result.cost_seconds,
summary=result.summary,
)
return result
except Exception as exc:
notifier.notify(
title=task.name,
status="failed",
cost_seconds=task.elapsed_seconds(),
summary=str(exc),
)
raise
这种写法的好处是直观,代码逻辑跟着主流程走,不会漏。坏处是 Agent 内部如果有很多子任务,你只能拿到最外层的结果,拿不到“中间某一步卡住”的状态。对于单任务、单 Agent 的场景,这种方式是我最推荐的。
4.2 事件驱动方式:通过消息队列监听任务事件
当 Agent 数量变多,或者任务跑在多个 worker 上的时候,在主流程里塞通知逻辑会变得很分散。这时更合理的做法是让 Agent 在关键节点发事件到消息队列,比如 Redis Stream 或者 RabbitMQ,然后由一个独立的消费者负责通知。
举个例子,我让每个 Agent 在启动、成功、失败、超时四个节点各发一条事件:
json复制{
"event": "agent.finished",
"agent_id": "doc-parser",
"task_id": "doc-parser-20250312-001",
"status": "success",
"finished_at": "2025-03-12T15:04:33+08:00"
}
消费者收到事件以后,把事件和用户订阅规则做匹配,再调通知服务发微信。这种做法的好处是通知逻辑和 Agent 执行逻辑彻底解耦,你可以随时改通知模板而不需要重新部署 Agent。缺点是引入了额外的中间件,如果只是三五个 Agent,有点杀鸡用牛刀。
4.3 框架回调:LangChain / LlamaIndex 的 Callback Handler
如果你在用 LangChain 或者 LlamaIndex,它们都有回调机制。LangChain 里叫 CallbackHandler,LlamaIndex 里叫 EventHandler。好处是你不必改 Agent 主流程,只需要“挂”一个处理器上去。
以 LangChain 为例,核心思路是继承 BaseCallbackHandler,重写 on_agent_finish 等方法:
python复制from langchain.callbacks.base import BaseCallbackHandler
class WeChatNotifierCallback(BaseCallbackHandler):
def __init__(self, notifier_client):
self.notifier = notifier_client
def on_agent_finish(self, finish, **kwargs):
self.notifier.notify(
title="LangChain Agent 完成",
status="success",
summary=finish.return_values.get("output", ""),
)
def on_agent_error(self, error, **kwargs):
self.notifier.notify(
title="LangChain Agent 异常",
status="failed",
summary=str(error),
)
这里面有个容易被绕进去的坑:LangChain 在执行过程中会触发大量回调,不仅是 on_agent_finish,还有 on_llm_start、on_tool_start、on_chain_end 等等。如果你每个事件都发微信,几轮迭代下来微信会被刷屏。所以回调处理器里一定做过滤,只保留你关心的终态事件。
另外,回调内做网络请求会阻塞 Agent 主循环。如果通知服务不可用,可能反过来拖慢 Agent。我后来把通知发送丢进线程池,用异步方式调通知服务,不让发消息影响任务本身。
4.4 我最终的集成方式
我的项目里既有自研 Agent,也有基于 LangChain 的流程,所以用了混合方式。自研 Agent 在任务结束钩子里直接调通知服务;LangChain 流程则挂一个自定义 CallbackHandler,只监听 on_agent_finish 和 on_agent_error。
两种方式都走同一个通知服务,统一了消息模板和发送通道。我特意没有让 Agent 直接持有企业微信的密钥,所有发送细节都收口在通知服务里。这样后续如果要把微信换成别的渠道,Agent 端一行代码都不用改。
5. 消息模板与频率控制:设计成“不打扰人”的通知
5.1 通知里应该放什么信息
一开始我的通知就一行字:“任务完成”。后来发现这行字几乎没有任何决策价值,因为我不知道是哪个任务、结果长什么样、需不需要现在处理。所以后来我把模板改成了固定格式:
code复制【Agent 任务完成】
任务:财务报告生成
状态:成功
耗时:12分34秒
摘要:共处理 12 个文件,输出 report.pdf
详情:http://internal.example.com/tasks/xxx
状态字段我用不同关键词区分:成功、失败、部分失败、超时。摘要里放 Agent 执行结果的关键信息。如果任务内部处理了很多子步骤,摘要最好概括成一个度量,比如“处理文件数”“生成图表数”“入库记录数”,而不是把所有调试日志都塞进去。
这里有一条很实用的经验:通知不是日志,读者没有精力读五十行堆栈。如果 Agent 失败,我只会把异常类型和出错环节放进消息正文,完整 traceback 通过详情链接查看。微信推送只负责“让你意识到需要处理”,详细排查还是得回到任务系统里。
5.2 频率控制:别让 Agent 把微信刷屏
通知太频繁,人会麻。我见过同事把群机器人接到 CI 上,每次构建都往群里推一条,结果不到一周全员屏蔽。这个教训放在 Agent 场景一样成立。
我给通知服务加了一个最简单的频率控制:同一个 Agent 任务在五分钟之内最多发送一条消息。如果任务状态频繁变化,比如失败后自动重试,只发第一次失败和最终成功;中间的重试过程静默处理。只有一直失败超过三次,才发一条“连续失败”的告警。
实现上可以用 Redis 做滑动窗口,也可以用一个简单的进程内字典记录 agent_id -> last_notify_at。个人项目用后者就够。要注意的就是重启后这个状态会丢,所以如果对通知频率有严格要求的场景,还是放到 Redis 里。
另一个控制手段是“级别”。我把消息分成三类:信息级(任务成功、正常结束)、警告级(部分失败、超时)、错误级(连续失败、系统异常)。默认情况下信息级只在白天推送,警告和错误级不受时间限制。夜间跑任务时,如果任务静默成功,我不会被打扰;只有出错时才会收到微信。
5.3 失败升级:不能只是发一条失败消息
刚接通知服务的时候,我的处理是“失败就发一条”。但有一次 Agent 在凌晨一点失败了,我看到了消息,却想着“明天上班再处理”,结果第二天忘了个精光。后来我加了一个失败升级逻辑:如果任务的失败级别是“高”,并且两小时内没有人在任务系统里点击确认,通知服务会自动再发一条加急提醒,文案会比第一次更醒目。
这个功能不复杂,只需要在数据库里存一条 notify_record,记录消息是否被确认,然后跑一个定时任务扫描未确认的失败消息。如果你还不想上数据库,也可以用 Redis 的过期键来标记“已确认”。对个人项目来说,这个升级机制比想象中重要,因为它让通知不再只是“看到”,而是“有反馈闭环”。
6. 生产环境避坑记录:这些坑会让你浪费一个下午
6.1 access_token 过期与并发刷新
第一次上线时,我的通知服务跑在单进程里,token 缓存得很稳。后来为了不阻塞 Agent,我把发送逻辑放进线程池,结果并发一高,多个线程同时发现 token 快过期,一起发起刷新请求。企业微信那边对同一个应用并发的 gettoken 请求有频率限制,于是出现了一个线程刷新成功了,另一个线程因为太频繁被限流,拿着旧的 token 去发消息,返回 40014。
解决办法有两个方向:一个是给 gettoken 加锁,保证同一时刻只有一个线程在刷新;另一个是在发送遇到 40014 时,主动清掉缓存并强制刷新一次再重试。我后来两个都做了,双保险。
python复制def send_with_retry(agent_id, touser, content):
for attempt in range(2):
try:
send_wechat_message(agent_id, touser, content)
return
except InvalidTokenError:
_token_cache["token"] = None
continue
raise
6.2 IP 白名单和企业可信 IP 的坑
企业微信自建应用后台可以配置“企业可信 IP”。如果调用接口的服务器 IP 不在白名单里,接口会报错返回 60020。这个问题在你本机调试时不会暴露,因为第一次配置你可能就把自己电脑的 IP 加进去了,但代码部署到服务器以后,服务器 IP 是另一个地址,忘了配置就会一直报错。
我踩这个坑的时候花了快半小时。因为当时所有参数看起来都是对的,密钥也没输错,但就是发不出去。最后翻文档才发现是后台可信 IP 配置的问题。所以搭建通知服务的第一步,先确认服务器公网出口 IP 并把它写进企业微信后台,可以省下排查时间。
如果你的服务器 IP 会经常变化,比如某些临时实例、容器环境,可以给通知服务单独绑一个固定出口,或者在每次部署时用脚本自动更新可信 IP 列表,免得哪天 IP 变了通知就悄悄失效。
6.3 推送成功不等于任务成功
这个坑特别隐蔽。我在 Agent 代码里不小心把通知调用放在了“保存结果”之前。当时任务逻辑是先调通知服务、再写数据库。一旦 Agent 在发完消息之后、写数据库之前崩溃,用户会收到一条“任务完成”的消息,结果去查数据发现什么都没有。
意识到问题以后,我的调整很简单:通知发送必须是 Agent 任务收尾动作的最后一步。比如“生成结果 → 持久化 → 更新状态 → 再通知”,顺序不能乱。尤其在多步骤流程里,每完成一个阶段就发一次通知,很容易出现“阶段成功了但全局任务失败”的误导。我现在只在全局终态发通知,中间过程最多打日志。
6.4 时间同步和超时设置容易忽略
容器里的时钟偏移会影响 token 缓存判断。比如容器时间比真实时间慢了十分钟,本地判断 token 还有效,实际上已经过期,发消息就会失败。如果你的服务跑在 Docker 或 Kubernetes 里,记得检查容器时间是否同步。或者更稳妥一点,在发送失败时也基于错误码做一次强制刷新,而不是完全相信本地时间。
另一个小坑是 HTTP 请求不设超时。企业微信接口偶尔会慢,如果 requests.post 不设 timeout,线程会一直挂在那里,久而久之连接池被占满,整个通知服务假死。我给所有外部 HTTP 调用都设置了 5 到 10 秒的超时,宁可这次发送失败触发重试,也不能让线程被一个网络问题拖死。
6.5 频率限制与重试策略
企业微信应用消息接口有频率限制,普通应用不是无限发的。当你突然给一群人发批量通知,或者 Agent 短时间内疯狂重试时,接口会返回 45009。这个错误不能靠简单重试解决,重试越快,限制越死。
我的处理方式是把发送请求放到一个有界队列里,由单线程消费者按固定间隔发送,相当于自己做了个平滑限流。比如正常情况下每秒钟最多发五条,剩下都排队。转发失败或频率超限的消息,先等 30 秒再重发,整体退避时间不超过三次。
这里要注意,企业微信后台能看到每个应用的调用量。如果你真的出现大量发送需求,先评估一下是不是通知太频繁,从源头减少消息数量,而不是单纯提高发送速率。
7. 从“推给自己”到“团队通知网关”:后续扩展
7.1 实际使用效果
这个通知服务上线以后,我的直接体感是:出问题到发现问题的时间,从“第二天早上”变成了“几分钟内”。以前跑一个 Agent 批量任务,睡前总惦记着要不要再起来看一眼。现在只要微信没有弹错误提醒,我就能睡个安稳觉。第二天早上看到一条“成功”的消息,顺手点进详情链接看摘要就行。
团队里有同事看到我在用,也问能不能接入他们的 Agent。我当时把通知服务稍微改了一下,加了一个很简单的用户订阅接口,让同事用企业微信账号绑定自己想接收的任务类型。其实核心逻辑没变,只是 touser 从固定用户变成了按订阅关系动态查询。
7.2 支持 markdown、文件卡片和定时汇总
企业微信消息接口支持的文件类型和 markdown 能力,让我可以做得更细。比如 Agent 生成了一份 PDF 报告,完成后我可以先发一条文字消息,再把 PDF 作为文件消息推给用户。文件消息接口需要先上传临时素材拿到 media_id,这会多一步异常处理,但体验比只发一个链接好很多。
我还有一个想法是定时汇总。如果一天有十来个 Agent 任务,每个都推送一条消息还是偏碎。可以加一个“午间摘要”和“晚间摘要”,把白天完成的任务汇总成一张表推送。这个功能我目前只做了一半,还在继续打磨。
7.3 做成一个统一通知网关
往后如果想做得更系统,可以把这套通知服务升级成团队内部的统一通知网关:支持多渠道、多用户、多 Agent,消息按级别路由,失败自动升级,后台还能看发送记录。这样每个 Agent 不需要关心用户偏好,只需要用统一接口上报事件。
对我来说,这个项目的初衷很朴素:AI Agent 跑完任务,别让我一直守着。通知服务代码量不大,但解决了真实痛点。如果你也在折腾 Agent,建议先别急着上复杂系统,把“跑完怎么通知你”这层打通,后面的自动化流程会顺很多。
