训练中文模型经常碰到一个诡异现象:同样的数据在自己的Windows笔记本上跑得好好的,推到Linux服务器后就开始报UnicodeDecodeError;打开BPE词表一看,里面不是“中国”“机器”这种正常词,反而是一堆类似“且“ç”的碎片。这些问题往往被归咎于模型或分词器,但真正断裂的地方通常更靠上游:UTF-8编码和Tokenization策略没有对齐。

一、乱码的本质:编码与解码不对称
先说一个容易混淆的概念:Unicode不是编码,UTF-8才是编码。Unicode为“中”分配了码点U+4E2D,UTF-8则负责把这个码点变成字节序列E4 B8 AD。如果写入文件时使用的是UTF-8,而读取时系统按GBK去解码,GBK会把E4 B8 AD这三个字节按双字节规则重新组合,得到完全无关的字符。乱码不是字符本身坏了,而是解码阶段用错了规则。
实际项目里乱码来源大致有三类。第一类是文件读写没有显式指定编码,Python在Windows上默认可能用GBK或cp936,在Linux上默认用UTF-8,同一个脚本跨平台就会出现差异。第二类是网络接口、数据库字段或CSV文件头部声明的编码和真实内容不一致。第三类是中间处理环节把字节流当字符串拼接、截断,破坏了多字节字符的完整性。下面这段代码是最常见的错误和修正方式。
# 错误:依赖平台默认编码,可能得到乱码
text = open('news.txt').read()
print(text[:20])
# 正确:显式指定UTF-8
with open('news.txt', encoding='utf-8') as f:
text = f.read()
print(text[:20])除了读写不一致,还有一种隐蔽情况:UTF-8编码的中文被GBK读取后往往不会直接报错,而是变成一些看起来正常的汉字,例如“涓枃”。这种乱码一旦进入训练语料,后续的词表和学习结果都会被污染,排查起来比直接报错更费劲。
二、UTF-8的细节:变长规则、BOM与规范化
UTF-8使用变长字节表示Unicode码点。单字节字符以0开头,兼容ASCII;多字节字符的首字节以110、1110、11110开头,后续字节都以10开头。中文常用汉字大多落在U+4E00到U+9FFF区间,编码后占3个字节,部分生僻字会占4个字节。因为ASCII部分完全不变,纯英文文本在UTF-8和Latin-1下表现一致,这也导致很多英文测试用例无法暴露中文编码问题。
另一个隐蔽坑是BOM。Windows记事本保存UTF-8文件时会在文件头部写入EF BB BF三个字节,读取后字符串开头会多出一个\ufeff字符。这个字符肉眼不可见,却可能被tokenizer当成独立token,或者影响第一个词的合并。如果训练语料混用了带BOM和不带BOM的文件,词表里甚至会出现两种略有差异的表示。稳妥做法是在读取原始字节后先去除BOM。
import codecs
with open('corpus.txt', 'rb') as f:
raw = f.read()
if raw.startswith(codecs.BOM_UTF8):
raw = raw[len(codecs.BOM_UTF8):]
text = raw.decode('utf-8')
print(text[:20])UTF-8解决的是字节与码点的映射问题,但同一个语义字符可能有多种Unicode表示。例如全角数字“1”和半角“1”语义相同,但码点不同;组合字符“é”可以用单个码点表示,也可以用“e”加组合重音符号表示。如果不做规范化,tokenizer会把这些视觉相同但字节不同的文本切成不同token,浪费词表容量。中文场景常用的规范化是NFKC,它会把全角符号、特殊兼容字符统一成半角或标准形式。
三、中文Tokenization策略:按字、按词与子词
中文没有英文那样的空格分隔,所以tokenization的选择直接影响模型效果。按字切分最简单,每个汉字作为一个token,词表很小,但序列会变长,模型必须跨多个字符才能理解“机器学习”这类组合语义。按词切分需要先跑分词器,例如jieba,粒度更接近人类理解,但会面临未登录词问题,新出现的网络用语、产品名称、专业术语很可能不在词表中,只能被切成碎片或被替换为未知符号。
子词策略是当前预训练模型的主流选择。BPE、WordPiece、Unigram等方法从训练语料中自动学出高频片段,“机器”和“学习”可能被合并成“机器学习”,但同时又保留单字和双字片段来兜底新词。对于中文来说,BPE可以直接在Unicode字符序列上训练,也可以退回到UTF-8字节级别做byte-level BPE。字节级BPE的好处是任何字符都能被表示,即使文本中混入少量乱码或生僻字,也不会直接变成未知token,但代价是训练过程会看到更多细碎符号。
关键前提是:训练tokenizer之前,文本必须是解码正确、规范化之后的干净字符串。如果文本本身是乱码,BPE会把“且这样的乱码字节对当成合法子词学进词表,后续所有下游任务都会继承这个错误。下面用SentencePiece训练一个简单的中文BPE模型。
import sentencepiece as spm
spm.SentencePieceTrainer.train(
input='clean_corpus.txt',
model_prefix='zh_bpe',
vocab_size=8000,
character_coverage=0.9995,
model_type='bpe'
)
sp = spm.SentencePieceProcessor(model_file='zh_bpe.model')
tokens = sp.encode('解决中文乱码问题', out_type=str)
print(tokens)上面代码输出的token列表通常不会再出现单个乱码符号。如果发现词表前几十个token里混有类似“Ô“¤”“ï”这类字符,基本可以判断上游编码出了问题,而不是分词器本身配置不对。
四、工程实践:建立从编码到分词的一致性检查
统一文件读写编码是最基础的一步。所有文本IO路径显式指定encoding='utf-8',数据库连接字符串明确使用UTF-8,HTTP响应先校验头部声明再解码。对于JSON数据,Python的json.dump默认会把非ASCII字符转成\uxxxx形式,虽然不会乱码,但可读性差且增加字符串长度,中文场景通常加上ensure_ascii=False。
import json
record = {'title': '解决中文乱码', 'score': 0.98}
with open('result.json', 'w', encoding='utf-8') as f:
json.dump(record, f, ensure_ascii=False)
with open('result.json', encoding='utf-8') as f:
data = json.load(f)
print(data['title'])如果数据来源不明,无法确认编码,可以先用chardet或charset-normalizer做探测,再统一转成UTF-8。探测结果只能作为参考,最终解码建议使用strict模式,一旦出现UnicodeDecodeError就立即标记坏样本,而不是用errors='ignore'静默丢弃字符。静默忽略会让问题样本悄悄进入训练集,后续排查成本更高。
import chardet
raw = open('unknown_source.txt', 'rb').read()
result = chardet.detect(raw)
encoding = result['encoding']
text = raw.decode(encoding or 'utf-8')
print(text[:20])在数据清洗脚本中还可以加入两个轻量校验。一是随机抽取字节序列解码后检查是否包含U+FFFD替换符,该符号通常代表解码失败;二是训练tokenizer后打印词表头部和尾部,人工检查是否有连续非法符号。编码问题看似琐碎,但它位于所有处理链路的起点,上游一个错误读取,经过tokenizer放大后会传导到整个模型的输入分布,因此值得在工程里设置硬性检查。
UTF-8编码中文乱码Tokenization策略修改时间:2026-10-07 04:38:20