LLM 数据增强任务卡 4 天:upsert 少传 id 后发生了什么

0 阅读5分钟

这篇写一个训练语料数据处理流水线里的真实问题。

背景是某 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 增强结果未带 idMySQL 找不到 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 空结果要进失败队列,不要只记日志。

长任务系统最怕的不是失败。

最怕的是它看起来还在跑,其实已经开始制造脏数据。

如果有帮助,欢迎点赞收藏。