你有同一个函数的五种实现。三种返回相同结果。一种略有不同。一种抛出异常。哪个才是对的?
大多数团队默认采用多数投票。当输出完全一致、错误显而易见时,这招确实管用。但一旦各实现在细微之处产生分歧,或者每个变体都给出不同答案时,这套机制就会崩解。N-version programming 只解决了问题的前半段:运行多个版本。更困难的后半段是判断该信任哪个输出。
什么是 N-version programming?
N-version programming 是一种容错技术,即运行同一规范的多个独立实现,然后综合它们的结果。经典形式是 triple modular redundancy:三个系统投票,多数获胜。它最早源于安全关键型硬件——比如航空电子——在那里单个 bug 就可能致命。
同样的思路在 AI 工程中又 resurgence 了。当你让 LLM 生成代码时,你可能会采样五个不同的 completion。当你有一个 legacy parser 和一个 rewrite 时,你可能会并行运行两者并比较。其硬件根源显而易见。我们借用了架构,却不一定总是借用了让它有效运作的 selection logic。
在硬件中,输出是比特。在软件中,输出是结构化数据、字符串、排序结果或副作用。多数投票假设「判断是否相等」计算开销低且常见。对大多数软件问题来说,这两个假设都不成立。
投票陷阱:为什么「最常见的」不等于「最正确的」
去年我在一次搜索排序重构中遇到了这个问题。我们有五种排序算法:legacy 系统、两种基于模型的方法,以及两种 heuristic baseline。在一个样本查询上,其中四种返回了不同的 top-10 列表。没有一种完全匹配。根本不存在可以投票的多数派。
这是常态,而非边缘情况。不同的实现针对不同的目标进行优化。一种可能偏好 recency。另一种可能加权 popularity。第三种可能有个只在周二才会触发的 bug。如果你按 exact string match 来投票,你就会把自己绑定到最平庸的实现上——也就是那个返回最平淡、最不出人意料输出的实现。
Exact-match voting 还会静默失效。两种实现可能因共享一个错误假设或复制的 bug 而返回同样的错误答案。Correlated failures 会彻底破坏 N-version redundancy。如果你的五个 parser 中有三个是在同一个有偏数据集上训练的,它们的共识毫无意义。
Differential testing 实际如何运作
更好的方法是结合 structured comparison 的 differential testing。不问「哪些输出完全相同」,而是问「根据我可以定义和衡量的标准,哪个输出最好」。
首先,为你的领域定义一个 equivalence function。对于搜索排序,你可能会用 normalized discounted cumulative gain(NDCG)来比较。对于 JSON parser,你可能会比较生成的对象图。对于字符串输出,你可能会用 semantic similarity 或下游任务得分。关键在于,相等变成一个光谱,而不是一个二元开关。
接下来,定义一个 scoring function,将每个输出映射为一个标量。这是领域知识介入的地方。对于代码生成任务,scoring function 可能综合编译成功、测试通过率、运行时性能和输出长度。具体权重并不重要,重要的是它们被显式定义出来。
有了分数,选择就变成了一个简单的优化问题:挑出得分最高的输出。如果平局,使用 fallback heuristic,比如优先选择历史错误率最低的实现。
下面是一个你可以改用的 selector。它运行 N 个变体,为每个输出打分,并返回最佳结果及诊断元数据:
from dataclasses import dataclass
from typing import Callable, List, Optional, TypeVar
import statistics
T = TypeVar("T")
@dataclass
class VariantResult:
variant_id: str
output: Optional[T]
error: Optional[Exception]
score: float = 0.0
class NVersionSelector:
def __init__(
self,
score_fn: Callable[[T], float],
equivalence_fn: Optional[Callable[[T, T], float]] = None,
tie_breaker: Optional[Callable[[List[VariantResult]], VariantResult]] = None,
):
self.score_fn = score_fn
self.equivalence_fn = equivalence_fn or (lambda a, b: 1.0 if a == b else 0.0)
self.tie_breaker = tie_breaker
def select(self, variants: List[Callable[..., T]], *args, **kwargs) -> VariantResult:
results: List[VariantResult] = []
for variant in variants:
try:
output = variant(*args, **kwargs)
results.append(VariantResult(
variant_id=variant.__name__,
output=output,
error=None,
))
except Exception as exc:
results.append(VariantResult(
variant_id=variant.__name__,
output=None,
error=exc,
))
# Score only successful runs
for r in results:
if r.output is not None:
r.score = self.score_fn(r.output)
# Filter to successful, scored results
valid = [r for r in results if r.error is None and r.output is not None]
if not valid:
raise RuntimeError("All variants failed")
best_score = max(r.score for r in valid)
candidates = [r for r in valid if r.score == best_score]
if len(candidates) == 1:
return candidates[0]
if self.tie_breaker:
return self.tie_breaker(candidates)
return self._default_tie_break(candidates, valid)
def _default_tie_break(
self, candidates: List[VariantResult], all_valid: List[VariantResult]
) -> VariantResult:
# Prefer the candidate whose output is most similar to other outputs
def consensus_score(candidate: VariantResult) -> float:
similarities = [
self.equivalence_fn(candidate.output, other.output)
for other in all_valid
if other.variant_id != candidate.variant_id
]
return statistics.mean(similarities) if similarities else 0.0
return max(candidates, key=consensus_score)
score_fn 是你为当前问题编码「最佳」含义的地方。equivalence_fn 处理分数平局的情况,通过衡量某个输出与其他输出的相似度。这将 majority vote 泛化为一种即使在没有任何两个输出完全匹配时也能工作的机制。
用多准则评估为实现打分
单一的标量分数很简洁,但如果你把太多维度压缩成一个数字,就会很危险。我更倾向于双层方法。
第一层是 hard filter。编译失败、违反不变量或崩溃的输出会被立即丢弃。没有 partial credit。
第二层是给幸存者的 soft score。这可以是一个 weighted sum,但权重要保持显式且可调。如果你发现 selector 总是偏爱快但错的答案,你可以提高正确性权重,而无需重写架构。
def score_generated_code(output: str) -> float:
if not compiles(output):
return -1.0 # Hard filter
tests_passed = run_test_suite(output)
execution_time = benchmark(output)
line_count = len(output.splitlines())
# Weighted sum on survivors only
return (
0.6 * tests_passed
+ 0.3 * (1.0 / (1.0 + execution_time))
+ 0.1 * (1.0 / (1.0 + line_count))
)
上面的权重是 arbitrary 的。在 held-out validation set 上调参,并在需求变化时更新它们。不要把它们埋在一个类里,让它们变得不可见。
何时会失效:correlated failures 与运行时开销
N-version selection 并非无成本。运行五个变体意味着五倍的计算、五倍的延迟和五倍的维护负担。如果你的实现是 LLM 调用,成本以美元和秒计算。如果它们是 microservices,成本以队列深度和线程数计算。
更大的风险是 correlated failure。五个实现并不代表从 bug 分布中独立抽取五次。它们共享语言、库、训练数据和人类作者。如果五个实现都用同一个正则表达式来解析日期,它们都会在同一 malformed input 上崩溃。Diversity 很难强制执行。它需要主动审计共享依赖和复制的逻辑。
还有一个问题是 selector 本身出错该怎么办。如果你的 scoring function 存在盲点,你会持续选出 bad outputs 却毫无察觉。监控 selection distribution。如果某个变体从未被选中,它要么没用,要么你的 score function 有偏。无论哪种情况,你都应该调查。
整合起来:今天就能上线的 selector
你不需要一个分布式系统才能起步。把你现有的函数包装进一个 selector,添加一个替代实现,并定义一个对你真正重要的单一评分标准。并行运行它们。比较输出。记录分数。
记录一周后,回顾那些变体出现分歧的 case。这种分歧是一份礼物。它告诉你规范哪里模糊、score function 哪里出错、或者哪个实现有 bug 而其他实现没有。
逐步扩展。两个变体配一个好的 selector,胜过五个变体配 majority vote。
FAQ:correlated failures、stateful systems 与运行时开销
如果五种实现返回五种不同结果怎么办?
对于非平凡问题,这是预期中的情况。使用 scoring function 对输出进行排序,而不是寻找 exact match。如果你无法定义一个好的 score function,问题不在 selector,而在于你尚未弄清楚「正确」对你的领域意味着什么。
如何防止跨变体的 correlated failures?
审计共享依赖、复制的代码和共同的训练数据。强制至少一个变体使用不同的语言或框架。目标是 uncorrelated error distributions,这比你想象的要难。
N-version programming 适用于 stateful systems 吗?
对于带副作用的系统,它表现很差。如果你的函数写入数据库或刷信用卡,运行五次是具有破坏性的。N-version selection 最适合 pure functions、只读查询,或者你可以在 selection 之后再执行副作用的操作。
运行五个变体会带来多少开销?
除非你并行运行,否则延迟由最慢的变体决定。CPU 和内存线性增长。从两个变体开始,在承诺使用五个之前先测量。运营成本往往比 selection logic 更重要。