从猜凶手到规则引擎:用Python实现可维护的约束推理模型
2026/9/7 5:37:38 网站建设 项目流程

看到“我和斯比打赌猜凶手”这个场景,大多数人会先想到剧情解谜,而不是代码。但如果把这个场景搬到软件开发里,你会发现它精准命中了一个让人头疼的问题:线索(业务规则)一直在变,程序怎么保证自己不“猜错”?小可猜错,输掉的只是一次打赌;程序猜错,输掉的是规则变更后的正确性。

本文借用一道“猜凶手”的推理题,把问题拆成三个工程问题:无解、多解、错解。很多人以为写推理程序就是把答案硬编码到 if-else 里,真正决定代码质量的,是规则如何建模、如何验证、如何回退。文章会从纯 Python 穷举开始,逐步走到 pytest 测试和 JSON 配置化,最终讲清楚一套可维护的约束推理模型。

读完之后,你至少能解决三类问题:自己写的规则引擎为什么结果总不对;业务加了一条新线索后,为什么旧答案直接失效;以及当程序输出多个候选凶手时,应该怎么判断是信息不足还是约束冲突。整个过程围绕“猜凶手”这个小场景展开,但方法论可以直接迁移到权限规则判断、优惠策略计算、工单自动分派等任何靠规则驱动的业务系统。

1. 这篇文章真正要解决的问题

很多新手拿到“猜凶手”这类题,第一反应是写一个函数,把所有判断条件塞进去,然后返回唯一答案。表面看流程能跑通,但只要线索一变,代码就很容易陷入“改一个地方坏另一个地方”的窘境。

真正值得思考的问题是:凶手不是靠“猜”出来的,而是靠一组约束“推”出来的。推理程序要维护的不是某个具体答案,而是三样东西:

  • 有哪些嫌疑人,也就是变量的取值范围;
  • 有哪些证词,也就是断言规则;
  • 有哪些全局条件,比如“只有一个人说真话”。

这三样东西组合在一起,就构成一个约束满足问题(Constraint Satisfaction Problem,CSP)。答案只是约束系统的一个具体解,约束变,解就会变。程序要做的不是硬记答案,而是根据当前规则集实时求出候选项,并告诉调用方当前结果是否足够确定。

所以这篇文章真正要解决的问题,不是“哪个人是凶手”,而是“当规则不断变化时,怎么让推理程序保持正确、可验证、可回退”。如果你正在写优惠规则、权限策略、工单分派逻辑,或者任何带有“条件组合判断”的业务代码,这篇文章里的方法都值得参考。

2. 基础概念:把“猜凶手”建模成约束满足问题

约束满足问题听起来很学术,其实理解起来并不复杂。一次推理中,你会面对几个要素:

CSP 要素猜凶手场景中的对应
变量凶手是谁
值域嫌疑人列表
约束每个证词的真假条件
全局约束真话数量恰好为 1
满足所有约束的凶手候选

这里有两个容易混淆的概念:证词和约束。证词是某个人说的一句话,比如“凶手是王五”,这句话在代码里是一个布尔表达式,它会根据凶手候选值返回 True 或 False。全局约束则是关于整组证词的限定条件,比如“四个人中只有一个人说真话”,它描述的是证词真值表的统计结果,而不是某一个人的单独断言。

为什么不直接写 if-else?因为 if-else 相当于把最终决策树写死了。每加一条新线索,你都要人工判断它会影响哪些分支,改起来成本很高。而约束模型把“事实产生规则,规则产生候选集”的链路拆开,新增线索只需要追加规则,候选集由求解器重新计算,不需要人脑模拟完整推理过程。

还需要理解“多解”这件事。很多非技术背景的人认为推理题一定有唯一答案,但在工程上,唯一解往往不是默认状态。信息不足时会有多个候选,规则互相矛盾时甚至一个候选都没有。这种情况不是 bug,而是模型如实反映了当前规则集的状态。合格的推理程序应该把这个状态明确暴露出来,而不是强行挑一个答案。

3. 环境准备与题目设定

本章开始进入实操。为了保持知识通用性,我使用 Python 标准库完成主要代码,只有测试部分会用到 pytest。版本请以你自己机器的实际环境为准,推荐使用 Python 3.8 及以上版本。

先创建项目目录,并准备基础环境:

mkdir guess-killer cd guess-killer python -m venv venv # Windows: venv\Scripts\activate # Linux/macOS: source venv/bin/activate pip install pytest

题目使用自定义构造版,保证约束条件足够简单、结果可验证。四位嫌疑人分别为张三、李四、王五、赵六,证词如下:

  • 张三说:凶手不是张三;
  • 李四说:凶手是王五;
  • 王五说:凶手是赵六;
  • 赵六说:李四在撒谎。

全局条件是:四个人中只有一个人说真话。

这个版本的题目故意绕开一些容易产生歧义的场景。赵六说“李四在撒谎”,本质是“李四说的那句话是假的”,也就是“凶手不是王五”。因此四条证词可以直接写成四个谓词函数,每个函数接收凶手作为入参,返回该证词的真假值。

题目本身并不是重点,重点是它足够小、足够清晰,适合用来演示约束模型。后文会反复修改这些证词,观察候选集如何变化,这才是规则系统真正复杂的地方。

4. 第一版求解器:穷举候选凶手

先写一个通用求解函数。它的思路很简单:遍历所有嫌疑人,依次判断每个嫌疑人在当前证词下是真话还是假话,然后统计真话数量,符合全局约束的就加入候选集。

# 文件路径:suspects/solver.py def solve(suspects, statements, must_true_count=1): """ 穷举所有嫌疑人,找出真话数满足条件的候选凶手。 :param suspects: 嫌疑人列表 :param statements: 证词谓词列表,每个元素是接收 killer 参数的函数 :param must_true_count: 要求为真的证词数量 :return: 列表,元素为 {"killer": 名字, "truth_table": 证词真假表} """ candidates = [] for killer in suspects: truth_table = [bool(stmt(killer)) for stmt in statements] if sum(truth_table) == must_true_count: candidates.append({"killer": killer, "truth_table": truth_table}) return candidates if __name__ == "__main__": suspects = ["张三", "李四", "王五", "赵六"] statements = [ lambda killer: killer != "张三", # 张三说:凶手不是张三 lambda killer: killer == "王五", # 李四说:凶手是王五 lambda killer: killer == "赵六", # 王五说:凶手是赵六 lambda killer: killer != "王五", # 赵六说:李四在撒谎 ] for item in solve(suspects, statements, must_true_count=1): print(item)

运行这段代码:

python suspects/solver.py

预期输出:

{'killer': '张三', 'truth_table': [False, False, False, True]}

结果符合预期:唯一候选是张三,四条证词只有赵六的话为真。因为赵六说“李四在撒谎”,而李四说“凶手是王五”,当凶手是张三时,李四的话确实是假话,赵六判断正确。

第一版代码只有 20 行左右,看起来很简洁,但它有一个隐患:所有规则都写在 Python 文件里。业务人员以后想加一条“案发时间在晚上八点”的线索,就要修改代码、提交发布、重新部署。这个过程中只要有一个证词方向写反,整个推理结果就会翻车。下一章会演示这种“翻车”具体长什么样。

5. 从“猜错”看推理系统的三种异常

当程序输出结果不符合预期时,第一反应不应该是“程序有大 bug”,而要按照候选集的状态分层判断。我把常见异常归纳为三种:无解、多解、错解。

无解,指候选集为空。这说明当前约束之间互相矛盾。比如把全局约束改成“真话数量为 0”,而四条证词无论凶手是谁都不可能同时为假,那么程序就会返回空列表。工程上的含义是:你新增的规则和已有规则冲突,需要回到约束层面排查。

多解,指候选集大于等于两个。这说明信息不足,现有规则还不足以把凶手唯一确定。比如把全局约束从“1 句真话”改成“2 句真话”,候选就会变成李四和王五。这种情况在业务系统中非常常见,正确做法是明确告诉调用方“当前信息不足”,而不是随机返回一个结果充数。

错解,指程序自洽但结果与事实不符。这往往是证词方向写反导致的,比如把赵六的话写成“凶手是王五”,而不是“凶手不是王五”。程序不会报错,因为规则内部是自洽的,但推理结果已经偏离真实场景。这种问题最难排查,因为没有任何异常信号。

下面这个辅助函数可以帮你快速判断当前规则集的整体状态:

# 文件路径:suspects/inspect.py def inspect(suspects, statements, must_true_count=1): """ 打印每个嫌疑人的真话分布,并标出当前候选。 """ for killer in suspects: truth_table = [bool(stmt(killer)) for stmt in statements] truth_count = sum(truth_table) mark = " <== 候选" if truth_count == must_true_count else "" print(f"{killer}: {truth_table} 真话数={truth_count}{mark}") if __name__ == "__main__": suspects = ["张三", "李四", "王五", "赵六"] statements = [ lambda killer: killer != "张三", lambda killer: killer == "王五", lambda killer: killer == "赵六", lambda killer: killer != "王五", ] print("=== 全局条件:恰好 1 句真话 ===") inspect(suspects, statements, must_true_count=1) print("\n=== 全局条件:恰好 2 句真话 ===") inspect(suspects, statements, must_true_count=2)

运行后会看到清晰的对照:

=== 全局条件:恰好 1 句真话 === 张三: [False, False, False, True] 真话数=1 <== 候选 李四: [True, False, False, True] 真话数=2 王五: [True, True, False, False] 真话数=2 赵六: [True, False, True, True] 真话数=3 === 全局条件:恰好 2 句真话 === 张三: [False, False, False, True] 真话数=1 李四: [True, False, False, True] 真话数=2 <== 候选 王五: [True, True, False, False] 真话数=2 <== 候选 赵六: [True, False, True, True] 真话数=3

这个函数的价值在于,它把候选集变化变成可观测的信息。当规则变更后,只需要跑一次 inspect,就能立刻看出是约束过严导致无解,还是约束过松导致多解。

5.1 证词方向写错,程序依然自洽

再演示一次“错解”。假设赵六的真实证词仍然是“李四在撒谎”,但代码写成了“凶手是王五”:

statements_wrong = [ lambda killer: killer != "张三", lambda killer: killer == "王五", lambda killer: killer == "赵六", lambda killer: killer == "王五", # 写错了! ] for item in solve(suspects, statements_wrong, must_true_count=1): print(item)

程序会正常输出:

{'killer': '李四', 'truth_table': [True, False, False, False]}

程序逻辑没有任何异常,但答案已经不是真实的“张三”了。这就是错解的危险性:结果自洽,却不符合事实。后文会讲到,应对错解最有效的手段不是靠人肉 review,而是把关键证词固化成自动化测试。

6. 用 pytest 固化推理规则

规则系统最容易出现的问题是“改一处崩一片”。加了新线索后,旧的唯一解可能变成多解,多解也可能变成无解。如果没有测试保护,你很难判断这个变化是预期内的规则演进,还是误操作导致的回归。

pytest 在这里的价值不是测试求解函数本身,而是测试“规则集的行为是否符合预期”。把当前题目对应的证词、全局约束和期望候选集写进用例,以后每次修改规则,只要运行一遍测试,就能立即发现行为变化。

# 文件路径:tests/test_solver.py from suspects.solver import solve SUSPECTS = ["张三", "李四", "王五", "赵六"] STATEMENTS_V1 = [ lambda killer: killer != "张三", lambda killer: killer == "王五", lambda killer: killer == "赵六", lambda killer: killer != "王五", ] def test_single_candidate_when_one_truth(): result = solve(SUSPECTS, STATEMENTS_V1, must_true_count=1) assert [item["killer"] for item in result] == ["张三"] def test_two_candidates_when_two_truth(): result = solve(SUSPECTS, STATEMENTS_V1, must_true_count=2) assert [item["killer"] for item in result] == ["李四", "王五"] def test_order_stable(): # 候选集顺序应该稳定,避免前端展示时出现随机跳动 result = solve(SUSPECTS, STATEMENTS_V1, must_true_count=2) killers = [item["killer"] for item in result] assert killers == killers # 实际项目中可改成与历史基线快照对比

运行测试:

pytest tests/ -v

预期输出:

tests/test_solver.py::test_single_candidate_when_one_truth PASSED tests/test_solver.py::test_two_candidates_when_two_truth PASSED tests/test_solver.py::test_order_stable PASSED

这里最关键的一点是:测试用例名字要能表达业务含义。比如test_single_candidate_when_one_truthtest_case_1更有利于后续维护。当你的规则系统积累了上百条线索,测试名就是最好的规则文档。

第 7 章会引入配置化,让测试可以直接读取 JSON 规则包。这样测试就从“验证一段 Python 函数”变成“验证一个业务规则版本”,可维护性会进一步提升。

7. 线索配置化:把规则从代码里拆出来

如果只有程序员能修改证词,推理系统的迭代速度一定跟不上业务变化。更合理的方式是:把规则内容从代码中拆出去,用 JSON 或 YAML 描述案件结构,代码只负责解释和执行。

先定义规则配置的 JSON 格式。每条证词包含说话人speaker和断言if_trueif_true支持两种基本操作:

  • killer_is:凶手是指定嫌疑人;
  • killer_not:凶手不是指定嫌疑人。
// 文件路径:configs/case_a.json { "suspects": ["张三", "李四", "王五", "赵六"], "statements": [ {"speaker": "张三", "if_true": {"killer_not": "张三"}}, {"speaker": "李四", "if_true": {"killer_is": "王五"}}, {"speaker": "王五", "if_true": {"killer_is": "赵六"}}, {"speaker": "赵六", "if_true": {"killer_not": "王五"}} ], "truth_count": 1 }

然后写一个解释器,把 JSON 里的断言翻译成可执行的谓词函数:

# 文件路径:suspects/rule_engine.py import json class GuessKillerEngine: def __init__(self, config_path): with open(config_path, "r", encoding="utf-8") as f: self.config = json.load(f) self.suspects = self.config["suspects"] self.truth_count = self.config.get("truth_count", 1) self.statements = [ self._build_predicate(s) for s in self.config["statements"] ] @staticmethod def _build_predicate(statement): rule = statement["if_true"] if "killer_is" in rule: target = rule["killer_is"] return lambda killer: killer == target if "killer_not" in rule: target = rule["killer_not"] return lambda killer: killer != target raise ValueError(f"无法识别的断言: {rule}") def solve(self): result = [] for killer in self.suspects: truth_table = [bool(stmt(killer)) for stmt in self.statements] if sum(truth_table) == self.truth_count: result.append({"killer": killer, "truth_table": truth_table}) return result if __name__ == "__main__": import sys path = sys.argv[1] if len(sys.argv) > 1 else "configs/case_a.json" engine = GuessKillerEngine(path) for item in engine.solve(): print(item)

运行方式:

python -m suspects.rule_engine configs/case_a.json

预期输出:

{'killer': '张三', 'truth_table': [False, False, False, True]}

这时再修改测试用例,让它直接读取 JSON 配置,而不是复制 Python 代码里的函数。这样每条线索的变更都变成了“改配置 + 跑测试”,与业务人员协作会更顺畅。

# 文件路径:tests/test_rule_engine.py from suspects.rule_engine import GuessKillerEngine def test_engine_loads_json_case(): engine = GuessKillerEngine("configs/case_a.json") result = engine.solve() assert [item["killer"] for item in result] == ["张三"]

配置化的另一个好处是回退成本极低。线上发现某条新线索影响了候选集,你不需要重新发布代码,只需要把配置回退到上一个稳定版本。如果配合 Git 管理,甚至能精确看到每条线索是在哪个 commit 引入的,排查效率会提高很多。

8. 常见问题与排查思路

推理类规则系统的问题,很多并不是出在算法上,而是出在规则模型和调用方的认知错位上。下面整理了几个高频问题:

问题现象可能原因排查方式解决方案
搜索结果为空多条约束互相矛盾打印每个嫌疑人的证词真假表,定位第一步失败的约束检查新增线索是否与既有事实冲突,必要时回退规则包
结果出现多个候选线索信息不足查看候选集数量及真话分布明确输出“多解”,补充有区分度的约束
结果与真实事实不符证词谓词方向写反复核每条证词对应的 JSON 断言为关键证词添加独立测试用例
修改线索后旧用例失败规则变更导致既有行为变化运行 pytest 查看失败用例确认行为变化是否符合预期,是则同步更新用例
配置无法加载JSON 编码或格式问题查看异常堆栈使用 UTF-8 编码,加载前用 JSON 校验工具检查
候选集输出顺序不稳定集合或字典遍历顺序不确定固定遍历结构,使用列表对候选集按固定字段排序

8.1 从“无解”定位冲突规则

无解是最容易让新手慌乱的场景,但解决思路很固定。先把所有嫌疑人的真话分布打印出来,你会看到每个候选都卡在哪一条证词上。然后从最近修改的历史记录里找到冲突来源,通常就是两条约束不能同时为真。

举例来说,如果新增一条全局约束“凶手有不在场证明的人必须排除”,但嫌疑人的不在场证明数据和证词本身冲突,就会出现无解。此时正确的做法不是强行调整某条证词,而是回到业务侧确认:这个“不在场证明”字段是否可靠?它与已有证词是否来自同一信息源?

8.2 从“多解”判断信息价值

多解并不代表程序有问题,它说明当前线索还不足以区分所有嫌疑人。此时每新增一条约束,可以观察候选集数量是否减少。如果一条新线索加入后,候选集完全没变,说明这条线索的信息价值很低,对推理没有区分度。

这个思路可以延伸成一套“线索价值评估”流程,在业务系统中特别实用。每次新增规则前,先计算规则对候选集的剪枝效果,能帮助团队避免无效配置堆砌。

9. 最佳实践、总结与后续学习方向

推理程序的可维护性,不取决于穷举逻辑写得多漂亮,而取决于规则变化时,系统能不能用最小代价说明“答案为什么变了”。围绕这个目标,建议从以下几点入手。

第一,把事实、证词、全局约束分开管理。事实是嫌疑人的基本信息,证词是每个人说的话,全局约束是证词数量和条件组合的限制。三者不要混在同一个配置段里,否则后续修改会越来越难。

第二,用候选集数量作为规则健康度指标。候选集为 0 是异常,候选集为 1 是确定状态,候选集大于 1 是信息不足。每次规则变更,都对比变更前后的候选集状态并记录到日志中。

第三,规则配置一定要版本化。把 JSON 配置放进 Git,和代码一起走评审、测试、发布流程。线上出现问题后,优先回退规则包,而不是回退代码。要保证运行时加载规则包有完整校验,比如 JSON Schema 校验、嫌疑人名称合法性校验。

第四,不要为了追求唯一解而硬编码答案。推理系统的职责是如实描述当前规则集下的可选范围,如果信息不足,就把多解状态暴露给上层调用方,由业务人员决定是否补充线索。强行返回一个答案,只会掩盖规则缺失问题。

第五,善用自动化测试保护关键规则。每新增一条线索,至少为一个“应当成立”和一个“不应当成立”的场景补充测试。这样即使规则数量膨胀,回归风险也能控制在可接受范围内。

后续如果想挑战更大规模的约束求解,可以了解 SMT 和 SAT 求解器的基本思路。本文的手写穷举适合小范围场景,一旦嫌疑人数量增长到几百甚至上千,就需要把约束传播、剪枝、启发式搜索等技术引入进来。到那时你会发现,核心建模思想仍然一致:变量、值域、约束,三者清晰分离,系统才具备可演进性。

建议先把本文的完整工程在本地跑通,然后自己动手改几条证词,观察候选集的变化。只有亲手制造一次“无解”和“多解”,才能真正理解为什么规则建模比穷举答案更重要。收藏备用只是第一步,把它用进你负责的业务系统,才会看到约束模型带来的长期收益。

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

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

立即咨询