1. 聊LLM数据格式之前,先看两种典型的"出错现场"
我见过太多人把大模型项目翻车的原因归到"模型不行""算力不够",其实问题往往出在最不起眼的数据格式上。先说两个我印象非常深的场景。
第一个场景:某开发者自己爬了一批问答数据,按照直觉存成了JSON数组,每个元素长这样:{"question": "...", "answer": "..."}。看起来没毛病,数据量也不大,几千条,训练的时候也没报错。但模型微调完一上线,对话行为非常奇怪——它经常把用户的问题和上一轮自己的回答混在一起输出,甚至会把训练数据里的"question"字段名原样输出来。排查到最后才发现,他用的微调框架默认读取的是alpaca格式的instruction/input/output字段,他自己定义的question/answer字段虽然也能训练(因为框架做了字段映射),但映射逻辑只处理了主字段,系统提示词和轮次拼接根本没对上,模型等于是在一种"半错乱"的序列上做的学习。
第二个场景是评测环节。某评测脚本把测试集导出成CSV,列头是prompt, expected,然后用Pandas读进来逐行跑推理。结果有一行prompt里包含了换行符和英文逗号,CSV导出时没有做转义,读进来后被拆成了两列,答案对不上,评测分数直接掉了一截。最开始大家都以为是模型能力退化了,折腾了两天才定位到是CSV转义问题。
这两个场景有个共同点:**格式不是技术难点,但它决定了数据能不能被正确表达、正确读取、正确喂给模型。**格式选错、写错,你后续做的清洗、配比、训练、评测全都会在某个看不见的地方悄悄出错。这篇就专门把LLM常见的数据格式梳理一遍,覆盖从数据集存储、微调模板、tokenizer配置到推理部署接口的各个环节,把我实际踩过的坑和社区里高频出现的问题一并说清楚。
2. 文本数据格式大盘点:JSON/JSONL、CSV、Parquet该怎么选
2.1 JSON与JSONL:最普及但最容易写错的两种
先明确一个概念:LLM的数据集在磁盘上存储时,大多数情况下就是文本文件,最常见的两种是JSON和JSONL。JSON是数组嵌套对象的写法,所有数据放在一个文件里,以[开头,以]结尾,中间是逗号分隔的对象。JSONL是JSON Lines的缩写,每一行是一个独立的JSON对象,行与行之间没有逗号,没有外层的方括号。
我强烈建议,只要数据量超过几百条,优先用JSONL而不是JSON。原因有三个:
第一,JSONL可以流式读取。我在处理百万级数据时,逐行读JSONL几乎不占内存,而读JSON必须把整个文件load进内存再json.load,数据量大了直接OOM。
第二,JSONL天然支持增量写入。你可以一边清洗一边append新行,不用维护一个不断膨胀的数组结构。JSON要改一个元素,理论上得重写整个文件。
第三,JSONL报错定位容易。某一行格式坏了,程序会告诉你"line 1234 parse error",你直接去那一行修就行。JSON整个文件解析失败时经常只给你一个"Expecting ',' delimiter"这种模糊提示,字段多的文件根本不知道错在哪。
JSONL不是没有缺点:它对"顶层数组"的表达天生不如JSON直观,一些数据分析工具打开JSONL可能不识别为表格。所以如果是给别人交付、或者要快速人工浏览,我会同时保留一份JSON版本。但训练脚本读的一律JSONL。
2.2 CSV:适合快速评测,不适合训练
CSV在LLM领域是一个"看起来能用、实际上很坑"的格式。它的最大问题是没有统一的转义标准。字段里出现逗号、换行、双引号时,不同工具处理方式不一致。Python的csv模块默认会用双引号包裹包含特殊字符的字段,但很多编辑器、Excel、老脚本根本不处理,直接按逗号粗暴切分。
一个很典型的坑就是:prompt文本里天然充满换行符和逗号。你把50个prompt写成CSV,自己用Pandas读取时可能一切正常(Pandas会自动处理),但换个工具读,或者有人用split(",")手写解析,数据就裂开了。我在评测数据里见过太多这种事故:一个字段被拆成两列,模型输出被整体错位,最后算出来的准确率完全没有参考价值。
所以我对CSV的定位是:只用于快速人工标注、简单的评测管理、或者给非技术人员看汇总结果。凡是需要进训练管线、需要严格保真的文本数据,不要用CSV做主存储。如果非用不可,至少遵循以下三个原则:
- 字段内任何换行符统一替换成
\n字面量,不要保留真实换行; - 用
csv.writer而不是手拼字符串,由库来处理转义; - 读取时统一指定
encoding(通常UTF-8)和quoting=csv.QUOTE_ALL。
2.3 Parquet/Arrow:大数据量下的工程选择
当数据量进入千万条甚至上亿条的级别时,JSONL的短板开始显现:占用空间大(纯文本冗余地编码了字段名和引号)、读取不快、没有列式统计信息。这时社区的主流选择是Parquet。
Parquet是列式存储格式,配合Arrow内存格式,做数据筛选、聚合、采样的时候性能优势非常明显。一个实际例子:我有一份1.2亿条的训练语料,JSONL版本占磁盘约85GB,转成Parquet后只有31GB,读取其中某个字段进行统计的速度大概快了一个数量级。
在大模型开源生态里,Parquet也是主流分发格式之一。很多大规模数据集在平台上下载下来就是parquet分片,用datasets库或Pandas都能直接读。需要注意:Parquet对嵌套结构的表达不如JSON灵活,字段一旦是深度嵌套的list of dict,读写会麻烦一些。一般做法是:复杂结构用JSONL,大规模扁平字段用Parquet。不要把两者对立,同一个项目里完全可以混用——原始采集用JSONL,清洗去重后转Parquet,训练时再流式读出来转成模型需要的格式。
2.4 数据字段设计的公共原则
不管用哪种存储格式,字段设计有几个公共原则,我在代码评审里反复强调:
- 字段名语义清晰、全局统一。
text、input、prompt、instruction别混用,一个数据集只用一种叫法。 - id字段必须有。每条数据带唯一id,出问题才能回溯。id用字符串,不要用自增整数,因为清洗去重后自增id会断裂。
- 元信息与正文分离。比如来源、语言、质量分这类字段单独放,不要塞进正文文本里,否则后续做数据过滤时还得用正则抠出来。
- 时间字段统一格式。收集时间、清洗时间用ISO 8601字符串,别用"2024/3/1"和"2024-03-01"混着来。
3. 微调数据集的三种主流范式:指令式、对话式、消息式
微调阶段的"数据格式"跟存储格式不是一回事,它指的是模型训练代码对每一条样本的字段结构约定。不同范式决定了你提供的每一条数据长什么样、有几个字段、字段怎么被拼进模板。这是LLM项目里最容易被忽视、也最容易翻车的部分。
3.1 指令式(Alpaca式):单轮问答的经典JSON结构
指令式格式的核心结构是三个字段,在社区里最常见的写法如下:
{ "instruction": "解释什么是条件概率", "input": "假设有两个事件A和B", "output": "条件概率是指在事件B已经发生的条件下,事件A发生的概率……" }instruction是任务指令,input是可选的任务输入(没有就填空字符串),output是期望输出。有的变体还有system字段,用来放系统提示词。
这套格式的原型来自当时开源社区流行的一套指令微调数据集的构建风格,它简单、直观、覆盖了"指令+上下文"这种最常见的监督微调形态。很多微调框架的默认字段映射就认它,你只要把数据整理成这个结构,instruction/input/output三个键传给训练脚本,一般不用改代码。
但也正因为简单,它有明显的天花板:它默认一条数据就是一轮问答。多轮对话数据如果用这个格式表达,最常见的做法是把历史对话全部塞进input字段,模型只能当作一整段文本去学习回复,轮次边界和角色信息基本靠模型自己悟。效果不稳定,而且容易把"谁说的话"搞混。
我的建议是:如果你是新手、任务偏单轮指令、或者用的是对格式映射依赖很重的微调框架,先从这个格式起步。但一旦要做多轮对话,立刻换对话式。
3.2 对话式(ShareGPT式):多轮对话与角色信息的表达
对话式格式解决的核心问题就是"多轮、多角色"。它把一条样本表达成一个对话列表,每一轮消息用from和value两个字段标识角色和内容,社区里常见以下这种结构:
{ "conversations": [ {"from": "human", "value": "帮我把这段话翻译成英文:今天天气真好"}, {"from": "gpt", "value": "The weather is really nice today."}, {"from": "human", "value": "再帮我解释一下其中的语法"}, {"from": "gpt", "value": "……"} ] }角色名在不同的数据集里并不统一,有的写human/gpt,有的写user/assistant,还有的写人类/模型。训练框架在加载时通常会在内部转换成统一的角色标识,但如果字段名的约定和框架不一致,轻则角色错乱,重则某一段消息被跳过。拿到别人的对话数据集第一件事,就是抽出几条看看角色名到底是什么。
对话式格式通常还允许在列表开头放一条system消息,作为全局系统提示词。这样做多轮微调时,模型能学到"系统设定贯穿全程"的概念,这一点比指令式自然得多。
核心要点:对话式格式里,value字段不要自己做任何模板拼接。不要在human的消息里预先加上"Human:"前缀,也不要在gpt消息里加"Assistant:"。这些前缀是模板层负责的,训练框架一般会根据角色名自动拼上正确的控制符。你手工拼了,等框架再拼一次,就变成了重复前缀,这是非常隐蔽的数据污染。
3.3 消息式(Messages式):贴近推理接口的标准化结构
消息式格式是一类与"推理接口消息体"高度对齐的表示方法,核心结构是messages列表,每条消息有role和content。其中role通常取system、user、assistant。它和对话式很像,但更严格、更贴近线上推理时发送给模型的请求体。
{ "messages": [ {"role": "system", "content": "你是一个擅长编程的助手"}, {"role": "user", "content": "用Python写一个快速排序"}, {"role": "assistant", "content": "下面是实现代码……"} ] }消息式的好处是:训练样本和推理请求的结构是一致的。你在微调时用这种结构,上线时直接把用户会话历史按同样结构发过去,不需要做格式转换,减少了训练/推理不一致带来的效果损耗。我见过不少项目,训练时用的是Alpaca格式,推理时却用对话接口发messages,两边模板不一致,模型表现飘忽,最后谁也说不清问题出在训练还是推理。用消息式可以从源头上规避这类问题。
需要注意,消息式对"单条样本应该包含哪些轮次"有讲究。经验法则是:每一条样本都要以assistant的一条最终回复结束,中间可以保留user/assistant交替,但不要出现以user结尾的样本,也不要截断到一半。否则模型学到的是"说完上句就可以停",而不是"回答完用户问题才结束"。
3.4 范式选型:什么时候用哪种
三种范式不是互斥的,很多数据集会同时提供多种格式的版本。我用下面这个判断思路来做选型:
| 场景 | 推荐范式 | 理由 |
|---|---|---|
| 单轮指令任务、新手快速跑通 | 指令式 | 结构简单,框架支持最成熟 |
| 多轮对话、客服、角色扮演 | 对话式 | 能自然表达轮次和角色 |
| 训练与推理结构严格对齐 | 消息式 | 减少模板不一致带来的loss |
| 大规模混合任务微调 | 对话式或消息式 | 统一成一种格式,管线简洁 |
混合任务批次里如果既有单轮又有轮,我会统一转成对话式或消息式,而不是保留两类格式分开训练。因为模型在训练时看到的序列结构越一致,越不容易学到杂乱的模式。
4. Tokenizer与模型权重:藏在数据格式背后的"隐形配置"
很多人以为"数据格式"只指数据集文件,等做到模型加载、推理部署才发现,Tokenizer的配置文件、权重文件的封装格式,同样是数据格式的一部分,而且出错时的表现极其诡异。
4.1 Tokenizer三件套:vocab/merges/tokenizer.json
搞清楚词表、合并规则和tokenizer配置的关系,是LLM工程的基本功。社区里常见的tokenizer文件组合是:
vocab.json:词表映射,把token字符串映射到id;merges.txt:Byte-Pair Encoding的合并规则列表,每一行是两个token如何合并成一个新token;tokenizer_config.json:配置项,包括特殊token、chat模板、pad方向等;tokenizer.json:一个把所有信息打包的序列化文件,很多加载接口直接认这一个。
最容易出事的是词表大小和特殊token不一致。举个例子:某个模型的词表大小是32000,其中保留了若干special token(比如<|endoftext|>、<|user|>、<|assistant|>)。如果你在微调时用了一个与原始模型不匹配的tokenizer,或者自己手动改了词表但没有同步更新embeddings层维度,训练会出现"index out of range"或者默默地用随机初始化的向量去表示某些token。一个典型症状是:loss能降,但生成的文本里大量重复特殊token。
另一个更隐蔽的坑是chat_template不一致。同一个tokenizer可能被挂上不同的chat_template配置,有的模板里system消息拼法、角色控制符顺序不同。微调时用的模板和推理时加载的模板一旦不一致,就相当于用一套格式训练、用另一套格式推理。模型在训练时学会的是"看到A模式就输出B",推理时却喂了C模式,效果崩了还不知道为什么。
所以我的操作习惯是:做任何微调前,先打印一份tokenizer的实际输出,把一条样本用tokenizer处理成token id序列,再decode回文本,肉眼看一遍。如果decode出来跟原始文本不一致,立刻排查模板和特殊token配置。这一步不要省。
4.2 模型权重格式:bin、safetensors、gguf的取舍
模型权重在磁盘上的存储格式也属于"数据格式"的一部分。常见三种:
pytorch_model.bin:老式Pickle序列化格式。加载兼容性好,但存在两个问题:一是Pickle反序列化有代码执行风险,加载来路不明的bin文件要非常小心;二是加载时内存占用高,因为要先完整解析再加载进模型。
safetensors:社区主推的安全格式。它直接按张量名存储裸的张量数据,不依赖Pickle,解析速度快,支持内存映射加载,还能只加载部分张量。我在加载大模型权重时,只要模型仓库同时提供了safetensors版本,优先用safetensors。它的命名结构也很简单:
{ "model.layers.0.self_attn.q_proj.weight": "张量元数据……", "model.layers.0.self_attn.k_proj.weight": "张量元数据……" }gguf:面向CPU和边缘部署的一种量化封装格式,常见于以llama.cpp为代表的一类推理生态。它把模型权重、tokenizer配置、某些超参数打包到一个文件里,适合单文件分发和低资源推理。gguf也内置了"通用对话模板"信息,但不同工具版本对gguf元数据的处理方式并不完全一致,换推理后端时最好验证一下模板是否生效。
选型上我的经验是:训练和微调阶段用safetensors,追求通过性和安全;部署到低配置机器或者需要方便的跨平台单文件分发时用gguf量化版本;bin格式尽量兼容旧工具链时再用。
4.3 格式不一致导致的常见bug
这类问题在社区提问里出现频率最高,我把见到的典型归纳为三类:
第一类,词表错位。加载了不同tokenizer的模型,看起来能跑,但输出是乱码或拉美字符堆,因为token id和实际token对不上。
第二类,张量命名不匹配。不同微调框架保存的权重文件,张量名可能带model.前缀或module.前缀,加载时报missing keys。这不是模型坏了,是框架约定的key名字差异,需要改权重加载时的key映射。
第三类,精度与dtype不一致。权重文件里存的是fp16,加载代码默认用fp32,或者反过来。表现为显存翻倍或数值精度异常。读取权重后第一时间确认dtype,别盲信加载日志。
5. 推理与部署侧的数据格式细节:从输入到输出的形状
训练完模型,真正上线时,数据格式的问题并没有消失,它们只是转移到了接口层。这里讲三个我在部署服务时反复处理的细节。
5.1 输入侧:instruction/input/prompt模板的拼接顺序
同一个模型,用不同的模板拼接输入,效果差异可以非常大。很多人以为prompt只是"把用户的话原样发给模型",实际上模型在预训练和微调阶段学到的文本模式高度依赖模板。
举个例子,同样一句话"写一首关于秋天的诗",至少有两种拼接方式:
# 方式A 用户:写一首关于秋天的诗 助手: # 方式B <|im_start|>user 写一首关于秋天的诗<|im_end|> <|im_start|>assistant <|im_end|>方式B是更结构化的一段对话编码。如果你微调时用的是方式B的tokenizer模板,推理时也必须是方式B。我在线上见过一个案例:微调时框架自动用了带"system+user+assistant"三段式模板,上线时后端工程师图省事,手动拼了"用户:xxx\n助手:",效果大幅下降,大家第一个怀疑的是量化精度,来回查了很久才发现模板根本没对齐。
正确做法是:训练和推理共用同一个模板函数。把模板拼接逻辑写成同一个模块,微调和推理都调用它,从源头杜绝手工拼字符串。
5.2 输出侧:max_tokens、stop token与文本截断
输出侧的数据格式主要涉及三个参数:max_tokens、stop、temperature。其中max_tokens不是"最大汉字数",而是最大token数。同一句话,中文大约1个汉字对应1-2个token;英文大约1个词对应1-3个token。如果你设置max_tokens=100,期望模型输出100个汉字,实际可能只有60-80个,中文场景经常出现被截断的半句话。
stop token是另一个容易忽视的地方。对话式训练出的模型可能学会了在输出结束时自动生成<|im_end|>之类的结束符,但推理接口如果没有配置对应stop,模型会把这个token当作普通文本继续生成或者原样输出。我的习惯是:模型仓库说明里写了什么结束符,就把它同时配置为stop序列和eos_token,两者对齐。
截断检测也很重要。返回结果里要通过finish_reason区分是"正常结束"还是"到达max_tokens"还是"命中stop"。如果频繁出现"到达max_tokens",说明设置太小或者任务本身输出过长,不要简单粗暴调大,先做统计分析。
5.3 服务接口的格式约定:不光是模型的事
线上服务层的数据格式一般包括请求体和响应体。请求体通常就是消息列表加参数,比如:
{ "messages": [ {"role": "user", "content": "今天上海天气怎么样"} ], "max_tokens": 512, "temperature": 0.7 }响应体则要区分"流式"和"非流式"。非流式一次性返回完整文本;流式按chunk返回增量片段。流式场景里,客户端如果按行解析,要注意服务端是否按data:前缀做SSE格式封装、每条chunk的JSON是否自包含。很多联调故障都出在客户端直接把多个chunk拼起来后做了整体JSON解析,结果因为chunk边界不是完整JSON而报错。
我的建议是,服务层单独抽象一层"数据适配器",上游业务传什么格式进来(可能是纯文本、可能是带上下文的对话列表),适配器负责转成模型需要的标准消息格式;模型返回后,再把标准化输出转成业务侧的字段。这样模型升级、换prompt模板、改角色名,都不会波及业务代码。
6. 我常用的数据格式检查清单与踩坑记录
6.1 一份可复制的格式自检表
每次拿到一个新数据集,或者做完一次数据转换,我会按下面的清单逐项检查。你可以直接复制过去用。
- 文件编码:统一UTF-8,没有BOM头;
- 存储格式:训练数据用JSONL或Parquet,不用CSV做主存储;
- 每条数据有唯一id;
- 字段名全局统一,无
text、prompt混用; - 微调格式与训练框架默认格式匹配(字段名完全一致,不做隐式映射);
- 消息角色名只有系统约定值(如
user/assistant),无变体; - value字段不含手工拼的模板前缀;
- tokenizer与模型匹配,special tokens配置正确;
- 模板函数在训练和推理中复用同一个实现;
- 输出侧stop token与eos token对齐;
- 保存的文件用
json.dumps(ensure_ascii=False)而不是默认的ASCII转义; - 大规模数据至少抽取3-5条肉眼检查decode结果。
6.2 三个真实踩坑记录
第一个坑是调用官方数据处理脚本时,它把"output"字段重命名成了"completion"。当时脚本没报错,只是静默做了兼容处理。后来我自己写脚本读出来做统计分析时,发现字段少了一列,排查了半天才发现是命名不一致。
第二个坑是转Parquet时嵌套字段被自动拍平。有一份多轮对话数据,conversations是list[dict],通过某个中间库转Parquet时,部分工具不支持深度嵌套,自动把dict变成了JSON字符串。下游读取时拿到的是字符串而不是结构体,处理逻辑全部要改。
第三个坑是用Excel编辑了CSV后保存成了带BOM的UTF-8。模型对着prompt第一行出现了一个看不见的\ufeff字符,导致指令匹配失效。后来我规定:凡是人工用办公软件处理过的数据文件,一律重新转码并检查BOM。
6.3 选型思路总结
回到标题"LLM常见数据格式",我的整体选型思路其实可以压缩成三句话:
- 存储层:几百条用JSONL,百万级用Parquet,CSV只做辅助;
- 样本层:单轮用指令式,多轮用对话式或消息式,训练推理尽量同构;
- 配置层:tokenizer、模板、权重格式要跟模型来源保持一致,任何转换都要留痕、可回溯。
这些看起来都是"小事",但LLM工程里最贵的成本就是排查这种隐性问题。数据格式规不规范,直接决定你的调试时间是从几分钟变成几小时还是几天。我见过太多团队花了大价钱微调、评测、上线,结果一个字段名或模板错位就让收益归零。所以每次开启新项目,我都建议把上面的自检清单过一遍,养成习惯之后,能帮你避开绝大多数低级事故。