这篇写一个训练语料数据处理流水线里的真实问题。
背景是某 A 股上市环保集团的训练推理平台。数据集上传后,要经过清洗、去重、LLM 数据增强,最后导出 JSONL 给训练链路。清洗任务通常几分钟,增强任务按数据量可能跑几十到上百小时。
一旦任务跑到这个时间跨度,核心问题就不只是「怎么调用模型」了,而是:
- 任务怎么暂停
- 怎么从断点继续
- 同一批数据重跑时怎么不重复写
- 写坏了怎么发现
总体链路
flowchart TD
A[Dataset Upload] --> B[Relational Corpus Table]
B --> C[Data Cleaning Task]
B --> D[LLM Augmentation Task]
C --> E[Upsert Writeback]
D --> E
E --> F[Export JSONL]
F --> G[MinIO]
S[task_execution_status] --> C
S --> D
R[Redis Pause Flag] --> C
R --> D
这张图里最重要的不是 LLM,而是旁边两个状态源:DB 里的任务游标、Redis 里的暂停标志。
为什么语料要进 MySQL
如果只是脚本,处理 JSONL 文件最简单。但平台要支持查询、分析、预览、软删除和断点续跑,文件流不够用。
多轮对话样本被拆成关系表记录,简化后类似这样:
{
"id": 1024,
"dataset_file_id": 7,
"row_identifier": "row-00031",
"turn_taking": 2,
"data_system": "...",
"data_prompt": "...",
"data_reasoning_content": "...",
"data_response": "...",
"data_weight": 1,
"is_deleted": "0"
}
这个 schema 在解决两个问题:平台能按行查询数据,任务也能按文件和 offset 恢复进度。
读取多轮对话时,按 row_identifier + id 排序重组。清洗或增强后,再把结果回写到同一套中间态里。
游标状态机
长任务不能靠 worker 记忆进度。我用状态表记录当前文件、offset、已处理行数和状态。
简化后的恢复逻辑是:
resume_file_id, resume_offset = get_resume_position(task_id, stage)
for file_id in dataset_files:
if should_skip(file_id, resume_file_id):
continue
offset = resume_offset if file_id == resume_file_id else 0
resume_offset = 0
while True:
batch = load_batch(file_id=file_id, offset=offset, limit=batch_size)
if not batch:
break
if is_task_paused(task_id):
save_cursor(file_id=file_id, offset=offset, status="paused")
raise TaskPausedException()
process(batch)
offset += batch_size
save_cursor(file_id=file_id, offset=offset, status="running")
这里的 resume_offset = 0 很容易被忽略。缺了它,多文件恢复时,后续文件也会套用恢复文件的 offset,前 N 行会被跳过。
为什么清洗 batch=10000,增强 batch=10
清洗主要是 CPU 上的规则处理、格式整理和去重,单条很快。batch_size=10000 可以减少数据库往返。
增强任务每条都可能调 LLM。单条慢,外部服务也可能波动。如果 batch 太大,用户点暂停后要等很久才生效。增强侧 batch_size=10 牺牲了一点吞吐,换暂停响应。
这两个数字不是压测最优值。它们只是表达了不同任务的优先级:清洗要吞吐,增强要可控。
故障:upsert 漏 id,读写同表无限循环
Response 增强阶段踩过一个坑。
这个阶段从目标表分页读原始记录,调用 LLM 生成 response,再 upsert 回同一张表。原本的意图是:断点重跑同一批数据时,只更新原记录。
问题是有个回写 payload 少了主键 id。
| 失败类型 | 脱敏样例 | 为什么错 | 处理策略 |
|---|---|---|---|
| upsert 漏主键 | response 增强结果未带 id | MySQL 找不到 duplicate key,更新退化为插入 | 回写显式带 original_record.id |
| 读写同表 | offset 分页读目标表,同时向目标表追加新行 | offset 增长追不上表尾增长,循环不退出 | 后续需要把无 id 更新做成硬失败 |
| 状态回写不完整 | 暂停后继续接口查不到 paused | 异常分支只更新了部分状态源 | 补齐执行状态、本地业务状态和外部状态 |
修复代码本身只有一行:
upsert_rows.append({
"id": original_record.id,
"data_response": generated_response,
"dataset_file_id": original_record.dataset_file_id,
"row_identifier": original_record.row_identifier
})
这段代码解决的是「让 upsert 命中原记录」。但根因不是一行字段,而是幂等依赖了隐式约定。
如果是现在收这块,我会在 DAO 层加保护:更新类 upsert 没有主键直接抛错,不允许退化成插入。
另一个边界:LLM 失败不能只记日志
批量 LLM 调用里,单条失败会被隔离,不中断整批。当前机制是失败项用空字符串占位,并统计 success/fail。
生产上没有确认出现过一批空样本混进训练集的问题。但从数据质量角度看,这个边界还是软。
更稳的做法是:
if not generated_response.strip():
mark_failed_sample(record_id, reason="empty_response")
continue
if not validate_output(generated_response):
mark_failed_sample(record_id, reason="invalid_format")
continue
upsert(record_id, generated_response)
这段逻辑的目标不是让任务永远成功,而是避免失败样本静默进入导出链路。
Takeaways
这类训练语料流水线,别只盯着清洗规则和 prompt。
真正容易出问题的是更底层的东西:
- 游标要持久化,不要只放 worker 内存。
- 暂停要是业务状态,不是直接杀进程。
- upsert 不能只靠调用方记得传主键。
- 读写同表时,offset 分页要特别小心。
- LLM 空结果要进失败队列,不要只记日志。
长任务系统最怕的不是失败。
最怕的是它看起来还在跑,其实已经开始制造脏数据。
如果有帮助,欢迎点赞收藏。