简介面向中文自然语言处理研究与开发人员资源包提供基于Sentencepiece和Bert Tokenizer的GPT2-Chinese中文模型训练源码适合需要搭建中文生成模型、做文本生成或微调训练的进阶学习者。压缩包共42个文件约13.8MB含9个Python脚本覆盖训练、生成、评估、5个JSON配置、7个txt词汇表以及Shell脚本、图像示例、license等覆盖从数据处理到模型训练、结果展示的完整链路。Sentencepiece利用BPE算法切分未登录词Bert Tokenizer则提供预训练语义表示两者结合能有效提升中文语料的编码质量。已有340人学习下载。使用者可直接复用训练、生成、评估等核心脚本结合词汇表与配置文件快速启动训练也可参考示例图像理解生成效果开源结构便于二次扩展对想深入GPT2中文训练细节的开发者是很有价值的实践参考。1. 训练GPT2-Chinese前先解决中文该用哪种Tokenizer同样一段中文语料用Bert Tokenizer切出来的token数量往往比SentencePiece多10%到20%。这不是分词器哪个更差而是WordPiece和BPE对中文子词边界的建模方式不同。训练GPT2-Chinese时tokenizer不只是预处理工具它决定了模型的token级别、词表大小、生成时的错误累积方式甚至影响loss曲线能不能正常下降。很多训练脚本跑不起来或loss一直在高位震荡根因不在模型结构而在词表与tokenizer没有和模型配置对齐。这篇文章围绕“SentencePiece Bert Tokenizer”这条双路径展开。先把两种分词策略的原理和边界讲清再落地到GPT2-Chinese的数据管线、模型初始化、训练参数和验证技巧。适合已经跑过简单模型、但不熟悉中文子词训练的读者也适合想从零设计中文生成模型训练方案的工程师。这里不会贴一份神秘的完整源码只把源码里最需要自己设计的那几层写清楚。2. SentencePiece与Bert Tokenizer的分词原理和选型边界2.1 为什么中文不能直接按空格分词从字符到子词的必经之路英文GPT2的原始tokenizer依赖空格和punctuation做预分词再在词内部做BPE合并。中文没有天然空格边界直接按空格分词会把一整句话变成一个token导致词表膨胀、低频词无法学习。早期中文模型常用“字”为单位每个汉字一个token简单但丢失词级语义生成句子的连贯性差。中文训练要解决的是如何在字符与词之间找到一个子词单位。Bert Tokenizer的思路是先用词典做最长匹配中文场景下通常退化成“整词切成单字”的WordPieceSentencePiece的思路则是把文本当作连续字符流不做预分词让BPE或Unigram算法自己发现高频子串。两者的共同点是都依赖一个训练好的词表差别在于词表是怎么学出来的以及遇见未登录词时如何回退。2.2 SentencePiece的BPE与Unigram无预分词的优势SentencePiece的核心设计是“语言无关的subword正则化”。它把句子先转换成Unicode码点序列再应用BPE或Unigram模型。中文文本不需要用户提供分词器模型会自动统计“人民”、“中国”、“模型”这类高频片段将其合并成独立token。BPE模式从字符对开始不断找到语料中频率最高的相邻字符对合并直到达到vocab_size。这种合并策略对中文的效果偏向于词根和常见双字词例如“训练”、“模型”会成为稳定token。Unigram模式则用概率模型逐步删减低频piece保留那些能最大化似然的分片对多音字和稀有字更友好。用SentencePiece训练GPT2-Chinese词表时有个必须处理的点原始BPE对空格和标点没有语义需要显式设置bos_id、eos_id、pad_id、unk_id否则后续训练时无法将GPT2的|endoftext|正确映射到句子结尾。很多源码里会用spm_train训练完模型后再手工把special token插进词表。2.3 Bert Tokenizer的WordPiece中文词表的特殊性Bert Tokenizer使用WordPiece算法思路是贪心地从左到右按词表最长匹配。对于中文因为词表里通常只有少量统计好的词汇大多数情况下匹配出来的是单字所以bert-base-chinese的vocab_size是21128其中大部分是汉字和常见标点真正的中文词占比很低。使用Bert Tokenizer训练GPT2时好处是能直接复用bert-base-chinese的词表和特殊token格式例如[UNK]、[PAD]、[SEP]。坏处是这些特殊token的语义是为BERT设计的GPT2只有一个|endoftext|如果直接把[SEP]当普通token沿用模型可能学到多余的分隔符逻辑。在一个中文自回归模型里输入格式越接近原始文本越好额外分隔符只会干扰下一个词预测。如果你之前用过roberta中文预训练模型会注意到它在WordPiece基础上做了更多子词拆分但本质上仍是静态词表加字典匹配。对GPT2-Chinese来说Bert Tokenizer更适合作为“快速基线”因为不需要额外训练词表但长期效果往往不如针对语料训练的SentencePiece词表。2.4 选型对比词表大小、可逆性与GPT2-Chinese兼容性下面的对比表可以帮你快速决定当前任务用哪条路径。维度SentencePiece (BPE/Unigram)Bert Tokenizer (WordPiece)预分词依赖不依赖直接处理字符流依赖词典和预分词器中文长词表示可形成“中文后端”等多字符token多数场景退化为单字特殊token控制自定义灵活但容易漏配自带但语义为BERT设计词表扩展需重新训练或mergeadd_tokens即可扩展生成可逆性好可用piece恢复原始文本一般[UNK]会导致信息丢失对GPT2兼容性需包装成PreTrainedTokenizer可直接加载但需处理生成格式如果目标是做中文向量化模型或检索任务完全没必要碰GPT2直接使用sentence-transformers里现成的中文向量化模型即可。但做生成任务时SentencePiece通常比Bert Tokenizer更能抓住中文词根且可以用更小的vocab_size达到相同覆盖率。实际项目中也有两者混合的做法用SentencePiece训练出词表再通过特殊token格式映射到BertTokenizer结构让两者的优势同时生效。3. 用SentencePiece和Bert Tokenizer构建GPT2-Chinese训练数据管线3.1 训练SentencePiece词表的最小可运行示例先准备一个纯文本语料文件corpus.txt每行一句话或一段文本即可。中文语料不需要提前做jieba分词保留原始文本反而能让BPE学到更好的子词边界。训练命令如下spm_train \ --inputcorpus.txt \ --model_prefixsp_gpt2 \ --vocab_size30000 \ --model_typebpe \ --character_coverage0.9995 \ --byte_fallbacktrue \ --pad_id0 --unk_id1 --bos_id2 --eos_id3执行后生成sp_gpt2.model和sp_gpt2.vocab。model是二进制模型用于加载并编码文本vocab是纯文本词表便于检查piece分布。参数含义--vocab_size30000中文生成模型建议2万到5万。太小会导致一句话被切得很碎生成时每个token信息量不足太大会让embedding矩阵膨胀12层GPT2的参数量增加明显。--model_typebpe用BPE做子词合并。如果内置语料偏口语可以换成unigram通常覆盖率更好。--character_coverage0.9995保留99.95%的字符。中文语料包含生僻字时这个值可以降到0.999避免词表里堆满只出现一两次的罕见字。--byte_fallbacktrue遇到完全没见过的字符时回退到UTF-8字节级编码而不是直接输出unk。这对生成模型特别重要因为生成时永远可能出现训练集之外的字符。3.2 用Bert Tokenizer加载并适配自建词表SentencePiece训练出的sp_gpt2.model并不能直接被GPT2的HuggingFace实现加载需要包装成PreTrainedTokenizer。一个常见做法是直接用SentencePieceProcessor做子类把最小接口补齐。import sentencepiece as spm from transformers import PreTrainedTokenizer class GPT2ChineseTokenizer(PreTrainedTokenizer): def __init__(self, model_file, **kwargs): super().__init__(**kwargs) self.spm spm.SentencePieceProcessor(model_filemodel_file) def _tokenize(self, text, **kwargs): return self.spm.encode(text, out_typestr) def _convert_token_to_id(self, token): return self.spm.piece_to_id(token) def _convert_id_to_token(self, index): return self.spm.id_to_piece(index) def get_vocab(self): return { self.spm.id_to_piece(i): i for i in range(self.spm.get_piece_size()) } property def vocab_size(self): return self.spm.get_piece_size()这段代码把SentencePiece的BPE输出直接映射成GPT2的token id。注意_tokenize返回的是string list不是id所以HuggingFace内部的encode流程会继续调用_convert_token_to_id完成转换。如果你更依赖Bert Tokenizer可以直接加载中文bert词表再扩展领域词。下面是给模型添加新词的常用写法from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) new_tokens [新能源汽车, 碳化硅, 大模型] tokenizer.add_tokens(new_tokens) # 添加后必须让模型的embedding尺寸同步变化 model.resize_token_embeddings(len(tokenizer))add_tokens后model.resize_token_embeddings会被调用来扩展输入embedding和输出lm_head矩阵。这个操作会随机初始化新token的embedding所以如果新词数量很大建议用原始语料里的上下文向量做一个初始化否则模型前几个epoch会花时间稳定新token。3.3 文本编码、padding与attention_mask的生成细节GPT2训练的标准做法是把多个文档拼接成固定长度序列分隔处插入|endoftext|。中文语料如果想保留文档边界不能简单用tokenizer(text, max_length512, truncationTrue)粗暴截断因为那样会丢失大量跨句语义。推荐的做法是先批量编码再拼接def encode_for_gpt2(texts, tokenizer, max_length512): all_ids [] for text in texts: ids tokenizer.encode(text, add_special_tokensFalse) ids.append(tokenizer.eos_token_id) all_ids.extend(ids) # 切分成固定长度块 blocks [] for i in range(0, len(all_ids) - max_length 1, max_length): blocks.append(all_ids[i:i max_length]) return blocks然后构建Dataset并做paddingfrom datasets import Dataset data {input_ids: blocks} dataset Dataset.from_dict(data) def to_training_sample(example): input_ids example[input_ids] return { input_ids: input_ids, attention_mask: [1] * len(input_ids), labels: input_ids, } dataset dataset.map(to_training_sample)这里labels直接等于input_ids因为因果语言模型里每个位置都要预测下一个token不需要像MLM那样准备token_type_ids。attention_mask必须为1否则padding出来的位置可能被模型参与计算干扰loss统计。需要注意如果是用Bert Tokenizer加载并设置了[PAD]但GPT2模型内部没有pad token idDataCollatorForLanguageModeling会自动把padding位置的label设为-100避免计算loss。这个行为依赖pad_token_id已经配置好在初始化模型时最好显式传入。4. GPT2-Chinese模型配置与训练参数设计4.1 初始化GPT2LMHeadModel并注入新词表的三个关键点下面这段代码以gpt2结构为基础创建一个小型中文GPT2模型并绑定tokenizerfrom transformers import GPT2Config, GPT2LMHeadModel config GPT2Config( vocab_sizelen(tokenizer), n_positions1024, n_ctx1024, n_embd768, n_layer12, n_head12, bos_token_idtokenizer.bos_token_id, eos_token_idtokenizer.eos_token_id, pad_token_idtokenizer.pad_token_id, ) model GPT2LMHeadModel(config) model.resize_token_embeddings(len(tokenizer))这里最容易被忽略的三个点vocab_size必须和tokenizer的实际长度一致如果先设置成固定值再调用resize_token_embeddings额外分配的新token会覆盖默认的随机初始化。bos_token_id和eos_token_id对生成阶段很重要。GPT2原本没有pad token如果不设置pad_token_id多序列生成时会因为padding不一致报错。n_positions决定了最大输入长度中文生成模型一般设置512或1024超过128对显存压力非常大。如果语料里长文本较多可以在训练时截断到512而不是盲目调大n_ctx。4.2 训练参数batch、学习率、warmup与梯度累积模型初始化后用HuggingFace Trainer训练是最稳定的路径from transformers import DataCollatorForLanguageModeling, Trainer, TrainingArguments collator DataCollatorForLanguageModeling( tokenizertokenizer, mlmFalse, ) training_args TrainingArguments( output_diroutput/gpt2_chinese, num_train_epochs3, per_device_train_batch_size4, gradient_accumulation_steps8, learning_rate3e-4, lr_scheduler_typecosine, warmup_steps500, logging_steps100, save_steps1000, fp16True, gradient_checkpointingTrue, ) trainer Trainer( modelmodel, argstraining_args, train_datasetdataset, data_collatorcollator, ) trainer.train()DataCollatorForLanguageModeling在mlmFalse时做的是因果语言建模它会负责将输入右移一位生成labels并且把padding位置的label置为-100。所以你不必在数据集中手工准备labels使用这个collator反而更不容易出错。关键参数对照参数建议值说明per_device_train_batch_size2~8取决于显存中文GPT2 12层建议从2开始gradient_accumulation_steps4~16用来模拟更大batch同时不增加显存learning_rate1e-4~5e-4从头训练建议3e-4微调建议1e-5warmup_steps500~2000语料小时取总步数的10%lr_scheduler_typecosine比linear更平滑适合中文生成训练gradient_checkpointingTrue以时间换显存对长序列训练帮助很大4.3 混合精度与多卡训练时的显存控制中文文本转成token后单条序列长度普遍比英文长因为很多词被切成单字token。这导致GPT2-Chinese的显存消耗比同等英文模型高30%左右。如果遇到OOM优先做三件事第一把fp16打开。Transformer结构对混合精度很稳定loss下降曲线不会明显变形。fp16True会自动处理master weights和loss scaling。第二开启gradient_checkpointingTrue。它不会降低最终效果但会让训练速度下降20%到30%。如果显存还不够再考虑把per_device_train_batch_size降到2。第三多卡训练时用Trainer的--ddp_find_unused_parametersFalse思维。通过代码设置training_args.ddp_find_unused_parameters False这个参数对GPT2这种无输入非Transformer分支的模型通常不会影响结果但能减少多卡通信量。如果你的训练脚本是自己写的DataParallel而不是DistributedDataParallel建议先迁移到后者否则batch维度上的梯度同步在中文长序列上会非常卡。4.4 训练中常见的loss异常排查loss完全不下降时先检查tokenizer而不是模型。一个很容易踩的坑是用SentencePiece训练词表时没有设置eos_id导致所有中文句子拼接后缺少结束符号。loss会先降一点随后稳定在比预期高的位置。如果loss锁死在某个值附近用以下脚本暴力验证import torch sample 人工智能正在改变世界 ids tokenizer.encode(sample) input_ids torch.tensor([ids]) with torch.no_grad(): outputs model(input_ids, labelsinput_ids) print(outputs.loss)这个loss值应该小于初始模型的log(vocab_size)的一半以上才算正常。如果loss接近log(vocab_size)说明模型还没有学进去优先调整学习率和warmup而不是增加层数。5. 训练完成后验证中文生成效果的三个具体技巧5.1 通过perplexity和生成样本检查分词质量训练结束后用模型输出的交叉熵计算perplexitypython - EOF import torch, math from transformers import GPT2LMHeadModel model_path output/gpt2_chinese/checkpoint-10000 model GPT2LMHeadModel.from_pretrained(model_path) loss outputs.loss.item() print(fPerplexity: {math.exp(loss):.2f}) EOFperplexity低于30在中文语料上通常说明模型已经学到局部语法低于20说明生成结果会比较流畅。只看loss还不够必须实际生成text 中国的人工智能 inputs tokenizer(text, return_tensorspt) outputs model.generate( inputs.input_ids, max_new_tokens50, do_sampleTrue, temperature0.8, top_p0.9, ) print(tokenizer.decode(outputs[0], skip_special_tokensTrue))temperature大于1会让中文生成变得混乱0.7到0.9比较合适。如果生成结果频繁出现未登录字词优先检查词表里是否缺少对应字。5.2 检查special tokens与词表对齐容易被忽略的坑使用Bert Tokenizer训练时经常出现词表里有[PAD]但配置里没写pad_token_id的情况。生成时HuggingFace会报错“pad token undefined”。更隐蔽的问题是add_tokens扩展新词后忘记调用resize_token_embeddings模型输出层的维度与tokenizer不一致。此时loss计算不会报错但生成结果里新词永远是大概率污染。一个快速检查方法是打印前几个token的embedding是否经过了训练更新emb model.get_input_embeddings().weight with torch.no_grad(): vec emb[tokenizer.convert_tokens_to_ids(新能源汽车)] print(vec[:10])如果这个向量与模型初始化时完全一样说明resize_token_embeddings之后没有run过训练步骤或者新token根本没参与训练。5.3 用一个小脚本快速对比两种tokenizer的中文编码差异如果训练集里两种tokenizer都试过可以在验证阶段直接用同一句话检查切分结果text 中文生成模型需要同时验证分词和生成效果 for name, tok in [(sp, sp_tokenizer), (bert, bert_tokenizer)]: ids tok.encode(text, add_special_tokensFalse) print(name, len(ids)) print(tok.convert_ids_to_tokens(ids))这段输出能明显看到SentencePiece会切出“中文”、“生成”、“模型”这样的词级token而Bert Tokenizer大概率切成一串单字。这个差异直接影响模型对长距离语义的建模能力。如果生成时的首token经常重复或跳词可以尝试把SentencePiece的vocab_size调大5000再训练一轮通常比增加模型层数更有效。本文还有配套的精品资源点击获取