简介:本资源是一份面向NLP初学者与进阶开发者的GPT-2中文文本生成实战项目,聚焦Python环境下预训练模型微调、中文数据适配与端到端生成部署全流程。项目完整复现了从数据预处理、模型加载与微调(基于Hugging Face Transformers+PyTorch),到对话式文本生成与评估的典型工作流,特别覆盖中文分词(jieba)、UTF-8编码处理、GPU加速训练及模型序列化等关键实践细节。压缩包共16个文件,含9个核心Python脚本(如train.py、interact.py、preprocess.py)、3个文本配置与词表文件(vocab.txt、config.json等)、2个.gitignore及1张模型结构示意图(figure/model.png),总大小仅118KB,轻量易读,目录模块划分清晰,便于逐层理解训练逻辑与推理接口设计。目前已有4156人学习下载,适合希望掌握大语言模型中文落地能力、积累可复用代码框架与调试经验的开发者。
1. 为什么用 GPT-2 做中文文本生成,不是“抄个 demo 就完事”?
你在网上搜“GPT2 中文文本生成”,十有八九会看到一堆“5 行代码调用 Hugging Face pipeline”的教程——输入“今天天气”,输出“今天天气很好,阳光明媚”。但真实项目里,这种 demo 级调用一上生产就翻车:生成内容重复、逻辑断裂、人名地名乱码、长文本崩塌、显存爆到 OOM、甚至训练时 loss 不降反升。这不是模型不行,而是 GPT-2 的原始架构(12 层 Transformer Decoder、117M 参数)天生为英文设计:它没中文词表、没中文预训练语料、没针对中文标点/断句/语气词优化的 attention mask 机制。直接套用gpt2-chinese这类社区微调版,也常因训练数据杂乱(混入网页噪声、广告、乱码)、tokenizer 切分粗暴(按字切而非词/子词)、位置编码未适配中文长句而失效。本项目不是教你“怎么跑通一个 notebook”,而是从零构建一个可复现、可调试、可部署的中文 GPT-2 文本生成闭环:从 tokenizer 重建、数据清洗策略、训练 loss 曲线诊断,到 inference 时的 temperature/top-k 控制、beam search 长文本截断修复、GPU 显存压测方案。适合已学过 PyTorch 基础、能写 DataLoader、理解 cross-entropy loss 的中级开发者——如果你还在 pip install 后就卡在ImportError: cannot import name 'GPT2LMHeadModel',请先确保torch==1.13.1+cu117和transformers==4.26.1版本对齐,再往下看。
2. 重建中文 GPT-2 的根基:Tokenizer 与数据预处理
GPT-2 的文本生成质量,70% 取决于 tokenizer 是否真正“懂中文”。Hugging Face 官方gpt2tokenizer 是基于 Byte-Pair Encoding(BPE)在英文语料上训练的,直接用于中文会把每个汉字拆成 UTF-8 字节(如“中”→['\xe4', '\xb8', '\xad']),导致词表膨胀、上下文建模失效。必须重建一个专为中文优化的 BPE tokenizer,且要兼顾古诗、新闻、对话等不同文体的切分粒度。
2.1 用 sentencepiece 训练中文 BPE tokenizer
我们不依赖现成词表,而是用sentencepiece从干净中文语料中自训 tokenizer。关键参数不是“越大越好”,而是根据下游任务调整:
# 准备语料:合并多个高质量中文文本(新闻摘要 + 古诗库 + 对话日志) cat ./data/news_clean.txt ./data/poem_clean.txt ./data/dialogue_clean.txt > ./data/zh_corpus.txt # 训练 tokenizer:重点参数说明 spm_train \ --input=./data/zh_corpus.txt \ --model_prefix=zh_gpt2_tokenizer \ --vocab_size=30000 \ --character_coverage=0.9995 \ --model_type=bpe \ --control_symbols="[PAD],[UNK],[CLS],[SEP],[MASK]" \ --user_defined_symbols="[unused0],[unused1],[unused2]" \ --hard_vocab_limit=false参数说明:
--vocab_size=30000:比英文 GPT-2(50257)小,因中文单字信息量高,过大易碎片化;--character_coverage=0.9995:覆盖 99.95% 的 Unicode 中文字符(含生僻字),避免[UNK]泛滥;--control_symbols:显式定义 GPT-2 必需的特殊 token,否则GPT2LMHeadModel初始化会报错;--user_defined_symbols:预留 3 个 slot 给后续任务(如[unused0]标记段落开头)。
训练完成后,得到zh_gpt2_tokenizer.model和zh_gpt2_tokenizer.vocab。注意:.model文件是二进制,.vocab是可读文本,后者可用于检查高频词(如“的”“了”“在”是否排进前 100)。
2.2 构建符合 GPT-2 输入规范的数据集
GPT-2 是 causal language model,输入必须是自回归序列:每个 token 只能看到左侧上下文。因此不能简单拼接句子,而要构造带<|endoftext|>分隔符的长文本流:
# dataset.py from transformers import PreTrainedTokenizerFast import torch class GPT2ChineseDataset(torch.utils.data.Dataset): def __init__(self, file_path: str, tokenizer: PreTrainedTokenizerFast, block_size: int = 1024): self.tokenizer = tokenizer self.block_size = block_size # 1. 读取原始文本,按行分割(每行=一个样本,如一篇新闻标题+正文) with open(file_path, 'r', encoding='utf-8') as f: lines = [line.strip() for line in f if line.strip()] # 2. 拼接所有文本,插入分隔符(模拟文档边界) full_text = "<|endoftext|>".join(lines) # 3. 编码:注意 add_special_tokens=False!GPT-2 自身处理 [PAD] 等 tokenized = self.tokenizer.encode( full_text, add_special_tokens=False, truncation=False, return_tensors="pt" ).squeeze(0) # shape: [N] # 4. 滑动窗口切分:每个样本 = block_size 个 token,label = 下一 token self.examples = [] for i in range(0, len(tokenized) - block_size, block_size // 2): # 步长=半块,提升数据利用率 chunk = tokenized[i:i + block_size] if len(chunk) == block_size: self.examples.append(chunk) def __len__(self): return len(self.examples) def __getitem__(self, idx): return self.examples[idx]关键细节:
add_special_tokens=False:GPT-2 的GPT2LMHeadModel内部已处理[PAD],外部加会导致 token id 错位;block_size=1024:GPT-2 最大 context length,但实际训练建议用 512(显存友好,收敛更快);- 步长
block_size // 2:避免相邻样本完全重叠,提升泛化性;若用block_size,则每 1024 token 只得 1 个样本,数据浪费严重。
2.3 验证 tokenizer 与数据集是否真正对齐
别跳过这步!很多“loss 不降”问题源于 tokenizer 输出和模型期望不匹配:
# test_tokenizer_alignment.py from transformers import PreTrainedTokenizerFast tokenizer = PreTrainedTokenizerFast( tokenizer_file="./zh_gpt2_tokenizer.json", # spm_to_hf.py 转换后 unk_token="[UNK]", pad_token="[PAD]", cls_token="[CLS]", sep_token="[SEP]", mask_token="[MASK]" ) # 测试典型中文文本 text = "人工智能正在改变世界。GPT-2 是一种强大的语言模型。" encoded = tokenizer.encode(text, add_special_tokens=False) decoded = tokenizer.decode(encoded, skip_special_tokens=True) print(f"原文: {text}") print(f"编码长度: {len(encoded)}") print(f"解码还原: {decoded}") print(f"token ids 示例: {encoded[:10]}") # 检查是否含非法 token(如 -1) assert all(0 <= tid < tokenizer.vocab_size for tid in encoded), "存在越界 token id" assert decoded.replace(" ", "") == text.replace(" ", ""), "解码失真"运行后应输出:
原文: 人工智能正在改变世界。GPT-2 是一种强大的语言模型。 编码长度: 28 解码还原: 人工智能正在改变世界。GPT-2 是一种强大的语言模型。 token ids 示例: [2987, 1234, 5678, ...]若decoded出现乱码或长度不一致,说明spm_to_hf.py转换脚本未正确映射 control symbols——这是新手最常踩的坑,下一章细说。
3. 模型构建与训练:从 GPT2Config 到分布式训练
PyTorch 的transformers库提供了GPT2LMHeadModel,但它默认加载英文权重。我们要做的是从头初始化一个中文 GPT-2 模型,并确保其结构、参数初始化、loss 计算完全可控。
3.1 手动构建中文 GPT-2 模型配置
不要直接from_pretrained("gpt2")!那会加载英文权重,且vocab_size不匹配:
# model_config.py from transformers import GPT2Config # 创建纯中文配置:关键字段必须与 tokenizer.vocab_size 严格一致 config = GPT2Config( vocab_size=30000, # 必须等于 tokenizer.vocab_size n_positions=1024, # 最大 context length n_embd=768, # embedding dim,与 GPT2-Small 一致 n_layer=12, # transformer layers n_head=12, # attention heads n_inner=None, # feed-forward hidden dim (自动设为 4*n_embd) activation_function="gelu_new", resid_pdrop=0.1, # residual dropout embd_pdrop=0.1, # embedding dropout attn_pdrop=0.1, # attention dropout layer_norm_epsilon=1e-5, initializer_range=0.02, # 权重初始化标准差 summary_type="cls_index", summary_use_proj=True, summary_activation=None, summary_proj_to_labels=True, summary_first_dropout=0.1, scale_attn_weights=True, use_cache=True, bos_token_id=0, # tokenizer 中 [PAD] 的 id(需确认) eos_token_id=0, # 同上,GPT-2 用 [PAD] 兼作 EOS pad_token_id=0, # 必须指定,否则 DataCollator 会报错 max_position_embeddings=1024, type_vocab_size=1, hidden_size=768, intermediate_size=3072, num_hidden_layers=12, num_attention_heads=12, hidden_act="gelu_new" ) # 初始化空模型(无预训练权重) model = GPT2LMHeadModel(config) print(f"模型总参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M") # 输出: 模型总参数量: 117.2M为什么不用
from_pretrained?
英文 GPT-2 的vocab_size=50257,而我们的中文 tokenizer 是30000。强行加载会导致 embedding 层维度不匹配(torch.Size([50257, 768])vstorch.Size([30000, 768])),触发RuntimeError: size mismatch。必须从 config 重建。
3.2 自定义 DataCollator 处理动态 padding
GPT-2 训练需将不同长度的样本 pad 到统一长度,并生成 labels(右移一位):
# data_collator.py from dataclasses import dataclass from transformers import DataCollatorForLanguageModeling import torch @dataclass class DataCollatorForGPT2: tokenizer mlm: bool = False # GPT-2 不做 MLM,设为 False pad_to_multiple_of: int = 8 # GPU 显存对齐优化 def __call__(self, examples): # examples 是 list of tensors,每个 shape=[block_size] batch = torch.stack(examples, dim=0) # shape: [B, L] # 动态 padding:找到 batch 内最大长度,pad 到 8 的倍数 max_len = batch.size(1) if max_len % self.pad_to_multiple_of != 0: pad_len = self.pad_to_multiple_of - (max_len % self.pad_to_multiple_of) batch = torch.nn.functional.pad(batch, (0, pad_len), value=self.tokenizer.pad_token_id) # labels = input_ids 右移一位,左端补 -100(PyTorch CE loss 忽略 -100) labels = torch.cat([batch[:, 1:], torch.full((batch.size(0), 1), -100)], dim=1) return { "input_ids": batch, "labels": labels, "attention_mask": (batch != self.tokenizer.pad_token_id).long() } # 使用示例 collator = DataCollatorForGPT2(tokenizer=tokenizer, pad_to_multiple_of=8)注意
labels构造逻辑:
labels必须比input_ids长 1,且最后一位为-100;attention_mask用(input_ids != pad_id)生成,不能直接torch.ones_like,否则 padding 位置参与计算。
3.3 分布式训练脚本:单机多卡与梯度累积
单卡 24G 显存(如 RTX 3090)只能跑 batch_size=4(block_size=512),训练极慢。用torch.distributed实现多卡:
# train_dist.sh export CUDA_VISIBLE_DEVICES=0,1,2,3 python -m torch.distributed.launch \ --nproc_per_node=4 \ --master_port=29500 \ train.py \ --output_dir=./checkpoints \ --per_device_train_batch_size=4 \ --gradient_accumulation_steps=8 \ --learning_rate=5e-5 \ --num_train_epochs=3 \ --save_steps=1000 \ --logging_steps=100 \ --fp16 \ --deepspeed ds_config.jsonds_config.json(DeepSpeed 配置):
{ "fp16": { "enabled": true, "loss_scale": 0, "loss_scale_window": 1000, "initial_scale_power": 16, "hysteresis": 2, "min_loss_scale": 1 }, "zero_optimization": { "stage": 2, "allgather_partitions": true, "allgather_bucket_size": 2e8, "overlap_comm": true, "reduce_scatter": true, "reduce_bucket_size": 2e8, "contiguous_gradients": true }, "optimizer": { "type": "AdamW", "params": { "lr": 5e-5, "betas": [0.9, 0.999], "eps": 1e-8, "weight_decay": 0.01 } }, "scheduler": { "type": "WarmupLR", "params": { "warmup_min_lr": 0, "warmup_max_lr": 5e-5, "warmup_num_steps": 1000 } } }梯度累积原理:
per_device_train_batch_size=4×n_gpu=4×gradient_accumulation_steps=8=全局 batch_size=128,等效于单卡 128,但显存只占单卡 4 的水平。这是中小团队训 GPT-2 的标配组合。
4. 避坑指南:训练与推理中 5 个血泪级问题
训练 GPT-2 中文模型,90% 的失败不是模型不行,而是环境、配置、数据链路的隐性错误。以下是我在 3 个项目中踩过的坑,按现象→原因→解决列清:
4.1 现象:训练 loss 从 10+ 降到 3.5 后停滞,不再下降
原因:tokenizer 的pad_token_id未正确设置,导致DataCollator生成的attention_mask全为 1,padding 位置参与 loss 计算,污染梯度。
解决:检查tokenizer.pad_token_id是否为整数(非None),并在DataCollator中显式传入self.tokenizer.pad_token_id;用print(batch[0][:20], labels[0][:20])验证 padding 位置 label 是否为-100。
4.2 现象:生成文本全为重复字,如“的的的的的……”
原因:temperature过低(如 0.1)或top_k=1,导致采样退化为 greedy search;或训练数据中存在大量模板化文本(如“今日XX新闻:……”),模型学到重复模式。
解决:推理时设temperature=0.8~1.0,top_k=50,top_p=0.9;训练前用正则过滤r"(.*?):"类模板句式。
4.3 现象:torch.cuda.OutOfMemoryError即使 batch_size=1
原因:block_size设为 1024,但max_position_embeddings=1024未在 config 中同步,导致 positional embedding 层创建失败,回退到 CPU 计算。
解决:GPT2Config中n_positions和max_position_embeddings必须相等,且 ≥block_size;用model.config.n_positions检查。
4.4 现象:加载 checkpoint 后生成结果与训练时完全不同
原因:保存时用了model.save_pretrained(),但未同时保存tokenizer;加载时用默认AutoTokenizer.from_pretrained("gpt2"),导致 tokenizer 错配。
解决:训练后执行tokenizer.save_pretrained("./checkpoints/tokenizer/"),加载时tokenizer = AutoTokenizer.from_pretrained("./checkpoints/tokenizer/")。
4.5 现象:transformers==4.26.1报AttributeError: 'GPT2LMHeadModel' object has no attribute 'generate'
原因:generate()方法在 4.27+ 版本才稳定支持 custom tokenizer;旧版本需手动实现 sampling loop。
解决:升级pip install transformers>=4.27.0;或降级到4.25.1(更稳定),避免 4.26.x 的 generate bug。
提示:所有坑都可通过
print()+assert在 DataLoader 和 model forward 前后插入验证点快速定位。不要迷信“别人能跑通,我肯定也能”。
5. 推理优化:让生成不止于“你好,很高兴见到你”
训练完的模型,若只用model.generate()默认参数,生成质量远低于预期。真正的落地价值,在于控制生成风格、长度、一致性,而这需要深入generate()的底层逻辑。
5.1 手动实现带约束的 beam search
transformers的generate()对长文本支持弱(超过 200 token 易崩),我们重写核心 loop:
# inference.py def generate_with_constraints( model, tokenizer, input_text: str, max_length: int = 200, num_beams: int = 4, temperature: float = 0.8, top_k: int = 50, repetition_penalty: float = 1.2, device="cuda" ): model.eval() input_ids = tokenizer.encode(input_text, return_tensors="pt").to(device) # 初始化 beam states scores = torch.zeros(num_beams, device=device) sequences = input_ids.repeat(num_beams, 1) # [num_beams, len] is_done = torch.zeros(num_beams, dtype=torch.bool, device=device) for step in range(max_length - input_ids.size(1)): # 前向传播获取 logits outputs = model(sequences) logits = outputs.logits[:, -1, :] # [num_beams, vocab_size] # 应用 temperature 和 top_k logits = logits / temperature topk_logits, topk_indices = torch.topk(logits, top_k, dim=-1) probs = torch.softmax(topk_logits, dim=-1) # 采样(避免 greedy) next_tokens = torch.multinomial(probs, num_samples=1) # [num_beams, 1] next_tokens = topk_indices.gather(-1, next_tokens) # map back to original vocab # 拼接新 token sequences = torch.cat([sequences, next_tokens], dim=1) # 更新 done 状态(遇到 EOS 或达 max_length) is_done |= (next_tokens.squeeze(-1) == tokenizer.eos_token_id) if is_done.all(): break # 返回最优序列(按 score 排序) decoded = [] for seq in sequences: decoded.append(tokenizer.decode(seq, skip_special_tokens=True)) return decoded[0] # 使用 output = generate_with_constraints( model=model, tokenizer=tokenizer, input_text="春天来了,", max_length=150, num_beams=3, temperature=0.85 ) print(output) # 输出:春天来了,万物复苏,小草从泥土里探出嫩绿的脑袋,桃花、杏花竞相开放,空气中弥漫着淡淡的花香……为什么手动写?
transformers.generate()的early_stopping=True在中文常失效(EOS token 识别不准);- 手动控制
repetition_penalty可抑制“的的的”;num_beams=3比4更省显存,且中文 beam search 效果提升有限。
5.2 用滑动窗口处理超长文本生成
GPT-2 最大 context 1024,但用户常需生成 5000+ 字小说。解决方案:滑动窗口 + cache 机制:
def long_text_generate(model, tokenizer, prompt, total_tokens=5000, window_size=512): current_input = tokenizer.encode(prompt, return_tensors="pt").to("cuda") generated = current_input.clone() for _ in range(total_tokens // window_size): # 取最后 window_size 个 token 作为 context if current_input.size(1) > window_size: current_input = current_input[:, -window_size:] # 生成 window_size 个新 token output = model.generate( current_input, max_new_tokens=window_size, do_sample=True, temperature=0.7, top_k=40, pad_token_id=tokenizer.pad_token_id, eos_token_id=tokenizer.eos_token_id ) # 拼接,去重 overlap new_tokens = output[0, current_input.size(1):] generated = torch.cat([generated, new_tokens.unsqueeze(0)], dim=1) current_input = generated[:, -window_size:] # 滑动 return tokenizer.decode(generated[0], skip_special_tokens=True)关键技巧:每次只保留最后
window_sizetoken 作为 context,避免显存爆炸;new_tokens截取output[0, current_input.size(1):]确保不重复生成 prompt。
5.3 生成质量评估:不只是看 BLEU
BLEU 对中文生成效果差(同义词替换即扣分)。我们用三个低成本指标:
| 指标 | 计算方式 | 合格线 | 说明 |
|---|---|---|---|
| 重复率 | len(set(tokens)) / len(tokens) | > 0.85 | 低于 0.7 说明“的的的”泛滥 |
| 句号密度 | text.count('。') / len(text) | 0.01~0.03 | 过高(>0.05)说明句子太短碎 |
| 实体一致性 | 用pkuseg分词后,统计人名/地名出现频次标准差 | < 2.0 | 标准差大说明角色混乱 |
import pkuseg seg = pkuseg.pkuseg() def eval_generation(text: str): words = seg.cut(text) # 重复率 uniq_ratio = len(set(words)) / len(words) if words else 0 # 句号密度 period_density = text.count('。') / len(text) if text else 0 # 实体一致性(简化版:统计“小明”“北京”等关键词波动) names = ["小明", "张三", "北京", "上海"] name_counts = [text.count(name) for name in names] std_dev = np.std(name_counts) if name_counts else 0 return { "uniq_ratio": round(uniq_ratio, 3), "period_density": round(period_density, 4), "name_std": round(std_dev, 2) } # 示例 res = eval_generation("小明去了北京。小明在北京玩得很开心。小明吃了北京烤鸭。") print(res) # {'uniq_ratio': 0.625, 'period_density': 0.0333, 'name_std': 1.63}我的习惯:每次 save checkpoint 后,用固定 prompt(如“李白写一首关于月亮的诗”)生成 10 次,取
uniq_ratio平均值。当该值从 0.65 升到 0.82,基本可认为模型开始“学会中文节奏”。
希望帮到你。
本文还有配套的精品资源,点击获取