# 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 模型可用性检测 -> 聚合告警邮件 + 钉钉 ``` ## 快速开始 ```bash 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,所有端点探测共用这一个。 ## 配置说明 ```yaml 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_seconds`、`prompt`、`voice`、`probe_mode`)。 所有字段都支持 `${ENV}` 注入,例如 `access_token: "${DINGTALK_DEV_NOTIFY_TOKEN}"`。 ### 钉钉机器人 固定调用 `https://oapi.dingtalk.com/robot/send`,`access_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 无效 / 验签失败 / 关键词不匹配 / 限流都能从这里看出来)。 单独联调机器人,不用等整轮检测: ```bash 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.com`、`noreply@aliyun.com`、`ALIYUN.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.py` 的 `ENDPOINTS` 里有默认值,需要覆盖时在分组下写同名字段。 | 分组 | 告警中显示名 | 端点 | 探测方式 | 费用 | |---|---|---|---|---| | `chat` | 文本对话 | `/compatible-mode/v1/chat/completions` | prompt 只发 `ok`,`max_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 自动告警 ``` 单条格式为 `模型名(显示名)报错 ,完整响应 `,多条之间用 `;\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`(可用性告警) | ## 部署 ```bash nohup python main.py -c config.yaml >> watchdog.log 2>&1 & ```