☰
AI智能体循环工程-第11章第6节-自动化与无人值守-通知与升级Slack邮件报告和人工接管点
2026/10/17 6:40:52 网站建设 项目流程

第6节 通知与升级:Slack/邮件报告和人工接管点

一句话总结:无人值守的"人类接口"设计——完成/失败/需要决策三类通知、升级规则(连续失败N次→呼叫人类)、每日简报模板;通知疲劳的防呆设计。Agent干活的下限靠循环,人类信任的上限靠通知。


本文导航

  • 一、被低估的模块:通知是无人值守的UI
  • 二、三类通知与升级规则
  • 三、完整实现:notifier.py
  • 四、通知疲劳:杀死系统的慢性毒药
  • 五、每日简报模板与人工接管点
  • 六、渠道工程:从Slack webhook到邮件降级链
  • 小结

一、被低估的模块:通知是无人值守的UI

前五节把Agent的"手"造完了:headless调用、PR审查、夜间分诊、goal终止、心跳常驻。这一节造的是"嘴"——它干完活怎么告诉你,出事了怎么喊你,想不清楚怎么问你。

这个模块在架构图上只占一个小框,却决定整套系统的生死。我见过两个真实失败案例,都不是Agent不行,是通知设计不行:

案例一,一个自动修bug服务,每5分钟给群发一条"运行正常"。第三天开始没人看群,第十七天它真的挂了,没人发现,挂了四天。

案例二,一个CI机器人把所有输出都堆进一条Slack消息(含完整日志),消息太长被折叠,关键错误藏在第300行。出事那晚大家翻折叠消息翻了二十分钟才找到根因。

通知是无人值守系统唯一的用户界面。界面设计的所有铁律在这里同样成立:信息层级要清晰、噪声要压到最低、重要的事要用重要渠道、永远别让用户猜"我该不该看"。

设计维度好的通知坏的通知
信息密度标题即结论,详情点开看所有内容平铺
频率有事才说,无事定期汇总定时轰炸
渠道分级紧急=电话/加急,一般=群消息一律群消息
可操作性带"该做什么"或"等我决策"只有"发生了什么"
信任成本看到通知=值得看看到通知=先怀疑

最后一行最致命:通知系统的信誉是一次性资产,烧完就没了。

再多说一层我的设计第一性原理。交互系统里,UI出问题用户会立刻发现并抱怨;无人值守系统里,“通知UI"坏了没有人在场发现——所以通知设计的目标函数不是"信息完整”,而是**“人类注意力的预算约束下,重要事件的到达率最大化”**。信息永远可以点开日志看,注意力一旦被噪声耗尽就再也回不来。后面每一节的设计(分级、去重、汇总、静默、降级链)都是这条原理的推论:任何一条消息被发出来之前,先回答"它配得上人类的一次抬头吗"。


二、三类通知与升级规则

我把所有需要通知的事件收敛成三类,这个分类两年没变过,因为它按**“需要人类做什么”**划分,而不是按事件来源划分:

什么都不用做

稍后看一眼

现在就决策

是

否

Agent事件

人类需要做什么?

✅ 完成通知
渠道:群消息
格式:一句话结果

❌ 失败通知
渠道:群消息+标签
格式:错误详情+建议

⚠️ 升级通知
渠道:加急/电话
格式:决策问题+选项

连续失败≥3次?

三类通知的关键差异:

维度完成通知失败通知升级通知
触发任务成功任务失败连续失败≥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 reviewAgent开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分钟

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询