资讯动态

意图识别与槽位填充:PyTorch+BERT联合建模实战

发布时间:2026/10/9 13:14:18 来源:尧图企业网站定制
简介面向自然语言处理初学者与算法工程实践者这份项目包提供了基于 PyTorch 与 BERT 的意图识别和槽位填充联合训练方案采用分类加序列标注的典型思路并选用 chinese-bert-wwm-ext 预训练模型。全包共 18 个文件包含 8 个 Python 脚本、7 个文本配置、2 个 JSON 数据及 1 个 YAML 参数文件其中 main.py 负责统一训练验证与预测config.py 与 model.yaml 调节模型参数dataset.py 与 preprocess.py 支撑数据处理整体约 10KB结构清晰易上手。项目将意图分类与槽位标注放在同一模型中联合优化避免流水线误差传播适合快速复现从数据预处理、模型搭建到预测输出的完整流程。已有 700 人学习下载读者可掌握 PyTorch 1.6 与 Transformers 4.5.0 环境下的项目组织方式并在此基础上扩展自己的对话系统任务。1. 意图识别与槽位填充为什么说联合建模才是任务型对话的常规解法一个用户对客服机器人说“帮我查一下明天上海到北京的机票”系统要同时回答两个问题用户想干什么查机票以及这个动作的约束条件是什么时间明天出发地上海目的地北京。前者叫意图识别intent detection后者叫槽位填充slot filling。这两个任务在 PyTorch BERT 的技术栈下经常被放在同一个模型里联合训练共享一个 BERT 编码器意图走句子级分类头槽位走词级序列标注头。相比先做意图分类再单独跑一个序列标注模型的做法联合建模在推理时只过一遍 BERT同时能利用“意图对槽位边界的约束”这类隐含信息是当前任务型对话、智能客服、工单自动分类里最常用的落地方案。这个项目包里的核心代码就是把这条链路完整串起来数据处理、标签对齐、双任务模型、训练评估。2. 先拆数据与标签意图识别和槽位填充的任务边界2.1 意图是句子级动作槽位是词级参数意图识别本质是一个句子级分类任务。输入一整句用户话术输出一个预设意图类别比如 book_flight、query_weather、turn_on_light。槽位填充则是一个词级序列标注任务对句子里的每个 token 打一个标签标出哪些片段是时间、哪些是地点、哪些是实体值。两者有天然的层级关系意图决定了槽位该怎么解释。同样是“北京”这个词在“订机票到北京”里是目的地在“北京明天多少度”里是查询对象。如果模型不知道用户在问什么槽位的含义就无法收敛。把两个任务放进同一个模型不是为了省一次推理而是让底层 BERT 学到的语义表征同时服务于两个目标。意图分类头会迫使编码器关注句子整体的语用信息而这些信息恰好能帮助序列标注头判断槽位的边界反过来槽位标注头强迫模型关注局部实体也能帮意图分类更稳定。这在数据量不大时体现得尤其明显几千条训练样本就能同时把两个任务训练到一个可用的水平。2.2 训练数据长什么样JSON 标注与 BIO 槽位标签这个项目包里的数据格式通常是 JSON 或 JSONL每行一个样本。以“帮我订一张明天去北京的机票”为例标注文件长这样{ text: 帮我订一张明天去北京的机票, intent: book_flight, slots: [ {start: 5, end: 7, label: date}, {start: 8, end: 10, label: destination} ] }start 和 end 是字符级索引左闭右开。中文按字索引“明天”占索引 5 和 6“北京”占索引 8 和 9。这种基于字符区间的标注方式比较自然人工标注时只需要框选文本片段再选标签不需要直接面对 BIO 标签序列。模型训练前要把字符区间转成 BIO 标签序列。BIO 体系里B- 表示一个槽位的开始I- 表示槽位的中间或结尾O 表示非槽位词。“明天”对应 B-date、I-date“北京”对应 B-destination、I-destination其余字都是 O。为什么不用简单的“给每个字标一个槽位名”因为没有边界信息模型无法区分“上海到北京”里两个连续地点实体的起止位置。BIO 是最低成本的方案标签数量约为“槽位类型数 × 2 1”十几类槽位也就是三十几个标签BERT 的序列标注头完全扛得住。2.3 标签体系设计的三个边界问题第一个边界问题是槽位角色冲突。同样是地点出发地和目的地必须拆成两个标签比如 B-from_city 和 B-to_city否则“从上海到北京”中两个地点会被标成同一个值下游系统没法区分。第二个边界问题是嵌套槽位比如“明天上午”里“明天”是 date“上午”是 time两者可能同时标注。实践中更常见的做法是让两者组成一个更大的 span“明天上午”作为 datetime而不是在 BIO 里做嵌套因为嵌套标注会让序列标注的标签空间急剧膨胀收敛难度变大。第三个边界问题是 O 类占比过高。一个自然句子里 70% 以上的字都是 O模型很容易学成“啥也不标”的保守策略后面在训练时会专门处理。数据量方面这个项目能跑通的最少样本量大约在几百条但想达到能上线的水平每个意图至少要有 200500 条真实话术槽位覆盖要按领域高频值补齐。如果拿到的 zip 包里自带数据建议先花半天时间把数据重新过一遍看有没有标注冲突因为标注质量比数据量更影响 BERT 微调的天花板。3. 用 PyTorch BERT 搭建联合模型共享编码器加两个输出头3.1 为什么用共享 BERT 而不是两个独立模型常见的做法是用 transformers 库里的 BertForSequenceClassification 做意图识别再用 BertForTokenClassification 做槽位填充两个模型分开训练、分开推理。这种方案在概念上好理解但工程上有两个问题第一推理时要跑两遍 BERT单条延迟翻倍第二两个模型各自微调后底层表征会产生漂移意图模型学到的语义信息完全传不到槽位模型。这个项目实践里采用的是共享 BERT 编码器加双输出头的结构。BERT 只加载一份预训练权重前向传播只过一次pooler 输出[CLS] 向量接一个全连接层做意图分类last_hidden_state每个 token 的向量序列再接一个全连接层做槽位标签分类。两个头共享底层的 12 层 Transformer反传时梯度同时更新 BERT 参数。这属于经典的多任务学习在对话 NLU 任务上比两个独立模型更稳。3.2 模型定义继承 BertPreTrainedModel 的实现直接基于 transformers 写一个自定义模型继承 BertPreTrainedModel 而不是自己从零搭 BERT是为了复用 from_pretrained 的权重加载逻辑。核心代码import torch from torch import nn from transformers import BertModel, BertPreTrainedModel class IntentSlotModel(BertPreTrainedModel): def __init__(self, config, num_intents, num_slots): super().__init__(config) self.bert BertModel(config) self.dropout nn.Dropout(config.hidden_dropout_prob) self.intent_head nn.Linear(config.hidden_size, num_intents) self.slot_head nn.Linear(config.hidden_size, num_slots) self.loss_intent nn.CrossEntropyLoss() self.loss_slot nn.CrossEntropyLoss(ignore_index-100) def forward(self, input_ids, attention_mask, token_type_idsNone, intent_labelsNone, slot_labelsNone): outputs self.bert( input_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) # pooled: [CLS] 对应的句子向量用于意图分类 # sequence: 每个 token 的向量用于槽位序列标注 logits_intent self.intent_head(self.dropout(outputs.pooler_output)) logits_slot self.slot_head(self.dropout(outputs.last_hidden_state)) loss None if intent_labels is not None and slot_labels is not None: loss self.loss_intent(logits_intent, intent_labels) loss self.loss_slot( logits_slot.view(-1, self.slot_head.out_features), slot_labels.view(-1) ) return loss, logits_intent, logits_slot逻辑说明BertModel 默认返回 BaseModelOutputWithPoolingpooler_output 是经过 MLP 的 [CLS] 向量last_hidden_state 是完整 token 序列的隐状态。意图分类头只吃 [CLS]槽位分类头吃全部 token 的隐状态并输出每个 token 在槽位标签上的 logits。损失函数把两个 CrossEntropyLoss 直接相加这是联合训练最简单的写法默认两个任务权重 1:1任务之间不会有量级差异因为都是交叉熵。参数说明里值得注意的有两点。第一num_intents 和 num_slots 要由外部传入不要试图从 config 里猜因为 config.num_labels 在加载 BERT 预训练权重时是 2原任务是下一句预测直接用它做槽位数会报尺寸不匹配。第二槽位损失的 ignore_index-100 是标配BERT 的 WordPiece 分词会把一个词切成多个 subword非首 subword 对应位置的标签用 -100 屏蔽不参与损失计算这个后面会细说。3.3 联合损失与训练循环两个任务一起反传训练循环里不需要对两个 loss 分别 backward因为模型 forward 已经把两个 loss 加起来返回了。用 AdamW 加线性学习率预热from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) intent_labels batch[intent_label].to(device) slot_labels batch[slot_labels].to(device) loss, _, _ model( input_ids, attention_maskattention_mask, intent_labelsintent_labels, slot_labelsslot_labels ) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()逻辑说明每个 batch 同时传入意图标签和槽位标签反传时两个任务的梯度叠加到同一套 BERT 参数上。clip_grad_norm 是 BERT 微调里防止梯度爆炸的常规操作max_norm 取 1.0。warmup 步数设成总步数的 10%前 10% 步学习率从 0 线性升到 2e-5之后线性衰减到 0这是 BERT 微调中比较标准的做法能避免预训练分布被破坏导致的早期震荡。训练时建议每跑完一个 epoch 就在验证集上算一下意图准确率和槽位 F1而不是只盯着 loss。实践里经常出现训练 loss 持续下降但槽位 F1 纹丝不动的情况这多数是标签对齐出了问题而不是模型没收敛。4. 数据加载与训练评估把模型真正跑起来的五个关键点4.1 对齐才是第一步offset_mapping 与标签对齐BERT 的 tokenizer 不是按原始字符逐个切分的。“机票”在中文 BERT 词表里是一个词但在某些情况下会被切成“机”和“票”英文里 “unhappy” 可能切成 “un” 和 “happy”。原始标注是字符索引模型需要的却是 token 级标签这个转换是整个项目里最容易翻车的一步。处理方式是用 tokenizer 返回的 offset_mapping它记录了每个 token 对应原文本的字符起止位置。对齐代码如下def encode_sample(tokenizer, text, intent, slot_spans, intent2id, label2id): encoded tokenizer( text, return_offsets_mappingTrue, truncationTrue, max_length128 ) offset_mapping encoded[offset_mapping] # 先按字符级做 BIO 标注 char_labels [O] * len(text) for start, end, label in slot_spans: char_labels[start] B- label for i in range(start 1, end): char_labels[i] I- label # 再按 offset_mapping 映射到 token 级 slot_labels [] for start, end in offset_mapping: if start 0 and end 0: # [CLS] 和 [SEP] 等特殊 token用 -100 屏蔽 slot_labels.append(-100) else: # 取该 token 第一个字符对应的标签 slot_labels.append(label2id[char_labels[start]]) return { input_ids: encoded[input_ids], attention_mask: encoded[attention_mask], intent_label: intent2id[intent], slot_labels: slot_labels }逻辑说明offset_mapping 的长度和 input_ids 完全一致第 i 个元素是 (start, end)表示 input_ids[i] 这个 token 对应原文本的第 start 到第 end 个字符。特殊 token 的 offset 是 (0, 0)这里直接给 -100。普通 token 取它第一个字符的标签因为无论 token 是完整词还是 subword它覆盖的所有字符在原文本里属于同一个槽位区间标签必然一致。参数说明max_length 建议设 64128。对话场景的话术通常很短设 64 可以显著降低显存占用但要注意 truncationTrue 会截断超长文本如果槽位恰好落在被截断的部分这个样本的槽位标签信息就丢了。保险做法是把 max_length 设成训练数据里 98% 文本长度而不是拍脑袋定一个值。4.2 训练参数参考值学习率、batch size、epoch 怎么定BERT 微调有自己的参数区间直接套用 CV 模型的默认值会出问题。可以参考下面这组参数参数推荐值说明学习率2e-5 ~ 3e-5BERT 微调的常用区间5e-5 以上容易不收敛batch size16 ~ 32单卡 16显存够就 32过大反而降低泛化epochs3 ~ 5数据量小于 1 万条时 3 轮足够max_length64 ~ 128短文本选 64长文本选 128warmup_ratio0.1前 10% 步学习率线性上升weight_decay0.01对全连接层和 BERT 参数都生效max_grad_norm1.0梯度裁剪阈值学习率是关键中的关键。BERT 预训练权重已经学过大量语言知识微调时学习率太大几步就会把底层表征冲烂表现是 loss 先降后升或者槽位 F1 完全不动。如果发现验证集上的指标在 epoch 2 左右就开始退化把学习率降到 1e-5 再试。batch size 的影响相比学习率要小一些主要受显存限制。显存不够时优先减 max_length 而不是硬撑 batch size因为把 128 的 max_length 降到 64显存直接减半效果损失通常不明显。4.3 评估指标意图准确率、槽位 F1、句子级准确率评估时三个指标要分开看。意图准确率是最直观的所有样本里意图预测正确的比例。槽位 F1 用 seqeval 库计算它按实体级别评估而不是按 token 级别也就是预测的“明天”和真实的“明天”完全重合才算一个正确预测这样能避免“标记对了字但边界错了”的虚假分数。from seqeval.metrics import classification_report from seqeval.metrics import f1_score # true_slot 和 pred_slot 都是二维列表每个样本是标签序列 true_slot [[B-date, I-date, O, B-destination, O]] pred_slot [[B-date, I-date, O, B-destination, O]] print(f1_score(true_slot, pred_slot))逻辑说明seqeval 的输入是标签字符串列表的列表注意必须把 -100 过滤掉再传给评估函数否则 seqeval 会把 -100 当成一个真实标签导致 F1 虚高。过滤方式是在模型预测时记录每个 token 的原始位置把 -100 对应的 logits 取出来但不参与评估。句子级准确率是最严格也最接近线上体验的指标意图和所有槽位都预测正确才算这一个样本通过。意图识别做到 98% 很容易槽位 F1 做到 95% 也不难但句子级准确率经常只有 80% 出头因为一句话里只要有一个槽位边界错一点整句就判错。这个指标决定了线上系统真正能正确响应的用户请求比例一定要每次训练完都打出来看。4.4 模型保存与加载label2id 比权重更容易丢保存时只存 model.state_dict() 是远远不够的。跑完训练后这个 zip 项目里看起来“能跑”的代码很多在保存环节是残缺的权重存了但 label2id 映射、intent2id 映射、tokenizer 配置都没存。等模型上线时加载权重后做推理输出的槽位标签编号对不上训练的标签结果全是乱的。完整的保存应该这样torch.save({ model_state: model.state_dict(), intent2id: intent2id, label2id: label2id, max_length: 128, }, checkpoint.pt) model.bert.save_pretrained(bert_weights/) tokenizer.save_pretrained(bert_weights/)逻辑说明建议用两种方式保存。第一种是把模型参数和映射关系打包进一个 checkpoint方便单个文件迁移第二种是把 BERT 部分单独用 save_pretrained 保存这样后续要加载到别的 transformers 模型做增量微调会很方便。加载时要把 state_dict 先 load 到自定义模型里再单独恢复映射表注意两者缺一不可。加载代码里有一个高频翻车点加载 BERT 权重时model.bert 和 tokenizer 必须是同一个预训练版本如果训练时用的是中文 BERT 而加载时换成了英文 BERTinput_ids 对应的语义完全不同模型输出会毫无意义。这个项目包里如果默认下载的是某个中文预训练模型就固定用它不要随意换。5. 意图识别项目避坑指南槽位错位、类别失衡与版本玄学5.1 槽位 F1 低得离谱loss 却一直在降现象训练时总 loss 从 5 稳定降到 0.5但验证集槽位 F1 一直卡在 40% 上下怎么调参都没用。原因标签对齐代码写错了。常见的错法是手工按空格分词后再对位遇到英文缩写、中文词边界变化就直接错位还有一种是把特殊 token 的位置标成了 O 而不是 -100导致模型在 [CLS] 位置强行学习一个“非槽位”标签干扰序列标注头。解决debug 时随便拿一条训练样本把 input_ids 还原成 token 字符串再打印出对齐后的 slot_labels 逐个检查。如果发现 token 对应的标签和文本内容对不上直接改正 encode 函数。最简单可靠的验证方式是找一个所有 token 都被完整分词器切开的句子人工核对一遍。5.2 预测结果对不上原来的句子长度现象推理输出槽位标签的数量和输入句子的字数对不上解码时下标越界。原因这是初学者最容易踩的坑。模型的输出长度是 token 数不是字符数。中文 BERT 会把词切成多个 subword英文更是如此token 数几乎总是大于等于字符分词后的词数。直接拿 BERT 输出的标签序列去还原原始文本标签数量和文本位置必然对不上。解决推理时记录 offset_mapping预测完成后逐 token 取标签再按 offset 映射回字符位置还原成 (start, end, label) 的槽位列表。这一步和训练时的对齐逻辑完全一致只是方向反过来。建议把这段逻辑抽成独立的 decode 函数训练和推理复用同一份映射逻辑避免两边各写一份最后不一致。5.3 O 类占比过高模型把所有词都预测成 O现象模型收敛得很好loss 很低但槽位 F1 几乎为 0预测结果全是一个个 O。原因自然语言的槽位占比通常只有 20%30%O 类是绝对多数。BERT 微调后学到的“最优解”就是无脑输出 O因为这样损失最低。这种情况在早期训练尤其明显模型还没见到足够的槽位信号就偏向保守策略。解决第一个手段是给槽位损失加类别权重把 O 类的权重调低比如 O 类 0.2、B/I 类 1.0这在 nn.CrossEntropyLoss 里直接用 weight 参数就能实现。第二个手段是增大 epoch 数让模型有更多机会学到少数类特征。第三个手段是检查数据增强把训练样本里 O 占比过高的句子过滤掉或者用真实语料做槽位替换增强增加正样本多样性。5.4 换了 transformers 版本后训练直接报错现象代码原本能跑升级 transformers 库之后 from_pretrained 报 size mismatch或者 forward 参数名变了模型初始化直接失败。原因transformers 在不同版本之间 API 有较大变化。老版本里 BertModel 的某些默认行为新版本里改了参数名或取消了参数自定义模型继承 BertPreTrainedModel 时config 的 num_labels 预设有差异导致加载权重时 shape 对不上。解决项目里固定 transformers 版本不要轻易升级。在 requirements.txt 里锁死确切版本号比如 transformers4.x.xtorch 也锁一个稳定版本。已经升级翻车的话先看报错是不是 “Some weights of the model checkpoint were not used” 或 size mismatch如果是核对自定义模型的 head 维度是不是和 config 里的预设置一致。玄学一点的经验是BERT 微调代码能跑就别升级环境依赖你的目标是把意图识别跑通不是尝鲜新功能。5.5 新领域效果差别急着换模型先查领域数据现象把训练好的模型直接用到新的业务领域比如从机票预订换到酒店预订意图识别准确率掉了 20 个点槽位 F1 更差。原因BERT 是通用预训练模型它学到的语言知识是通用的但意图和槽位的定义完全是领域相关的。“入住”这个词在机票领域不存在在酒店领域是高频槽位触发词同一个“时间”槽位不同领域的取值表达差异极大。直接用旧模型当然不行。解决先收集新领域几百条标注数据做增量微调在 checkpoint 上继续跑几个 epoch通常比推倒重训效果好得多。如果还没有标注数据先跑一版“意图识别 槽位填充”的零样本对比用现成模型预测一版结果再人工修正作为种子标注也就是常见的伪标注半自动流程。扩展新领域槽位时宁可多设几个语义重合但业务含义不同的标签也不要试图用一个宽泛槽位覆盖所有场景宽泛槽位带不动下游决策。6. 上生产前的最后一步推理加速与领域迁移的实践6.1 用 ONNX 导出把单条推理压到 10ms 以内BERT 模型在 GPU 上单条推理大约 2030ms在 CPU 上要 200ms 以上直接做实时对话不够用。常见做法是把训练好的模型导出成 ONNX再通过 ONNX Runtime 跑推理CPU 上也能压到 50ms 以内加上 batch 策略可以进一步降低单条延迟。导出关键代码import torch from transformers import BertConfig config BertConfig.from_pretrained(bert_weights/, torchscriptTrue) model IntentSlotModel.from_pretrained( bert_weights/, configconfig, num_intentslen(intent2id), num_slotslen(label2id) ) model.eval() dummy_input { input_ids: torch.randint(0, 30522, (1, 128)), attention_mask: torch.ones(1, 128, dtypetorch.long), } torch.onnx.export( model, tuple(dummy_input.values()), intent_slot.onnx, input_names[input_ids, attention_mask], output_names[intent_logits, slot_logits], dynamic_axes{input_ids: {0: batch}, attention_mask: {0: batch}} )逻辑说明导出时用动态 batch 维度这样线上可以一批多条一起推理。ONNX 导出后要把 tokenizer 和 label2id 单独部署因为 ONNX 只管张量计算不管文本切分和标签映射。部署时如果用 GPU可以再叠加 TensorRT 优化但大多数对话场景 CPU ONNX Runtime 已经够用。6.2 领域迁移的三板斧增量微调、伪标注、槽位约束新领域落地时先拿旧模型做无标签数据的预测也就是伪标注人工抽检修正后作为微调的训练集然后用增量微调而不是重头训练学习率调到 1e-52e-5 之间epoch 控制在 23 轮防止灾难性遗忘。如果最终槽位输出要对接业务系统强烈建议在模型上加一层槽位合法性校验日期槽位必须能被日期解析器识别城市槽位必须出现在城市词典里解析不了的槽位宁可丢弃也不下发这一步能绕过模型大量边界错误。我自己的习惯是任何 BERT 系列模型的意图识别项目上线前必做一次“脏数据冒烟测试”——拿几条带口语噪音、标点错误、中英混排的真实用户输入去测把所有错例打印出来人工核对一遍。有一次我图省事跳过了这步线上模型把“明天下午3点帮我订个会议室”里的“下午3点”标成了 date业务系统解析失败直接报错最后排查了半天才发现是训练数据里时间类槽位只有一个“明天”类样本完全没有覆盖“下午3点”这种表达。后来我把时间槽位拆成 date 和 time 两个字段补了一批带具体时刻的标注问题才彻底解决。这个方向最值得投入的地方不是换更大的模型而是把数据标注规范、对齐逻辑和评估体系打磨扎实。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑