多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

[玩转大模型] 第七篇:Skills 变成 Abilities——给 Agent 装上嘴巴,让定时任务把成功和失败喊出来

[玩转大模型] 第七篇:Skills 变成 Abilities——给 Agent 装上嘴巴,让定时任务把成功和失败喊出来 这两年我给自己攒了十条定时任务每天领网站的钻石、签到回帖、把日记同步到 Notion、把待办导成 Markdown、早上巡检自己的小产品、每周一份竞品动态。它们确实每天都在跑lastRunStatus也基本都是succeeded。可是用了两个多月之后我发现一件事我从来不知道它们到底跑成了什么样。要确认就得自己打开对话记录一条一条翻或者去 Notion 里看那天有没有新增行。换句话说我给我的 Agent 装上了手和脚唯独没装嘴巴——它会干活但干完活不出声。更麻烦的是没出声和没干活在界面上长得一模一样。所以这一篇想讲的就是一件很小的事怎么把一个发通知的能力做成 Skill让每一条定时任务跑完之后自己喊一声——成功了喊成功失败了喊失败需要我动手的时候把我叫过来。这是「玩转大模型」系列的第七篇。一、Agent 有手有脚唯独没有嘴巴先看一眼问题的真实形状再决定要不要给它装嘴巴。我把自己当前的定时任务配置整份导出来看过一遍两个数字挺难看观察项结果启用的定时任务10 条指令里明确要求发通知/发消息的0 条任务里挂了 Skill 的selectedSkillNames0 条跑完会主动告诉我需要你介入的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里那张的展开通道判定送达的方式频率限额正文体积上限一句话定位ntfyHTTP 200且回执event message且有id突发 60 条之后每 5 秒补 1 条ntfy.sh每日 250 条4 KB最省事的个人通道URL 即凭证BarkHTTP 200且回执code 200官方中继未公布URL 形态长文要转义iOS 唯一能绕过后台限制的自推方式飞书群机器人code 0100 次/分钟且 5 次/秒20 KB富文本最完整但限速是双维度企业微信群机器人errcode 020 条/分钟text2048 B、markdown4096 B最容易但 markdown 不支持表格钉钉群机器人errcode 020 条/分钟超限封禁 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,resphttp_post_json(hook,payload)coderesp.get(code,resp.get(StatusCode))ifcode!0:raiseSendError(feishu code{} msg{}.format(code,resp.get(msg)))SendError会让进程以1退出。定时任务因此能分辨我发出去了和我以为我发出去了。下面是六个通道的限额与回执口径对照我把每个通道会被拒的第一原因也标在了图上Q4个人用到底选哪个你的情况推荐理由只在手机上看ntfy不用注册、不用装企业软件配一个不易猜的 topic 就行全是 iOSBark能设critical级别穿透静音已经在用飞书/企微/钉钉对应那个反正你每天都打开它需要留档、要能转发给别人SMTPIM 消息会淹掉邮件不会我个人最终是 ntfy 做默认、mail 做兜底手机上必须立刻看到的走 ntfy需要留下证据的比如每天的发文明细走邮件。四、签名这件事飞书和钉钉接反了两家都是 HmacSHA256 Base64写错不会报错只会一直发不出去。这是整个实现里我最喜欢的一段因为它足够阴险两个通道的签名算法看起来一模一样实际把 key 和 data 放了个对调。飞书要签的是一个空字符串而把timestamp \n secret整串当 HMAC 的密钥# 飞书stringToSign 当 KEY对空字符串做摘要时间戳是秒tsstr(int(time.time()))string_to_signts\nsecret digesthmac.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时间戳是毫秒签名要 urlencodetsstr(round(time.time()*1000))string_to_signts\nsecret digesthmac.new(secret.encode(utf-8),string_to_sign.encode(utf-8),hashlib.sha256).digest()signurllib.parse.quote_plus(base64.b64encode(digest))urltimestamp{}sign{}.format(ts,sign)坑在哪如果你把飞书那段抄给钉钉用两家都可能返回 200然后你在响应体里看到errcode非 0。也就是说第三节的教训在这里复发了一次错误判定不做签名错误就会静默。飞书 key ts\nsecret data ts 单位秒 签名放body 钉钉 key secret data ts\nsecret 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--task52pj 签到--summaryChrome 未运行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会走退避重试attemptsint(cfg.get(retry,4ifargs.statusin(fail,need)else3))foriinrange(attempts):try:receiptCHANNELS[...](cfg,target,title,text)print(SENT via {}{}.format(channel,receipt))return0exceptSendErrorasexc:lastexcifiattempts-1:# 公共服务器按「补桶」限速ntfy.sh 是 1 条 / 5 秒退避太短等于白撞time.sleep(32*irandom.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的分界必须在写指令时就定死不能交给运行时的模型临场判断。以我的每日打卡同步为例情况statussummary 该写什么四项全达标ok今日四项全部完成某项数值没到目标发文 1/2failCSDN 今日仅 1/2 篇网站登录态掉了需要重登needCMC 需重登连续签到会断数据来自回落路径未核后台warn公众号数据来自日盘手填未核后台改完之后一天的通知长这样都是真实形态我把任务名换掉了✅ [成功] 每日打卡同步成功今日四项全部完成 [需要你介入] 公众号定时发表需要你介入后台未登录需要你扫码 ❌ [失败] 52pj 签到失败Chrome 未运行 ✅ [成功] 待办同步成功导出 43 行排除已完成 6 行最后是频率红线。我给自己定的上限是每天四条只在SKILL.md里写了一句 “Never notify for”一次只是重复了用户已经看过的内容的运行——不发。中间步骤成功——不发。模型自己的推理过程——不发。这条约束比任何技术细节都重要。**Agent 通知的失败模式从来不是通知太少而是被你静音。**一旦你把它静音need那种真正需要你的消息也就跟着死了整个能力归零。所以我宁可让ok也发因为它构成今天正常工作了的心跳也绝不让它变成一天二十条。按三种状态分支的完整决策流程是这样七、剩下的坑和这个能力的边界六个通道跑通之后我踩到的都是文档写了但我没读的那几行。Q7ntfy 为什么一定要 POST 到根 URL因为把 JSON 发到https://ntfy.sh/topicntfy 会认为那是一段纯文本消息于是整串 JSON 变成通知正文显示在你手机上而且是发送成功的。带 topic 的路径是给message头的纯文本用的JSON 形态必须发到根路径、由 body 里的topic字段路由。server(cfg.get(server)orhttps://ntfy.sh).rstrip(/)urlserver/# 不是 server / topicpayload{topic:topic,title:title,message:text,...}Q8企业微信的 markdown 为什么不直接用markdown_v2markdown_v2支持表格看起来更现代但它在旧客户端上会整段退化成纯文本包括那些星号。一条带表格的发文明细在旧版企业微信里会显示成一片|和**。我的默认因此走markdown不支持表格但 everywhere要表格就换text加对齐空格或者干脆走邮件。顺带一提企业微信的图片消息base64md5已经不能用了必须先上传拿media_id而media_id只有 3 天有效且绑定上传时用的那个 webhook。想给团队群发截图的话别指望存下来反复用。Q9Bark 能不能自建能但我不建议拿它当默认。官方中继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
返回列表