做 AI Agent 开发这段时间,我最大的痛点不是模型选型,不是 Prompt 调优,而是"跑完任务怎么通知你"这件事。每天早上把一批数据处理任务丢给 Agent,然后它在那儿跑十几分钟甚至一两个小时,我只能隔三差五切回终端看一眼进度,整个人被绑在工位上。后来我写了一个微信推送服务,把 Agent 的关键状态实时推到手机上,才算是真正解放了。这篇文章就聊聊这个通知服务的完整实现方案,从选型、代码到踩坑,适合正在做 AI Agent 应用、批量任务自动化、或者任何需要远程感知任务状态的朋友参考。内容不依赖特定框架,LangChain 用户和手写 Agent 的朋友都能直接抄作业。
1. 每天盯着终端等 Agent 跑完,我受够了
1.1 真正逼我动手的一次事故
我手头有一个数据采集 Agent,任务是从一批行业网站抓取公开信息,做清洗、去重、聚合,最后生成一份摘要报告。单次任务跑完大概需要四十分钟到一个半小时不等,取决于目标页面数量和接口的响应速度。那天下午我把任务丢进去,想着二十分钟后回来看一眼。结果开会开过头,回来的时候终端上已经是一片报错——Agent 在前五分钟就因为某个目标站返回了异常页面而中断,后边一个多小时全是空转。
当时我第一反应是"这玩意儿能不能主动通知我"。与其以后继续靠人肉轮询,不如花点时间把通知能力一次做好。那段时间我正好在优化整个 Agent 的运行链路,Notification 这个模块就是典型的"平时想不起、出事才后悔"的功能。说实话,如果那次不是损失了一个半小时,我可能到现在还在手动盯着终端。
1.2 Agent 任务和传统脚本的通知需求差别在哪
传统定时脚本、CI/CD 任务的通知模式很成熟:跑完发一封邮件、发一条群消息,失败再追加一条告警,基本够了。但 AI Agent 不一样,它的任务执行是动态的,你没法在任务下发时预知它什么时候会结束。具体到我的使用场景,Agent 和普通脚本有三点显著差异。
第一,Agent 的耗时不可控。LLM 的推理速度、工具调用的外部响应时间、甚至上下文长度导致的生成变慢,都会让实际执行时间产生很大波动。说好四十分钟的任务,可能因为某次 API 重试拖到两小时。
第二,Agent 内部有多个环节都可能失败。LLM 调用报错、工具参数格式不对、上下文超长被截断、外部接口限流,任何一个环节出问题,最终结果都可能和你预期相差十万八千里。普通脚本的退出码只能告诉你"挂了",Agent 失败还需要知道"挂在哪一步、为什么挂"。
第三,多 Agent 协作场景下,各子任务状态彼此关联。一个子 Agent 失败,可能会让主 Agent 重新规划,甚至把整个任务带偏。这种时候通知不只是"结束通知",而是需要事件级别的状态流。
这些差异决定了 Agent 的通知服务不能是简单发一条"跑完了"的邮件,它至少要支持多消息类型、能区分任务阶段、能携带上下文信息,最好还能带格式和 @ 提醒。顺着这个需求,我开始做方案选型。
2. 微信推送方案横向对比:为什么最后选了企业微信群机器人
2.1 个人微信自动化方案,我第一个排除了
很多人第一反应是"用个人微信给自己发消息"。确实,个人微信使用最普遍,如果能直接在个人微信上收通知,体验最顺。但个人微信没有一个官方开放的、面向开发者的消息推送 API。GitHub 上能找到基于 hook 或逆向的第三方方案,我也试过其中一个,结论是风险完全不可控:账号容易被平台风控甚至封禁,登录态经常失效,还要维护一个长期在线的登录进程。为了让 Agent 发条通知把自己的微信号搭进去,太不划算了。个人微信是拿来日常沟通的,不是拿来当基础设施的,这一点在我心里直接判了它出局。
2.2 Server酱和 PushPlus:方便是方便,但有两个顾虑
Server酱和 PushPlus 这类第三方推送服务,思路是把 HTTP 请求转成微信消息送达。Server酱走的是服务号模板消息,PushPlus 走的是公众号渠道。我用过 Server酱,接入确实快,注册拿个 SendKey,POST 一个 JSON 就完事,几分钟就能跑通。
但它有两个让我犹豫的地方。一是消息内容会经过第三方服务器中转,虽然不是绝密数据,但 Agent 任务里可能有内部系统名称、项目代号、报错日志堆栈这类信息,我不想每次都让它们在别人服务器上过一遍。二是这类服务的可用性不完全由你控制,一旦服务方调整策略、限流或者临时故障,你的通知链路就断了。免费档还有每天消息条数限制,Agent 跑得勤一点就要精打细算。
当然,如果只是本地小工具要个通知,Server酱完全够用,我并没有否定它。只是我的场景里,Agent 是要长期稳定跑的,我更希望通知通道掌握在自己手里。
2.3 企业微信群机器人:免费、可控、接口足够用
最后我锁定了企业微信群机器人。它有几点非常对我的需求。
第一,完全免费,不需要企业认证。你用个人身份注册一个企业微信(一个人也能创建企业),建一个群,往群里加一个机器人,就拿到 Webhook 地址了。
第二,接入成本极低,就是一个 POST 请求。没有 OAuth、没有签名、没有复杂的鉴权流程。Webhook 是标准的 HTTPS URL,任何语言都能调。
第三,消息类型覆盖了我的需求:文本、Markdown、图片、文件、图文卡片都有。Markdown 消息让我能把"任务耗时、成功/失败、关键执行路径"这些信息组织得清清楚楚,比纯文本强太多。
第四,支持 @ 提醒。文本消息里可以 @ 指定成员,甚至可以 @ 所有人。这对"任务失败需要立即处理"的场景很关键,尤其是 Agent 半夜跑挂了,一条带 @ 的失败通知比什么都管用。
至于企业微信的缺点——必须装企业微信 App、不能发到个人微信——在我这里不算问题。反正通知是手机推送,企业微信 App 在后台待着,消息到了就有提示,和收到微信消息的体验差别不大。
3. 从建群到拿到 Webhook:通知通道的最小闭环
3.1 企业微信群机器人的创建路径
具体操作路径如下,按部就班五分钟内能搞定。
第一步,注册企业微信。个人就能注册,不用提交营业执照,注册完成后你的企业里只有你一个人也没关系。
第二步,在企业微信客户端里创建一个群聊。如果企业里只有你自己,创建一个只有自己的群也可以,后续需要拉人再加。机器人身份是独立的,它发消息不会占成员名额。
第三步,进入群聊,点击右上角菜单,找到"群机器人",选择"添加机器人"。给机器人起个名字,比如 Agent-Bot,创建完成后会显示一个 Webhook 地址,格式大概是这样:
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
这个地址就是唯一需要保存的东西。复制保存好,建议直接放到环境变量里,不要硬编码在代码里。
第四步,创建机器人时可以设置关键词。如果你设置了关键词,机器人只发送包含"任务"或"Agent"这类关键词的消息,不匹配的请求会被接口拒绝。这个功能我强烈建议打开,一会儿在避坑部分详细说为什么。
3.2 Webhook 的安全边界与密钥管理
Webhook 地址就是通知通道的钥匙,谁拿到它谁就能往你的群里发消息。所以第一个原则是:永远不要提交到 Git 仓库。我之前见过有人把 Webhook 写进配置文件的示例代码里然后推到公开仓库,结果被扫描程序抓到,群里瞬间涌入一堆广告。处理办法很简单:从环境变量或本地密钥管理工具里读取。
我在代码里是这样组织的:设定一个环境变量
WECHAT_WEBHOOK_URL
,代码里只读环境变量,不在项目里保留任何真实地址。部署的时候通过系统服务配置或 CI/CD 的 Secret 注入,这样即使代码仓库泄露,通知通道也是安全的。
还有一个容易被忽略的点:Webhook 一旦泄露,光改代码里的地址没用,最干净的做法是去群里把机器人删掉再重新添加一个,换一个全新的 Webhook 地址。旧地址作废,攻击者手里的钥匙直接失效。
4. 通知服务核心代码:一个自带超时重试的 Python 客户端
4.1 先写一个能发纯文本和 Markdown 的最小客户端
企业微信群机器人的接口协议很简单,就是向 Webhook 地址 POST 一个 JSON。文本消息的格式是这样:
{
"msgtype": "text",
"text": {
"content": "Agent 任务已完成",
"mentioned_list": ["@all"]
}
}
Markdown 消息则是:
{
"msgtype": "markdown",
"markdown": {
"content": "### Agent 任务报告\n> 任务ID: `task-123`\n耗时: **48s**"
}
}
我封装了一个
WeChatBot
类,支持文本和 Markdown。代码不长,核心逻辑都在下面:
import time
import requests
import logging
logger = logging.getLogger(__name__)
class WeChatBot:
"""企业微信群机器人推送客户端"""
def __init__(self, webhook_url: str, timeout: int = 10, max_retries: int = 3):
self.webhook_url = webhook_url
self.timeout = timeout
self.max_retries = max_retries
self._last_send_time = 0.0
def _post(self, payload: dict) -> bool:
# 本地节流,避免连续发送触发接口频率限制
gap = time.time() - self._last_send_time
if gap < 1.0:
time.sleep(1.0 - gap)
for attempt in range(1, self.max_retries + 1):
try:
resp = requests.post(
self.webhook_url,
json=payload,
timeout=self.timeout,
)
data = resp.json()
if data.get("errcode") == 0:
self._last_send_time = time.time()
return True
logger.warning("企业微信返回错误: %s", data)
if data.get("errcode") == 93000:
# 触发频率限制,多等一会再重试
time.sleep(15)
else:
time.sleep(2 * attempt)
except requests.RequestException as exc:
logger.warning("请求企业微信接口异常: %s", exc)
time.sleep(2 * attempt)
return False
def send_text(
self,
content: str,
mentioned_list: list[str] | None = None,
mentioned_mobile_list: list[str] | None = None,
) -> bool:
text = {"content": content}
if mentioned_list:
text["mentioned_list"] = mentioned_list
if mentioned_mobile_list:
text["mentioned_mobile_list"] = mentioned_mobile_list
return self._post({"msgtype": "text", "text": text})
def send_markdown(self, content: str) -> bool:
return self._post({"msgtype": "markdown", "markdown": {"content": content}})
几个细节解释一下。
节流那行是我实际使用后加上的。企业微信群机器人的接口有频率限制,实测在短时间内连续发送超过 20 条就会返回 errcode 93000。Agent 在循环里调工具时,如果每个工具调用都发通知,极容易撞上这个限制。本地加一个 1 秒的最小间隔,虽然粗暴,但能挡住绝大多数连发场景。
重试策略里,93000 单独处理也很重要。93000 代表频率超限,按普通间隔重试没有意义,需要等更久。我设了 15 秒,实测在每分钟 20 条的限制下,等 15 秒后基本能恢复。
4.2 图片消息和文件消息:发送前要先上传临时素材
群机器人除了纯文本和 Markdown,还支持图片和文件。文件消息尤其有用——Agent 跑完生成报告、CSV、日志压缩包,可以直接推到群里,省去再开一次网盘或邮箱的流程。
但有个隐藏细节:文件消息的
media_id
不是直接传本地路径,而是要先通过上传接口把文件传到企业微信的临时素材库,拿到
media_id
后再发消息。
import os
class WeChatBotWithFile(WeChatBot):
def _upload_file(self, file_path: str) -> str | None:
key = self.webhook_url.split("key=")[-1]
upload_url = (
f"https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media"
f"?key={key}&type=file"
)
with open(file_path, "rb") as fh:
resp = requests.post(
upload_url,
files={"media": (os.path.basename(file_path), fh)},
timeout=self.timeout,
)
data = resp.json()
if data.get("errcode") == 0:
return data["media_id"]
logger.error("上传素材失败: %s", data)
return None
def send_file(self, file_path: str) -> bool:
media_id = self._upload_file(file_path)
if not media_id:
return False
return self._post({"msgtype": "file", "file": {"media_id": media_id}})
上传接口是
webhook/upload_media
,注意 URL 里有
key=xxx
,和你发送消息时的 Webhook 地址是同一个 key。文件大小限制是 20MB,超过的话建议先压缩,或者只传摘要文件。
4.3 消息长度和内容格式的几个硬性限制
群机器人的消息不是无限长的,我在开发时踩到过几个边界,列出来省得你们再试错。
文本消息的 content 最长支持 2048 字节。Agent 的报错堆栈经常能轻松超过这个长度,所以发送错误信息时,我会先截断到 1500 字节左右,再拼一段"完整日志见附件",配合文件通道把完整日志发过去。
Markdown 消息的 content 最长 4096 字节。另外,这里支持的 Markdown 是企业微信自定义的子集,不是标准 GFM。支持
#
到
###
三级标题、
**加粗**
、
[链接](url)
、引用、无序列表,但不支持行内图片,也不支持表格。设计模板的时候绕开这些不支持的语法,免得手机上显示得乱七八糟。
图片消息必须是 jpg/png,大小限制 2MB。超过 2MB 的截图建议先用工具压一下,或者干脆走文件通道。
5. 把通知服务挂载到 Agent 执行链路:回调、埋点与消息聚合
5.1 LangChain / LangGraph 回调接入:不侵入业务逻辑
如果你用 LangChain 或 LangGraph 搭建 Agent,最优雅的做法是实现一个
BaseCallbackHandler
。回调机制的好处是 Agent 本身的业务逻辑完全不用改动,框架在事件发生时自动触发你的方法。
import time
from langchain_core.callbacks import BaseCallbackHandler
class WeChatNotifyHandler(BaseCallbackHandler):
def __init__(self, bot: WeChatBot, task_name: str = "Agent"):
self.bot = bot
self.task_name = task_name
self._start_time = None
def on_chain_start(self, serialized, inputs, **kwargs):
if self._start_time is None:
self._start_time = time.time()
self.bot.send_text(f"[{self.task_name}] 任务已开始执行")
def on_chain_end(self, outputs, **kwargs):
if self._start_time is not None:
duration = time.time() - self._start_time
self.bot.send_markdown(
f"### {self.task_name} 执行完成\n"
f"- 耗时: **{duration:.1f}s**\n"
f"- 结果摘要: {str(outputs)[:200]}"
)
self._start_time = None
def on_chain_error(self, error, **kwargs):
self.bot.send_text(
f"[{self.task_name}] 执行失败: {str(error)[:500]}",
mentioned_list=["@all"],
)
self._start_time = None
实际使用中,我更关注
on_llm_error
和
on_tool_error
这两个事件。Agent 的失败很多时候不是整体崩溃,而是某个工具调用出错、LLM API 返回异常,框架会继续重试或重新规划。如果只看最终结果,你根本不知道中间经历了多少次重试。把这些异常事件也通过
send_text
推到群里,能帮你在任务还没结束时提前介入。
LangGraph 的情况类似,它的事件监听也走回调体系,只是可以更精细地指定"在哪个节点后触发通知"。比如我想在"数据抓取节点"结束后通知一次,在"报告生成节点"结束后再通知一次,就在对应节点挂一个回调函数即可。
5.2 自定义 Agent 的最简埋点方式
如果你的 Agent 是自己手写的 ReAct 循环,或者其他没有回调机制的框架,最直接的做法就是埋点。我在自己的采集 Agent 里就是这么干的。
def run_agent_task(task: str) -> str:
bot = get_bot()
bot.send_text(f"开始执行任务:{task[:100]}")
start = time.time()
try:
result = agent.execute(task)
duration = time.time() - start
bot.send_markdown(
f"### 任务完成\n"
f"- 任务: {task[:50]}\n"
f"- 耗时: **{duration:.1f}s**\n"
f"- 结果预览: {result[:300]}"
)
return result
except AgentExecutionError as exc:
bot.send_text(
f"任务失败: {task[:50]}\n错误: {str(exc)[:300]}",
mentioned_list=["@all"],
)
raise
埋点位置有个原则:通知调用要放在任务边界上,不要放在工具循环内部。放在循环里一方面会刷屏,另一方面会把通知服务和 Agent 的核心逻辑耦合得太紧。
还有一个很重要的细节:通知发送不能影响 Agent 主流程。如果
bot.send_text
内部抛了异常,不能让它把 Agent 任务本身带崩。所以我会给通知调用包一层
try/except
,或者把
WeChatBot
内部的异常全部吞掉并记录日志。通知服务可以失败,Agent 不能因为通知失败而中断。
5.3 消息聚合:避免通知轰炸
第一次上线时,我收到了一连串消息——开始、每个工具调用、一次重试、最终结果。手机被刷屏,后来我意识到通知必须分级和聚合。
一个值得借鉴的思路是把通知分成三级:
- 一级,立即通知:任务开始、任务最终成功、任务最终失败、关键安全事件。这些事件直接推送,即使多几条也没关系。
- 二级,延迟聚合:工具调用失败、LLM 重试、子任务完成等过程事件。这些不立即发,而是放进一个队列,每 5 分钟汇总一次。
- 三级,不推送:常规工具调用成功、内部日志等。只在最终报告里体现。
聚合实现不复杂。我维护了一个全局消息队列,二级通知只往队列里追加,后台一个定时任务每 300 秒把队列里的消息合并成一条 Markdown 发出去。合并后的消息大概长这样:
### Agent 过程汇总(最近5分钟)
- 工具调用失败: 2 次
- search_tool: 超时
- fetch_page: HTTP 503
- LLM 重试: 1 次
- 子任务完成: 3 个
这样既不会漏掉关键信息,也不会把通知通道变成垃圾场。
6. 上线一个月后踩过的坑
6.1 一次"消息被吞"的排查全过程
上线后第一次遇到问题是:Agent 日志里明明执行了
send_text
,返回值也是
True
,但手机上一个字都没看到。刚开始我还怀疑是企业微信 App 的通知被系统拦截,折腾半天后才发现问题出在发送方。
排查链路是这么走的。第一步,看 Agent 的日志。日志显示
send_text
返回了
True
,说明 HTTP 请求成功了。第二步,直接手动调接口,用一个 curl 向同一个 Webhook 发一条测试消息,发现消息又能正常到达群里。这下就排除了 Webhook 本身的问题。第三步,回到代码里仔细检查
_post
的返回值逻辑——结果发现我的重试代码里有个 bug:当企业微信返回
errcode=93000
时,重试后接口虽然最终返回了成功,但因为本地节流的
_last_send_time
更新时机不对,导致后续某几次循环里的发送请求又撞上 93000 被拦截,而
_post
因为重试成功返回了 True。手机上看到的是"发送成功",实际上最后一条消息根本没发出去。
这类问题在本地测试时不会暴露,因为手动调用频率低;一旦 Agent 在高频循环里调用,就会间歇性出现。解决方案就是把 93000 处理逻辑单独抽出来,并统一用一个集中式的速率限制器,而不是依赖靠睡线程的粗糙节流。这是我在这件事上收获最大的教训: "推送成功"的返回 True 不代表消息真的到了手机,还要考虑企业微信侧是否真的接受了。
6.2 频率限制 93000:比文档写得更容易触发
企业微信群机器人官方给出的频率限制是每个机器人每分钟最多 20 条。20 条/分钟听起来不少,但 Agent 并发执行时完全可能瞬间塞满。我遇到过一个场景:三个子 Agent 并行跑,每个在完成时都会发送通知,再加上主 Agent 的过程汇总,一分钟内轻松超过 20 条。
应对策略有几个层面。首先,在客户端做全局限速,我这里用一个简单的令牌桶,每秒恢复 1 个令牌,桶容量 20。其次,在业务层把可聚合的消息合并,减少消息总数。最后,在
_post
收到 93000 时做指数退避重试,而不是固定等待后乱试。
还有一点要提醒:企业微信这边没有调高额度的通道,至少群机器人没有。能做的只有减少消息量、排队发送,以及设计更好的聚合策略。
6.3 Markdown 渲染、@提醒和手机端设置的细节
最后几个容易被忽略的小细节,我分别踩过,逐个说。
Markdown 里链接的跳转行为和企业微信 Web 端、客户端版本有关。有的版本里链接会在内置浏览器打开,有的会跳到外部浏览器,如果你的 Agent 消息里带管理后台链接,建议提前在手机上测一遍,确认点击行为符合预期。另外,纯文本消息里的
http://
或
https://
链接,不一定会被自动识别为可点击链接。要发链接建议走 Markdown 的
[text](url)
格式,手机端点击体验会好很多。
@提醒的语法也容易搞混。文本消息里的
mentioned_list
填的是企业微信的 userid,不是昵称;如果你不确定 userid,可以用
mentioned_mobile_list
填手机号,企业微信会根据手机号匹配成员。
@all
则直接填字符串
@all
即可。实测用手机号最稳,因为个人注册的企业微信里 userid 默认是一串随机字符,不好记也不好看。
手机端推送这块,企业微信群消息默认不一定弹通知。如果群被设置了免打扰,机器人消息到达时手机只会在企业微信 App 内部显示一个红点,系统通知栏可能不弹。我自己的做法是:通知群关闭"群聊免打扰",或者更精确一点,给机器人消息设置关键词强提醒,只有包含"失败""完成"这些关键词的消息才会震动,过程消息安静地躺在群里。
最后再说一个我实际使用后的习惯:把通知服务和 Agent 主进程解耦。Agent 把要推送的消息写入本地队列,由一个独立的小进程消费并调用企业微信接口。这样就算 Agent 进程因为极端 bug 崩溃了,队列里已经落盘的消息依然能正常发出去,不会出现"Agent 挂了,通知也跟着沉默了"的情况。这一步是我做多 Agent 监控时才加上的,但它其实一开始就该有——通知链路作为旁路存在,不能和主链路同生共死。如果你也在给 Agent 做通知,建议从这个架构理念入手,后面的扩展会轻松很多。

159

被折叠的 条评论
为什么被折叠?



