ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

BERT多任务实战:意图识别、领域分类与槽位填充

BERT多任务实战:意图识别、领域分类与槽位填充

简介:围绕BERT的意图识别与自然语言理解实战资源,面向NLP入门者及需要落地领域分类、意图识别、槽位填充任务的开发者。内容对应SMP2019ECDT任务,完整覆盖数据预处理、BERT微调、顶部分类层与序列标注设计、模型评估和推理输出,可直接作为课程设计或竞赛复现的基线。压缩包共14个文件,大小约1.53MB,整体结构紧凑、各文件职责清晰,以6个Python脚本、3个JSON数据文件和3份PDF技术报告为主体:Python脚本包含模型配置、LSTM-CRF层、训练与评估等关键模块;JSON数据提供train/test和SMP2019任务样本;PDF报告展示不同参赛团队的解题思路与调优经验。已有1079人学习下载。包内还附带README、运行输出结果和数据格式说明,能够帮助读者快速理解从数据到模型落地的完整链路,并结合自己的对话系统或业务场景进行扩展改造。

1. 这个项目到底在解决什么问题:一条文本里的三层语义

意图识别在人工智能项目实践中是最接近落地的那类任务,尤其是用 BERT 做领域分类、意图识别和槽位填充,能把用户一句话拆成三个层次:这句话属于哪个垂直领域(天气、机票、酒店)、用户想做什么动作(查天气、订机票、订酒店)、动作里缺少哪些关键参数(出发地、目的地、时间)。一句话说清楚现象:用户输入「帮我订明天早上从上海到北京的机票」,系统先判定领域是 flight,再判定意图是 book_ticket,最后把「明天早上」填进 time 槽、「上海」填进 departure_city 槽、「北京」填进 destination_city 槽。三个任务共享一份文本输入,输出互相补充,这才构成一个能被上层逻辑直接消费的语义结果。

如果你正在找人工智能大作业方向、入门对话系统,或者从纯分类任务往序列标注上迈一步,这个组合是性价比最高的选择之一:数据不用太多、训练不用太长、效果却非常直观。更难能可贵的是,这三个任务可以挂在一个 BERT 上同时训练,而不是分别训三个模型再拼装,这让你一次就接触到多任务学习和共享参数的思想。后面几章,我按自己搭这套任务的习惯,把数据格式、模型结构、训练参数和踩过的坑完整写一遍。

2. 动手前先把标签体系定对:领域、意图、槽位的边界

2.1 领域分类与意图识别:两层的粒度差别决定数据标注成本

很多人第一次接触会混淆领域分类和意图识别,总觉得两个任务差不多。其实它们的边界很清楚:领域是一个粗粒度的主题概念,意图是这个主题下用户具体要执行的动作。「帮我订机票」落在 flight 领域、book_ticket 意图;「北京今天天气怎么样」落在 weather 领域、query_weather 意图。领域的数量通常几十个封顶,意图则需要拆到几十甚至几百,因为同一个领域下往往有大量动作变体。

看标签体系设计:

{ "text": "帮我订明天早上从上海到北京的机票", "domain": "flight", "intent": "book_ticket", "slots": [ {"value": "明天早上", "slot": "time"}, {"value": "上海", "slot": "departure_city"}, {"value": "北京", "slot": "destination_city"} ] }

text 是原始句子,domain 是领域标签,intent 是意图标签,slots 是槽位键值对列表。这个设计里,intent 和 domain 都是有穷枚举,属于文本分类;slots 是序列标注产物,在文本里定位实体再映射到语义槽。领域类别少,意图类别多,这两层的分类器共享同一个句子表示,所以你会希望一个 BERT 模型同时输出两组分类结果,而不是各训一套。

动手前最应该先做的,不是搭模型,而是把领域和意图的层级关系画出来。比如 flight 下面有 book_ticket、cancel_ticket、query_flight,weather 下面有 query_weather、alert_setting。画完这张表,你才知道数据标注要覆盖多少意图分支,也才能避免「领域和意图概念互相混用」这种最容易犯的归类错误。数据标注成本跟着意图数量走,领域多不影响整体量级,意图每多一个分支,至少要补几十条带槽值标注的语料,否则分类边界就是玄学。

2.2 槽位填充为什么要设计成序列标注:BIO 标签与实体边界

槽位填充任务在建模上其实是命名实体识别,只是实体类别换成了业务槽位名。常见做法是把每个字或每个词分配到 B、I、O 三种前缀里,B 表示槽位实体的开始,I 表示槽位实体的内部,O 表示非槽位内容。加上槽位名本身,就形成了一套类似 B-time、I-time、B-departure_city、I-departure_city 的标签集合。

看一个 BIO 标注例子:

帮 我 订 明 天 早 上 从 上 海 到 北 京 的 机 票 O O O B-time I-time I-time O B-departure_city I-departure_city O B-destination_city I-destination_city O O O

需要注意的是「从上海到北京」这类表达里,出发地和目的地是相邻实体,模型必须准确切出边界。BIO 体系在相邻实体上的表现比 BIOES 稍微弱一点,因为缺少 E 后缀来标记实体结束边界,但胜在标签数量少、模型更容易收敛。如果你的任务里实体密集、相邻频发,可以换成 BIOES 试试,代价是标签种类多了一半,数据量小的时候反而容易把标签学混。

序列标注的损失是按 token 逐个计算的,每一个 token 都贡献一个分类损失。这带来一个特性:标注数据的质量比数量更敏感。单独一个词标错,会拉低周围序列的边界判断,所以做槽位数据清洗时,重点不是删脏数据,而是人工检查实体边界有没有被多切或少切。常见做法是先跑一遍程序把标注结果可视化,每行文本下面挂上 token 和标签,肉眼扫一遍边界错误,比统计准确率还管用。

2.3 一套数据组织方式:训练集、验证集、测试集的结构

实践项目里,我不喜欢把三种标签分开存成多个文件,那会让数据对齐变得很痛苦。更顺手的是按行组织,一行一个样本,JSON 格式保存。明确了文本、领域、意图、槽位信息之后,还需要考虑训练集、验证集、测试集的划分:常见比例是 8:1:1,且划分时要按意图类型做分层抽样,不能简单随机切,否则某些冷门意图可能只在训练集里出现一两次,验证集和测试集完全没见过,指标虚高或虚低都说不准。

import json import random from collections import defaultdict def load_data(path): samples = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue samples.append(json.loads(line)) return samples def stratified_split(samples, test_ratio=0.1, val_ratio=0.1, seed=42): random.seed(seed) groups = defaultdict(list) for s in samples: groups[s["intent"]].append(s) train, val, test = [], [], [] for intent, items in groups.items(): random.shuffle(items) n_test = max(1, int(len(items) * test_ratio)) n_val = max(1, int(len(items) * val_ratio)) test += items[:n_test] val += items[n_test:n_test + n_val] train += items[n_test + n_val:] return train, val, test

这个函数按 intent 做分层切分,避免冷门意图在验证集和测试集里彻底缺席。n_test 和 n_val 用 max(1, ...) 保证每条意图至少分到一个测试样本,代价是数据量极少时验证集偏大,这可以接受,因为槽位任务的评估本身就需要跨意图观察。

数据组织还有一个容易被忽略的点:数量配比。领域分类的数据天然不平衡,「查天气」的语料往往比「取消机票」多得多。意图多的类别如果只有个位数样本,分类器大概率直接放弃它。常见补救方式不是简单复制样本,而是收集更多同义表达,或者对意图做合并——把语义动作相近的子类合成一个父类,宁可牺牲粒度,也别让类别裸奔。标签体系定到这里,才算真正具备开工条件。

3. 用 BERT 把三个任务跑起来:从数据处理到模型训练

3.1 tokenizer 对齐标签:这一步决定后面所有结果

BERT 的 tokenizer 会把词拆成子词,这是意图识别项目里第一个翻车点。中文场景下,BERT 使用字级分词,一个字通常是一个 token,似乎没有子词问题,但一旦文本里混入英文、数字、特殊符号,tokenizer 就会把一个词拆成多个子词,标签对齐就可能错位。

以「明天早上9点出发」为例,「9」可能保持原样,但「2025年」会被拆成「2025」和「年」。如果槽位标注是按原始字符做的,必须把标签逐个映射到 tokenizer 切出来的 token 序列上。常见做法是:直接按字符级标注,用 tokenizer 的 offset mapping 做映射,或者干脆把标签复制到该词的所有子 token 上。后一种做法实现简单,评估时再做一次合并就行。

from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") label2id = {"O": 0, "B-time": 1, "I-time": 2} # 示意,实际按你的标签集合扩展 def encode_with_slot_labels(text, slot_labels): encoded = tokenizer( text, truncation=True, max_length=128, return_offsets_mapping=True, return_tensors="pt" ) offsets = encoded["offset_mapping"][0] slot_ids = [] char_idx = 0 for offset in offsets: start, end = offset[0].item(), offset[1].item() if start == 0 and end == 0: slot_ids.append(-100) # special token 不参与 loss continue # 取该 token 覆盖区间内第一个字符的标签,子 token 沿用原标签 label = slot_labels[char_idx] if char_idx < len(slot_labels) else "O" slot_ids.append(label2id.get(label, 0)) char_idx = end encoded["slot_labels"] = slot_ids return encoded

这里把 special token 位置标成 -100,因为 PyTorch 的 CrossEntropyLoss 自带 ignore_index 参数,-100 位置的 token 不参与损失计算。char_idx 随着 token 的右边界推进,保证每个 token 都能拿到对应字符的槽位标签。提醒一句:如果句子被 truncation 截断,slot_labels 最后一个 token 又超界了,这里的if char_idx < len(slot_labels)是必须的,否则直接越界。

3.2 一个共享 BERT 三个输出头:领域分类、意图分类、槽位填充的模型写法

三个任务并不需要三个独立模型。BERT 的每个 token 都会产出一个向量表示,[CLS] 位置的向量可以当作整个句子的语义表示,喂给领域分类头和意图分类头;其他位置 token 的向量按序列输出,喂给槽位分类头。这是一个典型的共享编码器、多任务输出的结构。

import torch.nn as nn from transformers import BertModel class JointBERT(nn.Module): def __init__(self, bert_path, num_domains, num_intents, num_slots): super().__init__() self.bert = BertModel.from_pretrained(bert_path) hidden = self.bert.config.hidden_size self.domain_head = nn.Linear(hidden, num_domains) self.intent_head = nn.Linear(hidden, num_intents) self.slot_head = nn.Linear(hidden, num_slots) 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 ) sequence_output = outputs.last_hidden_state cls_output = sequence_output[:, 0, :] # [B, H] domain_logits = self.domain_head(cls_output) intent_logits = self.intent_head(cls_output) slot_logits = self.slot_head(sequence_output) # [B, L, S] return domain_logits, intent_logits, slot_logits

输出头都是单层线性层,参数量很小,真正干活的是 BERT 本体。domain 和 intent 都用 [CLS] 表示,槽位用全序列表示。这里选择不加 CRF 是因为数据量不保证 CRF 能学到可靠的转移约束,线性分类头在小规模数据上已经能拿到足够好的边界。如果你的训练数据有十万条以上,可以再考虑把 slot_head 换成 CRF 层。

3.3 多任务损失怎么融合:直接相加不等于最优

三个输出各自算交叉熵,加起来当作总损失。常见做法是加权求和,并且在初期把领域分类的权重调高。原因很现实:领域判断错了,意图和槽位基本全错,领域任务在对话系统里其实是后面所有逻辑的前置条件。我给三个 loss 的默认权重是领域 1.0、意图 1.0、槽位 1.0,先跑基线,看验证集趋势再调。

import torch from torch.utils.data import DataLoader def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss = 0.0 for batch in dataloader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) domain_labels = batch["domain_labels"].to(device) intent_labels = batch["intent_labels"].to(device) slot_labels = batch["slot_labels"].to(device) domain_logits, intent_logits, slot_logits = model( input_ids, attention_mask ) loss_fn = nn.CrossEntropyLoss(ignore_index=-100) domain_loss = loss_fn(domain_logits, domain_labels) intent_loss = loss_fn(intent_logits, intent_labels) slot_loss = loss_fn( slot_logits.permute(0, 2, 1), slot_labels ) loss = domain_loss + intent_loss + slot_loss optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(dataloader)

槽位 loss 需要把 logits 的维度从 [B, L, num_slots] 转成 [B, num_slots, L],因为 CrossEntropyLoss 期望类别维在第二维。loss 直接相加是最省事的融合方式,但它假设三个任务难度相当。实际任务里槽位标注的序列长度和标签数都远大于分类任务,loss 数值天然偏大,不加权重就会让模型主要迁就槽位任务。如果要调,最不容易翻车的做法是先跑几个 epoch 看三个任务各自的 loss 下降曲线,哪个下得慢就给哪个更高的权重。

3.4 训练关键参数:拿这一组配置先跑通

参数方面我给出一个经过多次项目验证的基线配置,适合从几千到十万条规模的中文对话数据。learning_rate 取 2e-5 到 5e-5,BERT 微调用太大会导致灾难性遗忘,用太小则收敛缓慢;batch_size 8 到 32 取决于显存,文本短的话直接上 32;max_length 64 到 128 足够覆盖绝大多数意图数据,不要盲目开 512,会拖慢训练还引入大量 padding。epoch 3 到 5 是 BERT 微调的常用区间,跑完看验证集指标再决定是否继续。warmup_ratio 0.1 帮助前几步稳定学习率。

这组参数在大部分情况下不会出明显问题。想调优先调 learning rate,其次动 batch size,最后才考虑模型结构。一次只动一个变量,记录验证集指标变化,这是最快建立体感的路径。

4. 避坑:数据对齐、损失函数和评估里最容易翻车的几个点

4.1 子 token 标签错位:一切 bad case 的根源

现象:训练 loss 在下降,验证集槽位 F1 却一直不涨,随便看几条预测结果,发现槽位实体总是偏一位。

原因:tokenizer 把原始文本切成了子词,而槽位标签仍然按原字符对齐,两者长度不一致,代码里没有做映射,导致标签错位。中文看似没有子词,但数字、英文、特殊符号会把词切开,一旦出现,后面所有标签都会漂移。

解决:使用 3.1 节里的 offset mapping 对齐方案,给每个 token 分配它涵盖的首个字符标签,子 token 沿用原词标签。每次预处理后抽几条数据把「token 列表 + 标签列表」打印出来人工对比,这个习惯能省掉大量后期排查时间。

4.2 padding 标签当成有效标签参与 loss

现象:训练时 loss 降得又快又稳,但验证集槽位 F1 只有零点几,细看发现模型把所有 padding 位置都预测成了 O。

原因:padding 位置的标签默认是 0,如果 label2id 里 O 恰好是 0,padding 部分被当成非实体参与损失计算,还占序列比例很大,模型被大量无信息标签带偏。

解决:把 padding 位置的标签设为 -100,同时保证 O 标签编号不是 -100。CrossEntropyLoss 里设置 ignore_index=-100。如果自定义了损失函数,同样要处理 ignore_index。检查方法很简单:统计一条样本里 -100 的 token 数量,再对比实际 special token 数,两者应一致。

4.3 领域和意图分类头的输入搞错位置

现象:领域分类的验证准确率正常,意图分类的准确率就是上不去,而且 bad case 集中在语义相近的意图之间。

原因:两个分类头都用了 [CLS] 向量,但 [CLS] 是整个句子的全局表示,对意图这种更细粒度的区分不够敏感,尤其在语料表达分歧大的时候。

解决:先确认意图类别是否互相纠缠,例如「订酒店」和「查酒店价格」动作相近,人为难分,模型当然难分。然后再考虑换表征:用 sequence_output 做 mean pooling 或取倒数第二层的 [CLS] 向量,往往是提升细粒度分类的简单有效手段。不要一上来换模型,先换输入特征。

4.4 中文实体边界越过特殊符号

现象:槽位预测里「明天早上8点」被模型预测为「明天早上」和「8点」两个实体,而标注只有 time 一个槽位。

原因:数字和中文之间被 tokenizer 拆开,模型学到的是字面片段而非业务边界,加上训练数据里时间表达形式太多样,规则性的边界在规模小时学不收敛。

解决:最直接的方法是把数字类 token 单独做归一化,在数据预处理阶段把普遍数字替换成数字占位符(如「2025年」→「[NUM]年」),缩短表达多样性。更优雅的方案是在实体级别做后处理,拿预测的 BIO 序列合并成完整短语后,再根据槽位类型修正边界,比如 time 槽里数字后面紧跟的时间单位必须收进同一实体。

4.5 三个任务的验证指标混在一起看

现象:训练日志里三个 loss 都在降,模型就收工了,上线后用户一问「帮我查明天上海到北京的机票」,领域和意图都对,槽位却把出发地和目的地互换。

原因:三个任务的难易程度和收敛速度不同,联合训练的 loss 值是加权后的总和,总 loss 下降不代表每个子任务都达到可用状态。

解决:训练过程中分别打印三个子任务的验证指标,而不是只看总 loss。领域分类看准确率,意图分类看准确率和混淆矩阵,槽位看实体级 F1。每个子任务都要设一个最低达标线,比如槽位 F1 不低于 0.9 才能发布,防止总指标掩盖单任务拉胯。停止训练的标准也应该取三个子任务都进入平台期之后。

5. 多任务联合优化与评估:把三个指标拆开看才能上线

5.1 为什么要让三个任务共享同一个 BERT

三个独立模型各自训练,每个模型参数量差不多,但推理时要串行跑三次,延迟和显存都翻倍。共享 BERT 的意义不只是省资源,更重要的是三个任务可以互相增强表示。比如槽位标注学到了「明天早上」是时间表达,这会促使 [CLS] 表示对时间语义更敏感,意图分类也会间接受益。多任务训练的本质是在同一个语义空间里做三个子任务,互相提供正则化的效果。实践感受是:槽位数据的量级通常是意图数据的数倍,如果没有槽位任务帮忙,意图分类在小数据下更容易过拟合。

不过共享参数也有一个隐患:任务之间如果有冲突,梯度会互相拉扯。领域分类想强调句子主题,意图分类想聚焦动作词,槽位在强调实体边界,理论上它们都在不同维度上抽取信息,实际冲突不明显。真遇到收敛异常,可以改成两个阶段:先免费使用槽位任务训练几轮,冻结 BERT 之后只调三个分类头。这种方案在数据极度不平衡时有用,平时不需要。

5.2 分类看准确率,槽位看实体 F1

评估方案要和任务本身匹配。领域分类、意图分类属于多分类,直接算准确率和 macro-F1。槽位填充是序列标注,逐 token 算准确率没有意义,因为绝大多数 token 是 O,模型即使一个实体没抓到也能拿到很高的 token 准确率。实体级 F1 才是对的指标:先把 BIO 序列合并成实体列表,比如 [(time, 明天早上), (departure_city, 上海), (destination_city, 北京)],然后按实体精确匹配计算 F1。预测的实体边界只要错一个字,就计为预测错误。

def merge_slot_spans(slot_ids, id2label, offsets): spans = [] start = None current_label = None for i, slot_id in enumerate(slot_ids): label = id2label[slot_id] if label.startswith("B-"): if start is not None: spans.append((current_label, start, i - 1)) start = i current_label = label[2:] elif label.startswith("I-"): if start is None: start = i current_label = label[2:] elif label == "O": if start is not None: spans.append((current_label, start, i - 1)) start = None current_label = None if start is not None: spans.append((current_label, start, len(slot_ids) - 1)) return spans

把预测的 BIO 序列和真实标注的 BIO 序列都合并成实体跨度列表,用元组集合求交集再算 F1。这个函数只吃 slot_ids 和 id2label,可以挂在验证循环里对每个 batch 调用。注意标签缩写要和训练时的 label2id 严格一致,否则前缀判断出错。很多项目槽位指标上不去,查到最后不是模型问题,而是评估代码里 BIO 合并逻辑写错了。

5.3 验证集应该怎么搭建

验证集既要反映真实意图分布,又要保证每个意图都有一定样本,否则指标波动太大,调参失去意义。前面用 stratified_split 按意图分层切分就是这个原因。除了分层,还要保证同一句话的语义变体不重复出现在训练集和验证集里。比如「帮我订明天去上海」和「订明天去上海的机票」是两种表达方式,算不同样本,但「帮我订明天去上海的机票」和「帮我订明天去上海的机票谢谢」则只应保留一个版本,否则验证集会看着像过拟合,实际是数据泄漏。

模拟真实场景时,验证集样本应该分配不同渠道来源,比如比例上偏用户自由输入,而不是对着模板生成的干净句子。模板生成的句子在 BERT 面前太规整,验证集看起来高,上线遇到口语变体直接败下阵来。这个修为无法在模型侧解决,只能在数据侧解决。

5.4 训练过程的监督机制

训练中监控三个子任务的验证指标需要提前定义好频率,每半个 epoch 评估一次比较稳妥。需要注意多任务下总 loss 下降而个别任务不降的可能,在这一步最好能自动最小化:设定早停条件,当槽位 F1 连续两次验证不涨时提前停止,即使其他两个任务还在涨。原因很简单,槽位标注最依赖边界信息的精确性,这是最难优化的任务,一旦停止提升,其他任务继续训练只是过拟合训练分布。做项目时优先保护最难任务,这是一个很主观但有效的经验。

6. 把模型接进真实对话系统:推理输出、简单部署和下一步

6.1 从原始文本到语义框架:推理时的输出组装

训练完成的模型在推理阶段其实很轻量,把一条用户输入经过 tokenizer 编码,丢给模型,得到三组 logits。取 argmax 得到领域、意图和每个 token 的槽位标签,再合并 BIO 序列得到可读的槽位键值对。组装成 JSON 语义框架后,上层逻辑直接消费这个 JSON 就能做下一步执行。

def predict_and_parse(model, tokenizer, text, device, id2domain, id2intent, id2label): encoded = tokenizer(text, return_tensors="pt", truncation=True, max_length=128) input_ids = encoded["input_ids"].to(device) attention_mask = encoded["attention_mask"].to(device) model.eval() with torch.no_grad(): domain_logits, intent_logits, slot_logits = model(input_ids, attention_mask) domain_id = domain_logits.argmax(dim=-1).item() intent_id = intent_logits.argmax(dim=-1).item() slot_ids = slot_logits.argmax(dim=-1).squeeze().tolist() domain = id2domain[domain_id] intent = id2intent[intent_id] slots = merge_slot_spans(slot_ids, id2label, None) return {"domain": domain, "intent": intent, "slots": slots}

推理时不再需要 slot_labels,merge_slot_spans 可以直接基于 token 索引工作。要注意这里 text 是单条输入,tokenizer 会在句首句尾插入 [CLS] 和 [SEP],所以 merge 结果的第一项和最后一项都是 special token,合并槽位时要跳过 0 和结尾的 special token 位置。这个细节漏掉的话,槽位实体会带着一个偏移量错位到句外,是你排错时最容易忽略的部分。

6.2 模型版本切换:让服务安静完成更新

模型训练好后要落到线上,这里有一个经常被忽视的工程问题:模型更新策略。如果直接把新模型文件替换旧服务里的加载路径,正在进行的会话会中断,甚至因为新旧模型输出格式稍有差异而出现空指针。常见做法是保留两套模型文件,把推理服务做成可配置加载路径,先只加载新模型做压测,再通过流量切分逐步把新请求路由到新版。这里的关键是模型版本号管理和加载缓存,让同一个模型文件只加载一次,而不是每次请求都重新加载,否则上线高峰会带来明显的延迟抖动。

我自己的习惯是训练完成后固定保存三个产物:模型权重、tokenizer、标签映射 JSON。标签映射 JSON 是最容易丢的,重新训练的时候没人记得 id 和 label 的对应关系,模型权重和标签映射一旦失配就是一场灾难。把 id2domain、id2intent、id2label 保存好,部署时就不用重新生成。

6.3 往这个方向继续深挖的三个去处

对话系统这条线上,意图识别只是入口,后面还能做对话状态追踪、知识库检索、执行动作生成。如果对人工智能项目实践和 ai 相关岗位感兴趣,这套流程已经覆盖了数据标注、模型微调、评估验证、部署上线的完整链路,可以作为独立项目写进履历。往下走可以从这三个点继续:一是用更轻量的模型替换 BERT,比如蒸馏版或直接训练字级模型,把推理时延降下来;二是给槽位填充引入词典约束,保证地点、航班号等实体不出现标签边界断裂;三是把单轮数据扩成多轮,把上文槽位并入当前轮模型输入,让系统能完整处理指代和省略。这三件事做完,这套系统就不再是课程作业水平,而是接近产品原型。

我自己最深的教训是每天训练出的模型都要手动挑 10 条 bad case 看一遍,因为验证集指标再高,真实用户的写法永远会超出预期,比如「帮我看看明天上海有没有下雨」「上海那边明天天气咋样」,这俩句子领域意图都对,但表达方式完全不在模板里。尽早暴露这类差异,比上线后让用户帮我们暴露要划算太多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进