去年冬天在一场内部技术分享上,我被刚转岗过来的同事问了一句:你讲了半天天注意力,能不能用一句话说清楚 BERT 到底在学什么?我当时愣了几秒,最后憋出来一句“它在学怎么用上下文猜一个被抠掉的字”。台下笑了,但我知道这个回答对入门的人来说其实够用了。这件事之后我把自己带新人的路径重新理了一遍,也翻出手头这本《BERT基础教程:Transformer大模型实战》对照着重读了两遍——它恰好踩在“从注意力公式到能跑通微调”这段最难自己啃的路上。这篇东西不是书评,我更想把它当成一份“大模型入门”的实操路线图:Transformer 和 BERT 这套东西到底是什么、能解决哪些真实问题、哪些章节值得反复看、哪些地方书上写得简略需要自己补、实际动手时会栽在哪几个坑里。适合两类人看:一类是刚接触 Transformer、看得懂公式但不知道代码怎么落地的朋友;另一类是把 BERT 当成“老古董”、直接跳去玩更大参数模型,结果回头发现连 attention mask 都写错的人。我会把参数怎么算、超参怎么定、显存怎么估、报错怎么排,一条条摊开讲。
1. 为什么把 BERT 当作大模型入门的第一站
1.1 三条入门路线的取舍:为什么编码器模型更适合打地基
现在入门的路径大概有三种。第一种是直接调通用大模型的接口,写几行请求就能出结果,爽感最强,但你对内部一无所知,一旦遇到效果不好只能靠改提示词碰运气。第二种是从自回归的语言模型啃起,理论上更接近当下主流的大模型形态,但它涉及的东西太多——数据配比、位置编码外推、训练并行、采样策略,每一项单独拎出来都能写一本书,入门阶段很容易被淹没。第三种就是从 BERT 这类编码器模型切入,它的结构是 Transformer 的“半截”,任务形式干净,训练成本极低,单张消费级显卡就能把预训练的一个微型版本跑完。
我自己的判断是:把编码器模型当成地基,把自回归模型当成上层建筑,顺序不能反。原因很实在——BERT 身上几乎所有概念都是后续模型的公共底座。多头注意力怎么切、残差和归一化放在哪、位置信息怎么注入、预训练任务怎么设计、下游任务怎么接一个轻量的头做微调,这套东西在你以后看任何模型结构图时都会反复出现。反过来,如果你一上来就啃自回归模型,注意力还没搞明白,就得同时应付 KV 缓存、长上下文外推这些更高级的工程问题,认知负荷直接爆掉。
还有一个容易被忽略的点:BERT 的调试反馈特别快。做文本分类,数据集几千条,十分钟一个 epoch,你改一个超参立刻能看到 loss 曲线变化。这种“改一下、马上看到结果”的循环对建立直觉太重要了。学大模型最怕的就是反馈周期长到三天,等你拿到结果,早忘了当时改的是什么。
当然,选 BERT 入门也有代价,最大的问题是它不擅长生成。你要是抱着“学完就能做对话”的期待来的,中间会有落差。我的处理办法是把目标和手段分开:目标是理解 Transformer 的信息流动方式,手段才是 BERT。生成能力是后面加解码器的事,地基阶段不用急。
1.2 学 BERT 到底能带走什么:四个可迁移的能力
很多人学完 BERT 只记住了“填空”和“判断下一句”,这太亏了。我把这门功课能带走的东西归成四块,每一块在后面都会反复用上。
第一块是注意力机制的手感。不是说会背公式,而是你能在脑子里画出一张形状表:输入是 (batch, seq_len, hidden),经过 Q、K、V 三个线性变换,注意力分数是 (batch, heads, seq_len, seq_len)。这个形状意识在排查维度报错时能救命,我见过太多人对着Expected size ... got ...的报错发呆,其实就是 heads 的维度没有正确 reshape。
第二块是预训练加微调这个范式。BERT 把这个范式做成了标准模板:先在大规模无标注语料上自学一个通用的语言表示,再针对具体任务加一个小头微调。你今天听到的很多训练流程,本质上还是这套东西的变体。理解了这个范式,你就知道为什么微调时学习率要小、为什么不能随便改动预训练层的初始化。
第三块是表征学习的直觉。BERT 输出的向量不是随机的,语义相近的句子在向量空间里靠得近。这个性质直接支撑了后面所有的语义检索、文本聚类、相似度计算。我在一个做客服工单归类的项目里,光靠 BERT 输出的向量加个简单的分类器就把人工分拣的工作量砍掉一大半,根本没用上什么花哨的模型。
第四块是工程习惯。数据处理管道怎么写、tokenizer 的坑有哪些、混合精度和梯度累积怎么配、验证集指标怎么选,这些是通用的,换个模型照样用。
1.3 这本书的定位:哪些章节该反复读,哪些可以快速翻过
拿到这本书我做的第一件事不是从头读,而是先画了一张目录地图。原理部分讲注意力、编码器结构、预训练任务的那几章,我建议你放慢速度,一个字一个字地过,尤其是注意力分数怎么算、多头是怎么切分又拼接的,这两处必须自己在纸上推一遍。中间讲数据处理和微调的章节,适合边读边开终端,读完一小节就敲一段代码验证。后面偏应用和部署的部分,第一遍可以快速翻过去,等你真正要上线的时候再回来查。
我的经验是:凡是带公式的段落,读的时候手边必须有纸笔;凡是带代码的段落,读的时候手边必须有能跑的编辑器。这本书最大的价值不在于它讲了什么新东西,而在于它把从零到跑通这段路上那些“默认你懂”的细节给补上了。比如为什么要在输入里加上特殊的分类标记、为什么句子的最大长度不能随便设得很大、为什么微调的学习率要比预训练小一到两个数量级。这些问题你在论文里基本找不到答案,但对能不能跑通影响巨大。
顺便说一句关于电子版的事。网上流传的电子版本我试过几个,公式渲染经常丢符号,个别还有章节错位,对着调试代码的时候非常容易误判。公式密集的段落建议用纸质版对照,电子版只用来做关键词检索。
2. Transformer 骨架:必须亲手推一遍的四个地方
2.1 注意力矩阵的形状与参数量:把数字算出来才算真懂
我先给一组具体数字,你跟着算一遍,整个编码器的规模感就出来了。假设隐藏维度 d=768,注意力头数 h=12,那么每个头的维度是 768/12=64。单层里 Q、K、V 三个投影矩阵,每个都是 768×768,参数量是 768×768≈59 万,三个加起来约 177 万。注意力输出还要再接一个 768×768 的投影,又是 59 万。光注意力模块一层就是 236 万参数左右。
再看前馈网络,标准配置是先把维度放大四倍到 3072,再压回 768,两个矩阵的参数量是 768×3072×2≈472 万。所以一层编码器大约是 236 万 + 472 万,接近 700 万参数,再加上两个归一化的缩放参数,基本可以忽略。
12 层堆起来就是 8400 万。再加上词表嵌入,假设词表三万,30000×768≈2300 万。总共约 1.07 亿参数,这就是常见的 base 规模。你把这个数字和书里给的表格对一下,如果对不上,多半是你把某个矩阵漏算了,或者把偏置项当成了大头——实际上偏置项在这个量级里几乎可以忽略。
为什么要费劲算这个?因为参数量直接决定显存占用和训练成本。我在带新人的时候发现,凡是能自己把这串数字推出来的人,后面估算显存、判断该用多大的 batch,基本不会出大错。算不出来的人,往往会做出“在 8G 显存上塞 256 的 batch”这种事。
2.2 位置编码与三种掩码:最容易写错又最不容易被发现的地方
Transformer 本身对顺序是无感的,你把句子里的词打乱,注意力算出来的结果只是行和列换了位置。所以必须额外注入位置信息。书里讲的是正弦位置编码,我建议你实现完之后做一个小验证:把同一句话的词序打乱,看输出向量有没有变化。如果没有变化,说明你的位置编码根本没加进去,或者加错了维度。
比位置编码更容易错的是掩码。至少三种,每一种写错都会导致 loss 看着在降但效果一塌糊涂。第一种是补齐掩码,一个 batch 里句子长短不一,短的要补零,注意力计算时必须把这些补齐位置屏蔽掉,否则模型会把注意力分到无意义的填充符上。第二种是因果掩码,只在解码器里用,让每个位置只能看到自己左边的内容,编码器不需要。第三种是预训练阶段对被人为遮掉的词的处理,这里涉及一个细节:被遮掉的位置本身在计算注意力时是否可见,不同实现的处理不一样,你得跟着自己用的那套流程走。
这三种掩码在代码里经常表现为一个加性矩阵,被屏蔽的位置加一个极大的负数,softmax 之后就接近零。我踩过最隐蔽的一个坑是:数据管道里生成的掩码张量类型是整型,直接加到浮点分数上触发了隐式类型转换,某些框架版本下不报错但结果偏差很大。排查了半天才发现是 dtype 的事。这个坑书上不会写,但只要你在做加性掩码,就值得加一行断言检查 dtype。
2.3 残差、归一化与预热:为什么你的模型总是训着训着就发散
残差连接和归一化的位置安排,是 Transformer 里最容易被当成“细节”忽略、又直接影响能不能训起来的部分。早期结构是归一化放在残差之后,后来大家发现这样深层模型的梯度不太理想,改成了归一化放在子层输入之前。你在读代码的时候一定要先确认清楚自己用的是哪一种,因为这两种写法的初始化策略和预热设置是不一样的。
预热这个事值得单独讲。学习率从零直接拉到目标值,在训练初期非常容易把模型带偏,因为那时候参数还是随机的,梯度方向噪声大。常见做法是前百分之几的步数里让学习率线性爬升,然后再按余弦或者线性衰减。我在一个小数据集上做过对照实验,不加预热的时候 loss 前两百步会剧烈震荡,加了一成的预热之后曲线平滑很多,最终验证指标也高了将近两个点。这个提升不算大,但在数据量小的时候尤其明显。
另外提一个常被忽略的参数:优化器里的数值稳定性项。默认值一般是 1e-8,如果你用混合精度训练,建议调到 1e-6 这个量级,否则小梯度在低精度下容易被吃掉。这一条是我在一个只有两千条样本的分类任务上试出来的,调完之后同样的配置下不再出现中间某几个 batch 的 loss 突然变成非数值的情况。
2.4 显存估算:把公式写出来,别靠试
显存大致分四块:模型参数、参数梯度、优化器状态、中间激活。前两块好算,参数量乘以精度字节数,梯度再来一份。优化器如果用带动量的自适应方法,通常还要额外存两份状态,也就是参数量的两倍。所以一个一亿参数的模型,用三十二位浮点训练,光是参数加梯度加优化器状态就要 4+4+8=16 字节每个参数,差不多 1.6G。
真正的大头是中间激活。粗略估算公式是:批大小 × 序列长度 × 隐藏维度 × 层数 × 一个系数。这个系数取决于你保留了多少中间结果,做梯度检查点的话能降到很小,不做的话可能到十几。举个例子,批大小 32、序列长度 128、隐藏维度 768、12 层,光是激活就可能是 32×128×768×12×10 这个量级,接近 3.8 亿个浮点数,一点五 G 左右。加上前面的一块,总共三 G 出头,这是一张八 G 卡能舒服跑起来的配置。
这个估算的意义在于,你可以先算再调,而不是一遍遍试到显存溢出。我通常的做法是先把序列长度定死,然后反推能塞多大的批。序列长度从 128 提到 256,激活大概翻倍;从 256 提到 512,再翻倍。所以很多时候把最大长度砍一半,比换一张卡见效更快。
3. 从原理到代码:预训练与微调的实操路径
3.1 分词器与数据管道:八成的问题都出在这一层
我在实际项目里统计过,模型效果不对的排查,最后定位到数据管道的比例接近八成,真正是模型结构写错的不到两成。所以这一层值得花最多的时间。
分词器第一件要确认的事是词表和对齐方式。中文处理有字符级、词级、子词级几种做法。字符级词表小、不会出现未登录词,但序列会变长;子词级能压缩长度,但对分词边界敏感。我一般的默认选择是子词级,然后拿真实业务语料跑一遍统计,看看平均长度和长度分位数。通常你会发现,百分之九十以上的样本长度都在某个值以下,最大长度按这个分位数来定,比拍脑袋设一个很大的值划算得多。我曾经在一个工单分类任务里把长度从 512 降到 128,准确率掉了不到半个点,但训练速度快了三倍多。
第二件事是补齐策略。把所有样本补齐到统一最大长度,是最好写但最浪费显存的做法。更好的方式是按 batch 内最长的那条补齐,或者把长度相近的样本分到一组。这个改动通常能省下三到四成的计算量,代价只是多写十几行数据整理逻辑。
第三件事是那些“看着无害”的输入字段。有的模型要求传片段编号,用来区分两个句子;有的需要传位置编号。这些东西在你只处理单句任务的时候,全部填零就行,但如果你的数据整理代码里忘了传,某些实现会默认报错,某些则会静默地用一个错误的值继续跑。后者最可怕。我的习惯是在数据管道出口处写一个断言,把每个张量的形状和取值范围都检查一遍,宁可多跑几秒,也别让脏数据流进模型。
3.2 继续预训练还是从零预训练:小团队该怎么选
书里讲预训练任务的部分,重点是遮词预测和下一句判断这两个目标。遮词预测的做法是随机挑一部分位置盖住,让模型根据上下文还原。这里有个比例分配的细节:被挑中的位置里,大部分替换成一个特殊的遮蔽符,一部分替换成随机词,还有一部分保持原样。为什么要这么绕?因为如果全换成遮蔽符,微调阶段根本见不到这个符号,会造成预训练和微调之间的分布错位。保持一部分原样,是为了让模型不要对“只要看到遮蔽符就必须猜”形成依赖。
那么实际工作中该不该自己从零预训练一个?我的答案是:绝大多数情况下不该。从零预训练需要的数据量和算力,个人和小团队根本扛不住,而且训出来的东西大概率不如公开的预训练权重。更务实的做法是继续预训练,也就是拿现成的权重,用自己领域的无标注语料再跑一轮遮词预测,学习率设得很小,步数控制在几千步以内。
这个做法在我的经验里收益挺明显。之前做一个专业领域的文档分类,直接拿通用权重微调,准确率卡在八十五上下。加了一轮继续预训练,用了大概二十万条领域内的无标注文本,三千步,学习率五乘以十的负五次方,然后再微调,准确率到了八十八点五。代价是多花了两个小时。这个投入产出比我觉得很划算。
要注意的是继续预训练之后,原来的一些下游头就不适用了,得重新接。另外步数不要贪多,跑太多轮会出现灾难性遗忘,模型把通用语言能力丢掉了。我的经验是盯着验证集上的遮词预测准确率,一旦开始掉头就停。
3.3 微调阶段的超参:给你一张可以直接抄的表
微调是这本书实战部分的重头戏。我把常用配置整理成一张表,这些值是我在多个分类和序列标注任务上试出来比较可靠的起点,不是绝对真理,但能让你少走弯路。
| 超参 | 分类任务常用值 | 序列标注常用值 | 说明 |
|---|---|---|---|
| 学习率 | 2e-5 ~ 5e-5 | 3e-5 ~ 5e-5 | 比预训练小一到两个数量级 |
| 批大小 | 16 ~ 32 | 8 ~ 16 | 显存紧张就用梯度累积凑等效批 |
| 轮数 | 3 ~ 5 | 5 ~ 8 | 小数据集容易过拟合,盯着验证集早停 |
| 预热比例 | 0.1 | 0.1 | 总步数的百分之十 |
| 梯度裁剪 | 1.0 | 1.0 | 防止个别 batch 梯度爆炸 |
| 权重衰减 | 0.01 | 0.01 | 归一化和偏置项一般不加 |
| 丢弃率 | 0.1 | 0.1 | 数据少时可以提到 0.2 |
关于批大小和学习率的关系,有个经验规则:批大小翻倍,学习率大致也可以翻倍。但这只在一定范围内成立,批大到一定程度收益就没了。我在一张八 G 卡上通常用批大小十六配合梯度累积两步,等效三十二,效果基本够用。
分类任务怎么接头?最朴素的做法是取第一个特殊标记位置上的输出向量,过一层丢弃再进一个线性层。这个位置在预训练阶段就被训练成“汇总整句信息”的角色,所以直接拿来用是合理的。但我见过有人取平均池化,效果也不差,甚至在某些任务上更好。这个没有定论,建议两种都试,用验证集说话。
序列标注则是每个位置都要出结果,直接在每个位置的输出上接线性层,注意要把补齐位置的损失屏蔽掉。这里有个细节:分词器可能把一个词切成了多个子词,标注是按词给的,你需要把词的标签对齐到子词上,通常是把标签赋给第一个子词,其余子词用一个忽略标记。这个对齐逻辑写错的话,指标会莫名其妙地低,但你看不出哪里错了。
3.4 评估与上线:别只看准确率
验证阶段我一般会同时看三个数:准确率、加权 F1、以及每个类别的召回。前两个是常规指标,第三个才是有用的。类别不均衡的时候,整体准确率可能很高,但少数类召回惨不忍睹。我在一个缺陷分类的项目里就吃过这个亏,模型报告九十二的准确率,上线之后业务方反馈“该报的都没报”,一查少数类召回只有四十。
阈值也需要单独调。模型输出的是概率,二分类默认用零点五做切分,但业务上两种错误的代价往往不对称。漏报代价高的时候,阈值要往下调;误报代价高的时候往上调。这个动作必须在验证集上做,而且要用独立于训练集的一批数据。
上线前还有一步常被省略:用真实的线上分布做一次回归。训练集和线上数据的分布往往有偏移,我见过模型在验证集上九十多分,上线后掉到七十的情况,原因是线上文本里有大量训练集没见过的格式符号。解决办法是在数据清洗阶段就把这些符号处理掉,或者把它们作为特殊字符加进词表。
4. 常见问题与排查实录
4.1 损失不降或者剧烈震荡,按这个顺序查
遇到训练不动,我一般按下面的顺序排查,大部分问题在前三步就能定位。
第一步看数据本身。把一批样本解码回原始文本,肉眼读十条,确认输入和标签的对应关系没串。这一步听起来很傻,但我至少三次发现是数据加载时随机打乱和标签索引没同步。
第二步看学习率。分类任务用五乘以十的负四次方以上,很容易出现前几十步 loss 往下掉然后突然起飞。把学习率降到二乘以十的负五次方,配上一成预热,通常就平了。
第三步看掩码。把注意力权重打印出来,看被补齐的位置权重是不是接近零。如果不是,说明掩码没生效。我遇到过一种情况是掩码的形状少了头这一维,广播之后语义完全变了,代码不报错,结果全错。
第四步看归一化和残差的位置有没有和权重初始化方式匹配。有些公开权重的结构和你自己写的代码结构不一样,直接加载会出现参数对不上的警告,很多人忽略这个警告继续跑,结果自然是乱的。
第五步才是怀疑模型容量和数据量不匹配。数据只有几百条的时候,一亿参数的模型百分之百过拟合。这时候要么减参数,要么冻结底层只训顶层,要么加数据。
4.2 显存溢出、报错与效果不达预期的速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 一开始训练就显存溢出 | 批大小或序列长度过大 | 序列长度砍半,或批大小减半加梯度累积 |
| 前几步正常后面突然溢出 | 某些 batch 长度偏长 | 按长度分桶,动态补齐 |
| loss 变成非数值 | 混合精度下梯度过小或学习率过大 | 提高优化器稳定性项,加梯度裁剪 |
| 验证指标远低于训练 | 过拟合或验证集泄漏 | 加丢弃、早停,检查是否有重复样本 |
| 训练指标本身就很低 | 标签对齐错误、掩码错误、学习率过低 | 解码样本核对,打印掩码 |
| 加载权重报参数不匹配 | 结构定义差异 | 逐层对照键名,只加载匹配部分 |
| 推理速度慢 | 序列长度过长、批大小为一 | 截断到分位数长度,批量推理 |
关于显存溢出,我再补一个实操技巧:先跑一个最小可复现的例子,批大小设成一,序列长度设成三十,确认能跑通。然后逐步加大,记录每次的最大显存占用,你就能画出自己这套配置的显存曲线,后面调参基本不用再试。
还有一个关于“效果不达预期”的坑值得单独说。很多人微调完发现效果不如直接拿预训练权重做特征提取加传统分类器。这种情况通常发生在数据量特别小的时候,比如每类只有几十条。这时候微调反而会破坏预训练学到的通用表示。我的做法是小于一千条样本先试特征提取加简单分类器作为基线,微调只有在基线之上才有意义。
5. 学习节奏与配套实战建议
5.1 四周节奏:从看懂公式到跑通一个完整项目
我自己带人时的节奏大概是四周。第一周只做两件事:把注意力机制的公式手推一遍,用几十行代码实现一个最小的单头注意力。这一周不碰任何预训练权重,目的纯粹是建立形状意识。
第二周实现一个两层的编码器,跑一个玩具任务,比如判断两个短句是不是同义。数据集自己造,两百条就够。这一周的目标是打通从数据到损失到反向传播的完整链路,把所有掩码都亲手写一遍。
第三周开始用公开的预训练权重做微调,找一个真实的分类数据集,把数据管道、超参、验证指标全走一遍。这一周会遇到最多的问题,也是收获最大的一周。
第四周做一件书上没写但很有价值的事:把训练好的模型导出,测一下推理延迟,看看在批量大小为一和批量大小为三十二时分别是什么表现,再试试把模型转成半精度,观察指标掉了多少。这一步做完,你对“能不能上线”这件事会有具体的感觉,而不是停留在“验证集分数挺高”的自我安慰里。
5.2 一份自测清单:判断自己是不是真的学会了
学完之后我建议用下面这几个问题自测,能全部答上来,说明这套东西你确实掌握了,而不是跟着教程抄了一遍。
- 给定隐藏维度和头数,你能在一分钟内说出注意力分数的张量形状吗?
- 补齐掩码和因果掩码分别用在什么位置,为什么编码器不用因果掩码?
- 预训练阶段的遮词比例为什么不是全部替换成遮蔽符?
- 微调学习率为什么比预训练小一到两个数量级?
- 显存不够的时候,除了减小批大小还有哪三种手段?
- 分词器把词切成子词之后,序列标注的标签怎么对齐?
- 少数类召回很低但准确率很高,你会先看哪个指标?
这几道题我拿来问过不少人,卡在第三题和第六题的最多。第三题卡住说明对预训练任务的理解还停留在表面,第六题卡住说明没真正做过序列标注的数据对齐。
最后分享一个我自己的体会。这本书我前后读了三遍,第一遍读完觉得自己懂了,第二遍边读边写代码发现到处是坑,第三遍是带人讲的时候,讲到哪里卡壳就说明哪里我没真懂。技术这个东西,读和讲的差距比想象中大得多。如果你手边正好有这本,别急着往后翻,把注意力那一章老老实实敲一遍代码,后面所有章节都会轻松很多。至于那些更大参数量的模型,等你把这一亿参数的结构摸透了再去看,会发现它们的结构图在你眼里已经没有秘密了,剩下的只是工程规模和训练技巧的差异。