资讯动态

对话机器人意图识别与路由设计:从if-else到可配置地图

发布时间:2026/10/14 9:15:41 来源:尧图企业网站定制
我最早做对话机器人那会儿也是从十几个if/else起家的。头一个月觉得很爽加一个查天气就写一个分支再补几个关键词什么今天热不热明天下雨吗都能罩得住。等意图攒到三四十个产品同学开始天天往里塞用户还可能这么说的表述代码就乱了——条件判断层层嵌套改一处逻辑怕碰坏另一处新意图上线得像拆炸弹。后来我开始做第三个项目时决定换一条路把所有用户想干什么的定义从代码里剥出来单独做一个叫lingbot-map的模块。它做的事情可以类比成一张城市地图用户说出一句话地图负责把它翻译成一个目的地意图再路由到对应的处理技能上如果地图上查无此地就走兜底路径。这套东西做完之后我明显感觉到加新意图的成本降了一个量级测试回归也从翻遍代码找分支变成了改一行配置。这篇文章就把 lingbot-map 从设计到落地、再到上线后踩坑优化的完整过程拆开讲。适合正在做对话机器人、客服助手或者智能体路由层的朋友参考尤其是被对话逻辑耦合折磨过的团队。1. 为什么对话地图比堆条件判断更靠谱先看失控现场1.1 意图一多面条代码是怎么形成的先还原一个典型场景。假设你做一个客服机器人最开始支持的意图是查订单改地址退换货这三类。代码大概长这样if 订单 in text or 物流 in text: return handle_order(text) elif 地址 in text or 配送 in text: return handle_address(text) elif 退 in text or 换 in text: return handle_refund(text)这时候没什么问题。但紧接着产品要求支持查发票开发票查积分兑换礼品找门店投诉建议……每个意图的表述方式都有一堆变体比如查订单可能是我的东西到哪了快递还有多久到昨天买的什么时候发货。如果你把这些变体全部塞进 if 条件里代码会变成if any(k in text for k in [订单, 物流, 快递, 发货, 到哪了, ...]): if 地址 in text and 改 in text: return handle_address(text) ...问题是关键词之间互相重叠。我的快递怎么改地址这一句话既命中快递又命中改地址到底走哪个分支排序靠前就是对的吗这种判断不光性能差而且没有任何置信度概念——你永远不知道这次命中是靠一个强关键词硬撑起来的还是靠两个弱关键词碰运气碰上的。我在某团队里见过一个跑了两年多的机器人主函数里嵌套了七层 if每次改动都要拉上三个人一起评审。后来我们统计了一下大约 40% 的线上误答都来自这种命中即触发的规则互相抢单。这不是个别团队的毛病而是几乎所有对话项目的必经之痛。1.2 lingbot-map 的分层思路意图识别和业务响应彻底分离lingbot-map 的核心思路是把理解用户和执行动作拆成两层。理解层只做一件事给用户的输入打上标签——这是哪个意图携带了哪些参数执行层只做一件事根据标签去调用对应的技能。打个比方传统的 if-else 写法像是把问路的人和看地图的人绑在一起每来一个新问法都要重新教一遍lingbot-map 的做法是画一张标准地图不管问路的人怎么措辞都先翻译成目的地编号再由导航员统一带路。这样一来业务上新增一个能力时开发者只需要做两件事在意图表里登记这个意图并配上足够多的示例说法。把意图 ID 和新的技能处理器绑定。至于用户到底说我要退款还是这玩意儿能退吗那是意图识别层的事和业务代码无关。这个分层带来的直接收益是意图相关改动几乎不碰业务代码业务逻辑调整也不影响意图识别两边的迭代可以并行。后面所有内容都是围绕这个分层思路展开的。如果说整篇文章只留一句话我希望你记住的是对话系统的复杂度瓶颈不在怎么响应而在怎么把千变万化的表达稳定地归到有限的意图桶里。2. 地图的三张表意图注册表、实体槽位、技能分发表lingbot-map 里有一个核心概念叫地图由三张表组成。我在做第一版的时候自己也没想清楚直接写死了一张大字典后来发现凡是写死的配置迟早都要变成活的。于是重构成了三张互相独立、又通过 ID 关联的表。2.1 意图注册表给每种用户想做的事建档案意图注册表是全地图的起点它回答的是用户这句话属于哪一类目的。每条记录最少要有这几个字段字段说明示例intent_id意图唯一编号全局不可重复weather_queryname给产品同学看的中文别名查天气examples示例说法列表越多越稳[今天天气怎么样, 明天会不会下雨, 帮我看看周末气温]negative_examples反例用于抑制误匹配[明天要开会吗, 帮我订一张去A市的票]threshold当前意图的触发阈值0.55这里我要特别强调negative_examples这个字段。很多初版设计都会漏掉它但实际线上表现差的系统往往不是该命中的没命中而是不该命中的乱命中。反例的作用是给打分器标定边界一条输入如果和某意图的正例很像、但同时命中了反例分数必须被压下来。我后面会专门讲这个案例。示例说法的数量也很关键。刚上线时每个意图给 5 条就够了但迭代三个月后一个意图没有 20 条以上的示例你很难覆盖用户的真实口语。这里有个经验值示例说法要来自真实日志不要自己坐在工位上瞎编——你编不出用户的花样。2.2 实体槽位地图上的精确坐标光有意图还不够。用户说明天A市天气怎么样意图是查天气但系统不知道查哪座城市、查哪一天技能就没法执行。实体槽位解决的就是这个问题。每个意图可以声明若干槽位并标注是否必填槽位名类型是否必填提取示例city城市名是A市date日期/相对时间否明天、后天、2025-06-01槽位提取不一定要上多复杂的模型。很多场景用词表 正则就能扛住比如城市词表、日期正则。只有当用户说法特别自由、槽位取值又特别多的时候才值得考虑训练一个专门的 NER 模型。我在项目里的原则是先用最笨的办法跑起来让日志告诉你哪里真的需要模型而不是提前给自己造复杂度。2.3 技能分发表意图与执行逻辑的解耦第三张表负责把意图 ID 映射到实际执行的技能函数。它通常还包含两个附加信息这个技能需要哪些槽位齐备才能触发以及它支持在哪些上下文状态下被调用。意图ID技能名必填槽位允许的上下文weather_queryweather_skillcitydefault, weather_sessionorder_queryorder_skillorder_nodefault, order_session这张表的价值在于执行层完全不感知用户怎么说的它只接收一个干净的参数包。比如weather_skill收到{city: A市, date: 明天}它只管调天气接口不管这句话是从哪个入口来的。这样一来同一个技能既可以被今明两天天气咋样触发也可以被多轮追问里的那后天呢触发技能本身不用改一行代码。三张表串起来的完整链路是这样的用户输入 → 预处理去噪、归一化、分词。意图打分器对输入和每个意图的示例/反例进行匹配输出候选意图列表。按分数排序过滤掉低于阈值的候选取第一名作为主意图。在主意图上执行实体槽位提取。检查必填槽位缺失则进入追问流程。槽位齐备后查技能分发表把参数包交给对应技能。3. 核心实现把地图从配置文件跑到线上3.1 为什么选择配置文件当地图本体第一版 lingbot-map 我把意图定义写在 Python 字典里也跑通了。但后来产品同学想自己调阈值、加示例每次都要找我改代码体验非常差。于是我把地图本体改成了独立的 YAML 文件代码只负责读取和执行。这一步的收益远超预期产品同学可以自己维护意图表开发只需要关注路由引擎本身。# lingbot_map.yaml intents: - id: weather_query name: 查天气 threshold: 0.55 examples: - 今天天气怎么样 - A市明天会下雨吗 - 帮我看看周末气温 - 后天热不热 negative_examples: - 明天要开会吗 - 帮我订一张去A市的票 slots: - name: city required: true extractor: city_dict - name: date required: false extractor: date_regex skill: weather_skill这份配置读起来几乎就是白话非技术背景的人也能看懂每一行的含义。我把这个文件放在单独一个目录里用 git 管理版本每次改动都走一次 review。上线前改配置的流程和改代码一样严格但改起来和看文档一样轻松。3.2 打分与路由引擎一个够用的实现下面这段是 lingbot-map 路由引擎的简化实现。真实项目里我把它拆成了几个类但核心逻辑全在这里import re from difflib import SequenceMatcher class LingbotMap: def __init__(self, config): self.intents config[intents] self._build_example_index() def _build_example_index(self): self.examples [] for intent in self.intents: for ex in intent[examples]: self.examples.append({ intent_id: intent[id], text: ex, tokens: self.tokenize(ex), }) def tokenize(self, text): return [w for w in re.split(r[\s。,.?!], text) if w] def _overlap_score(self, utterance, example): 用分词重叠率近似计算语义相似度适合冷启动 u_tokens self.tokenize(utterance) e_tokens example[tokens] if not e_tokens: return 0.0 hit sum(1 for t in u_tokens if t in e_tokens) return hit / max(len(e_tokens), 1) def score_intents(self, utterance): results {} for intent in self.intents: best 0.0 best_example for ex in intent[examples]: s self._overlap_score(utterance, ex) # 同义词扩展后可以进一步提升召回 s max(s, self._synonym_score(utterance, ex)) if s best: best s best_example ex # 反例压制 for neg in intent[negative_examples]: if self._overlap_score(utterance, {tokens: self.tokenize(neg)}) 0.5: best max(0.0, best - 0.3) results[intent[id]] { score: best, matched_example: best_example, } return results def route(self, utterance): scored self.score_intents(utterance) ranked sorted(scored.items(), keylambda kv: kv[1][score], reverseTrue) if not ranked: return None, {} top_id, top_info ranked[0] intent next(i for i in self.intents if i[id] top_id) if top_info[score] intent[threshold]: return None, {} slots self.extract_slots(utterance, intent[slots]) return top_id, slots_overlap_score用的是最简单的分词重叠率冷启动阶段完全够用。等积累了几千条真实对话日志再替换成向量相似度或者微调的小模型接口保持不变。注意score_intents里先取最高分、再按反例压制这个顺序很重要——反例应该作用于该意图的最终得分而不是让反例去参与最高示例分的比较否则边界情况容易出错。你可能要问分词重叠率这么粗糙能行吗我的回答是如果示例说法覆盖得足够密粗糙算法也能有不错的上限而如果示例说法稀疏再精密的模型也救不回来。数据量才是王道算法只是翻译官。3.3 多轮上下文让地图记住刚才说到哪了单轮路由跑通之后很快会碰到这个问题用户上一句说A市明天天气怎么样机器人回答了用户紧跟着一句那后天呢——这句没有任何城市信息如果当作独立请求处理city槽位必然缺失机器人就会反问您查哪个城市体验直接崩盘。lingbot-map 的做法是引入一个轻量的会话上下文对象class SessionContext: def __init__(self): self.current_intent None self.slots {} def inherit(self, utterance, new_intent, new_slots): 多轮槽位继承新句子里没提的槽位沿用上一轮的值 if self.current_intent new_intent: merged dict(self.slots) merged.update(new_slots) return merged return new_slots规则很简单如果当前的意图和上一轮相同并且新句子没有提取到某个槽位就沿用上一轮的值。这个规则至今没有被推翻过它简单到不需要机器学习但解决了对话系统里 80% 的多轮追问场景。剩下 20% 的复杂情况比如指代消解它指谁、话题切换算了还是查一下订单吧我的建议是留给专门的话题管理模块不要塞进地图路由层否则地图会膨胀到不可维护。4. 上线后才会暴露的四个坑每一条都是真金白银换来的4.1 相似意图互相抢单一次误匹配的完整排查链路lingbot-map 上线第二周运营反馈一个问题用户说帮我订一张明天去A市的票机器人居然回复了明天的天气。我去查了日志发现weather_query的得分是 0.62ticket_book的得分是 0.58前者刚好超过阈值。根因很清晰这条输入里有明天A市两个词而weather_query的示例明天A市天气怎么样里恰好也有这两个词重叠率算下来自然很高。但这里缺失的恰恰是订票这个核心动作词的权重。我当时的修复方案分三步给weather_query增加反例帮我订一张明天去A市的票。给ticket_book的示例里加入更多动作动词订、买、预约、出票。对示例词引入 IDF逆文档频率加权——像明天A市这种在多个意图里反复出现的词权重自动降低订退查改这种强区分词权重提高。排查的过程本身比结论更重要。我的习惯是任何一次误匹配都要把完整链路拉出来——原始输入、预处理结果、每个意图的得分、被选中主意图、槽位提取结果、最终响应。只有信息齐全了你才能判断问题到底出在打分、阈值还是槽位。很多团队一上来就调阈值调了半天发现是反例没写全纯属白费功夫。4.2 省略句把路由器打懵上下文丢失的表现与应对另一个高频坑是省略句和上下文遗忘。用户问完A市明天天气后追问那A市呢地图能靠上下文继承解决但如果用户直接说深圳呢而当前意图是ticket_book这就涉及话题切换和槽位嫁接——不是简单的继承能搞定的。我在处理这类问题时给上下文对象加了一个最近三个意图栈的记忆结构。当新输入疑似是省略句比如句子极短、没有核心动词、只含实体词时路由引擎会先在上一个意图上尝试槽位补全补全失败再尝试倒数第二个意图都失败才走兜底。这个策略有点笨但实测命中率很高因为它抓住了一个本质规律人在对话里省略一定是省略最近的语境而不是十万八千里前的话题。4.3 兜底话术到底该怎么说没听懂和信息不全是两码事兜底是地图上最容易被低估的路口。很多团队把兜底当成一句统一的抱歉我还没学会这是把两种完全不同的失败场景混为一谈了失败类型典型表现正确策略意图没命中用户的话超出所有意图的覆盖范围告知能力边界 给出可尝试的指令示例槽位缺失意图命中了但必填参数不够明确说出缺什么引导用户补齐我把这两种场景分别配置了不同的兜底模板。意图没命中时回复结构是我暂时还不支持……你可以试试……槽位缺失时回复结构是为了帮你处理XX还需要知道……。后者的转化率明显更高因为它给了用户一条可走的路而不是把他堵在门口。4.4 测试集才是地图的定期体检最后这个坑最隐蔽地图越调越准靠的是人脑记忆还是自动化保障答案是必须建立回归测试集。我把每一条线上误匹配案例都沉淀进一个 JSON 格式的测试集包含输入、期望意图、期望槽位。每次修改意图配置先跑一遍全量回归确保修一个 bug 不引入两个新 bug。[ {input: 帮我订一张明天去A市的票, expect_intent: ticket_book}, {input: 明天A市天气怎么样, expect_intent: weather_query}, {input: 那后天呢, expect_ignore_slot: city, context_intent: weather_query} ]这个测试集不用追求规模几百条就能覆盖主要风险。真正重要的是它必须跟着线上问题持续增长——每条误判都是报表里的一个负数转成测试例之后才变成资产。5. 用真实对话日志反哺地图评估指标与每周迭代节奏5.1 日志里最该记录哪几个字段地图跑起来之后日志是唯一能告诉你地图画得对不对的东西。我在 lingbot-map 里为每条请求记录了七个关键字段原始输入、意图得分 Top3、选中的主意图、槽位提取结果、是否走了兜底、用户对兜底的后续行为重问/放弃/换话题、响应耗时。这里面最容易被忽略的是用户对兜底的后续行为。我后来发现把兜底后用户的行为纳入评估比单看命中率有说服力得多。比如用户遭遇兜底后换了个说法重问说明兜底话术给了他线索、机器人逻辑没问题如果用户直接流失那多半是兜底话术把路堵死了。5.2 四个指标别只盯着准确率我每周都会从日志里算四个指标指标计算公式健康区间参考意图命中率正确命中主意图的请求数 / 总请求数85% 以上误识别率错误命中且被执行的请求数 / 总请求数5% 以下兜底率走兜底流程的请求数 / 总请求数10% 左右槽位填充率必填槽位填满的请求数 / 需要填槽的请求数90% 以上这四个指标合在一起看才有意义。兜底率不是越低越好因为如果兜底率压得太低往往意味着阈值放得很松、误识别率在偷偷上升。我见过一个团队把兜底率从 15% 压到 4%代价是误识别率从 2% 飙到 9%用户投诉量翻了一倍。所以指标之间要联动看单指标优化是自欺欺人。5.3 每周一次的地图修订节奏lingbot-map 上线稳定后我固定了一个每周修订流程效果很好周一从日志里导出上周所有误匹配和兜底样本人工抽样 200 条。按问题类型归类缺示例、缺反例、阈值不当、槽位提取不准、兜底话术不当。改配置、改话术先在回归测试集上跑一遍。周四发一个小版本周五观察线上指标。这个节奏的妙处在于把修地图变成了一件例行公事而不是事故驱动的救火。半个季度之后意图命中率从 78% 升到了 92%误识别率从 7% 降到了 3%而且大部分提升靠的是加示例、调阈值这些配置层面的动作代码几乎没动过。我在实际运行 lingbot-map 的过程中还有一个体会不要指望地图一次画全它本来就是一张需要持续修订的活地图。每一条线上日志都是画笔你要做的是定期把画错的地方擦掉重画。到了后期产品同学自己都能照着流程修订意图表开发只需要偶尔看一眼回归测试有没有跑挂。这种状态下的对话系统改起来才真正称得上轻松二字。

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

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

免费获取报价 →
↑