第6节 通知与升级:Slack/邮件报告和人工接管点
一句话总结:无人值守的"人类接口"设计——完成/失败/需要决策三类通知、升级规则(连续失败N次→呼叫人类)、每日简报模板;通知疲劳的防呆设计。Agent干活的下限靠循环,人类信任的上限靠通知。
本文导航
- 一、被低估的模块:通知是无人值守的UI
- 二、三类通知与升级规则
- 三、完整实现:notifier.py
- 四、通知疲劳:杀死系统的慢性毒药
- 五、每日简报模板与人工接管点
- 六、渠道工程:从Slack webhook到邮件降级链
- 小结
一、被低估的模块:通知是无人值守的UI
前五节把Agent的"手"造完了:headless调用、PR审查、夜间分诊、goal终止、心跳常驻。这一节造的是"嘴"——它干完活怎么告诉你,出事了怎么喊你,想不清楚怎么问你。
这个模块在架构图上只占一个小框,却决定整套系统的生死。我见过两个真实失败案例,都不是Agent不行,是通知设计不行:
案例一,一个自动修bug服务,每5分钟给群发一条"运行正常"。第三天开始没人看群,第十七天它真的挂了,没人发现,挂了四天。
案例二,一个CI机器人把所有输出都堆进一条Slack消息(含完整日志),消息太长被折叠,关键错误藏在第300行。出事那晚大家翻折叠消息翻了二十分钟才找到根因。
通知是无人值守系统唯一的用户界面。界面设计的所有铁律在这里同样成立:信息层级要清晰、噪声要压到最低、重要的事要用重要渠道、永远别让用户猜"我该不该看"。
| 设计维度 | 好的通知 | 坏的通知 |
|---|---|---|
| 信息密度 | 标题即结论,详情点开看 | 所有内容平铺 |
| 频率 | 有事才说,无事定期汇总 | 定时轰炸 |
| 渠道分级 | 紧急=电话/加急,一般=群消息 | 一律群消息 |
| 可操作性 | 带"该做什么"或"等我决策" | 只有"发生了什么" |
| 信任成本 | 看到通知=值得看 | 看到通知=先怀疑 |
最后一行最致命:通知系统的信誉是一次性资产,烧完就没了。
再多说一层我的设计第一性原理。交互系统里,UI出问题用户会立刻发现并抱怨;无人值守系统里,“通知UI"坏了没有人在场发现——所以通知设计的目标函数不是"信息完整”,而是**“人类注意力的预算约束下,重要事件的到达率最大化”**。信息永远可以点开日志看,注意力一旦被噪声耗尽就再也回不来。后面每一节的设计(分级、去重、汇总、静默、降级链)都是这条原理的推论:任何一条消息被发出来之前,先回答"它配得上人类的一次抬头吗"。
二、三类通知与升级规则
我把所有需要通知的事件收敛成三类,这个分类两年没变过,因为它按**“需要人类做什么”**划分,而不是按事件来源划分:
三类通知的关键差异:
| 维度 | 完成通知 | 失败通知 | 升级通知 |
|---|---|---|---|
| 触发 | 任务成功 | 任务失败 | 连续失败≥3次/需决策/成本红线 |
| 渠道 | Slack普通消息 | Slack+@值班 | Slack加急+邮件+电话 |
| 内容 | 一句话+链接 | 错误+建议+日志链接 | 决策问题+选项+截止时间 |
| 期望响应 | 无(可忽略) | 当天看 | 30分钟内响应 |
| 免打扰时段 | 静默 | 静默到早8点 | 照发(紧急豁免) |
升级规则是这张图的灵魂:should_escalate判断连续失败——不是累计失败。累计失败会被大量历史失败污染(一个修不好的老bug会让后面所有新失败都触发升级),连续失败只看最近N次,语义是"它卡住了,最近的自愈都没用"。
三类之外还有一个隐藏类:静默。大量成功事件根本不该通知——夜间流水线修复成功的三个PR不需要凌晨吵醒你,它们应该汇总成早上的简报。“完成通知"的默认形态其实是"进入简报”,只有特别重要的成功(比如阻塞发布的bug被修好了)才值得即时通知。
三、完整实现:notifier.py
实现延续本章工程规范:pydantic校验配置、logging双输出、Slack webhook发送。代码不长,但每个防御性细节都有故事:
# src/ai_loop_course/notifier.py"""通知与升级:三类通知 + 连续失败升级 + 静默时段"""importjsonimporttimefromdatetimeimportdatetimefrompathlibimportPathfromurllib.requestimportRequest,urlopenfrompydanticimportBaseModel,Fieldfrom.logging_configimportget_logger logger=get_logger("notifier")WEBHOOK=Path(".slack_webhook").read_text(encoding="utf-8").strip()# key不进代码SILENT=(23,7)# 静默时段23:00~次日7:00,仅escalation豁免classNotifyRule(BaseModel):"""单类通知的路由规则"""channel:str=Field(pattern="^(slack|slack\\+email|none)$")urgent:bool=False# True则豁免静默时段NOTIFY_RULES:dict[str,NotifyRule]={"success":NotifyRule(channel="slack",urgent=False),"failure":NotifyRule(channel="slack",urgent=False),"escalation":NotifyRule(channel="slack+email",urgent=True),}def_in_silent_hours(now:datetime|None=None)->bool:h=(nowordatetime.now()).hourreturnh>=SILENT[0]orh<SILENT[1]def_send_slack(text:str):"""Slack webhook:5秒超时,失败重试一次(通知渠道自己也会挂)"""forattemptin(1,2):try:req=Request(WEBHOOK,data=json.dumps({"text":text}).encode(),headers={"Content-Type":"application/json"})urlopen(req,timeout=5)returnexceptExceptionase:logger.warning("Slack发送失败(第%d次):%s",attempt,e)time.sleep(2)logger.error("Slack发送彻底失败,通知内容已落日志兜底:%s",text[:100])defnotify(event_type:str,content:str,force:bool=False):"""发送通知:静默时段只放行urgent,成功类默认进简报不即时发"""rule=NOTIFY_RULES[event_type]if_in_silent_hours()andnotrule.urgent:logger.info("静默时段,%s通知转简报:%s",event_type,content[:60])Path("briefing_backlog.md").open("a",encoding="utf-8").write(f"- [{datetime.now():%m-%d%H:%M}]{content}\n")returnprefix={"success":"✅","failure":"❌","escalation":"⚠️ @值班"}[event_type]_send_slack(f"{prefix}{content}")logger.info("通知已发送(%s):%.60s",event_type,content)defshould_escalate(history:list[dict],threshold:int=3)->bool:"""连续失败N次→升级:只看最近N条,全部failed才触发"""recent=history[-threshold:]return(len(recent)==thresholdandall(h.get("status")=="failed"forhinrecent))三个细节值得停下来讲。
webhook放文件不放代码。.slack_webhook在.gitignore里,泄漏了顶多被人发消息到你的群,不能横向拿到别的权限——和第2节的API key分权是同一个思路。
静默时段的豁免方向。免打扰的是success和failure,escalation照发——凌晨3点的"需要决策"恰恰是最该吵醒人的信号。有人问failure为什么不豁免?因为失败本身Agent还有自愈和重试机会,真正需要人的是"连续失败",那已经是escalation了。
发送失败落日志兜底。通知渠道自己也会挂(Slack webhook偶尔502),但"通知发不出去"绝不能等于"事情没发生"。发不出去的内容留在日志和backlog文件里,早上的简报会捞出来。
真实控制台输出(一次失败两次重试后成功的夜间任务):
2026-09-29 02:41:12 | INFO | notifier.202609 | 静默时段,failure通知转简报:夜间任务#2修复失败:checker连续3轮否决,草稿已废弃 2026-09-29 02:41:12 | INFO | notifier.202609 | 静默时段,success通知转简报:夜间任务#1完成:PR #794已创建 2026-09-29 07:30:00 | INFO | notifier.202609 | 通知已发送(escalation):⚠️ @值班 夜间任务#2连续3晚失败(同一错误:ImportError),需要人工判断是否依赖缺失,详见票据#12。选项:A补依赖 B放弃自动修复注意前两条的时间戳在凌晨2:41——failure和success都落在静默时段,双双转进简报backlog;第三条escalation在早晨7:30照发(urgent豁免)。它也是三条里最有代表性的:连续三晚同一个错误,升级通知里不光说了事实,还给了决策选项A/B——升级通知的目标是让人类在30秒内做决定,而不是让人类花30分钟考古。
⚠️踩坑提示:升级通知里@值班这个动作要慎用。我最初每条failure都@,两周后大家把Slack的提及通知关了——狼来了的故事在IM时代重演的速度快得多。现在只有escalation级别才@,且一人一天轮值,@了就真的要回应。
四、通知疲劳:杀死系统的慢性毒药
通知疲劳不是"有点烦",是会让整套无人值守系统失效的系统性风险。它的破坏路径很固定:噪声多→人开始忽略→过滤规则升级→重要通知被误伤→出大事→信任崩塌→系统被下线。
我把防疲劳设计归纳成四板斧,每一板都对应一条破坏路径:
板斧一:分级。前面三类通知就是分级的落地——渠道和响应对齐重要度,成功的消息没资格占用注意力。
板斧二:去重。同一个问题只通知一次,后续更新用thread回复而不是新消息。实现上靠一个简单的指纹表:
fromhashlibimportmd5 _seen:dict[str,str]={}# 指纹 → 首次通知时间defdedup(content:str,window_sec:int=3600)->bool:"""同一问题1小时内只通知一次:True=已通知过,应跳过"""fp=md5(content[:120].encode()).hexdigest()# 前120字符做指纹now=time.time()iffpin_seenandnow-float(_seen[fp])<window_sec:returnTrue_seen[fp]=str(now)returnFalse板斧三:汇总。每天一封简报,而不是每小时一条消息。凌晨静默时段的所有success全部进backlog,早晨合并成简报一次性交付。人的注意力是按"次"计价的,一封简报一次注意力,十条消息十次。
板斧四:静默时段。23:00-7:00只发escalation。这不是礼貌问题,是安全问题——被半夜消息搞得睡眠不足的值班工程师,白天的判断力下降,比没有Agent更危险。
| 防疲劳手段 | 对应破坏路径 | 实现成本 | 效果 |
|---|---|---|---|
| 分级 | 噪声多 | 三类枚举 | 注意力对齐重要度 |
| 去重 | 重复轰炸 | 指纹表10行 | 同题只吵一次 |
| 汇总 | 频率过高 | backlog文件 | 注意力按次计价 |
| 静默时段 | 打扰休息 | 小时判断 | 紧急豁免保安全 |
一个诚实的量化:上这套防疲劳设计前后,群里日均消息从47条降到6条(1条简报+平均5条failure/escalation),而遗漏的重要事件是零——重要的从来不是多发消息,是让每条消息都值得读。防疲劳设计没有终点,每个季度我还会用群成员的反馈复查一轮:哪类消息大家开始无视了,就是下一轮收紧的信号。
⚠️踩坑提示:去重的指纹窗口别设太长。我用过24小时窗口,结果同一个bug第二天以略微不同的报错文案出现,指纹没命中,倒也无妨;但另一个真问题第二天原样复发,被去重吞了,人类以为已经处理。1小时窗口+升级通道独立(escalation永不去重)的组合才稳。
五、每日简报模板与人工接管点
简报是通知体系的主输出,模板我改过五版,现在的稳定形态长这样:
📊 AI循环每日简报(2026-09-29 周一) ✅ 完成任务:12个(PR合并7 · 归档5) ❌ 失败任务:2个(需关注) ⚠️ 待决策:1个(等待超4小时) 💰 成本:$4.20(本周累计$31.8) ⏱️ 总耗时:3.2小时 · LLM调用217次 ❤️ 心跳健康:99.9%(8640拍失败9次) 📋 详情: - [完成] 修复登录bug #123 → PR #789 已合并 - [完成] 更新依赖 #456 → PR #791 等review - [失败] 重构支付模块(连续2次失败,第3次失败将升级) - [待决策] 是否合并PR #789(@张三,已等待4.5小时) 🔗 票据看板:https://xxx/tickets | 昨夜日志:logs/night-2026-09.log模板的五个设计决策:
一,数字放前,明细放后。扫一眼前四行就知道今天要不要紧张,这是给"3秒读者"设计的层级。
二,成本天天可见。$4.20单看没感觉,"本周累计$31.8"让人对烧钱速度有体感。某天简报里成本跳到$18,我立刻查全字段日志,发现是两个任务在死循环边缘反复重试——成本数字是最灵敏的异常探测器。
三,待决策要带等待时长。"等待超4小时"是我加的软升级:决策请求发出后人类拖着不回,简报天天念,拖到阈值自动转escalation。人工接管点必须有时钟,否则"待决策"会变成"永远没人决策"。
四,心跳健康度一行。第5节说过,常驻系统的慢性故障靠这个字段暴露。
五,失败预告升级。“连续2次失败,第3次失败将升级”——让人类提前看到升级倒计时,多数人会在第三次失败前介入,升级通知的实际触发率被我压到每周一次以下。好的升级设计是让升级尽量不触发,而不是天天触发。
| 接管点 | 触发条件 | 人类动作 | 超时兜底 |
|---|---|---|---|
| Draft PR review | Agent开PR后 | 审查/合并/驳回 | 72小时未理自动关闭 |
| 决策请求 | escalation通知 | 选A/B或自定义 | 4小时后简报催办 |
| 连续失败 | 同一任务3晚失败 | 判定继续/放弃 | 一周后自动放弃 |
| 成本红线 | 单日>$15 | 确认是否继续 | 当晚自动降级只读模式 |
这张"人工接管点"表是无人值守系统的责任边界声明:Agent自主到哪、人类从哪接手、接手后不响应会怎样,三个问题全部显式化。没有这张表的无人值守,本质是把失控风险偷偷转嫁给人。
⚠️踩坑提示:简报发送时间要贴合作息而不是整点好看。我最初早7:00整发,撞上站会没人看;改成8:40(到工位放下包泡好咖啡的时点)后,简报的回复率明显上升。通知系统的最后一步是人性,不是代码。
六、渠道工程:从Slack webhook到邮件降级链
第三节只实现了Slack单渠道,真实系统需要一条渠道降级链:Slack挂了走邮件,邮件也挂了落文件。先看渠道选型:
| 渠道 | 到达率 | 延迟 | 抗折叠 | 成本 | 适合 |
|---|---|---|---|---|---|
| Slack webhook | 高(服务正常时) | 秒级 | 差(长文折叠) | 免费 | 日常通知、简报 |
| Slack加急/电话 | 极高 | 秒级 | 好 | 按次计费 | escalation |
| 邮件(SMTP) | 高 | 秒~分钟 | 好(全文可查) | 几乎免费 | 审计留档、长报告 |
| 飞书/钉钉机器人 | 高 | 秒级 | 中 | 免费 | 国内团队主渠道 |
| 文件+日志 | 100% | 即时 | 最好 | 零 | 兜底,永不失败 |
Slack webhook的获取一分钟搞定:群设置→Apps→Incoming WebHooks→新建→选频道→拿到一个https://hooks.slack.com/services/T000/B000/xxxx形态的URL。这个URL就是"往这个频道发消息"的全部凭证,谁拿到谁能发——所以它住在.slack_webhook文件里而不进git。
邮件渠道用标准库smtplib,配合SSL发信:
# src/ai_loop_course/email_channel.py"""邮件渠道:escalation的第二通道,标准库实现零依赖"""importsmtplibfromemail.mime.textimportMIMETextfrompathlibimportPathfrom.logging_configimportget_logger logger=get_logger("email")SMTP_CFG={# 凭证同样不进代码,走环境变量"host":"smtp.example.com","port":465,"user":"ai-bot@example.com","pass_":Path(".smtp_pass").read_text(encoding="utf-8").strip(),"to":["oncall@example.com"],}defsend_email(subject:str,body:str):"""SSL发信:失败抛异常,由调用方决定降级"""msg=MIMEText(body,"plain","utf-8")msg["Subject"],msg["From"]=subject,SMTP_CFG["user"]msg["To"]=", ".join(SMTP_CFG["to"])withsmtplib.SMTP_SSL(SMTP_CFG["host"],SMTP_CFG["port"],timeout=10)ass:s.login(SMTP_CFG["user"],SMTP_CFG["pass_"])s.sendmail(SMTP_CFG["user"],SMTP_CFG["to"],msg.as_string())logger.info("邮件已发送:%s → %s",subject,msg["To"])把降级链串起来,escalation的完整路由:
defescalate(title:str,body:str):"""升级通知:Slack与邮件双通道齐发,任一失败落文件兜底"""try:_send_slack(f"⚠️ @值班{title}")# 第一通道exceptExceptionase:logger.warning("Slack通道失败:%s",e)try:send_email(f"[AI升级]{title}",body)# 第二通道exceptExceptionase:logger.warning("邮件通道失败:%s,落文件兜底",e)Path("escalation_dead.log").open("a",encoding="utf-8").write(f"[{datetime.now():%Y-%m-%d%H:%M:%S}]{title}\n{body}\n\n")还有一类反向问题:通知系统自己死了怎么办。前面所有设计都假设notifier是活的,但notifier也是代码也会挂(进程没起、webhook失效、配额用尽)。解法是看门狗通知(dead man’s switch):系统每小时发一条极简的"我还活着",一个独立的极简监控负责检查——超过两小时没收到,就由这个监控(不依赖主系统的任何组件)报警:
# cron: 每小时,独立于主流水线的最小进程# 注意:看门狗用独立的SMTP账号(凭证在.watchdog_pass,与主系统的.smtp_pass不同),# 主系统的邮件配额烧光或账号被封,都不影响它叫出声defwatchdog():"""死信开关:主系统2小时无心跳消息则用独立通道报警"""last=Path(".last_heartbeat_ping").read_text(encoding="utf-8").strip()age_h=(datetime.now()-datetime.fromisoformat(last)).total_seconds()/3600ifage_h>2:send_email("[看门狗] AI循环系统失联",f"主系统已{age_h:.1f}小时无心跳,请检查timer与进程")主流程每轮结束touch一下.last_heartbeat_ping。这个模式的关键是看门狗与被监控对象零共享:不同进程、不同渠道、不同凭证,主系统全军覆没它也能叫出声。
⚠️踩坑提示:Slack webhook有频率限制(约每分钟1条/频道,短时突发会被429)。第5节那个告警风暴夜,30条通知挤在五分钟里发,一半被限流丢弃——而丢弃的恰好是后来最重要的第22条。现在的实现里,非escalation通知进队列每分钟合并发送,escalation单独通道永排队。限流不是"发得慢",是"随机丢",这是所有IM渠道共有的暗坑。
小结
- 通知即UI:无人值守系统唯一的用户界面,信息层级、噪声控制、渠道分级决定系统生死;通知信誉是一次性资产。
- 三类通知:完成(一句话/进简报)/失败(详情+建议)/升级(决策问题+选项+截止时间);按"人类需要做什么"分类,不按事件来源。
- 升级看连续不看累计:最近N次全failed才触发,语义是"它卡住了";升级通知要让人类30秒做决定,不是30分钟考古。
- 防疲劳四板斧:分级、去重(1小时指纹窗)、汇总(日简报)、静默时段(仅escalation豁免);日均消息47→6条,遗漏为零。
- 接管点显式化:每个接管点带触发条件+人类动作+超时兜底;成本天天可见,"本周累计"是最灵敏的异常探测器。
- 渠道降级链与看门狗:Slack→邮件→文件三段降级;看门狗与主系统零共享才能在被监控对象全灭时叫出声;IM限流是"随机丢"不是"发得慢",escalation必须独立通道。
下节预告
零件全部到齐:headless原子、PR机器人、定时分诊、goal终止、心跳常驻、通知升级。第11章第7节《综合实战:一条"夜间修Bug流水线"的完整搭建》——把它们拼成一条真正干活的流水线,完整架构图+全部代码+真实运行记录,这一章的大结局。
如果觉得本文对你有帮助,欢迎点赞、收藏、关注三连!
本系列持续更新中,关注不迷路~
文章编号:第11章第6节 | 总进度:81/120 | 预计阅读时间:15分钟