같은 함수의 다섯 가지 구현체가 있다. 세 개는 같은 결과를 반환한다. 하나는 조금 다르다. 하나는 예외를 던진다. 어느 것이 맞는가?
대부분의 팀은 다수결을 기본으로 삼는다. 출력이 동일하고 오류가 명백할 때는 잘 작동한다. 하지만 구현체들이 미묘하게 의견이 갈리거나, 모든 variant가 다른 답을 반환하는 순간 무너진다. N-version programming은 문제의 전반부, 즉 여러 버전을 실행하는 것만 해결한다. 더 어려운 후반부는 어떤 출력을 신뢰할지 결정하는 것이다.
N-version programming이란 무엇인가?
N-version programming은 동일한 명세를 독립적으로 구현한 여러 버전을 실행하고 그 결과를 결합하는 결함 허용 기법이다. 고전적인 형태는 triple modular redundancy다: 세 시스템이 투표하고 다수가 승리한다. 이는 안전이 중요한 하드웨어, 예를 들어 항공 전자(avionics)로 거슬러 올라가는데, 거기서는 하나의 버그가 사람을 죽일 수 있었다.
같은 아이디어가 AI 엔지니어링에서 다시 부상했다. LLM에 코드 생성을 요청할 때 다섯 가지 다른 completion을 샘플링할 수 있다. 레거시 파서와 재작성본이 있을 때 둘을 병렬로 실행하고 비교할 수 있다. 하드웨어의 뿌리가 드러난다. 우리는 아키텍처를 차용했지만, 그것을 작동하게 만드는 선택 로직은 항상 함께 차용하지는 않았다.
하드웨어에서는 출력이 비트다. 소프트웨어에서는 출력이 구조화된 데이터, 문자열, 순위, 부작용이다. 다수결은 동일성 계산이 저렴하고 흔하게 발견된다고 가정한다. 대부분의 소프트웨어 문제에서는 두 가정 모두 성립하지 않는다.
투표의 함정: 왜 ‘가장 흔한’ 것이 ‘가장 올바른’ 것이 아닌가
작년에 검색 순위 리팩토링에서 이 문제를 겪었다. 다섯 가지 순위 알고리즘이 있었다: 레거시 시스템, 두 가지 모델 기반 접근법, 두 가지 휴리스틱 베이스라인. 샘플 쿼리에서 네 개는 각기 다른 상위 10개 목록을 반환했다. 정확히 일치하는 것은 없었다. 투표할 다수가 없었다.
이는 엣지 케이스가 아니라 정상적인 경우다. 다른 구현체는 다른 것을 최적화한다. 하나는 최신성을 선호할 수 있다. 다른 하나는 인기도에 가중치를 둘 수 있다. 세 번째는 화요일에만 나타나는 버그를 가지고 있을 수 있다. 정확한 문자열 일치로 투표한다면, 자신을 가장 평범한 구현체, 즉 가장 평이하고 덜 놀라운 출력을 반환하는 것에 묶게 될 것이다.
정확한 일치 투표는 조용히도 실패한다. 두 구현체가 잘못된 가정이나 복사된 버그를 공유하기 때문에 같은 잘못된 답을 반환할 수 있다. correlated failures는 N-version 중복성을 완전히 무너뜨린다. 다섯 개 파서 중 세 개가 같은 편향된 데이터셋으로 학습되었다면, 그들의 합의는 아무 의미가 없다.
Differential testing이 실제로 작동하는 방식
더 나은 접근법은 구조화된 비교를 통한 differential testing이다. “어떤 출력이 동일한가”가 아니라 “내가 정의하고 측정할 수 있는 기준에 따라 어떤 출력이 최선인가”를 묻는다.
먼저 자신의 도메인에 대한 equivalence function을 정의하는 것부터 시작하라. 검색 순위의 경우 normalized discounted cumulative gain(NDCG)을 사용해 비교할 수 있다. JSON 파서의 경우 결과 객체 그래프를 비교할 수 있다. 문자열 출력의 경우 semantic similarity나 downstream task score를 사용할 수 있다. 핵심은 동일성이 이진 스위치가 아니라 스펙트럼이 된다는 것이다.
다음으로 각 출력을 스칼라로 매핑하는 scoring function을 정의하라. 이것이 도메인 지식이 들어가는 곳이다. 코드 생성 작업의 scoring function은 컴파일 성공, 테스트 통과율, 런타임 성능, 출력 길이를 결합할 수 있다. 정확한 가중치는 그것들이 명시적이라는 사실만큼 중요하지 않다.
점수를 손에 쥐면 선택은 단순한 최적화가 된다. 가장 높은 점수의 출력을 고른다. 동점이 있다면 역사적 오류율이 가장 낮은 구현체를 선호하는 것과 같은 대체 휴리스틱을 사용하라.
다음은 적용할 수 있는 selector다. N개의 variant를 실행하고 각 출력을 채점하며, 진단 메타데이터와 함께 최선의 결과를 반환한다:
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은 출력이 나머지와 얼마나 유사한지 측정하여 동점 상황을 처리한다. 이는 두 출력이 정확히 일치하지 않을 때도 작동하는 무언가로 다수결을 일반화한 것이다.
다중 기준 평가로 구현체 채점하기
단일 스칼라 점수는 깔끔하지만, 너무 많은 차원을 하나의 숫자로 축소하면 위험하다. 나는 이중 접근법을 선호한다.
첫 번째 단계는 hard filter다. 컴파일에 실패하거나, 불변식을 위반하거나, 충돌하는 출력은 즉시 폐기된다. 부분 점수는 없다.
두 번째 단계는 생존자를 위한 soft score다. 이는 가중 합계가 될 수 있지만, 가중치를 명시적이고 조정 가능하게 유지하라. selector가 일관되게 빠르지만 틀린 답을 선호한다면, 아키텍처를 다시 작성하지 않고도 correctness 가중치를 올릴 수 있다.
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))
)
위의 가중치는 임의적이다. held-out validation set에 대해 튜닝하고, 요구사항이 변경되면 업데이트하라. 클래스 내부에 묻어 보이지 않게 만들지 마라.
한계가 드러날 때: correlated failures와 런타임 비용
N-version selection은 공짜가 아니다. 다섯 가지 variant를 실행한다는 것은 다섯 배의 계산, 다섯 배의 지연 시간, 다섯 배의 유지보수 부담을 의미한다. 구현체가 LLM 호출이라면 비용은 달러와 초로 측정된다. 마이크로서비스라면 큐 깊이와 스레드 수로 측정된다.
더 큰 위험은 correlated failure다. 다섯 가지 구현체가 버그 분포에서 다섯 번의 독립적인 추출을 보장하지 않는다. 이들은 언어, 라이브러리, 학습 데이터, 인간 작성자를 공유한다. 다섯 개 모두가 날짜를 파싱하기 위해 같은 정규 표현식을 사용한다면, 모두 같은 malformed input에서 깨질 것이다. 다양성을 강제하기는 어렵다. 공유 의존성과 복사된 로직을 적극적으로 감사해야 한다.
selector 자체가 틀렸을 때 어떻게 할 것인지도 문제다. scoring function에 맹점이 있다면 일관되게 나쁜 출력을 선택하고 결코 알아차리지 못할 것이다. 선택 분포를 모니터링하라. 어떤 variant가 절대 선택되지 않는다면, 그것은 쓸모없거나 score function이 편향되어 있기 때문이다. 어느 쪽이든 조사해야 한다.
종합하기: 오늘 배포할 수 있는 selector
시작하려면 분산 시스템이 필요하지 않다. 기존 함수를 selector로 감싸고, 하나의 대안 구현체를 추가하며, 자신에게 중요한 단일 채점 기준을 정의하라. 병렬로 실행하고, 출력을 비교하고, 점수를 기록하라.
일주일간의 로깅 후, variant들이 의견이 갈렸던 사례를 검토하라. 그 불일치는 선물이다. 명세가 모호한 곳, score function이 틀린 곳, 혹은 한 구현체가 다른 것들에는 없는 버그를 가진 곳을 알려준다.
점진적으로 확장하라. 좋은 selector를 가진 두 가지 variant가 다수결을 가진 다섯 가지 variant를 이긴다.
FAQ: correlated failures, stateful systems, 그리고 런타임 오버헤드
다섯 가지 구현체 모두가 다른 결과를 반환하면 어떻게 하나?
이는 사소하지 않은 문제에 대한 예상된 경우다. 정확한 일치를 찾는 대신 scoring function을 사용해 출력을 순위 매겨라. 좋은 score function을 정의할 수 없다면, 문제는 selector에 있는 것이 아니다. 아직 자신의 도메인에서 “올바름”이 무엇을 의미하는지 모르기 때문이다.
variant 간의 correlated failures를 어떻게 방지하나?
공유 의존성, 복사된 코드, 공통 학습 데이터를 감사하라. 적어도 하나의 variant는 다른 언어 또는 프레임워크를 사용하도록 강제하라. 목표는 uncorrelated error distributions인데, 이는 들리는 것보다 어렵다.
N-version programming이 stateful system에서 작동하나?
부작용이 있는 시스템에서는 잘 작동하지 않는다. 함수가 데이터베이스에 쓰거나 신용카드를 청구한다면, 다섯 번 실행하는 것은 파괴적이다. N-version selection은 순수 함수, 읽기 전용 쿼리, 혹은 선택 후 부작용을 스테이징할 수 있는 연산에 가장 적합하다.
다섯 가지 variant를 실행하는 데 얼마나 많은 오버헤드가 추가되나?
병렬로 실행하지 않는 한 지연 시간은 가장 느린 variant에 의해 제한된다. CPU와 메모리는 선형적으로 증가한다. 다섯 개에 전념하기 전에 두 개로 시작해서 측정하라. 운영 비용이 선택 로직보다 종종 더 중요하다.