中文GPT-2文本生成实战:从数据清洗到模型训练 简介一套基于Python和GPT-2实现中文文本生成的项目面向NLP初学者、算法工程师以及需要快速搭建中文生成原型的开发者可解决从零训练与调用中文GPT-2的常见难点。压缩包体积仅118KB共16个文件其中9个Python脚本构成核心工程覆盖数据预处理、模型微调、交互生成与MMI对话扩展另含词汇表、配置文件、代码忽略规则及模型结构示意图整体结构规范适合按模块拆解学习。目前已有4156人学习下载。项目示范了基于transformers库加载预训练英文模型并进行中文微调的完整流程包括中文分词与词表构建、PyTorch训练循环设计、损失函数与优化器配置、模型权重保存/加载以及基于起始文本的连续生成接口。此外还涉及编码解码、自注意力机制、GPU加速、序列化与量化压缩等关键知识点可结合源码理解每一步实现细节适合作为课程设计或中小型中文文本生成项目的实用参考。1. 中文 GPT-2 文本生成这个项目到底在做什么「基于 Python 的 GPT-2 中文文本生成模型项目实现」这句话拆开看是让你用一套完全本地化的技术栈把「输入一句上文、输出一句下文」的中文生成能力从零跑通。它不用你调任何云端 API而是把语料清洗、分词器训练、模型训练、采样生成这整条链路都握在自己手里。它适合两类人一是刚进 NLP 方向、想拿生成模型练手的学生二是要在内部系统里落地一个可控文本生成能力、又不想被外部服务绑定的工程师。常见落地形态有文案打底、对白生成、小说续写、评论模拟等。先说结论这项目的难点不在训练框架而在中文语料怎么处理、分词器怎么训练、生成参数怎么调。接下来按「选型 → 数据处理 → 训练 → 排查 → 进阶」这条线展开全程可复现。2. 为什么中文文本生成绕不开 GPT-2选型理由与最小骨架2.1 自回归生成为什么 GPT-2 天生就是续写模型文本生成任务里模型要回答的核心问题是给定前面的词下一个词最可能是什么。GPT-2 的架构把这个目标做到了极致——它是一个纯自回归模型把整句话的联合概率拆成每一步的条件概率之积P(x1, x2, ..., xn) P(x1) * P(x2|x1) * P(x3|x1,x2) * ...训练时它只做一件事预测下一个 token。因此它不需要像 BERT 那样做掩码、预测句子关系这些辅助任务所有能力都集中在「顺着上下文往下续写」上。中文场景恰恰最需要这种能力——中文的句间衔接、成语沿用、对仗排比本质上都是「根据前文推断后文」。相比后来更大规模的模型GPT-2 的优势是可控和便宜。你不需要几千张卡一张 8GB 显存的消费级显卡就能微调一个 117M 参数量的版本整个模型权重文件才几百兆部署到内网服务器毫无压力。对「要自己掌控数据、掌控模型」的项目来说它仍然是性价比最高的入门选择。2.2 模型尺寸选型117M 与 345M 的取舍GPT-2 系列有多个规格我实际用下来中文项目最常选的是 117M 和 345M 这两个档次。先看参数对比配置层数隐藏维度注意力头数参数总量单卡显存需求训练适合场景117M1276812约 1.17 亿8GB 勉强可行入门验证、短文本续写345M24102416约 3.45 亿16GB 起步风格化文案、更长生成774M36128020约 7.74 亿24GB 起步数据量充足的正式项目选 117M 还是 345M我给你的判断标准很简单如果你的语料总量不足 5GB、或显卡显存只有 8GB 左右直接选 117M。它训练一晚上能出效果迭代速度快你会有更多时间调数据和参数。反之你已经积累了几十 GB 高质量中文语料且要做产品级生成再上 345M。还有一点容易忽略模型参数的「语言先验」决定了它的知识上限。117M 模型对成语、典故的运用会明显弱于 345M这不是调参能补回来的是容量问题。所以你如果做的是小说续写这类对文采有要求的场景尽量给 345M 留出预算。2.3 用 transformers 搭出最小中文 GPT-2 骨架选型定下来后第一步是搭建模型的骨架。这里我用 Python 生态最通用的 transformers 库它把 GPT-2 的网络结构封装成了可直接调用的类。下面是最小可用的搭建代码from transformers import GPT2Config, GPT2LMHeadModel # 以 117M 标准配置为基础改成适合中文的词表与序列长度 config GPT2Config( vocab_size21128, # 词表大小中文常见做法是取 21128 n_positions512, # 最大位置编码长度 n_ctx512, # 上下文窗口长度 n_embd768, # 隐藏层向量维度 n_layer12, # Transformer 层数 n_head12, # 注意力头数 bos_token_id101, # 句子开头 token 的 id eos_token_id102, # 句子结尾 token 的 id pad_token_id0, # 填充 token 的 id ) model GPT2LMHeadModel(config) print(model.num_parameters()) # 输出参数量验证配置是否生效这段代码的关键点有三个。第一vocab_size必须和你后续训练的分词器词表大小完全一致否则模型加载预训练权重时会直接因为维度不匹配报错。第二n_ctx和n_positions保持一致它决定了模型能看到的文本长度——中文信息密度比英文高512 足够处理大多数续写场景强行加到 1024 会让训练时间和显存占用明显上升。第三bos_token_id和eos_token_id的值不是固定的它取决于你训练分词器时定义的特殊 token 顺序。很多人在这一步踩的第一个坑就是模型能建出来但不知道它只是个随机初始化的空壳。你没加载任何预训练权重所以现在拿它生成文本得到的是毫无意义的乱码。要让随机参数变成能续写中文的模型必须走完整的数据处理和训练流程后面两章就是干这件事的。3. 中文语料与分词器GPT-2 项目的地基工程3.1 分词路线字级、词级、BPE 三选一中文不像英文那样天然按空格分词所以做中文 GPT-2 必须先回答一个问题模型眼里「一句话」是怎么切开的。这里有三条路线我用实际效果给你排个序。字级分词最简单每个汉字当做一个 token。词表只需要几千字但代价是序列长度暴涨——512 个 token 只够装 512 个字模型很难学到跨长距离的句间关系训练成本也更高。词级分词要先跑 jieba 这类工具切词再把词映射成 token。问题是中文词表动辄几十万而且分词工具本身就引入了误差切错的词会被模型当成噪音学进去。更麻烦的是生成阶段遇到词表外的词OOV会直接翻车。现在的主流方案是 BPEByte Pair Encoding。它从字符合并成高频子词能自适应中文的构词规律常见字组合会被并成完整词生僻字则退回到单字级别兼顾了词表大小和序列长度。我给你的结论是除非你在做的是非常特殊的字级任务否则别犹豫直接选 BPE。3.2 在中文语料上训练一个 BPE 分词器GPT-2 原始实现用的是 ByteLevel BPE——它先把所有文本按 UTF-8 转成字节再做 BPE 合并。这么做的好处是永远不会遇到未知字符任何中文、表情符号、特殊符号都能被编码。我们用 tokenizers 库在中文语料上训练一个自己的 BPEfrom tokenizers import ByteLevelBPETokenizer # 初始化 ByteLevel BPE 分词器 tokenizer ByteLevelBPETokenizer() # 训练语料是一个纯文本文件一行一段UTF-8 编码 tokenizer.train( files[corpus.txt], # 你的清洗后中文语料 vocab_size21128, # 和模型 config 的 vocab_size 对齐 min_frequency2, # 出现次数低于 2 次的片段丢弃 special_tokens[[BOS], [EOS], [UNK], [PAD]], ) # 保存分词器配置后续训练和推理都用这份配置 tokenizer.save(tokenizer.json) # 验证效果看看「自然语言处理」被切成什么样子 output tokenizer.encode(自然语言处理) print(output.tokens)分词器训练完必须干一件事打印几个句子的切分结果人工确认词表质量。中文语料里常见的切分问题是「自然语言处理」被切成「自 然 语 言 处 理」而不是「自然 语言 处理」这说明词表太小或者语料不够可以适当调大vocab_size再试。这里有个容易忽略的细节vocab_size不是越大越好。词表 5 万确实能装下更多常见词但会让输出层的 softmax 计算变慢训练时间肉眼可见地涨。21128 这个值是从中文 BERT 沿用的经验值对大多数 GPT-2 中文项目够用。3.3 语料清洗让训练不翻车的四个预处理步骤你可以直接跳过语料清洗去训练但结果通常是模型损失降了生成出来的文本里满是“”和网址或者整段完全没有标点。我处理中文语料有一套固定流程每个步骤都是踩过坑换来的。第一步统一编码与转义。全部转成 UTF-8把全角数字字母转成半角中文引号统一成「」或“”否则同一句话会出现多种写法白白浪费词表容量。第二步剔除噪音行去掉含 http、www 的链接行去掉 HTML 标签残留去掉连续重复超过 20 次的标点行这些噪音会让模型学会输出垃圾。第三步按句清洗删除长度小于 10 个字的碎片行删除中文字符占比低于 60% 的行常见于混入的英文或代码片段合并断行——很多爬下来的文本每句话都被硬切成了两行。第四步段落重排把清洗后的句子按原文顺序重新拼成段落段落之间用空行隔开。模型对段落结构非常敏感你在这一步处理得越干净后面生成的结果越像人写的。清洗脚本看起来简单实际上 80% 的项目时间都耗在这。你可以先拿 10MB 语料做完整流程测试确认分词质量后再全量跑避免一晚上训练完才发现数据有问题。3.4 滑动窗口构造训练样本语料清洗完还需要把它切成模型能吃的训练样本。GPT-2 的输入是定长序列比如 512 个 token超过的截断、不足的补齐。但如果只是简单从每段开头硬切句子会被从中间砍断模型会学到「半句话开头、半句话结尾」的错误模式。我一般用滑动窗口def build_samples(texts, tokenizer, block_size512, stride256): 把长文本用滑动窗口切成定长训练样本 block_size: 模型输入的 token 长度 stride: 窗口每次滑动的步长一般取 block_size 的一半 samples [] for text in texts: # 加上句子开始和结束标记让模型学会识别文本边界 encoded tokenizer.encode([BOS] text [EOS]) tokens encoded.ids if len(tokens) block_size: samples.append(tokens) continue # 超出长度的部分按 stride 滑动切片相邻样本有重叠 for i in range(0, len(tokens) - block_size 1, stride): samples.append(tokens[i : i block_size]) return samples这段逻辑里有两个参数要说明。stride256意味着相邻两个样本有一半的 token 是重叠的好处是长文本中任何位置的句子都不会被硬生生截断模型能看到完整的语义单元坏处是会产生约一倍的数据冗余训练轮数不变的情况下每个 epoch 的 step 数会变多。如果你数据量很充足可以把 stride 提高到 512直接等价于无重叠硬切省训练时间。[BOS]和[EOS]标记在生成阶段也至关重要。训练时模型学到「看到 EOS 就停止输出」推理时你在 prompt 前加上 BOS生成的结尾才会自然收住而不是永远续写下去。4. 训练与生成跑通中文 GPT-2 的最小闭环4.1 从零训练还是继续预训练两条路线怎么选骨架搭好、数据备齐之后面临一个路线问题是从随机初始化开始从零训练还是在已有的中文 GPT-2 权重基础上继续训练。这个选择直接决定你要训练多久、效果下限在哪。判断条件从零训练加载已有权重继续训练语料规模10GB 以上1GB 以上即可计算资源多卡训练数周单卡训练 1~2 天生成质量下限前几个 epoch 全是乱码一开始就能生成通顺句子领域适配度完全由你的语料决定需要一定步数迁移到目标领域我的建议很直接除非你有几十 GB 且非常垂直的语料、并且明确要为这个领域训练专属模型否则一律选择加载已有的中文 GPT-2 权重继续训练。这个方案把「让模型学会中文」的成本省掉了你只需要花算力让模型适配你的领域数据成本低一个量级效果却通常是更好的——已有权重积累的通用语言先验是那几十 GB 通用语料替你付过的学费。4.2 训练参数必调的 5 个参数与推荐范围从零训练和继续训练在参数设置上有差别但下面这几个参数是我每次必调的直接抄表参数推荐值说明与理由per_device_train_batch_size8 ~ 16单卡显存允许的范围内取最大OOM 就减半gradient_accumulation_steps2 ~ 8等效扩大 batch size稳定梯度但不增加显存峰值learning_rate5e-5 ~ 3e-4继续训练取下限从零训练取上限太大直接发散warmup_steps500 ~ 2000前几步让学习率从 0 线性上升防止初期震荡max_grad_norm1.0梯度裁剪中文长句容易让梯度爆炸这参数救过我好几次还有一个容易踩的配置padding。GPT-2 的原始分词器没有 pad token所以每个 batch 里的不同长度样本只能靠右侧补齐。我习惯把 pad token 直接设成 eos token并用 attention mask 把 padding 位置遮住否则模型会学到「句子结尾后面跟着一堆结束符」这种错误模式。4.3 最小训练脚本基于 transformers Trainer搭建完整训练流程用 transformers 库的 Trainer 是最快路径。它帮你处理了梯度累积、warmup、checkpoint 保存、日志打印这些杂活你只需要准备好数据集和参数from transformers import ( GPT2LMHeadModel, GPT2Tokenizer, Trainer, TrainingArguments, DataCollatorForLanguageModeling, ) from datasets import Dataset # 加载第 3 章训练好的分词器和模型骨架 tokenizer GPT2Tokenizer.from_pretrained(./tokenizer) tokenizer.pad_token tokenizer.eos_token # 关键设置 pad token否则 batch 会报错 model GPT2LMHeadModel.from_pretrained(./gpt2-chinese) # 把第 3 章滑动窗口切好的样本封装成 Dataset dataset Dataset.from_dict({input_ids: all_samples}) # 自动构建 labelsGPT-2 的 labels 就是 input_ids 本身模型内部会移位 data_collator DataCollatorForLanguageModeling( tokenizertokenizer, mlmFalse ) training_args TrainingArguments( output_dir./output, overwrite_output_dirTrue, num_train_epochs3, per_device_train_batch_size8, gradient_accumulation_steps4, learning_rate5e-5, warmup_steps1000, max_grad_norm1.0, logging_steps100, save_steps2000, fp16True, # 混合精度显存直接省一半速度翻倍 ) trainer Trainer( modelmodel, argstraining_args, train_datasetdataset, data_collatordata_collator, ) trainer.train() trainer.save_model(./output/final)这里的核心逻辑要说清楚DataCollatorForLanguageModeling里设mlmFalse它会自动把 input_ids 复制一份当作 labels而 GPT-2 模型的内部前向传播会自动移位——对第 t 个位置的输出计算的损失是它预测第 t1 个 token 的误差这正是自回归语言模型的训练方式。如果你的显存只有 8GB把per_device_train_batch_size降到 4gradient_accumulation_steps提到 8等效 batch size 仍然是 32训练效果不会差。如果连 4 都跑不动那就要回到第 2 章把n_ctx从 512 改成 256这一步能省超过一半显存。4.4 生成参数温度、Top-k、Top-p 的调参逻辑训练完成后生成文本用的采样策略对效果影响巨大。模型推理时输出的不是一个词而是整个词表上的概率分布。如果每次都取概率最大的 token生成结果会陷入死循环式的重复如果完全按概率随机采样结果会混乱无逻辑。所以要用采样参数在这两者之间找平衡input_ids tokenizer.encode([BOS]人工智能的未來在于, return_tensorspt) # 用 generate 方法做自回归生成 output model.generate( input_ids, max_new_tokens200, # 最多生成的新 token 数 do_sampleTrue, # 启用随机采样否则退化成贪心搜索 temperature0.8, # 控制概率分布的尖锐程度 top_k40, # 只在概率最高的 40 个 token 里采样 top_p0.9, # 累积概率达到 0.9 的最小 token 集合里采样 repetition_penalty1.1, # 对已出现 token 的得分做惩罚抑制复读 eos_token_idtokenizer.encode([EOS])[0], # 遇到结束符就停止 ) print(tokenizer.decode(output[0], skip_special_tokensTrue))四个参数的分工用几句话讲明白。temperature大于 1 让分布更平缓、输出更随机小于 1 更保守、更贴训练数据风格中文文案生成我一般锁在 0.7~0.9。top_k限制候选范围过滤掉那些概率极低但偶尔冒出来的生僻字top_p则是按累积概率动态筛选——两者可以同时开实际效果是给采样加了两道安全网。repetition_penalty是复读的后悔药设为 1.1 时模型对已经写过的 token 打一点折扣分连续复读的问题会显著缓解但如果设到 1.3 以上生成会变得前言不搭后语。生成这步网上能搜到很多「玄学调参组合」我的经验是以 temperature 为主、top_k 和 top_p 为辅一次只动一个变量用一批固定 prompt 做对比而不是同时调多个参数然后凭感觉判断。5. 中文 GPT-2 项目常见问题排查现象、原因与解法5.1 显存溢出OOM 不只是 batch size 的事现象训练刚开始没几步直接报CUDA out of memory。新手第一反应是把 batch size 从 8 砍到 4再砍到 1还是溢出。原因显存占用的大头其实是激活值和中间变量不是模型参数本身。当n_ctx512时每个样本有 512 个位置、每个位置都要过 12 层 Transformer这部分激活值在反向传播前全部驻留显存。很多人忽略了序列长度这个真正的显存杀手。解决优先把序列长度从 512 降到 256这一步通常能把峰值显存砍掉一半同时开启 fp16 混合精度显存再省一半。这两个手段比单压 batch size 有效得多推荐组合是block_size256 fp16True batch_size88GB 显存足够跑 117M 模型。5.2 生成结果全是重复标点或乱码现象模型训练完成生成的文本里大量出现“的的的的的”或“。。。。。。”甚至出现“”这样的乱码字符。原因重复标点多半是采样参数问题——repetition_penalty没设或temperature设得太高模型在概率空间中反复横跳。乱码字符的情况先确认分词器 encode 时是不是丢了[UNK]的映射再看语料清洗时是否过滤掉了非 UTF-8 字符。解决生成参数先固定temperature0.8, top_k40, repetition_penalty1.2如果连续复读还压不住把repetition_penalty提高到 1.5 再试。乱码则回头检查第 3.3 的清洗步骤确认所有文本在喂给分词器之前都是干净的 UTF-8逐个排查比调参效率高得多。5.3 训练损失不降甚至发散现象loss 在前几百步忽高忽低然后一路飙升到 nan。或者 loss 掉到 5.0 左右就卡住三个小时不动。原因发散通常是学习率太大。中文长文本的自回归任务里梯度范数波动剧烈学习率一小幅上调就可能直接击穿训练稳定点。loss 卡住则多半是数据问题——滑动窗口切出来的样本里大量样本的开头是上一句的半截模型学不到稳定的上下文规律。解决先加max_grad_norm1.0做梯度裁剪再把学习率降一半重跑一轮。loss 卡住不动的去检查样本质量打印 50 条训练样本人工看一眼如果超过一半是「断头断尾」的句子把滑动窗口的 stride 从 256 降回 128让每个样本都能覆盖完整的句群。5.4 生成内容语法通顺但完全没有逻辑现象模型输出的一句话单独看没问题主谓宾齐全但前后两句之间毫无关联像是一个字一个字凑出来的。原因模型容量不足。117M 参数能学会词法和句法但跨句推理、指代消解、篇章连贯这些能力需要更大的参数量和学习更久的训练。另一种可能是语料本身就有问题——如果你清洗后的语料都是短碎片模型根本没见过长段落自然学不会长篇连贯。解决换两个思路。第一从零训练的模型尝试加载已有中文权重继续训练让模型把这部分通用篇章能力直接继承过来第二如果必须从零训练把语料里长度小于 50 个字的行全部去掉确保模型学习的样本都是完整段落训练轮数可以适当地多跑几个 epoch。5.5 加载模型报错分词器与模型 vocab 对不上现象运行from_pretrained恢复模型时报tokenizer的词表大小和模型 embedding 维度不一致或 vocab 文件里有重复 token模型根本无法加载。原因训练分词器时vocab_size21128但 BPE 算法实际生成词表会因为特殊 token 的加入变成 21132 之类多出来的几个 id 就是[BOS]、[EOS]这些特殊 token。如果你在搭建模型骨架时把vocab_size写死成 21128模型和分词器之间就差了几个维度。另一个低频原因是训练语料里有重复的空白字符导致训练出的词表含多个长得一样的 token。解决不要手工猜词表大小训练完分词器后直接读len(tokenizer)拿真实词表长度再回头建模型。GPT-2 的 embedding 和输出层会共享这份词表这样两边永远对齐。顺带把tokenizer.save(tokenizer.json)和模型 config 放在同一目录保证以后加载用的都是同一份配置。6. 把中文 GPT-2 用进真实场景的三个习惯6.1 用 prompt 模板稳定输出风格直接丢一句「我喜歡冬天」给模型它可能回一句干巴巴的「我喜歡冬天但是夏天也很好」。但用模板「请用散文的笔触续写我喜歡冬天」输出质量会明显上一个台阶。不是模型变聪明了而是训练语料里「请……续写」这种模式必然导向书面化的样本模型被 prompt 带进了对应的风格区间。我建议按你的目标场景准备 20~30 个固定的 prompt 模板逐条测试一轮找到最稳定的那个。6.2 用 top_k 动态衰减压住短尾场景的复读生成短文本比如一句话文案和长文本比如小说段落的最优采样参数其实不同。短内容允许稍高的随机性来求惊艳长内容必须更保守防止失控。我习惯对超过 300 token 的生成把 temperature 从 0.9 线性降到 0.7、top_k 从 50 降到 20for step in range(max_new_tokens): ratio step / max_new_tokens temperature 0.9 - 0.2 * ratio top_k 50 - 30 * ratio outputs model.generate( input_ids, max_new_tokens1, do_sampleTrue, temperaturetemperature, top_ktop_k, repetition_penalty1.2, )缺点是逐 token 调用 generate 会慢一些但换来的是长文本后半段不会陷入无意义复读值得。6.3 用困惑度 PPL 量化生成质量生成效果不能只靠肉眼感觉。我习惯用困惑度PPL做离线回归对同一批测试 prompt对比不同 checkpoint 在同一批输出上的平均 PPL数值越低说明模型对文本的把握越有信心import torch def cal_ppl(model, tokenizer, text): inputs tokenizer(text, return_tensorspt) with torch.no_grad(): loss model( inputs[input_ids], labelsinputs[input_ids] ).loss return torch.exp(loss).item() # 用多个固定句子求平均作为当前模型的质量指标 test_texts [今天天气很好适合出去散步。, 他翻开书发现里面夹着一张旧照片。] avg_ppl sum(cal_ppl(model, tokenizer, t) for t in test_texts) / len(test_texts)PPL 也有它的边界它衡量的是模型对输入文本的「熟悉程度」不是语义正确性所以必须配合人工抽检不能只看数字。我通常每次训练完用同一批测试文本跑一遍 PPL记录在实验日志里再和上一版对比。PPL 跌了就说明这次改动有效涨了就回头查参数或数据这样迭代就不会雾里看花。这个项目做到最后你会发现真正花时间的永远是数据清洗和调参的经验库而不是模型本身。我也曾在一遍遍试参数时怀疑是不是模型结构写错了最后发现只是训练样本切得太碎。每次动手改一个变量、记录一组结果经验就是这么攒下来的。希望帮到你。本文还有配套的精品资源点击获取