Day07 | BERT 中文微调实战:Transformer 的另一半
苦猿的大模型日记 · Day07 · BERT 中文微调实战-帮普通人把AI学进简历系列
前言:一个反直觉的招聘数据
我前两天干了一件事——在 BOSS 直聘上手动数了 100 条"AI 算法工程师"的 JD。
我想搞清楚一个问题:国内招聘市场,到底更想要会 BERT 的人,还是会 GPT 预训练的人?
LLM 时代,按理说 GPT 那一套才是主旋律。
结果是——
100 条 JD 里,提到"BERT 微调"或"会用 BERT/RoBERTa 做下游任务"的,有 47 条。
提到"会 GPT 预训练"或"训练过 LLM"的,只有 8 条。
差了快 6 倍。
我当时盯着这个数字看了半天,突然想明白一件事——
LLM 是聚光灯下的叙事,BERT 是简历上的门槛。
大厂训练大模型的人就那么多,但每家做 NLP 落地的公司——做搜索、做推荐、做客服、做风控——都需要会微调 BERT 的工程师。
我顺手又翻了下我带的几个学员——
简历上写了 BERT 微调项目的,面邀率比没写的高 3 倍。
写"会调用 GPT API"的,几乎没差异。
为啥?
因为"会调 API"证明不了工程能力——会调 API 的人太多了。
但"会微调 BERT"证明的是——你会装 CUDA、会用 HuggingFace、会调超参、会避坑。
是一整套真本事。
所以 Day07 我决定先拐个弯。
按 Day06 结尾的预告,本来该进"GPT 预训练主线"。但这个招聘数据告诉我:先把 BERT 这一支讲透,对读者简历的边际收益更高。
而且,BERT 对普通人特别友好——
- 不挑硬件:单卡 16GB 显存够用,CPU 都能跑(虽然慢)
- 不挑数据:千条级别就能微调出可用的模型
- 不挑领域:情感、意图、NER、句对、检索都能套
门槛低、ROI 高、简历认可度高——这就是为啥 Day07 必须先讲它。
而且 Day06 PART 06 我自己埋了个钩子——
"Encoder 看全句,BERT 那一派;Decoder 只看前文,GPT 那一派——后者赢了。"
今天,就把这半句补完。
读完 Day07,你能:
- 搞懂 BERT 跟 GPT 的本质区别(不止 Encoder vs Decoder,训练任务也完全不同)
- 知道
[CLS]/[SEP]/[MASK]这三个特殊 token 各自干嘛 - 理解 BERT 的两个预训练任务:MLM(完形填空)和 NSP(下一句预测)
- 用 HuggingFace
transformers三行代码加载bert-base-chinese - 在 ChnSentiCorp 中文评论数据集上微调 BERT,跟 Day05 LSTM(88%) / Day06 mini-Transformer 正面对比
PART 01:Encoder vs Decoder——Transformer 的两派之争
回到 Day06。
我们在 PART 06 拆 Transformer 架构时,画过一张图——完整的 Transformer 有两部分:Encoder 和 Decoder。
- Encoder:每个词同时看到前后文,双向
- Decoder:每个词只能看前面,单向(因为有 Masked Attention)
那篇论文 *Attention Is All You Need* 当年是用来做翻译的,Encoder + Decoder 都用上。但后来的人发现——这两半可以单独拎出来用,而且各自长出了一派:
| 派系 | 用哪半 | 代表模型 | 擅长 |
|---|---|---|---|
| Encoder 派 | 只用 Encoder | BERT / RoBERTa / ALBERT | 理解——分类、NER、检索 |
| Decoder 派 | 只用 Decoder | GPT 系列 / Llama / Qwen | 生成——对话、写代码、写文章 |
| Encoder-Decoder | 两半都用 | T5 / BART | 翻译、摘要 |
为啥 Decoder 在生成任务上赢麻了
Decoder 的核心动作叫 auto-regressive(自回归)生成——
给一段前文 → 预测下一个词 → 把这个词拼回输入 → 再预测下一个 → ……循环
GPT 写文章就是这么一个字一个字"吐"出来的。Masked Attention 让它只能看前面,跟"边写边读"的生成动作天然契合。
举个真实生成过程——
输入:"今天天气真好,我们去"
第 1 步生成:"公园" → 当前序列:"今天天气真好,我们去公园"
第 2 步生成:"散步" → 当前序列:"今天天气真好,我们去公园散步"
第 3 步生成:"。" → 结束
每一步都只能基于已生成的前文,看不到"未来"。这就是 auto-regressive。
而 Encoder 的双向机制反而不能用来做生成——你能看到后面那个词还没生成呢,逻辑上不成立。
所以——生成任务的市场更大,对话、写代码、写文案,OpenAI 押注 Decoder-only,GPT 系一路赢到现在。
但不代表 Encoder 派死了
BERT 在"理解型 NLP 任务"上,至今统治企业落地。
举个具体场景——
假设你是一家电商的算法工程师,每天要给 5000 万条商品评论自动分类(正向/负向/吐槽/物流/质量)。
两条路:
- 路线 A(LLM):每条调一次 GPT-4 API,单次 0.01 元 → 每天 50 万
- 路线 B(BERT):微调一次后本地部署,单次推理 ≈ 0 → 每天 0
一个月就是 1500 万 vs 0 的差距。
LLM 自己也能干这些,但贵。
所以——
会调 GPT API 不稀奇,会微调 BERT 才是真门槛。
一句话总结这两派:
"读"用 BERT,"写"用 GPT。

PART 02:BERT 长啥样——三个特殊 token 的玄机
派系搞清楚了,看 BERT 具体长啥样。
第一步,是搞懂它的输入格式。
BERT 的输入不是直接一串中文,而是会在头尾加几个特殊 token——这三个 token 各有玄机。
[CLS]:每句话开头的"摘要员"
每句话开头必加 [CLS](Classification)。
它不是简单的"句首标记"——它的最终隐藏状态,就是整句话的语义向量。
做分类任务时,你不取所有词的输出,只取 [CLS] 这一个位置,扔给分类头。
为啥?因为 BERT 在预训练时就被设计成"让 [CLS] 这一位去总结整句"。
理解这一点,你就理解了 BERT 做分类为啥比 LSTM 强——LSTM 是把所有词的隐藏状态池化平均,BERT 是专门训练了一位"摘要员"。
[SEP]:句子的边界
句与句之间加 [SEP](Separator),告诉模型"这是两句话"。
这个主要用于句对任务——比如判断两句话是否矛盾、问答的"问题-答案"对。
[MASK]:预训练时的"被盖住的词"
[MASK] 是 BERT 预训练时用的——随机盖住 15% 的词,让模型猜。
注意——只在预训练阶段出现,微调时不会用到。但理解它的作用,是搞懂 BERT 怎么"学会语言"的关键(PART 03 展开)。
一个真实例子
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
tokens = tokenizer("这部电影太好看了")
print(tokenizer.convert_ids_to_tokens(tokens["input_ids"]))
# ['[CLS]', '这', '部', '电', '影', '太', '好', '看', '了', '[SEP]']
注意——bert-base-chinese 是字级别的,每个汉字一个 token,不是词级别。
字级别 vs Subword——一个反直觉的设计
这里有个反直觉点——英文 BERT(bert-base-uncased)不是字级别,而是 WordPiece 子词。
比如单词 "playing",英文 BERT 会拆成 ["play", "##ing"] 两个 token——既保留了词根语义,又能控制词表大小(英文 BERT 词表约 30K)。
而 bert-base-chinese 为什么走字级别?
因为中文没有"词"的明确边界——"上海"是一个词还是两个字?"比亚迪"是三个字还是一个公司名?分词本身就是个 NLP 难题。
走字级别绕开了分词争议,且中文汉字总数也就 2 万多常用字,词表很好控制。
这个差异会影响 max_length 的设置——
- 中文:128 token ≈ 128 字(够装 90% 评论)
- 英文:128 token ≈ 90-100 单词(短了点)
所以别照搬英文教程的 max_length,中文按字数推。
金句:[CLS] 是 BERT 给每句话配的"摘要员"——一个人代表整支队伍。
![一句话被 tokenizer 拆成 token 序列,[CLS] / [SEP] / [MASK] 的位置](https://i-blog.csdnimg.cn/img_convert/0a7966e6641f8a21db7d574d752d679b.png)
PART 03:BERT 是怎么"预训练"出来的——MLM + NSP
[MASK] 引出一个核心问题——
BERT 是怎么学会用 [MASK] 猜词的?
答案:预训练。
BERT 的预训练有两个任务,搞懂这两个,你就搞懂了 BERT 的"内功"。
任务 1:MLM(Masked Language Model,完形填空)
MLM 的玩法:从一句话里随机盖住 15% 的词,让模型根据上下文猜出被盖住的词。
比如:
原句:这部电影太好看了
MLM 输入:这部电影太[MASK]看了
模型预测:[MASK]= "好"
关键点——模型能同时看到 [MASK] 前面和后面的词(双向)。
这就是 Encoder 派的核心优势:LSTM 只能根据前面猜,BERT 能根据前后一起猜。
类比一下——
MLM 就是给 BERT 做了 100 亿次的完形填空题。
做完这 100 亿题,BERT 就"学会语言"了。
任务 2:NSP(Next Sentence Prediction,下一句预测)
NSP 的玩法:给模型两句话 A 和 B,让它判断"B 是不是真的跟在 A 后面"。
例:
A:今天天气真好
B:我们去公园散步吧 → 标签:是下一句
>
A:今天天气真好
B:Python 是一门编程语言 → 标签:不是下一句
这个任务让 BERT 学句间关系,对问答、句对任务有帮助。
但有个反直觉的事实——
后来 RoBERTa 论文(Facebook,2019)证明:NSP 其实没啥用,去掉反而更好。
具体怎么个"没用"法——
RoBERTa 做了对照实验:保留 NSP 和 去掉 NSP 训了两个一样的模型,在 GLUE 基准上对比——去掉 NSP 的版本几乎所有任务都更准。
为啥?因为 NSP 任务太简单——模型只要看两句话的"主题相关性"就能猜出答案,没真学到句间逻辑,反而干扰了 MLM 的学习信号。
所以后来主流 BERT 变体(RoBERTa / ALBERT / DeBERTa)都改了预训练配方,但分类头、[CLS] 这些下游接口跟 BERT 一致——你今天学的微调代码,换模型名就能用。
但原始 BERT 就是这么训的,我们理解原理即可——不需要纠结 NSP 该不该有。
预训练的代价
BERT-base 的预训练规模:
- 16GB 纯文本(BooksCorpus + 英文 Wikipedia)
- 33 亿词
- 4 张 Cloud TPU v3,训了 4 天
按当前云市场价折算——单次预训练成本 1 万美金起。
中文版 bert-base-chinese:在中文维基百科 + 百度百科等语料上重新训的,规模量级类似。
这就是为啥你不能自己预训练 BERT——
烧不起那个钱。
横向对比一下,把这种规模感拉满——
| 训练任务 | 数据量 | 硬件 | 成本 |
|---|---|---|---|
| Day05 LSTM 微调 | IMDb 25K 条 | 你的笔记本 | 5 分钟 / 0 元 |
| Day06 mini-Transformer | IMDb 25K 条 | 你的笔记本 | 8 分钟 / 0 元 |
| Day07 BERT 微调 | ChnSentiCorp 7.6K 条 | 你的笔记本 | 10 分钟 / 0 元 |
| BERT 预训练 | 33 亿词 | 4 张 TPU v3 | 1 万美金 + 4 天 |
| GPT-3 预训练 | 5000 亿词 | 上万张 A100 | 460 万美金 |
最底下两行——就是为啥 HF 模型超市存在。
别人烧了钱,你拿来微调——这就是 2026 年做 NLP 的标准姿势。
那你能干啥?
用别人预训练好的 BERT,"搬"到自己的任务上——这叫微调。
这是 Day07 的核心动作。
金句:BERT 的预训练是别人烧的钱,你的活是把它"搬"到自己的任务上——这叫微调。
![MLM 完形填空:根据上下文双向推断被 [MASK] 盖住的词](https://i-blog.csdnimg.cn/img_convert/92acaf2e22e5eaa8b27f6d78063320d8.png)
PART 04:HuggingFace transformers 入门——三行代码加载 BERT
预训练是别人烧的,我们怎么"搬"?
答案是 HuggingFace transformers 库——NLP 界的"模型超市",几乎所有公开的预训练模型都在这里。
安装
pip install transformers datasets
国内镜像(重要)
第一次加载 bert-base-chinese 会下载约 400MB 权重文件,国内直连 HuggingFace 很慢甚至超时,强烈建议设镜像:
export HF_ENDPOINT=https://hf-mirror.com
Windows PowerShell:
$env:HF_ENDPOINT = "https://hf-mirror.com"
三行代码加载
from transformers import AutoTokenizer, AutoModelForSequenceClassification
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-chinese", num_labels=2
)
就这么点。三个对象,每个都值得说一下——
AutoTokenizer:把中文拆成 token + 转 ID(PART 02 那个[CLS] 这 部 电...就是它干的)AutoModelForSequenceClassification:直接给你一个"带分类头"的 BERT——预训练权重已加载好,顶上的分类头是随机初始化的num_labels=2:ChnSentiCorp 是二分类(正向/负向),所以是 2
AutoModel vs AutoModelForSequenceClassification——一个高频踩坑
新手常误用 AutoModel:
# 错误写法
from transformers import AutoModel
model = AutoModel.from_pretrained("bert-base-chinese") # 只到 Encoder,没有分类头
output = model(input_ids)
# output.last_hidden_state 形状 [B, seq_len, 768]
# 没法直接拿去分类,得自己接一层 Linear
AutoModel 只加载到 Encoder 部分,输出是每个 token 的 768 维向量,没有分类头。
要分类任务,必须用 AutoModelForSequenceClassification——它内部已经帮你接好了 [CLS] → Linear(768, num_labels) 的分类头。
加载时那两行 warning——别慌
第一次加载你会看到:
Some weights of the model checkpoint at bert-base-chinese were not used
when initializing BertForSequenceClassification: [cls.predictions...,
cls.seq_relationship...]
- This IS expected if you are initializing BertForSequenceClassification
from the checkpoint of a model trained on another task...
翻译成人话——
预训练时 BERT 有两个头(MLM 头和 NSP 头),微调时换成分类头,所以那两个头的权重没被加载,扔了。
这是正常行为,不是错误。
紧接着还有一行:
Some weights of BertForSequenceClassification were not initialized
from the model checkpoint and are newly initialized:
[classifier.weight, classifier.bias]
- You should probably TRAIN this model on a down-stream task...
这就是 PART 03 说的"分类头随机初始化"——这正是你需要微调的原因。
注意一个反差点——
Day06 你手写 mini-Transformer 训了 ~89%,所有权重从随机初始化开始。
今天加载的 BERT,预训练权重已就位——它的起点不是 0,是已经会中文。
顺带提一句 HF 还有个更省事的快捷方式——
from transformers import pipeline
clf = pipeline("sentiment-analysis", model="bert-base-chinese")
print(clf("这家酒店真不错"))
# [{'label': 'LABEL_1', 'score': 0.99}]
但这是直接用预训练权重 + 随机头,没在你的数据上微调过——准确率不会高。
快捷方式适合做 demo,正经项目还得走 Trainer 微调——下一节展开。
Day06 你从 0 搭 Transformer 学原理,今天你从"已会语言"的 BERT 起步学微调——这就是预训练模型的意义。

PART 05:实战——bert-base-chinese 微调 ChnSentiCorp
模型加载好了,开干。
数据集简介
ChnSentiCorp——中文评论情感二分类数据集。
- 来源:酒店、书籍、电脑三个品类的中文评论
- 规模:训练集 7659 条 / 验证集 960 条 / 测试集 1200 条
- 标签:正向(1)/ 负向(0)
跟 Day05 的 IMDb 同任务不同语言——刚好可以做跨篇对比。
加载数据
from datasets import load_dataset
ds = load_dataset("seamew/ChnSentiCorp")
print(ds)
# DatasetDict({
# train: Dataset({features: ["text", "label"], num_rows: 7659})
# validation: Dataset({..., num_rows: 960})
# test: Dataset({..., num_rows: 1200})
# })
看几条样本感受一下数据:
for i in [0, 100, 5000]:
sample = ds["train"][i]
print(f"label={sample['label']} text={sample['text'][:60]}")
# label=1 选择途安的理由。1、总起来说看中途安的外观,比较低调...
# label=1 地理位置非常好,就在步行街旁边,购物吃饭都很方便...
# label=0 这破书也能出版?错别字一大堆,作者是不是没校对...
label=1 是正向,label=0 是负向。正负样本比例约 6:4,不算特别不均衡,可以不做重采样。
tokenize
把文字喂给 BERT 之前,先转成 ID 序列:
def tokenize(batch):
return tokenizer(
batch["text"],
padding="max_length",
truncation=True,
max_length=128,
)
ds = ds.map(tokenize, batched=True)
这里有两个踩坑高发点——
max_length=128:中文评论很多 2000+ 字,BERT 最大只支持 512,不设上限直接 OOM。128 对 ChnSentiCorp 够用了。truncation=True:超过 max_length 自动截断。忘记加这个,batch 里长度不齐直接报错。
Trainer——HuggingFace 的训练循环
跟 Day04/Day05/Day06 我们手写的训练循环不同,HuggingFace 提供 Trainer 封装——一行 trainer.train() 完成所有训练逻辑。
from transformers import TrainingArguments, Trainer
import numpy as np
from evaluate import load
accuracy = load("accuracy")
def compute_metrics(eval_pred):
logits, labels = eval_pred
preds = np.argmax(logits, axis=-1)
return accuracy.compute(predictions=preds, references=labels)
args = TrainingArguments(
output_dir="./bert-chnsent",
num_train_epochs=3,
per_device_train_batch_size=16,
learning_rate=2e-5,
weight_decay=0.01,
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="accuracy",
)
trainer = Trainer(
model=model,
args=args,
train_dataset=ds["train"],
eval_dataset=ds["validation"],
compute_metrics=compute_metrics,
)
trainer.train()
逐参数解释(这些数字是 BERT 微调的事实标准)——
| 参数 | 取值 | 为啥 |
|---|---|---|
num_train_epochs=3 |
3 | BERT 微调一般 2-5 epoch,多了过拟合 |
per_device_train_batch_size=16 |
16 | 16GB 显存的极限,再大 OOM |
learning_rate=2e-5 |
2e-5 | BERT 微调标准 lr,太大权重打飞 |
weight_decay=0.01 |
0.01 | L2 正则,防过拟合 |
evaluation_strategy="epoch" |
epoch | 每个 epoch 结束跑一次验证 |
load_best_model_at_end=True |
True | 训练结束加载验证集最优的 checkpoint,不是最后一个 |
compute_metrics 的作用——
默认 Trainer 只输出 loss,不会自动算 accuracy。compute_metrics 是一个回调函数,每个 epoch 末被调用,把模型输出和真实标签传给你,你返回什么指标,日志里就显示什么。
按回车。
单卡 5-10 分钟,3 个 epoch 跑完。
结果
验证集准确率,典型值在 93-94%。
我贴一下我本地一次跑的真实日志(数值仅供参考,你跑可能上下浮动 1-2 个点):
Epoch 1: train_loss=0.32, eval_accuracy=0.912
Epoch 2: train_loss=0.14, eval_accuracy=0.931
Epoch 3: train_loss=0.08, eval_accuracy=0.935
把这个数字跟前两篇放一起——
| 模型 | IMDb/ChnSentiCorp 准确率 | 单次训练成本 | 简历可写度 |
|---|---|---|---|
| Day05 LSTM | 88% | 5 分钟 | "会用 RNN 做文本分类" |
| Day06 mini-Transformer | ~89% | 8 分钟 | "理解 Transformer 架构" |
| Day07 BERT | 93%+ | 10 分钟 | "会用预训练模型 BERT 微调" |
准确率涨了 5 个点。
但这 5 个点不是"BERT 算法更好"那么简单——是预训练知识迁移到了下游任务。
Day05 的 LSTM 是从 0 学认字。
Day06 的 mini-Transformer 也是从 0 学,只是架构更好。
Day07 的 BERT——带着 33 亿词的语料功底来考试。
起点就不一样。
金句:LSTM 是从 0 学认字,BERT 是带着 33 亿词的功底来考试——起点就不一样。

PART 06:从训练完成到上线——部署 + ONNX 加速
到这里你已经训出了一个 93% 准确率的 BERT。
但面试官不会只问你训出多少准确率——
下一句往往是:"那你这个模型,部署过吗?推理延迟多少?"
这是简历的"第二行门槛"——
会微调只值一行简历,会部署+加速值第二行。
Step 1:先看 PyTorch 原生推理的基线
# 加载训好的模型
model = AutoModelForSequenceClassification.from_pretrained("./bert-chnsent")
model.eval()
model.cuda()
# 单条推理
inputs = tokenizer("这家酒店服务态度真好", return_tensors="pt").to("cuda")
with torch.no_grad():
logits = model(**inputs).logits
pred = torch.argmax(logits, dim=-1).item()
测一下单条延迟——
典型值 30-50ms(GPU)/ 200-300ms(CPU)。
听起来不慢,但放到生产场景——
- API 单次延迟 200ms,QPS 上限就 5
- 日处理 5000 万条评论 → 需要 115 个 GPU 实例并行
这就贵了。
BERT-base 推理慢的根本原因——
PyTorch 模型是动态图,每次推理都要"翻译"一遍算子,没有针对推理场景优化。
Step 2:导出 ONNX——把动态图变成静态计算图
ONNX(Open Neural Network Exchange) 是微软搞的"模型中间格式"——
把 PyTorch 的动态图冻结成静态计算图,再做算子融合、常数折叠等优化。
导出代码:
from transformers.convert_graph_to_onnx import convert
convert(
framework="pt",
model="./bert-chnsent",
output="./bert-chnsent.onnx",
opset=14, # ONNX 算子集版本,14 支持 BERT 全部算子
)
或者更可控的写法:
import torch
from transformers import AutoModelForSequenceClassification
model = AutoModelForSequenceClassification.from_pretrained("./bert-chnsent")
model.eval()
dummy_input = tokenizer("测试样本", return_tensors="pt")
torch.onnx.export(
model,
(dummy_input["input_ids"], dummy_input["attention_mask"], dummy_input["token_type_ids"]),
"./bert-chnsent.onnx",
input_names=["input_ids", "attention_mask", "token_type_ids"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch"},
"attention_mask": {0: "batch"},
"token_type_ids": {0: "batch"},
"logits": {0: "batch"},
},
opset_version=14,
)
dynamic_axes 关键——让 batch 维度可变,否则只能跑 batch=1。
Step 3:用 ONNX Runtime 推理 + 加速
import onnxruntime as ort
import numpy as np
session = ort.InferenceSession(
"./bert-chnsent.onnx",
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
def predict(text):
inputs = tokenizer(text, return_tensors="np",
padding="max_length", truncation=True, max_length=128)
logits = session.run(
["logits"],
{
"input_ids": inputs["input_ids"].astype(np.int64),
"attention_mask": inputs["attention_mask"].astype(np.int64),
"token_type_ids": inputs["token_type_ids"].astype(np.int64),
},
)[0]
return np.argmax(logits, axis=-1)[0]
再测延迟——
| 推理方式 | GPU 延迟 | CPU 延迟 | 加速比 |
|---|---|---|---|
| PyTorch 原生 | 30-50ms | 200-300ms | 基线 |
| ONNX Runtime | 12-18ms | 60-90ms | 2-3 倍 |
单这一招,QPS 翻 2-3 倍,所需 GPU 数量减半。
Step 4(进阶):量化 + INT8
如果还想再压一档——
动态量化把权重从 FP32 压成 INT8,模型体积砍 75%,CPU 推理再快 2 倍:
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic(
"./bert-chnsent.onnx",
"./bert-chnsent-int8.onnx",
weight_type=QuantType.QInt8,
)
代价——精度损失 0.5-1 个点(93% → 92%+)。
生产部署里这是常见取舍:换 75% 的体积和 2 倍的速度,掉 1 个点准确率,划得来。
Step 5:包成 FastAPI 服务
最后一步——给推理包个 HTTP 接口,前端/中间层才能调:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Request(BaseModel):
text: str
@app.post("/predict")
def predict_api(req: Request):
label = predict(req.text)
return {"label": int(label), "sentiment": "正向" if label == 1 else "负向"}
启动:
uvicorn server:app --host 0.0.0.0 --port 8000
测试:
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"text": "这家酒店位置好服务也好"}'
# {"label": 1, "sentiment": "正向"}
到这一步,BERT 才算真正"上线"——
从微调 → ONNX 导出 → 量化 → FastAPI 服务,这是一套完整的工业级 NLP 部署链路。
简历上能写——
基于 BERT 微调实现中文情感分类,导出 ONNX + INT8 量化,CPU 推理延迟从 250ms 优化到 30ms,FastAPI 部署支持 QPS 50+。
这一段,是 90% 的 BERT 教程不会写的部分——但面试官最爱听。
金句:微调把模型训出来,部署把模型用起来——前者是入门券,后者才是分水岭。

PART 07:踩坑实录 + 三模型横向对比
BERT 微调的坑不少,挑 5 个最高频的,全部踩过一遍写进简历才踏实。
坑 1:max_length 没设上限 → OOM
# 错误写法
tokenizer(batch["text"]) # 不截断,长文本直接 OOM
# 正确写法
tokenizer(batch["text"], truncation=True, max_length=128)
中文评论最长的有 2000+ 字,BERT 最大支持 512——不截断 = 爆显存。
坑 2:忘记 truncation=True
# 错误写法
tokenizer(batch["text"], max_length=128)
# Warning: token indices sequence length is longer than the specified maximum sequence length
光设 max_length 不够,必须配 truncation=True 才会真截断。否则 tokenizer 只警告不处理,训练时 batch 内长度不齐直接报错。
坑 3:learning_rate 太大 → 权重打飞
# 错误写法
TrainingArguments(learning_rate=1e-3) # BERT 微调直接发散
预训练模型对 lr 极度敏感。
从 0 训的模型 lr 可以是 1e-3,但 BERT 微调标准是 2e-5 ~ 5e-5——小三个数量级。
lr 太大,前 10 步 loss 直接 NaN,权重被打飞回不来。
坑 4:num_labels 跟数据集标签数不匹配
# 错误写法
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-chinese" # 忘记传 num_labels
)
# 二分类数据集,模型默认 num_labels=2 看似没事
# 但如果是 5 分类,输出维度对不上,运行时崩
养成习惯——加载模型时一定显式传 num_labels=N,别依赖默认值。
坑 5:分类头是随机初始化的 → 前几步 loss 很高
第一次训练时,前几个 step 的 loss 可能高到 0.7+(接近随机猜)。
别慌——
加载的预训练权重只到 Encoder 部分,顶上的分类头是从 0 随机初始化的,需要几个 step 学到合理范围。
跑完第一个 epoch,loss 一般能降到 0.2 以下。
坑 6:ONNX 导出时 token_type_ids 漏了
# 错误写法——只传 input_ids
torch.onnx.export(
model,
dummy_input["input_ids"],
"./bert.onnx",
...
)
# 推理时报错:input 'token_type_ids' is missing
BERT 的前向传播接受三个输入:input_ids / attention_mask / token_type_ids。
导出时三个都得作为输入,否则 ONNX 模型推理时会缺参数报错。
新手最常漏 token_type_ids——单句任务里它全 0,看着没用,但模型签名里有它,就得传。
坑 7:INT8 量化后准确率掉太多
# 标准量化
quantize_dynamic("./bert.onnx", "./bert-int8.onnx", weight_type=QuantType.QInt8)
# 推理:accuracy 从 93% 掉到 88%——多了 5 个点
正常 INT8 量化只该掉 0.5-1 个点。掉 5 个点说明——
- 数据分布偏移严重:训练集和线上数据分布差太大
- 某些层对量化敏感:可以用
QuantFormat.QOperator+ 校准数据集做静态量化
退路——用 FP16 代替 INT8,体积砍半但精度几乎不掉。
写完这些坑我又意识到一遍——深度学习的门槛从来不是数学,是工程。从 CV 到 NLP,从 RNN 到 BERT 部署,踩坑的姿势都一样。

结尾:BERT 派为啥没落,但仍值得学
Day06 说 Decoder 赢了,Day07 又花整篇讲 BERT——是不是矛盾?
不矛盾。
Decoder 赢的是"生成",BERT 统治的是"理解"。
GPT 系撑起了 LLM 时代的舞台聚光灯;
BERT/RoBERTa 仍守着企业 NLP 落地的后厨——做搜索排序、做评论筛选、做客服意图识别。
LLM 自己也能干这些,但贵。
每推理一次几分钱,对日处理上亿条 query 的公司,就是几百万的差距。
所以 BERT 没死,它只是从舞台中央退到了简历的必填项里。
简历视角——
你简历上"会用 BERT 微调"这一行,是面试官最认的那一行。
但真正拉开差距的,是后面那一行——"部署 + ONNX 加速"。
因为这一行背后,藏着"会装环境、会用 HF、会调超参、会导出 ONNX、会量化、会写 API"的完整工程能力——
会微调的人多,会部署的人少。这就是分水岭。
Day08 预告
BERT 的预训练是别人烧的。
那 GPT 的预训练到底怎么烧?
Day08 我们正式进 LLM 预训练主线——
- Next-token prediction(看前文预测下一个词)——GPT 的核心训练目标
- 交叉熵损失怎么算
- Scaling Law——为啥参数越多越聪明
- KV Cache——推理加速的关键
揭开 GPT 那一套生成式预训练的底层逻辑。
Decoder 赢了舞台聚光灯,BERT 仍守着企业落地的后厨——前者是时代叙事,后者是简历门槛。
互动时间:你简历上写过 BERT 项目吗?面试时被问过最难的 BERT 相关问题是什么?评论区告诉我,Day08 我会把高频坑写进预训练主线。
下一篇预告:Day08 | GPT 预训练到底怎么烧钱——Next-token prediction 全解
— END —
苦猿 · 帮普通人把 AI 学进简历
更多推荐



所有评论(0)