苦猿的大模型日记 · 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。

Encoder vs Decoder 两派之争:双向 vs 单向


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] 的位置


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] 盖住的词


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 起步学微调——这就是预训练模型的意义。

本地终端加载 bert-base-chinese,下载进度条跑到 100%


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,不会自动算 accuracycompute_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 亿词的功底来考试——起点就不一样

训练日志:3 个 epoch 的 loss 下降 + 验证集 accuracy 爬升到 93%


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 教程不会写的部分——但面试官最爱听。

金句:微调把模型训出来,部署把模型用起来——前者是入门券,后者才是分水岭

部署链路:PyTorch → ONNX → INT8 量化 → FastAPI,延迟逐步下降


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 部署,踩坑的姿势都一样。

三模型 IMDb/ChnSentiCorp 准确率对比


结尾: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 学进简历

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐