☰
LLM应用开发中的数据格式全解析:从JSONL到ChatML的实战指南
2026/10/14 5:45:12 网站建设 项目流程

1. 为什么数据格式是LLM应用的第一道坎

做LLM应用开发这几年,我踩过最多的坑不是模型选型,也不是提示词设计,而是数据格式。听起来很基础对吧?但实际情况是,我见过太多项目在原型阶段跑得挺欢,一到接入真实数据就各种报错——JSON解析失败、字段对不上、编码乱码、嵌套层级太深导致token爆炸。这些问题归根结底都是数据格式没处理好。

LLM常见数据格式这个话题,说白了就是搞清楚大语言模型在训练、推理、微调、应用集成这几个环节里,数据到底长什么样、该怎么组织。它解决的核心问题是:让你在构建LLM应用时,知道用什么格式喂数据、用什么格式存数据、用什么格式传数据,避免在格式转换上浪费大量时间。不管你是刚接触LLM的开发者,还是已经在做RAG、Agent、微调的老手,这篇文章里的内容都能直接拿去用。

我自己的经验是,LLM项目里大概有30%到40%的调试时间花在数据格式问题上。这个比例高得离谱,但只要你把常见格式和它们的使用场景摸清楚了,这部分时间至少能砍掉一半。下面我就按实际项目中的使用频率,从最核心的几种格式开始拆解。

2. LLM全链路中的核心数据格式拆解

2.1 JSON与JSONL:微调数据的主力格式

JSON大家都不陌生,但在LLM场景里,它有一个特殊变体叫JSONL(JSON Lines),这个才是微调数据的主力格式。JSONL的特点是每行一个独立的JSON对象,行与行之间用换行符分隔。为什么不用标准JSON数组?因为JSONL支持流式读取,你不需要把整个文件加载到内存里就能逐行处理。对于动辄几个GB的微调数据集来说,这个特性太重要了。

我拿一个实际的微调数据例子来说明。假设你要做指令微调,每条数据通常包含三个核心字段:instruction(指令)、input(输入上下文)、output(期望输出)。用JSONL格式写出来是这样的:

{"instruction": "将以下文本翻译成英文", "input": "今天天气很好", "output": "The weather is nice today"} {"instruction": "总结这段话的核心观点", "input": "长文本内容...", "output": "核心观点是..."}

每一行都是一个完整的训练样本,互不干扰。这种格式的好处是,你在预处理阶段可以用脚本逐行清洗、过滤、转换,不用担心内存溢出。而且很多训练框架(比如Hugging Face的datasets库)原生支持JSONL的流式加载,你直接传文件路径就行。

但JSONL也有坑。最常见的问题是字段名不统一。比如有的数据集用"instruction",有的用"prompt",有的用"query"。你在合并多个数据源的时候,必须先做字段映射,否则训练脚本会直接报KeyError。我的做法是写一个统一的预处理脚本,把所有数据源的字段名标准化成同一套命名规范,然后再合并。这个脚本大概长这样:

import json def normalize_sample(sample, field_map): normalized = {} for target_field, source_field in field_map.items(): normalized[target_field] = sample.get(source_field, "") return normalized field_map = { "instruction": "prompt", "input": "context", "output": "response" } with open("raw_data.jsonl", "r") as fin, open("normalized.jsonl", "w") as fout: for line in fin: sample = json.loads(line.strip()) normalized = normalize_sample(sample, field_map) fout.write(json.dumps(normalized, ensure_ascii=False) + "\n")

注意ensure_ascii=False这个参数,不加的话中文会被转义成\uXXXX形式,虽然不影响解析,但文件体积会膨胀好几倍,而且人眼没法直接检查数据。

实操心得:JSONL文件在写入时一定要确保每行末尾有换行符,最后一行也不例外。有些工具在读取时如果最后一行没有换行符,会直接忽略掉那条数据,而且不报错。这个坑我踩过,排查了半天才发现是文件末尾少了换行。

2.2 ChatML与对话格式:多轮对话的标准化方案

做对话类应用的时候,数据格式就变成了多轮对话的结构。目前业界比较通用的方案是ChatML(Chat Markup Language)风格,它的核心思想是用特殊token来标记不同角色的发言边界。一个典型的多轮对话样本长这样:

{ "messages": [ {"role": "system", "content": "你是一个专业的翻译助手"}, {"role": "user", "content": "把'你好'翻译成英文"}, {"role": "assistant", "content": "Hello"}, {"role": "user", "content": "那'谢谢'呢"}, {"role": "assistant", "content": "Thank you"} ] }

这个格式的关键在于role字段,通常有system、user、assistant三种角色。system消息用来设定模型的行为准则,user是用户输入,assistant是模型回复。在训练时,通常只对assistant角色的内容计算loss,user和system部分会被mask掉。这个细节很重要,如果你不小心把user的内容也纳入loss计算,模型会学会"自己跟自己对话",输出质量会明显下降。

不同模型对对话格式的要求有细微差别。有的模型要求system消息必须放在第一条,有的模型支持在对话中间插入system消息。有的模型用<|im_start|>和<|im_end|>作为特殊token,有的用[INST]和[/INST]。你在准备数据之前,一定要先查清楚目标模型的对话模板(chat template)。很多训练框架提供了tokenizer.apply_chat_template()方法,你直接把messages列表传进去,它会自动帮你转换成模型需要的格式。这个函数一定要用,不要自己手动拼接字符串,否则很容易漏掉特殊token或者空格。

我见过一个典型的错误案例:某开发者手动拼接对话历史,用\n分隔不同角色的发言,结果模型训练出来之后,回复里经常莫名其妙地出现"user:"这样的前缀。原因就是训练数据里没有明确的分隔标记,模型分不清哪些内容该生成、哪些不该生成。用标准的ChatML格式加上正确的chat template,这个问题就不会出现。

2.3 嵌入向量与向量数据库格式

做RAG应用的时候,数据格式就变成了向量。文本经过嵌入模型处理后,会变成一个浮点数数组,通常是768维、1024维或1536维。这个数组本身没什么好说的,但它在存储和检索时的格式选择很关键。

向量数据库通常支持两种存储方式:一种是存原始向量加元数据,另一种是存向量加原始文本。我建议两种都存,因为检索回来之后你往往需要展示原始文本给用户看,如果只存了向量,还得回源查询,多一次网络往返。元数据部分用JSON格式存,可以放文档来源、页码、时间戳等信息,方便做过滤。

向量的精度也值得注意。很多嵌入模型默认输出float32,但存储时可以用float16甚至int8量化来节省空间。float16能把存储空间减半,检索精度损失很小,通常在1%以内。int8量化能压缩到四分之一,但精度损失会明显一些,大概在3%到5%。如果你的向量库规模在百万级别以下,用float32就行,没必要折腾量化。上了千万级别,再考虑float16或int8。

import numpy as np # 模拟一个嵌入向量 embedding = np.random.rand(1536).astype(np.float32) # 转为float16存储 embedding_fp16 = embedding.astype(np.float16) print(f"float32大小: {embedding.nbytes} bytes") print(f"float16大小: {embedding_fp16.nbytes} bytes")

注意事项:向量归一化是一个容易被忽略的细节。有些嵌入模型输出的向量已经归一化了,有些没有。如果你用余弦相似度做检索,归一化不影响结果;但如果你用内积(dot product)做检索,没归一化的向量会导致相似度分数不可比。最稳妥的做法是,在存入向量库之前统一做L2归一化。

2.4 结构化输出格式:Function Calling与JSON Mode

让LLM输出结构化数据是另一个高频需求。比如你让模型从一段文本里抽取实体,希望它输出JSON格式的结果。但LLM天生是生成自由文本的,你不加约束的话,它可能在JSON前后加一堆解释性文字,导致你的解析脚本直接崩溃。

目前有两种主流方案。第一种是JSON Mode,你在调用API时设置response_format={"type": "json_object"},模型会保证输出合法的JSON。但JSON Mode只保证格式合法,不保证字段名和字段类型符合你的预期。第二种是Function Calling(也叫Tool Use),你定义一个函数签名,模型会按照签名来输出参数。这个约束更强,字段名和类型都会被校验。

我一般优先用Function Calling,因为它的约束更明确。举个例子,你要从用户评论中抽取情感倾向和关键词:

{ "name": "extract_sentiment", "parameters": { "type": "object", "properties": { "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]}, "keywords": {"type": "array", "items": {"type": "string"}}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, "required": ["sentiment", "keywords"] } }

模型输出的时候会严格按照这个schema来,你拿到结果直接json.loads就行,不需要做额外的清洗。但要注意,不是所有模型都支持Function Calling,而且不同模型对schema的支持程度也不一样。有的模型不支持enum约束,有的模型对嵌套对象的支持不好。你在选型的时候要先测试一下。

3. 实操:从原始数据到训练格式的完整转换流程

3.1 数据清洗与字段标准化

假设你手头有一批原始数据,格式五花八门,有的是CSV,有的是JSON,有的是纯文本。第一步是统一清洗。我通常写一个pipeline,分三步走:读取、清洗、输出。

读取阶段要根据文件扩展名选择不同的解析器。CSV用pandas,JSON用json库,纯文本直接按行读。清洗阶段要做的事情包括:去除HTML标签、去除多余空白字符、过滤掉过短或过长的样本、检查必填字段是否为空。输出阶段统一写成JSONL格式,字段名标准化。

import pandas as pd import json import re def clean_text(text): if not isinstance(text, str): return "" text = re.sub(r"<[^>]+>", "", text) # 去HTML标签 text = re.sub(r"\s+", " ", text).strip() # 合并空白 return text def process_csv(filepath): df = pd.read_csv(filepath) samples = [] for _, row in df.iterrows(): sample = { "instruction": clean_text(row.get("question", "")), "input": clean_text(row.get("context", "")), "output": clean_text(row.get("answer", "")) } if len(sample["instruction"]) < 5 or len(sample["output"]) < 1: continue samples.append(sample) return samples

这段代码里有两个细节值得说。第一个是clean_text函数对非字符串类型的处理,直接返回空字符串。因为pandas读取CSV时,空单元格会变成NaN(浮点数),如果你不处理,后续的字符串操作会报错。第二个是过滤逻辑,指令少于5个字符或者输出为空的样本直接丢弃。这种样本通常是脏数据,留着只会干扰训练。

3.2 格式转换中的token长度控制

清洗完之后,下一步是控制token长度。LLM有最大上下文限制,比如4096或8192个token。如果你的样本超长了,要么截断,要么丢弃。我的策略是:instruction和output尽量保留完整,input部分如果超长就截断。

计算token数需要用目标模型对应的tokenizer。不要用字符数估算,中英文的token比例差异很大。一个中文字通常对应1到2个token,一个英文单词通常对应1到1.5个token。但具体数字取决于tokenizer的实现。

from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("model_name") def truncate_sample(sample, max_length=2048): instruction_tokens = tokenizer.encode(sample["instruction"]) output_tokens = tokenizer.encode(sample["output"]) input_budget = max_length - len(instruction_tokens) - len(output_tokens) - 10 if input_budget < 0: return None # 指令和输出本身就超长了,丢弃 input_tokens = tokenizer.encode(sample["input"])[:input_budget] sample["input"] = tokenizer.decode(input_tokens) return sample

这里的-10是留给特殊token的余量。不同模型的特殊token数量不一样,有的多有的少,留10个是比较安全的做法。如果你不确定,可以留20个。

实操心得:截断input的时候,尽量在句子边界截断,不要从句子中间切断。你可以用tokenizer.decode之后,找到最后一个句号或换行符的位置,把后面的内容去掉。这样截断后的文本更自然,对训练效果的影响更小。

3.3 训练格式的最终组装

最后一步是把清洗好的数据组装成训练框架需要的格式。如果你用Hugging Face的Trainer,通常需要把数据转成datasets.Dataset对象。如果你用LLaMA-Factory这类微调框架,它们通常支持直接读取JSONL文件,你只需要在配置里指定字段映射就行。

我以LLaMA-Factory的配置为例:

dataset: - name: my_dataset file: data/normalized.jsonl format: alpaca columns: instruction: instruction input: input output: output

这里的format: alpaca指的是Alpaca格式,它要求数据包含instruction、input、output三个字段。LLaMA-Factory会自动把这些字段拼接到模型对应的chat template里。你不需要自己拼接,框架会处理。

但要注意,不同框架对格式的要求不一样。有的框架要求sharegpt格式,数据里要有conversations字段,每个元素包含from和value。你在准备数据之前,先确定用哪个框架,然后按照它的文档来准备。不要先准备数据再选框架,那样很可能要返工。

4. 常见问题与排查技巧实录

4.1 编码问题:乱码与BOM头

编码问题是LLM数据处理中最常见的问题之一。典型症状是:数据里出现\ufeff、é、’这样的乱码。\ufeff是BOM(Byte Order Mark)头,通常出现在Windows系统创建的UTF-8文件里。Python的open()函数默认不会去掉BOM,你需要用encoding="utf-8-sig"来读取。

# 错误写法 with open("data.jsonl", "r", encoding="utf-8") as f: for line in f: print(line[:20]) # 可能输出 \ufeff{"instruction"... # 正确写法 with open("data.jsonl", "r", encoding="utf-8-sig") as f: for line in f: print(line[:20]) # 输出 {"instruction"...

é和’这类乱码通常是UTF-8被错误地按Latin-1解码导致的。解决办法是先用chardet库检测文件编码,然后用正确的编码读取。

import chardet with open("data.txt", "rb") as f: raw = f.read() detected = chardet.detect(raw) print(detected) # {'encoding': 'utf-8', 'confidence': 0.99}

4.2 JSON解析失败:尾逗号与转义字符

JSON解析失败的原因主要有两个:尾逗号和未转义的特殊字符。尾逗号是指{"a": 1, "b": 2,}这种最后一个元素后面多了逗号,标准JSON不允许这样。未转义的特殊字符是指字符串里出现了双引号或反斜杠但没有转义。

排查这类问题,我一般用json.loads逐行解析,捕获异常并打印行号:

import json with open("data.jsonl", "r", encoding="utf-8-sig") as f: for i, line in enumerate(f, 1): try: json.loads(line) except json.JSONDecodeError as e: print(f"第{i}行解析失败: {e}") print(f"内容: {line[:100]}")

如果数据量很大,逐行解析太慢,可以用jq命令行工具快速检查:

jq . data.jsonl > /dev/null

jq会在遇到第一个非法JSON时报错并退出,错误信息里会包含行号。

4.3 字段缺失与类型不匹配

字段缺失的问题在合并多个数据源时特别常见。A数据源用"question"字段,B数据源用"query"字段,你合并之后发现一半的数据没有instruction。解决办法是在合并之前先做字段映射,映射表要显式定义,不要靠猜。

类型不匹配的问题也很常见。比如某个字段在A数据源里是字符串,在B数据源里是数组。你直接合并的话,训练脚本在处理时会报类型错误。我的做法是写一个schema校验函数,对每个字段做类型检查,不符合的样本直接丢弃或者转换。

def validate_sample(sample): if not isinstance(sample.get("instruction"), str): return False if not isinstance(sample.get("output"), str): return False if len(sample["instruction"]) == 0 or len(sample["output"]) == 0: return False return True

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
文件开头出现\ufeffUTF-8 BOM头用hexdump查看文件头读取时用utf-8-sig编码
中文显示为é编码解码不匹配用chardet检测编码用检测到的编码重新读取
JSON解析报错尾逗号或未转义字符逐行json.loads捕获异常用正则清洗或手动修复
字段缺失多数据源字段名不统一检查各数据源的字段名写字段映射脚本统一命名
训练时loss为NaN数据中有空样本或超长样本检查样本长度分布过滤空样本,截断超长样本
模型输出重复训练数据中output有重复内容统计output的重复率去重或降低重复样本权重

避坑技巧:在处理大规模数据之前,先拿100条样本跑一遍完整流程。确认格式转换、token计算、训练加载都没问题之后,再全量处理。这样能避免跑了几小时才发现格式错误的情况。

5. 不同场景下的格式选型建议

5.1 微调场景:JSONL + ChatML是首选

微调场景下,我强烈建议用JSONL存储数据,对话格式用ChatML。原因有三:第一,JSONL支持流式读取,内存友好;第二,ChatML是业界通用标准,主流模型和框架都支持;第三,JSONL方便做数据版本管理和增量更新,你可以在文件末尾追加新数据,不需要重写整个文件。

如果你做的是指令微调而非对话微调,Alpaca格式(instruction/input/output)也够用。但如果你后续要扩展到多轮对话,还是ChatML更灵活。我的建议是一步到位,直接用ChatML格式,省得后面转换。

5.2 RAG场景:原始文本 + 向量 + 元数据三件套

RAG场景的数据格式要复杂一些,因为涉及文本、向量、元数据三种数据。我的做法是用一个JSON对象把三者打包在一起:

{ "id": "doc_001_chunk_003", "text": "原始文本内容...", "embedding": [0.123, -0.456, ...], "metadata": { "source": "某文档.pdf", "page": 5, "chunk_index": 3, "timestamp": "2024-01-15T10:30:00Z" } }

存储的时候,向量部分存入向量数据库,文本和元数据存入关系数据库或文档数据库。检索时先用向量相似度找到候选ID,再用ID回查文本和元数据。这种分离存储的方式比把所有数据都塞进向量数据库更灵活,也更容易做元数据过滤。

5.3 推理API场景:请求与响应的格式约定

调用LLM推理API时,请求和响应的格式也需要约定好。请求体通常是JSON,包含model、messages、temperature、max_tokens等参数。响应体也是JSON,包含choices数组,每个choice里有message字段。

我建议在应用层定义一个统一的请求响应格式,屏蔽不同API提供商的差异。这样你切换模型提供商的时候,只需要改适配层,不需要改业务代码。

class LLMRequest: def __init__(self, messages, temperature=0.7, max_tokens=1024): self.messages = messages self.temperature = temperature self.max_tokens = max_tokens def to_dict(self): return { "messages": self.messages, "temperature": self.temperature, "max_tokens": self.max_tokens } class LLMResponse: def __init__(self, content, finish_reason, usage): self.content = content self.finish_reason = finish_reason self.usage = usage

这种封装方式的好处是,你的业务代码只依赖LLMRequest和LLMResponse这两个类,不直接依赖任何API提供商的SDK。切换提供商时,只需要写一个新的适配器,把LLMRequest转成目标API的格式,再把响应转回LLMResponse。

5.4 格式选型的决策流程

面对一个具体项目,怎么选数据格式?我通常按这个流程走:

第一步,确定数据用途。是训练、推理还是存储?训练用JSONL,推理用JSON,存储看具体数据库。

第二步,确定模型类型。是对话模型还是基座模型?对话模型用ChatML,基座模型用纯文本加分隔符。

第三步,确定框架要求。你用的训练框架或推理框架对格式有什么硬性要求?先查文档,按文档来。

第四步,考虑扩展性。未来会不会加多轮对话?会不会加多模态数据?选一个容易扩展的格式,避免后期大改。

这套流程走下来,基本上不会选错格式。我自己的项目里,微调数据统一用JSONL + ChatML,RAG数据用JSON + 向量分离存储,推理请求用统一的JSON封装。这套组合用了两年多,没出过什么大问题。

最后分享一个小技巧:不管你用什么格式,都写一个格式校验脚本,在数据进入训练或推理流程之前跑一遍。校验内容包括:JSON合法性、必填字段是否存在、字段类型是否正确、样本长度是否在范围内。这个脚本花不了多少时间,但能帮你省下大量排查格式问题的时间。我在每个项目里都会放一个validate_data.py,每次数据更新后自动跑一遍,已经成为标准流程了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询