现在一提到意图识别,很多人第一反应就是丢给大模型。用户说句话,让模型判断他想干嘛,返回个意图标签,完事。
这么做当然能跑通,现在的模型理解个意图绰绰有余。但真到了有流量的系统上,这条路会很快让你难受。意图识别真正难的地方,不是能不能识别出来,而是怎么在准确率、延迟和成本之间找平衡。
这篇就聊聊怎么把这件事做好。为什么不能全靠大模型,三层结构各自怎么落地,还有一些教程里不太提、但上线之后八成会碰到的问题。
一、全靠大模型,量一上来就崩
系统日活几百的时候,全丢大模型没啥问题,省事。但请求量大了,会在三个地方出事。
先是延迟。一次大模型调用,就算用最快的模型,首个 token 出来也得几百毫秒往上,慢的时候一两秒。用户说”打开设置”,等一秒钟才有反应,这体验已经不行了。意图识别一般在整条链路的最前面,它不出结果后面全卡着。相当于你把本该最快完成的一步,交给了最慢的那个组件。
然后是成本。一次意图识别,算上 system prompt、用户输入和几个 few-shot 例子,平均下来五百个 token 不算多。按现在主流模型的价钱,一万次请求就是一笔实打实的开销。日请求上了百万,光”判断用户想干嘛”这一项,月底账单就够你看半天。更亏的是,这里面七八成都是”打开设置””查余额”这种一眼就懂的意图,你花旗舰模型的钱,去识别这些废话。
最后是稳定性。模型会幻觉。你给它二十个意图让它选一个,它偶尔会冒出个不存在的第二十一个,或者干脆把 JSON 写崩。请求基数大了,这种小概率的绝对次数就很可观。意图识别错了还不是小事,它是链路开头,一步错后面填参数、调工具全跟着错。
这三样是会一起出问题的。所以全丢大模型这套,做个 demo 挺好,扛生产不行。
二、那全写死规则呢,也不行
反过来想,全用关键词和正则来做,是不是又快又省又稳。
前面这几样它确实占了,但灵活性没了。用户说话不按套路来。你规则里写了”查余额”,人家偏说”我还有多少钱””看下卡里剩多少””余额多少来着”。这种说法你穷举不完的,中文表达方式几乎无限,规则写得再多,总有个你没想到的说法把你干趴下。
还有上下文的问题。用户上一句问”这个套餐多少钱”,下一句”那就这个吧”。单看”那就这个吧”这五个字,正则根本不知道他要干嘛,意图在上文里藏着。规则是没有记忆的,这种对话它天生处理不了。
两个极端摆一起看就清楚了:
| 方案 | 快 | 省 | 稳 | 灵活 |
|---|---|---|---|---|
| 全写死规则 | ✅ | ✅ | ✅ | ❌ |
| 全丢大模型 | ❌ | ❌ | ❌ | ✅ |
这两行基本是互补的。所以别在两个里挑一个,把它们拼起来,让不同的请求走不同的路。这就是分层的思路。
三、三层漏斗:让请求各走各的路
漏斗这个比喻挺贴切,上面口大下面口小。请求从最上层进来,能在上面解决的就别往下漏,上面搞不定的才往下沉。
- 规则层,处理那些意思明确、说法固定的请求,毫秒级,基本不花钱,先把高频简单的挡掉。
- 上下文层,处理需要看对话历史才能判断的常规请求,用小模型或者语义向量匹配,快且便宜,日常大部分流量都靠它接。
- 工具层,兜底那些复杂又模糊的请求,用大模型加工具调用,把”意图”和”执行”直接串起来,只处理少量真正难啃的。
一句话说,就是让简单请求便宜地解决掉,只有复杂请求才值得动用大模型。
画个图看请求怎么一层层漏下去:
1 | 用户请求 |
图里有两个往下漏的关口,一个是规则没命中,一个是上下文层置信度不够。这两个关口决定了流量怎么分配,也是后面要重点说的地方。下面一层一层拆开讲。
四、规则层:越克制越好
规则层的技术特别朴素,朴素到都不太像 AI。关键词、正则、状态机,就这些。
它就干一件事,处理那些命令式的、没有歧义的、基本不会变的意图。比如:
- “打开设置”,跳设置页
- “转人工”,转人工客服
- “查余额”,调余额接口
这几个有个共同点,说法就那么几种,而且意思和说法是绑死的。没人会用一百种花样说”转人工”,他就是想转人工。这种请求交给大模型属于杀鸡用牛刀。
代码写出来大概长这样,很短:
1 | public class RuleLayer { |
看着简单,但这层最容易犯的毛病是贪心。我见过有团队拿规则层当万能药,什么意图都往里塞,最后规则文件好几千行,正则套正则,改一个词全线报错,谁都不敢碰。这就完全反了。
规则层放什么,我一般守三条:
只放高频的。一个意图一天调用不了几次,就不值得占一条规则,规则是要维护的,维护也是成本。
只放说法固定的。一个意图要是有几十种说法,说明它压根不适合规则,早点往下层放。
只放基本不变的。业务上经常调整的意图别硬编码,不然你会陷在”再加个词、再加个词”里出不来。
还有个坑比较隐蔽,规则会误伤。用户说”我不想转人工,我自己弄”,你正则里有”转人工”,直接就给转了。所以正则得考虑否定词、考虑上下文。可一旦复杂到这个程度,其实就是个信号,说明这个意图该往下一层交了。
判断一个意图要不要放规则层,标准很土:说法就那么几种、还基本不变,放;稍微有点犹豫,往下沉。
五、上下文层:真正干活的地方
规则层是把高频简单的先拦一道,上下文层才是这套东西里真正扛活的。日常大部分常规流量,都是这层接住的。
之所以是它扛,是因为真实对话大部分都得看上下文。前面说的”那就这个吧””还是不行””换一个”,单拎出来没有意义,意图都在对话历史里。规则层够不着,全丢大模型又太贵,上下文层正好卡在中间那个甜点区。
5.1 两条路子
这层一般两种做法,各有各的场景。
一种是语义向量匹配。把预设的意图样例提前向量化存进向量库,每个意图准备几十条典型说法。用户请求进来,同样向量化,然后检索,看它离哪个意图最近。
1 | 意图库(离线构建): |
这条路好在改意图不用重训模型,加个新意图就往库里补几条样例、重新向量化。冷启动也快,没多少数据就能先跑。缺点是它主要看语义像不像,碰上要绕几个弯推理的意图就有点吃力。
另一种是拿轻量小模型微调。用个几亿参数的小模型,BERT 那类或者小尺寸的生成模型,用业务数据训成一个专门的意图分类器。它比向量匹配更懂你的业务,准确率通常更高,推理也快。代价是得有够用的标注数据,而且每次加意图基本都要重训。
怎么取舍,我的经验是冷启动阶段、意图还在频繁改,就用向量匹配;等业务稳了、数据也攒够了、想把准确率再往上提,就上小模型。成熟点的系统经常两个都用,向量匹配做粗筛,小模型做精排。
5.2 这层的命门是状态管理
这层最容易翻车的,不是选哪个模型,是状态管理。
你要判断”那就这个吧”是啥意思,前提是你知道用户上一句在聊什么。这个”上一句在聊什么”,就是对话状态。状态没管好,模型再准也白搭,喂进去的上下文是乱的,它照样判断错。
状态里一般得存这么几样:
1 | { |
有了这个,”那就这个吧”进来的时候,你就知道当前意图是选套餐、上文在聊 A 套餐,那这句话的意思就明白了,是确认选 A。
状态管理几个实战里的点:
对话历史别无限往里塞。context 有限,也费钱。常见做法是最近几轮留原文,更早的做个摘要压一压,用户已经确认过的关键约束单独拎出来存成结构化字段。
状态得会过期。用户十分钟前的对话,未必还算当前的上下文。一般设个 TTL,或者用户明显换话题了就重置。不然会出现你问新问题、它还拿着你半小时前的话来理解,很别扭。
意图切换和打断也得管。用户正办套餐呢,突然插一句”等下,先帮我查个余额”,这就是典型的打断。你得能认出这是个新意图,把旧任务先存着,处理完再回来。这块做不好,对话会显得很轴,认死理。
状态管理说白了就是把对话的记忆管明白。不酷炫,但它是上下文层能不能真扛住流量的底子。
六、工具层:最后一道防线,别让它扛主力
请求一路漏到工具层,说明前两层都没搞定,剩下的是那些复杂、模糊、得真理解真推理的。到这儿才轮到大模型上。
工具层是大模型加工具调用。不光让它判意图,是让它把”意图到执行”这条线直接打通,理解完用户的复杂需求,自己选工具(Function Calling 或者 MCP),把参数填好,直接路由到执行。
举个例子,用户说”帮我把上个月话费超过 100 的那几个月找出来,看看能不能换个便宜点的套餐”。这里面有查询、有筛选、有对比、还有推荐,规则和向量匹配都拿它没办法。大模型能理解,拆成几步工具调用:查历史账单、筛选、匹配套餐、给建议。
Function Calling 底层怎么回事、模型怎么训出来的,我之前专门写过一篇《大模型 Function Calling:底层原理与训练机制》,这里不展开了。只说一个跟意图识别相关的点:工具层的好处是它把”识别”和”执行”合成了一步。前两层本质上是分类器,只告诉你这是啥意图,后面还得有人接着去执行。工具层里模型认出意图的同时,工具选了、参数填了,一条线是通的。
但工具层有条底线,它是防线不是主力。前面算过账,大模型又慢又贵。它要是扛了主流量,前面辛辛苦苦搭规则层、上下文层就全白费了,成本瞬间起飞。所以工具层健康的状态,是只有很小一部分请求落到这里。
这反倒给了你一个特别好用的监控信号。盯着工具层的流量占比,哪天它突然往上蹿,就说明上层出问题了,要么规则失效了,要么上下文层的阈值飘了,要么来了一批新意图上层没覆盖到。这个占比基本就是整个漏斗健不健康的温度计。
七、教程不太讲、但上线一定会碰到的事
上面三层是骨架。真跑到线上,你会发现还有一堆细节决定生死。这部分我觉得反而是最有用的。
7.1 层与层之间的阀门
漏斗漏得对不对,全看阀门。规则层的阀门简单,命中没命中,二选一。麻烦的是上下文层。模型给你返回”这是查余额,置信度 0.72”,这个 0.72 到底算不算数,得有条线来定。
线定高了,比如 0.95,一堆本来上下文层能解决的请求,就因为差那么一点被打到工具层,成本上去了。线定低了,比如 0.5,模型半蒙半猜的结果也当成确定意图返回,准确率就掉了。
这条线没有标准答案,得拿真实数据一点点调。而且往往不是一个全局值,是每个意图一条线。有些意图容错高,走错了损失也不大,线可以低点;有些意图很敏感,比如涉及扣款、注销,那必须置信度很高才敢放。
7.2 允许它说”不知道”
这个新手最容易漏。你意图库里就二十个意图,用户随时可能来一句完全在这二十个之外的话,系统得有退路。
不少系统会硬从二十个里挑个最像的返回,这是灾难。用户问了个八竿子打不着的,系统硬归到”查余额”,然后一本正经报了个余额,用户当场懵掉。
正确做法是允许拒识,模型可以返回”我不确定,这不属于已知的任何意图”。拒识之后要么走澄清,要么下沉到工具层让大模型兜。”我不知道”是个合法输出,有时候还挺重要,别逼着分类器非得给个答案。
7.3 拿不准就问一句
置信度卡在中间那段,不高到能直接执行,也不低到该拒识,这时候最好的动作往往不是猜,是回问一句。
用户说”我要改一下”,系统判出来可能是改套餐、也可能是改地址,两个都在 0.6 上下。与其赌一把,不如直接问”您是想改套餐,还是改收货地址”。一句话,把模糊意图变成明确意图,用户反而觉得靠谱。
澄清的价值在于,它把系统的不确定,换成了一次成本很低的交互,而不是硬猜然后错。设计漏斗的时候,置信度中间区间的默认动作就该是澄清。
7.4 意图和槽位是两码事
不少人把意图识别和参数提取混在一起,其实是两步。先认意图,用户想干嘛;再填槽位,干这事需要哪些参数。
“订一张明天从北京到上海的机票”,意图是订机票,槽位是日期明天、出发北京、到达上海。意图对了但槽没填全,比如没说日期,系统还得追问”您想订哪天的”,这又绕回澄清了。
在三层漏斗里,槽位填充一般发生在意图定下来之后。规则层或者上下文层把意图定了,再由专门的抽取逻辑去填槽,正则、NER 模型或者大模型都行。槽没填全就多轮追问,凑齐了才真执行。
7.5 一条数据都没有怎么冷启动
新业务刚上,你手里一条真实用户数据都没有,小模型没法训,向量库里那些样例还是你自己拍脑袋编的。这时候只能换个思路。
我一般是这个顺序。
先靠规则层加工具层顶着。高频明确的意图手写规则,剩下的全兜给大模型。这阶段工具层扛的流量偏高,成本贵点,但先能跑起来。
让工具层顺手攒数据。大模型处理请求的时候,把”用户输入到识别出的意图”记下来,这就是你最早的一批标注数据,当然得抽样人工校一下。
数据够了再把上下文层补上。拿攒的数据训小模型或者填向量库,把流量从工具层往上层挪,成本自然就降了。
这是个数据飞轮。工具层兜底、产出数据、喂给上层、上层接管更多流量、工具层退回防线的位置。一开始贵,那是买数据的钱,跑顺了成本就下来了。
7.6 错了得有地方回收
线上一定会有识别错的。关键不是会不会错,是错了以后有没有被记下来、有没有拿去改进。
一套还算健康的意图识别系统,背后都有个 badcase 回收的口子。用户明显不满意的对话,比如反复追问、重复说同一句、直接说”你没懂我意思”,自动打标进待分析的队列,人工确认之后,该补规则补规则、该加向量样例加样例、该进训练集进训练集。没有这个回收,你的系统就停在上线那天的水平,不会再往前走。
八、串一遍:一个请求怎么走完全程
说了这么多,跟着请求走一遍,把东西串起来。
用户说”那还是算了,帮我看看这个月花了多少”。
1 | 1. 规则层 |
再看个复杂的,直接漏到底。
用户说”我感觉我这套餐不太划算,你帮我分析下有没有更适合我的”。
1 | 1. 规则层:没命中 → 漏 |
同一套系统,简单请求毫秒级在上层就解决了,复杂的才往下沉到大模型。分层的意义就在这,不是每个请求都上最强的武器,是让每个请求配上刚好合适的成本。
九、说到底在解决什么
把前面的东西收一收。意图识别这套三层结构,其实是在同时管四个东西:准确率、延迟、成本、灵活性。
单靠一种方案永远搞不定这四个。规则牺牲灵活,大模型牺牲延迟和成本。分层聪明的地方不在于它用了什么新技术,规则、正则、向量匹配这些都老得掉牙了,而在于它让每种请求都配上了最合适的处理方式。高频明确的走规则,占了快和便宜;常规但要看上下文的走上下文层,速度和灵活都顾上;少量复杂模糊的才交给工具层,用成本换能力。
再把拒识、澄清、阈值、冷启动飞轮、badcase 回收这些补上,整套东西才算真能在线上活下来,而且越用越好。
写在最后
意图识别这题,越琢磨越觉得它像整个 AI 工程的一个缩影。大模型是真强,强到容易让人产生一种什么都丢给它就行的错觉。但真把系统做上线、扛住量、把成本摁住的,从来不是用了最强的模型,而是知道哪些地方不用它。
规则层拦一道,上下文层接大头,工具层兜底难的。这么几句话背后,是围着延迟、成本、稳定、灵活这四个变量来回权衡的结果。分层这件事,图的就是让每一分成本都花在该花的地方。