如果把一个剧本杀赌局写进代码,判定规则要怎么设计?
上周朋友群里出现了一条有点绕的消息:“我和斯比打赌猜凶手,小可猜错了的话那斯比呢?”乍看像一句闲聊,但它其实是一个很典型的工程问题:两个参与者拿到不同线索,分别做推理;第三方情报源负责补充信息;当情报源本身出错时,下游决策者到底要不要为错误负责?
这个场景并不只在游戏里出现。推荐系统里,上游特征错误、算法排序再正确也会推荐偏;AI Agent 链路里,工具返回了脏数据,模型再怎么编排也可能得出错误结论;数据分析链路里,数仓字段口径错了,下游报表自动跟着错。现实中我们经常吵“锅到底是谁的”,但更工程化的做法是:先定义清楚判定规则,再把“错误归因”落到代码里。
这篇文章要做一个很小的推理判定引擎。我们把“我和斯比”设计成两个并行推理分支,把“小可”设计成提供线索的第三方来源,然后回答三个问题:
- 怎样才算猜对?需要一套事实基准。
- 猜错了算谁的?需要区分“上游线索错误”和“推理逻辑错误”。
- 小可给错情报时,斯比应不应该被算作“猜错”?要用可追溯的判定逻辑解决。
读完之后,你可以照着写出一个小型判定系统,也能把这个思路套用到 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整个过程只需要标准库dataclasses、collections和typing,这部分是 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这里有几个设计点需要解释。
第一,Suspect和Clue都使用了frozen=True,成为不可变对象。线索被创建后不应该被随意篡改,否则仲裁的追溯就没有意义。如果你需要修正一条线索,更合理的做法是创建一条新线索,而不是修改原对象。
第二,Clue的weight是浮点数。为什么不用整数?因为真实场景的权重往往需要支持小数,比如 0.8、1.5。保留浮点型可以更灵活。
第三,Detective.infer方法接收include_invalid参数。当它为True时,所有线索都会参与打分;当它为False时,无效线索会被跳过。这就为后面的仲裁对比提供了基础。
第四,top_guess用max取最高分。这个实现有个隐含行为:如果两名嫌疑人得分相同,返回先被遍历到的那一个。在 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 时,我们应该把错误归给工具,还是归给模型?
最简单的答案是“都怪”,因为最终结果错了。但更好的做法是像本文的仲裁器一样,做一次“剔除脏数据后的回放”:如果模型在拿到正确数据时能输出正确结果,那说明模型逻辑本身没问题,问题出在上游工具或工具配置;如果模型拿到正确数据还是输出错误,那才是真正需要优化的推理能力。
这个思路也可以用在测试用例设计上。当你给一个系统写回归测试时,不要只准备“全对”的输入,还要准备“带脏数据但推理逻辑本身正确”的输入,用来验证系统是否能把错误归因到正确的位置。一旦做到了这一点,你在架构评审、故障复盘、责任界定这些环节上,就会有一份数据,而不是凭感觉争论。
所以下次再看到“我和斯比打赌猜凶手”这种段子,不妨换个角度想:这不是一句绕口令,而是一道系统设计题。先把判断标准定清楚,把线索来源标清楚,把错误类型分清楚,剩下的,就交给代码去裁决。