我故意构建了一个糟糕的决策系统(让你免于如此)

发布: (2025年12月19日 GMT+8 19:00)
9 min read
原文: Dev.to

Source: Dev.to

任务:相同问题,两种实现

两个系统解决完全相同的问题:

输入文本 → 提取关键词 → 计算分数 → 推荐动作

动作空间故意保持小:

  • WAIT_AND_SEE
  • BUY_MORE_STOCK
  • PANIC_REORDER

保持任务简单,使我们能够完全专注于 系统行为,而不是模型质量。

基准理念

该基准故意保持简约:

  1. 采用 单一、固定的输入文本
  2. 在系统中 多次运行 它。
  3. 观察输出是否保持稳定。

为什么这很重要:仅能运行一次的系统并不是系统——那只是巧合。如果相同的输入产生不同的输出,则系统层面存在根本性问题。

基准结果:BAD 与 GOOD

以下结果是通过对 相同输入 在两个系统中各运行五次得到的。

BAD 系统输出(摘录)

BAD 系统会逐步升级其决策:

RunScoreAction
114WAIT_AND_SEE
342BUY_MORE_STOCK
574PANIC_REORDER

相同的输入。相同的关键字。却产生了完全不同的决策。

汇总基准摘要

BAD 系统

  • 运行次数:5
  • 唯一分数:5 → [14, 28, 42, 58, 74]
  • 唯一动作:3

GOOD 系统

  • 运行次数:5
  • 唯一分数:1 → [14, 14, 14, 14, 14]
  • 唯一动作:1

GOOD 系统的行为像一个纯函数。BAD 系统的行为则像内存泄漏。

故障分类:BAD 系统的崩溃方式

坏系统并不会以单一、显而易见的方式失效。相反,它表现出 多种相互作用的故障模式,这些模式在真实的 AI 与数据系统中很常见。给这些故障模式命名可以让它们更容易被检测到——也更难被意外上线。

1️⃣ Drift

  • 定义:即使输入保持完全不变,系统的输出也会随时间改变。
  • 根本原因:跨运行的全局分数累计;状态单调增长且未被重置。
  • 为何危险
    • 业务逻辑在没有任何显式更改的情况下发生变动。
    • 历史执行顺序会影响当前决策。
    • 监控面板常常因为数值仍在“合理”范围而忽视问题。

漂移尤其危险,因为它看起来像是学习——但实际上并不是。

2️⃣ Non‑determinism

  • 定义:相同的输入会产生不同的输出。
  • 根本原因:在评分过程中注入随机噪声;对执行历史的隐式依赖。
  • 为何危险
    • 错误无法可靠复现。
    • 测试失败变得不稳定且不可信。
    • A/B 实验失去统计意义。

如果你无法复现一次决策,就无法调试它。

3️⃣ Hidden State

  • 定义:函数依赖于在其接口或输入中不可见的数据。
  • 根本原因:全局变量,如 CURRENT_SCORELAST_TEXTRUN_COUNT
  • 为何危险
    • 代码无法在局部范围内被理解。
    • 重构会以非显而易见的方式改变行为。
    • 新加入的贡献者在不知情的情况下引入回归。

隐藏状态把每一次函数调用都变成了猜谜游戏。

4️⃣ Silent Corruption

  • 定义:系统在没有错误的情况下继续运行,但其决策错误率逐渐上升。
  • 根本原因:缺乏显式的失败信号;没有不变式或健全性检查。
  • 为何危险
    • 错误输出向下游传播。
    • 问题仅通过业务影响才会显现。
    • 回滚变得困难甚至不可能。

响亮的故障会被修复,沉默的故障会被部署。

为什么这个分类法重要

这些失败模式很少单独出现。在 BAD 系统中,它们相互强化:

  • 隐藏状态导致漂移。
  • 漂移放大非确定性。
  • 非确定性掩盖静默腐败。

理解这些模式比修复单个 bug 更有价值——因为相同的分类法适用于更大、更复杂的 AI 系统。

单一指标:稳定性得分

为了概括系统行为,我使用了一个单一指标:

stability_score = 1 - (unique_scores / runs)
  • 1.0 → 完全稳定
  • 0.0 → 完全不稳定

稳定性结果

系统稳定性得分
BAD0.0
GOOD0.8

这个数字已经告诉你哪个系统值得信赖。

最小修复:四个小补丁改变一切

这不是一次重写。这些是 外科手术式的更改。每个补丁都消除了一整类失败模式,而不引入新的抽象或框架。

补丁 1 — 移除全局状态

之前(错误):

# global mutation + history dependence
GS.CURRENT_SCORE += base
return GS.CURRENT_SCORE

之后(正确):

def score_keywords(keywords, text):
    return sum(len(w) % 7 for w in keywords) + len(text) % 13

此更改修复的内容

  • 消除分数漂移。
  • 移除隐藏的历史依赖。
  • 使函数确定且可测试。

依赖全局状态的函数不是函数——它是内存泄漏。

补丁 2 — 将副作用推到边界

之前(错误):

def extract_keywords(text):
    print("Extracting keywords...")
    open("log.txt", "a").write(text)
    return tokens[:k]

之后(正确):

def extract_keywords(text):
    # Pure computation – no I/O, no printing
    return tokenize(text)[:k]

此更改修复的内容

  • 移除隐藏的 I/O 副作用,使运行确定性更强。
  • 将日志记录与核心逻辑分离(例如通过装饰器或包装器)。

补丁 3 — 强制不变量

之前(错误):

def compute_score(keywords):
    # No sanity checks
    return sum(len(k) for k in keywords) * random.random()

之后(正确):

def compute_score(keywords):
    assert all(isinstance(k, str) for k in keywords), "Keywords must be strings"
    base = sum(len(k) for k in keywords)
    return base  # deterministic, no random factor

此更改修复的内容

  • 及早检测损坏的输入。
  • 保证分数保持在预期范围内。

补丁 4 — 重置每次运行的状态

之前(错误):

RUN_COUNT += 1          # global counter never reset
CURRENT_SCORE += 5     # accumulates across runs

之后(正确):

def run_pipeline(text):
    # Local state only
    keywords = extract_keywords(text)
    score = compute_score(keywords)
    action = decide_action(score)
    return {"score": score, "action": action}

此更改修复的内容

  • 保证每次调用相互独立。
  • 消除跨运行的漂移和隐藏状态。

补丁详细信息

补丁 3 — 明确依赖关系

Before (BAD):

if GS.LAST_TEXT is not None:
    base += len(GS.LAST_TEXT) % 13

After (GOOD):

def score_keywords(keywords, text):
    base = sum(len(w) % 7 for w in keywords)
    return base + (len(text) % 13)

What this fixes

  • 没有隐藏输入。
  • 数据流清晰。
  • 安全的重构。

补丁 4 — 为魔法数字命名

Before (BAD):

if score > 42:
    action = "PANIC_REORDER"

After (GOOD):

@dataclass(frozen=True)
class Config:
    panic_threshold: int = 42

if score > cfg.panic_threshold:
    action = "PANIC_REORDER"

What this fixes

  • 决策变得可解释。
  • 参数可供审查。
  • 行为变化变得有意图。

摘要

这四个补丁:

  • 移除隐藏状态
  • 消除非确定性
  • 使行为可解释
  • 恢复系统的信任

没有代理。没有框架。只有工程纪律。

最终要点

The BAD system works. 那就是问题所在。
It fails in the most dangerous way possible: plausibly and quietly.

The GOOD system is boring, predictable, and easy to reason about — which is exactly what you want in production.

Working code is not the same as a working system.

代码与可复现性

本文中使用的所有代码——包括有意破坏的系统、干净的实现以及基准测试——均已在 GitHub 上公开:

👉 https://github.com/Ertugrulmutlu/I-Intentionally-Built-a-Bad-Decision-System-So-You-Don-t-Have-To

如果你想复现结果,请运行:

python compare.py

该基准测试会多次将相同的输入分别通过两个系统,并在几行输出中展示为何可预测性比华丽的抽象更重要。

Back to Blog

相关文章

阅读更多 »