LLM 애플리케이션에서 A/B 테스트를 설계하고 실행하는 방법, 통계적 유의성 판단, 실험 결과 해석을 다룹니다.
오프라인 평가는 미리 준비된 데이터셋에서 성능을 측정합니다. 이 방식은 유용하지만, 근본적인 한계가 있습니다.
첫째, 평가 데이터셋이 실제 사용자 질문의 다양성을 완전히 반영하지 못합니다. 둘째, 오프라인 메트릭 개선이 실제 사용자 경험 개선으로 반드시 이어지지는 않습니다. 셋째, 사용자 행동 패턴(클릭률, 재질문 비율, 세션 시간 등)은 오프라인에서 측정할 수 없습니다.
A/B 테스트는 실제 프로덕션 트래픽의 일부를 새로운 변형(Variant)에 노출시켜, 통제된 환경에서 변경 사항의 실제 영향을 측정합니다.
전통적인 웹 A/B 테스트와 LLM A/B 테스트는 몇 가지 중요한 차이점이 있습니다.
같은 입력에 대해 매번 다른 출력이 나오므로, 차이가 실험 변형 때문인지 모델의 확률적 특성 때문인지 구분하기 어렵습니다.
클릭률이나 전환율 같은 단일 메트릭이 아니라, 정확성, 유용성, 안전성 등 다차원 메트릭을 동시에 추적해야 합니다.
사용자가 응답을 읽고 판단하기까지 시간이 걸리며, 잘못된 답변의 영향이 즉시 드러나지 않을 수 있습니다.
LLM A/B 테스트에서는 전통적 A/B 테스트보다 더 큰 표본 크기가 필요합니다. 출력의 변동성이 크기 때문에, 실험 변형 간의 진정한 차이를 감지하려면 더 많은 데이터가 필요합니다.
모든 실험은 명확한 가설에서 시작합니다.
experiment_design = {
"name": "cot-prompt-v2",
"hypothesis": "Chain-of-Thought 프롬프트를 적용하면 "
"QA 정확도가 5% 이상 향상될 것이다",
"primary_metric": "answer_accuracy",
"secondary_metrics": [
"user_satisfaction_rating",
"response_latency_p95",
"token_usage_mean",
],
"guardrail_metrics": [
"toxicity_rate", # 악화되면 안 되는 메트릭
"error_rate",
],
"variants": {
"control": {
"description": "기존 프롬프트",
"prompt_version": "v1.2",
},
"treatment": {
"description": "CoT 프롬프트 적용",
"prompt_version": "v2.0-cot",
},
},
"traffic_split": {"control": 50, "treatment": 50},
"min_sample_size": 1000,
"max_duration_days": 14,
}통계적으로 유의미한 결과를 얻기 위한 최소 표본 크기를 사전에 계산합니다.
import math
def calculate_sample_size(
baseline_rate: float,
minimum_detectable_effect: float,
alpha: float = 0.05,
power: float = 0.80
) -> int:
"""필요한 최소 표본 크기를 계산합니다.
baseline_rate: 현재 지표 값 (예: 정확도 0.75)
minimum_detectable_effect: 감지하고자 하는 최소 효과 크기
alpha: 유의 수준 (1종 오류 확률)
power: 검정력 (1 - 2종 오류 확률)
"""
from scipy import stats
z_alpha = stats.norm.ppf(1 - alpha / 2) # 양측 검정
z_beta = stats.norm.ppf(power)
p1 = baseline_rate
p2 = baseline_rate + minimum_detectable_effect
p_avg = (p1 + p2) / 2
numerator = (z_alpha * math.sqrt(2 * p_avg * (1 - p_avg))
+ z_beta * math.sqrt(p1 * (1 - p1) + p2 * (1 - p2))) ** 2
denominator = (p2 - p1) ** 2
n = math.ceil(numerator / denominator)
return n # 각 그룹당 필요한 표본 수
# 예시: 정확도 75%에서 5% 개선을 감지하려면
sample_per_group = calculate_sample_size(
baseline_rate=0.75,
minimum_detectable_effect=0.05
)
print("그룹당 필요 표본 수: " + str(sample_per_group))동일한 사용자가 항상 같은 실험군에 배정되도록 결정론적 해싱을 사용합니다.
import hashlib
class TrafficRouter:
"""실험 트래픽 분배를 관리합니다."""
def __init__(self, experiment_id: str, traffic_split: dict):
self.experiment_id = experiment_id
self.traffic_split = traffic_split
self._validate_split()
def _validate_split(self):
total = sum(self.traffic_split.values())
assert total == 100, "트래픽 비율의 합이 100이어야 합니다"
def assign_variant(self, user_id: str) -> str:
"""사용자를 실험 변형에 배정합니다."""
hash_input = self.experiment_id + ":" + user_id
hash_value = hashlib.sha256(hash_input.encode()).hexdigest()
bucket = int(hash_value[:8], 16) % 100
cumulative = 0
for variant, percentage in self.traffic_split.items():
cumulative += percentage
if bucket < cumulative:
return variant
return list(self.traffic_split.keys())[-1]
# 사용 예시
router = TrafficRouter(
experiment_id="cot-prompt-v2",
traffic_split={"control": 50, "treatment": 50}
)
variant = router.assign_variant("user-12345")
print("배정된 변형: " + variant)새로운 변형의 위험을 최소화하기 위해, 트래픽을 점진적으로 늘리는 전략입니다.
class GradualRollout:
"""점진적 트래픽 롤아웃을 관리합니다."""
def __init__(self, experiment_id: str):
self.experiment_id = experiment_id
self.stages = [
{"treatment_pct": 5, "duration_hours": 24, "auto_advance": True},
{"treatment_pct": 25, "duration_hours": 48, "auto_advance": True},
{"treatment_pct": 50, "duration_hours": 72, "auto_advance": False},
]
self.current_stage = 0
def should_advance(self, metrics: dict) -> dict:
"""다음 단계로 진행할 수 있는지 판단합니다."""
guardrails = {
"error_rate": {"max": 0.05},
"toxicity_rate": {"max": 0.01},
"latency_p95": {"max": 5.0},
}
violations = []
for metric, threshold in guardrails.items():
if metric in metrics:
if metrics[metric] > threshold["max"]:
violations.append({
"metric": metric,
"value": metrics[metric],
"threshold": threshold["max"],
})
return {
"can_advance": len(violations) == 0,
"violations": violations,
"next_stage": self.current_stage + 1,
}from datetime import datetime
def log_experiment_event(
experiment_id: str,
variant: str,
user_id: str,
request_data: dict,
response_data: dict,
metrics: dict
) -> dict:
"""실험 이벤트를 구조화하여 로깅합니다."""
event = {
"timestamp": datetime.utcnow().isoformat(),
"experiment_id": experiment_id,
"variant": variant,
"user_id": user_id,
"request": {
"input": request_data.get("input"),
"prompt_version": request_data.get("prompt_version"),
"model": request_data.get("model"),
},
"response": {
"output": response_data.get("output"),
"input_tokens": response_data.get("input_tokens"),
"output_tokens": response_data.get("output_tokens"),
"latency_ms": response_data.get("latency_ms"),
},
"metrics": metrics,
}
return eventLLM 애플리케이션에서 수집할 수 있는 간접적인 품질 지표입니다.
user_behavior_metrics = {
"thumbs_up_rate": {
"description": "사용자가 좋아요를 누른 비율",
"calculation": "thumbs_up_count / total_responses",
"higher_is_better": True,
},
"thumbs_down_rate": {
"description": "사용자가 싫어요를 누른 비율",
"calculation": "thumbs_down_count / total_responses",
"higher_is_better": False,
},
"regeneration_rate": {
"description": "사용자가 응답 재생성을 요청한 비율",
"calculation": "regeneration_count / total_responses",
"higher_is_better": False,
},
"follow_up_question_rate": {
"description": "같은 주제로 후속 질문을 한 비율",
"calculation": "follow_up_count / total_sessions",
"higher_is_better": False, # 한 번에 해결이 이상적
},
"copy_rate": {
"description": "코드/텍스트 복사 비율",
"calculation": "copy_events / total_responses",
"higher_is_better": True, # 유용한 응답의 지표
},
}from scipy import stats
import numpy as np
def proportion_z_test(
control_successes: int,
control_total: int,
treatment_successes: int,
treatment_total: int,
alpha: float = 0.05
) -> dict:
"""두 비율 간의 차이에 대한 Z-검정을 수행합니다."""
p_control = control_successes / control_total
p_treatment = treatment_successes / treatment_total
p_pooled = (control_successes + treatment_successes) / (
control_total + treatment_total
)
se = math.sqrt(
p_pooled * (1 - p_pooled) * (1/control_total + 1/treatment_total)
)
if se == 0:
return {"error": "표준 오차가 0입니다"}
z_stat = (p_treatment - p_control) / se
p_value = 2 * (1 - stats.norm.cdf(abs(z_stat)))
# 효과 크기 (절대 차이)
absolute_effect = p_treatment - p_control
relative_effect = absolute_effect / p_control if p_control > 0 else 0
# 신뢰 구간
ci_margin = stats.norm.ppf(1 - alpha/2) * se
ci_lower = absolute_effect - ci_margin
ci_upper = absolute_effect + ci_margin
return {
"control_rate": round(p_control, 4),
"treatment_rate": round(p_treatment, 4),
"absolute_effect": round(absolute_effect, 4),
"relative_effect": round(relative_effect, 4),
"z_statistic": round(z_stat, 4),
"p_value": round(p_value, 6),
"significant": p_value < alpha,
"confidence_interval": [round(ci_lower, 4), round(ci_upper, 4)],
}def continuous_metric_test(
control_values: list,
treatment_values: list,
alpha: float = 0.05
) -> dict:
"""연속형 메트릭의 차이를 Welch's t-검정으로 분석합니다."""
control = np.array(control_values)
treatment = np.array(treatment_values)
t_stat, p_value = stats.ttest_ind(control, treatment, equal_var=False)
return {
"control_mean": round(float(np.mean(control)), 4),
"control_std": round(float(np.std(control)), 4),
"treatment_mean": round(float(np.mean(treatment)), 4),
"treatment_std": round(float(np.std(treatment)), 4),
"absolute_effect": round(
float(np.mean(treatment) - np.mean(control)), 4
),
"t_statistic": round(float(t_stat), 4),
"p_value": round(float(p_value), 6),
"significant": float(p_value) < alpha,
}다수의 메트릭을 동시에 검정할 때는 다중 비교 문제(Multiple Comparison Problem)가 발생합니다. 10개 메트릭을 각각 5% 유의 수준으로 검정하면, 아무 효과가 없어도 1개 이상에서 유의미한 결과가 나올 확률이 약 40%에 달합니다. Bonferroni 보정이나 Benjamini-Hochberg 보정을 적용하여 유의 수준을 조정해야 합니다.
def bonferroni_correction(p_values: list, alpha: float = 0.05) -> dict:
"""Bonferroni 보정을 적용합니다."""
n_tests = len(p_values)
adjusted_alpha = alpha / n_tests
results = []
for i, p in enumerate(p_values):
results.append({
"original_p": round(p, 6),
"adjusted_alpha": round(adjusted_alpha, 6),
"significant": p < adjusted_alpha,
})
return {
"method": "bonferroni",
"n_tests": n_tests,
"original_alpha": alpha,
"adjusted_alpha": round(adjusted_alpha, 6),
"results": results,
}class ExperimentDecision:
"""실험 종료 여부와 결과를 판단합니다."""
def __init__(self, experiment: dict):
self.experiment = experiment
def should_stop(self, current_data: dict) -> dict:
"""실험을 종료해야 하는지 판단합니다."""
reasons = []
# 1. 최소 표본 크기 달성 확인
min_size = self.experiment["min_sample_size"]
if current_data["total_samples"] < min_size:
return {
"should_stop": False,
"reason": "최소 표본 크기 미달 ("
+ str(current_data["total_samples"])
+ "/" + str(min_size) + ")",
}
# 2. 가드레일 위반 확인 (즉시 중단)
for metric, threshold in self.experiment.get("guardrails", {}).items():
if current_data.get(metric, 0) > threshold:
return {
"should_stop": True,
"decision": "중단 - 가드레일 위반",
"reason": metric + " 값이 임계치 초과",
}
# 3. 통계적 유의성 달성
primary = current_data.get("primary_metric_analysis", {})
if primary.get("significant") and primary.get("absolute_effect", 0) > 0:
reasons.append("주요 메트릭에서 유의미한 개선 확인")
# 4. 최대 기간 초과
if current_data.get("duration_days", 0) >= self.experiment["max_duration_days"]:
reasons.append("최대 실험 기간 도달")
if reasons:
return {
"should_stop": True,
"decision": "종료",
"reasons": reasons,
}
return {"should_stop": False, "reason": "실험 진행 중"}A/B 테스트는 LLM 애플리케이션의 변경 사항이 실제 사용자에게 미치는 영향을 정량적으로 측정하는 핵심 방법론입니다. LLM의 비결정론적 특성으로 인해 더 큰 표본이 필요하며, 다차원 메트릭의 동시 추적과 다중 비교 보정이 중요합니다.
점진적 롤아웃과 가드레일 메트릭을 통해 위험을 최소화하고, 통계적으로 엄밀한 분석으로 의사결정의 신뢰도를 높여야 합니다.
다음 장에서는 A/B 테스트를 포함한 모든 프로덕션 활동의 기반이 되는 로깅과 관찰 가능성(Observability)을 다룹니다.
이 글이 도움이 되셨나요?