先交代一下来龙去脉。我一直在琢磨怎么把Rust用到知识推理这个方向上——正好有位开发者发来一段已经膨胀到五百多行的if-else配置校验逻辑,问有没有办法让规则变得看得见、改得动、甚至能解释每一步为什么这么走。这类问题在软件工程里其实有成熟方案:把判断写成声明式规则,交给一个基于规则的知识推理引擎去执行。但市面上的规则引擎大多绑着JVM或解释器运行时,只是为了让配置文件自己会校验就引入一整套重家伙,怎么看都不划算。于是我想,干脆用Rust写一个轻量级的知识推理引擎,编译成库直接嵌进业务服务,既能处理几百条规则的组合推理,又几乎不引入外部依赖。这篇文章就聊聊我从零实现它的完整过程:前向链骨架、Rete增量匹配、Rust的工程细节,以及几个必须避开的坑。
1. 我为什么非要自己写一个规则引擎,而不是继续堆条件判断
1.1 if-else帝国的崩溃现场
当规则数量堆到五百条,问题的本质已经不是“写不写得出来”,而是“改一次要流多少汗”。每一个if-else分支都是一个独立判断,可真实业务规则之间偏偏存在组合、优先级和负逻辑:什么情况下A和B同时成立但结论不成立,什么情况下C的配置和D互相依赖。这些约束一旦硬编码,新增一条规则就得把整个条件链重新读一遍,测试要覆盖的组合数量也指数上涨。规则引擎的思路完全相反——把规则本身变成数据,让一个推理内核去解释执行。你在规则文件里写“如果端口是80并且协议是HTTP,则绑定合法”,而不是在代码里写两层if。规则能读、能改、能版本管理,出问题还能逐条查。
知识推理引擎的基本构成也不复杂:事实库保存已知信息,规则库存放规则,推理机负责把规则和事实匹配起来,产生新事实或副作用。前向链推理最适合这一类场景:事实是现成的,规则负责在事实之上导出结论。之所以选前向链而不是后向链,是因为配置校验、监控诊断这类需求几乎都是数据驱动的——事件来了、配置改了,立刻推一遍,把所有违规结论挖出来。后向链更适合“给定一个目标反推条件”的问答式场景,初版引擎完全不需要。
1.2 Rust在这个问题上的位置
选Rust不是因为“它新潮”,而是三个实际理由。第一,规则天生适合用Rust的枚举和模式匹配来表达,后面写AST时会非常顺手;第二,Rust没有GC、依赖树可以控制得很小,编译成一个库嵌进业务服务,体积和启动消耗都可控;第三,事实和规则之间存在明显的图状引用关系,所有权的模型会强迫你提前想清楚“谁是持有者、谁是借用者”,这反而让工程腐化的速度慢下来。
当然代价也存在。最明显的是实现Rete这种网络式数据结构时,Rust的借用检查器会频繁卡住你,早期的开发体验确实不如脚本语言顺畅。但这个“卡”其实很有价值,它在每个设计点上都在逼你回答数据到底归谁管,一旦想清楚,后面反而几乎不会出现运行时才能发现的悬垂引用。按照自己的场景从零设计,而不是照着教科书复刻一个标准引擎——这可能就是标题里“发散创新”四个字的真正含义。我的目标始终是做出一个能落在真实工程里的库,而不是又造一个理论玩具。
2. 先把前向链跑通:事实、规则与冲突消解的最小骨架
2.1 用enum建模事实与模式
我不想把事实定义成强类型结构体,因为推理引擎要面对“未知谓词继续推理”的场景。事实统一成“谓词+参数列表”更灵活。参数用Value枚举表示:
#[derive(Debug, Clone, PartialEq, Eq)] pub enum Value { Int(i64), Str(String), Bool(bool), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Fact { pub id: u64, pub predicate: String, pub args: Vec<Value>, }id字段不是真实业务数据,是引擎内部给每条事实发的身份证,后面的Rete网络和解释链全靠它。规则的左侧条件用Pattern描述,Pattern要能表达“任意值”“具体值”“变量绑定”“范围比较”四件事:
#[derive(Debug, Clone, PartialEq)] pub enum Pattern { Any, Lit(Value), Bind(String), Range(i64, i64), } pub enum Condition { Match { predicate: String, args: Vec<Pattern> }, NotMatch { predicate: String, args: Vec<Pattern> }, }设计意图很简单:Match表示“存在这样一条事实”,NotMatch表示“不存在这样一条事实”。NotMatch在处理负逻辑时几乎天天用到,比如“端口空闲”必须表述成“不存在占用该端口的事实”。把负条件作为一等公民放进来,比用逻辑取反绕来绕去清楚得多。
2.2 匹配循环和冲突消解策略
前向链推理的经典循环是Match-Resolve-Act。Match阶段把每条规则的左侧条件和当前事实库全部匹配一遍,产生一组带变量绑定的候选;Resolve阶段从候选里选出一条真正执行的规则;Act阶段执行该规则的右侧动作,通常是断言新事实、撤回旧事实或发出副作用消息。循环一直跑到没有候选为止。为了防止意外活锁,run方法接受一个max_firings上限,超过就跑不动并报告状态,后面第6章会展开讨论这个上限如何被人滥用又被我救回来的故事。
冲突消解是推理引擎里很实用的设计点,多条规则同时匹配同一批事实时,必须规定先跑哪条。我用的策略十分朴素:先比规则优先级priority,再比规则名的字典序。优先级让关键规则先跑,字典序保证结果可复现。不要小看可复现性——规则引擎一旦非确定,排查问题的成本就会立刻失控。
pub struct Rule { pub name: String, pub when: Vec<Condition>, pub then: Vec<Action>, pub priority: i32, } pub enum Action { Assert { predicate: String, args: Vec<Pattern> }, Retract { predicate: String }, Emit(String), } impl Engine { pub fn run(&mut self, max_firings: usize) -> usize { let mut fired = 0; while fired < max_firings { let candidates = self.find_all_matches(); if candidates.is_empty() { break; } let chosen = self.resolve_conflicts(candidates); self.act(&chosen); fired += 1; } fired } }这里find_all_matches是最朴素的全量匹配:每一轮都对事实库全量扫描。对教学和验证语义来说这是最稳妥的起点,但性能会随事实数量和规则复杂度的增加迅速恶化,这就是第3章要解决的问题。
2.3 一个最小可运行的前向链引擎
把上面几块拼起来,最小引擎不到两百行就能工作。assert_fact负责写入一条新事实并分配id,act负责根据Action更新事实库和外部副作用。跑一轮“如果订单金额超过一千且是新用户,则标记为高价值用户”这种规则,整个过程是直观的:匹配阶段把订单事实和新用户事实绑定到变量,Act阶段断言一个名为high_value的新事实。下一轮循环里,新事实又可能作为其他规则的前提,这就有了一条真正的推理链。
实现这个最小版本时我犯过的一个低级错误是在循环内不断克隆全部事实集合作入参,导致复杂度再翻一倍。正确的做法是只让find_all_matches借用事实库,冲突消解结果也只保存规则名和绑定的一组事实id,Act时才真正修改事实库。Rust的借用规则其实一直在帮你做这个约束,早点顺应它会少踩很多坑。等最小引擎能跑了,再着手写测试、补冲突消解的日志输出,我会建议你把“能解释每次为什么选这条规则”当作和“能跑”一样重要的验收标准。
3. 从全量重扫到Rete:推理性能的关键跃迁
3.1 朴素匹配的时间黑洞
最小引擎跑通后,第一次压力测试就把我打醒了。15条规则、200条事实、每条规则平均三个前提条件,朴素匹配的代价大约是O(R乘F^k),这里的k是单条规则前提数量。R是15,F是200,k是3,意味着最坏情况要把上亿次条件组合全部试一遍。虽然实际执行中类型过滤和变量绑定一致性检查会砍掉大量分支,但每新增一条事实就要全部重来的感觉仍然糟糕。尤其配置校验这种场景,用户改一条配置期待的是毫秒级响应,而不是一次两百条事实的重扫。
这里我不想用“优化”这个词,因为Rete算法本质上不是对朴素方案的微调,它是完全不同的思路:把规则拆成测试网络,让上一次的匹配结果留在节点里反复复用。认识这个区别很重要,它决定了你的实现方向,而不是在一堆循环里做局部加速。
3.2 Rete的核心:把中间结果缓存起来
Rete的关键洞察是:一条规则的前件可以拆成一棵树。单事实层面的测试放进Alpha网络,例如“谓词是port_binding”“第一个参数是整数范围1到65535”。跨事实的变量连接放在Beta网络,例如“规则要求port_binding和service_config同时存在,且它们的第二个参数相等”。Alpha网络每次来新事实只动自己分支的测试;Beta网络做的是把两个来源的部分匹配join起来。
因为中间结果全部缓存在节点里,新增一条事实时只要从根节点往下灌,命中的分支更新token,没命中的分支完全不用碰。这个特性对“频繁改单条配置”的使用方式极其友好。代价是要用内存换速度,后面实测会看到Rete的峰值内存比朴素高很多。
3.3 Rust实现Rete:用节点和token代替递归遍历
动手前先想清楚数据结构。Alpha节点分三类:类型检查、字段比较测试、记忆节点。Beta节点负责join两侧token,并缓存自己的部分匹配结果。Token是部分匹配的记录,包含变量绑定和支撑它的事实id列表:
pub enum AlphaNode { Type { pred: String, next: Vec<usize> }, Test { arg_idx: usize, cmp: Cmp, next: Vec<usize> }, Memory { matched: Vec<u64> }, } pub enum Cmp { Equal(Value), NotEqual(Value), Greater(Value), Less(Value), } pub struct BetaNode { pub left: Option<usize>, pub right: usize, pub joins: Vec<(usize, usize)>, pub tokens: Vec<Token>, } #[derive(Clone)] pub struct Token { pub bindings: BTreeMap<String, Value>, pub fact_ids: Vec<u64>, }加入一条新事实的流程是:先走Alpha网络做字段级测试,通过后进入对应Alpha Memory,然后逐级向Beta节点传播。Beta节点拿到右侧新token时,和左侧缓存的所有token做join,join条件不是简单的逻辑公式,而是严格按照bindings中同名变量必须值一致来匹配。这一步做到位,匹配结果才不会出现笛卡尔积式的垃圾组合。
为什么选BTreeMap而不是HashMap存绑定?规则匹配的变量通常只有个位数,BTreeMap的迭代顺序稳定,join时按key遍历更可预测,而且标准库直接可用,不用引入额外依赖。性能上在这个规模没有可感知差异,我倾向选择可预测性更好的一方。
4. Rust的所有权、枚举和模式匹配在引擎里实打实派上的用场
4.1 用enum表达规则语言,match驱动执行
规则语言既然定了Pattern、Condition、Action三类枚举,解释它们就成了纯粹的match。Rust的穷尽性检查在这里非常值钱:将来你新增一种Pattern变体,编译器会强迫你把所有match点都处理一遍,漏掉任何一个就是编译错误。这在演进频繁的推理引擎里几乎等于免费的可维护性。相比之下,动态语言里加一种模式,最容易漏掉某个深处判断,然后等线上数据来教做人。
执行规则的动作也走同样的路,match Action的分支:Assert去构造新Fact,Retract去按谓词删除,Emit把字符串交给外部回调。每一步都是纯函数式的数据处理,测试起来非常舒服——喂一组fact,看结果fact集合的变化,不用mock任何东西。规则引擎的复杂性集中在数据流,而不是控制流,所以函数式的风格反而比一堆可变状态更容易理解和维护。
4.2 用什么容器管理事实:Rc对象图不如索引式arena
第一版我把事实库设计成Rc<RefCell >集合,想着要共享可变状态,结果写下来处处别扭。Clone要小心,借用要小心,debugger里看每个节点全是地址值。后来老老实实换成Vec 当arena,再配一个HashMap<u64, usize>做id到下标的映射,事实id本身永不复用。三种方案的取舍可以列一张表:
| 方案 | 优点 | 缺点 | 我的评估 |
|---|---|---|---|
| Vec + id映射 | 内存局部性好、调试直观 | 删除时swap_remove会让下标变化 | 正式采用 |
| HashMap<u64,Fact> | 删除简单、遍历时借用干净 | 全量扫描时缓存不友好 | 备选 |
| Rc<RefCell >对象图 | 完全共享,图状访问自然 | Clone语义复杂、调试差、容易循环引用 | 放弃 |
Rete网络里每个Token要保存支撑事实的id列表,而不是直接持引用。事实在arena里的物理位置可能变化,但id永远不变,这样Token就不用担心悬挂引用。这个“物理位置可动、逻辑身份不变”的约定,让整个网络的所有权关系一下子简单了许多。如果你把同样的逻辑放到脚本语言里,大概率不会主动想到这层设计,但Rust的所有权模型会逼你把所有权边界画得明明白白。
4.3 模式匹配与变量绑定的实现要点
变量绑定是规则匹配中最容易出错的点,核心逻辑只有几条,但必须做对:Bind(String)首次出现时把事实参数值存入bindings;同名变量再次出现时必须和旧值严格相等;所有条件共享同一个bindings集合,而不是各自建map。我早期就吃过亏,每个条件单独建绑定,导致规则里两个条件对同一个变量给出不同值时照样通过。修复后的代码是典型的逐模式match:
fn bind_pattern( pat: &Pattern, val: &Value, bindings: &mut BTreeMap<String, Value>, ) -> bool { match (pat, val) { (Pattern::Any, _) => true, (Pattern::Lit(l), v) => l == v, (Pattern::Bind(name), v) => match bindings.get(name) { Some(old) => old == v, None => { bindings.insert(name.clone(), v.clone()); true } }, (Pattern::Range(lo, hi), Value::Int(n)) => *lo <= *n && *n <= *hi, _ => false, } }这段代码看起来简单,却是整个引擎正确性的地基。一个事实参数是Int,一个模式是Str,bind_pattern会走到fallback分支返回false;一套模式里两个变量互相约束,bindings会自动完成一致性校验。变量一致性的语义一旦弄错,后面的冲突消解和Rete join全都会跟着错,而且错误往往是间歇性的、只在特定数据组合下出现,非常难查。
5. 模拟项目X的实测记录:配置校验知识库跑出来的数据
5.1 搭一个具体但不失真实的测试场景
为了验证引擎,我搭了一个模拟项目X的配置校验知识库。场景是:一组服务配置需要满足若干约束,包括端口与协议匹配、TLS版本不能低于某个阈值、依赖的服务必须在配置集中存在、保留ID列表不允许被业务占用。总共15条规则,每条规则平均3到4个前提条件,事实库200条,其中一半是合法配置,一半是特意构造的非法配置,用来触发规则产生诊断结论。
这种场景选得刻意,因为它几乎覆盖了前向链引擎的所有路径:有正条件、有负条件、有数值比较、有多条规则同时匹配同一事实的冲突。用来验证性能和语义是否正确,比单调的“订单积分”例子可靠得多。配置校验这种业务还有一个显著特征——事实之间关联性强,一条端口事实经常要跟一条服务事实、一条协议事实做三方join,正好能把Beta网络的复杂度压出来。
5.2 朴素匹配与Rete的实测对比
本地固定配置下,我记录到的相对差距如下表。数字不吹成通用基准,只是同一台机器上的趋势数据,看量级就够了:
| 场景 | 朴素全量匹配 | Rete增量匹配 | 说明 |
|---|---|---|---|
| 初始加载200条事实 | 1.7ms | 2.5ms | Rete要先建网络并灌入全部事实 |
| 新增1条事实后的单次匹配 | 1.6ms | 0.2ms | 只有受影响路径被刷新 |
| 新增1条非法事实并推理到结论 | 4.2ms | 1.1ms | 多轮推理需要多次传播 |
| 峰值内存 | 约6MB | 约36MB | Token缓存和索引开销明显 |
最直观的结论是:Rete的增量优势非常集中,在“新增单条事实”的场景里相差大约一个数量级;但Rete的内存开销也确实是朴素模式的六倍。配置校验这类场景通常事实不多、规则不长、却要求高频增量变更,Rete明显占优。反过来,如果是一次性批量导入成千上万条事实然后只跑一轮推理,Rete的构建成本就不划算,朴素扫描反而更符合直觉。
5.3 增量更新是我在这个项目里最满意的行为
模拟场景里最常用的操作是“改一条配置,重新检查整份配置是否合法”。朴素实现每次都要扫描全部事实,即使只改了一个端口号。Rete实现则把新事实从根节点灌入,Alpha测试只走该事实的分支,Beta节点也只更新和它相关的token链。多数情况下,新增一条事实实际触碰的节点不到总节点数的三成。
另一个观察是,真正多轮推理收敛的过程里,匹配时间占比并不高,绝大部分运行时间花在Act产生的副作用处理上。这提醒我一件事:如果将来要优化,优先优化的不是匹配算法本身,而是Action执行路径。过早为了匹配性能把架构复杂化,性价比其实不高。跑了一段时间后我更确信:Rete带来的优势不在跑分,而在“每次只算必要部分”的工程体验,它让推理引擎在真实业务节奏里很自然。
6. 踩过的坑与工程取舍:从悬挂Token到规则活锁
6.1 事实删除后,Token会变成定时炸弹
最早的删除实现很粗暴:从Vec 里把目标事实swap_remove掉,同时更新id到下标的映射。问题立刻暴露在Rete的Token里——Token保存的是fact_id列表,删除动作发生时,那些还在Token里的fact_id并没有被同步清理。下一轮传播遇到一个指向已删除事实的Token,要么panic,要么更糟:拿着半份绑定的旧数据继续推理,产出一个看起来合理实际错误的结论。
修复方案没有捷径:删除事实时,不仅要移出事实库,还要扫描所有Beta节点的Token,把包含该fact_id的Token剔除或标记为失效。代价是删除操作从O(1)变成O(T),T是Token总数。工程上的取舍是,删除频率低所以可以接受;如果哪天真的高频删除,就得引入单独的dirty索引和惰性清理,让Token先留着、后续传播时再判活。我实际选择的是后面这种做法,因为配置校验场景里撤回操作很少,不值得为它破坏实现的简单性。
提示:任何断言“事实被删除”的引擎,都必须先回答一个递归问题:依赖这条事实而推导出的结论,要不要跟着删除?我的实现暂时不支持自动级联撤回,只保证不会产生悬挂引用。这个限制必须写在文档第一行,不然使用者会默认引擎具备完整的真值维护能力。
6.2 规则循环触发造成的活锁
规则之间互相触发太正常了。一条规则断言“服务A需要服务B”,另一条规则根据“服务B存在”又断言“服务A需要服务B”,这种循环在真实规则库里几乎无法避免。我第一版直接依赖max_firings来兜底,结果发现120步的正常推理链会被误伤,而300步的活锁又占用大量时间空转。
最终方案是双重防线。第一道是绑定级去重:记录每条规则最近一次firing时产生的绑定hash,同一规则同一绑定不重复触发,这能拦住大多数自循环。第二道是全局步数上限,默认放得很宽,只防真正的失控。去重逻辑本身很简单,但收益很大,顺带还给解释链提供了一个“这条规则为什么停下来”的依据。如果你也想实现智能一点的循环控制,从“同一规则同一绑定不重复触发”开始,一般就够用了。
6.3 冲突消解必须保证确定性
两条规则优先级相同,规则名不同,若不额外排序,执行顺序取决于匹配时的内部迭代顺序——这在发布版本里可能是稳定的,但一旦换了release构建或改了fact插入顺序,结果就可能变化。规则引擎不确定比规则引擎慢更可怕,因为用户会开始怀疑自己是跟一个黑盒打交道。我加的排序非常保守:优先级降序,优先级相同再按规则名字典序升序,最后按规则在规则库中的声明序号。三个层级合起来,任何输入顺序下输出都稳定。
调试时我还会在冲突消解日志里打印候选规则名和它们的优先级,方便直接回答“为什么选它而不是选另一条”。这种可解释性在规则引擎里往往被低估,但等你需要排查线上规则行为时,它就是救命的。我见过太多规则引擎项目最后死于“规则之间到底谁先跑”的争执,一个显式、稳定、可解释的冲突消解策略,提前就把这种争执摁死了。
6.4 变量名遮蔽:匹配正确性的隐形陷阱
很早实现Match条件时,我让每个条件模式独自做绑定,认为bindings是局部的。于是规则写成“存在x:父节点、存在y:子节点”没问题,但写成“同一个变量在多个位置出现”时,两个位置得到不同值也通过了。修复方法在第4章已经写过:所有条件共享同一个bindings集合,Bind第一次出现插入,之后出现必须和旧值相等;失败立即让整个条件返回false。
这类bug最难的地方在于,正常数据大多碰不到冲突场景,一旦碰到,会表现为“某条规则莫名其妙不触发”或“某条不该触发的规则却触发了”。如果代码里没有把bindings作为显式参数贯穿所有match路径,这种问题几乎不可能靠肉眼发现,只能靠对每一个多位置变量的针对性测试。我后来在测试集里专门加了一条包含同名变量多出现次数的规则,确保每次引擎改动后它都跑一遍,这一条测试就帮我拦住过至少三次重构回归。
7. 让这个引擎继续生长:解释链、wasm与先跑通的建议
7.1 给推理过程生成解释链,回答“为什么”比“所以”更重要
规则引擎最容易被诟病的一点就是“它给我一个结果,但不告诉我为什么”。其实原理很简单:每次firing时把使用的规则、前提事实id、产生的事实id、发生在第几步记录下来,就能构成一张推理有向无环图。我为此定义了一个轻量的日志结构:
pub struct Justification { pub rule: String, pub premise_fact_ids: Vec<u64>, pub produced_fact_id: Option<u64>, pub fired_at_step: usize, }有了这张图,用户从任何结论反向追溯,就能看到完整路径:哪条规则、哪些事实、第几步。这个设计同时服务两个目标:排查异常时还原现场,以及向业务方证明系统没有做不可控的推理。Rust的零成本抽象在这里也派上用场,只要选择不记录,日志开销近乎为零,生产环境可以按需开关。如果你想把这个引擎做成正式产品,解释链甚至比性能数据更重要,因为规则推理的可信度最终建立在“每一步都能被审计”之上。
7.2 把核心编译成wasm,让知识库离线也能跑
因为引擎核心没有任何IO依赖,运行所需就是一组规则数据加一组事实数据,移植到wasm几乎没有障碍。这个方向很有吸引力:把校验知识库编译进前端静态资源,用户在浏览器里离线填写表单,配置合法性在本地就能完成校验,根本不用等网络往返。Rust生态对wasm的支持已经足够成熟,核心库不需要做任何架构调整,只要在构建配置里加一个目标平台即可。
当然这要求规则数据本身不含敏感逻辑,并且版本要和后端保持一致,否则会出现前后端校验结果不一致的新问题。我的建议是把规则数据当作版本化配置单独发布,前后端共用同一份规则集,这样编译产物只是解释器,规则仍然可以单独更新。从这个角度看,Rust实现的规则引擎天生比JVM系规则引擎更适合做这种轻量嵌入式交付,这也是我当初选型时没有预料到的额外红利。
7.3 如果重来一遍,我会怎么建议自己
最核心的建议是:先跑通朴素版本,不要一上来就写Rete。朴素全量匹配虽然慢,但它把语义正确性和工程结构先钉死了,你会清楚地知道哪个环节是纯计算、哪个环节是状态修改。等朴素版本跑通,用profiler找到真实瓶颈,再按需引入Rete的增量网络。我在这个项目上其实是按照两条独立路径走的,朴素版花三天,Rete版又花两个晚上,中间大量时间都用来画节点关系图而不是写代码。回想起来,先画图、再动手,比边写边改要快得多。
最后想说的是,知识推理引擎最大的价值从来不在“跑得多快”,而在“每一步为什么这么走、能不能复现”。Rust给了我一个顺手的方式去实现这条路,但真正让引擎值得信赖的,仍然是那句朴素的老话:让规则配得上被审查。如果你也打算动手写一个,我的建议就是别急着把算法堆满,先把一条规则的完整生命周期走顺,再把两条规则的冲突处理好,最后才轮到性能和高级特性——这个顺序踩出来的经验,比我给你任何现成代码都值钱。