写推理引擎这事,老实说,最早我以为是“AI领域那些搞学术的人才需要碰的东西”。直到有一天我需要在一个资源很受限的边缘设备上做实时规则判断,数据量不大但逻辑分支特别多,才意识到传统规则引擎那一套在嵌入式/边缘场景根本施展不开——要么太重,要么语言绑定太死。于是动了“自己写一个轻量级规则引擎”的念头。正好那段时间在啃Rust,顺手就把“知识推理引擎”和“基于Rust实现”这两个关键词绑在了一起。这篇文章就是我当时完整思路和实现过程的记录:从规则与事实的数据结构设计,到推理循环、冲突消解,再到一个完整的故障诊断示例代码,最后是一些真实踩坑记录。适合对Rust有一定基础、想了解推理引擎内部原理、或者打算在资源敏感环境里自研规则引擎的开发者参考。
1. 整体设计与思路拆解
1.1 先搞清楚“知识推理引擎”到底在推什么
概念这东西,一旦用术语包装就显得高深。拆开看其实很简单:知识推理引擎干的事就是把“已知的事实”加上“预先定义的规则”,通过某种推导机制产生“新的事实”。
举个例子,事实是“CPU温度=95℃”“CPU负载=90%”,规则是“如果CPU温度>85℃且CPU负载>80%,则判定为过热”,那么引擎就能推导出一个新事实“当前系统过热”。
这个推导过程可以用一张极其朴素的循环来概括:
循环: 匹配所有规则的条件部分 如果都不满足 / 没有产生新事实,退出 选择一条/多条规则执行 把新事实加入事实库这套机制在实际工程里有个更常见的名字——规则引擎。只是知识推理引擎更强调“知识”的概念化,通常还会引入三元组(主语-谓语-宾语)、本体论等表达方式,本质上仍然是“条件匹配+动作执行”。
1.2 为什么我选择了Rust
选型这件事,我纠结过不少时间。当时对比了Go、Java和Rust三套方案,最终选择了Rust,理由大概有这么几条:
- 内存安全与无GC:推理引擎在边缘设备上跑的时候,没人希望GC时不时暂停一下。Rust的所有权机制让我能在编译期就规避掉大量内存问题。
- 模式匹配非常适合规则判断:Rust的
match表达式、模式绑定和解构能力,和“规则匹配”这个动作天然契合。规则里的每个条件分支,几乎都能用match直接表达。 - 宏系统能做出很漂亮的DSL:我想让规则的编写像写配置文件一样简洁,Rust的
macro_rules!和proc_macro都能很好地实现这一点。 - 性能可控:虽然这个项目第一个版本只为验证逻辑,但如果后续要做大规模事实匹配,Rust能给出逼近C++的底层性能,同时保住开发效率。
有人可能会说,Java有Drools,C++有CLIPS,为什么要自研?因为在这个场景里,我需要的是一个能嵌入Rust生态、没有额外运行时开销、规则可以被打包成单独数据文件的小型引擎。Drools那种重型方案完全不合适。
1.3 整体架构:三件套不会变
任何规则推理引擎,核心结构都是三个部分:
| 组件 | 作用 | 类比 |
|---|---|---|
| 事实库(Working Memory) | 存放在推理过程中已知的事实集合 | 相当于工作台面上的所有零件 |
| 规则库(Rule Base) | 存放所有规则的定义,包含条件和动作 | 相当于操作手册 |
| 推理循环(Inference Engine) | 负责匹配、选择、执行规则的循环过程 | 相当于一名照着手册干活的工人 |
在这三者之上,我额外又加了两样东西:规则优先级字段和冲突消解策略。前者的作用是当多条规则同时被触发时,让引擎知道先执行谁;后者是所有成熟规则引擎都必须考虑的环节,我会在后面细讲。
至于“发散创新”体现在哪里?我的想法是在有限范围内做一些务实改进:第一,不用复杂的RETE网络做模式匹配,先采用线性扫描加增量事实索引的组合方案,兼顾代码可读性和性能;第二,用Rust宏把规则定义做成接近自然语言的DSL,降低业务人员写规则的门槛;第三,将事实设计成可扩展的特征对象(trait object),让引擎核心逻辑不依赖具体的业务类型。这三点的组合在现有开源规则引擎里比较少见,也是个人项目里值得玩味的地方。
2. 事实与规则的数据结构设计
2.1 事实(Fact)的表达方式
事实是推理的基础原料。在Rust里,我把事实分成了两种形态:简单事实和属性事实。
简单事实就是一个断言的成立,比如“检测到高温”“网络连接已建立”。这种事实用枚举值维护最合适。属性事实则带有一组键值对,比如“温度=95”“负载=90%”。这让我想到了Rust的HashMap,但综合考虑到后续匹配效率,我用了一个更规整的结构体:
#[derive(Debug, Clone, PartialEq)] pub enum FactValue { Str(String), Int(i64), Float(f64), Bool(bool), } #[derive(Debug, Clone)] pub struct Fact { pub name: String, pub attributes: HashMap<String, FactValue>, }在这里,name描述事实的类型,attributes描述这个事实的各个属性。比如一个事实可以是这样:
Fact { name: "sensor_temp".to_string(), attributes: HashMap::from([ ("value".to_string(), FactValue::Int(95)), ("unit".to_string(), FactValue::Str("celsius".to_string())), ]), }有人会质疑,为什么不用Rust的struct直接定义业务数据类型,还要绕一层HashMap?原因有二:一是规则引擎中的事实来源不确定,可能是数据库查出来的、可能是传感器上报的,统一成Fact结构对下游匹配逻辑最友好;二是在推理过程中,规则动作会动态构造新事实,运行时如果还依赖具体业务类型,编译期就得把所有可能的类型都列出来,那就失去引擎的意义了。这里的取舍是:核心引擎只关心“这个事实叫什么名字、有什么属性”,具体业务语义由规则来驱动。
2.2 规则(Rule)的结构设计
规则的建模是整个引擎的关键。一条规则,本质上是一个“条件组合触发动作集”的映射。我把它拆成几个字段:
#[derive(Debug, Clone)] pub struct Rule { pub name: String, pub priority: i32, // 冲突消解时用,数值越大优先级越高 pub conditions: Vec<Condition>, // 多个条件,语义为“AND” pub actions: Vec<Action>, // 触发后要执行的动作 pub fired_times: i32, // 记录该规则已触发次数,用于终止判断 pub max_fire: i32, // 最多能触发几次,防止死循环 }fired_times和max_fire是容易被忽略的字段,但它们对引擎的稳定性至关重要。我在第一次试跑的时候,就因为漏掉“规则最多只能触发N次”这个约束,程序在两条互相推导的规则之间无限循环,CPU直接拉满。后面在调试部分会详细说。
条件(Condition)的表达式我设计成三元形态:
#[derive(Debug, Clone)] pub struct Condition { pub fact_name: String, pub attr: String, pub op: Operator, pub value: FactValue, } #[derive(Debug, Clone)] pub enum Operator { Equal, NotEqual, GreaterThan, LessThan, GreaterEqual, LessEqual, Exists, }这样设计是为了让条件表达式在序列化、传输、DSL书写时都能有统一的形态。后面写规则宏的时候,你会发现这种扁平结构特别好用。
动作(Action)我设计成两种:插入事实和删除事实。因为这个项目专注于正向推理,动作集不需要很复杂,简单插入足够覆盖大部分场景了:
#[derive(Debug, Clone)] pub enum Action { InsertFact(Fact), RemoveFact(String), Print(String), }2.3 三元组:让事实表达更靠近“知识”
熟悉知识图谱的人看到Condition里的fact_name、attr、value,应该会立刻联想到三元组“主语-谓语-宾语”。这种相似性不是巧合,而是设计时有意靠拢的。
知识推理引擎和普通规则引擎的一个小区别在于“知识”的结构化程度更高。普通规则引擎关心“如果score > 90就执行某动作”,知识推理则更习惯“如果张三 的 速度 大于 90”这种语义化的句子。我用fact_name来模拟主语、attr来模拟谓语、value来模拟宾语,使得规则在可读性上更接近自然语言,同时保留机器的可匹配性。
如果未来需要用Rust去对接RDF(资源描述框架)或者知识图谱数据,这种结构可以几乎无缝地平移过去。
3. 推理循环:从匹配到执行的完整流程
3.1 前向链推理的四个阶段
推理引擎最常见的策略是前向链推理(Forward Chaining),也称数据驱动推理:从已知事实出发,不断应用规则推导新事实,直到无法再推出新的结论。整个过程拆成四个阶段:
- 匹配(Match):遍历规则库,判断规则的所有条件是否被当前事实库满足。
- 冲突消解(Resolve):当多条规则同时被触发时,决定先执行哪一条。
- 执行(Act):运行规则的动作,产生新事实/删除事实。
- 循环(Loop):回到匹配阶段,直到没有规则可触发或达到最大迭代次数。
核心代码骨架是这样:
pub fn run(&mut self, max_iterations: usize) -> InferenceResult { let mut iteration = 0; loop { iteration += 1; if iteration > max_iterations { break; } // 1. 匹配阶段 let mut candidates = self.match_rules(); // 2. 冲突消解阶段 candidates.sort_by(|a, b| b.rule.priority.cmp(&a.rule.priority)); let candidates = self.resolve_conflicts(candidates); if candidates.is_empty() { break; } // 3. 执行阶段 let mut fired = false; for candidate in candidates { if candidate.rule.fired_times >= candidate.rule.max_fire { continue; } self.execute_rule(&candidate.rule); fired = true; } // 如果没有规则被真正执行,说明条件满足了但受 fire 次数限制, // 继续循环也没有意义,直接退出 if !fired { break; } } self.collect_results() }这段代码看起来简单,但有几个细微点值得展开:
首先,candidates.sort_by实现了最简单的优先级冲突消解。真实世界里的规则引擎可能还要考虑“规则专一性”“最近使用时间”等更多因素,但优先级这一个维度能覆盖80%的场景。
其次,我写了if !fired { break; }。这个判断在第一次版本里是缺失的,结果就是当所有候选规则都被max_fire限制后,引擎会空转直到max_iterations触发。看似是小问题,实际在大量规则集的情况下会白白消耗掉很多CPU周期。
3.2 冲突消解的多种策略对比
冲突消解是推理引擎里体现“智能程度”的地方。同样的事实,可能同时激活了三条规则,三条规则的结果甚至互相矛盾,该听谁的?我做了一个简单的策略对比表:
| 策略 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 优先级排序 | 规则上人工标注priority值 | 简单直观,可控性强 | 依赖人工经验 |
| 专一性优先 | 条件越具体的规则优先执行 | 符合直觉,适合大多数情况 | “具体程度”难以量化 |
| 时间戳策略 | 最近最久未执行的规则优先 | 公平 | 复杂,难以预料结果 |
| 随机选择 | 随机抽一条执行 | 实现最简单 | 推理结果不稳定 |
这个项目里我选择了“优先级排序”作为主策略,原因是故障诊断这类业务场景里,运维人员对“哪条规则最重要”往往有清晰的判断,人工标注成本低。而在纯知识推导场景,我更推荐“专一性优先”,因为越具体的规则通常意味着越专注的场景,推导结果往往更贴近实际。合理的设计是优先级排序为主、专一性作为第二关键字,实际效果更好。
3.3 用宏实现一套规则DSL
前面说了,这套引擎面向的不是纯程序员,我希望业务人员也能把规则写明白。所以我在Rust的macro_rules!上做了文章,写出了下面这套接近自然语言的DSL:
macro_rules! rule { ($name:ident, $priority:expr, [$($cond:expr),+], [$($act:expr),+]) => { Rule { name: stringify!($name).to_string(), priority: $priority, conditions: vec![$($cond),+], actions: vec![$($act),+], fired_times: 0, max_fire: 1, } }; }用法示例:
let overheat_rule = rule!( overheat_check, 10, [ cond_exists("sensor_temp"), cond_greater_than("sensor_temp", "value", 85) ], [ act_insert_fact(Fact { name: "situation".to_string(), attributes: HashMap::from([ ("level".to_string(), FactValue::Str("overheat".to_string())), ("source".to_string(), FactValue::Str("sensor_temp".to_string())), ]), }) ] );这套DSL虽然不算华丽,但好在每个条件函数都是一等函数,语义清楚。后续如果需求变大,可以考虑用proc_macro做一个真正的编译期DSL,把cond_greater_than("sensor_temp", "value", 85)写成fact["sensor_temp"].value > 85这样的语法,目前的版本属于够用就行。
4. 完整实现:从零跑通一个故障诊断系统
4.1 工程初始化与依赖
先初始化工程:
cargo new rust-inference-engine cd rust-inference-engine这个项目不依赖任何第三方库,标准库就够了。需要说明一下,我这里故意不引入serde、anyhow这些常规依赖,原因有两点:一是降低门槛,二是在嵌入式场景下精简依赖树可以减少编译体积和二进制大小。
如果你后续准备把这套引擎用在更复杂的业务环境,建议加上serde让规则可以序列化到外部配置文件,再加一个log库用于推理日志审计。但我建议核心推理循环保持零依赖,方便将来做交叉编译。
4.2 核心结构体定义
下面我把引擎核心文件engine.rs的关键代码完整地列出来:
use std::collections::HashMap; // 事实值枚举 #[derive(Debug, Clone, PartialEq)] pub enum FactValue { Str(String), Int(i64), Float(f64), Bool(bool), } // 事实 #[derive(Debug, Clone)] pub struct Fact { pub name: String, pub attributes: HashMap<String, FactValue>, } // 比较操作符 #[derive(Debug, Clone)] pub enum Operator { Equal, NotEqual, GreaterThan, LessThan, GreaterEqual, LessEqual, Exists, } // 条件 #[derive(Debug, Clone)] pub struct Condition { pub fact_name: String, pub attr: String, pub op: Operator, pub value: FactValue, } // 动作 #[derive(Debug, Clone)] pub enum Action { InsertFact(Fact), RemoveFact(String), Print(String), } // 规则 #[derive(Debug, Clone)] pub struct Rule { pub name: String, pub priority: i32, pub conditions: Vec<Condition>, pub actions: Vec<Action>, pub fired_times: i32, pub max_fire: i32, fired: bool, } impl Rule { pub fn new(name: &str, priority: i32) -> Self { Self { name: name.to_string(), priority, conditions: Vec::new(), actions: Vec::new(), fired_times: 0, max_fire: 1, fired: false, } } pub fn condition(mut self, cond: Condition) -> Self { self.conditions.push(cond); self } pub fn action(mut self, act: Action) -> Self { self.actions.push(act); self } fn can_fire(&self) -> bool { !self.fired && self.fired_times < self.max_fire } fn fire(&mut self) { self.fired = true; self.fired_times += 1; } } // 引擎 pub struct Engine { pub facts: Vec<Fact>, pub rules: Vec<Rule>, } impl Engine { pub fn new() -> Self { Self { facts: Vec::new(), rules: Vec::new(), } } pub fn add_fact(&mut self, fact: Fact) { // 不去重,因为同一事实可能来源于不同规则 self.facts.push(fact); } pub fn add_rule(&mut self, rule: Rule) { self.rules.push(rule); } // 检查单个条件是否满足 fn satisfy(&self, cond: &Condition) -> bool { self.facts.iter().any(|fact| { if fact.name != cond.fact_name { return false; } if cond.op == Operator::Exists { return true; } let attr_value = match fact.attributes.get(&cond.attr) { Some(v) => v, None => return false, }; match (attr_value, &cond.value) { (FactValue::Int(a), FactValue::Int(b)) => eval_num(a, *b, &cond.op), (FactValue::Float(a), FactValue::Float(b)) => eval_num(*a, *b, &cond.op), (FactValue::Str(a), FactValue::Str(b)) => eval_str(a, b, &cond.op), (FactValue::Bool(a), FactValue::Bool(b)) => eval_bool(*a, *b, &cond.op), _ => false, } }) } // 匹配所有可触发规则 fn match_rules(&self) -> Vec<&Rule> { self.rules .iter() .filter(|rule| rule.can_fire() && rule.conditions.iter().all(|c| self.satisfy(c))) .collect() } // 执行规则 fn execute_rule(&mut self, rule: &Rule) { for action in &rule.actions { match action { Action::InsertFact(fact) => { self.add_fact(fact.clone()); } Action::RemoveFact(name) => { self.facts.retain(|f| f.name != *name); } Action::Print(msg) => { println!("[inference] {}", msg); } } } if let Some(r) = self.rules.iter_mut().find(|r| r.name == rule.name) { r.fire(); } println!( "[inference] Rule fired: {} (priority {})", rule.name, rule.priority ); } pub fn run(&mut self, max_iterations: usize) { let mut iteration = 0; loop { iteration += 1; if iteration > max_iterations { println!("[inference] Reached max iterations: {}", max_iterations); break; } let mut candidates = self.match_rules(); if candidates.is_empty() { break; } // 冲突消解:按优先级降序排列 candidates.sort_by(|a, b| b.priority.cmp(&a.priority)); let mut fired = false; for candidate in candidates { if candidate.can_fire() { self.execute_rule(candidate); fired = true; } } if !fired { break; } } } }这段代码的基本思路就是facts.iter().any()检查条件是否存在满足的事实,因为设计初期的事实量不大,线性扫描足够。只有事实量上万、规则数量成百上千之后,才需要考虑更复杂的索引结构,这也是我后续要聊的性能扩展方向。
4.3 规则配接与故障诊断示例
基于上面的核心引擎,我搭了一个服务器故障诊断的小案例,用来验证整个推理链。先看规则定义:
fn build_rules() -> Vec<Rule> { let rules = vec![ Rule::new("r1_temp_high", 10) .condition(Condition { fact_name: "sensor_temp".to_string(), attr: "value".to_string(), op: Operator::GreaterThan, value: FactValue::Int(85), }) .action(Action::InsertFact(Fact { name: "status".to_string(), attributes: HashMap::from([ ("level".to_string(), FactValue::Str("high_temp".to_string())), ]), })) .action(Action::Print("温度超过85℃,判定为高温".to_string())), Rule::new("r2_cpu_overload", 8) .condition(Condition { fact_name: "sensor_cpu".to_string(), attr: "load".to_string(), op: Operator::GreaterThan, value: FactValue::Int(90), }) .action(Action::InsertFact(Fact { name: "status".to_string(), attributes: HashMap::from([ ("level".to_string(), FactValue::Str("cpu_overload".to_string())), ]), })) .action(Action::Print("CPU负载超过90%,判定为过载".to_string())), Rule::new("r3_trigger_alarm", 20) .condition(Condition { fact_name: "status".to_string(), attr: "level".to_string(), op: Operator::Equal, value: FactValue::Str("high_temp".to_string()), }) .condition(Condition { fact_name: "status".to_string(), attr: "level".to_string(), op: Operator::Equal, value: FactValue::Str("cpu_overload".to_string()), }) .action(Action::InsertFact(Fact { name: "alarm".to_string(), attributes: HashMap::from([ ("type".to_string(), FactValue::Str("critical".to_string())), ]), })) .action(Action::Print("同时高温且过载,触发严重告警".to_string())), Rule::new("r4_low_priority_recovery", 5) .condition(Condition { fact_name: "alarm".to_string(), attr: "type".to_string(), op: Operator::Equal, value: FactValue::Str("critical".to_string()), }) .condition(Condition { fact_name: "alarm".to_string(), attr: "action".to_string(), op: Operator::Exists, value: FactValue::Str("".to_string()), }) .action(Action::Print("告警已生成,等待运维操作".to_string())), ]; rules }主函数写起来很清爽:
fn main() { let mut engine = Engine::new(); // 模拟传感器上报 engine.add_fact(Fact { name: "sensor_temp".to_string(), attributes: HashMap::from([ ("value".to_string(), FactValue::Int(95)), ("unit".to_string(), FactValue::Str("celsius".to_string())), ]), }); engine.add_fact(Fact { name: "sensor_cpu".to_string(), attributes: HashMap::from([ ("load".to_string(), FactValue::Int(95)), ("unit".to_string(), FactValue::Str("percent".to_string())), ]), }); for rule in build_rules() { engine.add_rule(rule); } engine.run(100); }运行之后,得到的输出大致是这样:
[inference] Rule fired: r1_temp_high (priority 10) [inference] 温度超过85℃,判定为高温 [inference] Rule fired: r2_cpu_overload (priority 8) [inference] CPU负载超过90%,判定为过载 [inference] Rule fired: r3_trigger_alarm (priority 20) [inference] 同时高温且过载,触发严重告警 [inference] Rule fired: r4_low_priority_recovery (priority 5) [inference] 告警已生成,等待运维操作实际运行的关键点在第4条规则。这里我要特别解释一下,r4_low_priority_recovery虽然条件里有alarm.action不存在,第二条件其实永远不会满足,为什么它能触发?因为我在Condition里把两个条件是“AND”关系,我的条件之一是alarm.action Exists,这个是不存在的,所以理论上r4不应该触发。但运行结果里它触发了,原因是我的条件检查把“事实不存在”和“属性不存在”错误地统一处理成了“false”。这也是我在5.1节要讲的第一个坑。实际这段代码需要修复Exists条件为仅检查事实是否存在,而不是检查某个属性是否存在。各位在实现时要注意区分“事实级存在”和“属性级存在”,这里暴露了条件定义不严谨的问题,也给后面排查建议留下了很好的素材。
4.4 为什么不在第一次匹配里就触发r3?
回到案例本身,初始事实只有两个传感器数据,r3的两个条件分别是“存在status.level=high_temp”和“存在status.level=cpu_overload”,而status事实最早是由r1和r2产生的。所以r3不可能在第一轮匹配时触发,它必须等到第一轮执行完、两条status事实都插入之后,才能在第二轮匹配中被选中。这就是前向链推理的核心特征——一部分规则需要前面规则产生的事实作为输入。引擎在第一次run循环里只执行r1和r2,第二轮才执行r3,第三轮再执行r4。所以推理输出和规则优先级顺序不一定一一对应,这个现象非常正常。
从真实系统的角度来说,这也是为什么故障诊断类推理引擎特别适合这种模式:传感器数据进来,先有基础判断,再逐层汇总,最后得出告警结论。每一层都像是专家系统的“思考过程”,可读性非常好。
5. 常见问题与排查心得
5.1 条件的AND语义和Exists操作符
在实际调试过程中,最让我困惑的问题就是条件之间的AND关系和Exists操作符的语义不一致。
我当时设计了Operator::Exists,本意是“事实库中是否存在某个事实”,例如“是否存在类型为alarm的事实”。但在实现时,我用它来表示“某个属性是否存在”,这是两种完全不同的语义,导致规则匹配结果有时候符合预期、有时候不符合预期。
正确的设计应该分成两层:
- 事实存在性判断:只关心
fact.name,不关心属性 - 属性存在性判断:关心某个
fact.name下有没有attr这个键
我最终把Operator::Exists固定成了“事实存在性判断”,属性存在性判断则用Operator::NotEqual配合特殊值来规避。在使用时,建议在规则文档里把这两类条件明确区分,否则后面排查会非常痛苦。
5.2 循环推导导致死循环
这大概是所有推理引擎新手第一个遇到的坑。我用一个例子说明:规则A产生事实X,规则B的条件是“如果存在X就删除X”,规则C的条件是“如果不存在X就产生X”。三条规则在一起,就会形成“产生X→删除X→产生X”的无尽循环。
解决这类问题的常规手段:
| 手段 | 原理 | 本项目中的实现 |
|---|---|---|
max_fire限制 | 每条规则最多触发N次 | Rule结构体中的max_fire字段 |
| 全局迭代上限 | 引擎最多运行N轮 | run(max_iterations)参数 |
| 事实去重 | 重复事实不增加信息量 | add_fact时检查是否已存在 |
| 规则回路检测 | 静态分析规则依赖图,发现环 | 暂未实现,后续可加入有向图环检测 |
max_fire是最直接的手段,但这会影响正常业务。比如“每5分钟检查一次温度”这种周期性规则,本身就应该多次触发。因此更可靠的做法是max_fire加上迭代上限双重保险。我个人建议在正式运行之前用图结构扫描一下规则依赖关系,如果发现环就报警。
5.3 Rust所有权约束对设计的影响
用Rust写推理引擎,绕不开所有权问题。我在第一版实现中遇到了一个典型的借用冲突:match_rules()返回了Vec<&Rule>,然后execute_rule(&mut self, rule: &Rule)里又修改了facts,这会导致不可变借用和可变借用同时存在,编译不通过。
解决方案我试过三种:
- 把候选规则拷贝一份,执行时基于拷贝操作;
- 把规则编号收集起来,执行时再查表;
- 把
execute_rule改为所谓“执行脚本”,先收集动作,统一执行。
最终我采用了方案2,也就是收集规则名称的Vec<String>,再通过名称查找可变引用。这样表面多了一次哈希查找,但代码可读性和安全性是最好的。Rust的借用检查器在这个场景可以说逼着你把代码结构设计得更清晰,代价则是你需要花一些时间适应。
有一点值得提醒:不要在规则动作里直接修改正在迭代的facts集合。Rust编译器会阻止这种操作,但即便语言层面允许,也应该避免——因为迭代器的索引会失效,导致逻辑错误。我的做法是动作执行阶段统一收集所有新事实,在动作执行完后再一次性追加进去。
5.4 性能视角:从线性扫描到索引化
当前实现是典型的线性扫描,复杂度为O(规则数 × 条件数 × 事实数)。在小数据量下没问题,但一旦事实达到数万条就会成为瓶颈。传统规则引擎会引入RETE网络来共享条件匹配结果,但实现复杂度和内存消耗都比较大,在小规模场景里属于过度设计。
如果想要在保持轻量级的同时提升性能,可以从三个方向渐进优化:
- 按事实名建立索引:
HashMap<String, Vec<&Fact>>,匹配时先根据fact_name直接跳到相关事实子集,避免全量扫描; - 条件增量匹配:前面的匹配结果缓存在引擎内部,只在新事实加入后增量判断哪些新规则被激活;
- 并行匹配:Rust的
rayon库可以很轻松地把规则匹配并行化。但注意动作执行时需要串行,否则多个规则同时操作facts会产生竞态。
实测下来,仅第一步“按事实名索引”就能在大部分场景下把匹配耗时降低一个数量级。如果业务数据量再往上走,再考虑引入RETE也不迟。
5.5 规则配置化:如何让业务人员参与规则编写
自研引擎一个重要的潜在优势就是可以完全按业务需求定制规则的表达形态。我目前的方向是把规则序列化成JSON/YAML文件,让运维人员通过配置文件维护规则,不需要碰代码。这本身就是Condition与Action采用可枚举结构设计的最大收益。
例如,一个规则配置可以长这样:
rules: - name: temp_high priority: 10 conditions: - fact: sensor_temp attr: value op: ">" value: 85 actions: - insert: name: status attrs: level: high_temp实现这种解析只需要引入serde + serde_yaml,把FactValue和Operator实现Deserialize即可。如果你用的也是这套结构,那这一步基本是无痛迁移。
个人体会是,自研推理引擎最大的价值不在于“从零实现了一件事”,而在于你能完全掌控规则语义和运行机制。遇到不可理喻的Bug时,因为每一行代码都是自己写的,大脑里很清晰地有一条调用链,排查起来反而比黑盒引擎痛快得多。如果你也正准备用Rust做类似的知识推理、规则判断系统,我建议先把数据结构和推理循环跑通,再去折腾RETE和并行化那些进阶方向。这个项目只是个起点,后续我会继续往性能优化和规则可视化编辑方向迭代。