
简介针对BERT在自然语言理解中的应用这份压缩包给出一个可运行的意图识别实战项目围绕领域分类、意图识别和槽位填充三个核心任务展开适合NLP初学者、算法工程师及参加评测任务的团队参考。包内共14个文件以6个Python脚本为主体覆盖模型配置、CRF层、训练与评估等环节3个JSON文件提供训练集、测试集及SMP2019 ECDT任务原始数据另有3份参赛队伍技术报告、1份说明文档和1份运行输出文件整体仅1.53MB便于快速下载和复现。项目以SMP2019评测任务为背景清晰展示从文本预处理、BERT微调到槽位标签解码的完整流程并附多支队伍的方案对比可帮助读者理解不同技术路线在真实中文NLU任务上的效果。已有1079人学习下载既能作为课程设计素材也能为投入相关研发工作提供扎实的代码与实验基础。1. 从执念到选型为什么 NLP 课程项目几乎绕不开 BERT 做意图理解不管是人工智能大作业、毕业设计还是竞赛开场意图识别几乎是每个人都会撞上的第一座山。装好环境、跑通 demo 之后你会发现真正让人头疼的不是 BERT 怎么调用而是怎么把「领域分类 意图识别 槽位填充」这三个任务优雅地揉进同一个项目里。标题里那个 zip 压缩包拆开看就是一套完整的 NLU 实践模板给定用户话语先判断它属于哪个业务领域比如机票、酒店、天气再识别用户想干什么查航班、订房间最后把时间、地点、人名这些关键槽位抽出来。这套流程在智能客服、车载助手、语音助手后台都在跑属于「看起来不难、做起来处处是坑、做完能堵住面试官半小时」的典型项目。它适合两类人一类是刚学完 Transformer 基础、想用项目验证理解的新手另一类是已经在用规则或传统分类模型、想看看 BERT 能带来多少提升的从业者。本文按我自己做这类项目的顺序来写任务拆解与选型思路、数据格式与标签体系、模型结构怎么组织、训练与评估避坑、最后一章给可以带走的进阶技巧。2. BERT 做三个子任务先想清楚任务关系再谈模型结构2.1 领域分类、意图识别、槽位填充是什么关系很多初学者把这三个任务当成三个独立的分类器这是第一个误区。它们的真实关系是领域分类是粗粒度分组意图识别是细粒度行为判断槽位填充则是从序列里抽取参数。比如用户说「帮我订明天早上八点从北京到上海的机票」领域是「机票」意图是「订机票」槽位是「出发地北京、目的地上海、时间明天早上八点」。从建模角度看领域分类和意图识别都输入CLS向量做多分类槽位填充输入每个 token 的隐状态做序列标注。它们的区别在于领域数量少、类别边界相对清晰意图数量多且经常出现相似表达槽位填充是 token 级别的细活。BERT 能同时服务这三个任务不是因为它有三个脑袋而是因为预训练模型把语义知识装在了每一层的隐状态里你只需要在输出端接不同的任务头。我一般会明确一个原则领域分类是意图识别的「前置过滤」槽位填充依赖意图类型来限定槽位集合。你可以做三个独立的模型也可以共享 BERT 主干、只接不同输出头。独立模型的优点是训练简单、互不干扰缺点是部署时占内存、且每个任务都得重新加载一次 bert 权重。共享主干的多任务模型训练时要小心梯度互相拉扯这是后面要讲的重点。2.2 为什么选 BERT 而不是 LSTM、TextCNN 或 GPT选 BERT 的理由本质上是你需要「双向 字词级 可微调」这三个特性同时在场。意图识别领域里有个著名的坑一句话里的关键信息经常在句尾比如「我要改签」和「我要改签明天那班」的意图其实不同。LSTM 和 TextCNN 都能用但它们对上下文的理解深度不够尤其在槽位填充这类需要精细对齐 token 的任务上效果被 BERT 甩开一大截。GPT 系列是单向的做生成可以做槽位填充这种 token 标注任务不是一个路数。TextCNN 快但感受野有限意图多了之后类别间细微差异学不到。BERT 的预训练目标MLM NSP让它在微调时能用双向上下文综合判断这对意图识别和槽位填充都是刚需。实践中我对一万条训练数据做过对比BERT 微调后的意图分类 F1 比 TextCNN 高 8~12 个百分点槽位填充的实体级 F1 差距更大尤其是长尾槽位。需要强调一点选 BERT 不等于「把数据丢进去就完事」。预训练模型只是起点后面要做的数据处理、标签体系设计、输出头组织直接决定你的项目是能跑还是能打。2.3 项目文件通常怎么组织从 zip 到可训练工程拿到一个标题里那种 zip 包一般解压后是data、model、utils、train.py、predict.py、README这样的标准结构。数据文件和模型代码是核心不需要从头造轮子。这里给出我常用的目录组织方式intent_slot_project/ ├── data/ │ ├── train.json │ ├── dev.json │ └── test.json ├── bert_base/ # 预训练模型权重与词表 ├── model/ │ ├── bert_intent_slot.py │ └── config.py ├── utils/ │ ├── data_processor.py │ └── metrics.py ├── train.py └── predict.pydata 目录放标注好的数据原始多是 json 或 conll 格式bert_base 目录放从 Hugging Face 拉下来的预训练权重。重点说一说为什么数据选 json 而不是纯文本这三个任务的标注信息是多层的领域、意图、槽位纯文本没法承载结构化的标注比如一段文本同时标出「哪几个字是槽位值」和「这个槽位属于什么类型」json 天然合适。{ text: 帮我订明天早上八点从北京到上海的机票, domain: 机票, intent: 订机票, slots: [ {entity: 北京, type: 出发地, start: 9, end: 11}, {entity: 上海, type: 目的地, start: 14, end: 16}, {entity: 明天早上八点, type: 时间, start: 3, end: 9} ] }start 和 end 是字符级别的起止下标注意这里是 Python 的切片规则end 是开区间。这种标注方式在后面转 BMES 标签序列时很关键错一个下标损失的是一整句的槽位标注精度。3. 造数据是门手艺从原始文本到 BERT 能吃的序列3.1 标签体系设计领域、意图、槽位分开定还是合并定这个决定影响后面所有代码。我用过两套方案效果差异很大。方案一是分开定领域标签domain_label是[机票, 酒店, 天气]意图标签intent_label是[订机票, 查天气, 退酒店...]槽位标签是[出发地, 目的地, 时间, 日期...]。方案二是直接合并把领域意图拼成一个标签比如机票-订机票作为单一分类目标。合并方案在意图分类时更方便因为「订机票」本身已经隐含了领域信息少一层分类就少一次错误传递。但合并方案对槽位填充没有任何帮助因为槽位抽取是根据 token 序列上下文来做的跟你领域意图怎么编码没关系。所以我的习惯是意图识别用合并标签领域意图拼一起槽位填充用独立的序列标注标签集。具体来说我会给槽位标签设计成 BMES 格式这是序列标注任务的标准做法B-出发地: 槽位值的第一个字 M-出发地: 槽位值的中间字 E-出发地: 槽位值的最后一个字 S-出发地: 单字槽位 O: 非槽位比如「北京」在「从北京到上海」里如果按字切分「北」是 B-出发地「京」是 E-出发地。为什么要搞这么复杂而不是直接「一个字是不是槽位值」因为槽位填充的实际评估是按实体级别entity-level来算的如果你只用 BIO 标记模型容易把一个槽位值从中间截断BMES 能消除这种歧义。代价是标签集合变大了好几倍对应的输出维度也要变大。3.2 将原始标注转成 BERT 输入tokenizer 对齐是第一道坎BERT 处理文本不是按字来的是按 WordPiece 词表切词的这意味着「北京」可能被切成一个 token也可能被切成两个字取决于词表。这让槽位标注变得非常微妙原始 json 里的字符级 start/end 下标和 BERT tokenizer 切出来的 token 序列的位置是两套坐标系。不处理对齐你的标注就全废了。下面这段代码是我处理对齐的标准做法from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def align_slots_to_tokens(text, slots, tokenizer, max_len128): # 先用 tokenizer 拿到 token 序列和每个 token 对应的字符区间 encoding tokenizer( text, max_lengthmax_len, truncationTrue, return_offsets_mappingTrue ) offset_mapping encoding[offset_mapping] # 每个 token 对应原文的 [start, end) # 初始化标签序列长度等于 token 数含 [CLS] 和 [SEP] label_ids [O] * len(offset_mapping) for slot in slots: start, end slot[start], slot[end] # 字符级区间 slot_type slot[type] token_positions [] for idx, (tok_start, tok_end) in enumerate(offset_mapping): if tok_start start and tok_end end: token_positions.append(idx) if not token_positions: continue # 槽位被截断直接跳过 # 按 BMES 规则设置标签 if len(token_positions) 1: pos token_positions[0] if label_ids[pos] O: label_ids[pos] fS-{slot_type} else: first, last token_positions[0], token_positions[-1] if label_ids[first] O: label_ids[first] fB-{slot_type} for mid in token_positions[1:-1]: if label_ids[mid] O: label_ids[mid] fM-{slot_type} if label_ids[last] O: label_ids[last] fE-{slot_type} # 把标签字符串映射成数字 id slot2id {label: i for i, label in enumerate(all_slot_labels)} slot_ids [slot2id.get(label, slot2id[O]) for label in label_ids] return encoding[input_ids], encoding[attention_mask], slot_ids这段代码有几个关键逻辑。第一return_offsets_mappingTrue是 BERT tokenizer 里最容易忽略的参数它返回每个 token 在原始文本中的字符起止位置是对齐的基石。第二判定一个 token 是否属于槽位用的是「完全落进槽位区间」而不是「有重叠」因为重叠通常意味着边界切错了。第三一个槽位被截断token 数超了 max_len 被切掉时直接跳过宁可放弃这个槽位也不能让标签错位。实际踩坑提示中文 BERT 的 WordPiece 词表里绝大部分常用字是单字成 token 的但像「』」这种特殊符号可能会被分到「[UNK]」导致对齐错位。跑一次脚本后一定要抽样打印几条样本看看text / tokens / label_ids是否能对上这一步不能省。3.3 拼接 BERT 输入input_ids、attention_mask 和 token_type_ids把单条样本转成三个向量还不够训练时得把它们 padding 到同长度并告诉模型哪些是真实 token、哪些是 padding。我见过很多翻车现场都是 padding mask 忘做了模型把 padding 位也当成了槽位去预测结果指标惨不忍睹。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def encode_example(text, slots, tokenizer, max_len128): encoding tokenizer( text, max_lengthmax_len, truncationTrue, paddingmax_length, return_offsets_mappingTrue, return_tensorsNone ) slot_ids align_slots_to_tokens(text, slots, tokenizer, max_len) return { input_ids: encoding[input_ids], attention_mask: encoding[attention_mask], token_type_ids: encoding.get(token_type_ids, [0] * max_len), slot_labels: slot_ids, # 注意不是每条样本一个 label三个任务各要各的标签 domain_label: domain2id[example[domain]], intent_label: intent2id[example[intent]] }关于token_type_ids中文单句任务里它全为 0 就够用了。如果你是做句对任务比如「用户说系统回复」联合判断它才有实际意义。别把它当成什么神秘参数大多数单句意图识别项目里它不影响结果但写上没坏处。这背后有个更重要的选择padding 是在数据预处理阶段做还是等 batch 时动态做。小数据集无所谓但如果你的数据超过几万条预处理阶段做 max_length padding 会白占好多内存。建议用 DataCollatorWithPadding 动态 padding每批按该批最大长度补。我项目里一般在预处理阶段先不 pad训练脚本里再动态处理这样代码结构也干净。4. 模型结构一个 BERT三个输出头两种损失组织方式4.1 输出头设计用 CLS 向量做分类用 token 隐状态做序列标注BERT 的输出有last_hidden_state每个 token 的向量和pooler_output[CLS]向量过了一个全连接 tanh。领域分类和意图识别用 pooler_output槽位填充用 last_hidden_state。这个选型的直觉解释分类是对整句话的语义做全局判断[CLS]聚合了全句信息槽位填充是对每个 token 的语义做局部判断必须用逐 token 的隐状态。import torch import torch.nn as nn from transformers import BertModel class BertIntentSlotModel(nn.Module): def __init__(self, num_domains, num_intents, num_slot_labels, bert_dirbert-base-chinese): super().__init__() self.bert BertModel.from_pretrained(bert_dir) hidden_size self.bert.config.hidden_size self.domain_head nn.Linear(hidden_size, num_domains) self.intent_head nn.Linear(hidden_size, num_intents) self.slot_head nn.Linear(hidden_size, num_slot_labels) # 序列标注任务常用 CRF 层先不加用 CrossEntropy 做 baseline self.dropout nn.Dropout(0.1) def forward(self, input_ids, attention_mask, token_type_idsNone): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) pooled outputs.pooler_output # [batch, hidden] seq_out outputs.last_hidden_state # [batch, seq_len, hidden] pooled self.dropout(pooled) seq_out self.dropout(seq_out) domain_logits self.domain_head(pooled) # [batch, num_domains] intent_logits self.intent_head(pooled) # [batch, num_intents] slot_logits self.slot_head(seq_out) # [batch, seq_len, num_slot_labels] return domain_logits, intent_logits, slot_logitsdomain 和 intent 用的是同一个 pooled 向量但各自的 Linear 权重独立这是参数共享中的「特征共享、任务头独立」模式。slot 头作用在seq_out上输出形状是三维的Loss 计算时要把slot_logits和slot_labels都 reshape 成二维再算。4.2 损失函数怎么组合加权求和还是交替训练三个任务的 loss 组合是我最想写的一段。最简单的方式是直接求和但这么做有个隐患如果三个任务的类别数差异太大比如领域只有 3 类槽位标签有 30 类交叉熵损失的值域就不在同一个量级加起来的梯度会被大 loss 的任务主导。我试过一次槽位 loss 在 2 左右意图 loss 在 0.8 左右直接相加后模型把全部精力拿去优化槽位意图识别的准确率明显下滑。推荐用权重调和。常见的做法是用不确定性加权Uncertainty Weighting但那个要额外训练一个方差参数对新手不友好。更实用的做法是让三个任务的 loss 量级对齐后相加domain_loss nn.CrossEntropyLoss()(domain_logits.view(-1, num_domains), domain_labels.view(-1)) intent_loss nn.CrossEntropyLoss()(intent_logits.view(-1, num_intents), intent_labels.view(-1)) active_loss attention_mask.view(-1) 1 slot_logits_flat slot_logits.view(-1, num_slot_labels) slot_labels_flat slot_labels.view(-1) slot_loss nn.CrossEntropyLoss()(slot_logits_flat[active_loss], slot_labels_flat[active_loss]) loss 1.0 * domain_loss 1.0 * intent_loss 1.5 * slot_lossactive_loss那行的意思是只对真实 token 的位置计算槽位损失padding 位不参与这个细节必须保留。权重分配上槽位 loss 我一般给到 1.2~1.8因为序列标注任务本身就是难任务需要给它更大的梯度占比但也不是越大越好我做个实验从 0.5 测到 3.0最优大概在 1.5 附近。4.3 为什么效果不稳随机种子、学习率与 Batch Size 的博弈BERT 微调有个玄学同样的代码和数据跑两次结果能差 1~2 个点。原因主要是 dropout 的随机性和优化器状态初始化。为了可复现我通常固定三处种子import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True学习率和 batch size 的搭配也有讲究。BERT 微调学习率建议在 2e-5 到 5e-5 之间我常用 3e-5。batch size 小于 8 时建议调低学习率防止单批噪声太大。一个实用的检查法先用几十条数据跑一个 batch 的过拟合测试如果 loss 不降先检查数据管线别急着调参。from transformers import AdamW from transformers import get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr3e-5, correct_biasFalse) total_steps len(train_dataloader) * num_epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps )warmup 阶段设成总步数的 10%这是 BERT 微调的常见做法目的是让模型先用小步幅探索参数空间再逐步放大步幅进入正式优化。如果数据量大、训练步数长这个比例可以适当调小。注意correct_biasFalse是对应 BERT 官方 AdamW 的实现差异一些 transformers 版本里已经自动处理了新版代码这个参数可能已经弃用跑的时候留意警告就好。5. 训练实战与避坑记录五个高频翻车点5.1 训练循环与评估接口先跑通再谈调优这是个很多人都会忽略的环节。写好模型后先不急着上全量数据拿 100 条样本跑一个 epoch确认 loss 能降下来、代码不报错再上全量数据训练。这个「冒烟测试」帮我省下了大量排查时间。训练循环里我一般只做四件事取 batch、前向计算、反向传播、按步数打印 loss。验证环节留到每个 epoch 结束后单独做。评估意图分类用准确率槽位填充用实体级 F1后面一个章节详细讲。model.train() for epoch in range(num_epochs): for step, batch in enumerate(train_dataloader): input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) domain_labels batch[domain_label].to(device) intent_labels batch[intent_label].to(device) slot_labels batch[slot_labels].to(device) domain_logits, intent_logits, slot_logits model( input_ids, attention_mask ) loss compute_loss( domain_logits, intent_logits, slot_logits, domain_labels, intent_labels, slot_labels, attention_mask ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step()clip_grad_norm_看起来很不起眼但对 BERT 微调特别有用。预训练模型的梯度偶尔会爆加一个 max_norm1.0 的裁剪能省掉「跑着跑着 loss 变 NaN」的排查时间。5.2 槽位标签错位训练 loss 不降的头号嫌疑犯现象loss 一直在 1.5 左右徘徊怎么调参都降不下去。原因槽位标签序列和 input_ids 长度对不上模型在错位的标签上反复横跳。解决训练前打印一条样本的input_idsdecode 成文字和slot_labels映射回标签字符串并排看确认每个字的标签是否合理。这不是一句空话一定要逐条肉眼检查特别是截断边界处的样本。# 训练前检查一条样本 tokens tokenizer.convert_ids_to_tokens(batch[input_ids][0]) labels [id2slot[id] for id in batch[slot_labels][0].tolist()] for tok, lab in zip(tokens[:20], labels[:20]): print(f{tok}\t{lab})如果你看到「北 → B-出发地、京 → E-出发地」这种输出说明对齐正确。如果看到「北 → O、京 → B-出发地」这种错位基本是 start 下标计算有偏差。5.3 类别不平衡意图样本差距过大导致分类头瞎猜现象意图分类准确率看着还行80%但看混淆矩阵发现模型把所有样本预测成了数量最多的那个类别。原因意图类别长尾分布少数类样本量不到多数类的十分之一模型直接摆烂。解决先做类别统计对少数类做加权采样或 loss 加权。from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight( balanced, classesnp.unique(intent_labels), yintent_labels ) intent_weight torch.tensor(class_weights, dtypetorch.float).to(device) intent_loss nn.CrossEntropyLoss(weightintent_weight)( intent_logits.view(-1, num_intents), intent_labels.view(-1) )compute_class_weight会自动算出一个权重列表让少数类获得更大的梯度比例这是处理类别不平衡最快的方法。注意权重不是越高越好权重过大容易导致模型对少数类过拟合建议实际实验时乘以一个 0.8~1.2 的系数微调。5.4 BERT 权重被改坏多人协作时的环境问题现象同事跑同样的代码你的效果明显比他差。原因环境里 transformers 或 torch 版本不一致BERT 预训练权重加载时发生版本兼容变更。解决训练前记录环境版本并在 README 里固化依赖。更细的坑是from_pretrained时不小心把config.json改坏了比如 num_labels 改了但忘记恢复导致权重结构对不上但代码不报错。排查方法是检查加载后模型参数的均值和方差是否和官方一致。import torch state_dict torch.load(bert_base/pytorch_model.bin, map_locationcpu) embedding_weight state_dict[bert.embeddings.word_embeddings.weight] print(fembedding 参数形状: {embedding_weight.shape}, 均值: {embedding_weight.mean():.6f})如果均值异常比如出现负数大数基本可以断定权重文件出了问题重新下载原版预训练模型即可。预训练模型文件建议通过 Hugging Face hub 拉取尽量不用二次转存的版本减少中间环节的出错概率。5.5 评估口径不对准确率很高但线上效果差现象离线 F1 到 0.9上线后用户对话的识别结果却一塌糊涂。原因评测时把「整体 token 准确率」当成了「实体级 F1」两者差远了。token 准确率高是因为 O非槽位占绝大多数模型只要全预测 O 就能拿高分。解决评估指标必须按实体的完整匹配来算——模型预测出的每个槽位实体必须类型和边界都完全正确才算对。def sequence_accuracy(pred_ids, label_ids): 严格要求整条序列标签完全一致才算对 correct 0 total 0 for p, l in zip(pred_ids, label_ids): if p l: correct 1 total 1 return correct / total def entity_f1(pred_slots, true_slots): 槽位实体级 F1按 (start, end, type) 三元组比对 pred_set set(pred_slots) true_set set(true_slots) tp len(pred_set true_set) fp len(pred_set - true_set) fn len(true_set - pred_set) precision tp / (tp fp) if tp fp 0 else 0 recall tp / (tp fn) if tp fn 0 else 0 f1 2 * precision * recall / (precision recall) if precision recall 0 else 0 return f1预测槽位三元组时要从模型输出的 BMES 序列里按「连续相同类型标签」还原成实体边界。多预测一个标点都算 FP少预测一个字都算 FN这就是序列标注任务冷酷的地方。线上效果差还有一个常见原因线下数据是清洗过的书面语线上用户输入全是口语和废话词数据分布漂移了这不是模型问题是数据工程问题。6. 进阶技巧用 CRF 做序列约束、用单模型多任务部署、用脚本快速验证6.1 从 Softmax 到 CRF给槽位标注加上「规则」很多教程止步于在slot_head后面接一个Linear CrossEntropy但实际项目里你会遇到一些令人抓狂的错误比如模型预测出「B-出发地 → M-出发地 → O → B-出发地」这种连续序列中间突然断开的情况不符合 BMES 的合法转移规则。 Softmax 层独立预测每个 token是不懂这些规则的。CRF条件随机场层能学到标签间的转移约束比如B后面只能跟M或EO后面不能直接跟ME后面只能是O或B。 这些约束不需要你手写规则CRF 会在训练时自己学到。Hugging Face 不内置 CRF常见做法是用torchcrf库或者手写一个简单的 CRF 层。# 使用 torchcrf 包的示意结构 from torchcrf import CRF class BertIntentSlotWithCRF(nn.Module): def __init__(self, num_domains, num_intents, num_slot_labels, bert_dir): super().__init__() self.bert BertModel.from_pretrained(bert_dir) hidden_size self.bert.config.hidden_size self.domain_head nn.Linear(hidden_size, num_domains) self.intent_head nn.Linear(hidden_size, num_intents) self.slot_emission nn.Linear(hidden_size, num_slot_labels) self.crf CRF(num_slot_labels, batch_firstTrue) def forward(self, input_ids, attention_mask): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state emissions self.slot_emission(seq_out) # [batch, seq_len, num_slot_labels] # 训练时用 crf 算 loss预测时用 decode return emissions训练时变化很大槽位 loss 不再是CrossEntropyLoss而是-crf(emissions, tags, maskattention_mask.bool())预测时用crf.decode(emissions, maskattention_mask.bool())。我实测下来CRF 对实体级 F1 的稳定提升在 1~2 个点尤其在槽位边界模糊的场景里收益明显。代价是解码速度变慢长文本批量推理时要注意延迟。6.2 部署视角共享 BERT 主干的模型怎么落地项目做完后要能对外演示或部署我一般把训练好的模型保存成 Hugging Face 格式加载后写一个极简推理接口。from transformers import BertTokenizer import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertIntentSlotModel( num_domainsnum_domains, num_intentsnum_intents, num_slot_labelslen(id2slot), bert_dirbest_model ) model.eval() def predict(text): encoding tokenizer(text, return_tensorspt, truncationTrue, max_length128) with torch.no_grad(): domain_logits, intent_logits, slot_logits model( encoding[input_ids], encoding[attention_mask] ) domain_id torch.argmax(domain_logits, dim-1).item() intent_id torch.argmax(intent_logits, dim-1).item() slot_ids torch.argmax(slot_logits, dim-1).squeeze(0).tolist() tokens tokenizer.convert_ids_to_tokens(encoding[input_ids].squeeze(0).tolist()) pred_slots [] i 0 while i len(slot_ids): if slot_ids[i] ! O_ID: label_name id2slot[slot_ids[i]].split(-, 1)[1] entity_text tokens[i] j i 1 while j len(slot_ids) and id2slot[slot_ids[j]].endswith(label_name): entity_text tokens[j].replace(##, ) j 1 pred_slots.append((label_name, entity_text)) i j else: i 1 return domain_id, intent_id, pred_slots这个while循环做的事是从 BMES 序列里把连续的同一类型标签合并成一个实体。中间那个while判定条件用的是.endswith(label_name)这意味着即使不判断 B/M/E 过渡也能把同一类型的连续 token 串起来这是项目里常用的简化方式线上效果没差多少。部署时一个容易忽略的问题模型共享 BERT 权重后导出为 ONNX 时三个输出头是分开的建议一次性导出整张计算图避免重复加载 BERT 主干。如果模型还要进一步加速可以砍掉最后一层甚至几层 Transformer这是后话实践下来领域分类掉点不大意图分类会掉需要按自己的数据测。6.3 快速验证脚本不用训练全量数据就能判断方案可不可行这套脚本是给我自己用的后悔药遇到一个不确定的改动比如换了标签体系、加了数据增强先跑一小部分数据看趋势而不是等几小时训练完才发现不 work。具体来说我会写一个带超参注入的脚本控制训练数据的比例和训练轮数。python train.py --train_size 0.1 --max_epochs 2 --batch_size 8 --lr 3e-5跑这种轻量实验有两个观察点第一看 loss 在第二个 epoch 是否明显下降如果 2 个 epoch 后 loss 还在高位说明数据处理有问题第二看评估脚本能不能跑通这个技巧帮我发现过多次「评估时忘了把标签还原成中文」的低级错误。等轻量实验通过后再用全量数据和更长的轮数跑正式实验。这套思路做了很久我唯一的习惯是每次改完数据处理或模型结构的任何一行代码都会先跑一次这个小脚本再决定是否提交到全量训练。最后希望这篇笔记能帮你少踩几个坑希望帮到你。本文还有配套的精品资源点击获取