☰
自然语言理解中的规则工程实践:可解释、可控的意图识别系统
2026/10/14 4:17:24 网站建设 项目流程

这次要聊的项目代号是 Innovative Language Understanding through Rule Engineering,翻译成大白话,就是用规则工程的办法去实现一套能理解自然语言的系统。这个方向听起来挺反潮流,毕竟现在聊自然语言处理,默认就是大模型、端到端、海量数据,谁还会回头手写规则?但真实业务里最刚需的东西往往被大家忽略了:可解释、可控制、可离线运行、能快速修复单点问题。我前一段时间在某团队参与了一个内部答疑系统的语言理解层,数据量少得可怜,标注成本又高,最终选的就是规则工程这条路。这篇文章适合正在做智能客服、表单自动解析、命令词理解这类场景的人参考,也适合想了解规则系统为什么在某些场景下反而比复杂模型更可靠的工程师。

1. 为什么这个项目最后落在规则工程上

1.1 数据现状决定了算法路线的上限

拿到需求的时候,第一反应肯定也是跑一个序列标注模型或者意图分类模型。但把真实数据拉出来一看,问题就来了:有效标注样本一共只有几千条,而且大部分是客服工单里抄出来的半截话,句式混乱、宾语省略、夹杂口头禅。这种数据量丢给深度模型,别说上线,连训练集和验证集的分布都稳不住。

更麻烦的是业务方对准确率的要求。客服场景里,用户说错一句话没关系,系统理解错了要背锅。比如用户说"我想把明天的预约改到后天",如果系统把意图判断成"取消预约",就等于直接把订单删了。这种错误是不可接受的,而深度模型恰恰很难给出一个"为什么这么判断"的解释。业务方问过来,你说"这是模型推理出来的",对方是不可能放你过验收的。

规则系统的优势在这里就非常明显:每一条判断逻辑都摆在明面上,错了可以当场定位到具体规则,改完立刻生效,不需要重新收集数据、重新训练、重新走评测流程。对于小规模、单一领域、句子形态相对收敛的业务场景,这种可控性比理论上限更重要。

1.2 规则工程不是"低级方案",而是约束下的最优解

很多人一听到规则就觉得是几个正则表达式拼凑出来的土办法。我不这么看。手写正则确实是规则系统的一种实现手段,但规则工程的核心在于组织规则的方式:怎么切片、怎么定优先级、怎么做冲突仲裁、怎么评测回归。这些机制搭起来之后,规则系统的能力边界和可维护性会完全不一样。

我理解的 Innovative 在这里有另一层意思:把规则系统当作一个持续迭代的产品来做,而不是写完就扔的脚本。给它建测试集、给它做覆盖率统计、给每条规则生命周期管理。做到这个程度,规则工程在特定场景下的稳定性是模型方案很难追上的。

这篇文章主要想分享四块内容:

  • 规则怎么切片和存储,才能维护得动;
  • 匹配引擎怎么设计,才能让规则之间有秩序而不是互相打架;
  • 评测集和回归流程怎么搭,规则系统才不会越改越乱;
  • 实际落地过程中踩过的坑,以及沉淀下来的维护纪律。

2. 规则切片与存储:把一团正则变成可维护的知识库

2.1 领域、意图、槽位三层模型

一开始我犯过一个错误:把所有规则堆在一个大文件里,每条规则试图同时处理"这句话属于哪个业务范围"、"用户想干什么"、"具体对象是谁"。结果就是规则之间大量重叠,改一条规则要担心另外好几条跟着受牵连。

后来改成三层模型之后,结构才清晰起来。这三层分别是:

层级职责典型问题
领域层判断语句属于哪个业务范围这句话是咨询订单还是咨询物流?
意图层判断用户想达成什么动作是取消、修改、还是新增预约?
槽位层抽取用户语句中的关键实体日期是哪一天,预约编号是多少?

举个例子:用户说"我想把明天的预约取消掉"。领域层先判断这是预约管理领域;意图层再判断这里是取消意图;槽位层最后把"明天"抽出来作为 date 槽位。三层各管一段,互不越界。

这样设计有三个好处:

  1. 规则可以复用。比如"预约"这个领域词只需在领域层定义一次,意图层直接引用领域层的识别结果。
  2. 评测可以分治。某个槽位抽错了,只需要看槽位层的问题,不用从一大条规则里找原因。
  3. 协作不冲突。不同的人可以分别维护领域词表、意图规则和槽位抽取模板,彼此之间影响可控。

2.2 落地 JSON 模板与字段约定

切分好层级之后,下一步就是设计规则的存储格式。我最终用的是 JSON,因为结构化程度高,方便做自动化校验,也方便在配置中心里做版本管理。

一条完整的意图规则长这样:

{ "id": "rule_appointment_cancel_001", "domain": "appointment", "intent": "cancel_appointment", "priority": 10, "activation_score": 0.72, "patterns": [ "取消{date}的预约", "不去了,预约{date}", "帮我取消一下{date}的预约" ], "slots": ["date"], "synonyms": { "取消": ["不去了", "撤销", "退掉", "取消掉"] }, "whitelist": ["明天", "今天", "后天"], "blacklist": ["改到", "修改", "重新预约"], "enabled": true }

每个字段都有它存在的理由:

  • id 必须是全局唯一的规则标识,方便日志追踪和问题定位。
  • domain 和 intent 把规则挂到对应的业务分层上。
  • priority 决定多条规则同时触发时的仲裁顺序,越大越优先。
  • activation_score 是这条规则生效所需的最低置信度阈值。
  • patterns 是规则的主匹配模板,其中 {date} 这样的占位符在匹配时会做动态替换。
  • synonyms 用来做同义表达归一化,比如"取消"本身就是一个同义词组。
  • whitelist 和 blacklist 是边界控制的关键:白名单限定槽位取值,黑名单把语义相反的触发词提前挡掉。
  • enabled 是规则的开关,线上出问题时可以一键关闭某条规则,不用重启服务。

说一个实际设计思路:blacklist 的价值非常高。以取消预约为例,用户说"我想把明天的预约改成取消",这种情况下"取消"出现了,但真实意图是修改,而不是取消。没有黑名单约束,规则就可能误触发。把"改到""修改""重新预约"加入黑名单之后,即使匹配到了取消模板,仲裁层也会因为黑名单命中而降低这条规则的置信度。

2.3 动态片段与同义表达的归一化

规则系统最怕的是用户表达千变万化,写几条固定模板根本兜不住。我的应对办法是三层归一化。

第一层是无效填充词清理。真实用户输入里到处都是"嗯""那个""就是""请问一下"这类词,先做一个轻量的停用词过滤,把噪音去掉再进规则匹配。

第二层是同义词替换。直接把"取消""撤销""退掉""取消掉"归一成一个等价集合。这样不需要为每个同义词各写一条规则,规则数量能压缩不少。

第三层是占位符动态替换。比如 {date} 可以替换成 whitelist 里的一组日期表达,也可以启用日期识别模块,把"明天""后天""下周一"这类相对日期解析成具体日期。这一步放在槽位提取阶段做,不要在规则匹配阶段做,否则规则模板会非常臃肿。

文件组织上,我在项目里用了一个按领域分组的目录结构,每个领域一个 JSON 文件,加载时按约定顺序读取并合并到内存缓存。这样做的目的还是可维护性:某个领域出问题,直接打开对应的文件,不需要在整个业务里去搜。

3. 匹配引擎设计:从"命中"到"决策"的三步走

3.1 触发层:低成本初筛

规则数量达到一定规模之后,如果每条规则进来都全量跑一遍正则,性能会很难看。所以我给匹配引擎设计了三个层级:触发层、激活层、仲裁层。

触发层是最前面的粗筛漏斗,目的是用最小的计算成本排除明显无关的语句。领域层在这一步只检查有没有领域关键词,比如"预约""订单""物流"这些词是否出现。如果领域都没命中,后续意图规则根本不需要参与计算。

这一层我特意不设计复杂逻辑,只做最简单的字符串包含检查。性能优先,宁可多放进来一些候选,也不能在这里把真语句滤掉。触发层设计的核心原则是"召回优先、精度后置":误放进来的语句后面还有激活层和仲裁层把关,但如果在这里漏掉了,后面怎么补救都来不及。

候选领域确定之后,对应的意图规则集合会被加载到下一层的候选池里。

3.2 激活层:规则打分与槽位提取

激活层要做的事情比较核心:对候选规则逐条计算置信度分数,同时完成槽位提取。

简单打分逻辑我放在这里,可以作为参考:

def score_rule(rule, tokens, syntax_flags): matched_patterns = [ p for p in rule["patterns"] if pattern_hits(p, tokens) ] if not matched_patterns: return 0.0 coverage = len(matched_patterns) / len(rule["patterns"]) specificity = len(rule["patterns"]) * 0.04 syntax_bonus = 0.12 if syntax_flags["strict_question"] else 0.0 length_penalty = min(0.18, len(tokens) * 0.008) blacklist_hit = any( word in tokens for word in rule.get("blacklist", []) ) conflict_penalty = 0.15 if blacklist_hit else 0.0 score = ( coverage * rule["activation_score"] + specificity + syntax_bonus - length_penalty - conflict_penalty ) return max(score, 0.0)

几个参数的设计意图说一下:

  • coverage 表示规则模板的命中覆盖率。假如一条规则有四个模板,命中了两个,覆盖率就是 0.5。这是最基础的信号。
  • specificity 是规则特化程度。模板越多的规则通常越具体,给一个微小加成,避免所有规则打分都挤在一起。
  • syntax_bonus 用于奖励完整句式的表达。比如用户完整说"我想取消明天的预约",比只说"取消预约"更明确,应该得到更高的分。
  • length_penalty 惩罚超长语句。一句话绕来绕去十几个词,很可能掺杂了额外意图,置信度应该适当降低。
  • conflict_penalty 就是前面提到的 blacklist 机制,用于防止"语义方向相反"的误触发。

槽位提取发生在匹配过程中。匹配模板里的 {date} 命中之后,系统会记录下原始文本位置,再把文本解析成标准化日期。一个重要的工程经验是:槽位解析结果不要直接覆盖原始文本,保留原始表达和解析结果两个字段,这样后续排查问题时有据可查。

3.3 仲裁层:多规则同时触发的判决

现实情况下,一句话大概率会同时触发多条规则。比如"我想把明天的预约取消掉",既会触发"取消预约"规则,也可能触发"修改预约"规则,因为"把预约"这个片段对修改类模板也有一定的命中率。

仲裁层的任务就是在候选规则之间做出最终决定。我这边的策略是:

情况处理策略
两条规则意图相同合并处理,置信度取最高分
意图不同,优先级不同高优先级直接胜出
意图不同,优先级相同置信度分数高者胜出
分数差小于 0.03判定为歧义,输出候选列表给上层人工确认

最后一个策略很重要。规则系统最忌讳的是"硬选一个",明明两个意图都说得通,非要猜一个,猜错了就是事故。如果两条规则的分数非常接近,直接返回模糊结果,让上层对话管理模块去追问确认,这样反而更安全。

仲裁层还有一个细节:优先级的设置必须有全局约束,不能随意给规则填高数值。谁优先谁靠后,应该在规则评审时统一决定。否则改一条规则的优先级,可能引发一连串的连锁反应。

4. 评测集与迭代闭环:让规则系统像算法模型一样成长

4.1 冷启动评测集,先出题再做题

规则系统如果没有评测集,就像考试不设标准答案,改来改去全凭感觉。我在这套系统里的做法是:在写规则之前,先建立一份评测集。哪怕一开始只有几十条,也要有。

评测集的设计原则很简单:每个意图至少二十条正例、十条负例。负例尤其重要,因为它能暴露规则的过度泛化问题。比如"我不想取消预约"这句话,里面包含了"取消"和"预约",但它表达的是否定的态度。这种句子必须出现在负例里,否则规则很容易误伤。

我通常把测试样本分成这么几类:

类型说明举例
正常表达用户直接说明需求我想取消明天的预约
口语省略缺少部分成分但语义明确明天的预约,取消掉
长句插入中间夹了额外信息我明天可能没空,帮我把预约取消一下
歧义句两种意图都可能成立我把预约改到取消的行吗
跨领域干扰包含多个领域词但主打一个方向订单晚到了,顺便帮我看看预约还在不在

负例的分类越细,规则系统的边界就越清晰。每一条负例本质上都在给规则画"不能越界"的红线。

4.2 迭代流程:bad case 驱动规则补全

评测集建立之后,迭代流程就变得有章可循了。整个过程分五步:

  1. 收集线上日志里识别失败或者置信度偏低的句子。
  2. 对每一条失败样本做分类:是漏匹配、误匹配、槽位错误、还是优先级仲裁错误。
  3. 改规则之前,先把这条样本加入评测集,写好它应该输出的意图。
  4. 修改规则,只改动最小范围。
  5. 跑全量回归,看整体通过率有没有下降。

第五步是守护整个系统的安全网。我写了一个非常简单的回归脚本,每次改完规则只跑这个脚本,通过率不达标就不允许合入。

def run_regression(samples, engine): failed = [] total = len(samples) for sample in samples: result = engine.understand(sample["text"]) expected = sample["intent"] if expected == "unknown" and result is None: continue if result and result["intent"] == expected: continue failed.append({ "text": sample["text"], "expected": expected, "got": result["intent"] if result else "unknown" }) accuracy = (total - len(failed)) / total return accuracy, failed

这个脚本不复杂,但它的价值在于:每次修改必须带来通过率提升或持平,一旦通过率下降,要立刻排查是不是新规则破坏了已有规则。规则系统最大的风险就是改一个修一个,改两个漏三个。回归脚本就是用来拦住这个风险的。

4.3 覆盖率报告与盲区发现

除了整体通过率,我还会定期生成一份覆盖率报告。统计维度包括:每条规则的实际命中次数、每个意图的整体召回率、以及长期没有命中的"僵尸规则"。

这种报告能暴露很多隐藏问题。比如有一个意图设计了五条规则,但线上跑了一个月,只有两条规则命中过,其他三条从来没触发。这时候就要分析:是这三条规则设计错了,覆盖的句式在真实场景里根本不存在?还是它们被其他高优先级规则抢走了候选?不论哪种情况,都需要调整。

另外一个很实用的指标是置信度分布。如果大量样本的置信度都集中在 0.5 到 0.6 之间,说明规则模板的区分度不够,需要补充更具体的表达;如果置信度普遍偏高,则说明负例覆盖不足,规则泛化过度但还没暴雷。

覆盖率报告让规则系统不再是一个黑盒,而是有了可视化的运行健康度。这一点上,规则工程和算法模型没有什么本质区别,都需要用数据说话。

5. 实战踩坑记录与规则维护纪律

5.1 翻车案例一:贪心正则与语义边界

第一次大规模上线之前,测试组报了一个很经典的 bug:用户输入"我想把明天的订单取消掉,顺便看看后天的预约",系统返回了两个意图,其中一个是"取消预约",但实际用户只提到了预约相关的内容,订单和预约混在一起,最终仲裁结果完全跑偏。

排查下来发现,问题出在规则模板写得太贪心。"取消{object}"这个模板命中"取消掉"之后,object 被贪婪匹配到了后面的"后天的预约",导致一条本来该匹配"取消订单"的规则被错误激活。

这个教训让我定了一条规矩:模板边界宁可写窄,也不要写宽。占位符后面如果还有别的语言成分,必须显式加边界约束。尤其是中文文本没有天然空格,边界控制一定要靠 whitelist 和黑名单双重限制,不能只靠正则的非贪婪模式。

5.2 翻车案例二:规则版本与多分支协作

后续迭代过程中,团队里两个人同时维护规则文件,结果出现了严重不一致。甲在某分支加了三条新规则,乙在另一个分支改了旧规则的黑名单,两边合并之后,出现了一条规则引用了一个已经不存在的槽位定义,线上加载直接报错。

规则文件本质上是代码,不是文本配置。它必须有版本管理、必须有 diff 审查、必须有变更记录。我后来把规则文件的加载改成了带版本号的方式,规则变更必须附一条变更说明,说明改了什么、为什么改、影响了哪些意图。这个习惯建立起来之后,"改了一个地方结果坏了另一个地方"的情况明显变少。

5.3 规则维护的三条纪律

踩过这些坑之后,我给自己定了几条规则维护纪律,分享出来供参考:

第一,先写测试再改规则。任何规则变更,都必须先在评测集里加一条对应的用例,确认它失败,然后再去改规则。这样可以确保修改是有针对性的,而不是顺带碰运气。

第二,一次只改一条规则。如果一次改了三条规则,回归挂了,很难定位到底是哪一条改出了问题。一次只碰一个点,跑完回归确认没问题,再动下一个点。

第三,每条规则都要带开关和观察期。新规则上线先观察一段时间,看命中情况和误触发情况,再决定是否长期保留。一旦发现副作用,立刻关掉开关,不要急着删规则,先确保线上稳定,再做后续分析。

最后再分享一个小技巧:我会给每条规则加一个"最后命中时间"的统计字段。定期扫一遍,那些半年都没命中过的规则,要么是覆盖的句式已经没人用了,要么是已经被更高优先级的规则完全压制。把这些僵尸规则清理掉,规则系统的维护成本会低很多。规则工程做到后面,真正考验人的已经不再是能不能写出匹配模板,而是能不能克制住不停加规则的冲动。少一条规则,系统就多一分稳定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询