Agent 단순 A/B 테스트 설계

프롬프트 A vs B, top-k=5 vs 10 — 단일 변수 실험의 설계와 실행

가장 기본적인 Agent A/B 테스트를 설계한다. 단일 변수(프롬프트 변형, retrieval 파라미터)를 대상으로 무작위 배정 단위 결정, 트래픽 분할, 실험 기간 산정까지의 전체 과정을 다룬다. MINERVA QnA Chatbot의 프롬프트 실험을 구체적 예시로 사용한다.

Experimentation
Agent
저자

Kwangmin Kim

공개

2026년 03월 21일

1 정의

정의: 단순 A/B 테스트 (Simple A/B Test)

하나의 실험 변수만 변경하고 나머지는 모두 고정한 상태에서, 두 변형(Control vs Treatment)의 성능 차이를 통계적으로 검정하는 실험이다.

  • 실험 변수: 프롬프트, top-k, chunk size, 모델 등 하나만 변경
  • 대조군(Control): 현재 운영 중인 구성
  • 처치군(Treatment): 변경된 구성
  • 목적: “이 변경이 메트릭을 유의하게 개선하는가?”에 답한다
  • 역학: Randomized Controlled Trial (2-arm, 단일 개입)
  • IT: A/B Test, Split Test

2 실험 설계 5단계

2.1 실험 변수 선정

원칙: 한 번에 하나의 변수만 바꾼다. 두 개 이상을 동시에 바꾸면 어느 변수가 효과의 원인인지 알 수 없다.

MINERVA QnA Chatbot 예시:

실험 변수 Control Treatment
실험 1 시스템 프롬프트 v1 (기본) v2 (도메인 용어집 포함)
실험 2 top-k k=5 k=10
실험 3 검색 전략 Hybrid(BM25+Vector) Vector-only
실험 4 청킹 512 tokens 1024 tokens
실험 1과 실험 2를 동시에 진행하면?

프롬프트도 바꾸고 top-k도 바꾸면, 성능이 개선되었을 때 프롬프트 때문인지 top-k 때문인지 분리할 수 없다. 이를 교란(confounding)이라 한다. 다중 변수를 동시에 실험하려면 다중 비교 실험 설계의 Factorial design을 사용해야 한다.

2.2 무작위 배정 단위 결정

Agent A/B에서 무엇을 기준으로 무작위 배정하는가는 핵심 설계 결정이다.

배정 단위 설명 장점 단점
Query-level 각 질의를 독립적으로 A 또는 B에 배정 표본 크기 최대화, 빠른 수렴 같은 사용자가 A와 B를 번갈아 경험 → 혼란
Session-level 세션 단위로 배정. 한 세션 내 모든 질의는 같은 변형 일관된 사용자 경험 세션 수가 질의 수보다 적음
User-level 사용자 단위로 배정. 한 사용자는 항상 같은 변형 가장 깨끗한 설계, 사용자 간 비교 가능 가장 적은 표본, 수렴 느림

MINERVA 권장: 내부 Agent는 사용자 수가 제한적이므로 Query-level 배정을 기본으로 한다. 단, 멀티턴 대화에서 맥락 일관성이 중요한 경우 Session-level로 전환한다.

import hashlib

def assign_variant(unit_id: str, experiment_id: str, traffic_ratio: float = 0.5) -> str:
    """결정적 해싱 기반 변형 배정

    동일한 unit_id + experiment_id 조합은 항상 같은 변형에 배정된다.
    이를 통해 재현성을 보장한다.

    Args:
        unit_id: 배정 단위 ID (query_id, session_id, 또는 user_id)
        experiment_id: 실험 식별자
        traffic_ratio: Control 비율 (0.5 = 50:50)
    """
    hash_input = f"{experiment_id}:{unit_id}"
    hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest(), 16)
    bucket = (hash_value % 10000) / 10000  # 0~1 사이 값

    return "control" if bucket < traffic_ratio else "treatment"

2.3 가설과 메트릭 확정

실험 시작 전에 문서화한다. 이전 포스트의 메트릭 문서화 템플릿을 사용한다.

예시: 프롬프트 v2 실험

가설: 시스템 프롬프트에 도메인 용어집을 포함하면, 표준화 관련 질의에 대한
      Relevance Score가 0.3점 이상 개선된다.

H₀: μ_treatment - μ_control = 0
H₁: μ_treatment - μ_control ≠ 0

1차 메트릭: Relevance Score (LLM-as-Judge, 1-5)
2차 메트릭: Faithfulness Score, Hit Rate@5
가드레일: Hallucination Rate < 10%, Error Rate < 2%
유의 수준: α = 0.05
검정력: 1-β = 0.80
MDE: 0.3점 (5점 척도 기준)

2.4 트래픽 분할과 실험 기간

from statsmodels.stats.power import TTestIndPower

def calculate_sample_size(
    effect_size: float,
    alpha: float = 0.05,
    power: float = 0.80,
    ratio: float = 1.0  # treatment/control 비율
) -> int:
    """독립 2표본 t-검정 기반 표본 크기 계산"""
    analysis = TTestIndPower()
    n = analysis.solve_power(
        effect_size=effect_size,
        alpha=alpha,
        power=power,
        ratio=ratio,
        alternative="two-sided"
    )
    return int(np.ceil(n))

# 예시: Relevance Score (1-5, σ≈1.0), MDE=0.3
# Cohen's d = 0.3 / 1.0 = 0.3
n_per_group = calculate_sample_size(effect_size=0.3)
# → 약 176 질의/그룹

# MINERVA QnA Chatbot: 일일 ~50건 질의 가정
# 50:50 분할 → 그룹당 25건/일
# 필요 기간: 176 / 25 ≈ 8일
트래픽이 적을 때의 전략

일일 50건으로도 부족하면:

  1. MDE를 키운다: 0.3 → 0.5 (큰 효과만 감지). n이 줄어든다
  2. 분산 감소: 질의 유형별 층화(stratification)로 분산을 줄인다
  3. 오프라인 스크리닝: 오프라인에서 명확한 후보만 온라인으로 보낸다
  4. Sequential testing: 중간에 조기 종료할 수 있게 설계한다 → Sequential Testing 포스트

2.5 실험 실행과 모니터링

import pandas as pd
import numpy as np
from scipy import stats

class SimpleABExperiment:
    """단순 A/B 실험 실행 및 모니터링"""

    def __init__(self, experiment_id: str, config_control: dict, config_treatment: dict):
        self.experiment_id = experiment_id
        self.configs = {"control": config_control, "treatment": config_treatment}
        self.results = []

    def run_query(self, query: str, query_id: str) -> dict:
        """단일 질의 실행: 변형 배정 → Agent 실행 → 결과 기록"""
        variant = assign_variant(query_id, self.experiment_id)
        config = self.configs[variant]

        # Agent 실행
        response = run_agent(config, query)

        # 자동 평가
        scores = auto_evaluate(query, response)

        result = {
            "query_id": query_id,
            "variant": variant,
            "query": query,
            "response": response["answer"],
            "relevance": scores["relevance"],
            "faithfulness": scores["faithfulness"],
            "latency_ms": response["latency_ms"],
            "timestamp": pd.Timestamp.now()
        }
        self.results.append(result)
        return result

    def get_interim_report(self) -> dict:
        """중간 결과 리포트 (실험 중 모니터링용)"""
        df = pd.DataFrame(self.results)
        report = {}
        for metric in ["relevance", "faithfulness", "latency_ms"]:
            ctrl = df[df["variant"] == "control"][metric]
            treat = df[df["variant"] == "treatment"][metric]
            report[metric] = {
                "control_mean": ctrl.mean(),
                "treatment_mean": treat.mean(),
                "diff": treat.mean() - ctrl.mean(),
                "control_n": len(ctrl),
                "treatment_n": len(treat),
            }
        # 가드레일 체크
        treat_df = df[df["variant"] == "treatment"]
        report["guardrail"] = {
            "hallucination_rate": (treat_df["faithfulness"] <= 2).mean(),
            "error_rate": (treat_df["latency_ms"] < 0).mean(),  # 오류 = 음수 플래그
        }
        return report

3 결과 검정

실험이 목표 표본 크기에 도달하면, 사전 정의한 방법으로 검정한다.

핵심 가정

결과 검정의 타당성은 다음 가정에 의존한다:

  • SUTVA (Stable Unit Treatment Value Assumption): 한 질의의 배정이 다른 질의의 결과에 영향을 주지 않는다. Agent 실험에서는 같은 사용자가 Control과 Treatment를 번갈아 경험할 때 학습 효과(carryover)가 발생할 수 있어 위반될 수 있다. 직관적으로, SUTVA는 “옆 테이블 손님의 주문이 내 음식 맛에 영향을 주지 않는다”는 가정이다. 그런데 Agent 실험에서는 같은 사용자가 Treatment의 좋은 응답을 본 뒤 Control의 응답에 더 불만을 느낄 수 있다 — 옆 테이블의 음식을 맛본 뒤 내 음식이 더 별로 느껴지는 것과 같다. Query-level 배정에서 이 위험이 가장 크고, User-level 배정에서 가장 작다.
  • Ignorability (무작위 배정): 결정적 해싱(deterministic hashing)이 올바르게 구현되어 배정이 결과와 독립이다. SRM 검정으로 이를 확인한다. 비유하면, 동전 던지기가 공정한지 확인하는 것이다. 해싱 함수에 버그가 있어 특정 질의 유형이 한쪽에 몰리면, 동전이 기울어진 상태에서 실험한 것과 같다.
  • Positivity: 모든 질의 유형이 두 변형 모두에 배정될 양의 확률을 가진다. 특정 질의 유형이 한쪽에만 배정되면 비교 자체가 불가하다. 예를 들어, “코드 분석” 질의가 전부 Treatment에만 배정되면, Treatment의 낮은 점수가 처치 효과인지 코드 질의의 본래 난이도인지 분리할 수 없다.

3.1 연속형 메트릭 (Relevance Score 등)

def test_continuous_metric(control_scores, treatment_scores, alpha=0.05):
    """독립 2표본 t-검정 (Welch's t-test)"""
    stat, p_value = stats.ttest_ind(
        treatment_scores, control_scores,
        equal_var=False,  # Welch's t-test: 등분산 가정 안 함
        alternative="two-sided"
    )
    diff = np.mean(treatment_scores) - np.mean(control_scores)
    # Cohen's d
    pooled_std = np.sqrt(
        (np.std(control_scores, ddof=1)**2 + np.std(treatment_scores, ddof=1)**2) / 2
    )
    cohens_d = diff / pooled_std

    return {
        "diff": diff,
        "cohens_d": cohens_d,
        "p_value": p_value,
        "significant": p_value < alpha,
        "ci_95": (
            diff - 1.96 * pooled_std * np.sqrt(1/len(control_scores) + 1/len(treatment_scores)),
            diff + 1.96 * pooled_std * np.sqrt(1/len(control_scores) + 1/len(treatment_scores))
        )
    }

3.2 이진형 메트릭 (Hit Rate, Error Rate 등)

from statsmodels.stats.proportion import proportions_ztest

def test_binary_metric(control_hits, control_n, treatment_hits, treatment_n, alpha=0.05):
    """2표본 비율 검정"""
    stat, p_value = proportions_ztest(
        count=[treatment_hits, control_hits],
        nobs=[treatment_n, control_n],
        alternative="two-sided"
    )
    p_ctrl = control_hits / control_n
    p_treat = treatment_hits / treatment_n
    diff = p_treat - p_ctrl

    return {
        "control_rate": p_ctrl,
        "treatment_rate": p_treat,
        "diff": diff,
        "relative_lift": diff / p_ctrl if p_ctrl > 0 else float("inf"),
        "p_value": p_value,
        "significant": p_value < alpha,
    }

4 주의사항

4.1 Peeking Problem

실험 도중 결과를 확인하고 “유의하니까 중단하자”고 결정하면 1종 오류(false positive)가 증가한다. 고정 표본 설계에서는 목표 표본 수에 도달할 때까지 결과로 의사결정하지 않는다.

중간 확인이 필요하면 Sequential Testing을 사용한다.

4.2 SRM (Sample Ratio Mismatch)

50:50으로 배정했는데 실제 수집된 데이터가 55:45라면, 배정 메커니즘이나 로깅에 문제가 있는 것이다. 결과를 해석하기 전에 반드시 SRM 검정을 먼저 수행한다.

from scipy.stats import chisquare

def check_srm(n_control, n_treatment, expected_ratio=0.5):
    """Sample Ratio Mismatch 검정"""
    total = n_control + n_treatment
    expected = [total * expected_ratio, total * (1 - expected_ratio)]
    observed = [n_control, n_treatment]
    stat, p_value = chisquare(observed, expected)
    return {
        "actual_ratio": n_control / total,
        "expected_ratio": expected_ratio,
        "p_value": p_value,
        "srm_detected": p_value < 0.01,  # 매우 보수적 기준
        "action": "실험 중단 후 원인 조사" if p_value < 0.01 else "정상"
    }

5 관련 주제

선행 지식

시리즈 다음 포스트

다른 카테고리 연결

Subscribe

Enjoy this blog? Get notified of new posts by email: