AI Agent任务通知方案:用企业微信群机器人实现实时状态推送

做 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 做通知,建议从这个架构理念入手,后面的扩展会轻松很多。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值