收件箱和模型可用性监测

关习习 38610a932f 更新部署步骤 2 周之前
docs f78825b0ef first commit 2 周之前
README.md 38610a932f 更新部署步骤 2 周之前
config.py f78825b0ef first commit 2 周之前
config.yaml.example f78825b0ef first commit 2 周之前
mailer.py f78825b0ef first commit 2 周之前
main.py f78825b0ef first commit 2 周之前
notifier.py f78825b0ef first commit 2 周之前
probes.py f78825b0ef first commit 2 周之前
requirements.txt f78825b0ef first commit 2 周之前

README.md

model-watchdog

阿里云百炼(DashScope)模型下线通知 + 模型可用性双模块监控。 一个常驻 loop,按 interval_minutes 反复执行两个检测,命中即"邮件 + 钉钉"双重告警。

部署文档: 【腾讯文档】model_watchdog部署步骤 https://docs.qq.com/doc/DUnFhZHFGcW5EU1ds

main.py loop(每 interval_minutes 一轮)
├── 模块 A  check_offline_notice   检测到下线通知 -> 转发原邮件给通知列表 + 钉钉
└── 模块 B  check_model_health     模型可用性检测 -> 聚合告警邮件 + 钉钉

快速开始

pip install -r requirements.txt

python main.py --once --dry-run     # 只检测并打印将发送的告警,不真发
python main.py --once              # 真实跑一轮
python main.py                     # 常驻循环

命令行参数:

参数 说明
-c, --config 配置文件路径,默认 config.yaml
--once 只执行一轮后退出
--dry-run 只检测并打印告警内容,不真正发送
--skip-mail 跳过模块 A(只测模型)
--skip-probe 跳过模块 B(只测邮箱)
--test-dingtalk 只给钉钉发一条测试消息后退出

重点配置

  1. offline_notice.sender_domains —— 允许触发告警的发件人域名白名单。

  2. api_key —— 百炼 DashScope Key,所有端点探测共用这一个。

配置说明

api_key: "sk-xxx"              # 百炼 Key,所有端点共用
interval_minutes: 360          # 每隔多少分钟跑一轮(360 = 6 小时)

mail:
  imap_host: "imap.exmail.qq.com"
  imap_port: 993
  inbox_folder: "INBOX"
  sent_folder: "Sent Messages" # 填错会自动在常见候选名里兜底探测
  smtp_host: "smtp.exmail.qq.com"
  smtp_port: 465
  smtp_ssl: true
  username: "cat@example.com"  # 监控账号,同时是发件人
  password: "xxx"
  append_to_sent: true         # 服务商不自动归档时,用 IMAP APPEND 补一份
  notify_emails:               # 告警群发列表
    - "someone@example.com"

offline_notice:
  keyword: "模型下线通知"        # 标题或正文命中即算
  sender_domains:              # 发件人域名白名单(不是完整地址)
    - "aliyun.com"
  lookback_days: 3             # 收件箱与已发送的回溯天数

models:                        # 只填模型名,端点参数见 config.py 的 ENDPOINTS
  chat:       { enabled: true, models: ["qwen3.5-plus", "qwen3.5-flash"] }
  tts:        { enabled: true, voice: "longxiaochun_v2", models: ["cosyvoice-v3-flash"] }
  asr:        { enabled: true, models: ["fun-asr"] }
  text2image: { enabled: true, probe_mode: "validate", models: ["wan2.2-t2i-flash"] }
  text2video: { enabled: true, probe_mode: "validate", models: ["wanx2.1-t2v-turbo"] }

dingtalk:
  enabled: false
  access_token: "xxx"              # 机器人 token,只填这个即可
  secret: "SECxxx"                 # 安全设置选"加签"时填,否则留空
  webhook: ""                      # 留空用官方地址;也可粘整条自带 token 的 webhook
  at_all: false                    # true = @所有人
  timeout_seconds: 15
  at_mobiles:                      # 需要 @ 的成员手机号
    # - "13800000000"

enabled: false 的分组整组跳过;分组下写与 ENDPOINTS 同名的字段即可覆盖默认值 (如 timeout_secondspromptvoiceprobe_mode)。 所有字段都支持 ${ENV} 注入,例如 access_token: "${DINGTALK_DEV_NOTIFY_TOKEN}"

钉钉机器人

固定调用 https://oapi.dingtalk.com/robot/sendaccess_token / timestamp / sign 作为 query 参数传入,消息类型为 markdown。

  • 加签sign = base64(HMAC-SHA256(secret, "{毫秒时间戳}\n{secret}")), 由 requests 的 params 负责 URL 编码 —— 不要自己 quote,二次编码会导致验签失败。
  • @成员at_mobiles 里的号码会同时写进 at.atMobiles 并追加到正文末尾, 因为钉钉要求 markdown 正文里出现 @手机号 字面量,@ 才会真的生效。
  • enabled: true 但没配 access_token(且 webhook 里也没有)时,load_config 会直接报错退出, 避免"以为配好了、其实一直静默不推送"。
  • 钉钉返回 errcode != 0 时抛异常,errmsg 会原样打进日志(token 无效 / 验签失败 / 关键词不匹配 / 限流都能从这里看出来)。

单独联调机器人,不用等整轮检测:

python main.py --test-dingtalk

模块 A:模型下线通知检测 → 转发

判定条件(两者同时满足):

  • lookback_days(默认 3)天收件箱
  • 发件人域名属于 offline_notice.sender_domains,且标题或正文包含 offline_notice.keyword(默认「模型下线通知」)

为什么校验域名而不是完整地址

阿里云用哪个具体邮箱号发通知是不确定的,写死完整地址一旦对不上就会静默漏消息—— 这是最危险的失效方式。改成域名白名单后,该域名下换任何邮箱号发都能收到, 同时又拦住了外部陌生人发含关键词的骚扰邮件。

匹配规则是「域名相等为其子域」,不是裸 endswith

发件人 aliyun.com 是否命中
noreply@aliyun.com ✅ 本域
service@mail.aliyun.com ✅ 子域
evil@notaliyun.com ❌ 裸 endswith 会误放行
evil@fake-aliyun.com ❌ 同上
evil@aliyun.com.evil.cn ❌ 域名前缀伪装
aliyun.com@qq.com ❌ 目标域塞进本地部分

配置写法有容错:aliyun.com@aliyun.comnoreply@aliyun.comALIYUN.COM 都会被规整成 aliyun.com;只有一个域名时也可以不写成列表。 留空则不校验发件人,启动时会打 WARNING。

命中后立即把原邮件转发notify_emails,并同时推钉钉。转发件标题:

【模型下线通知转发】<原邮件标题>

转发件结构:

部分 内容
text/plain 转发说明(原发件人/时间/Message-ID)+ 原邮件正文,可直接阅读
message/rfc822 原始邮件完整存档 original.eml,保留全部头信息

Reply-To 设为原发件人,直接回复即可回到阿里云。

去重(本地无状态):按「这封是否已经转发过」判定,依据写在信头里。

转发时把原邮件的 Message-ID 写进转发件的两个信头:

信头 作用
References 标准转发/回复关系头
X-Forwarded-Msgid 自定义头,冗余一份,防止服务商改写 References

每轮检测先扫「已发送」近 N 天,用 BODY.PEEK[HEADER.FIELDS (REFERENCES X-FORWARDED-MSGID)] 批量取这两个头, 解析成「已转发过的原邮件 Message-ID 集合」。收件箱里命中的通知若其 Message-ID 已在集合中,说明这封已经转发过,跳过。

标题不参与去重,所以改标题不影响去重;但不要手工删除或改写转发件的这两个信头

发信后程序会回查「已发送」确认转发关系已入库(轮询重试 4×4s,因为服务商自动归档有延迟); 若始终查不到,则用 IMAP APPEND 补一份(mail.append_to_sent: true)。 腾讯企业邮实测为自动归档、不接受 APPEND,且会完整保留上述两个信头,靠自动归档即可去重。

另外,程序会跳过发件人等于自己账号的邮件,避免转发件进自己收件箱后自我触发。

IMAP 的 FROM 只是子串匹配,仅用来缩小 fetch 范围;取回邮件后会再做一次精确的域名归属校验。 极少数邮件没有 Message-ID,则用「发件人 + 日期 + 标题」生成一个稳定标记参与去重。

模块 B:模型可用性检测

按 API 端点分类,你只需要往对应分组的 models 里填模型名, 端点地址和探测参数都在 config.pyENDPOINTS 里有默认值,需要覆盖时在分组下写同名字段。

分组 告警中显示名 端点 探测方式 费用
chat 文本对话 /compatible-mode/v1/chat/completions prompt 只发 okmax_tokens=16 约 30 token
tts 文本转语音 wss://.../api-ws/v1/inference/ 建连 + run-task,收到 task-started 立即断开,不发合成文本
asr 语音转文本 /api/v1/services/audio/asr/transcription 异步提交官方示例音频,拿到 task_id 即通过,不轮询 极低
text2image 文生图 /api/v1/services/aigc/text2image/image-synthesis 见下方 probe_mode 0(validate)
text2video 文生视频 /api/v1/services/aigc/video-generation/video-synthesis 见下方 probe_mode 0(validate)

probe_mode(文生图 / 文生视频)

阿里云对这两个端点是先解析模型、再校验参数的顺序,据此可以零成本判断模型是否在线:

  • validate(默认,推荐):故意不传 prompt
    • 模型在线 → 400 InvalidParameter: input.prompt should not be null → 判为可用
    • 模型下线 → 400 InvalidParameter: Model not exist. → 判为不可用
    • 不会真的出图/出视频,零生成费用
  • submit:真实提交生成任务,端到端验证,会产生生成费用(视频尤其贵)

告警格式

邮件标题:

【模型可用性告警】3 个模型不可用:deepseek-v4、cosyvoice-v9-flash、wan9.9-t2i-flash

邮件正文:

【模型可用性告警】

检测时间: 2026-09-01 11:10:01
探测总数: 4 失败: 3

deepseek-v4(文本对话)报错 model_not_found The model `deepseek-v4` does not exist...,完整响应 {...};
cosyvoice-v9-flash(文本转语音)报错 ModelNotFound Model not found (...)!,完整响应 {...};
wan9.9-t2i-flash(文生图)报错 InvalidParameter Model not exist.,完整响应 {...}

—— model-watchdog 自动告警

单条格式为 模型名(显示名)报错 <code> <message>,完整响应 <raw>,多条之间用 ;\n 连接。 本轮所有失败模型聚合为一条告警,完整响应 保留原始响应体(最长 1500 字符)。 全部模型正常时不发任何通知(静默)。

运行日志

正常一轮(无告警):

配置加载完成: 每 3 分钟一轮 | 待测模型 文本对话×2、文本转语音×1、语音转文本×1、文生图×1、文生视频×1 | 邮箱 cat@mail.gxx12138.space
下线通知匹配条件: 发件人域名=aliyun.com 关键词=模型下线通知 回溯=3 天
========== 开始第 2026-09-01 12:13:41 轮检测 ==========
[邮件] 已发送中近 3 天的转发记录: 2 封原邮件
[邮件] 收件箱近 3 天命中下线通知: 1 封
[邮件] 其中 1 封已转发过,跳过
[模块A] 无需要转发的模型下线通知
[探测] qwen3.5-plus (文本对话) 正常, 1456 ms
...
[模块B] 探测 6 个模型,失败 0 个
========== 本轮结束,耗时 3.4s,转发下线通知 0 条,不可用模型 0 个 ==========
休眠 3 分钟,下次检测 2026-09-01 12:16:44

其中「已转发过,跳过」就是去重生效的标志。首次转发时会看到:

[邮件] 已转发下线通知给 1 人: 【模型下线通知转发】xxx
[邮件] 转发记录已确认在已发送中(服务商自动归档),去重生效

文件结构

文件 职责
main.py 主循环 + 两个检测模块的编排
config.py 配置加载、${ENV} 注入、端点默认值 ENDPOINTS、校验
config.yaml 用户配置(只需填 key / 频次 / 邮箱 / 模型名)
probes.py 各 API 端点的探测实现与成败判定
mailer.py IMAP 收件检测 + 原邮件转发 + 已发送去重 + SMTP 群发
notifier.py 钉钉机器人 + dual_forward(转发下线通知)/ dual_alert(可用性告警)

部署

nohup python main.py -c config.yaml >> watchdog.log 2>&1 &