尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

意图识别与命名实体识别:多轮对话NLU落地实战

发布时间:2026/9/28 22:56:47

资讯中心
01
ARTICLE

意图识别与命名实体识别:多轮对话NLU落地实战

意图识别与命名实体识别:多轮对话NLU落地实战
简介这份资源面向自然语言处理初学者与对话系统开发者聚焦意图识别与命名实体识别在多轮对话场景中的工程落地帮助读者理解如何让机器解析用户话语背后的真实目的并抽取关键实体。压缩包共45个文件约259KB以19个Python脚本为核心辅以12个pyc编译文件、5个txt说明、2个json配置、2个md文档以及模型文件、日志和流程图等涵盖数据准备、模型训练、服务封装与场景测试等环节。内容围绕意图分类、实体抽取、对话状态跟踪与多轮交互策略展开包含可运行的代码、模型配置与样例对话便于读者对照学习从数据预处理到服务部署的完整链路。目前已有624人学习下载适合希望掌握NLP对话系统核心模块、积累项目实践经验的中级学习者参考。1. 意图识别加命名实体识别多轮对话场景到底该怎么落地做过客服机器人、智能助手或者任务型对话系统的人大概率都遇到过这种场景用户第一句说“帮我查一下上个月的话费”系统识别出意图是「查询话费」正常回复。用户接着说“那流量呢”如果系统只靠单轮意图分类这句话大概率会被判成「查询流量」——听起来对但上下文里“那”指代的是同一个查询动作槽位应该继承上一轮的月份和账户而不是重新开一轮。这就是多轮对话里最典型的翻车点单轮模型看起来准确率很高串成对话就崩了。这个项目标题里的三个关键词——意图识别、命名实体识别、多轮对话——其实是一条完整的链路。意图识别负责判断“用户想干什么”命名实体识别负责抽取“干这件事需要哪些参数”多轮对话负责维护“跨轮次的状态和指代”。三者缺一不可。只做意图分类系统知道用户要查话费但不知道查哪个月只做实体抽取系统拿到一堆时间、地点、金额却不知道要干嘛不做多轮状态管理用户每换一句话系统就失忆一次。这套方案适合谁如果你正在做任务型对话系统、智能客服、语音助手的 NLU 模块或者你的毕业设计/大作业选题落在“人工智能项目实战”这个方向上这篇文章的路径可以直接复用。我下面会按“先跑通单轮 NLU再叠多轮状态机最后处理指代和槽位继承”的顺序讲每一步都有可抄的代码和参数说明。整套东西不需要 GPU 集群一台普通开发机就能跑通最小闭环。2. 先把单轮 NLU 跑通意图分类和实体抽取的最小实现2.1 意图分类为什么先用轻量方案而不是直接上大模型很多人一上来就想用大模型做意图分类觉得效果好。但实际项目里意图分类的类别通常是固定的、有限的比如 20 到 50 个意图而且对延迟敏感——多轮对话里每轮都要分类一次用大模型推理一次几百毫秒三轮下来用户就等得不耐烦了。我一般会先用轻量方案跑 baseline确认数据标注质量和类别边界没问题再考虑要不要升级。最常见的做法是用预训练语言模型做特征提取接一个分类头在标注数据上微调。如果标注数据少每个意图不到 100 条可以先用句子向量加逻辑回归做快速验证。下面是一个基于 transformers 的最小意图分类实现import torch from torch.utils.data import Dataset, DataLoader from transformers import AutoTokenizer, AutoModelForSequenceClassification from torch.optim import AdamW # 意图标签映射实际项目里从配置文件读取 INTENT_LABELS { query_bill: 0, query_data: 1, recharge: 2, complaint: 3, cancel_service: 4, } ID2INTENT {v: k for k, v in INTENT_LABELS.items()} class IntentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len64): self.texts texts self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encoding self.tokenizer( self.texts[idx], truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt, ) return { input_ids: encoding[input_ids].squeeze(), attention_mask: encoding[attention_mask].squeeze(), label: torch.tensor(self.labels[idx], dtypetorch.long), } def train_intent_model(train_texts, train_labels, epochs3, batch_size16, lr2e-5): tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model AutoModelForSequenceClassification.from_pretrained( bert-base-chinese, num_labelslen(INTENT_LABELS) ) dataset IntentDataset(train_texts, train_labels, tokenizer) loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue) optimizer AdamW(model.parameters(), lrlr) model.train() for epoch in range(epochs): total_loss 0 for batch in loader: optimizer.zero_grad() outputs model( input_idsbatch[input_ids], attention_maskbatch[attention_mask], labelsbatch[label], ) loss outputs.loss loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch1}, avg_loss{total_loss/len(loader):.4f}) return model, tokenizer这段代码的关键参数有三个max_len64对意图分类足够因为意图通常由短句决定不需要长上下文lr2e-5是 BERT 微调的经典学习率太高会破坏预训练权重太低收敛慢epochs3在几千条数据上通常够用再多容易过拟合。训练完成后推理时取 logits 的 argmax 就是预测意图。2.2 命名实体识别用 BIO 标注做序列标注意图分类告诉你“用户要查话费”但查哪个月、哪个号码得靠命名实体识别抽出来。实体类型通常包括时间、产品名、金额、电话号码等。标注格式用 BIOB-XXX 表示实体开始I-XXX 表示实体内部O 表示非实体。from transformers import AutoModelForTokenClassification # BIO 标签体系 NER_LABELS [ O, B-time, I-time, B-product, I-product, B-amount, I-amount, B-phone, I-phone, ] NER_LABEL2ID {label: i for i, label in enumerate(NER_LABELS)} NER_ID2LABEL {i: label for label, i in NER_LABEL2ID.items()} class NERDataset(Dataset): def __init__(self, texts, label_seqs, tokenizer, max_len128): self.texts texts self.label_seqs label_seqs self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encoding self.tokenizer( self.texts[idx], truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt, is_split_into_wordsFalse, ) # 对齐标签这里简化处理实际项目需要用 offset_mapping 做字符级对齐 labels self.label_seqs[idx][:self.max_len] labels [NER_LABEL2ID[O]] * (self.max_len - len(labels)) return { input_ids: encoding[input_ids].squeeze(), attention_mask: encoding[attention_mask].squeeze(), labels: torch.tensor(labels, dtypetorch.long), } def train_ner_model(train_texts, train_label_seqs, epochs5, batch_size16, lr3e-5): tokenizer AutoTokenizer.from_pretrained(bert-base-chinese) model AutoModelForTokenClassification.from_pretrained( bert-base-chinese, num_labelslen(NER_LABELS) ) dataset NERDataset(train_texts, train_label_seqs, tokenizer) loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue) optimizer AdamW(model.parameters(), lrlr) model.train() for epoch in range(epochs): total_loss 0 for batch in loader: optimizer.zero_grad() outputs model( input_idsbatch[input_ids], attention_maskbatch[attention_mask], labelsbatch[labels], ) loss outputs.loss loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch1}, avg_loss{total_loss/len(loader):.4f}) return model, tokenizer这里有个血泪经验标签对齐是 NER 里最容易翻车的地方。BERT 的 tokenizer 会把一个词切成多个 subword如果你的标签是按字符标注的必须用offset_mapping把字符级标签映射到 token 级否则训练数据全是错的。上面代码为了简洁做了简化实际项目里这一步不能省。推理时把模型输出的标签序列做 BIO 解码就能得到实体列表def decode_bio(tokens, pred_ids): entities [] current_entity None for token, pid in zip(tokens, pred_ids): label NER_ID2LABEL[pid] if label.startswith(B-): if current_entity: entities.append(current_entity) current_entity {type: label[2:], text: token} elif label.startswith(I-) and current_entity: current_entity[text] token else: if current_entity: entities.append(current_entity) current_entity None if current_entity: entities.append(current_entity) return entities参数上NER 的max_len要比意图分类大因为实体可能出现在长句里128 是常见起点。学习率用3e-5比意图分类略高因为序列标注任务对参数更新更敏感。3. 多轮对话状态管理让系统记住上一轮说了什么3.1 对话状态追踪的基本结构单轮 NLU 跑通后下一步是把多轮串起来。核心问题是用户说“那流量呢”的时候系统怎么知道这是在延续上一轮的查询动作答案是维护一个对话状态Dialogue State每一轮把 NLU 的结果合并进去。对话状态通常包含三部分当前意图、已填充的槽位、对话历史。我一般用一个字典结构来管理class DialogueState: def __init__(self): self.current_intent None self.slots {} # 已填充的槽位如 {time: 上个月, product: 话费} self.history [] # 对话历史每轮存 (user_text, intent, entities) self.turn_count 0 def update(self, user_text, intent, entities): self.turn_count 1 self.history.append({ turn: self.turn_count, user: user_text, intent: intent, entities: entities, }) # 意图继承如果本轮意图置信度低或为指代类沿用上一轮意图 if intent and intent ! unknown: self.current_intent intent # 槽位更新新抽取的实体覆盖旧值 for ent in entities: self.slots[ent[type]] ent[text] return self def get_context(self): return { intent: self.current_intent, slots: self.slots.copy(), turn: self.turn_count, }这个结构看起来简单但实际项目里 80% 的多轮 bug 都出在状态更新逻辑上。比如用户说“不是这个月是上个月”实体抽取会拿到“上个月”直接覆盖time槽位没问题但如果用户说“流量也查一下”实体里只有“流量”没有时间这时候time槽位应该保留上一轮的值而不是被清空。3.2 指代消解和槽位继承的处理策略指代消解是多轮对话里最玄学的部分。用户说“那流量呢”“这个多少钱”“帮我改成那个”这些句子单独看根本没法处理必须结合上下文。我的做法是分两步先判断当前轮是否包含指代词如果包含就从对话历史里找最近的同类实体做替换。# 指代词列表实际项目里可以更全 PRONOUNS [那, 这个, 那个, 它, 他们, 这些, 那些] def resolve_coreference(user_text, state): 简单的指代消解如果当前轮实体为空且包含指代词继承上一轮同类槽位 has_pronoun any(p in user_text for p in PRONOUNS) if not has_pronoun: return state.slots # 从最近的历史轮次里找实体 for turn in reversed(state.history[:-1]): if turn[entities]: # 把上一轮的实体填入当前槽位不覆盖已有值 for ent in turn[entities]: if ent[type] not in state.slots: state.slots[ent[type]] ent[text] break return state.slots这段逻辑的核心是指代词出现时优先从最近的、有实体的历史轮次里继承槽位且不覆盖当前轮已经抽到的实体。比如用户先说“查上个月话费”系统记录time上个月, product话费用户接着说“那流量呢”实体抽取只拿到product流量指代消解会把time上个月从历史里补回来最终槽位是time上个月, product流量。参数上PRONOUNS列表需要根据实际语料调整中文里“那”“这个”最常见但也要注意“那”有时候是连词不是指代词比如“那我先问问”实际项目里可以加一个简单的规则过滤如果“那”后面紧跟动词且句子没有实体大概率不是指代。3.3 把 NLU 和状态管理串成完整流程把前面几块拼起来一个完整的多轮对话处理流程是这样的def process_turn(user_text, state, intent_model, intent_tokenizer, ner_model, ner_tokenizer): # 第一步意图分类 intent_inputs intent_tokenizer(user_text, return_tensorspt, truncationTrue, max_length64) with torch.no_grad(): intent_logits intent_model(**intent_inputs).logits intent_id torch.argmax(intent_logits, dim-1).item() intent ID2INTENT[intent_id] # 第二步实体抽取 ner_inputs ner_tokenizer(user_text, return_tensorspt, truncationTrue, max_length128) with torch.no_grad(): ner_logits ner_model(**ner_inputs).logits ner_preds torch.argmax(ner_logits, dim-1).squeeze().tolist() tokens ner_tokenizer.convert_ids_to_tokens(ner_inputs[input_ids].squeeze()) entities decode_bio(tokens, ner_preds) # 第三步更新对话状态 state.update(user_text, intent, entities) # 第四步指代消解 resolve_coreference(user_text, state) return state.get_context()这个流程每轮调用一次返回的 context 就是当前对话的完整状态下游的对话策略模块根据 intent 和 slots 决定下一步动作。实际部署时意图分类和实体抽取可以合并成一个多任务模型共享底层 BERT 编码器推理速度能快 40% 左右。4. 避坑指南多轮对话 NLU 最常见的五个翻车点4.1 实体标签对齐错误导致训练 loss 不降现象NER 模型训练时 loss 一直在 2.0 以上震荡预测结果全是 O。 原因BERT tokenizer 把中文词切成了 subword但标签还是按字符对齐的导致标签和 token 错位。 解决用tokenizer(text, return_offsets_mappingTrue)拿到每个 token 对应的字符区间再把字符级标签映射到 token 级。对于被切成多个 subword 的词第一个 subword 用 B-后续用 I-。4.2 意图分类在短句上准确率骤降现象训练集准确率 95%但用户说“查一下”“看看”“帮我弄”这种短句时意图分类完全乱套。 原因短句缺乏上下文特征模型学到的都是长句里的关键词模式。 解决在训练数据里补充短句样本或者用对话历史拼接当前句作为输入。我一般会把上一轮用户说的话和当前句用 [SEP] 拼起来送进模型让模型有上下文可参考。4.3 槽位被错误覆盖导致信息丢失现象用户先说“查上个月话费”系统正确填充time上个月用户接着说“流量也查一下”系统把time清空了。 原因状态更新逻辑里用了全量覆盖新轮次没有抽到 time 实体就把旧值删了。 解决槽位更新用增量合并只覆盖本轮抽到的实体类型没抽到的保留旧值。但要注意如果用户明确说“不是上个月”那需要先做否定检测再决定是否清除。4.4 指代消解把连词当成指代词现象用户说“那我先问问”系统把“那”当成指代词从历史里继承了一堆无关槽位。 原因指代词列表太宽泛没有做上下文判断。 解决加规则过滤——如果指代词后面紧跟动词且当前轮没有实体跳过指代消解。更稳的做法是训练一个小的指代检测分类器但规则在大多数场景下够用。4.5 多轮对话状态在并发场景下串线现象两个用户同时对话A 用户的槽位出现在 B 用户的回复里。 原因对话状态对象被全局共享了没有按 session 隔离。 解决每个会话 ID 对应一个独立的 DialogueState 实例用字典或 Redis 存储请求进来先根据 session_id 取状态处理完再写回。这个坑在 demo 阶段不容易发现一上并发就暴露。5. 进阶技巧用规则兜底和置信度阈值让系统更稳模型不是万能的实际项目里我一般会在 NLU 后面加一层规则兜底。意图分类的 softmax 输出可以拿到置信度如果最高置信度低于 0.6就不直接采用模型结果而是走规则匹配或者追问澄清。实体抽取同理如果 BIO 解码出来的实体边界不完整比如只有 B- 没有 I-就丢弃这个实体。def intent_with_fallback(user_text, model, tokenizer, threshold0.6): inputs tokenizer(user_text, return_tensorspt, truncationTrue, max_length64) with torch.no_grad(): logits model(**inputs).logits probs torch.softmax(logits, dim-1) max_prob, pred_id torch.max(probs, dim-1) if max_prob.item() threshold: # 置信度低走规则匹配 if any(kw in user_text for kw in [话费, 账单, 扣费]): return query_bill, max_prob.item() elif any(kw in user_text for kw in [流量, 套餐, 剩余]): return query_data, max_prob.item() else: return unknown, max_prob.item() return ID2INTENT[pred_id.item()], max_prob.item()阈值设多少合适我一般从 0.6 起步根据线上 badcase 调整。阈值太高会导致大量请求走规则规则覆盖不到就变成 unknown阈值太低则模型错误结果直接透传。实际项目里可以统计不同阈值下的准确率和覆盖率曲线选一个平衡点。另一个技巧是对话历史的截断策略。多轮对话不可能无限保留历史一般保留最近 5 到 8 轮就够了。太早的历史对当前轮的影响很小反而会增加推理开销。如果对话轮次超过阈值就把最早的轮次压缩成摘要比如只保留意图和关键槽位而不是直接丢弃。验证这套系统是否靠谱我通常用两个指标一是单轮 NLU 的意图准确率和实体 F1这个在离线测试集上跑二是多轮场景下的任务完成率这个需要构造多轮测试用例比如“查话费 → 那流量呢 → 帮我充100”这种连续对话看系统能不能正确继承槽位并完成充值意图。任务完成率能到 85% 以上基本就可以上线试跑了。我自己踩过最深的坑是早期版本没做 session 隔离测试时一切正常上线后用户一多就出现串线排查了半天才发现是状态对象被多个请求共享了。后来养成习惯任何跟对话状态相关的代码第一件事就是确认 session_id 有没有正确传递。希望这些经验能帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。