从剧本杀赌局到代码:设计一个可追溯的错误归因判定引擎
2026/9/7 8:36:12 网站建设 项目流程

如果把一个剧本杀赌局写进代码,判定规则要怎么设计?

上周朋友群里出现了一条有点绕的消息:“我和斯比打赌猜凶手,小可猜错了的话那斯比呢?”乍看像一句闲聊,但它其实是一个很典型的工程问题:两个参与者拿到不同线索,分别做推理;第三方情报源负责补充信息;当情报源本身出错时,下游决策者到底要不要为错误负责?

这个场景并不只在游戏里出现。推荐系统里,上游特征错误、算法排序再正确也会推荐偏;AI Agent 链路里,工具返回了脏数据,模型再怎么编排也可能得出错误结论;数据分析链路里,数仓字段口径错了,下游报表自动跟着错。现实中我们经常吵“锅到底是谁的”,但更工程化的做法是:先定义清楚判定规则,再把“错误归因”落到代码里。

这篇文章要做一个很小的推理判定引擎。我们把“我和斯比”设计成两个并行推理分支,把“小可”设计成提供线索的第三方来源,然后回答三个问题:

  1. 怎样才算猜对?需要一套事实基准。
  2. 猜错了算谁的?需要区分“上游线索错误”和“推理逻辑错误”。
  3. 小可给错情报时,斯比应不应该被算作“猜错”?要用可追溯的判定逻辑解决。

读完之后,你可以照着写出一个小型判定系统,也能把这个思路套用到 Agent 调用链、接口数据校验和规则引擎设计中。

1. 一个打赌局,为何能翻译成系统设计

先还原一下场景。假设有个剧本杀模拟器,案发现场有三位嫌疑人:

  • 管家,掌握庄园钥匙,熟悉所有房间。
  • 厨娘,负责三餐,厨房活动频繁。
  • 园丁,近期在西区花圃工作。

系统给我们两个玩家各分配线索,我拿到两条,斯比拿到三条。小可扮演“情报中间人”,给斯比补了一条信息。现在我和斯比打赌:谁先猜出真凶。

如果只停留在故事层,这场赌局根本没法判。因为:

  • 两个人看到的线索不一样。
  • 情报源本身的准确性没有约束。
  • 猜错之后,到底是因为“线索太少”,还是因为“线索本身是假的”,还是因为“推理方法不对”,没有人说得清。

但把问题翻译成系统设计,就很清晰了:

  • “凶手”是一个预先设定好的真相,也就是事实基准。
  • “线索”是带权重的输入特征。
  • “我”和“斯比”是两条独立的推理服务,输入不同特征后输出候选结论。
  • “小可”是第三方数据源。
  • “裁判”是一个仲裁模块,负责判断每条推理结论是否与事实基准一致,并给出错误原因。

进一步看,这里有一个更有价值的点:小可的情报如果被标记为无效,那么斯比因为用了这条情报而猜错,应该被归类为“上游数据错误”,而不是“推理逻辑错误”。这和我们平时排查线上故障时的思路完全一样:先看数据是否可信,再看逻辑是否正确。

所以这篇文章真正要解决的不是“谁是凶手”,而是“当多个推理分支和一个可选上游情报源共同工作时,如何设计一套可追溯、可归因的判定系统”。

2. 核心概念:推理分支、可信线索与错误归因

在写代码之前,先把领域里的几个概念说清楚。这套小系统中一共包含五类对象,它们与真实业务场景的对应关系如下:

核心概念故事里的角色系统里的含义
Suspect 嫌疑人管家、厨娘、园丁候选结果集合
Clue 线索现场勘察、证人口供、花房记录输入特征
Detective 侦探我、斯比消费线索并产生结论的推理分支
Arbiter 仲裁器裁判负责比对结论与事实基准
Verdict 裁决最终判定带有错误归因的结果对象

2.1 嫌疑人:候选结果集合

在每个推理问题里,候选结果必须是有限且明确的。如果候选集合不固定,仲裁器就无法判断“猜对了”。所以在系统初始化时,我们首先要定义一份完整名单。

2.2 线索:带权重、带来源、带有效性的输入特征

线索是本系统的核心对象。它不只是一段文本,还应该包含这些字段:

  • 所属嫌疑人。
  • 关键字,用于描述线索内容。
  • 权重,代表这条线索对推理结果的影响程度。
  • 来源,用于事后追溯,比如“现场勘察”“证人口供”“小可情报”。
  • 有效性标记,用来表示上游数据是否可信。

权重设计是这个推理引擎的关键。某条线索如果指向管家的钥匙,且来自现场勘察,权重可以高一点;某条线索只是“夜半人影”,没有明确身份,权重就应该低一点。权重本质上表达的是线索的置信度。

2.3 侦探:一个基于线索打分的选择器

侦探本身并不需要做复杂的自然语言理解。我们可以用最朴素的方式实现:为每个嫌疑人累计线索权重,得分最高的人就是侦探的猜测结果。这也是很多投票类、评分类系统的通用做法。

侦探需要支持两种推理模式:

  • 全量推理:包含所有线索,不管有效性。
  • 干净推理:只使用有效线索。

为什么要区分这两种模式?因为在仲裁阶段,我们需要对比“包含无效线索的猜测”和“剔除无效线索后的猜测”。这个对比正是错误归因的依据。

2.4 仲裁器与裁决:判断对错,并归类错误

仲裁器拿着真实的凶手名单,逐一检查每个侦探的猜测结果,输出裁决。裁决里至少要包含这些信息:

  • 玩家名称。
  • 真实答案。
  • 原始猜测结果。
  • 剔除无效线索后的猜测结果。
  • 是否猜对。
  • 是否使用了无效线索。
  • 错误类型。

错误类型是本系统最有价值的部分。我们根据“原始猜测是否错误”“是否使用了无效线索”“剔除无效线索后是否猜对”三个维度,把错误分成几类,详见第 5 节。

3. 环境准备与项目结构

本教程尽量保持简单,不依赖任何第三方库,只要本机有 Python 3.9 及以上版本即可运行。

mystery/ ├── core.py # 领域模型:嫌疑人、线索、侦探 ├── arbiter.py # 仲裁逻辑:裁决对象、仲裁器 └── demo.py # 演示脚本:构造线索,执行判定

创建目录:

mkdir mystery cd mystery

后续代码都放在mystery目录下。如果你用的是虚拟环境,可以顺手建一个,但不是必须的:

python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate

整个过程只需要标准库dataclassescollectionstyping,这部分是 Python 内置能力。

4. 代码实现:领域模型

先从领域模型开始。新建core.py,代码如下:

# mystery/core.py from collections import defaultdict from dataclasses import dataclass, field from typing import Dict, List, Tuple @dataclass(frozen=True) class Suspect: name: str brief: str @dataclass(frozen=True) class Clue: clue_id: str suspect_name: str keyword: str weight: float source: str valid: bool = True @dataclass class Detective: player_name: str clues: List[Clue] = field(default_factory=list) def add_clue(self, clue: Clue) -> None: self.clues.append(clue) def infer(self, include_invalid: bool = False) -> Dict[str, float]: """按嫌疑人累计线索权重,得到推理得分表""" scores: Dict[str, float] = defaultdict(float) for clue in self.clues: if not include_invalid and not clue.valid: continue scores[clue.suspect_name] += clue.weight return dict(scores) def top_guess(self, include_invalid: bool = False) -> Tuple[str, float]: """返回得分最高的嫌疑人名字和分数""" scores = self.infer(include_invalid=include_invalid) if not scores: return "未定", 0.0 best_name, best_score = max(scores.items(), key=lambda item: item[1]) return best_name, best_score

这里有几个设计点需要解释。

第一,SuspectClue都使用了frozen=True,成为不可变对象。线索被创建后不应该被随意篡改,否则仲裁的追溯就没有意义。如果你需要修正一条线索,更合理的做法是创建一条新线索,而不是修改原对象。

第二,Clueweight是浮点数。为什么不用整数?因为真实场景的权重往往需要支持小数,比如 0.8、1.5。保留浮点型可以更灵活。

第三,Detective.infer方法接收include_invalid参数。当它为True时,所有线索都会参与打分;当它为False时,无效线索会被跳过。这就为后面的仲裁对比提供了基础。

第四,top_guessmax取最高分。这个实现有个隐含行为:如果两名嫌疑人得分相同,返回先被遍历到的那一个。在 Demo 阶段这个行为可以接受,但工程上要留意,第 8 节我们会再讲。

这个文件里的类不包含任何业务判断逻辑,它们只负责“存数据”和“算分数”。这一层保持纯净,后续测试和维护都会更方便。

5. 核心逻辑:仲裁与责任判定

领域模型只回答了“侦探猜了谁”,没有回答“猜得对不对、错在谁”。这一步交给仲裁器。

新建arbiter.py,内容如下:

# mystery/arbiter.py from dataclasses import dataclass from enum import Enum from typing import Tuple from core import Detective class ErrorCategory(Enum): NO_ERROR = "正确" WRONG_EVIDENCE = "上游线索误导" LACK_EVIDENCE = "证据不足" WRONG_LOGIC = "推理逻辑偏差" @dataclass class Verdict: player: str truth: str raw_guess: str clean_guess: str raw_score: float clean_score: float is_correct: bool has_invalid_clue: bool error_category: ErrorCategory class Arbiter: def __init__(self, truth: str): self.truth = truth def judge(self, detective: Detective) -> Verdict: raw_guess, raw_score = detective.top_guess(include_invalid=True) clean_guess, clean_score = detective.top_guess(include_invalid=False) is_correct = raw_guess == self.truth has_invalid = any(not clue.valid for clue in detective.clues) if is_correct: category = ErrorCategory.NO_ERROR elif has_invalid and clean_guess == self.truth: category = ErrorCategory.WRONG_EVIDENCE elif clean_guess == "未定": category = ErrorCategory.LACK_EVIDENCE else: category = ErrorCategory.WRONG_LOGIC return Verdict( player=detective.player_name, truth=self.truth, raw_guess=raw_guess, clean_guess=clean_guess, raw_score=raw_score, clean_score=clean_score, is_correct=is_correct, has_invalid_clue=has_invalid, error_category=category, )

仲裁器的判定规则可以概括成一张决策表:

条件错误类型
原始猜测与真相一致正确
原始猜测错误,使用了无效线索,但剔除无效线索后能猜对上游线索误导
原始猜测错误,剔除无效线索后没有任何候选结论证据不足
原始猜测错误,剔除无效线索后仍猜错推理逻辑偏差

这套规则回答文章开头的问题:小可猜错了,斯比要不要背锅?

如果斯比只是因为采用了小可提供的无效线索而猜错,但把这条线索去掉后,斯比自己的推理结果是对的,那么仲裁器会把它归为“上游线索误导”,而不是“推理逻辑偏差”。换句话说,上游数据错误不应直接等同于下游推理错误。这个分类在真实系统里非常重要,它决定了你在故障复盘时是去找数据团队,还是去找算法团队。

6. 案例演示:谁背锅

最后写一个可运行的演示脚本demo.py,把整个流程串起来。

# mystery/demo.py from core import Clue, Detective, Suspect from arbiter import Arbiter suspects = [ Suspect("管家", "掌管庄园钥匙,熟悉所有房间"), Suspect("厨娘", "负责三餐,厨房活动频繁"), Suspect("园丁", "最近一个月被调去西区打理花圃"), ] truth = "管家" # 我收集到的线索 my_clues = [ Clue("C01", "管家", "钥匙", 2.0, "现场勘察", True), Clue("C02", "厨娘", "厨房", 1.0, "证人口供", True), ] # 斯比收集到的线索,其中 S03 来自小可,但被标记为无效 sby_clues = [ Clue("S01", "园丁", "花圃", 0.5, "花房记录", True), Clue("S02", "管家", "钥匙", 2.0, "现场勘察", True), Clue("S03", "厨娘", "夜半人影", 1.5, "小可情报", False), ] me = Detective("我") for clue in my_clues: me.add_clue(clue) sby = Detective("斯比") for clue in sby_clues: sby.add_clue(clue) arbiter = Arbiter(truth) for role in (me, sby): v = arbiter.judge(role) print("=" * 40) print(f"玩家: {v.player}") print(f"真实凶手: {v.truth}") print(f"原始猜测: {v.raw_guess}(得分 {v.raw_score})") print(f"剔除无效线索后: {v.clean_guess}(得分 {v.clean_score})") print(f"是否猜对: {v.is_correct}") print(f"是否使用了无效线索: {v.has_invalid_clue}") print(f"错误归类: {v.error_category.value}")

运行命令:

python demo.py

预期输出:

======================================== 玩家: 我 真实凶手: 管家 原始猜测: 管家(得分 2.0) 剔除无效线索后: 管家(得分 2.0) 是否猜对: True 是否使用了无效线索: False 错误归类: 正确 ======================================== 玩家: 斯比 真实凶手: 管家 原始猜测: 厨娘(得分 3.5) 剔除无效线索后: 管家(得分 2.5) 是否猜对: False 是否使用了无效线索: True 错误归类: 上游线索误导

结果很直观。我的分支有效线索足够,直接猜中真凶。斯比的分支虽然自己掌握了一条指向管家的现场线索,但因为加入了小可给出的“夜半人影”且权重较高,导致最终猜测被带偏。仲裁器在剔除无效线索之后发现,斯比原本是可以推理出正确答案的,因此把错误归为“上游线索误导”。

这个案例之所以特意设计成“斯比手里本来有正确答案相关的线索”,就是为了说明错误归因的价值:下游分支并不是完全无脑,它只是被上游的脏数据干扰了。系统没有简单地把斯比判定为“推理错误”,而是还原了数据链路中的真实问题。

7. 如何验证判定结果

代码跑通只是第一步。要让这套判定引擎真正可靠,还需要做一些验证实验。

7.1 验证真实答案变化时,判定是否正确

truth改成厨娘,重新运行。你会发现斯比的“原始猜测”恰好等于真相,因此会被判定为“正确”。这个边界条件说明:即使存在无效线索,如果无效线索指向真凶,最终结果反而是对的。这种情况下错误被掩盖了,但在统计正确率时它仍然是正确样本。

7.2 验证证据不足场景

把斯比的线索全部删掉,再运行一次。此时top_guess(include_invalid=False)返回未定,仲裁器会把它归类为“证据不足”。这条规则非常重要,它告诉你在实际系统中,有些错误不是推理算法的问题,而是样本缺失的问题。

7.3 验证平局问题

假设S02的权重是 1.5,而不是 2.0,那么斯比的干净推理结果中,园丁和管家得分可能相同。max会取先出现的那个。这种隐蔽的不确定性很容易被忽略,建议在测试样例中专门覆盖。

8. 常见问题与排查方法

问题现象可能原因排查方式解决方案
得分最高有多个时结果不稳定max默认取第一个遍历到的嫌疑人打印infer()完整得分表增加tie_breaker参数,明确平局处理策略
所有线索都被剔除后推理结果为“未定”无效线索过多或有效线索缺失输出线索有效性列表保证至少保留一条有效线索,或返回“证据不足”
需要修正一条线索,但实例不可变Clue设置了frozen=True确认不可变设计意在避免篡改新建一条 Clue 替代旧线索,保留旧记录用于追溯
无效线索没有计入任何统计infer(include_invalid=False)时被跳过检查valid字段和传参明确统计口径:干净推理永远只使用有效线索
几名嫌疑人权重体系不一致不同来源的线索权重量纲不同检查每个来源的权重分布在录入线索前做归一化或校准
仲裁结果与业务直觉不符错误分类规则不够细审查judge中的分支顺序根据业务需要扩展ErrorCategory

其中“权重体系不一致”是最容易踩的坑。现场勘察给的 2.0 分和证人随口说的 2.0 分,在实际业务里不应该具有相同影响力。最稳妥的做法是在数据录入时先定义一套权重规范,而不是等到仲裁阶段才发现分数不可比。

9. 工程化建议:把“责任划分”思路治理链路

这个 Demo 虽然小,但背后是一套通用的处理思路。如果把它放到真实项目里,有五个建议值得落地。

9.1 给每条线索打上来源标记和有效性标记

没有来源的线索不应该进入推理链路。来源标记让你可以回答“这条线索是谁给的”“它是否被人工修正过”。有效性标记则让仲裁器能够区分“数据本身不可信”和“推理过程不准确”。

9.2 把判定幂等化

同一个输入,无论跑多少次,裁决结果必须一致。这就要求所有判断不依赖随机数、不依赖系统时间、不依赖字典遍历顺序。如果后续要引入随机打散策略,必须显式传入随机种子,保证可复现。

9.3 记录“中间状态”而不是只记录结论

在故障复盘时,只看最终猜测远远不够。建议把侦探的完整得分表、每条线索的权重、无效线索剔除前后的对比结果都记录到日志中。这样出了问题,可以回溯到具体是哪条线索影响了最终结论。

9.4 防止数据回灌导致的连带错误

如果系统允许人工修正线索,修正后的版本要带版本号。比如S03第一次是无效的,人工复核后改成有效,版本变成S03-r1。下游推理必须明确自己消费的是哪个版本,否则就会出现“小可猜错了,斯比却用了修正前的版本,最后又背了一次锅”的情况。

9.5 把错误分类纳入监控指标

不要只统计“猜对率”,还应该统计“上游线索误导率”“证据不足率”“推理逻辑偏差率”。这三个指标对应完全不同的优化手段。上游误导率偏高,说明数据质量需要治理;证据不足率偏高,说明特征覆盖度不够;推理逻辑偏差率偏高,才应该去优化推理算法。

10. 延伸思考:AI Agent 与多分支决策的启发

最后再说点延伸思考。文章开头提到“小可猜错了的话,那斯比呢”,这其实和当前 AI Agent 链路中常见的问题非常相似。

一个 Agent 通常由多段组成:先调用外部工具拿数据,再交给大模型做推理,最后生成结果。假如外部工具返回了一个错误的数据,大模型基于错误数据做的判断也会错。那么在评估这个 Agent 时,我们应该把错误归给工具,还是归给模型?

最简单的答案是“都怪”,因为最终结果错了。但更好的做法是像本文的仲裁器一样,做一次“剔除脏数据后的回放”:如果模型在拿到正确数据时能输出正确结果,那说明模型逻辑本身没问题,问题出在上游工具或工具配置;如果模型拿到正确数据还是输出错误,那才是真正需要优化的推理能力。

这个思路也可以用在测试用例设计上。当你给一个系统写回归测试时,不要只准备“全对”的输入,还要准备“带脏数据但推理逻辑本身正确”的输入,用来验证系统是否能把错误归因到正确的位置。一旦做到了这一点,你在架构评审、故障复盘、责任界定这些环节上,就会有一份数据,而不是凭感觉争论。

所以下次再看到“我和斯比打赌猜凶手”这种段子,不妨换个角度想:这不是一句绕口令,而是一道系统设计题。先把判断标准定清楚,把线索来源标清楚,把错误类型分清楚,剩下的,就交给代码去裁决。

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

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

立即咨询