BERT中文文本分类实战:从数据流到模型微调的完整工程链路
简介面向 NLP 初学者和算法工程师一套基于 pytorch-transformers 的 BERT 中文文本分类代码覆盖了数据准备、模型微调到效果评估的完整流程可用于快速搭建分类基线或业务预研。数据源自 THUCNews 随机抽取的20万条新闻标题分为财经、房产、股票等10个类别按18万、1万、1万划分训练、验证与测试集标注清晰且类别均衡可直接用于监督学习实验。代码包共29个文件以 Python 脚本、预训练 BERT 权重、词汇表、JSON 配置、标注文本和说明文档为主整体约732.57MB其中预训练权重与配套配置、词汇表可加载为可用的中文 BERT 模型预处理脚本完成文本清洗训练脚本实现微调与保存并附结果图表辅助评估目录划分清晰便于按模块查阅。已有3700人学习下载。借助这套代码使用者可完整复现中文文本分类结果也可基于模块化设计扩展类别、调整超参数或接入自身业务数据适合作为 NLP 分类任务的实践参考。1. BERT 中文文本分类先读懂数据流再动手跑代码做中文 NLP 分类任务很多同学第一反应就是把文本丢给 BERT跑一个 finetune 就完事。但真正上手你会发现翻车率最高的环节反而不在模型本身——tokenizer 的编码方式、label 与 id 的映射、数据集的封装顺序任何一个地方对不上训练出来的模型就是一团黑匣子。这份基于 pytorch-transformers也就是现在的 HuggingFace transformers 库实现的 BERT 中文文本分类代码恰好把整条数据流从原始文本到预测结果串得清清楚楚。它适合两类人一类是刚接触预训练模型微调、想跑通一个完整中文分类项目的入门者另一类是自己写过分类脚本、但想梳理清楚数据封装和训练循环的细节、顺便看看有没有更规范的写法的开发者。下面我会从目录结构、数据侧实现、训练闭环、踩坑记录到改造思路把这份代码逐层拆开讲。2. 整体架构与目录设计先把一份能跑的工程布局讲清楚2.1 目录结构每个文件放在哪、负责什么事打开这份资源第一件事不是看代码而是先把目录结构过一遍。一份好的分类工程目录就代表了数据流动的方向。下面是我拿到的源码包展开后的典型结构和多数 BERT 分类项目的组织方式一致BERT_Chinese_Classifier/ ├── config.py # 全局配置数据路径、超参数、模型路径 ├── requirements.txt # 依赖列表 ├── data/ │ ├── train.txt # 训练集每行 text\tlabel │ ├── dev.txt # 验证集 │ └── test.txt # 测试集 ├── src/ │ ├── dataset.py # 数据读取、tokenizer 编码、Dataset 封装 │ ├── model.py # BERT 模型 分类头 │ ├── train.py # 训练主流程 │ ├── evaluate.py # 验证/测试脚本 │ └── predict.py # 单条文本推理入口 └── output/ ├── checkpoints/ # 训练产生的模型权重 └── predictions/ # 预测结果输出这个布局的思路很直接config.py 是全局的“总闸”所有路径和超参都集中在这里训练、评估、预测三个入口共享同一份配置避免每个脚本各自维护一套参数。data/ 目录放原始文本src/ 目录放核心代码output/ 目录放产物。看这份代码你会发现作者把数据读取和模型定义拆成了 dataset.py 和 model.py 两个独立文件。这样做的好处是改数据格式时不用动模型文件换模型结构时也不用碰数据封装职责边界清晰二次开发时能少踩很多坑。2.2 依赖与版本pytorch-transformers 的前世今生这里先解释一个关键点项目标题里写的 pytorch-transformers 其实是 transformers 库的曾用名。在 2019 年前后这个库还叫 pytorch-transformers后来和 tensorflow 版本合并统一改名为 transformers。所以这份代码实际加载的依赖是当下的 HuggingFace transformers 库安装时直接用新名字即可。requirements.txt 里的核心依赖一般长这样transformers4.18.0 torch1.10.0 numpy pandas scikit-learn tqdm装的时候有个血泪经验不要一上来就pip install transformers装最新版。transformers 库迭代很快4.x 各个小版本之间 API 有细微差异尤其是AutoModelForSequenceClassification的返回类型和Trainer的行为不同版本会有些不同。我一般会先建一个干净的虚拟环境装好 torch 之后再装 transformers然后跑一段最小样例验证能加载 BERT 模型再往下走。安装命令# Python 3.8 环境建议先装 torch 再装 transformers pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install transformers numpy pandas scikit-learn tqdmCUDA 版本要和你本机的显卡驱动匹配装错的话训练时会出现 CUDA error这类问题后面第 5 章单独讲。2.3 配置文件超参数集中在 config.py 里config.py 把所有可调整的参数都集中在一起这份代码的超参设计比较常规拿过来改几行就能适配自己的数据。核心参数拆解大概是这样# config.py 核心内容 import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) # 数据路径 TRAIN_PATH os.path.join(BASE_DIR, data, train.txt) DEV_PATH os.path.join(BASE_DIR, data, dev.txt) TEST_PATH os.path.join(BASE_DIR, data, test.txt) # 模型配置 MODEL_NAME bert-base-chinese # 中文 BERT 预训练模型 NUM_LABELS 10 # 分类类别数按自己的数据改 MAX_LEN 128 # 文本最大长度超过则截断 # 训练超参数 BATCH_SIZE 32 LEARNING_RATE 2e-5 EPOCHS 5 WARMUP_RATIO 0.1 # warmup 比例 # 输出配置 OUTPUT_DIR os.path.join(BASE_DIR, output) CHECKPOINT_DIR os.path.join(OUTPUT_DIR, checkpoints) DEVICE cuda if torch.cuda.is_available() else cpu参数说明MAX_LEN 128是中文 BERT 的标准输入长度上限BERT 本身最长支持 512 token但实际分类任务里 128 通常够用——长文本截断到 128 之后信息损失不大但训练速度快接近 4 倍。如果你的数据是长文本比如新闻全文、法律文书这个值要调大后面第 6 章会讲长文本的处理策略。WARMUP_RATIO表示训练过程中前 10% 的 step 使用线性 warmup学习率从 0 逐渐升到设定的LEARNING_RATE这样能避免模型在初始阶段因为学习率过大而震荡。3. 数据侧核心代码从原始文本到模型输入的完整编码链路3.1 原始数据格式一行文本一个 tab 一个标签这份代码要求的数据格式非常简单data/train.txt里每一行是一条样本文本和标签之间用\t分隔标签是字符串形式的类别名。示例这家餐厅的菜非常好吃环境也很优雅 正面 完全是一次糟糕的体验再也不来了 负面 性价比很高服务态度也不错 正面文本里不能出现 tab 符否则会切错位置。如果你的原始数据是 CSV 或者 Excel建议提前转成这种格式或者改 dataset.py 里的读取逻辑。作者在 README 里提到“文本中的特殊符号建议提前清洗”我在实际使用时一般会在前面加一步去重和清洗去掉换行符、规范全角半角、过滤掉过短的文本。3.2 tokenizer 编码padding、truncation、return_tensors 缺一不可接下来看 dataset.py 的核心部分。BERT 不能直接吃原始字符串需要先把文本转成 input_ids、attention_mask、token_type_ids 三个张量这个转换由 tokenizer 完成。# src/dataset.py from torch.utils.data import Dataset from transformers import AutoTokenizer class BertClassifierDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len): self.texts texts # labels 是字符串形式的类别名转成 id self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] label self.labels[idx] # 核心编码逻辑padding truncation 一步完成 encoded self.tokenizer.encode_plus( text, max_lengthself.max_len, add_special_tokensTrue, # 自动加 [CLS] 和 [SEP] paddingmax_length, # 统一 pad 到 max_len truncationTrue, # 超过 max_len 截断 return_attention_maskTrue, return_token_type_idsTrue, return_tensorspt # 返回 PyTorch 张量不需要手动转换 ) return { input_ids: encoded[input_ids].squeeze(0), attention_mask: encoded[attention_mask].squeeze(0), token_type_ids: encoded[token_type_ids].squeeze(0), label: label }几个参数值得特别注意paddingmax_length会把所有样本统一 pad 到max_len这样同一个 batch 里的 tensor 形状一致能直接拼成一个矩阵喂给模型。如果用paddingTrue只 pad 到当前 batch 的最长长度输出形状会不一致DataLoader 里的collate_fn处理起来很麻烦。truncationTrue表示超过max_len就截断。中文 BERT 的词表里是字级别的 token128 个 token 大约对应 128 个汉字短文本完全够用。return_tensorspt让 tokenizer 直接返回 PyTorch 张量。这里有个小坑返回的张量是 shape(1, max_len)多了个 batch 维度所以后面要加一个.squeeze(0)。add_special_tokensTrue会自动在文本开头加[CLS]结尾加[SEP]。对分类任务来说[CLS]位置的向量会作为整个句子的语义表示输入到分类头所以这个 token 必须加。3.3 label 与 id 的映射字符串到数字的跨接文本转成 token 之后标签也不能直接用字符串。在train.py里有一段代码负责构建 label 映射关系# src/train.py 中处理 label 映射的片段 labels list(set(all_labels)) # 从训练集里取所有不重复的标签 labels.sort() # 排序保证映射稳定 label2id {label: i for i, label in enumerate(labels)} id2label {i: label for label, i in label2id.items()} # 把文本和 label id 组装成 Dataset train_dataset BertClassifierDataset( textstrain_texts, labels[label2id[label] for label in train_labels], # 转成 id tokenizertokenizer, max_lenconfig.MAX_LEN )这段代码对初学者来说很有学习价值。label2id和id2label两个字典构建出来之后训练时用 label id 计算 loss预测时用 id2label 把输出转回可读的字符串类别名。很多新手在这块踩坑最常见的问题是训练集和验证集的 label 集合不一致导致验证时label2id[label]直接 KeyError。我一般会在构建映射前先打印一下验证集的标签分布确认两边一致再跑训练。3.4 DataLoader 的 collate_fn一个容易忽略的细节Dataset 定义好之后DataLoader 默认的 collate 行为是按 batch 把多个样本堆叠。但这里有个问题__getitem__返回的是一个 dict每个字段都是单个样本的张量直接堆叠会报错。所以还得配一个collate_fnfrom torch.utils.data import DataLoader def collate_fn(batch): # batch 是一个 list里面是多个样本的 dict input_ids torch.stack([item[input_ids] for item in batch]) attention_mask torch.stack([item[attention_mask] for item in batch]) token_type_ids torch.stack([item[token_type_ids] for item in batch]) labels torch.tensor([item[label] for item in batch], dtypetorch.long) return { input_ids: input_ids, attention_mask: attention_mask, token_type_ids: token_type_ids, labels: labels } train_loader DataLoader( train_dataset, batch_sizeconfig.BATCH_SIZE, shuffleTrue, collate_fncollate_fn )这个collate_fn的核心动作是把 batch 里 N 条样本的每个字段分别用torch.stack堆叠成形状(N, max_len)的张量。labels 单独转成torch.long类型因为后面的CrossEntropyLoss要求目标必须是 long 类型。注意这里用torch.stack而不是torch.cat。stack 是在新维度上堆叠cat 是沿着已有维度拼接。对单条样本来说必须是 stack 才能把多个样本合成一个 batch。4. 训练闭环拆解模型初始化、训练循环与评估4.1 模型初始化AutoModelForSequenceClassification 一行搞定模型侧的核心代码在 model.py 和 train.py 里。用 transformers 做分类微调最常见的方式是直接用AutoModelForSequenceClassification它在 BERT 顶部自动添加了一个线性分类层隐藏层输出经过池化后映射到 NUM_LABELS 维。# src/train.py 中模型初始化部分 from transformers import AutoModelForSequenceClassification, AdamW model AutoModelForSequenceClassification.from_pretrained( config.MODEL_NAME, # bert-base-chinese num_labelsconfig.NUM_LABELS, return_dictTrue # 返回 dict 形式的输出便于取 logits ) model.to(config.DEVICE)这里的num_labels必须和 config.py 里的NUM_LABELS一致同时要和你数据集的类别总数一致。假如你的数据有 10 个类别但NUM_LABELS误设成 2模型最后一层输出维度就是 2训练时 loss 会异常高模型永远收敛不了。我见过有人在这里“手写” BERT 分类模型加载BertModel手动取[CLS]向量再接一个nn.Linear(768, num_labels)。这种做法当然可行但没必要。AutoModelForSequenceClassification内部做的就是这件事而且它对return_dict的处理、对标签缺省的容错都更规范。除非你需要自定义分类头比如多层全连接加 dropout否则用 Auto 系列就够了。关于参数冻结的问题很多做迁移学习的同学习惯把 bert 底层的参数 freeze 住只训练分类头。但 BERT 微调的主流做法恰恰相反——全量参数都参与训练只是学习率设得低一些2e-5 到 5e-5 之间。BERT 预训练时见过大量语料文本语义特征已经很强但下游任务的特定模式仍需微调底层的注意力权重。强行冻结底层参数在数据量较小时效果还行数据量一大效果就不如全量微调。4.2 训练循环optimizer、scheduler、梯度累积训练循环写得比较“基础但规范”没有用 transformers 的 Trainer 封装而是手写了标准的训练 loop这样每一步发生的计算都清晰可见适合学习也方便改造成自己的逻辑。# src/train.py 训练主循环 from transformers import AdamW, get_linear_schedule_with_warmup import torch optimizer AdamW(model.parameters(), lrconfig.LEARNING_RATE) # 总训练步数len(train_loader) 是每个 epoch 的 batch 数 total_steps len(train_loader) * config.EPOCHS warmup_steps int(total_steps * config.WARMUP_RATIO) scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepswarmup_steps, num_training_stepstotal_steps ) for epoch in range(config.EPOCHS): model.train() total_loss 0 for step, batch in enumerate(train_loader): # 把数据搬到 GPU input_ids batch[input_ids].to(config.DEVICE) attention_mask batch[attention_mask].to(config.DEVICE) token_type_ids batch[token_type_ids].to(config.DEVICE) labels batch[labels].to(config.DEVICE) # 前向传播 outputs model( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids, labelslabels ) loss outputs.loss total_loss loss.item() # 反向传播与参数更新 loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 50 0: print(fEpoch {epoch1}/{config.EPOCHS}, Step {step}, Loss: {loss.item():.4f})这段代码里的几个细节值得展开outputs.loss是模型内部计算好的交叉熵损失。当传入labels参数时AutoModelForSequenceClassification会自动计算CrossEntropyLoss不需要在外部手动写损失函数。如果你想用别的方式计算 loss可以不给 model 传 labels改为在拿到 logits 后自己算。clip_grad_norm_(max_norm1.0)是梯度裁剪。BERT 在训练初期容易出现梯度爆炸表现为 loss 突然变成 nan 或 inf加上这个裁剪能防止梯度超过 1.0 的范数是保险丝式的设置。optimizer.zero_grad()放在 update 之后、下一轮反向传播之前。顺序错了会导致梯度累积到上一次的参数上训练出现诡异波动。很多人在这块出过问题现象是 loss 忽高忽低不下降。如果你的显存不够常见做法是加一个梯度累积每 N 个 batch 才执行一次optimizer.step()用更小的有效 batch size 拼出大 batch 的效果。改法很简单把loss.backward()之后的内容包在if (step 1) % gradient_accumulation_steps 0:里。源代码没写这个但实际跑大批量数据时很有用。4.3 评估函数与模型保存别只盯 accuracy训练完之后evaluate.py 负责在验证集上评估模型。这份代码的评估写得比较完整除了准确率还输出了精确率、召回率和 F1# src/evaluate.py from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score def evaluate(model, dev_loader): model.eval() all_preds [] all_labels [] with torch.inference_mode(): # 比 no_grad 更快不追踪梯度 for batch in dev_loader: input_ids batch[input_ids].to(config.DEVICE) attention_mask batch[attention_mask].to(config.DEVICE) token_type_ids batch[token_type_ids].to(config.DEVICE) labels batch[labels].to(config.DEVICE) # 前向传播拿到 logits outputs model( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) logits outputs.logits # 取最大概率对应的索引作为预测类别 preds torch.argmax(logits, dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.cpu().numpy()) accuracy accuracy_score(all_labels, all_preds) precision precision_score(all_labels, all_preds, averageweighted) recall recall_score(all_labels, all_preds, averageweighted) f1 f1_score(all_labels, all_preds, averageweighted) print(fAccuracy: {accuracy:.4f}, Precision: {precision:.4f}, Recall: {recall:.4f}, F1: {f1:.4f}) return f1torch.inference_mode()是 PyTorch 1.9 之后引入的替代torch.no_grad()的上下文管理器它会关闭梯度跟踪和自动求导推理时速度更快。torch.argmax(logits, dim1)取每个样本预测概率最大的类别索引。这里要注意logits 的形状是(batch_size, num_labels)所以dim1是对类别维度求最大值取出来之后是每个样本的预测类别 id。评估时模型一定要加model.eval()。这行代码会把 dropout 层关掉、BN 层切换为推理模式如果漏了评估结果会带有随机性同样的数据跑两次结果不一样。模型保存部分一般会执行两个动作保存完整的模型权重同时把 tokenizer 也一并保存这样后续加载时不用再指定模型名称model.save_pretrained(config.CHECKPOINT_DIR) tokenizer.save_pretrained(config.CHECKPOINT_DIR)保存 tokenizer 是一个很容易被忽略的动作。换个环境、换个机器如果还要重新下载 bert-base-chinese 的 tokenizer 文件慢且容易出网络问题。把 tokenizer 放到 checkpoint 目录里推理时直接AutoTokenizer.from_pretrained(CHECKPOINT_DIR)就能恢复出和训练时完全一致的配置。5. 常见问题排查跑通之后还得防的坑5.1 现象训练时报错CUDA out of memory这个报错在 BERT 微调项目里出现频率极高。显存不够的表现是程序跑到第一个 batch 前向传播时直接抛出RuntimeError: CUDA out of memory有时候甚至是在加载模型的时候就炸了。原因基本是两种一是 batch size 太大一个 batch 的文本经过 BERT 的 12 层 Transformer 之后中间激活值占用显存非常高二是机器上其他程序占用了显存nvidia-smi 看下来可用显存只有几百兆。解决路径从简单到复杂先调小config.BATCH_SIZE从 32 降到 8 或者 4显存占用是线性下降的再不行开梯度累积用小 batch 模拟大 batch最后还有一招是开torch.cuda.empty_cache()释放缓存碎片。如果用的是 6G 显存的卡BERT-base 加 batch size 8 和 max_len 128 通常能跑动再大就得考虑换显卡或者用混合精度。混合精度在代码里开启很简单from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for step, batch in enumerate(train_loader): # ... with autocast(): outputs model(input_idsinput_ids, attention_maskattention_mask, labelslabels) loss outputs.loss scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()这段代码把前向传播和 loss 计算放在autocast()上下文里FP16 计算能在不损失太多精度的情况下把显存占用降低一半左右。5.2 现象loss 不下降或直接变成 nan训练了十几个 steploss 纹丝不动甚至直接输出 nan 或者 inf。排查顺序一般是下面这样先看学习率。BERT 微调的学习率范围极其敏感1e-4就很容易导致 loss 震荡甚至发散2e-5到5e-5是安全区间。这份代码里LEARNING_RATE 2e-5是一个经过验证的起点值。再看数据和 label 映射。如果NUM_LABELS设置成 2但实际数据有 10 类label2id映射就会出现越界的 label idCrossEntropyLoss收到超出范围的 target 会计算出 nan 或者报错。我遇到过一次很隐蔽的情况训练集 label 正常验证集里混入了一个训练集没出现过的新标签label2id[label]直接 KeyError。这个问题在真实项目里很常见原因是数据划分时没有保证训练/验证集的标签空间一致。解决方法是构造映射时从两个集合的并集里取 label或者更稳妥的做法是直接检查验证集中每个标签是否都出现在训练集的标签集合里发现不在就报错提示。最后看数据里面有没有空文本。空文本经过 tokenizer 后只剩[CLS]和[SEP]两个 token语义信息为零模型在这种样本上容易输出极端 logits。我在预处理阶段会过滤掉len(text.strip()) 1的样本。5.3 现象预测结果全部都是同一个类别训练完模型评估 F1 看起来还行但实际拿来预测单条文本时所有输出都集中在某一个类别上尤其是多数类。这是典型的类别不均衡问题。原因很简单数据里某个类别的样本占比过高比如 90% 都是“不好”这个类别模型学到的策略是“永远预测不好”整体准确率也有 90%但另一个类别完全没学会。解决思路有几个层面。数据层面做下采样或上采样让类别分布均衡一些。代码层面给CrossEntropyLoss传class_weight。在 transformers 的模型里可以在初始化时传入classifier_dropout之类的参数但计算 class_weight 更直接的方式是手动改训练循环from torch.nn import CrossEntropyLoss # 按样本数量的倒数计算权重数据里样本越少的类别权重越大 class_counts torch.bincount(torch.tensor(all_labels), minlengthconfig.NUM_LABELS) class_weights 1.0 / class_counts.float() loss_fn CrossEntropyLoss(weightclass_weights.to(config.DEVICE)) # 训练时不用 outputs.loss改为手动计算 logits outputs.logits loss loss_fn(logits, labels)模型层面对少数类样本做数据增强比如 EDA同义词替换、随机插入、随机删除、随机交换这在文本分类里能起到一定缓解作用。这份源代码没有实现这些但作为二次开发的改进方向很适合。5.4 现象不同机器上跑出来的效果不一致这里有两种情况。第一种是随机性问题。BERT 微调本身有一定随机性数据加载顺序、dropout 的随机掩码、权重初始化的微小差异都会导致最终模型效果有波动。解决方式是固定随机种子。我在开始训练前一般会加这样几行import numpy as np import torch import random def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)第二种是环境一致性。transformers 版本不同、torch 版本不同同一个模型的输出会有细微差异严重的甚至导致模型结构对不上、无法加载权重。所以跨机器复现时首先保证pip freeze requirements-lock.txt把精确版本号锁定再用这个文件到新环境安装依赖。6. 二次开发思路把这份代码改成你自己的业务分类器跑通这份代码只是开始把它改造成能支撑真实业务的分类器才是关键。这里分享三个我在实际项目中用得最多的改造方向。第一多标签分类。这份代码默认是单标签分类一个样本只属于一个类别。但现实业务里一篇文章可能同时属于科技和生活两个类别这就是多标签分类任务。改造点有两个模型侧把num_labels改成年标签总数损失函数从 CrossEntropyLoss 换成 BCEWithLogitsLoss。模型侧直接用AutoModelForSequenceClassification也能做多标签只要把损失函数换掉即可。# 多标签分类损失改造 from torch.nn import BCEWithLogitsLoss loss_fn BCEWithLogitsLoss() # 标签格式从 single label id 改成 multi-hot 向量 # 比如 [0, 1, 0, 1, 0] 表示该样本属于第 1 和第 3 个类别 loss loss_fn(logits, multi_hot_labels.float())推理时不能用argmax要改成按阈值判断每个类别是否生效比如sigmoid(logits) 0.5记为 1否则为 0。第二长文本处理。MAX_LEN128对短文本如评论、微博够用但遇到行业报告、法律文书这类长文本截断到 128 后信息丢失严重。一个常规做法是分段 池化把长文本切成多个 128 token 的段每段单独过模型取[CLS]向量然后用最大池化或加权平均合并成整个文本的表示再过分类头。这个改动会涉及模型结构的调整手写一个自定义分类器会更灵活。第三推理性能优化。模型训练好只是第一步如果要做在线推理比如 API 接口速度和显存就是大问题。这份代码的 predict.py 是单条预测的示例实际部署时我会做两个优化一是用torch.inference_mode()包住推理函数二是把模型转成 half 精度显存直接减半速度提升明显。如果对延迟要求更高还可以用 ONNX Runtime 导出模型做 CPU 推理能压到几十毫秒级别。最后说一个我个人的习惯每次拿到一个新的文本分类任务我不会直接上全量数据训练而是先抽 200 条样本、跑 3 个 epoch快速验证数据读取、label 映射、训练循环整条链路没有问题再上全量数据。这个“冒烟测试”的习惯帮我挡掉了无数次“辛辛苦苦训练了 5 个小时评估损失却比随机猜测还差”的尴尬局面其中大部分问题都出在数据预处理和 label 映射上而不是模型本身。这份资源的价值在于它把 BERT 中文分类的整条工程链路揉成了一坨完整的代码从数据格式、tokenizer 编码到训练评估、模型保存每一环都能对照着看。希望你能把它跑起来改造成符合自己业务场景的分类器。如果过程中遇到我上面提到的问题回过来看看第 5 章的排查清单多半能找到答案。希望帮到你。本文还有配套的精品资源点击获取