前阵子折腾一批标注数据差点被格式问题折腾到崩溃。我在一个知识问答项目里拿到了一份标注文件工具导出的是 XML但训练脚本只吃 JSON。更坑的是另外一位同事要合并一部分样本他那边习惯用纯 TXT 的“文本 制表符 标签”格式。于是整个下午我都在折腾 XML、JSON、TXT 互转这件事。搞完之后我意识到标注格式转换看着不起眼但它是数据链路里绕不开的一道坎值得单独写一期。很多做 AI 实战的朋友会把精力放在模型结构、超参数调优上但真实项目里数据与数据集阶段消耗的时间往往比训练还多。而格式转换又是这个阶段最枯燥、最容易出错、也最容易被低估的一环。这篇文章我就结合自己做过的几个数据处理小项目把 XML、JSON、TXT 三种格式互转的底层逻辑、代码实现、踩坑点和验证方案完整拆开讲一遍。1. 为什么“格式转换”不只是一次文本替换1.1 一个典型场景三种工具的三种出口我先讲一个很常见的困境。假设你的数据链路是这样的标注员用工具 A 标注了一批文本导出出来是 XML算法工程师写的数据处理脚本接收的是 JSON业务方要验收或者要做规则过滤只愿意看 TXT。这个链路中间每换一次格式就需要一次转换。而问题恰恰在于很多初学者拿到格式转换任务第一反应是写一堆正则或者暴力字符串拼接。我之前接过一个文本分类的模拟项目标注文件长这样的一句话稿annotation record id001 text这家餐厅的服务态度很好/text label正面评价/label /record /annotation训练代码那边要求的 JSON 格式是这样{id: 001, text: 这家餐厅的服务态度很好, label: 正面评价}而业务方要的 TXT 是这样001 这家餐厅的服务态度很好 正面评价你当然可以用正则表达式把 XML 里的text标签内容抠出来再拼成 JSON 字符串。但如果标注文件里有符号、有嵌套的 XML 子节点、有中文编码问题、有缺失标签正则方案立刻会变成一团乱麻。1.2 转换的本质保住“结构语义”而不是“字符串外观”格式转换的核心难点不是把 A 格式的字符变成 B 格式的字符而是保住数据原本的结构语义。XML 是一种树状结构一个父节点可以有多个子节点每个节点还可以带属性JSON 是键值对嵌套结构强调对象和数组TXT 则是纯线性的文本约定靠分隔符或固定位置表达信息。三种格式表达同一条数据时信息密度和层级关系都不一样。所以转换过程必须经过一层“语义解析”把源格式先读成内存里的通用对象再序列化成目标格式。我说的直白一点你手里不应该是“字符串到字符串”的正则替换而应该是“字符串到结构化对象再到字符串”两次变换。这个思路一旦捋顺后面写什么转换都只是换解析器和序列化器的事。2. XML、JSON、TXT 三者的实际差异树、表与行文本2.1 XML 的结构特点标签、属性、子节点XML 是我最早接触的标注格式因为它很适合描述“带属性且有层级”的对象。一条样本可以写作document category售后 record id1001 text退货运费谁承担/text entities entity type产品运费/entity /entities label售后咨询/label /record /document这里面有两类信息特别容易在转换中丢失属性比如category售后、type产品和文本节点比如标签内容。另外一个特色是 XML 对转义要求很严格文本里如果出现、、必须写成lt;、gt;、amp;否则就不是合法 XML。这也直接决定了手工正则抠 XML 内容时一旦遇到特殊字符极容易出错。2.2 JSON 的结构特点对象、数组、还是嵌套JSON 的特点是“轻”和“严格”。它没有属性概念但可以通过嵌套对象表达同样的语义{ document: { category: 售后, records: [ { id: 1001, text: 退货运费谁承担, entities: [ {type: 产品, value: 运费} ], label: 售后咨询 } ] } }JSON 用数组表达一个节点可能出现的多次出现而 XML 中多个同名字节点也表达同样的“列表”语义。转换时最头疼的点就是这个对应关系XML 里单条子节点和多个子节点的写法不同JSON 里则统一成数组。2.3 TXT 的结构特点约定大于标准TXT 严格意义上谈不上“标准格式”它全靠大家事先约定。常见的有三种约定每行一条样本字段之间用制表符分隔类似 TSV每行第一条文本后面跟标签用逗号或空格分隔用固定行数表示一组样本比如第一行是 id第二行是 text第三行是 label。TXT 的最大优点是直观、好审计人也容易写最大缺点是表达不了嵌套结构。凡是要转成 TXT 的数据基本都得“拍平”把嵌套对象展平成列。2.4 一张表直接对比三种格式的信息表达能力维度XMLJSONTXT结构表达树状标签支持属性键值嵌套支持数组扁平行式靠分隔符列表表达多个同名子节点数组默认不支持需约定属性表达原生支持 attribute用子键模拟不支持可读性一般标签冗余较好层次清晰最好但也最无结构转义要求严格lt;等中引号、反斜杠低解析难度中低低但规则分散有了这个对比再看转换需求你应该能明白XML 转 JSON 时要把“同名子节点”翻译成“数组”JSON 转 TXT 时要把“嵌套键”拍平成分隔符列TXT 转 XML 时则要把“扁平的行”重新提升成“树”。3. 转换工具选型标准库、第三方库与自研的取舍3.1 Python 标准库能用但别硬上Python 自带xml.etree.ElementTree和json两个标准库模块。前者可以解析 XML 为 ElementTree 对象后者负责 JSON 序列化。用标准库的好处是零依赖。坏处是XML 转 dict 的过程需要自己写递归遇到属性、命名空间、节点列表时一堆边界条件。我早期试过手撸一个xml_to_dict函数处理五分钟数据量没问题一碰到带 namespace 的 XML 就翻车。所以我现在只在“绝对不允许安装第三方库”的环境里才用标准库方案。3.2 第三方库xmltodict 和 dicttoxml 是省心搭档我实测下来最顺手的组合是解析 XML 成 dictxmltodict它天然把同名子节点处理成列表可以开启force_list保证单节点也变成列表dict 转 XMLdicttoxml虽然生成的 XML 偏啰嗦但可以直接构建标准结构JSON 与 dict 互转json模块本身无需第三方。再配合pandas做中间表或者直接用纯 Python list of dict 作为“中间表示”这一套已经覆盖绝大多数标注转换场景。3.3 什么情况下才需要自研解析器自研解析器只建议在两类情况下考虑数据格式完全私有、没有现成解析器对转换速度极其敏感单日处理上亿级行的场景。对绝大多数标注数据集来说第三方库已经足够自研解析器带来的性能提升远小于它引入的维护成本。这一点希望你听劝不要为了炫技去手写 XML 解析器。我用过一段时间的自研递归解析后来换了xmltodict代码量缩减了三分之一健壮性上了一个台阶。我整理了一个选型表给你参考工具强项弱项使用建议xml.etree.ElementTree标准库零依赖需要手写递归转 dict轻量小文件禁止装包的场景xmltodictXML 转 dict 稳健列表处理友好对命名空间需额外配置主力工具日常首选dicttoxmldict 转 XML 简单属性表达不如手工构建灵活生成标准简单 XML 可用json序列化反序列化无法处理斜杠和中文会转义记得ensure_asciiFalsepandas表格化中间表示批量友好嵌套结构需要拆列适合拍平 TXT 或做数据清洗4. 统一中间表示转换代码的骨架设计4.1 为什么三层转换里必须先“归一化”我强烈建议你在所有转换代码中引入一个“中间表示层”。所谓中间表示就是我前面说的“结构化对象”——通常是一份统一 schema 的 dict 列表[ {id: 001, text: 这家餐厅的服务态度很好, label: 正面评价}, {id: 002, text: 快递速度太慢了, label: 负面评价} ]这样XML、JSON、TXT 三者在转出的时候都先归到这一层再统一输出目标格式。好处是显而易见的你只要把“解析 XML 到中间层”、“解析 JSON 到中间层”、“解析 TXT 到中间层”和“中间层到目标格式”这几段逻辑分别独立将来新增一种格式只需新增一个解析函数不用改动其它转换分支。我之前第一次写转换脚本时没有中间层直接写了“XML 字符串 - JSON 字符串”的函数。结果后来又遇到“JSON 字符串 - XML 字符串”需求整个逻辑得重写。现在改成中间层后所有转换都是两段式省心太多。4.2 字段映射先定义标准字段再处理别名标注数据的字段名往往不统一。有的 XML 里叫content有的 JSON 里叫text_body业务方的 TXT 里则叫文本。如果转换脚本里写死了字段名换一个数据源就得改一遍代码。正确做法是定义一组标准字段例如doc_id、text、label、attr再写一个映射字典field_map { doc_id: [id, docid, 编号], text: [text, content, 文本, 正文], label: [label, tag, 标签, 类别] }转换的时候不管源格式里字段叫什么统一取第一个命中的值放进中间层。这样数据源再换你只需要扩充映射字典而不是改解析逻辑。4.3 主流程解析函数 序列化函数整体主流程建议写成四个独立函数def parse_xml_to_records(xml_text, field_map) - list[dict]: ... def parse_json_to_records(json_text, field_map) - list[dict]: ... def parse_txt_to_records(txt_text, field_map, sep\t) - list[dict]: ... def records_to_xml(records) - str: ... def records_to_json(records) - str: ... def records_to_txt(records, sep\t) - str: ...调用的时候自由组合。比如做“XML 转 JSON”就是records parse_xml_to_records(xml_content, field_map) json_output records_to_json(records)这样做的好处我想再强调一次你能准确地说出每一步的输入输出出现问题时也能快速定位是解析环节还是序列化环节出了问题。5. XML、JSON、TXT 互转的具体实现与批量处理5.1 XML 转 JSON先看最常用的 XML 转 JSON。我直接用xmltodict把 XML 解析成 dict再做标准化处理。import xmltodict import json def xml_to_records(xml_text, field_map): parsed xmltodict.parse(xml_text, force_list(record,)) records [] # 根据实际根节点和记录节点调整取值路径 all_records parsed.get(annotation, {}).get(record, []) for item in all_records: rec {} # 先把标准字段映射关系反转源字段名 - 标准字段名 reverse_map {} for standard, aliases in field_map.items(): for alias in aliases: reverse_map[alias] standard for key, value in item.items(): if key in reverse_map: rec[reverse_map[key]] value records.append(rec) return records def records_to_json(records, indent2): return json.dumps(records, ensure_asciiFalse, indentindent)这里有两个关键点要记牢第一是force_list(record,)强制 XML 里的record节点始终解析成列表否则单条记录时会变成 dict后续遍历会直接报错。第二是ensure_asciiFalse不设置的话一段中文会被转成\u4e00之类的一长串转义字符人工检查的时候根本没法看。5.2 JSON 转 TXTJSON 转 TXT 的场景最常见因为训练脚本和业务验收基本都偏好扁平文件。假设中间层记录是前面那个{id: ..., text: ..., label: ...}的形式转 TXT 其实非常简单def records_to_txt(records, sep\t): lines [] for rec in records: line f{rec.get(doc_id, )}{sep}{rec.get(text, )}{sep}{rec.get(label, )} lines.append(line) return \n.join(lines)TXT 格式之所以“简单但有坑”是因为分隔符本身容易和文本内容冲突。比如文本里本来就带着制表符你是保留它还是替换掉我的习惯是统一把文本里的制表符替换成空格因为一旦保留后续按分隔符切割时样本字段就会错位这种问题非常隐蔽通常要到训练阶段才会爆炸。如果你要转换的 JSON 里还有嵌套结构比如实体列表那需要先把嵌套拍平。举个典型例子{id: 1001, text: 退货运费谁承担, entities: [{type: 产品, value: 运费}]}拍平成 TXT 时可以做成多列固定格式1001 退货运费谁承担 实体产品:运费这个“实体类型 冒号 实体值”的字符串拼接形式是我在实践中摸索出来的折中方案既能保留语义又不破坏扁平文件结构。5.3 TXT 转 XMLTXT 转 XML 是反方向操作难点在于要把扁平的行“重新提升”成树状结构。先看核心代码import xml.etree.ElementTree as ET def records_to_xml(records, root_tagannotation, record_tagrecord): root ET.Element(root_tag) for rec in records: rec_elem ET.SubElement(root, record_tag) rec_elem.set(id, str(rec.get(doc_id, ))) text_elem ET.SubElement(rec_elem, text) text_elem.text rec.get(text, ) label_elem ET.SubElement(rec_elem, label) label_elem.text rec.get(label, ) # 如果中间层里还有 attr 之类的附加字典可以继续扩展子节点 return ET.tostring(root, encodingunicode, xml_declarationFalse)这里有一个小细节ET.tostring(..., encodingunicode)返回的是字符串而不是字节串方便后续直接写入文件或继续加工。如果你用默认的encodingutf-8拿到的是一段bytes写文件的时候就得注意模式是wb还是w容易搞混。5.4 批量转换脚本入参、输出目录、日志真实项目里很少只转一个文件通常是一整个目录。我习惯把批量入口写成这样import glob import os def batch_convert(input_dir, input_ext, output_dir, output_ext, field_map, txt_sep\t): os.makedirs(output_dir, exist_okTrue) file_paths glob.glob(os.path.join(input_dir, f*.{input_ext})) for path in file_paths: with open(path, r, encodingutf-8) as f: content f.read() # 根据输入格式选择解析函数 if input_ext.lower() xml: records xml_to_records(content, field_map) elif input_ext.lower() json: records json_to_records(content, field_map) elif input_ext.lower() in (txt, tsv, csv): records txt_to_records(content, field_map, septxt_sep) else: raise ValueError(funsupported input ext: {input_ext}) # 根据输出格式选择序列化函数 if output_ext.lower() xml: output records_to_xml(records) elif output_ext.lower() json: output records_to_json(records) elif output_ext.lower() in (txt, tsv): output records_to_txt(records, septxt_sep) else: raise ValueError(funsupported output ext: {output_ext}) out_path os.path.join(output_dir, os.path.splitext(os.path.basename(path))[0] . output_ext) with open(out_path, w, encodingutf-8) as f: f.write(output) print(fconverted: {path} - {out_path}, records{len(records)})这段脚本的框架逻辑很简单入口按文件扩展名选择解析函数出口按输出扩展名选择序列化函数。为了保险我还在每行日志里打印了记录数批量转换完扫一眼日志就能知道哪个文件记录数异常比如只有 0 条那就说明解析环节出了问题。6. 转换过程中的常见坑必须亲眼见一遍才信6.1 编码问题BOM 头和中文乱码第一批最容易踩的坑就是编码。Windows 环境下导出的 XML 或 TXT文件头可能带\ufeffUTF-8 BOM。你用 UTF-8 读取时这个字符会悄悄混进第一个字段里导致doc_id变成\ufeff001。应对办法是读取时用utf-8-sig编码with open(path, r, encodingutf-8-sig) as f: content f.read()另一个是中文乱码。有些标注工具导出的 XML 其实是 GBK 或 GB18030 编码你用 UTF-8 去读就会看到一堆问号或乱码字符。遇到这种情况可以先尝试gb18030编码读取因为这个编码的兼容性更强连生僻字也能解析。更稳妥的办法是用chardet或charset-normalizer自动检测编码但要注意用机械化方式判断编码毕竟有误差借助专用检测库能有效降低风险。6.2 字段缺失与默认值标注数据里经常有空缺字段。有的记录有label有的没有。转换脚本如果粗暴执行rec[label]遇到缺键直接KeyError但如果你把所有字段都填充成空字符串又可能干扰后续训练时的过滤逻辑。我的处理方式是在标准化过程中对每个字段显式指定默认值DEFAULTS { doc_id: , text: , label: UNKNOWN }把缺省label设置成UNKNOWN而不是空字符串是因为很多数据处理流程里“空标签”会被过滤掉而“UNKNOWN”至少能让你统计出哪些样本缺标签方便排查。不过这里要提醒一句如果你的下游任务对标签种类很敏感这种默认值策略要和团队提前对齐。6.3 嵌套层级不一致XML 单节点 vs 多节点这大概是 XML 转 JSON 时最隐蔽的坑。在 XML 里一个标签下如果只有一个子节点label正面评价/labelxmltodict解析出来可能是{label: 正面评价}而如果有两个子节点labels label正面评价/label label服务态度/label /labels解析出来就是{labels: {label: [正面评价, 服务态度]}}。同一个字段一会儿是 dict一会儿是 dict 套 list数据类型不稳定后续处理代码就必须到处判断isinstance。解决办法有两个。一个是前面提过的force_list(record,)这种强制列表方案让所有可能的列表节点都返回列表。另一个是标准化阶段做一个“统一为列表”的工具函数def ensure_list(value): if value is None: return [] if isinstance(value, list): return value return [value]然后对每个字段都做一次 :rec[labels] ensure_list(rec.get(labels, []))这样从中间层出来的数据所有可能出现多个值的字段形态完全一致下游代码才能安全统一处理。6.4 特殊字符转义、、、引号XML 转义是另一个高频坑。比如文本内容是“RB 售后流程”其中包含字符。如果你手工拼 XML 字符串时不处理生成的 XML 直接不合法解析端会报错。正确做法是使用xml.sax.saxutils.escape()或 XML 解析库自带序列化能力例如from xml.sax.saxutils import escape safe_text escape(rec.get(text, ))反过来从 XML 解析出来的文本也已经自动把amp;还原成了不需要你再处理。而这个排错过程总结出来一句话不要手工拼接 XML尽量让库去序列化手工拼 JSON 也行但要记得转义引号和反斜杠。6.5 转换后的反向校验别打开文件看一眼就说成功转换完成不代表万事大吉。我见过不少人转完文件打开看一眼格式对不对就宣布“完成了”。这种做法在数据量小时可能蒙混过关数据量一上去字段错位、记录丢失、标签值被截断等问题都藏在里面。我的校验流程分三层第一层数量校验。转换前后记录数必须一致。这个通过控制台日志就能核对。第二层字段校验。随机抽取部分样本比对源文件和目标文件里的字段值是否对应。我一般抽 20 条左右人工或者用脚本比对关键字段是否完全一致。第三层分布校验。统计源格式和目标格式中标签的分布情况。比如转换前“正面评价”有 500 条转换后只有 480 条那中间一定丢数据了。分布不一致是数据转换失败最典型的信号。我甚至会把这三层校验封装成一个函数所有格式转换完成后自动跑一遍再用汇总报告来确认转换结果有效def validate_conversion(src_records, dst_records, label_keylabel): assert len(src_records) len(dst_records), 记录数不一致 # 简单抽查前 20 条 for s, d in zip(src_records[:20], dst_records[:20]): assert s.get(text) d.get(text), f文本不一致: {s} vs {d} assert s.get(label_key) d.get(label_key), f标签不一致: {s} vs {d} # 标签分布 from collections import Counter src_dist Counter(r.get(label_key) for r in src_records) dst_dist Counter(r.get(label_key) for r in dst_records) assert src_dist dst_dist, f标签分布不一致: {src_dist} vs {dst_dist} print(validation passed)有了这套校验每次转换脚本改完我基本能在三分钟之内确认结果是否可靠而不是靠肉眼抽查。7. 格式转换之外命名规范、目录组织和版本管理转换脚本本身写好后还有几个项目管理层面的习惯想分享给你。第一输出目录要和原始目录完全隔离。我习惯在项目里建一个data/raw存放原始标注数据data/converted存放转换后的数据转换脚本和字段映射表放在scripts目录里。这样一旦转换坏了原始数据永远有备份不会污染根目录。第二字段映射表使用独立配置文件。我之前把field_map直接写在转换脚本里后来换数据源就得改代码。现在我把映射关系放到单独的 JSON 配置文件里转换脚本从这个配置读取映射关系。换数据源只需要改配置不需要动代码逻辑{ doc_id: [id, docid, 编号], text: [text, content, 文本, 正文], label: [label, tag, 标签, 类别] }第三转换脚本要保留清晰的运行日志。最好的状态是跑完批量转换后你能从日志里准确说出每一个文件转换了多少条记录、输出到哪里、校验是否通过。排查问题的时候这份日志比什么都有用。我建议你在转换完成后顺手做一件事输出一份conversion_report.txt记录文件名、输入输出格式、记录数、校验状态。这能帮团队里的其他人快速理解数据管道的情况也方便回溯。8. 我踩过几次坑之后的一些体会讲到最后分享几条纯粹的个人体感不一定都写在教科书里但确实是从实操中反复换来的。第一转换不是一次性的。数据标注是动态过程标注员可能隔几天补一批样本每次新增样本都需要重新转换并重新校验。所以不要写一次性脚本尽量把转换流程做成一个可复用入口哪怕只是把batch_convert函数包装成一个命令行工具后面收益也会很大。第二先跑小样本再跑全量。这是我改不掉的习惯。任何格式转换脚本我都会先抽 50 条样本跑通确认输出格式正确、记录数一致、特殊字符没有乱码然后再跑全量。全量跑完后还会再抽一次分布校验。两次抽查虽然多花几分钟但能挡住绝大多数灾难性错误。第三中间表示层不要省略。如果你的项目只需要转一次可能觉得中间层是多余设计。但只要数据链路稍有扩展——比如新增一种输入格式、下游脚本换了字段要求——中间层立刻变成省时间的关键。为可能的变化留一点余地是值得的。这三条经验加上前面那些代码和坑点基本覆盖了我在标注格式转换上积累的全部实用经验。数据预处理阶段的活儿往往不被注意但它决定了模型训练能不能顺利跑起来。把格式转换这件事做扎实整个数据链路才能稳定可靠。