
凌晨两点半训练还在跑。屏幕上的 loss 曲线已经压得很低但距离收敛少说还得一两个钟头。睡吧不踏实不睡吧又确实在熬时间。这种感受凡是正经训练过深度学习模型的人应该都不陌生。后来我花了大概二十分钟写了个脚本让训练一结束就自动发一封邮件到手机邮箱里训练中途异常退出也会通知。自那以后晚上终于敢合眼了。这个技巧本身没有多高深但实用价值非常大只需要一个能发邮件的脚本、一组 SMTP 配置就能把“等训练结束”这件苦差事完全托管出去。它适合所有训练脚本跑在服务器、工作站、实验室机器上的人尤其适合训练任务动辄几小时起步、却没法一直守在终端前的场景。这篇文章会把完整做法、参数选型、代码模板和踩过的坑一次性讲清楚你照着抄就能用。1. 核心需求与方案选型为什么“训练完发邮件”是小投入高回报1.1 “等训练结束”到底在等什么很多人第一反应是训练结束就结束了看一眼时间再去取结果不就完了问题是机器学习训练不像炖汤定个闹钟就能准点出锅。训练时长天然是波动的。数据加载速度快慢、GPU 是否被别人抢占、某个 batch 里出现了异常数据、学习率调度策略触发提前收敛……这些因素都让“预计三小时”经常变成“四小时起步”。你按三小时去等剩下的时间就是干耗你按五小时去等结果三小时就结束了模型在终端里挂着白白浪费算力。比耗时更麻烦的是异常中断。显存溢出、数据加载器卡死、磁盘写满、代码里某个断言失败这些情况都不会优雅地打印“训练失败”然后等你来。很多时候进程直接崩掉或者卡在一个地方不继续走。如果你没有第一时间发现可能等你想起来去查看的时候GPU 已经空转了好几个小时。第三个痛点才是真正的“刚需”训练机器和人不在一起。我在的公司里训练任务都跑在实验室的服务器上白天大家各忙各的晚上服务器继续跑。人根本不在机房也不可能为了盯训练进度在工位守着。这时候如果没有一个主动推送的机制你就只能隔一会儿远程登录看一眼费时费力还打断手头的事。所以“训练完发邮件”解决的不是某个具体 bug而是把“等待”这个高消耗动作彻底从人身上剥离掉。训练开始后你可以去写文档、去开会、去睡觉收到邮件再行动。这是一笔投入二十分钟、长期省下无数精力的买卖。1.2 几种实现思路的对比为什么我最终选了 SMTP 邮件做训练通知有几种常见路子我在这里直接放一张对比表比我干说要清楚。方案侵入性实时性部署成本依赖条件人工定期查看日志无差无人必须记得去看日志文件 定时脚本扫描低中低需要 crontab 日志解析逻辑程序内发邮件低中低有 SMTP 配置即可即时通讯群机器人推送低高低需要对应软件账号和公网可达第三方通知聚合服务低高中需要注册第三方平台、引入 SDK先说即时通讯机器人和通知聚合服务它们的实时性确实比邮件好手机弹通知的速度几乎是秒级。但问题是它们大多依赖某个具体平台或者公网可达的推送通道训练服务器在内网环境的时候就容易出问题。而且作为训练通知我们需要的只是“任务结束叫我一声”要求秒级响应吗完全不需要。延迟两分钟我都觉得无所谓。邮件方案最大的优势是协议开放、零额外依赖。SMTP 是邮件系统通用的发送协议任何一门语言都有现成的库邮箱人人都有邮件可以长期存档、回溯查看它不依赖任何特定 App更不用为了收一条通知去注册某个平台账号。对于“训练结束”这种分钟级延迟完全可接受的场景邮件是性价比最高的选择。另外还有一个很多人没意识到的点邮件是“可追溯”的。训练失败之后你翻邮件记录就能看到哪个实验在什么时间失败了、报了什么错。比你在聊天记录里翻来翻去要规整得多。2. SMTP 前置准备邮箱授权码、端口和密钥管理2.1 SMTP 参数清单与端口选择SMTP 的全称是简单邮件传输协议你不需要懂协议细节只需要记住程序通过 SMTP 服务器把邮件投递出去。你需要准备四样东西发件邮箱地址邮箱授权码不是登录密码SMTP 服务器地址在邮箱服务商的帮助文档里能查到端口号通常二选一端口这里有一点讲究。常见的是 465 和 587 两个。465 对应的是 SSL 加密程序一上来就建立加密通道587 对应的是 STARTTLS先走明文握手再升级成加密连接。我个人的习惯是直接用 465 SMTP_SSL少一次握手步骤少一类奇怪的中间层兼容问题实测下来最省事。还有一点容易被忽略SMTP 服务器地址是发件服务商提供的不是你随便填一个域名就行。你用哪家邮箱就要去那家邮箱的设置页里找“SMTP 服务器地址”。有的服务商对第三方客户端登录还有额外限制需要用独立密码或授权码这个下面专门讲。2.2 授权码生成与安全存放习惯先纠正一个高频误区程序里用的绝对不能是你的邮箱登录密码。绝大多数邮箱服务商在开启 SMTP 服务的时候都会要求设置一个独立的授权码。这个授权码专门给第三方客户端和脚本用权限上比登录密码更受限而且可以随时吊销重置。用登录密码走 SMTP一旦脚本配置文件泄露等于把整个邮箱交出去了风险完全不同。拿到授权码之后的存放问题我见过太多人踩坑。直接把邮箱账号、授权码、SMTP 地址硬编码在 train.py 里然后整个仓库推到代码托管平台。如果这个仓库是公开的等于把发件权限直接公开挂了出去即使是私有的团队里任何拿到代码的人也都看得到你的凭据。正确姿势是放进环境变量或者 .env 文件。我的习惯是项目根目录放一个 .env 文件格式长这样SMTP_HOSTsmtp.example.com SMTP_PORT465 SMTP_USERnotifyexample.com SMTP_AUTH_CODE这里填你的授权码 MAIL_TOmeexample.com,teammateexample.com然后在代码里用python-dotenv读取。同时必须把.env写进.gitignore确保它永远不会被提交到版本库。代码里使用环境变量import os from dotenv import load_dotenv load_dotenv() smtp_host os.getenv(SMTP_HOST) smtp_port int(os.getenv(SMTP_PORT, 465)) smtp_user os.getenv(SMTP_USER) smtp_auth_code os.getenv(SMTP_AUTH_CODE)这样做还有个额外好处换邮箱、换授权码的时候不用改代码只改环境变量就行。训练脚本在本地、测试机、生产服务器上跑同一套代码完全兼容。3. 最小可用实现让训练结束自动发邮件的完整代码3.1 先写一个能用的 send_mail 函数不管后面怎么封装核心的发送函数就这么一段。我直接给代码关键点写在注释里。import smtplib import os from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr def send_mail(subject, body, to_addrNone): smtp_host os.getenv(SMTP_HOST) smtp_port int(os.getenv(SMTP_PORT, 465)) user os.getenv(SMTP_USER) auth_code os.getenv(SMTP_AUTH_CODE) from_addr user if to_addr: to_addrs [to_addr] else: to_addrs os.getenv(MAIL_TO, user).split(,) # 纯文本邮件UTF-8 编码 msg MIMEText(body, plain, utf-8) # 主题用 Header 包装解决中文乱码问题 msg[Subject] Header(subject, utf-8) msg[From] formataddr((str(Header(模型训练通知, utf-8)), from_addr)) msg[To] ,.join(to_addrs) try: if smtp_port 465: server smtplib.SMTP_SSL(smtp_host, smtp_port, timeout10) else: server smtplib.SMTP(smtp_host, smtp_port, timeout10) server.starttls() server.login(user, auth_code) server.sendmail(from_addr, to_addrs, msg.as_string()) server.quit() except Exception as e: # 发信失败不能让训练任务崩溃记日志就行 print(f[notify] send mail failed: {e})几个细节值得展开说。MIMEText(body, plain, utf-8)第三个参数 utf-8 很容易被漏掉。不写的话正文里的中文在部分邮件客户端里会显示成乱码。Header(subject, utf-8)同理是给主题编码用的。formataddr里那段Header(模型训练通知, utf-8)是设置发件人显示名。收件人看到的发件人是你自定义的名字而不是一长串无意义邮箱地址可读性会好很多。timeout10很重要。SMTP 服务器有时候会响应很慢尤其是从内网访问外网邮箱服务器的场景。不设超时的话一个卡住的连接可能让训练结束后的流程挂在那里迟迟不退出。设成 10 秒最坏情况也就是多等十秒。3.2 把它包进训练流程try/finally 的正确姿势有了发送能力接下来要考虑的是“什么时候发”。直觉做法是训练跑完最后一个 epoch 之后调用一次send_mail()。但这有个致命问题如果训练中途异常崩溃这段代码根本执行不到你依然什么都收不到。所以要处理的其实不是“训练成功后的通知”而是“训练终止后的通知”——无论正常结束还是异常结束都要把人叫起来。这里我给一个推荐写法比单纯用 finally 更清晰import time import traceback def main(): notifier TrainNotifier(模拟项目X-图像分类) notifier.start() start_time time.time() try: model, metrics train_model() except Exception as e: duration time.time() - start_time notifier.fail(duration, traceback.format_exc()) # 重新抛出异常让进程退出码不为 0方便外层任务调度识别失败状态 raise else: duration time.time() - start_time notifier.success(metrics, duration, checkpoint_path/data/checkpoints/exp_v3/best_model.pth)注意这里用的是except ... raise加else而不是finally。理由很简单成功邮件和失败邮件内容完全不同。失败邮件要带堆栈成功邮件要带指标和 checkpoint 路径。如果统一放在finally里就得先设一个状态变量再根据状态拼内容代码反而绕。else分支只会在 try 块没有异常时执行天然区分了两种情况。还有一个很多人忽略的点发完邮件之后要不要重新抛出异常要。训练失败这个事实必须传导出去否则脚本的退出码是 0上层调度系统会认为任务正常完成可能不会触发重试或者告警。失败邮件是给人看的非零退出码是给系统看的两者都要。3.3 邮件内容设计主题和正文怎么写才真正有用通知邮件的核心价值是“让收件人第一时间决策要不要行动”。扫一眼手机推送你就要知道三件事哪个任务结束了、成没成功、要不要现在处理。所以主题行必须承担信息密度最大的任务。我常用的主题格式长这样[训练通知] 模拟项目X-图像分类 - 成功 - acc0.9412 - 耗时2h41m [训练通知] 模拟项目X-图像分类 - 失败 - CUDA out of memory - 耗时1h02m失败主题里直接带上错误摘要的前半句收件人甚至不用点开邮件就知道是显存炸了还是数据加载挂了。如果错误信息太长截取前 80 个字符足够。正文部分我会按这个模板组织训练任务已结束。 项目模拟项目X-图像分类 状态成功 开始时间2025-01-12 09:30:21 结束时间2025-01-12 12:11:43 训练时长2小时41分22秒 最终指标 - best_acc: 0.9412 - final_loss: 0.1871 Checkpoint 已保存至 /data/checkpoints/exp_v3/best_model.pth失败邮件的正文则把“最终指标”替换成“错误信息”然后把完整堆栈贴进去。时间项目统一用服务器本地时间后面我会单独讲时区问题。这里有一个进阶技巧如果你的实验有训练曲线可以考虑把曲线图作为附件或内嵌图片一起发出去。用MIMEMultipart加MIMEImage就能实现代码量不大。我实际体验是有了曲线图收到邮件之后基本不用再登录服务器看日志了。4. 进阶集成把通知器做成框架无关的通用组件4.1 回调机制训练框架在关键时刻通知你的标准姿势如果每次训练任务都要手写 try/else代码会冗余。更优雅的做法是把它做成一个通用组件然后挂到训练框架的回调机制里。所谓回调本质就是框架在执行到特定时机时调用你预先注册的函数。常见的时机包括训练开始、每个 epoch 结束、验证集评估结束、整个训练结束等等。只要你用的训练框架暴露了这样的钩子就可以把发邮件动作挂上去。思路其实很简单class NotifyCallback: def on_train_begin(self): self.notifier.start() def on_epoch_end(self, epoch, logs): # 可选每个 epoch 都发但记得节流不然邮件太频繁 pass def on_train_end(self, metrics): self.notifier.success(metrics, checkpoint_path)把通知逻辑从训练主流程中拆出来之后训练代码本身保持干净只关心模型和数据。通知这件事变成旁路组件想换方案、想停用都不影响训练主体。4.2 封装一个 TrainNotifier 类把前面的send_mail函数再封装一层做成一个带“开始/成功/失败”语义的类用起来会顺手很多。这个类可以直接复制到你的项目里。import smtplib import os import time from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr class TrainNotifier: def __init__(self, project_nametrain): self.project_name project_name self.start_time time.time() self.smtp_host os.getenv(SMTP_HOST) self.smtp_port int(os.getenv(SMTP_PORT, 465)) self.user os.getenv(SMTP_USER) self.auth_code os.getenv(SMTP_AUTH_CODE) self.to_addrs os.getenv(MAIL_TO, self.user).split(,) def _send(self, subject, body): msg MIMEText(body, plain, utf-8) msg[Subject] Header(subject, utf-8) msg[From] formataddr((str(Header(模型训练通知, utf-8)), self.user)) msg[To] ,.join(self.to_addrs) try: if self.smtp_port 465: server smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, timeout10) else: server smtplib.SMTP(self.smtp_host, self.smtp_port, timeout10) server.starttls() server.login(self.user, self.auth_code) server.sendmail(self.user, self.to_addrs, msg.as_string()) server.quit() except Exception as e: print(f[notifier] send failed: {e}) def start(self): subject f[训练通知] {self.project_name} - 开始 body ( f训练任务已启动。\n f项目{self.project_name}\n f开始时间{time.strftime(%Y-%m-%d %H:%M:%S)} ) self._send(subject, body) def success(self, metrics, duration_sec, checkpoint_path): subject f[训练通知] {self.project_name} - 成功 - 耗时{int(duration_sec // 60)}m body ( f训练任务已结束。\n f项目{self.project_name}\n f状态成功\n f开始时间{time.strftime(%Y-%m-%d %H:%M:%S, time.localtime(self.start_time))}\n f结束时间{time.strftime(%Y-%m-%d %H:%M:%S)}\n f训练时长{int(duration_sec // 3600)}小时{int(duration_sec % 3600 // 60)}分钟\n f指标{metrics}\n fCheckpoint{checkpoint_path} ) self._send(subject, body) def fail(self, duration_sec, error_info): subject f[训练通知] {self.project_name} - 失败 - 耗时{int(duration_sec // 60)}m body ( f训练任务已结束。\n f项目{self.project_name}\n f状态失败\n f结束时间{time.strftime(%Y-%m-%d %H:%M:%S)}\n f训练时长{int(duration_sec // 3600)}小时{int(duration_sec % 3600 // 60)}分钟\n f错误信息\n{error_info} ) self._send(subject, body)这个类有几个设计点值得说明。start_time在构造函数里记录这样不管后面是成功还是失败邮件里的“训练时长”都是从开始那一刻算起的。把项目名作为构造参数传入在跑多个实验时能清楚区分是哪一次训练。_send内部把异常吞了只打日志。这是有意为之邮件只是通知手段不能因为邮件服务器抖动导致训练进程退出。4.3 分布式并行训练如何避免一人收到 N 封邮件分布式训练是进阶场景也是很容易踩坑的地方。多卡训练时每个进程都会执行你的训练主函数。如果直接在每个进程里都调用notifier.success()你会立刻收到与进程数量一样多的重复邮件。4 卡就是 4 封8 卡就是 8 封体验非常糟糕。解决办法是只让主进程发通知。通用写法是判断一下当前进程的 rankdef main(): if dist.get_rank() 0: notifier.start() try: model, metrics train_model() except Exception: if dist.get_rank() 0: notifier.fail(time.time() - start_time, traceback.format_exc()) raise else: # 这里建议加一个同步点确保所有进程训练完成后主进程再发邮件 dist.barrier() if dist.get_rank() 0: notifier.success(metrics, time.time() - start_time, checkpoint_path)dist.barrier()这行很多人会漏。多卡训练里每个进程训练结束的时间未必严格一致有的进程可能早几秒退出 try 块。如果不加同步主进程可能在其他进程还没保存完日志和 checkpoint 的时候就把成功邮件发出去了。加一个 barrier保证大家都到位了再通知邮件里的信息才完整。另外即使主进程发信失败也不要在其他 rank 上做补救重发。保持“只有 rank 0 一个发送者”这个原则重复邮件比漏发一封更烦人。5. 完整实战复盘给一个真实训练任务装上邮件通知5.1 场景设定与目标我在实际工作中对接过一个典型的训练任务这里用“模拟项目X-图像分类”代指。它的特点很典型训练脚本跑在实验室的一台 Linux 服务器上装了新的 GPU 驱动和训练框架单卡训练预计 2 到 4 个小时需要后台运行人在工位不能一直开着终端要求训练结束、训练失败都要能第一时间知道下面就是我当时的完整操作流程每一步都有实际意义。5.2 分步配置与实施第一步创建 .env 文件在项目根目录新建.env填入 SMTP 相关配置。这里的smtp.example.com要替换成你自己邮箱服务商提供的 SMTP 服务器地址端口按服务商支持情况填。第二步创建 notifier.py把上面那个TrainNotifier类完整保存到notifier.py。这一步不需要改任何东西因为配置全部走环境变量。第三步修改训练主程序的入口在main()函数里按前面 try/else 的写法包一层。如果训练脚本已经有main()改动很小核心就三个位置启动时调start()成功分支调success()异常分支调fail()。当时我们训练脚本里有大量日志输出包括用日志库打到文件的、用print打到终端的。这些跟邮件通知完全不冲突。训练结束后邮件是额外通道不代替日志。第四步先做一次发信连通性测试这一步一定要做。很多人直接把代码接到训练脚本里就跑结果训练等了两小时结束后才发现 SMTP 配置写错了一封邮件都没发出去白白等了两个钟头。我当时的做法是单独跑一小段 Pythonpython -c from notifier import TrainNotifier; n TrainNotifier(连通性测试); n.success(test ok, 1, /tmp/test)要是这个测试邮件能收到说明配置没问题可以放心接进训练流程。收不到先在隔离环境里排查别把训练任务当测试工具。第五步后台启动训练任务确认配置正常后用nohup把训练脚本放到后台跑nohup python train.py train.log 21 这里把标准输出和错误输出都重定向到train.log。这样即使邮件出了问题你至少还能通过日志人工排查。重启服务器、断开会话都不会影响训练进程。第六步验证收件训练任务起来之后先确认收到了“开始”邮件。这个以我的经验非常有用它能确认整个链路从训练一开始就是通的而不是等到结束的时候才发现有问题。我甚至建议把开始通知做成每次训练必发。5.3 收到的邮件实际效果展示训练正常结束后我收到的是这样一封邮件主题: [训练通知] 模拟项目X-图像分类 - 成功 - acc0.9412 - 耗时2h41m 训练任务已结束。 项目模拟项目X-图像分类 状态成功 开始时间2025-01-12 09:30:21 结束时间2025-01-12 12:11:43 训练时长2小时41分22秒 最终指标 - best_acc: 0.9412 - final_loss: 0.1871 Checkpoint 已保存至 /data/checkpoints/exp_v3/best_model.pth手机上的邮件客户端一推送扫一眼主题就能判断这次实验成功了最佳准确率 94.12%checkpoint 在那个路径下不需要立刻去服务器做什么。这种体验比反复远程登录看日志舒服太多了。6. 常见问题排查与避坑实录6.1 高频问题速查表用这套方案接近一年我把自己遇到过的和身边同事踩过的坑整理成了一张表。现象可能原因解决办法SMTP 认证失败授权码错误或账号未开启 SMTP 服务到邮箱服务商后台重新生成授权码确认 SMTP 开关已打开连接超时服务器到邮件服务商网络不通或端口被封先 ping SMTP 服务器地址再换 465/587 端口测试邮件发送成功但收不到进了垃圾箱检查垃圾箱调低发信频率标题别带太多“测试”字样邮件正文中文乱码缺少 utf-8 编码参数MIMEText(body, plain, utf-8)第三参数必须写附件超过大小限制训练曲线图/日志文件太大对图片做压缩限制附件在 10MB 以内最稳连续收到多封重复邮件分布式训练多进程都发了判断 rank只让 rank 0 发信发信频率过高被限流每个 epoch 都发邮件增大节流间隔或只发开始/结束通知邮件里时间看起来不对服务器时区与本地不一致在脚本开头统一time.tzset()或直接用 UTC 并标注6.2 三个容易被忽略的细节第一个细节先做“裸发信测试”不要拿整段训练流程当测试环境。训练任务动辄几小时如果邮件配置有问题你得等几个小时才会发现。单独跑一次发信函数只需要几秒钟这个成本必须花。第二个细节不要在每个 epoch 都发邮件。我知道有人觉得每个 epoch 发一封能实时掌握进度但邮件不是即时聊天工具邮箱服务商对发信频率是有限制的。短时间内发太多轻则进垃圾箱重则被临时封禁发信权限。我见过一个同事一小时发了三十多封邮件然后邮箱服务商直接禁止他当天继续发信。如果你的确需要看进度折中方案是每 N 个 epoch 发一次或者只在进度发生显著变化时发。第三个细节不要在邮件里贴敏感的超长路径。基于安全习惯邮件内容是会被转发的。当你把含有机密研究数据、内部文件路径、服务器地址的邮件发给收件人时隐私边界会被放大。训练任务的信息等级要自己拿捏宁可少写也不要图省事把整个配置 dump 进去。我个人在实际操作中的体会是这套邮件通知方案最大的价值不在于省了多少次登录查看而在于它把“训练任务”从一个需要时刻牵挂的负担变成了一个可以真正放手的后台任务。收到开始邮件你知道链路通了收到成功邮件你知道可以去拿结果了收到失败邮件你知道该去处理什么问题了。最后再分享一个小技巧如果你平时会同时跑多个实验邮件主题里一定记得带上实验编号或者项目名。不然训练实验一多你看到四封“训练通知 - 成功”的时候根本分不清哪封对应哪个任务。有了清晰的命名这套机制在实验规模变大之后依然好用。