意图识别不必一上来就求大模型:先硬匹配、再正则、最后才上 LLM------又快又省,还兜底。
前言
给硬件产品做语音助手时,最纠结的是意图(NLU)识别怎么做。直接上大模型?延迟高、要钱、断网就抓瞎。全用规则匹配?表达能力又不够,"把音量调大一点""麻烦帮我调大音量"这种说法怎么写死?
一个很务实的答案是本地优先的三级降级链:先拿纯字符串硬匹配(最快最省),命中不了再上正则(能带参数),再不行才交给大模型(最灵活最兜底)。每一级都"有结果就返回,没有就继续往下走"。这样常见的指令本地秒回、复杂的才花钱走 LLM。
我在做这套链子时最深的体会是:真正的功夫不在"上不上大模型",而在前两级规则怎么写才不出错。 规则写得好,绝大多数请求根本到不了大模型;写得糙,就会出现"说'不要开灯'结果灯亮了"这种致命误判。这篇把每一级讲透,并补上原文没展开、实际最坑人的几件事:归一化、子串边界、正则回溯、槽位归一化、置信度阈值与维护成本。
一、问题长什么样:意图识别该信谁
假设设备支持"打开相机""拍照""开始录像""关闭录像"这些指令。用户可能说:
"拍照"
"帮我把相机打开"
"开始录像吧"
"相机"
需求是:把这些五花八门的说法,统一归到几个标准意图 (打开相机 / 拍照 / 开始录像 / 关闭录像)里,并且最好还能从说法里抽出参数 (比如"把音量调到 50"要抽出 50)。
难点在于:说法太多、太随意,而且识别结果还要足够确定------语音助手答错一次,用户就对整个产品失去信任。如果每次都发大模型,慢且贵;如果只靠死匹配,又覆盖不了。
二、根因:单一方案解决不了"又快又准又灵活"
- 硬匹配:快、零成本、100% 确定,但只能认死说法,不会抽参数;
- 正则:能认"短语+参数"的组合,但要人工写规则,覆盖有限;
- 大模型:最灵活、最能理解各种说法,但慢、贵、依赖网络、还可能幻觉。
三个方案各有短板,但短板是互补的。这其实是个"不可能三角":要快到本地即时响应、要准到可以无条件信任、又要灵活到覆盖任意说法,单一方案最多占两角。 与其二选一,不如按"本地优先、由快到慢、逐级兜底"的顺序把它们串起来。
三、匹配之前:文本预处理与归一化
很多人一上来就写匹配规则,结果被"同一个词的不同写法"坑惨。匹配前,先把输入归一化到稳定形态。
3.1 三种归一化:全半角、大小写、标点空白
- 全角/半角统一:中文输入法常混入全角字符。Unicode 里全角可见字符(U+FF01--U+FF5E)与 ASCII(U+0021--U+007E)一一对应,码位偏移 0xFEE0,全角空格是 U+3000,归一化就是把它们折回半角。
- 大小写统一 :英文意图应大小写不敏感。注意用
lowercase(Locale.ROOT)而不是无参的lowercase()------后者依赖默认 Locale,在土耳其语环境下"I"会变成无点的"ı",导致英文匹配莫名失败。 - 标点与空白 :中英文标点归入同一集合、去除首尾空白、合并连续空白;这正是原文硬匹配里
PUNCTUATION集合"周边只允许空白/标点"的基础。
3.2 要不要分词:一句诚实的取舍
中文没有天然空格,词边界不明显。经典做法是基于词典的最大正向匹配:从句首取能匹配上的最长词,与硬匹配"取最长短语"同一思想。
但有个诚实结论:对短指令意图识别,通常不需要真正分词。 意图短语本身就是现成词表,直接做子串/全等匹配,比"先分词再判断"更快更可控;分词工具还会带来额外体积和依赖。只有要处理"从未登记过的组合说法"时,分词才有价值。所以前两级选"短语匹配"而非"分词匹配"。
四、三级降级链的骨架:顺序执行,命中即返回
先定义"意图模板":每个标准意图,配一张"实体词 → 说法列表"的表。比如"拍照"意图的实体词 camera_action 下挂 ["拍照", "拍个照", "帮我拍张照"]。三级处理器都基于这套模板做匹配。
调度层维护一个有序的处理器列表,逐个尝试,第一个返回非空结果的胜出:
kotlin
class IntentMatcher {
private val processors = mutableListOf<MatchProcessor>() // 有序
suspend fun match(command: String): NluResult? {
processors.forEach { processor ->
val result = processor.match(command)
if (result != null) {
return result // 本级命中,立即返回,不再往下
}
}
return null // 三级都没命中
}
}
装配时依次加入:硬匹配 → 正则匹配 → 大模型匹配。这样一个请求要么本地秒回,要么才走 LLM。
五、第一级 · 硬匹配:全等、周边校验、最长匹配
5.1 全等优先,且校验"周边只能空白/标点"
硬匹配只认两种情况:整句与某个说法全等 (忽略大小写);或者包含该说法,且说法以外的部分只有空白和标点(说明整句话就是来触发这个动作的,没有别的业务参数)。
kotlin
val matched = when {
// 情况1:整句全等
normalized.equals(phrase, ignoreCase = true) -> true
// 情况2:包含该说法,且说法外只有空白/标点(无业务参数)
normalized.contains(phrase, ignoreCase = true)
&& hasNoExtraPayload(normalized, phrase) -> true
else -> false
}
// 校验"说法之外"没有多余的业务内容
private fun hasNoExtraPayload(command: String, phrase: String): Boolean {
val start = command.indexOf(phrase, ignoreCase = true)
val end = start + phrase.length
val before = command.substring(0, start)
val after = command.substring(end)
return before.all { it.isWhitespace() || it in PUNCTUATION } // 两侧只能空白/标点
&& after.all { it.isWhitespace() || it in PUNCTUATION }
}
这个"周边只能空白/标点"的校验很关键------它保证"拍照"这个说法不会误命中"拍照发给小王"这种其实带参数的话(带参数的话应该交给正则级去抽参数)。
5.2 隐藏坑:取"最长匹配"防止短词误中
如果模板里既有"相机"又有"打开相机",用户说"打开相机",字符串匹配时"相机"和"打开相机"都可能命中同一个意图。如果取短的那个,结果不够精确。所以硬匹配在多个候选里要挑说法最长的那个:
kotlin
// 在所有命中候选中,取 phraseLength 最大的(最长短语更精确)
if (best == null || candidate.phraseLength > best!!.phraseLength) {
best = candidate
}
这也是一种防误命中策略:长短语的信息量更大,更值得信。短词很容易是长句的子串,取最长能显著降低把"打开相机"错判成"相机"的概率。
5.3 子串匹配的边界与陷阱
硬匹配再小心,也绕不开子串匹配的天生短板:
- 否定句式:"不要打开相机""别开灯"里都含正向短语,硬匹配会当成"要执行",这是规则匹配最经典的盲区。常规做法是命中前先扫否定词表(不 / 别 / 不要 / 请勿),命中则反转意图或降级。
- 英文没有词边界 :
"cat"会命中"category",涉及英文时要按词切分。 - 同义说法靠词表堆:"打开 / 开启 / 启动"只能一条条登记,词表越堆越长------这正是后面"维护成本"要面对的问题。
否定检测也是规则,又会带来新例外------单靠规则会不断逼近边界,这也是最后一级必须留给模型的理由。
六、第二级 · 正则匹配:抽参数、缓存、防回溯
6.1 从"带参数的话"里抽参数
硬匹配没命中(多半因为话里带了参数),就轮到正则。正则处理器会为每个说法生成三种模式,分别覆盖"说法在前、参数在后""参数在前、说法在后""说法在中间":
kotlin
// 例如说法 "调到音量":
// 1) "调到音量50" → 说法在头,参数在尾
// 2) "音量调到50" → 说法在中
// 3) "麻烦调到音量50" → 说法前有修饰词
private fun buildPatterns(phrase: String): List<Pattern> {
val quoted = Pattern.quote(phrase) // 转义,防说法里的正则元字符捣乱
val flags = Pattern.CASE_INSENSITIVE or Pattern.UNICODE_CASE
return listOf(
Pattern.compile("^$quoted(.+)$", flags), // 短语 + 尾部参数
Pattern.compile("^(.+?)$quoted$", flags), // 头部参数 + 短语
Pattern.compile("^.+$quoted(.+)$", flags), // 前有修饰 + 短语 + 尾部参数
)
}
命中的话,group(1) 就是抽出来的参数。同样遵循"取最长匹配"的规则,保证说法被准确对齐。
6.2 隐藏坑:缓存 Pattern,别反复编译
正则编译(Pattern.compile)是 CPU 密集型操作,对同一个说法反复编译纯属浪费。工程上用一张缓存表,同一个说法只编译一次:
kotlin
private val patternCache = mutableMapOf<String, List<Pattern>>()
val patterns = patternCache.getOrPut(phrase) { buildPatterns(phrase) }
这样在大量指令涌入时,正则匹配级的性能不会成为瓶颈。另外 Pattern 不可变且线程安全 、可缓存共享,Matcher 却非线程安全、必须每次新建。
6.3 两个正则陷阱:元字符与回溯失控
- 元字符要先转义 。说法里若含
.*+([?这些字符,直接拼进正则会改变语义甚至编译报错。Pattern.quote用\Q...\E包起来,让它们严格按字面匹配。 - 回溯失控 。Java 的
java.util.regex是基于回溯的 NFA 引擎。当正则出现可嵌套的重复量词(如(a+)+),在"匹配失败"的输入上引擎会穷举各种拆分,耗时可能随输入长度指数级增长,这就是 ReDoS:^(a+)+$对一长串a后跟一个不匹配字符就能卡满 CPU。缓解:用占有量词 (a++、*+)或原子组 ((?>...))切断回溯;避免嵌套量词;限制输入长度;用具体字符类替代宽泛的.。
七、第三级 · 大模型匹配:把前两级解决不了的交给 LLM
前两级都没命中,说明说法太随意、规则覆盖不了,这时才调大模型。思路是把意图模板和说法一起塞进 prompt,让模型从给定的意图集合里挑一个,并抽出参数:
给模型的信息大致是:
- 已知意图:{打开相机/拍照/开始录像/关闭录像}
- 各自的触发说法:...
- 用户说:"麻烦帮我开一下摄像头好不"
模型输出:{ intent: "打开相机", entity: "...", params: [...] }
这一级兜住了所有"说人话但规则匹配不到"的情况。即使它偶尔失败,前两级已经挡掉了绝大多数常见指令,体验依然可控。要注意:让模型"从候选意图里选一个"比"自由生成意图"稳得多,收敛输出空间能降低它编出不存在的意图的概率。
八、槽位提取与归一化:抽出来的不能直接用
正则级抽到的只是原始字符串,例如 "50"、"五十"、"百分之五十",真正要用还得归一化成程序能吃的类型。
- 数值归一化 :阿拉伯数字直接转;中文数字下面给一个覆盖"十/百/千"的简化版(不含"万/亿",也没处理"二百五"这类省略):
kotlin
private val cnDigits = mapOf(
'零' to 0, '一' to 1, '二' to 2, '两' to 2, '三' to 3, '四' to 4,
'五' to 5, '六' to 6, '七' to 7, '八' to 8, '九' to 9
)
private val cnUnits = mapOf('十' to 10, '百' to 100, '千' to 1000)
fun parseChineseNumber(text: String): Int? {
if (text.isEmpty()) return null
text.toIntOrNull()?.let { return it } // 阿拉伯数字直接返回
var total = 0
var current = 0
for (ch in text) {
val digit = cnDigits[ch]
if (digit != null) { current = digit; continue }
val unit = cnUnits[ch] ?: return null // 出现不认识的字,放弃
if (current == 0 && unit == 10) current = 1 // "十五"当"一十五"
total += current * unit
current = 0
}
return total + current
}
"五十" 得 50、"十五" 得 15、"一百零五" 得 105,逻辑是"逐段乘单位再累加"。但口语省略、大写数字、"俩" 都得单独兜,规则一多这里同样会膨胀。
- 单位/比例归一化 :
"百分之五十"要剥离"百分之"再解析数字,归一化成50(单位 %)。 - 失败即降级 :归一化失败时(如抽到"一半""一点点"),不要硬猜,直接判为"本级未命中"交给大模型。宁可降级,也不要给出错的参数。
九、工程化:优先级、冲突消解、置信度与测试
9.1 规则优先级与冲突消解
链条里"处理器顺序"本身就是一层优先级。同一级内部若多个意图同时命中,常用的消解依据是:
- 最长匹配优先:说法越长越具体,信度越高;
- 显式优先级优先 :在模板里给意图标
priority,业务上更看重的排前面; - 注册顺序兜底:优先级相同时取先注册的。
一个边界:一句话含两个意图(如"打开相机然后拍照")时,规则级通常只返回一个,未必对------宜返回多个意图或交给模型。
9.2 置信度与阈值设计
规则命中的确定性有层次:整句全等 > 周边校验通过的包含 > 正则抽参命中 ,可据此打一个粗略置信度分。大模型这级更微妙:它输出带随机性,且让模型自报把握往往不靠谱,更实用的做法是多次采样取多数。
至于阈值:top1 与 top2 得分接近时,宁可追问"你是想 A 还是 B",也不要赌------语音场景答错一次的损失远大于多问一句。本文不给具体数字,它随业务变化,只能实测调参。
9.3 测试用例怎么设计
规则系统最怕"改一条、坏一片",用例是刚需,建议按这几类组织:
- 正例:每个说法的常见变体(加语气词、加前缀修饰);
- 负例:不该命中的输入("拍照"不该被"相机"意图抢走);
- 边界:空串、纯标点、超长输入、全半角/大小写混合;
- 歧义:一句话含两个意图,验证冲突消解是否符合预期;
- 对抗:否定句式("不要开灯")、错别字、同音替换。
断言时不只要验 intent,还要验 entity/params。而且用例本身就是最好的文档:新人改规则前先跑一遍,一目了然。
十、规则维护成本与何时转向模型
规则方案有个天花板:覆盖度越高,规则越多,组合与例外会让维护成本加速上升------说法 × 修饰词 × 参数的组合随维度相乘而膨胀,每加一条新规则都要回归全量用例。
出现这些信号就该转向模型:规则持续增长但边际收益递减;长尾说法(同义、省略、错别字)补不完;团队大部分时间花在"修规则"而非"做功能"上。
但要强调:转向模型不等于丢掉规则,混合架构才是主流------确定的高频指令留给规则,模糊长尾交给模型。至于"多少条规则就该切换",没有普适数字,取决于业务规模与容错度。
十一、升华:降级链是一种"成本与体验"的权衡架构
这套"本地优先、逐级兜底"的思路,本质是在成本、速度、灵活性之间找平衡:
- 把高频、确定的指令留在本地(硬/正则),零成本、结果确定;
- 把低频、模糊的指令才交给大模型,控制成本与延迟;
- 大模型作为"最后一根稻草",兜住规则的空白。
这种模式可迁移到很多场景:本地缓存优先 → 远端兜底 、精确匹配优先 → 模糊检索兜底 、内置规则优先 → AI 兜底 。原则相通:让最便宜的方案先尝试,贵的方案只在必要时启用。
更妙的是,链条本身可插拔------每个处理器都是统一的"入参命令、出参意图"接口,可随时插入新处理器(比如加一个同义词扩展级)而不动其他层级。
结论
- 意图识别不必一上来就上大模型,用本地优先的三级降级链:硬匹配 → 正则 → LLM。
- 匹配前先归一化 :全半角、标点空白统一,大小写用
Locale.ROOT折叠,避开土耳其语环境的坑。 - 硬匹配要校验"说法周边只能空白/标点",避免把带参数的指令误当纯指令;并警惕否定句式这类子串盲区。
- 多候选命中时取最长短语,短词是长句子串,取最长能显著降低误判。
- 正则要缓存
Pattern(它线程安全,Matcher不然);先用Pattern.quote转义元字符,并防回溯失控。 - 抽参数后要做槽位归一化(如中文数字转数值),归一化失败宁可降级也别硬猜。
- 优先级与阈值都是经验值 :多意图冲突时宁追问勿乱猜,具体数字只能实测;大模型作为最后兜底、链条可插拔,规则维护成本过高时转向模型,但通常是规则挡高频、模型兜长尾的混合架构。
你的语音/意图识别是直上大模型还是本地优先?本地规则又覆盖了多少指令?欢迎在评论区聊聊。
关键词标签 :#Android #自然语言理解 #NLU #降级链 #意图识别 #正则 #大模型 #语音助手