一个飞得太靠近太阳的假设

20 世纪 80 年代初,NASA 面临着一个至今仍在困扰安全关键工程领域的问题:如何容忍那些尚未发现的 bug?他们的答案是 N-version programming。将同一份规范交给三个独立团队。并行运行三个程序。对输出结果进行投票。如果其中一个团队写出了 bug,另外两个团队会通过多数票将其压倒。

这听起来像是数学上的常识。但它也是错的。

1986 年,John Knight 和 Nancy Leveson 发表了 NASA 资助的一项大规模实验的结果。两所大学的 27 名研究生为同一份弹道导弹拦截器规范编写了独立的实现。每个程序单独来看都极其可靠。六个版本完全没有出错。23 个版本在一百万个随机生成的测试用例中通过了超过 99.9%。

但当多个版本在同一个输入上失败时,它们共同失败的频率远高于统计独立性所预测的结果。z-score 为 100.55。在 99% 的置信区间内,独立性的 null hypothesis 被彻底摧毁。NASA 押注人类错误是随机的。Knight 和 Leveson 证明了错误是成簇出现的。

N-Version Programming 实际承诺了什么

N-version programming 背后的逻辑借自硬件冗余。如果你在火箭上安装三个相同的陀螺仪,其中一个发生漂移,另外两个会通过多数票压倒漂移。故障是独立的,因为陀螺仪是物理实体,受到独立的制造差异、温度梯度和振动模式的影响。

软件则不同。当你让三个团队解决同一个问题时,你得到的不是三次独立的掷骰子。你得到的是三个读了同一份模糊规范、从同一本教科书里学了相同算法、用同一种语言和同一个标准库写代码的人。他们的错误相互关联,因为他们的输入相互关联。

N-version 架构在实践中看起来是这样的:

from typing import Callable, List, TypeVar

T = TypeVar('T')

def n_version_vote(
    implementations: List[Callable[[float], T]],
    input_value: float
) -> T:
    """Run N versions and return the majority output."""
    results = [impl(input_value) for impl in implementations]
    
    # Simple majority vote
    from collections import Counter
    counts = Counter(str(r) for r in results)
    most_common = counts.most_common(1)[0][0]
    
    # Return the actual value that matched the winning string
    for r in results:
        if str(r) == most_common:
            return r
    
    raise RuntimeError("No consensus")

这个投票器是简单的部分。困难的部分藏在其中的假设里:implementations[0]implementations[1]implementations[2] 在输入空间的不相关子集上失败。Knight 和 Leveson 证明了这个假设本身就是缺陷。

关联性故障从何而来

Knight-Leveson 实验揭示了 common-mode failure 的两种不同机制,而哪一种都无法通过让团队”再努力一点”来解决。

首先是规范歧义。导弹拦截器规范中包含了一些真正困难的边界情况。27 名程序员中有 8 名处理错了三个雷达点共线的情况。错误各不相同。一个在数组下标上犯了 off-by-one 错误。一个使用了数值稳定性差的算法。一个完全忘记了边界情况。但故障集中在同一个困难的输入上,因为问题本身就很难,而不是因为程序员粗心。

这就是”难度因子”。当输入处于边界条件、需要棘手的 floating-point 运算,或涉及规定不足的状态转换时,独立团队往往会在同一个地方遇到困难。他们的解决方案不同。但他们的故障区域重叠。

第二种机制是共享的心智模型。接受相同课程训练的程序员会应用相同的启发式方法。他们会伸手去拿同一个排序算法、同一个防御性拷贝模式、同一个用于 floating-point 相等性判断的 epsilon 比较。当这种共享的默认值对当前问题是错误的时候,每个团队都会从同一个悬崖上跌落。

NASA 自己的《软件安全指南》最终明确承认了这一点。它指出,“许多专业人士认为 N-Version programming 是无效的,甚至适得其反。” 在 NASA 对一架实验飞机的一项研究中,测试期间发现的每一个软件问题都来自冗余管理系统。主飞行控制软件毫无瑕疵。N-version 层是唯一出现故障的东西。

无人提及的复杂性税

即使 N-version programming 像宣传的那样有效,它也会收取高昂的代价。你要为三个完整的实现买单。你要为一个本身就包含逻辑的投票器买单。你要为运行三个进程并协调其输出的运营开销买单。

那个投票器并不是一段简单的代码。它必须处理平局、超时、不同的输出格式,以及多数票本身是错误的情况。Brunelle 和 Eckhardt 在 1985 年通过 SIFT 操作系统证明了这一点。两个新的 N-version 以多数票压倒了最初正确的实现,并产出了一个错误的答案。冗余系统制造了它本应阻止的错误。

复杂性以难以衡量的方式层层叠加,直到它们咬你一口。你现在有三个可部署物需要版本管理,三个测试矩阵需要维护,还有三个团队会在需求不可避免地变化时对规范变更做出不同的解读。人为错误的表面积增长得比可靠性的提升更快。

什么才真正有效

NASA 没有放弃容错。他们放弃的是”多样性来自独立性”这一特定假设。现代高保证系统采用了一系列技术组合,针对 Knight 和 Leveson 识别的实际故障模式。

关键路径上的形式化方法。 与其寄希望于三个团队正确解读规范,不如将规范写成机器可检查的形式。TLA+、Coq 和 SPIN 等工具可以在任何人写下实现代码之前验证设计是否满足其不变量。NASA 自己的 Remote Agent Experiment 就使用了 SPIN 来发现那些逃过了大量测试的并发 bug。

通过不同的技术栈实现多样性。 如果你需要冗余,那就做得彻底。Airbus A330 飞行控制系统在其主通道和备份通道中使用了不同的硬件架构、不同的编程语言和独立的编译器。目标不仅仅是独立的团队,而是堆栈每一层都有独立的故障模式。

简化优于复制。 NASA《软件安全指南》最终建议只在”小型简单函数”中使用 N-version programming。这个教训不够光鲜但非常有效:最可靠的系统是简单到可以直接推理的系统。每一行冗余管理代码都是一行可能失败的代码。

以下是一个简化的、由规范驱动的方法在实践中的样子。与其让三个不透明的实现对输出进行投票,不如显式编码关键不变量并在运行时检查它:

from dataclasses import dataclass
from typing import Optional

@dataclass(frozen=True)
class LaunchCommand:
    thrust_level: float  # 0.0 to 1.0
    abort_flag: bool
    
    def is_valid(self) -> bool:
        """Runtime invariant check derived from the formal spec."""
        if not (0.0 <= self.thrust_level <= 1.0):
            return False
        if self.abort_flag and self.thrust_level > 0.0:
            return False
        return True

def execute_command(cmd: LaunchCommand) -> Optional[str]:
    if not cmd.is_valid():
        raise ValueError("Invariant violation: command violates safety spec")
    
    # Execute only after the single, explicit check passes.
    return f"Executing thrust={cmd.thrust_level}, abort={cmd.abort_flag}"

这不是 N-version programming。它是一个带有显式、可测试安全边界的单一实现。你的努力应该放在边界上。而不是寄希望于三个团队中有两个做对了。

现代的回响

Knight 和 Leveson 1986 年的论文带有一个每年都在变得更加相关的警告。关联性故障不需要关联的团队。它们只需要关联的输入和关联的难度。随着 AI 辅助编程工具的泛滥,我们正在行星尺度上运行一场新的 N-version 实验。在重叠语料库上训练、被相似模式提示的模型,会产生具有共享故障模式的代码。

近期关于 LLM 代码生成的研究表明,AI 生成组件的 co-error rate 在 15% 到 30% 之间。beta factor,即归因于共同原因的故障比例,可能已经超过了 Knight 和 Leveson 测量到的人类程序员数值。我们正在用更多的参与者和同样错误的假设重复这场实验。

NASA 以昂贵的方式学到了这一课。你不需要三个实现。你需要的是一个带有你可以陈述、检查和信任的不变量的实现。其他一切都是披着工程外衣的乐观主义。

常见问题

什么是 N-version programming?

N-version programming 是一种软件容错技术,多个独立团队实现同一份规范。程序并行运行,一个投票器选择多数输出,其假设是独立团队会犯独立的错误。

NASA 真的用过 N-version programming 吗?

是的。NASA 资助了多版本软件的早期研究,并且一度有一些政策文件实际上强制要求容错系统使用 N-version programming。航天飞机的发动机启动序列就使用了它,尽管 NASA 后来在经验研究揭示其局限性后,将其使用限制在小型、简单的函数中。

Knight-Leveson 实验是什么?

1986 年,John Knight 和 Nancy Leveson 让 27 名程序员为一份导弹拦截器规范编写独立实现。在一百多万次测试中,程序表现出的 coincident failures 远多于统计独立性所预测的结果,从而否定了 N-version programming 的核心假设。

N-version programming 还值得使用吗?

在有限的情况下是的。NASA 目前的指导方针表明,它可能适用于可以强制执行真正多样性的小型、定义明确的函数。对于一般的应用开发,复杂性成本和关联性故障风险超过了收益。形式化方法、运行时不变量检查和简化通常是更好的投资。