从剧本杀赌局到代码:设计一个可追溯的错误归因判定引擎
发布时间:2026/9/7 8:36:41 作者:尧图编辑部 阅读量:1,286

如果把一个剧本杀赌局写进代码判定规则要怎么设计上周朋友群里出现了一条有点绕的消息“我和斯比打赌猜凶手小可猜错了的话那斯比呢”乍看像一句闲聊但它其实是一个很典型的工程问题两个参与者拿到不同线索分别做推理第三方情报源负责补充信息当情报源本身出错时下游决策者到底要不要为错误负责这个场景并不只在游戏里出现。推荐系统里上游特征错误、算法排序再正确也会推荐偏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(frozenTrue) class Suspect: name: str brief: str dataclass(frozenTrue) 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_factorylist) 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_invalidinclude_invalid) if not scores: return 未定, 0.0 best_name, best_score max(scores.items(), keylambda item: item[1]) return best_name, best_score这里有几个设计点需要解释。第一Suspect和Clue都使用了frozenTrue成为不可变对象。线索被创建后不应该被随意篡改否则仲裁的追溯就没有意义。如果你需要修正一条线索更合理的做法是创建一条新线索而不是修改原对象。第二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_invalidTrue) clean_guess, clean_score detective.top_guess(include_invalidFalse) 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( playerdetective.player_name, truthself.truth, raw_guessraw_guess, clean_guessclean_guess, raw_scoreraw_score, clean_scoreclean_score, is_correctis_correct, has_invalid_cluehas_invalid, error_categorycategory, )仲裁器的判定规则可以概括成一张决策表条件错误类型原始猜测与真相一致正确原始猜测错误使用了无效线索但剔除无效线索后能猜对上游线索误导原始猜测错误剔除无效线索后没有任何候选结论证据不足原始猜测错误剔除无效线索后仍猜错推理逻辑偏差这套规则回答文章开头的问题小可猜错了斯比要不要背锅如果斯比只是因为采用了小可提供的无效线索而猜错但把这条线索去掉后斯比自己的推理结果是对的那么仲裁器会把它归为“上游线索误导”而不是“推理逻辑偏差”。换句话说上游数据错误不应直接等同于下游推理错误。这个分类在真实系统里非常重要它决定了你在故障复盘时是去找数据团队还是去找算法团队。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_invalidFalse)返回未定仲裁器会把它归类为“证据不足”。这条规则非常重要它告诉你在实际系统中有些错误不是推理算法的问题而是样本缺失的问题。7.3 验证平局问题假设S02的权重是 1.5而不是 2.0那么斯比的干净推理结果中园丁和管家得分可能相同。max会取先出现的那个。这种隐蔽的不确定性很容易被忽略建议在测试样例中专门覆盖。8. 常见问题与排查方法问题现象可能原因排查方式解决方案得分最高有多个时结果不稳定max默认取第一个遍历到的嫌疑人打印infer()完整得分表增加tie_breaker参数明确平局处理策略所有线索都被剔除后推理结果为“未定”无效线索过多或有效线索缺失输出线索有效性列表保证至少保留一条有效线索或返回“证据不足”需要修正一条线索但实例不可变Clue设置了frozenTrue确认不可变设计意在避免篡改新建一条 Clue 替代旧线索保留旧记录用于追溯无效线索没有计入任何统计在infer(include_invalidFalse)时被跳过检查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 时我们应该把错误归给工具还是归给模型最简单的答案是“都怪”因为最终结果错了。但更好的做法是像本文的仲裁器一样做一次“剔除脏数据后的回放”如果模型在拿到正确数据时能输出正确结果那说明模型逻辑本身没问题问题出在上游工具或工具配置如果模型拿到正确数据还是输出错误那才是真正需要优化的推理能力。这个思路也可以用在测试用例设计上。当你给一个系统写回归测试时不要只准备“全对”的输入还要准备“带脏数据但推理逻辑本身正确”的输入用来验证系统是否能把错误归因到正确的位置。一旦做到了这一点你在架构评审、故障复盘、责任界定这些环节上就会有一份数据而不是凭感觉争论。所以下次再看到“我和斯比打赌猜凶手”这种段子不妨换个角度想这不是一句绕口令而是一道系统设计题。先把判断标准定清楚把线索来源标清楚把错误类型分清楚剩下的就交给代码去裁决。