1. 先把BERT这件事说透:它到底在解决什么问题
做自然语言处理这行的人,几乎绕不开BERT模型。但很多人对它的理解停留在"一个预训练模型"这六个字上,面试能背出MLM和NSP,真到项目里却不知道怎么用、为什么这么用、什么时候不该用。我在实际项目里带过不少新人,最常见的误区是把BERT当成一个"万能文本分类器",往上一套就指望指标起飞,结果标注几百条数据训下来,准确率还不如传统特征工程。
先把话说清楚:BERT本质上是一个双向编码器结构的预训练语言表示模型。它不生成文字,不做对话,不写文章,它干的事情是——把一句话喂进去,输出每个字/词在这个上下文里的向量表示。这些向量携带着语法结构、语义关系、指代信息,你可以拿它去做分类、抽取、匹配、检索、序列标注。它解决的问题是:在过去,要让机器理解一句话,你得手工设计大量特征;现在,你只需要一个预训练好的编码器加一层简单的输出头,就能在标注数据量不大的情况下拿到可用的效果。
这个价值有多大?我举个实际场景。某公司做合同要素抽取,早期用规则加正则,字段一多就崩,七百多条规则维护到没人愿意接手。换成预训练编码器加序列标注头之后,标注了三千条样本,F1直接从零点五几干到零点八八,规则只剩几条兜底。这就是这类模型的真实杀伤力——它把"语言理解"这件苦力活,从特征工程变成了数据标注,而数据标注的可扩展性远好于规则维护。
适合读这篇的人:做过一些机器学习但没系统用过预训练模型的,正在选型纠结要不要上BERT的,模型训起来了但指标死活不动的,以及想知道显存到底怎么算、超参到底怎么调的人。下面我会从结构原理一路讲到实操脚本、显存账本和踩坑记录,尽量把每个"为什么"都掰开。
2. 为什么是双向编码器:几个关键设计背后的取舍
2.1 从"从左往右读"到"同时看两边"
要理解BERT的设计,得先理解它之前的做法。早期的语言模型是自回归的——给定前文预测下一个词,从左往右一个一个读。这种结构天然适合生成,但它有个硬伤:在编码第5个词的时候,模型根本不知道第6个词是什么。对于"苹果发布了新款手机,它的股价上涨了"这种句子,"它"指代谁,只看左边是判断不出来的。
BERT的解法是把整个句子一次性喂进 Transformer 的编码器堆栈,靠自注意力机制让每个位置都能看到所有位置。这在数学上就是注意力矩阵里不做上三角掩码,每个token对所有token计算相关性。代价是它没法直接做生成任务(因为训练时看不到"未来",推理时也就不会按顺序吐字),收益是上下文表示的质量明显更高,尤其是涉及指代消解、语义角色、长距离依赖的任务。
注意:这个"双向"说的是编码阶段能看到完整上下文,不是说它能理解整段话的全局逻辑。它的感受野上限就是最大序列长度(通常是512个token),超出部分它是真的看不见,不是"理解得弱"。
2.2 "预训练+微调"这套范式为什么能打
传统监督学习是"一个任务一份数据一个模型",每个任务从零开始学。预训练范式的逻辑是:先用海量无标注文本让模型学会"语言长什么样",这个过程不需要人工标注,成本极低;然后针对具体任务,用少量标注数据做微调,把通用的语言能力迁移到特定任务上。
这里面有个很容易被忽略的细节:微调时通常不需要冻结底层,全量参数一起更新效果最好。很多人受计算机视觉那套"冻结backbone只训分类头"的经验影响,在BERT上也这么干,结果指标差一截。原因在于文本任务的分布差异往往比图像任务更大,底层编码器需要跟着调整。当然,如果你的标注数据只有几百条,冻结底层反而能防止过拟合,这属于小样本场景的特例处理。
2.3 什么样的任务该用它,什么任务趁早换路
我按经验给个判断标准,不敢说绝对,但踩过的坑基本都在里面:
| 任务类型 | 适配程度 | 说明 |
|---|---|---|
| 文本分类、情感分析 | 高 | 取[CLS]向量接一层全连接即可,最省事 |
| 命名实体识别、序列标注 | 高 | 每个token输出接CRF或直接softmax |
| 句子对匹配、语义相似度 | 高 | 两句拼一起送入,用[CLS]或双塔结构 |
| 抽取式问答 | 高 | 预测答案片段的起止位置偏移 |
| 语义检索、向量召回 | 中高 | 需要双塔或对比学习改造,别用单塔直接算 |
| 长文本(超512token)理解 | 中 | 要滑窗或分块聚合,信息有损 |
| 文本生成、摘要、翻译 | 低 | 编码器结构不适合,换解码器架构 |
| 强时效性内容(新词、新梗) | 低 | 词表外的表达表示质量会掉,需要增量适配 |
最后那一行值得多说两句。模型的世界知识冻结在训练语料的时间点上,遇到训练集里没见过的新表达,它只能靠子词切分去猜。我在一个舆情项目里遇到过,某类新兴网络表达刚出现时,模型的分类置信度普遍偏低,不是因为语义难,而是因为这些字组合在预训练语料里几乎没出现过。解决办法不是换更大的模型,而是把领域语料拿去继续做掩码语言建模,让它重新认识这些表达。
3. 拆开结构看:输入表示、注意力堆栈与训练目标
3.1 输入表示:三套向量相加的门道
一句话进模型之前,要先变成三个东西:词元ID序列、位置ID序列、句子段ID序列。这三个序列分别去查三张嵌入表,得到三个同维度的向量,逐元素相加(有的实现后面还接一层LayerNorm),才形成最终的输入表示。
- 词元嵌入:词表大小通常是三万级别,中文模型基本按字切分,一个汉字一个token,标点和数字也各占位。词表大意味着嵌入表参数多,这是模型参数量的重要组成部分。
- 位置嵌入:可学习的位置向量,最大长度512,每个位置一个768维向量。这是它和正弦位置编码的重要差别——位置信息是学出来的,不是公式算的,好处是灵活,坏处是超过512就完全没法外推。
- 段嵌入:只有两个向量,用来区分句子对里的第一句和第二句。单句任务里全填0,句子对任务里前半段填0后半段填1。
这里有个实操中特别容易掉的坑:特殊符号的占位。每个序列开头必须加一个分类符号(记作[CLS]),句子之间和结尾加分隔符号([SEP])。这两个符号本身是有向量的,[CLS]经过多层注意力之后汇聚了整个序列的信息,是分类任务的标准取用位置。但如果你自己在拼数据的时候忘了加,或者加了但没算进长度限制里,就会出现截断后[SEP]丢失、模型行为异常的问题。我见过有人手动拼字符串做推理,结果分词后长度刚好512,末尾的[SEP]被截掉了,模型输出概率分布明显偏平。
3.2 编码器堆叠与自注意力的计算量
基础版结构是12层编码器,每层两个子层:多头自注意力 + 前馈网络,每个子层外面包残差连接和LayerNorm。隐藏维度768,注意力头数12,每个头64维,前馈网络中间层3072维。
单层自注意力的计算量大致是序列长度的平方乘以隐藏维度。这就是为什么长度翻倍,计算量翻四倍。512长度的序列,注意力矩阵是512×512,每个头算一遍,12个头并行。序列长度128的时候这个矩阵只有128×128,省下来的不只是计算,更是显存——注意力矩阵是要存下来做反向传播的。
参数量我给大家算一遍基础版的账,方便你们估算自己的场景:
- 词元嵌入:30522 × 768 ≈ 2340万
- 位置嵌入:512 × 768 ≈ 39万
- 段嵌入:2 × 768 ≈ 1500
- 每层:注意力QKV三个 768×768 共约177万,输出投影 768×768 约59万,前馈网络 768×3072 加 3072×768 约472万,合计约708万
- 12层合计:约8500万
加上嵌入层,总共约1.1亿参数。这个数字要记住,后面算显存全靠它。
3.3 两个训练目标:一个真有用,一个后来被扔了
预训练阶段用两个任务:
掩码语言建模:随机遮住输入序列中15%的token,让模型根据上下文预测被遮住的是什么。这15%里面又细分——80%替换成掩码符号,10%替换成随机词,10%保持原样。为什么要这么设计?如果100%都替换成掩码符号,模型只在"有掩码"的情况下学习,微调时没有掩码符号就会出现分布不匹配。加那10%随机替换是逼模型对每个位置都保持警惕,不能只盯着掩码位置偷懒;加那10%保持不变是让模型学会"有时候什么都不用改"。
下一句预测:给模型一对句子,判断第二句是不是第一句的真实后续。这个任务的初衷是让模型学到句间关系,但后来的实践普遍认为它价值有限——负样本是从不同文档随机抽的,太容易区分,模型学到的更多是主题差异而不是逻辑关系。后续的改进版本基本都把这个任务去掉了,换成动态掩码、更大批次、更长训练这些更实在的手段。你在自己领域做继续预训练的时候,也可以只保留掩码语言建模,省一半数据构造的功夫。
提示:如果要做领域继续预训练,掩码比例可以适当提高。通用语料15%够用,专业领域(比如医学、法律)文本信息密度高,15%到20%之间比较合适,太高会导致训练信号不足。
3.4 参数量、显存与批大小的账本
这部分是实操里最容易被低估的。很多人拿8G显存的卡跑基础版微调,batch设32、长度设512,直接爆掉,然后以为是模型太大。
显存主要花在四块:模型参数、参数梯度、优化器状态、前向激活值。以fp32训练基础版为例:
- 参数:1.1亿 × 4字节 ≈ 440MB
- 梯度:同规模 ≈ 440MB
- 优化器状态(Adam,一阶二阶动量各一份):1.1亿 × 8字节 ≈ 880MB
- 以上三项合计约1.76GB,这部分和batch无关
激活值才是随batch和长度线性增长的大头。粗略估算公式是:batch × 序列长度 × 隐藏维度 × 层数 × 系数,系数取34左右(包含注意力矩阵、前馈中间激活、dropout掩码等)。代入几个常见配置:
| batch | 序列长度 | 激活值估算 | 加上参数等总计 |
|---|---|---|---|
| 1 | 512 | 约640MB | 约2.4GB |
| 8 | 128 | 约1.3GB | 约3.1GB |
| 32 | 128 | 约5.1GB | 约6.9GB |
| 16 | 512 | 约10.2GB | 约12GB |
| 32 | 512 | 约20.5GB | 约22GB |
这张表解释了一个常见现象:为什么把长度从128提到512,batch必须砍到四分之一。因为激活值和长度是线性关系,但注意力矩阵那部分是平方关系,实际占用还要再高一些。我一般给的经验值是——16G的卡,基础版微调,长度128可以跑batch 32到48,长度512只能跑batch 8到12,还得开混合精度。
省显存的手段按性价比排序:混合精度(bf16优先)> 梯度累积(用小batch模拟大batch)> 梯度检查点(拿时间换空间,慢三成左右)> 冻结底层(只在数据极少时用)。梯度累积这招特别值得说,它把batch拆成几次前向累加梯度再统一更新,数学上和真的大batch几乎等价(LayerNorm和dropout有轻微差别),显存占用按小batch算。我很多项目里实际batch只有8,但累积4步,等效32,效果和直接开32基本一致。
4. 上手实操:从加载到微调的完整链路
4.1 环境和依赖的最小集合
不需要装一堆东西。核心就两个:深度学习框架加模型库。版本上有个坑——模型库的版本和框架版本有对应关系,尤其是涉及编译算子的部分,版本错配会报一堆看不懂的符号错误。我的习惯是先在干净环境里跑一个最小加载脚本,通了再往下写。
pip install torch --index-url 官方源 pip install transformers datasets加载模型和分词器最省事的写法是用自动类,它会根据模型目录里的配置文件自动选对结构:
from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name = "本地或远程的模型路径" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)第一次加载会下载权重,之后会走本地缓存。缓存目录要注意磁盘空间,基础版权重四百多兆,多下几个变体很容易吃掉几个G。如果做多任务实验,建议显式指定缓存路径,别让它们散落在用户目录里。
4.2 分词器:坑最多的一环
分词器是把字符串变成ID序列的地方,也是实际项目里问题最集中的地方。几个必须搞清楚的点:
第一,中文是按字切的,不是按词。"人工智能"会变成三个token,不是两个也不是一个。这意味着中文句子的token数大致等于字数加标点,长度预算要按字算。英文则是子词切分,一个长单词可能被拆成三四个片段。中英混排的文本,token数和字符数的比例不稳定,做长度截断时最好实测一下自己数据集的分布。
**第二,最大长度是硬约束。**模型的位置嵌入表就是512行,超了没有对应的位置向量可用。分词器默认会截断,但这个截断是从尾部砍的。如果你的关键信息在句末(比如合同的责任条款),截断后就丢了。稳妥的做法是先分析数据长度分布,看95分位在哪里,再决定截断策略——是保留头部,还是头尾各留一半,还是干脆做滑窗。
# 看一眼自己数据的长度分布,别拍脑袋定 max_length lengths = [len(tokenizer.encode(t, add_special_tokens=True)) for t in texts] import numpy as np print("P50:", np.percentile(lengths, 50)) print("P95:", np.percentile(lengths, 95)) print("P99:", np.percentile(lengths, 99)) print("Max:", max(lengths))**第三,未知词符号。**词表里没有的字会被映射成未知符号,向量表示基本没信息。中文常用字覆盖得比较全,但生僻字、特殊符号、某些表情会被打进去。数量少无所谓,如果某个字段里未知符号占比超过5%,说明词表不适配,得考虑换模型或者扩词表。
**第四,句子对任务的拼接方式。**要用分词器的成对编码接口,不要自己拼字符串。它会自动插入分隔符号并正确设置段ID,手动拼很容易出错。
4.3 数据处理与动态填充
固定长度填充是最浪费显存的做法。一批数据里最长的那条只有80个token,你非要填到512,剩下的432个位置全是无效计算。动态填充是每批按批次内最大值填,能省三到五成显存,效果还不受影响。
from torch.utils.data import Dataset import torch class TextDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=512): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): enc = self.tokenizer( self.texts[idx], truncation=True, max_length=self.max_len, # 注意这里不 padding,留给 collate_fn 动态处理 ) enc["labels"] = self.labels[idx] return enc def collate_fn(batch): # 按批次内最长序列填充 max_len = max(len(x["input_ids"]) for x in batch) input_ids, attention_mask, labels = [], [], [] for x in batch: pad = max_len - len(x["input_ids"]) input_ids.append(x["input_ids"] + [tokenizer.pad_token_id] * pad) attention_mask.append(x["attention_mask"] + [0] * pad) labels.append(x["labels"]) return { "input_ids": torch.tensor(input_ids), "attention_mask": torch.tensor(attention_mask), "labels": torch.tensor(labels), }这里有个细节值得强调:填充位置必须用注意力掩码标出来,否则模型会把填充符号当成真实内容参与注意力计算,效果会明显变差。另外填充符号的向量本身虽然是零初始化,但它经过多层网络后不一定还是零,掩码是必须的。
还有一个进阶技巧是按长度分桶。把长度相近的样本排到一起,能进一步减少填充浪费。实现上就是按长度排序后切batch,再在batch内打乱顺序。这个改动代码量不大,训练速度能提两成左右,数据量大时很值。
4.4 微调超参:别照搬论文,按数据量调
论文里的配置是给大数据集设计的。你手上如果是几千条标注样本,直接抄那套参数很容易过拟合。我按经验给一组起点值:
| 超参 | 小数据(1k~5k) | 中数据(1w~5w) | 说明 |
|---|---|---|---|
| 学习率 | 2e-5 ~ 3e-5 | 3e-5 ~ 5e-5 | 比预训练小两个数量级 |
| batch等效大小 | 16 ~ 32 | 32 ~ 64 | 用梯度累积凑,别硬堆显存 |
| 训练轮数 | 3 ~ 5 | 2 ~ 4 | 小数据多跑几轮但要早停 |
| 预热比例 | 10% | 6% ~ 10% | 前几步慢慢升学习率,防震荡 |
| 权重衰减 | 0.01 | 0.01 | 常规设置 |
| 最大长度 | 按P95定 | 按P95定 | 别默认512 |
| 丢弃率 | 0.1 ~ 0.2 | 0.1 | 小数据可以适当调高 |
学习率是最敏感的一个。我给个实操判断方法:如果训练loss在前100步剧烈抖动甚至变成nan,学习率太高;如果跑了半个epoch loss几乎不动,学习率太低或者数据有问题。3e-5基本是个安全起点,跑一次看曲线再上下调。
训练循环的关键部分长这样,混合精度和梯度裁剪都加上:
from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=3e-5, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps, ) scaler = torch.cuda.amp.GradScaler() model.train() for epoch in range(epochs): for step, batch in enumerate(train_loader): batch = {k: v.to(device) for k, v in batch.items()} with torch.cuda.amp.autocast(dtype=torch.bfloat16): outputs = model(**batch) loss = outputs.loss / grad_accum_steps scaler.scale(loss).backward() if (step + 1) % grad_accum_steps == 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() scheduler.step() optimizer.zero_grad()clip_grad_norm_这行别省。文本任务里梯度爆炸不算罕见,尤其是学习率稍高或者遇到特别长的样本时,裁剪能把训练稳定性拉回来不少。阈值1.0是通用起点。
4.5 训练过程的观察点与保存策略
跑起来之后,别只盯着loss。我一般看四个数:训练loss、验证集主指标、验证集loss、学习率曲线。训练loss降但验证loss升,就是过拟合,早停或者加正则;两个都平,检查数据标注一致性;训练loss震荡,调小学习率或加大预热。
模型保存要按验证指标存最优,不是存最后一轮:
best_f1 = 0 for epoch in range(epochs): train_one_epoch() metrics = evaluate() if metrics["f1"] > best_f1: best_f1 = metrics["f1"] model.save_pretrained("./best_ckpt") tokenizer.save_pretrained("./best_ckpt") print(f"epoch {epoch} f1={metrics['f1']:.4f} best={best_f1:.4f}")这里有个细节:分词器要和模型一起存。很多人只存了模型权重,推理时又去加载原始的远程分词器,一旦做过词表修改或者版本不同,就会出现ID错位,预测结果全是乱的,而且很难查。
5. 常见问题与排查实录
5.1 显存爆了:先算账再动手
报显存不足的时候,别急着重装环境或者换卡,先算账。按第3.4节的表对一下自己的配置,大部分情况是batch或长度设大了。具体处理顺序:
- 把长度从512降到实际P95(比如192),这一步通常能省一大半
- 开bf16混合精度,参数副本和激活值都减半
- batch降到4或8,用梯度累积补等效batch
- 还不够就开梯度检查点,代价是训练慢三成
- 最后才是考虑更小的模型变体
有个隐蔽的显存杀手是评估阶段。评估时忘了加torch.no_grad(),计算图全存下来,显存占用比训练还高。这个坑我踩过,排查了半天才发现是评估函数里少了一行。
另一个隐蔽问题是内存泄漏式累积。训练循环里把loss或输出张量append到一个列表里做记录,如果没.item()转成标量,整个计算图都被引用着不释放,跑几十步就爆。记录指标一定要转成Python数字。
5.2 指标不涨:八成是数据问题,不是模型问题
模型训完指标不理想,很多人的第一反应是换更大的模型、调更多参数。我做过统计,实际项目里最后定位到模型结构问题的不到两成,剩下八成是数据或流程问题。排查顺序建议这样:
**先查标注一致性。**随机抽200条人工复核,看标注标准是否统一。同一个表达在不同标注员手里标成不同类,模型学出来就是一团糊。这类问题在细粒度分类里特别常见,比如"投诉"和"咨询"的边界,不定义清楚,模型再好也白搭。
**再查类别分布。**如果正负样本是1比50,模型会倾向于全预测多数类,准确率看着有98%,召回是0。这时候要看每个类的精确率和召回率,别看总体准确率。处理方式:重采样、类别加权损失、或者调整预测阈值。
**然后查输入是否被截断。**把训练数据的长度分布打出来,看看有多少条超过最大长度被砍了。如果关键信息恰好在被砍掉的部分,模型当然学不会。我遇到过一个工单分类项目,用户的问题描述在前,后面跟着一大段系统日志,截断到128之后日志全没了,但真正区分类别的恰恰是日志里的错误码。改成头部64加尾部64的截断策略,指标直接涨了六个点。
**最后才考虑模型和超参。**真要调,优先级是:学习率 > 训练轮数 > 长度 > batch。换更大模型放最后,因为收益不确定而成本确定。
5.3 上线之后的性能问题
离线指标好看,上线一压测就崩,这类问题在工程侧很集中。几个我反复遇到的:
**吞吐量上不去。**单条推理延迟可能只有十几毫秒,但QPS跑不高,原因是没做批处理。把请求攒一小批一起前向,吞吐能翻好几倍。攒批的等待时间要设上限,不然延迟会飙。
**长度不一致拖慢整体。**线上请求长度参差不齐,按最长填充会浪费大量算力。做法是按长度分桶,同桶内批处理,或者用支持变长输入的推理引擎。
**首次推理特别慢。**模型加载和算子初始化在第一次调用时完成,如果有冷启动要求,可以在服务启动时先跑一次假数据预热。
**量化之后指标掉。**把权重从32位压到16位甚至8位能省显存提速度,但精度损失不一定可接受。稳妥做法是先在小验证集上对比量化前后的指标差,差在1个点以内可以接受,超了就只量化部分层。
5.4 问题速查表
| 现象 | 可能原因 | 排查动作 | 处理方案 |
|---|---|---|---|
| loss变nan | 学习率过高、梯度爆炸 | 打印前100步loss | 降学习率、加梯度裁剪、加预热 |
| loss不降 | 学习率过低、标签错位 | 检查标签与输入是否对齐 | 调学习率、小样本过拟合测试 |
| 验证指标震荡 | batch太小、数据量少 | 看每轮指标波动幅度 | 增大等效batch、多轮取平均 |
| 全预测同一类 | 类别极不平衡 | 看混淆矩阵 | 类别加权、重采样、调阈值 |
| 推理结果乱码式错乱 | 分词器与模型不匹配 | 打印input_ids对照 | 模型和分词器一起存一起加载 |
| 显存逐轮增长 | 张量未释放 | 看显存曲线 | 指标转标量、评估加no_grad |
| 长文本效果差 | 关键信息被截断 | 看长度分布与截断位置 | 改截断策略、滑窗聚合 |
| 新词识别差 | 词表未覆盖 | 统计未知符号占比 | 领域继续预训练、扩词表 |
提示:做小样本过拟合测试是排查流程问题的利器。拿20条数据,去掉所有正则和早停,训200轮,如果训练集准确率上不到95%以上,说明代码链路有bug,别往下调参了,先修代码。
6. 变体选型与落地边界
6.1 几个主流改进方向怎么选
基础版之后出现了一批改进版本,核心改动集中在几处:去掉下一句预测任务、改用动态掩码、扩大训练数据与批次、调整层数和宽度。选型时不用记名字,记这几条差异就行:
| 改进方向 | 带来的收益 | 代价 |
|---|---|---|
| 去掉句间任务 + 动态掩码 | 同数据下效果普遍更好 | 训练成本略高 |
| 层数加深、宽度加大 | 表示能力更强 | 显存和延迟线性上升 |
| 层间参数共享 | 参数量大幅下降 | 效果略降,训练更慢 |
| 用判别式替换生成式预训练 | 小模型也能打 | 实现相对复杂 |
| 蒸馏压缩 | 推理快数倍 | 需要教师模型,效果有损 |
我的实际选择逻辑很简单:离线任务、算力够,优先选效果好的改进版;线上高并发、延迟敏感,优先选蒸馏后的小模型;数据极少(千条以内),优先选参数少的版本防过拟合。
6.2 中文场景的几个具体考量
中文用这套模型有几个和英文不一样的地方,不注意会吃亏。
词表是按字设计的,一个汉字一个token,好处是不会出现英文那种一个词被切碎的情况,坏处是同样语义内容占的token数更多。一句30个字的中文,大约30个token,对应的英文可能只要15个。这意味着中文的有效上下文长度实际比英文短一半。做长文档任务时,中文的512长度限制更紧张。
标点和空格的处理。中文分词器对全角半角的处理有时候不一致,同一句话里混用会导致token数差异。做数据清洗时统一一下全角半角,能减少一些无谓的波动。
领域适配的必要性更高。通用中文语料覆盖不了专业术语,医学、法律、金融这些领域,直接拿通用模型效果会打折。有效的做法是拿领域无标注文本做继续预训练,几百万字就够看到明显提升。
6.3 什么时候该果断放弃它
最后说点逆向的话。这个模型不是万能的,有些场景硬上就是浪费资源。
短文本、强规则的场景。比如判断一个输入是不是纯数字、是不是手机号格式,正则一行代码搞定,上模型是杀鸡用牛刀。
对延迟极端敏感的场景。基础版在CPU上单条推理几十毫秒,如果业务要求10毫秒以内,要么上GPU,要么换更小的模型,要么干脆用轻量特征加浅层分类器。
数据量极大的场景。如果你有百万级标注数据,从头训练一个任务专用的小模型可能比微调大模型更划算,推理成本低一个数量级。
需要生成的任务。编码器结构做生成是真的别扭,别硬改,换个架构。
我在实际项目里最常做的判断是:先用一个极简基线(比如字符级特征加线性模型)跑一遍,看指标在哪。如果基线就能到0.85,而上限要求是0.88,那微调预训练模型可能只能带来两三个点,但要付出标注、训练、部署的一整套成本,值不值要算清楚。反过来,如果基线只有0.5出头,那这套东西的投入产出比就非常高。
还有个小经验分享:先跑通全流程,再优化指标。我见过太多人卡在调参上,模型训了十几版,结果发现数据管道里标签和文本的索引错位了一行,所有实验都是白做的。花半天时间做一个端到端的冒烟测试——取100条数据,跑2个epoch,看训练集能不能过拟合到接近100%——这半天的价值比后面一周调参都大。