北京深言科技 大模型agent算法 社招
这个小伙伴5年的工作经历 双非研究生 好像是医学和计算机交叉学科
1. 请先做一下自我介绍
2. 你们是直接拿开源 BERT 做分类,还是基于开源 BERT 做微调?
答案:
一般不是直接拿开源 BERT 裸用,而是基于开源预训练 BERT 做下游分类任务微调。
开源 BERT 只提供了通用语义表示能力,它在大规模语料上通过 MLM、NSP 等任务学到了语言知识,但它不知道我们具体业务里的标签体系,比如用户意图分类、文本风险分类、工单分类等。
所以实际做法是:
拿一个开源预训练模型,比如:
bert-base-chinese chinese-roberta-wwm-ext hfl/chinese-macbert-base
然后在业务数据上接一个分类层,对 BERT 和分类层一起训练。
典型结构是:
输入文本 ↓ Tokenizer ↓ BERT Encoder ↓ Pooling / CLS 向量 ↓ Linear 分类层 ↓ Softmax ↓ 类别概率
代码示例:
from transformers import BertModel
import torch.nn as nn
class BertClassifier(nn.Module):
def __init__(self, model_name, num_labels):
super().__init__()
self.bert = BertModel.from_pretrained(model_name)
self.dropout = nn.Dropout(0.1)
self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels)
def forward(self, input_ids, attention_mask, token_type_ids=None):
outputs = self.bert(
input_ids=input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids
)
cls = outputs.last_hidden_state[:, 0, :]
logits = self.classifier(self.dropout(cls))
return logits
3. BERT 微调具体是怎么做的?分类任务的数据和训练流程是什么?
答案:
BERT 微调分类任务,本质上是把文本转成 token,然后输入 BERT,取出句向量,再用分类头预测标签。
数据一般长这样:
文本: "用户想查询订单物流" 标签: logistics_query 文本: "帮我退一下这个商品" 标签: refund_request
训练时会把标签映射成 id:
label2id = {
"logistics_query": 0,
"refund_request": 1,
"complaint": 2,
"other": 3
}
然后用 tokenizer 编码:
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-chinese")
encoded = tokenizer(
"用户想查询订单物流",
max_length=128,
padding="max_length",
truncation=True,
return_tensors="pt"
)
print(encoded.keys())
# input_ids, token_type_ids, attention_mask
训练目标一般是交叉熵:
import torch
import torch.nn as nn
criterion = nn.CrossEntropyLoss()
logits = model(
input_ids=encoded["input_ids"],
attention_mask=encoded["attention_mask"],
token_type_ids=encoded["token_type_ids"]
)
labels = torch.tensor([0])
loss = criterion(logits, labels)
loss.backward()
完整微调流程:
准备标注数据 ↓ 标签映射 ↓ Tokenizer 编码 ↓ 构建 Dataset / DataLoader ↓ 加载预训练 BERT ↓ 接分类层 ↓ 使用 CrossEntropyLoss 训练 ↓ 验证集评估 ↓ 保存模型 ↓ 线上推理
4. 分类层具体取 BERT 的哪一层输出做分类?
答案:
最常见的是取最后一层的 [CLS] token 向量。
BERT 输入一般是:
[CLS] 今 天 天 气 很 好 [SEP]
经过 BERT 后,每个 token 都会有一个 hidden state:
[CLS] -> h_cls 今 -> h_1 天 -> h_2 ... [SEP] -> h_sep
如果做句子级分类,通常用:
cls_vector = outputs.last_hidden_state[:, 0, :]
它的形状是:
(batch_size, hidden_size)
对于 bert-base-chinese,hidden_size 是 768,所以分类层一般是:
nn.Linear(768, num_labels)
不过实际项目里也可以有其他策略,比如:
1. 最后一层 CLS 2. 最后四层 CLS 拼接 3. 所有 token 做 mean pooling 4. attention pooling 5. CLS + mean pooling 融合
如果业务文本比较短,比如意图识别,用最后一层 CLS 通常就够了。如果文本较长、关键信息分布在多个 token 上,mean pooling 或 attention pooling 可能更稳定。
代码示例:
outputs = self.bert(
input_ids=input_ids,
attention_mask=attention_mask,
output_hidden_states=True
)
# 最后一层 CLS
cls_last = outputs.last_hidden_state[:, 0, :]
# 最后四层 CLS 拼接
hidden_states = outputs.hidden_states
cls_concat = torch.cat(
[hidden_states[-i][:, 0, :] for i in range(1, 5)],
dim=-1
)
如果拼接最后四层,线性层输入维度就是:
self.classifier = nn.Linear(768 * 4, num_labels)
5. BERT 后面的 Linear 层和 BERT 是怎么连接的?
答案:
Linear 层本质上就是接收 BERT 编码后的句向量,然后映射到类别空间。
以 bert-base 为例,BERT 输出的 CLS 向量维度是 768,如果有 10 个分类,那么 Linear 层就是:
nn.Linear(768, 10)
它做的事情是:
logits = xW + b
其中:
x: [batch_size, 768] W: [768, num_labels] b: [num_labels] logits: [batch_size, num_labels]
代码:
class BertForIntent(nn.Module):
def __init__(self, num_labels):
super().__init__()
self.bert = BertModel.from_pretrained("bert-base-chinese")
self.dropout = nn.Dropout(0.1)
self.fc = nn.Linear(768, num_labels)
def forward(self, input_ids, attention_mask, token_type_ids):
output = self.bert(
input_ids=input_ids,
attention_mask=attention_mask,
token_type_ids=token_type_ids
)
cls = output.last_hidden_state[:, 0, :]
cls = self.dropout(cls)
logits = self.fc(cls)
return logits
训练的时候,BERT 参数和 Linear 参数一般一起更新:
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
如果业务数据很少,也可以先冻结 BERT,只训练分类层:
for param in model.bert.parameters():
param.requires_grad = False
但一般效果会比全量微调差一些。
6. Linear 线性层的输入是什么?维度怎么确定?
答案:
Linear 层的输入取决于你选择的 pooling 策略。
如果取最后一层 CLS:
x = outputs.last_hidden_state[:, 0, :]
那么输入维度是:
hidden_size
对于 BERT-base:
768
对于 BERT-large:
1024
如果使用 mean pooling,输入维度仍然是 768:
def mean_pooling(last_hidden_state, attention_mask):
mask = attention_mask.unsqueeze(-1).float()
summed = torch.sum(last_hidden_state * mask, dim=1)
count = torch.clamp(mask.sum(dim=1), min=1e-9)
return summed / count
如果拼接最后四层 CLS:
x = torch.cat([
hidden_states[-1][:, 0, :],
hidden_states[-2][:, 0, :],
hidden_states[-3][:, 0, :],
hidden_states[-4][:, 0, :]
], dim=-1)
那么输入维度是:
768 * 4 = 3072
对应分类层:
self.classifier = nn.Linear(3072, num_labels)
所以 Linear 层输入维度不是固定的,而是由 BERT hidden_size 和特征融合方式共同决定。
7. 如果不用单层 CLS,而是做多层融合,你会怎么设计?
答案:
多层融合一般是为了解决最后一层语义过于任务化、局部信息损失的问题。BERT 的不同层捕获的信息不一样:
底层:偏词法、字符、局部结构 中层:偏句法、短语关系 高层:偏语义、任务相关特征
一种常见方式是最后四层加权融合:
import torch
import torch.nn as nn
class LayerWeightedPooling(nn.Module):
def __init__(self, num_layers=4):
super().__init__()
self.weights = nn.Parameter(torch.ones(num_layers))
def forward(self, hidden_states):
# hidden_states: tuple, length = 13 for bert-base
selected = hidden_states[-4:]
norm_weights = torch.softmax(self.weights, dim=0)
output = 0
for w, h in zip(norm_weights, selected):
output += w * h[:, 0, :]
return output
然后接分类层:
self.pooling = LayerWeightedPooling(num_layers=4)
self.classifier = nn.Linear(768, num_labels)
outputs = self.bert(
input_ids=input_ids,
attention_mask=attention_mask,
output_hidden_states=True
)
x = self.pooling(outputs.hidden_states)
logits = self.classifier(x)
这种方式相比直接拼接更省参数,因为它最后还是保持 768 维。如果数据量足够,也可以拼接最后四层,表达能力更强,但更容易过拟合。
8. BERT 分类里 CLS pooling、mean pooling、attention pooling 分别适合什么场景?
答案:
CLS pooling 适合短文本分类,比如意图识别、情感分类、句子级分类。它实现简单,推理快,也是最常用的 baseline。
mean pooling 是把所有有效 token 的向量做平均,适合关键信息分散在整段文本里的情况,比如长文本分类、摘要语义分类、FAQ 匹配。
attention pooling 是让模型自动学习哪些 token 更重要,适合业务文本里有明显关键词,但位置不固定的情况。
代码示例:
class AttentionPooling(nn.Module):
def __init__(self, hidden_size):
super().__init__()
self.attention = nn.Linear(hidden_size, 1)
def forward(self, last_hidden_state, attention_mask):
scores = self.attention(last_hidden_state).squeeze(-1)
scores = scores.masked_fill(attention_mask == 0, -1e9)
weights = torch.softmax(scores, dim=-1)
pooled = torch.sum(last_hidden_state * weights.unsqueeze(-1), dim=1)
return pooled
接分类层:
pooled = self.att_pooling(outputs.last_hidden_state, attention_mask) logits = self.classifier(pooled)
我一般会先用 CLS 做 baseline,如果发现长文本、噪声文本效果不好,再尝试 mean pooling 或 attention pooling。
9. BERT 微调时怎么处理类别不均衡问题?
答案:
类别不均衡在分类任务里很常见,比如大部分样本都是“其他”,少数样本是“投诉”或者“风险”。
常见处理方式有三类:
第一类是在 loss 上加权,让少数类 loss 权重大一些:
import torch import torch.nn as nn class_weights = torch.tensor([1.0, 2.5, 4.0, 1.2]).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)
第二类是采样层面处理,比如 oversampling 少数类,或者用 WeightedRandomSampler:
from torch.utils.data import WeightedRandomSampler
sample_weights = [class_weights[label].item() for label in labels]
sampler = WeightedRandomSampler(
weights=sample_weights,
num_samples=len(sample_weights),
replacement=True
)
第三类是换损失函数,比如 Focal Loss:
class FocalLoss(nn.Module):
def __init__(self, gamma=2.0, weight=None):
super().__init__()
self.gamma = gamma
self.weight = weight
def forward(self, logits, targets):
ce_loss = nn.functional.cross_entropy(
logits,
targets,
weight=self.weight,
reduction="none"
)
pt = torch.exp(-ce_loss)
loss = ((1 - pt) ** self.gamma) * ce_loss
return loss.mean()
实际使用时我会先看混淆矩阵和每个类别的 precision / recall。如果少数类召回很差,优先用 class weight 或 Focal Loss;如果数据本身质量差,还要补充样本和清洗标注。
10. BERT 微调时怎么避免过拟合和灾难性遗忘?
答案:
BERT 微调很容易过拟合,尤其是业务数据只有几千条的时候。常用方式有:
降低学习率:
2e-5、3e-5、5e-5 是常见范围
使用 warmup:
from transformers import get_linear_schedule_with_warmup
scheduler = get_linear_schedule_with_warmup(
optimizer,
num_warmup_steps=int(total_steps * 0.1),
num_training_steps=total_steps
)
加 dropout:
self.dropout = nn.Dropout(0.1)
使用 weight decay:
optimizer = torch.optim.AdamW(
model.parameters(),
lr=2e-5,
weight_decay=0.01
)
还可以做分层学习率,让底层 BERT 学得慢一点,分类层学得快一点:
optimizer = torch.optim.AdamW([
{"params": model.bert.embeddings.parameters(), "lr": 1e-5},
{"params": model.bert.encoder.layer[:6].parameters(), "lr": 1e-5},
{"params": model.bert.encoder.layer[6:].parameters(), "lr": 2e-5},
{"params": model.classifier.parameters(), "lr": 1e-4},
])
如果数据非常少,可以冻结一部分 BERT 层:
for layer in model.bert.encoder.layer[:8]:
for param in layer.parameters():
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
本专栏聚焦 AI-Agent 面试高频考点,内容来自真实面试与项目实践。系统覆盖大模型基础、Prompt工程、RAG、Agent架构、工具调用、多Agent协作、记忆机制、评测、安全与部署优化等核心模块。以“原理+场景+实战”为主线,提供高频题解析、标准答题思路与工程落地方法,帮助你高效查漏补缺.
查看26道真题和解析