LLM数据格式梳理:JSONL、Parquet、Safetensors与GGUF 做 LLM 相关内容数据格式是绕不过去的一道坎。不管你是准备预训练语料、做指令微调还是部署量化后的模型第一天就会碰到 JSON、JSONL、Parquet、Safetensors、GGUF 这一堆名词。这篇文章把这些常见格式从头到尾梳理一遍说清楚每一种格式解决什么问题、在哪个阶段用、互相之间怎么转换也把我实际处理数据时踩过的坑一并列出来。适合刚开始接触大模型数据处理的新人也适合手里已经有数据但总觉得管得不够规范的工程师。1. 先给 LLM 数据格式画一张全景图1.1 数据格式的三大类别LLM 相关的数据格式看上去很杂但按生命周期分其实就三大类训练样本格式、大规模存储格式、模型权重格式。三类格式服务的阶段不同选型逻辑也完全不同。训练样本格式是我们日常接触最多的比如纯文本、JSON、JSONL以及各种指令微调、偏好数据的具体结构。这一类格式的核心诉求是“结构表达”说白了就是能不能把一条样本里的指令、回答、角色关系表达清楚同时又方便程序去解析和切分。大规模存储格式主要指 Parquet、Arrow 这类面向海量数据的列式存储格式。它们在普通小数据集上显得“杀鸡用牛刀”但数据量一旦到百万级、千万级行的解析开销和磁盘 IO 就会变成实实在在的瓶颈这时候就该它们上场。模型权重格式则是另一套体系包括训练时保存的 ckpt、bin 文件社区逐步转向的 Safetensors以及面向推理部署的 GGUF 等。这一类格式的关键词是“安全”和“体积”既要保证加载时不执行恶意代码又要尽量压缩体积让模型能跑在普通设备上。用一张表可以快速建立整体认知类别典型格式使用阶段核心诉求训练样本txt / json / jsonl / ChatML 结构、偏好配对结构数据清洗、微调训练结构清晰、便于拆装与扩展大规模存储parquet / arrow预训练语料、海量 SFT 数据列式读取、高压缩率、低 IO模型权重ckpt / bin / safetensors / gguf训练保存、量化、推理加载安全、单文件分发、体积可控1.2 选错格式的隐性成本很多人觉得格式是小事等出问题才知道代价。格式选错通常不会立刻报错它更像一种慢性病JSON 嵌套太深导致解析慢几百万条样本全塞进一个数组导致内存溢出权重文件用旧格式加载直接报安全警告或者数据和聊天模板不配套导致训练完的模型答非所问。我个人的经验是格式是数据 pipeline 的“接口契约”。团队统一代码风格重要但统一数据 schema 更重要。你花半天把格式定清楚后面省下的是两天三天的返工时间。这一点在做多人协作的数据项目时尤其明显每个人导出的数据字段名不一样合并的时候就是你最想摔键盘的时候。2. 训练样本格式从纯文本到指令数据2.1 预训练语料纯文本与轻量包装预训练阶段大部分语料本质上就是普通文本模型只需要“读”大量连续文本。但工程上我们不会直接把一堆 txt 丢给训练脚本而是会给每条样本加一个轻量包装最常见的做法就是 JSONL 里放 text 字段{text: 今天天气不错适合出去走走。, source: 某语料批次_202401, id: 000001}这里加 source 和 id 不是画蛇添足。source 能帮你在数据质量出问题时快速定位污染批次id 则方便做去重和样本级溯源。我接手过的模拟项目里有一次发现某个批次的 PDF 解析结果全是乱码就是因为 source 字段记录得完整半小时内就锁定并替换了问题数据。预训练数据还有一个经常被忽略的格式——token 化之后的二进制序列。文本经过分词器变成 token id 后通常直接用 numpy 数组保存成 .npy 或二进制文件token id 用 uint16超长文本用 uint32。这一步的目的纯粹是省空间和加速毕竟训练时反复读原始文本再分词IO 和算力都吃不消。清洗预训练语料时格式层面要处理的三件事是按文档切分而不是按行切分保证一个样本是一个语义完整的块做去重常用 MinHash 或 SimHash 一类的近似方法做质量过滤按长度、特殊字符占比、语言混杂度等规则筛掉垃圾样本。这些都属于“格式之外但格式要配合”的工作。2.2 指令微调单轮、多轮与对话结构指令微调数据的格式就丰富多了。早期最流行的是三字段指令样本结构大致长这样{ instruction: 把下面的句子翻译成英文, input: 今天天气不错, output: The weather is nice today. }instruction 是任务描述input 是输入内容output 是期望输出。这个格式的好处是简单直观但缺点也很明显它表达不了 system 角色也表达不了多轮对话。后来社区逐渐演化出了类 ShareGPT 的多轮对话结构{ conversations: [ {from: human, value: 帮我写一个 Python 函数计算斐波那契数列}, {from: gpt, value: 好的这是一个递归实现...}, {from: human, value: 能不能改成迭代版本}, {from: gpt, value: 当然迭代版本如下...} ] }from 和 value 两个字段在多轮数据里极其常见很多开源数据集都用这个结构。它比三字段格式高明在两点一是角色轮换一目了然二是天然支持多轮上下文。再往后就是现在大模型 API 接口常见的 messages 结构{ messages: [ {role: system, content: 你是一个乐于助人的助手。}, {role: user, content: 介绍一下黑洞}, {role: assistant, content: 黑洞是时空曲率大到光都无法逃脱的天体...} ] }这个结构跟 ChatML 文本模板是一一对应的。ChatML 模板长这样|im_start|system 你是一个乐于助人的助手。|im_end| |im_start|user 介绍一下黑洞|im_end| |im_start|assistant 黑洞是时空曲率大到光都无法逃脱的天体...|im_end|这里有一个关键点模型最终看到的不是 JSON而是把消息渲染成带角色标记的 token 序列。所以“数据格式”和“渲染模板”是强绑定的。你的数据集用的是 messages 结构分词器配置里的 chat template 也必须用同一套角色标记否则训练时和推理时对不上效果必然崩。这也是为什么现在很多训练框架直接用 messages 结构作为标准输入因为跟推理接口天然一致。2.3 偏好数据chosen 与 rejected 配对RLHF 和 DPO 训练用的是偏好数据格式和指令数据不一样它强调的是“对比”。最通用的结构是{prompt: 这道题应该怎么解, chosen: 先求导再令导数为零..., rejected: 不知道}字段含义很清楚同一个 prompt 下chosen 是更符合预期的回答rejected 是较差的回答。有些协议还会保存完整的多轮对话用 chosen_turns 和 rejected_turns 数组来存储。这里必须提醒一点chosen 和 rejected 的字段顺序一定不能搞反。我见过不止一个人做 DPO 时把两个字段的值写反训练出来的模型越调越差因为优化方向完全反了。如果数据集很大建议先抽样打印几条人工确认下字段语义再开始训练。偏好数据还有一些附属字段比如 source数据来源、score_chosen、score_rejected人工或模型打分。这些字段对数据分析和后续筛选很有用但训练时一般会被忽略保留它们不碍事别删。2.4 JSON 与 JSONL什么时候选谁JSON 和 JSONL 是训练样本最基础的两层皮。JSON 适合小文件、配置文件、需要整体嵌套结构的数据JSONL 则是一行一条记录每条独立成 JSON 对象适合海量样本。为什么训练数据几乎都用 JSONL三个原因流式读取不用把整个文件读进内存按行处理可以随时断点续跑处理到哪一行都清清楚楚可以按行号切片做并行分片多进程或多机处理都方便。JSON 转 JSONL 的代码非常简单但要写对细节import json with open(data.json, encodingutf-8) as f: samples json.load(f) with open(data.jsonl, w, encodingutf-8) as f: for sample in samples: f.write(json.dumps(sample, ensure_asciiFalse) \n)注意两点ensure_asciiFalse 必须加否则中文会被写成 \uXXXX文件体积变大而且肉眼完全没法排查每条记录写完要带换行符一些不严谨的读取器会因此把最后一条解析坏。反过来JSONL 转 JSON 也很常见用小数据集做格式审查时尤其有用。把多行记录读进来组成列表再整体 dump 出去就行这里就不再单独给代码了。3. 大规模数据集的高效存储Parquet 与 Arrow3.1 列式存储到底在解决什么问题Parquet 和 Arrow 处理的痛点和 JSONL 完全不同。JSONL 是行式存储一条样本的所有字段挨在一起。这就像一叠整页的报名表你要统计所有人的年龄分布只能一张张翻。Parquet 是列式存储同一列的数据物理上存放在一起。继续用报名表打比方它相当于把所有年龄填到一列专门的本子上统计年龄翻这一本就够了。列式存储配合高效的压缩算法磁盘占用更小读取指定列时 IO 量大幅下降。Arrow 则是内存中的列式格式它的强项是零拷贝、高性能计算通常在数据分析和分布式计算里跟 Parquet 配合使用。理解成“Parquet 管磁盘、Arrow 管内存”就差不多了。3.2 什么时候该切到 Parquet我的判断标准很简单数据量到了百万条级别或者你需要频繁按字段做统计、过滤、筛选就值得从 JSONL 切到 Parquet。如果只是几万条 SFT 数据用 JSONL 完全没问题强行上 Parquet 反而增加了调试成本。三类格式的对比维度JSONLParquetArrow可读性高文本直接看低需要工具低需要工具列式读取不支持支持支持压缩率中等高中等适用场景中小规模样本、流式处理大规模语料、统计分析内存计算、跨语言数据交换用 Python 读 Parquet 很简单pyarrow 或常见的数据框架都支持import pyarrow.parquet as pq table pq.read_table(train.parquet) for batch in table.to_batches(): texts batch.column(text).to_pylist() for text in texts: print(text)如果数据里有多个字段读的时候可以只读需要的列。比如只取 instruction 列做长度统计pq.read_table(train.parquet, columns[instruction])就够了这就是列式存储的直观好处。3.3 实操中的一个加速例子我之前处理过一个千万级的 SFT 数据集原始格式是 JSONL要到所有样本里统计 instruction 的长度分布再把超过阈值的长样本挑出来重新清洗。JSONL 版本硬读硬扫花了接近三十分钟。后来把整个数据集转成 Parquet再做同样的统计和过滤五分钟以内就结束了。这个加速主要来自两个方面Parquet 的压缩让磁盘读取量小了一个量级只读取 instruction 列避免了把整行所有字段都反序列化。数据量越大这个差距越夸张。转换时要注意 schema 一致性问题。同一列里别混着 None 和字符串字符串和 bytes 也别混存。PyArrow 在推断 schema 时遇到混合类型会抛异常或者悄悄转成奇怪的类型等你读出来才发现数据对不上。所以写转换脚本时最好显式指定字段类型给处理过程留一个稳定契约。4. 模型权重与推理部署格式4.1 训练权重格式从 pickle 到 Safetensors模型训练保存权重最常见的是主流深度学习框架自带的保存方式本质上是把整个对象序列化成一个文件常见的后缀是 .bin 或 .ckpt。这类格式的问题在于使用了 pickle 机制反序列化时有执行任意代码的风险。你从网上下载一个训练权重加载时它可能在你的机器上跑了一段你完全不知道的代码。Safetensors 的出现就是为了解决这个问题。它只存张量的元数据和原始字节不经过 pickle加载过程纯粹是读取数据安全性高得多。同时因为它跳过了反序列化的开销加载速度通常也更快还支持内存映射。现在社区主流的开源模型仓库里权重基本都换成了这种格式。两种格式对比维度ckpt / binpickle 类Safetensors安全性低反序列化可能执行代码高纯数据读取加载速度较慢更快支持 mmap兼容性老工具链常见主流框架与社区已普遍支持定位问题难度报错信息混乱错误定位清晰这里给一个实操建议凡是从外面拿来的权重先确认是不是安全格式。如果是老的 pickle 类格式尽量找到原始出处确认可信后再加载最好先在自己的隔离环境里跑一次别一上来就在生产环境的机器上执行。4.2 GGUF面向推理的单一文件格式GGUF 是推理生态里非常流行的一种模型封装格式。它的核心设计思想是“单文件分发”把模型权重、分词器、模型超参数、聊天模板全部打包进一个文件里。部署的时候你只需要拿一个文件拷走不需要额外准备配置文件也不需要保证环境里预装各类依赖。另一个特点是量化友好。GGUF 支持 FP16 以及多种量化精度比如常见的 q8_0、q5_k_m、q4_k_m。量化过后模型体积大幅缩小内存占用和推理速度都更友好这让大模型在普通 CPU 设备上跑起来成为可能尤其在移动端、嵌入式、个人电脑这类场景里非常实用。需要明确的是GGUF 主要面向推理不适合拿来继续训练。你拿着一个量化过的 GGUF 文件去做微调精度损失会直接放大训练误差。常规做法是训练用 Safetensors 这样的完整精度格式训练完成后再转换量化成 GGUF 去部署。4.3 权重转换与量化的基本玩法从 Safetensors 转 GGUF 时社区里通常用开源转换脚本大致流程是python convert.py 模型目录 --outfile 模型名称.gguf --outtype q8_0outtype 参数决定输出精度。我的经验是如果是 CPU 部署或资源紧张q4_k_m 体积最小但精度损失需要自己评估追求质量稳定就上 q8_0文件大一些但行为更接近原版内存充足且需要最高质量用 f16 即可。量化参数的选择不能拍脑袋。我自己在代码生成类任务上试过 q4_k_m生成结果明显变差换了 q8_0 才恢复正常。如果你要跑的任务对输出质量敏感一定要在同一个评测集上对比量化前后的效果不要只看体积数字好看。转换完成之后强烈建议做一次冒烟测试随便给模型几个不同类型的输入确认输出是正常的中文或代码再提交到部署流程。我遇到过转换半途失败但文件仍然生成的情况表面看起来有输出实际内容全是乱码。冒烟测试治这类问题最有效。5. 场景驱动的格式选型与转换实战5.1 一个典型的指令数据转换流程假设你手上是三字段指令格式的 JSON 文件但训练框架希望输入是 messages 结构。转换的逻辑是把 instruction 和 input 拼成一条 user 消息把 output 作为 assistant 消息再统一加一条 system 提示语。import json def to_messages(item, system_prompt你是一个乐于助人的助手。): user_content item[instruction] if item.get(input): user_content \n item[input] return [ {role: system, content: system_prompt}, {role: user, content: user_content}, {role: assistant, content: item[output]}, ] with open(raw_instructions.json, encodingutf-8) as f: data json.load(f) with open(train.jsonl, w, encodingutf-8) as f: for item in data: messages to_messages(item) f.write(json.dumps({messages: messages}, ensure_asciiFalse) \n)注意 system 提示语要跟你训练框架和推理时用的 system 保持一致。很多人训练时随手加一句“你是一个乐于助人的助手”部署时又换成了别的设定模型行为就变得不可控。system 提示语属于数据的一部分不是可有可无的装饰。5.2 不同场景格式速查表我把常见场景的格式选择整理成一张速查表方便直接照用场景推荐格式不推荐小型 SFT 数据十万条以内JSONLmessages 或类 ShareGPT 结构Parquet纯属没必要大规模预训练语料Parquet token 化二进制纯文本难以溯源和过滤RLHF / DPO 偏好数据JSONLchosen / rejected 配对JSON 嵌套容易错位模型训练迁移Safetensorspickle 类 bin / ckptCPU / 边缘设备部署GGUFq8_0 / q4_k_mbin / ckpt体积和加载都吃亏这个表格不是教条核心思路是“让格式匹配数据量和生命周期阶段”。5.3 把 schema 当作接口来管理数据格式在项目里最容易被忽略的就是 schema 版本管理。我自己吃过一次大亏当时团队共用一个 SFT 数据集没有人给数据加版本标识后来对话模板升级旧数据的 role 字段命名不兼容整个训练流程直接失效重跑数据清洗又花了好几天。从那以后我养成了一个习惯每条样本都会带上 schema 版本字段至少在外层的元数据里写清楚 format 版本。比如在文件头或在每条记录里维护一个schema_version: 1.2这样的字段。数据集的 schema 跟接口一样需要演进但演进必须有版本记录否则你永远不知道一份旧数据是用什么结构写出来的。另外建议每次都把 tokenizer 的配置跟数据一起归档。分词器版本升级后chat template 可能变化旧数据虽然格式没变渲染出来的 token 序列已经不同了。数据、分词器、模板三者要当成一个整体来管理任意一个单独升级都可能让其他部分失效。6. 常见问题与排查技巧实录6.1 高频问题速查表现象可能原因排查与解决训练 Loss 不降或乱跳数据格式与 chat template 不匹配角色标记混淆渲染几条样本人工检查 token 序列加载权重报错文件格式不被支持或文件损坏换成安全格式重新导出校验文件完整性JSONL 读到一半报错中间某行被截断或转义有问题按行号定位坏行重新生成该批次Parquet 读取类型异常同列混入 None 或不同类型显式指定 schema清洗空值和混合类型GGUF 输出乱码转换时模板配置丢失用带完整模板的原权重重新导出DPO 训练效果越来越差chosen 和 rejected 写反了抽样人工核对字段语义这张表是我遇到过的典型问题汇总也是新手最常踩的坑。大部分问题都不是模型本身的问题而是格式层面的细节。6.2 我踩过的几个坑第一个坑是把十万条样本一次性写进一个大 JSON 数组。数据量一大加载这个文件时内存直线飙升处理脚本直接 OOM。改成 JSONL 流式读写以后内存占用稳定在一个极低水平处理速度反而更快。第二个坑是处理三字段指令数据时遇到 input 为空就直接删掉这个字段。看起来省空间结果不同来源的数据字段不一致解析逻辑要写两套。后来统一规则字段固定保留为空就填空字符串解析代码只处理一种情况省心很多。第三个坑在偏好数据上。做 DPO 训练时我拿到一批数据没仔细看字段顺序就丢进训练脚本几天后发现模型明显退化。抽查原始数据才发现 chosen 和 rejected 的值完全对调了。从那以后我要求所有偏好数据进训练前必须抽样人工检查。第四个坑是 GGUF 量化选型出错。图省事选了体积最小的 q4_k_m 做代码生成任务生成结果差到没法用。后来换 q8_0 才恢复可用。量化参数的取舍一定要跟具体任务绑定别只看压缩率。6.3 一套通用的排查流程当你怀疑数据格式有问题时按这个流程走基本都能定位第一步抽样渲染 2 到 3 条样本把 messages 或对话结构按照模板渲染成纯文本直接看角色标记和内容是否错乱。这一步能发现八成的问题。第二步确认数据集的 role 字段和分词器模板里定义的角色名词一致。比如数据里写的是 user模板里却只认 human训练时角色就无法区分。第三步核对训练框架的读取器实际期望的 schema。有时候框架文档说支持 messages 格式但实际代码要求字段名不同这个不亲自看源码或日志很难发现。第四步统计样本长度的分布情况看看有没有空样本、超长样本、异常截断的样本。这一步能帮你筛出一批肉眼看不出的脏数据。再补一个定位坏数据的小技巧JSONL 读取报错时写一个简单的行号扫描脚本逐行尝试解析失败就输出当前行号。大多数时候坏数据就那么几行定位到行号再回去看原始来源文件比重新导出全部数据快得多。我个人在这些年的实操里最深的体会是数据格式在 LLM 项目里看着像边角料工程实际上决定了整条流水线的上限。格式不统一、模板不配套、字段对不上这些坑不会让你的训练直接崩溃但会在最需要效果的时候拖你后腿。我现在的习惯是任何新项目开工前先花半天把数据 schema 定下来把格式、版本、模板三者对齐后面每一步都走得顺很多。你如果遇到“训练时好好的推理时胡说八道”的情况先别急着怀疑模型回过头去检查一下数据格式和模板是不是已经悄悄分家了。