四小时的批量任务死在 98%:我们学到的断点续跑三件事
上个月我们在本地推理机上跑一个批量任务:对两万四千条脱敏后的客服消息做分类,每条都要过一遍本地模型,一趟跑满四小时。

四小时的批量任务死在 98%:我们学到的断点续跑三件事
上个月我们在本地推理机上跑一个批量任务:对两万四千条脱敏后的客服消息做分类,每条都要过一遍本地模型,一趟跑满四小时。
八月中段出了事。第三趟跑到 23,500 条的时候机房电压波动,机器直接重启。还剩 500 条,按理说十分钟的事。实际呢?我们盯着空白的结果文件看了很久——全量八千兆的中间产物一个字节都没落盘,因为脚本是"跑完再一次写入"。四小时,从头再来。
那次之后我们把断点续跑拆成三件事,每一件都是交过学费的。
**第一件:结果要小批落盘,别信"最后保存"。**
现在的写法是每 100 条追加一次 jsonl,并且显式 flush。100 条是试出来的:单个 checkpoint 开销可以忽略,最坏情况的重跑量约 20 分钟。这里刻意不追求最小 checkpoint:每 10 条落盘一次看着更安心,但九百多次 fsync 下来,整个任务的墙钟时间被拖长了大约百分之七,最后还是要为每一毫秒买账。最初我们用 SQLite 存进度,结果有趟跑满之后磁盘被日志吃满了,数据库写进去一堆半截事务,得不偿失。追加写的流式文件反而最简单——坏了就坏,好就行。
顺便说一句落盘的位置。结果目录和日志目录要分开挂盘。我们统一挂在同一块数据盘的时候出过事:一次日志刷写把所在分区的 inode 吃掉一半,批量任务莫名其妙开始报 EIO,最后排查了两小时才发现是 inode 耗尽,不是数据本身有问题。
**第二件:恢复进度只看结果文件,不看内存里的计数器,也不看日志里的百分比。**
第一次的教训就是进度数字不可信:日志已经写了"19,200/24,000",但数据文件只 flush 到 19,100。现在的恢复逻辑是:读结果文件,把已完成的 ID 收成一个集合,待办 = 全量 − 已完成。任务跑多久死在哪里都无所谓,集合说了算。原则就一句话——能算出来的状态,绝不存两份。内存计数器、进度条、heartbeat 里的百分比,全是派生值,丢了就丢了。
这里还有一个容易踩的坑:jsonl 追加写的最后一行可能是半行。断电可能恰好把一行写了一半,恢复的时候必须校验最后一行的完整性,坏了就砍掉重跑那一条。我们为此写过一个 lint 脚本:每行独立解析,失败就报告行号。五分钟的活,省过两次更大的排查。
**第三件:幂等。同一个输入,重跑多少遍都只该有一条结果。**
一条记录的唯一键不是数据库自增 ID,而是「输入内容 + 模型版本 + prompt 版本」的哈希。这样做的好处是,模型升级或者 prompt 改版之后,旧 checkpoint 目录自动作废,新目录从零算,新旧结果永远不会搅在一起。我们吃过"prompt 悄悄改了一版,旧结果混在新批次里"的亏,出来的报告两个版本各占一半,比直接重跑还难解释。
上线之前我们还给恢复逻辑加了一个"对时刻":随机抽 200 条 ID,分别确认在结果文件里能查到、且内容能解析。200 条是拍脑袋的,但没有这个样本级的抽检,我们对"集合等于真实进度"这件事就只有信仰,没有证据。
数字对比一下:修复后第二趟任务又在 74% 处断过一次,恢复只花了 11 分钟补完剩余部分,其中 3 分钟耗在重新扫描已完成集合上。这三个月这套机制陪任务跑了二十多趟,中断两次,两次恢复都在十分钟以内,没有一次需要重算。
批量任务其实和长跑是一回事:你不需要一公里都记住,只需要每个路口都留下一枚记号。
留言区
欢迎分享你的想法!
加载留言中…