文章
LLM 数据增强流水线如何做暂停和断点续跑
聂二(AILDNC)复盘一个训练语料数据处理流水线:为什么长耗时 LLM 增强任务要用 DB 游标、Redis 暂停标志和 upsert 幂等回写。
训练语料数据处理流水线是一套用于大模型训练数据清洗与增强的长任务系统,它用关系表、DB 游标和幂等回写支持暂停、断点续跑和可追溯加工。
这篇是聂二(AILDNC)从一个真实项目里拆出来的工程复盘。客户身份做了脱敏,可以公开的背景是:某 A 股上市环保集团的训练推理平台,语料大多和环保项目相关,但平台能力按通用数据处理设计。
我后来对这类系统有个很直接的判断:LLM 数据增强最麻烦的地方,常常不是生成质量,而是任务怎么停、怎么接着跑、怎么不把数据写脏。
清洗任务几分钟能跑完,问题还不明显。增强任务不一样,数据量一上来就可能跑几十到上百小时。用户会点暂停,worker 会挂,模型调用会失败,测试环境会反复重跑。只要它是平台能力,就不能像脚本一样「跑崩了重来」。
为什么不直接处理 JSONL 文件
一开始最容易想到的做法,是把数据集当文件流处理:读 JSONL,清洗,增强,再写回一个新文件。它轻,也符合很多离线数据工程的直觉。
但这个项目里,文件流不够用。
平台需要让用户查看数据集内容,做查询和分析,还要在前端展示任务进度。多轮对话样本也不能只是一个字符串,它需要按会话行、轮次、system、prompt、reasoning、response、weight 等字段拆开,后面再重组回完整对话。
所以我把语料拍平进 MySQL。每条记录带 dataset_file_id、row_identifier、turn_taking 和一组 data_* 字段,删除用 is_deleted 软删除。读取时按 row_identifier + id 排序,把拆开的多轮对话拼回去。
这不是更优雅的方案,只是更适合这个平台的方案。它换来了分页、预览、查询、软删除和断点续跑,也把压力转移到了数据库。
有舍有得。
长任务的核心其实是游标
这条流水线的暂停和续跑,不是靠 worker 记忆,而是靠数据库里的游标。
状态表按 (task_id, stage, task_type) 记录几件事:
current_file_id:现在处理到哪个文件current_offset:这个文件处理到哪里processed_rows:已处理多少行status:running、paused、failed 这类任务状态
恢复任务时,系统先读游标。已经完成的文件跳过;命中恢复文件时,从 offset 继续;恢复文件处理完之后,resume_offset 要立刻归零。
这个归零细节很小,但很要命。多文件顺序处理时,如果 offset 不归零,后续文件也会被套上恢复文件的偏移量,前 N 行直接被跳过。你不会立刻报错,只是数据悄悄少了。
暂停用的是 Redis 标志位轮询。每个 batch 开头看一次,如果命中暂停,就先保存游标,再抛业务异常,最后把状态写成 paused。
为什么不是 Celery revoke?说实话,当时没有做很复杂的方案对比。Redis 用得熟,而且这个场景要的是优雅暂停:停之前把进度存好,后面能接着跑。直接杀任务不是这个问题的答案。
10000 和 10 同时存在,不是笔误
这条流水线里有两个很反差的 batch_size:
- 数据清洗:
10000 - 数据增强:
10
清洗大多是 CPU 上的规则处理、去重、格式整理,单条很快,batch 大一点可以少跑数据库往返。增强就完全不同了。每条样本都可能触发一次 LLM 调用,慢、贵,还容易被外部服务波动影响。
如果增强 batch 也设成很大,用户点暂停之后,系统可能要等很久才反应。对用户来说,这就等于暂停键坏了。
所以增强侧把 batch_size 压到 10。它不一定是最优数字,也不是压测精算出来的参数,但它表达了一个取舍:吞吐让一点,暂停响应要回来。
我后来越来越觉得,这类参数真正要解释的不是「为什么是 10」,而是「你到底优先保护什么」。在这个项目里,优先保护的是长任务可控。
一个 upsert 漏了 id,任务跑了四五天
这套设计最硬的一次教训,出在 Response 增强阶段。
这个阶段从目标表分页读原始记录,调用 LLM 生成新的 response,再把结果回写到同一张表。为了支持断点重跑,落库统一用 INSERT ... ON DUPLICATE KEY UPDATE。理论上,同一批数据重复处理也只是更新,不会插入重复行。
问题就出在「理论上」。
当时回写字典漏传了主键 id。MySQL 没有主键可以命中,upsert 就退化成 insert。于是系统一边按 offset 读表,一边往同一张表插新行。
结果很诡异:offset += batch_size 在往前走,但表尾也在往后长。while True 永远到不了终点。
这个任务卡了四五天,产生了千万级脏数据。测试侧发现后,脏数据直接删掉,代码修复其实只有一行:回写时补上 'id': original_record.id。
一行代码,背后不是一行问题。
真正的问题是「读写同表 + offset 游标 + upsert 必须传主键」这三个条件叠在一起。只要有一个隐式约定没守住,系统就从幂等更新变成了无限插入。
幂等不能只靠约定
upsert 是好东西,但它不是保险箱。
这次故障让我对「隐式约定」更敏感。调用方必须传 id,这句话如果只写在脑子里、注释里、经验里,迟早会有人漏掉。更稳的做法应该是让 DAO 层或数据结构本身拦住这种错误:Response 增强这种更新场景没有 id 就直接失败,而不是默默 insert。
同样的道理也出现在暂停状态上。暂停时原本只更新了部分状态,导致继续任务时查不到 paused 记录。后来补齐了执行状态表、本地业务表和外部业务系统的状态回写。
这类冗余不丑。真正丑的是异常分支里只写了一半。
还有一个没炸出来的隐患
LLM 批量调用里,单条失败被 try/except 隔离,不会中断整批。失败结果用空字符串占位,同时统计 success/fail。
素材里确认,生产上没有出现过一批空样本混进训练集的问题。但从机制上看,这里还不够硬。空串占位能保护任务不中断,却不能保证训练数据质量。
如果现在重新收这条链路,我会加一道内容级质量门:增强结果为空、过短、格式不合法,不能只记日志,至少要进入失败样本队列或等待重试。长任务系统不能只关心跑完,也要关心跑完以后留下什么。
我会带走的几个判断
做训练语料流水线,很多人会先盯着清洗规则、增强 prompt、去重算法。这些当然重要。但一旦它变成平台能力,真正决定可用性的东西会往下沉:
- 进度是不是持久化的,而不是 worker 记忆里的
- 暂停是不是一种业务状态,而不是 kill 掉进程
- 重跑是不是幂等的,而不是靠运气不重复
- 写入是不是有防线,而不是靠调用方记得传字段
- 失败样本是不是被拦住,而不是混进训练集
这些东西不显眼。演示时也不太好讲。
但任务跑到第三天,用户点暂停的时候,它们就会变成系统的脸面。
相关内容可以看 aildnc.com 上的案例与项目页。 如果你正在做企业训练语料处理、RAG 知识库、文档解析或 LLM 数据增强,可以通过页面下方微信二维码或邮件沟通,邮箱:contact@aildnc.com。