llm-anatomy 是一个"把 LLM 拆开看"的教学连载:不背黑话,每一章把大模型的一个器官亲手造一遍,所有结论都配可复现的实验数字。
上一篇《拆开 LLM(三):手写 Transformer,换件成 LLaMA》我们把整台车焊了出来。这一篇是正传收官:聊聊怎么"用"模型——以及一个被行业悄悄埋掉的预训练任务。
一条日志里的讣告
先看一段真实输出。用 transformers 加载 bert-base-chinese 的原版权重时,日志里躺着这么几行(节选):
BertModel LOAD REPORT
Key | Status
-----------------------------+-----------
cls.seq_relationship.weight | UNEXPECTED
cls.seq_relationship.bias | UNEXPECTED
cls.predictions.… | UNEXPECTED
UNEXPECTED 的意思是:权重文件里有这几个参数,但你要加载的模型架构里没有它们的位置,扔掉了。cls.predictions.* 是 MLM(完形填空)头,加载纯编码器用不上,正常。但 cls.seq_relationship 这两行,是另一个故事的遗骸——它是 NSP(Next Sentence Prediction)的分类头。
一个 768→2 的线性层,1,538 个参数。2018 年,它是 BERT 两大预训练任务之一的输出端;2026 年,它躺在每一份 BERT 权重文件里,被每一次加载默默丢弃。没有讣告,没有发布会,一个曾经的"标配任务"就这么消失了。
这一章讲两个故事:NSP 是怎么死的,以及"微调"这件事这几年是怎么一路变形的。讲完就动手:冻结头和手写 LoRA,同一个任务上真跑,数字全真。
NSP 之死:训练目标错了,算力救不回来
BERT 的预训练是两个任务同时进行:
- MLM:随机遮住 15% 的词,让模型猜。这个活到今天还是各种编码器的看家本领。
- NSP:给两段文本,判断第二段是不是第一段的真实下文。正样本取真实相邻句,负样本从语料库里随机抓一段。原论文的动机很合理——问答、推理这类任务需要"句子之间"的理解,光会填词不够。
论文自己的消融实验还显示去掉 NSP 会掉点。看起来铁证如山,于是 NSP 成了标配。
然后 2019 年,它在半年内被连捅三刀:
- XLNet(2019.6):试了类似目标,没观察到收益,弃用。
- RoBERTa(2019.7):正面对决。控制变量重训 BERT,把 NSP 损失整个删掉、输入改成连续长文本拼满 512 token——下游成绩不降反升。原论文里 NSP 的"收益",更可能来自实验设置的耦合,而不是任务本身。
- ALBERT(2019.9):给出了病理报告。NSP 的负样本是从别的文档随机抓的——别的文档大概率聊的是别的话题,于是模型根本不用理解"连贯性",只要闻出"话题变了"就能答对。负样本设计得太松,任务就退化成了它最容易的那个影子:NSP 名义上教句间推理,实际上教的是主题分类,而主题信息 MLM 顺手就学了。ALBERT 的修法是 SOP:负样本改成"同一文档的相邻两段,调换顺序"——话题信号被抹掉,模型被逼着真学连贯性。
之后的事你知道了:ELECTRA、DeBERTa 没带 NSP,中文社区的 RoBERTa-wwm 系列没带 NSP,decoder-only 的 GPT 一脉从头到尾只做下一个词预测。NSP 就此下葬,墓碑就是开头那两行 UNEXPECTED。
这个故事值得放在收官章的开头,因为它是全系列最贵的一课:训练目标设计错了,堆再多算力也是白搭。 超参调错了浪费一次实验,架构选错了浪费一个项目,而预训练目标错了——每一张卡每一度电都在放大那个错误。BERT 用全谷歌的算力训 NSP,训出来的是一个用 1,538 个参数就能替代的主题嗅探器。
顺便说一个我写这章时才注意到的巧合:NSP 头是 768→2 的线性层,1,538 个参数——和下面实验里我们给冻结 BERT 配的分类头一模一样大。同一个形状的头,接错任务就是废物,接对任务能拿 90% 准确率。
微调范式变迁:每一代都在抠同一个字——贵
预训练模型出来了,怎么让它干你的活?这条路线的演化和第 0 章那条架构演化链一个脾气:每一代方案都在修上一代的"贵"。
全量微调:人手一份 102M
最直觉的做法:把整个 BERT 的 1.02 亿参数全部解冻,在你的任务上继续训。效果确实是天花板——但账很难看:
- 显存:AdamW 训练时每个参数要带一份梯度、两份动量状态。fp32 下 102M 参数光这三样就是约 1.2 GB,还没算激活值。BERT 时代咬咬牙能忍,放到 7B、70B 模型上就是几百 GB 起步。
- 存储:每个下游任务得到一份完整的模型拷贝,bert-base 一份约 400 MB。十个任务四个 G,一百个任务自己算。
- 部署:一百个任务就是一百个模型实例,没法共享。
2018-2019 年大家真就这么干,因为模型"才"一亿参数。这个方案没有错,它只是注定活不到十亿参数时代。
冻结头:穷人版微调
修"贵"的第一刀最粗暴:主干一个参数都不动,只在顶上训一个线性分类头。 BERT 从"可训练模型"降级成"特征提取器"——句子进去,768 维向量出来,向量之上是个和逻辑回归没区别的小头。
便宜到什么程度,下面实验里有真实数字。代价也明摆着:特征是预训练时冻结的通用特征,任务没法反过来重塑模型内部的注意力——头再怎么训,也只能在别人画好的特征空间里划线。
LoRA:给冻结的权重开一条旁路(2021)
全量太贵,冻结头太弱,中间这块空白 2021 年被 LoRA 补上,思路漂亮得很:
微调时权重的变化量 ΔW 其实是个低秩矩阵——任务适配根本用不着 768×768 的全部自由度。那就别动 W 了:把 ΔW 分解成两个小矩阵 B·A(秩只有 8),并联在原权重旁边。W 冻结,只训 B 和 A。推理时还能把 B·A 加回 W,一纳秒额外延迟都没有。
更妙的是部署账:主干全公司共用一份,每个任务只存一个 1.2 MB 的 adapter,切任务就是换 adapter。参数省 345 倍只是开胃菜,一张卡伺候一百个任务才是正餐。
API 微调:连卡都不用(2023 至今)
再往后连 LoRA 的运维都嫌贵:拉数据、配环境、盯训练,对多数团队都是负担。于是平台说——数据给我,剩下的别管。这条腿本文没有实跑,只在后面算账,先按下不表。
顺带划一条边界:本章从头到尾讲的是任务适配这条线——把通用底座掰成会干你的活。至于让模型"说话得体"的对齐微调(RLHF→DPO→RFT),是平行的另一条链,值得单独一篇,这里不展开。
实验设置:2000 条子集,如实交代
任务:ChnSentiCorp 中文情感二分类(酒店/书籍/电商评论,正负两极)。设备:MacBook(Apple Silicon,MPS 后端)。模型:bert-base-chinese 本地权重,local_files_only=True,全程不联网。
原始 train 有 9,600 条,我固定种子抽了 train 2,000 / val 500 的子集:
| |
为什么抽子集:这是台笔记本,我给每条路线的时间预算是 15 分钟以内,而按实测速度推算,全量 9,600 条跑完 3 个 epoch 的 LoRA 要一刻钟以上。缩到 2,000 条后每条腿几分钟收工,而两条路线的相对差距不受影响——我们要的是趋势,不是刷榜。所以下面的 acc 别拿去和论文榜单对齐,那些是 9,600 条全量喂出来的。
抽出来的子集是均衡的:train 正例 972 / 负例 1,028,val 正例 240 / 负例 260。基线(全猜负类)是 52%,请记住这个数。
路线一:冻结头,工程上等于"特征缓存"
冻结头有个很多教程不讲的工程推论:既然主干不更新,那每条句子的 [CLS] 向量在整个训练期间就是常数。 常数就该缓存——BERT 只前向一遍,把 2,500 个 768 维向量存成 npy 文件,之后分类头爱训多少轮训多少轮,BERT 再也不用出场:
| |
这个 trick 本身就是教学点:它把"冻结主干"这四个字的含义焊死了——冻结 = 把 BERT 降级成一次性特征提取器。 想通这一层,“为什么冻结头弱"也就想通了:特征落盘那一刻,模型对你的任务的全部理解就定格了。
坑 1:缓存特征前忘了 model.eval()。 dropout 还开着的话,同一句话每次前向的特征都不一样,你的线性头就在一堆随机噪声上训练。这个 bug 不报错、acc 还能有八十几,最难抓。
真实跑出来的账:
可训练参数: 1,538(只有线性头,BERT 的 102M 全部冻结)
特征缓存耗时: 20.1s(BERT 前向 2500 句,只跑这一遍)
线性头训练耗时: 0.04s(100 epoch 全批量)
val accuracy: 0.9040
看清楚这个结构:20.1 秒是 BERT 的一次性买路钱,之后训练 100 个 epoch 只要 0.04 秒——因为所谓"一个 epoch"只是一次 2000×768 的矩阵乘。1,538 个参数,90.4% 准确率,52% 的基线被按在地上摩擦。BERT 的预训练特征白送你 38 个点,这就是 2018 年那场革命的含金量。
路线二:手写 LoRA,不用 peft
到了全章的灵魂。LoRA 的核心是一个包装类,硬核部分不到 30 行:
| |
逐段拆:
self.base + requires_grad = False:原来的 768×768 投影原封不动地留在原地,只是被冻住。LoRA 不修改任何预训练权重——它是并联,不是替换。
lora_A 是 8×768,lora_B 是 768×8:输入先被 A 压到 8 维,再被 B 弹回 768 维。两个矩阵的乘积 B·A 是一个 768×768 的矩阵,但它的秩最多只有 8——这就是"低秩旁路"的字面意思。你花 12,288 个参数,租下了一个 589,824 参数矩阵的"秩 8 切片”。
lora_B 全零初始化——整个设计里最讲究的一笔。B 是零,B·A 就是零,旁路在第 0 步等于不存在:注入前后的 BERT 逐位等价,训练从"完全是原模型"出发,梯度来一点,才偏离一点。 反过来,如果 B 也随机初始化,第 0 步就往预训练权重的输出里灌了一泡随机噪声,你精心继承的预训练知识开局就被污染(文末"弄坏"环节请亲手试)。A 保持随机则是为了给梯度留活路——A、B 都是零的话 ∂L/∂A 恒为零,旁路永远学不动。
scaling = alpha / r:旁路输出的固定倍率。它存在的意义是解耦两个旋钮——改 r 做实验时,α/r 会自动补偿旁路的量级变化,学习率不用跟着重调。
坑 2:nn.Linear 的权重形状是 (out, in),前向是 y = x·Wᵀ。 所以旁路要写成 x @ A.T @ B.T,和论文里"B·A·x"的列向量写法互为转置。这里搞反了,报错信息是维度对不上还算走运,最怕 r 恰好凑出能广播的形状、静悄悄算错。
注入环节。挑 BERT 每一层注意力的 query 和 value 投影,原地换成包装版:
| |
为什么是 q 和 v? 这是 LoRA 原论文用消融换来的配方:同等参数预算下,把预算摊到 Wq 和 Wv 两处(哪怕每处秩小一点),比全押在任何单个矩阵上都好,而只挂 Wk 的收益垫底。直觉上也说得通:q 决定"每个位置去关注谁",v 决定"被关注时交出什么"——一个管寻址、一个管内容,微调想改变的正是这两件事。至于 FFN 挂不挂,论文当年为控制变量没研究,后来社区实践(比如 QLoRA)发现全挂更稳——这条留在"弄坏"环节给你自己验证。
组装成分类器时,有个顺序问题值得用代码钉住:
| |
坑 3:先冻结,后注入,顺序不能反。 新建的 nn.Parameter 默认可训练,所以注入放在冻结之后,LoRA 参数天然存活。反过来先注入再执行"冻结全部 bert 参数",那个循环会把 lora_A、lora_B 一起冻掉——模型照样能跑、loss 照样在降(因为分类头还活着),你以为在训 LoRA,其实在训一个昂贵的冻结头。不报错的坑才是好坑。
坑 4:改 r 忘了 scaling 联动。 如果你自己实现时把 alpha / r 写死成常数,砍 r 的实验会连学习率一起砍废。α/r 不是装饰,它是"r 是容量旋钮、α 是量级旋钮"这个解耦的实现。
跑起来(3 个 epoch,只训 LoRA + 分类头;逐 epoch 数字来自训练日志,未入 results.json):
总参数: 102,564,098
可训练: 296,450(LoRA 294,912 + 分类头 1,538),占比 0.29%
epoch 1: train loss 0.3950
epoch 2: train loss 0.2219
epoch 3: train loss 0.1876
训练耗时: 207.2s(3 epoch × 2000 条)
val accuracy: 0.9100
对账时刻
把冻结头、LoRA 和全量微调的账放在同一张桌上(全量那行是参数账的理论值,没有实跑,它的角色是"贵"的参照系):
| 路线 | 可训练参数 | 占全量比 | val acc | 耗时 |
|---|---|---|---|---|
| 全量微调(理论值,未实跑) | 102,269,186 | 100% | - | - |
| 冻结头(特征缓存+线性探针) | 1,538 | 0.0015% | 0.9040 | 20.1s |
| LoRA r=8(3 epoch) | 296,450 | 0.29% | 0.9100 | 207.2s |

左图是对数轴——每条横杠之间差的不是几倍,是两三个数量级。 右图那两根几乎一样高的柱子,是这次实验里我最喜欢的结果,因为它诚实:
LoRA 花了 193 倍的参数、10 倍的时间,只比冻结头多拿 0.6 个点。更有意思的是中间过程:LoRA 跑到第 2 个 epoch 时 val acc 是 0.9040——和冻结头一分不差打平(都是 452/500),第 3 个 epoch 才拉开到 0.9100。为什么优势这么小?因为这个任务太便宜了:情感二分类的信号在 BERT 预训练特征里已经接近线性可分,冻结特征上画一条线就能到 90%。LoRA 重塑注意力的能力,得在更硬的任务上才能兑现——多分类、领域漂移、生成式任务,那才是它和冻结头拉开身位的地方。
先跑冻结头永远不亏:20 秒告诉你任务的地板在哪,再决定值不值得掏出重武器。
LoRA 的参数账,算给你看
294,912 这个数不是黑箱,掰开是四步乘法:
- 一个注入点:A 是 8×768 = 6,144,B 是 768×8 = 6,144,合计 12,288
- 每层挂 q、v 两处:12,288 × 2 = 24,576
- BERT 12 层:24,576 × 12 = 294,912
- 加上 768→2 的分类头 1,538,可训练合计 296,450
对比全量微调的 102,269,186:0.29%,1/345。 第 0 章自测题第三问——“LoRA 的可训练参数凭什么能压到全量的千分之几”——答案就这四行乘法,没有魔法,只有 r=8 远小于 768。
这笔账在训练侧和部署侧各兑现一次。训练侧:AdamW 的梯度加两份动量是每参数 12 字节,全量微调光这三样就约 1.2 GB,LoRA 只要约 3.6 MB(激活值该存还得存,反传要穿过冻结层,这个 LoRA 不省)。部署侧:全量微调每个任务存 400 MB 的完整拷贝,LoRA 每个任务存 1.2 MB 的 adapter——同一份主干权重,配一抽屉 adapter,这就是 2021 年之后"一模型伺候百业"的物质基础。
第三条腿:API 微调——只算账,不贴假数字
把这条写全是为了对第 0 章的预告负责,但把话说在前头:这条腿本文没有实跑,下面没有任何伪装成实验结果的数字。
流程比前两条腿加起来还短:把数据整理成对话格式的 JSONL,传到平台,点开始,等一个专属 model id,然后照常调 API。卡不用买,环境不用配,训练不用盯——平台在你看不见的地方替你跑了(大概率就是)某种 LoRA 变体。
什么时候该选它?三个条件同时成立时:团队没有 GPU 和炼丹工程师;需求是风格和格式的对齐(客服口吻、结构化输出、领域术语),而不是往模型里灌私有知识;数据在合规上允许出域。三条全中,它就是最快的路。
代价也要摆在桌面上,我最介意的是这四条:
- 数据出域:训练数据必须交给平台。多数场景无所谓,医疗、金融、内部代码——你自己掂量。
- 权重通常不归你:拿到的是一个 model id,不是模型。平台若下线基座模型,你的微调成果跟着陪葬。(也开始有例外:Thinking Machines 的 Tinker 这类托管微调,练完的 LoRA checkpoint 可以下载带走——但那托管的是开源模型,闭源基座依然只给 id。)
- 可控性归零:想看训练曲线?想调注入点?想知道为什么效果不好?黑箱不回答这些问题。
- 成本结构变了:训练费一次性,但微调模型的推理单价通常更高——量大之后,这笔账未必比自己挂 LoRA 便宜。
一句话总结三条腿:冻结头是探路兵,LoRA 是主力部队,API 是雇佣军——雇佣军好用,但你得接受打完仗武器归人家。
地图走完了
第 0 章画过一张演化全图,主干是六代架构的修 bug 链,底部拖着一条绿色的微调范式链。现在,图上每一块都被亲手造过了:
- 第 1 章:手写 autograd——梯度从哪来。
- 第 2 章:手撕注意力——那条被后来所有优化围着转的公式。
- 第 3 章:手搭 Transformer,逐件换成 LLaMA——整车出厂。
- 第 4 章:冻结头、LoRA、API——车造好了怎么开,以及 NSP 这条歧路是怎么被埋掉的。
从一条链式法则出发,走到能徒手给 BERT 挂 LoRA,“从一条公式到 LLaMA"的地图闭环了。现在回头看第 0 章那三道自测题——KV cache 缓存什么、FlashAttention 省什么、LoRA 凭什么——如果它们已经从黑话变成了常识,这个系列的任务就完成了。
试一试(包括把它弄坏)
git clone https://github.com/KerroKapple/llm-anatomy && cd llm-anatomy
uv run python ch04/data.py # 看子集统计
uv run python ch04/frozen_head.py # 路线一:特征缓存 + 线性探针
uv run python ch04/lora.py # 路线二:手写 LoRA(可传 epoch 数:lora.py 1)
uv run python ch04/compare.py # 汇总表 + 对比图
(数据集用 ChnSentiCorp csv,BERT 权重请自备本地目录,路径在 ch04/data.py 顶部改。)
跑通只是及格,把它弄坏才算学会:
- 污染起点:把
lora_B的初始化从torch.zeros改成torch.randn(...) * 0.02,重跑lora.py。看第一个 epoch 的 loss 起点比 B=0 时高多少——你灌进去的那泡随机噪声,模型得先花梯度把它擦干净。这就是"B 零初始化保证起点等价"的反面教材。 - 把秩砍到骨头:
inject_lora(self.bert, r=1, alpha=2),可训练参数掉到 38,402。看 acc 掉不掉——如果几乎不掉,说明这个任务需要的 ΔW 连秩 1 都填不满,“微调的本征秩很低"这句论文摘要就成了你亲手验证过的事实。 - 换注入点:把
inject_lora里的 q/v 两行换成 FFN 的layer.intermediate.dense和layer.output.dense(注意这两个是 768↔3072 的矩形矩阵,A、B 形状会自动跟着in_features/out_features走)。同预算下比一比 acc,你就有了自己的"注入点消融表”——论文没替你做的实验,自己做。 - 抓一次坑 3:故意把冻结循环挪到
inject_lora之后,打印sum(p.numel() for p in model.parameters() if p.requires_grad)。看着它从 296,450 变成 1,538——这就是"以为在训 LoRA,其实在训冻结头"的现场。
系列收官与番外投票
五章正传到此收官。说好的不膨胀,就不膨胀——但第 0 章埋过两个番外候选,写不写、先写哪个,评论区投票说了算:
- 《2026 还要不要学 RNN》:不抛情怀,用三条真实训练曲线(RNN vs LSTM vs 单层 Transformer,同数据同预算)说话。
- 《fasttext vs LLM 的三笔账》:分类任务上把延迟、成本、acc 三笔账真实算一遍——什么时候一个 2016 年的词袋模型仍然是对的工程答案。
想看哪篇,评论区吼一声。都想看的话,那就都写——反正番外不占正传编号,不算膨胀。
仓库在 github.com/KerroKapple/llm-anatomy,每章一个目录,所有实验数字可复现。有问题欢迎开 issue 讨论。
