第二部 原理篇:规则驱动的智能体设计

第 11 章:规则引擎:业务正确性的实现机制

11.1 工程问题的本质:规则从哪来、改得动、挡得住

规则驱动的前提是规则本身可靠。三个问题决定规则体系的可信度:规则从哪来(业务逻辑与代码分离,还是散落各处)、规则改得动吗(修改要发版还是改配置)、规则挡得住吗(能否被角色覆盖、被模型绕过、被训练篡改)。

设计原理: 规则体系的核心矛盾是表达能力与可控能力的平衡。规则要有足够的表达力覆盖业务逻辑,又不能有任意能力(否则规则即代码、可训练即可注入)。

框架的解法是两层分离:动作能力不可训练(内置 action 集合为 frozenset,运行时不可修改),动作组合可训练(engine_steps 步骤序列存配置,经审批修改)。规则可以训练逻辑,但不能训练出任意代码执行能力——这是可训练的安全边界。

11.2 多态裁决:合规检查与人工决策的分离

规则结果采用三态:pass(通过)/ warn(警告,需审批可继续)/ block(阻断,不可继续)。硬规则(is_hard=True)的阻断不可被任何角色覆盖。

原理: 三态将"合规检查"与"人工决策"分离为两个独立环节。block 是系统的最终裁决——合规检查认为不可行,不再咨询任何人;warn 是系统的建议——合规检查认为有风险,将决策权上交给审批链(第 13 章)。approver_role 字段指明由谁决策,使"谁有权通过"成为规则的一部分而非临时决定。

11.3 数据驱动规则:配置即治理

普通规则逻辑以 engine_steps(JSON 步骤序列)存储在数据库,规则参数(阈值、折扣上限、审批金额线)同样存配置。引擎按步骤顺序执行,任何步骤异常默认返回 block(安全优先)。

原理: 数据驱动规则的意义不在"少写代码",而在把规则的变更纳入治理。规则逻辑是业务事实的一部分,业务事实的变更应当可审计、可回滚、可审批。将规则从代码移入配置,变更路径就从"发版"变为"审批后热更新"——变更记录落在业务表而非 git 历史,审批责任落在审批链而非代码评审。

失败防范: 引擎执行异常默认 block(fail-closed)。这是刻意的选择:规则引擎不确定时,宁可阻断业务,也不静默放行。放行的错误是"一次违规操作",阻断的错误是"一次请求被拒"——前者成本远高于后者。

11.4 沙箱:表达能力的边界

表达式求值在受限沙箱中执行(AST 白名单):禁止 eval/exec/import/任意函数调用,字符串方法白名单仅对 str 实例开放,属性访问拒绝私有与魔术属性(堵 __class__.__bases__ 属性链逃逸)。

原理: 沙箱回答"规则能表达什么、不能表达什么":规则可以比较、取值、分支、汇总——即业务逻辑;规则不能导入模块、执行代码、访问内部对象——即系统能力。可训练的是业务判断,不可训练的是系统权限。 这一边界是"配置可训练"不至于演变为"配置即漏洞"的保障。

11.5 规则引擎的经典理论与 DSL 设计

11.5.1 执行模型:前向链与步骤序列

经典规则引擎(产生式系统)以前向链(forward chaining)执行:工作记忆(working memory)中的事实反复触发规则,直到没有新事实产生。前向链适合"事实驱动、规则间有级联依赖"的场景,代价是执行过程难以预测(规则触发顺序影响结果)。

本框架选择显式步骤序列(engine_steps 顺序执行)而非前向链,这是刻意的工程取舍,见表 11-1:

表 11-1 前向链与显式步骤序列对比

维度 前向链(产生式) 显式步骤序列
执行可预测性 低(触发顺序影响结果) 高(按序执行)
调试难度 高(需追踪规则触发) 低(逐步可断)
表达力 高(隐式级联) 中(显式编排)
适合场景 复杂推理、专家系统 业务裁决、流程化校验

原理: 业务规则引擎的首要需求是可预测与可审计,而非表达力最大化。显式步骤序列牺牲了隐式级联的表达力,换取"每一步执行什么、产出什么"完全确定——这与第 8 章"可信结果 = 生成 × 裁决"的裁决确定性要求一致。RETE 算法优化的是前向链的匹配效率,在业务规则(数十条、级联有限)场景中,序列化的确定性价值高于匹配效率[36]。

11.5.2 DSL 设计:语法受限与静态校验

规则 DSL(engine_steps)的工程要点是"语法受限 + 静态可校验":

原理: DSL 的价值不在"语法漂亮",而在把非法规则挡在运行时之外。运行时才暴露的规则错误是"配置型故障"——难定位、影响面大;提交时被拒的规则错误只是"一次编辑失败"。静态校验前置是配置治理的第一道闸门(第 14 章热更新之前)。

11.5.3 裁决路径的选择框架:规则 vs 决策表 vs 模型

业务裁决并非一律用规则。三类路径的适用边界,见表 11-2:

表 11-2 规则与模型裁决路径的选择

路径 适用场景 不适用的场景
规则(if-then/engine_steps) 决策条件明确、数量有限、需可解释 条件组合爆炸(需决策表/规则表)
决策表/查找表 条件维度多但枚举有界(折扣率×客户等级) 条件无界(自由文本判定)
模型裁决 无显式规则、需泛化(语义相似判断) 需确定性、需审计(不可解释)

原理: 选择框架的依据是"决策是否可枚举、是否需要解释"。可枚举且有界 → 表驱动;可描述但复杂 → 规则引擎;不可描述 → 模型。先问可解释性需求,再选实现路径——审计需求为刚性约束时,模型裁决仅在无替代时引入,且须以 warn(人工复核)而非 block 形态参与(第 12 章审核链第 5 层)。

11.6 失败模式与权衡

表 11-3 规则引擎的失败模式与权衡

失败形态 根因 设计应对
规则校验静默放行 注册表为空、规则未注册 进程级共享注册表 + 硬编码安全铁律
沙箱逃逸 属性链访问魔术对象 AST 白名单 + 私有属性拒绝
规则逻辑漂移 双份实现(引擎与回退 check) 引擎优先、回退兼容、参数签名适配
配置更新不生效 缓存未失效 进程级缓存 + 代次校验 + 热更新

权衡: 数据驱动换取可控性的代价是调试难度上升——规则错误不再表现为代码堆栈,而是步骤序列的失败。应对:引擎记录逐步执行日志与规则名,失败信息携带规则标识,将"配置型故障"的排查锚点还给规则本身。

11.7 规则即数据:规则表本身的可训练化与审批链

第 11.3 节把"规则的逻辑"落进 engine_steps;本节的递进是——规则表本身也可训练。

系统的识别规则(intent-rules)、子意图序列(SUB-INTENT-DEFS)、槽位口径(SLOT-DEFS)与单号规格(ID-SPECS)统一落在 business_rules 表,由同名 rule_id 区分,识别/路由/训练各环从该表读取并与之对齐。

设计原理: "规则即数据"的意义是把规则的定义权交给业务侧。规则存 DB 后,修改路径从"改代码 + 发版"变为"改配置 + 审批 + 热生效":提交变更写入待审批提案(如 submit_id_specs_change(proposed, current) 落到 approval_proposals),经 L2 审批链推进(advance_training_approval),审批通过后 apply_…_change 更新 config_json 并使缓存失效强制刷新(invalidate_cache)。

这一链条保证:代码只提供框架语义,业务行为由受治理的数据决定——与 11.1 节"动作能力不可训练、动作组合可训练"的安全边界一致。

失败防范: DB 配置带 rule_hash 完整性校验(加盐 SHA256),被篡改或与期望不符时 fail-closed 拒绝加载、回退硬编码兜底,防止"可训练"退化为"可注入"。规则的编辑与生效分离,编辑不越过审批门。

把"规则即数据"落成表族关系,可看清"一张表 + 一个变更提案表"如何支撑全部可训练对象,如图 11-1 所示:

图 11-1 "规则即数据"的表族关系

erDiagram
    business_rules ||--o{ approval_proposals : "rule_id 关联变更提案"
    business_rules {
        string rule_id PK
        string rule_type
        text config_json
        string rule_hash
    }
    approval_proposals {
        int id PK
        string rule_id FK
        text proposed
        text current
        string status
        string approver
    }

原理: 表族只有两张核心表,却覆盖了全部可训练对象——business_rules 以 rule_id 作判别键,同一张表承载识别规则(intent-rules)、子意图序列(SUB-INTENT-DEFS)、槽位口径(SLOT-DEFS)与单号规格(ID-SPECS);approval_proposals 以 proposed / current 一对字段记录"变更前后",使每次训练都可回滚、可审计。

新增一类可训练对象只需新增一个 rule_id,不改表结构——这正是 11.3 节"配置即治理"在数据模型上的体现,也是 14.3 节"一切皆可训练"能够成立的结构前提。

11.8 实现剖析:三态裁决与沙箱在引擎中的落地

本节从 runtime/rule_engine.py(1,071 行)摘取三段核心代码,分别印证 11.2 节的三态裁决、11.1 节的动作白名单与 11.4 节的沙箱边界。代码为便于排版做了轻度压缩,省略处统一以 # ... 标注。

11.8.1 三态裁决:终态动作与 is_hard

规则引擎的执行模型是"步骤序列 + 终态动作":非终态动作(fetch/lookup/compare)只写变量并返回 None,终态动作(block/pass/warn/route_approval)立即返回裁决结果、终止后续步骤。以下摘自 execute() 与 _action_block():

def execute(self, rule_def, context, params, rule_name=""):
    try:
        # 每次执行重置变量上下文(实例级状态,非线程安全)
        self._vars, self._context, self._params = {}, context or {}, params or {}
        steps = rule_def.get("engine_steps", []) if rule_def else []

        for step in steps:
            result = self._execute_step(step)
            if result is not None:          # ★ 终态动作:立即返回,不再执行后续步骤
                if not result.rule_name:
                    result.rule_name = rule_name
                return result

        # 全部步骤执行完毕仍未触发终态 → 默认通过
        return RuleResult(status=RuleResult.STATUS_PASS, rule_name=rule_name,
                          message="规则执行完成,未触发终态动作")

    except Exception as e:
        # ★ 安全优先:任何异常都返回 block,且标记为硬阻断
        return RuleResult(status=RuleResult.STATUS_BLOCK, rule_name=rule_name,
                          message=f"规则执行异常,安全阻断: {e}", is_hard=True)


def _action_block(self, step):
    message = step.get("message", "规则拦截")
    # ★ block 恒为硬阻断(is_hard=True,安全铁律)
    #   不接受配置覆盖为软阻断(软性提示请使用 warn action)
    return RuleResult(status=RuleResult.STATUS_BLOCK, message=message, is_hard=True)

这段代码印证 11.2 节:三态分离的落点是 is_hard 与 requires_approval 两个布尔字段。block 在构造结果时硬编码 is_hard=True,把"不可覆盖"从口头约定变成运行时可检查的事实;warn / route_approval 则置 requires_approval=True 并携带 approver_role,把决策权交给审批链(第 13 章)。同时,except 分支把一切未预期异常统一转为硬阻断,即 11.3 节的 fail-closed。

11.8.2 动作白名单:frozenset 作为安全边界

class RuleEngine:
    # ★ 不可变的安全 action 集合(frozenset 确保运行时不可修改)
    BUILTIN_ACTIONS: frozenset = frozenset({
        "fetch", "lookup", "filter", "compare", "pluck",
        "to_map", "range_lookup", "set_diff", "aggregate",
        "branch", "block", "pass", "warn", "route_approval",
    })

    def _execute_step(self, step):
        action = step.get("action", "")
        if action not in self.BUILTIN_ACTIONS:      # ★ 白名单外一律拒绝
            raise ValueError(f"未知的 action: {action!r},"
                             f"允许的 action: {sorted(self.BUILTIN_ACTIONS)}")
        handler = getattr(self, f"_action_{action}", None)
        if handler is None:
            raise ValueError(f"action {action!r} 尚未实现")
        return handler(step)

本段印证 11.1 节的安全边界:动作能力不可训练。BUILTIN_ACTIONS 是 frozenset——运行时不可增删;规则定义只能"选择用哪些动作、传什么参数",无法引入新动作。新增动作必须改代码、走评审,而不能靠改配置或训练完成。这是"配置可训练"不演变为"配置即注入"的第一道闸门。

11.8.3 沙箱:AST 白名单如何堵住属性链逃逸

规则表达式先经 ast.parse(..., mode="eval") 解析,再由 _SafeExprEvaluator 逐节点求值:任何未显式实现的节点类型都被 generic_visit 拒绝。以下摘自该求值器:

def visit_Attribute(self, node):
    """属性访问:dict 支持 key 访问;对象拒绝 _/__ 私有与魔术属性"""
    value = self.visit(node.value)
    attr = node.attr
    if isinstance(value, dict):
        return value.get(attr)          # dict 的 key 访问无对象属性逃逸风险
    if attr.startswith("_"):
        raise ValueError(f"不允许访问私有/魔术属性: {attr}")   # ★ 堵属性链逃逸
    return getattr(value, attr, None)


def visit_Call(self, node):
    """函数调用:仅白名单内置函数 + 白名单字符串方法"""
    if node.keywords:
        raise ValueError("不允许使用关键字参数")
    args = [self.visit(arg) for arg in node.args]

    if isinstance(node.func, ast.Name):             # len/sum/abs/min/max/int/float/str
        if node.func.id not in self._ALLOWED_FUNCS:
            raise ValueError(f"不允许调用的函数: {node.func.id}")
        return self._ALLOWED_FUNCS[node.func.id](*args)

    if isinstance(node.func, ast.Attribute):        # 仅 str 实例可调用白名单方法
        if node.func.attr not in self._ALLOWED_STR_METHODS:
            raise ValueError(f"不允许调用的方法: {node.func.attr}")
        obj = self.visit(node.func.value)
        if not isinstance(obj, str):                # ★ 防止 obj.__class__ 属性链
            raise ValueError(f"方法 {node.func.attr} 仅支持字符串类型调用")
        return getattr(obj, node.func.attr)(*args)

    raise ValueError("仅允许调用内置函数或白名单字符串方法")


def generic_visit(self, node):
    """兜底:遇到任何未显式处理的节点类型即拒绝"""
    raise ValueError(f"不允许的语法节点: {type(node).__name__}")

这段代码印证 11.4 节:沙箱的边界由"白名单 + 兜底拒绝"共同划定。visit_Attribute 拒绝 _ 开头的属性,visit_Call 要求方法调用者必须是 str 实例,generic_visit 兜底拒绝 Import/Lambda 等一切未列举节点——三者叠加,封死了 __class__.__bases__.__subclasses__ 这类属性链逃逸路径。规则能表达的是业务判断(比较、取值、分支、汇总),不能表达的是系统能力(导入、执行、反射)。

11.9 本章小结