💡这两年我给自己攒了十条定时任务:每天领网站的钻石、签到回帖、把日记同步到 Notion、把待办导成 Markdown、早上巡检自己的小产品、每周一份竞品动态。它们确实每天都在跑,lastRunStatus也基本都是succeeded。
可是用了两个多月之后我发现一件事:我从来不知道它们到底跑成了什么样。要确认,就得自己打开对话记录一条一条翻,或者去 Notion 里看那天有没有新增行。换句话说,我给我的 Agent 装上了手和脚,唯独没装嘴巴——它会干活,但干完活不出声。
更麻烦的是,"没出声"和"没干活"在界面上长得一模一样。
所以这一篇想讲的就是一件很小的事:怎么把一个"发通知"的能力做成 Skill,让每一条定时任务跑完之后自己喊一声——成功了喊成功,失败了喊失败,需要我动手的时候把我叫过来。这是「玩转大模型」系列的第七篇。
一、Agent 有手有脚,唯独没有嘴巴
先看一眼问题的真实形状,再决定要不要给它装嘴巴。
我把自己当前的定时任务配置整份导出来看过一遍,两个数字挺难看:
| 观察项 | 结果 |
|---|---|
| 启用的定时任务 | 10 条 |
| 指令里明确要求"发通知/发消息"的 | 0 条 |
任务里挂了 Skill 的(selectedSkillNames) | 0 条 |
| 跑完会主动告诉我"需要你介入"的 | 0 条 |
也就是说,我写的每一条指令都是只进不出:进去一堆动作(打开浏览器、点按钮、查数据库、写一行 Notion),出来一份报告。而那份报告落在一个没人看的会话里。
指令 --> Agent 执行 --> 生成报告 --> 写进某个会话 --> 结束 ↑ 没有一条边连到你Q1:平台不是有运行记录和状态吗?
有,而且状态看着还挺可靠——问题就在这里。
我导出记录的那天,几条任务的运行状态是succeeded,可对应的lastDurationMs分别只有 8、10、20、21 毫秒。这几个任务本来的动作是启动浏览器、连 CSDN 后台、查 Notion、写文件。8 毫秒连一次 TCP 握手都不够。
那它们为什么是succeeded?因为调度器只知道"这次运行没有抛出异常",它不知道"这次运行有没有真的干活"。**平台能告诉你任务有没有崩,不能告诉你任务有没有成。**这两件事差得远:
| 状态来源 | 能回答 | 不能回答 |
|---|---|---|
| 调度器的运行状态 | 进程有没有异常退出 | 业务目标有没有达成 |
| 任务自己的报告 | 达成了什么、哪一步落空 | 你是否看到了这份报告 |
| 出站通知 | 你现在知道了 | —— |
真正缺的就是第三层。前两层我都已经有了,只是它们停在"写下来"这一步,没有"送出去"。
Q2:那让模型在指令里直接 fetch 一个 webhook 不就行了?
能跑,但会烂掉。我试过写在指令里,三个问题马上就出来:
- **凭据进了对话。**指令文本里挂着 webhook 地址和密钥,意味着它出现在聊天记录里、出现在我每次让模型改指令的 diff 里、也可能出现在我截图求助时。
- **收件人成了一个可写参数。**模型既然能拼出这个 URL,它就能在某次"理解偏差"里拼出另一个 URL。发错群这种事,一旦发生过一次,你就不敢再自动化了。
- **成功判定不可靠。**模型看到
HTTP 200就会说"已发送",而这三家 IM 机器人都用 200 包住错误。
所以我想要的其实不是"会发消息的模型",而是"一个模型不能乱改的发消息能力"。这两句话的差别,就是这篇文章剩下的部分要讲的。
二、为什么这个能力要做成 Skill
同一个"发通知",可以做成脚本、MCP Server 或者 Skill,三者的边界完全不同。
我的判断标准很简单:**这段知识是"怎么调工具"还是"什么时候该调、调到什么程度算对"。**前者适合脚本和 MCP,后者适合 Skill。
| 承载方式 | 它擅长什么 | 在"发通知"这件事上的问题 |
|---|---|---|
| shell 脚本 / CLI | 确定性的执行,谁调都一样 | 模型不知道什么时候该调、该传什么状态 |
| MCP Server | 把外部系统接成工具,带 schema | 一个只有四个参数的能力不值得占一份工具注册表;连接和进程都是成本 |
| Skill | 触发条件 + 边界 + 脚本,一次装好反复用 | 需要你自己把边界写死,否则模型会自由发挥 |
我最后做的是第三种,目录结构就三样东西:
notify-ability/ ├── SKILL.md # 触发条件、三条硬约束、通道口径表、禁止事项 └── scripts/ ├── notify.py # 单文件、只用标准库,299 行 ├── config.example.json# 六个通道的配置骨架,凭据由用户自己填 └── fake_smtp.py # 本机回环 SMTP,用来测 mail 通道SKILL.md的 description 里我写清了触发词,这是它作为 Ability 被"想起来"的入口:
Use when a cron job finishes and must announce the result, when the user asks to 加通知/发提醒/装上嘴巴/推到手机, or when a run must pause for human approval. Not for bulk alerting, routing tables, or on-call escalation.最后一句 “Not for” 同样重要——不给它写清不该干什么,早上的巡检任务会给你发二十条消息。
这个 Skill 只守三条规则,三条都是从上面那三个坑里长出来的:
- **发给谁是配置,不是参数。**命令行里没有
--to、没有--webhook、没有 chat id。模型能写的只有状态、任务名和一句结论。它没法把消息改道,webhook 也永远不会出现在对话里。 - **成功由通道自己说,不由 HTTP 状态码说。**脚本会解析响应体里的错误码,判定不通过就退出
1,打印NOT SENT。 - **措辞来自模板。**调用方只提供
status / task / summary / detail / link五个字段,emoji、字段顺序、时间戳由脚本决定。这样十条任务的报告长得像同一个人写的,而不像十次自由发挥。
Q3:这三条约束会不会太硬,把有用的用法挡掉了?
会,而且我故意的。比如"一次运行只发一条、只发一个通道"这条,看起来妨碍了"我想同时发到手机和工作群"。但允许脚本遍历收件人列表,就等于允许某次模型理解偏差把内部报告发到外部群。这个能力的使用频率很低(一天三四条),多写一行配置的成本远小于发错一次的成本。
三、六个通道,六种"成功"的定义
真正动手才发现:所谓"发个通知",六个通道有六套规则、六种回执。
我是按"谁在什么时候需要被打扰"来选通道的,所以六个都实现了一遍:个人的手机(ntfy、Bark)、团队群(飞书、企业微信、钉钉)、以及兜底的邮箱(SMTP)。
先给结论表,这张表就是SKILL.md里那张的展开:
| 通道 | 判定送达的方式 | 频率限额 | 正文体积上限 | 一句话定位 |
|---|---|---|---|---|
| ntfy | HTTP 200且回执event == "message"且有id | 突发 60 条,之后每 5 秒补 1 条;ntfy.sh每日 250 条 | 4 KB | 最省事的个人通道,URL 即凭证 |
| Bark | HTTP 200且回执code == 200 | 官方中继未公布 | URL 形态,长文要转义 | iOS 唯一能绕过后台限制的自推方式 |
| 飞书群机器人 | code == 0 | 100 次/分钟,且 5 次/秒 | 20 KB | 富文本最完整,但限速是双维度 |
| 企业微信群机器人 | errcode == 0 | 20 条/分钟 | text2048 B、markdown4096 B | 最容易,但 markdown 不支持表格 |
| 钉钉群机器人 | errcode == 0 | 20 条/分钟,超限封禁 10 分钟 | 同企微量级 | 唯一需要 URL-encode 签名的 |
| SMTP | 服务器接受RCPT TO且无拒收地址 | 取决于服务商 | 实践上几十 KB | 唯一能留档、能转发的通道 |
这张表里最容易踩的是最后一列之前的那一列:除了 Bark 和 ntfy,失败也返回 HTTP 200。
我实测过三种故意的错误配置,三家都给我 200:
飞书 HTTP 200 {"code":19001,"msg":"param invalid"} 企业微信 HTTP 200 {"errcode":93000,"errmsg":"invalid webhook url"} 钉钉 HTTP 200 {"errcode":300005,"errmsg":"..."}如果你只用resp.status == 200判断,那么"配置里的 webhook 被删掉了"这种情况会伪装成"通知已发送"。这是全篇文章里最值得记住的一条:**IM 机器人的错误在响应体里,不在状态码里。**脚本里对应的写法就是一行硬判断:
status,resp=http_post_json(hook,payload)code=resp.get("code",resp.get("StatusCode"))ifcode!=0:raiseSendError("feishu code={} msg={}".format(code,resp.get("msg")))SendError会让进程以1退出。定时任务因此能分辨"我发出去了"和"我以为我发出去了"。
下面是六个通道的限额与回执口径对照,我把每个通道"会被拒的第一原因"也标在了图上:
Q4:个人用到底选哪个?
| 你的情况 | 推荐 | 理由 |
|---|---|---|
| 只在手机上看 | ntfy | 不用注册、不用装企业软件,配一个不易猜的 topic 就行 |
| 全是 iOS | Bark | 能设critical级别穿透静音 |
| 已经在用飞书/企微/钉钉 | 对应那个 | 反正你每天都打开它 |
| 需要留档、要能转发给别人 | SMTP | IM 消息会淹掉,邮件不会 |
我个人最终是 ntfy 做默认、mail 做兜底:手机上必须立刻看到的走 ntfy,需要留下证据的(比如每天的发文明细)走邮件。
四、签名这件事,飞书和钉钉接反了
两家都是 HmacSHA256 + Base64,写错不会报错,只会一直发不出去。
这是整个实现里我最喜欢的一段,因为它足够阴险:两个通道的签名算法看起来一模一样,实际把 key 和 data 放了个对调。
飞书要签的是一个"空字符串",而把timestamp + "\n" + secret整串当 HMAC 的密钥:
# 飞书:stringToSign 当 KEY,对空字符串做摘要;时间戳是秒ts=str(int(time.time()))string_to_sign=ts+"\n"+secret digest=hmac.new(string_to_sign.encode("utf-8"),b"",hashlib.sha256).digest()payload["timestamp"]=ts payload["sign"]=base64.b64encode(digest).decode()钉钉正好反过来:secret是 HMAC 的密钥,timestamp + "\n" + secret是待签数据,而且时间戳是毫秒,签名还要 URL-encode,并且它挂在 query 上不在 body 里:
# 钉钉:secret 当 KEY,签 stringToSign;时间戳是毫秒,签名要 urlencodets=str(round(time.time()*1000))string_to_sign=ts+"\n"+secret digest=hmac.new(secret.encode("utf-8"),string_to_sign.encode("utf-8"),hashlib.sha256).digest()sign=urllib.parse.quote_plus(base64.b64encode(digest))url+="×tamp={}&sign={}".format(ts,sign)坑在哪?如果你把飞书那段抄给钉钉用,两家都可能返回 200,然后你在响应体里看到errcode非 0。也就是说第三节的教训在这里复发了一次:错误判定不做,签名错误就会静默。
飞书 key = ts+"\n"+secret data = "" ts 单位:秒 签名放:body 钉钉 key = secret data = ts+"\n"+secret ts 单位:毫秒 签名放:query + urlencode两种接线的差异画在一起更不容易记错:
还有一个只有真跑过才知道的:飞书官方文档里写着整点和半点前后要避开请求,否则会命中一个限流错误码(11232)。而定时任务天然喜欢0 9 * * *、30 8 * * *这种整点。我的做法是让调度分钟带偏移——35 10、40 9、5 0——而不是在脚本里 sleep。脚本里 sleep 会让一次运行的失败原因变得含混,调度层的偏移是干净的。
Q5:为什么不用官方的 SDK?
因为我要它能在任何一条 cron 里被裸调用。notify.py的 import 列表里只有标准库(argparse/base64/hashlib/hmac/json/smtplib/urllib),没有 pip 依赖。定时任务的运行环境不该因为一个通知器缺包而失败——那正是"没嘴巴"的另一种形式。
五、notify.py:一个命令、一行输出、一个退出码
接口的宽度决定它能不能被安全地自动化。
调用面我压到只剩五个字段:
python3 scripts/notify.py--statusok--task"每日打卡同步"--summary"四项全部完成"python3 scripts/notify.py--statusfail--task"52pj 签到"--summary"Chrome 未运行"python3 scripts/notify.py--statusneed--task"公众号定时发表"--summary"后台未登录,需要你扫码"--status只有四个取值,而它同时决定了三件事——图标、文案标签、以及邮件/推送的紧急级别:
--status | 图标与标签 | 语义 | 该不该重试 |
|---|---|---|---|
ok | ✅ 成功 | 全部达标 | 通知失败不重要 |
warn | ⚠️ 部分完成 | 有落空项但不需要人 | 一般 |
fail | ❌ 失败 | 目标没达成 | 重试次数更高(默认 4 次) |
need | 🔔 需要你介入 | 卡在只有用户能做的事上(扫码、重登、额度用完) | 重试次数更高 |
退出码是这套东西真正的接口。调用方(也就是那条定时任务里的模型)不需要读任何日志,只看数字:
0 -> SENT via <channel>|<回执> 消息确实被通道承认了 1 -> NOT SENT via <channel>|<原因> 通道明确拒绝或无法判定成功 2 -> CONFIG ERROR: <缺什么> 配置本身不合法,重试没有意义1和2的区分很值钱:2意味着"再试一百次也没用,必须有人去改配置",脚本对它是直接抛出不进重试循环。而1会走退避重试:
attempts=int(cfg.get("retry",4ifargs.statusin("fail","need")else3))foriinrange(attempts):try:receipt=CHANNELS[...](cfg,target,title,text)print("SENT via {}|{}".format(channel,receipt))return0exceptSendErrorasexc:last=excifi<attempts-1:# 公共服务器按「补桶」限速(ntfy.sh 是 1 条 / 5 秒),退避太短等于白撞time.sleep(3+2*i+random.random())这里有两个细节值得抄走:3 + 2*i的递增是因为公共 ntfy 是令牌桶补速(每秒不补,每 5 秒补 1),退避 1 秒重试四次等于白撞四次;加random()是为了多条任务同时失败时不要一起撞墙。
Q6:凭据为什么一定要落在配置文件里?
因为"凭据出现在哪"是可审计性的一部分。我的规矩是三条:
- 对话里不收 webhook、
key、access_token、device_key、SMTP 口令——用户自己编辑~/.config/agent-notify/config.json,权限600。 - 命令行参数里不出现任何凭据,
ps aux和 shell history 才是最容易漏的地方。 - 配置文件不进 Git,仓库里只留
config.example.json。
CONFIG_PATHS第一项是环境变量AGENT_NOTIFY_CONFIG,这不是为了灵活,是为了测试:我可以用一份假配置把六个通道全跑一遍,包括用fake_smtp.py在本机回环起一个 SMTP 服务器,验证 mail 通道的分支真的走通。
六、把它挂到定时任务上
能力做好了只是第一步,让每条任务都记得用它才是目的。
接入方式是在每条定时任务的指令末尾追加一小段固定文本,不改变任务原有的任何逻辑:
完成后调用 agent-notify 技能,按本次结果发一条通知: 全部达标 --status ok;有未达标项 --status fail;需要用户动手(扫码、重登、额度用完)--status need。 --task 用本任务名,--summary 只写一行结论,不要把整份报告塞进去。 通知发送失败不要改变本任务的结论,但要在那份报告里补一行「通知未送达」及原因。这四行里最关键的是最后一行。**通知失败不能污染业务结论。**否则你会得到一种最糟的情况:日记同步明明成功了,因为推送超时被标成失败,第二天你去查为什么没同步,越查越乱。
然后是判定口径。ok / fail / need的分界必须在写指令时就定死,不能交给运行时的模型临场判断。以我的"每日打卡同步"为例:
| 情况 | status | summary 该写什么 |
|---|---|---|
| 四项全达标 | ok | 今日四项全部完成 |
| 某项数值没到目标(发文 1/2) | fail | CSDN 今日仅 1/2 篇 |
| 网站登录态掉了需要重登 | need | CMC 需重登,连续签到会断 |
| 数据来自回落路径,未核后台 | warn | 公众号数据来自日盘手填,未核后台 |
改完之后,一天的通知长这样(都是真实形态,我把任务名换掉了):
✅ [成功] 每日打卡同步|成功|今日四项全部完成 🔔 [需要你介入] 公众号定时发表|需要你介入|后台未登录,需要你扫码 ❌ [失败] 52pj 签到|失败|Chrome 未运行 ✅ [成功] 待办同步|成功|导出 43 行,排除已完成 6 行最后是频率红线。我给自己定的上限是每天四条,只在SKILL.md里写了一句 “Never notify for”:
- 一次只是重复了用户已经看过的内容的运行——不发。
- 中间步骤成功——不发。
- 模型自己的推理过程——不发。
这条约束比任何技术细节都重要。**Agent 通知的失败模式从来不是通知太少,而是被你静音。**一旦你把它静音,need那种真正需要你的消息也就跟着死了,整个能力归零。所以我宁可让ok也发(因为它构成"今天正常工作了"的心跳),也绝不让它变成一天二十条。
按三种状态分支的完整决策流程是这样:
七、剩下的坑,和这个能力的边界
六个通道跑通之后,我踩到的都是文档写了但我没读的那几行。
Q7:ntfy 为什么一定要 POST 到根 URL?
因为把 JSON 发到https://ntfy.sh/<topic>,ntfy 会认为那是一段纯文本消息,于是整串 JSON 变成通知正文显示在你手机上,而且是"发送成功"的。带 topic 的路径是给message头的纯文本用的;JSON 形态必须发到根路径、由 body 里的topic字段路由。
server=(cfg.get("server")or"https://ntfy.sh").rstrip("/")url=server+"/"# 不是 server + "/" + topicpayload={"topic":topic,"title":title,"message":text,...}Q8:企业微信的 markdown 为什么不直接用markdown_v2?
markdown_v2支持表格,看起来更现代,但它在旧客户端上会整段退化成纯文本,包括那些星号。一条带表格的发文明细在旧版企业微信里会显示成一片|和**。我的默认因此走markdown(不支持表格但 everywhere),要表格就换text加对齐空格,或者干脆走邮件。
顺带一提企业微信的图片消息:base64+md5已经不能用了,必须先上传拿media_id,而media_id只有 3 天有效,且绑定上传时用的那个 webhook。想给团队群发截图的话,别指望存下来反复用。
Q9:Bark 能不能自建?
能,但我不建议拿它当默认。官方中继api.day.app一直可用,而公开的镜像站在我这边测下来连通率很差——need级别的通知如果落在一个时好时坏的镜像上,你会失去对整个机制的信任。真在意隐私就自建到自己域名下,配level与group字段,把fail设成critical才能穿透静音。
Q10:什么时候不该做这个能力?
三种情况我会直接劝退:
- 你的任务是给别人用的告警系统。这需要路由、静默、升级、值班表,不是"一条命令一行输出"能覆盖的,用现成的监控体系。
- 你的任务本身一天跑几十次。这时候你需要的不是通知,是聚合和去重。
- 你还没想清楚"什么算失败"。定义不出
fail的任务,装上嘴巴只会开始说谎。
我的经验顺序是反的:先把任务的验收口径写死(比如 CMC 那一条必须是"点击后余额数字变大",而不是"点了按钮"),再装嘴巴。否则通知只是把含糊的succeeded变成了含糊的推送。
最后总结
- 这一篇解决的问题只有一个:Agent 会干活但不出声,而平台的
succeeded不等于业务达成——缺的是第三层"你现在知道了"。 - 这个能力适合做成 Skill 而不是脚本或 MCP,因为要固化的不是"怎么调 HTTP",而是三条边界:发给谁是配置不是参数、成功由通道自己说、措辞来自模板。
- 六个通道最该记住的一条:飞书、企业微信、钉钉失败也返回 HTTP 200,错误在响应体的
code/errcode里;只有 ntfy 和 Bark 的状态码可信。 - 飞书和钉钉的签名是对调的(谁当 key、谁当 data),时间戳单位还一个秒一个毫秒,写错会静默失败。
- 接口宽度决定可自动化程度:五个字段、四个状态、三个退出码(
0已送达 /1通道拒绝 /2配置不合法需要人),没有依赖,标准库裸跑。 - 挂到定时任务上只需要追加四行指令,其中最关键的一句是"通知发送失败不要改变本任务的结论"。
- 每天四条是上限。Agent 通知的失败模式不是太少,是被你静音。
参考资料 & 致谢
[1] 群机器人配置说明-飞书开放平台
[2] 发送消息-企业微信服务端 API
[3] 自定义机器人接入-钉钉开放平台
[4] Publish messages-Publish API-ntfy 文档
[5] Bark server-API 说明
[6] Access tokens and signing-Feishu custom bot security