평가 하네스의 내부 구조를 해부합니다. 태스크 정의 시스템, 모델 백엔드 추상화, 실행 엔진의 배칭과 병렬화, 결과 집계와 리포팅까지 설계 패턴을 코드와 함께 분석합니다.
잘 설계된 평가 하네스는 명확한 계층(Layer) 분리를 통해 확장성과 유지보수성을 확보합니다. 각 계층은 독립적으로 변경할 수 있어야 하며, 상위 계층은 하위 계층의 구현 세부사항을 몰라도 동작해야 합니다.
이 아키텍처에서 핵심 원칙은 **관심사의 분리(Separation of Concerns)**입니다. 태스크 정의는 모델이 무엇인지 모르고, 모델 백엔드는 태스크가 무엇인지 모릅니다. 실행 엔진이 이 둘을 연결하는 중재자 역할을 합니다.
태스크(Task)는 평가 하네스에서 가장 중요한 추상화입니다. 하나의 태스크는 "모델에게 무엇을 시킬 것인가"와 "결과를 어떻게 판정할 것인가"를 정의합니다.
from dataclasses import dataclass, field
from typing import Callable, Any
@dataclass
class TaskConfig:
"""평가 태스크의 설정을 정의합니다."""
task_name: str
dataset_path: str
dataset_name: str | None = None
dataset_split: str = "test"
# 프롬프트 구성
prompt_template: str = ""
system_prompt: str | None = None
num_fewshot: int = 0
fewshot_split: str = "validation"
# 메트릭 구성
metrics: list[str] = field(default_factory=lambda: ["accuracy"])
# 출력 처리
output_type: str = "generate" # "generate" | "loglikelihood" | "loglikelihood_rolling"
max_gen_tokens: int = 256
stop_sequences: list[str] = field(default_factory=list)
@dataclass
class TaskInstance:
"""하나의 평가 인스턴스를 나타냅니다."""
task_name: str
instance_id: str
prompt: str
reference: str | list[str]
metadata: dict[str, Any] = field(default_factory=dict)프롬프트 형식은 평가 결과에 결정적인 영향을 미칩니다. 같은 모델, 같은 데이터셋이라도 프롬프트 템플릿에 따라 정확도가 10% 이상 차이 날 수 있습니다.
프롬프트 템플릿을 변경하면 이전 결과와의 비교가 무의미해집니다. 벤치마크 결과를 보고할 때는 반드시 사용한 프롬프트 템플릿을 함께 명시해야 합니다.
from string import Template
class PromptBuilder:
"""태스크 인스턴스로부터 모델 입력 프롬프트를 생성합니다."""
def __init__(self, config: TaskConfig):
self.config = config
self.template = Template(config.prompt_template)
self._fewshot_examples: list[dict] | None = None
def build_prompt(self, instance: dict, fewshot_examples: list[dict] | None = None) -> str:
"""단일 인스턴스에 대한 전체 프롬프트를 조립합니다."""
parts: list[str] = []
# few-shot 예제 추가
if fewshot_examples:
for example in fewshot_examples:
parts.append(self._format_example(example, include_answer=True))
parts.append("") # 구분자
# 실제 질문 추가
parts.append(self._format_example(instance, include_answer=False))
return "\n".join(parts)
def _format_example(self, data: dict, include_answer: bool) -> str:
"""단일 예제를 템플릿에 맞게 포맷합니다."""
text = self.template.safe_substitute(data)
if include_answer and "answer" in data:
text += f" {data['answer']}"
return text평가 하네스가 지원하는 평가 유형은 크게 세 가지로 나뉩니다.
| 유형 | 설명 | 사용 사례 |
|---|---|---|
| Generate | 모델이 텍스트를 생성하고 정답과 비교 | 질의응답, 요약, 번역 |
| Loglikelihood | 각 선택지의 로그 확률을 비교 | 객관식 문제, 문장 완성 |
| Loglikelihood Rolling | 텍스트 전체의 퍼플렉시티 계산 | 언어 모델링 품질 측정 |
Generate 방식은 직관적이지만 출력 파싱이 필요합니다. Loglikelihood 방식은 더 안정적인 결과를 제공하지만 API 모델에서는 토큰 확률에 접근하기 어려운 경우가 많습니다.
평가 하네스의 핵심 설계 원칙 중 하나는 **모델에 대한 불가지론(Model Agnosticism)**입니다. 어떤 모델이든 동일한 인터페이스를 통해 평가할 수 있어야 합니다.
from abc import ABC, abstractmethod
class ModelBackend(ABC):
"""모든 모델 백엔드가 구현해야 하는 인터페이스입니다."""
@abstractmethod
def generate(
self,
prompts: list[str],
max_tokens: int = 256,
temperature: float = 0.0,
stop: list[str] | None = None,
) -> list[str]:
"""프롬프트 목록에 대한 생성 결과를 반환합니다."""
...
@abstractmethod
def loglikelihood(
self,
contexts: list[str],
continuations: list[str],
) -> list[float]:
"""각 (context, continuation) 쌍의 로그 확률을 반환합니다."""
...
@abstractmethod
def model_info(self) -> dict:
"""모델 메타데이터를 반환합니다."""
...
class HuggingFaceBackend(ModelBackend):
"""HuggingFace Transformers 모델 백엔드."""
def __init__(self, model_name: str, device: str = "auto", dtype: str = "float16"):
from transformers import AutoModelForCausalLM, AutoTokenizer
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForCausalLM.from_pretrained(
model_name,
device_map=device,
torch_dtype=dtype,
)
def generate(self, prompts, max_tokens=256, temperature=0.0, stop=None):
# 구현 생략 - 토크나이저와 모델을 사용한 생성
...
def loglikelihood(self, contexts, continuations):
# 구현 생략 - 토큰별 로그 확률 계산
...
def model_info(self):
return {"type": "huggingface", "name": self.model.config.name_or_path}
class OpenAIBackend(ModelBackend):
"""OpenAI API 모델 백엔드."""
def __init__(self, model_name: str = "gpt-4o", api_key: str | None = None):
from openai import OpenAI
self.client = OpenAI(api_key=api_key)
self.model_name = model_name
def generate(self, prompts, max_tokens=256, temperature=0.0, stop=None):
results = []
for prompt in prompts:
response = self.client.chat.completions.create(
model=self.model_name,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=temperature,
stop=stop,
)
results.append(response.choices[0].message.content or "")
return results
def loglikelihood(self, contexts, continuations):
raise NotImplementedError("OpenAI API는 토큰 로그 확률 직접 접근을 제한합니다.")
def model_info(self):
return {"type": "openai", "name": self.model_name}실제 프레임워크에서는 vLLM, TGI(Text Generation Inference), Ollama, Anthropic API 등 다양한 백엔드를 추가로 지원합니다. 각 백엔드는 동일한 추상 인터페이스를 구현하므로, 태스크 정의를 변경하지 않고도 모델을 교체할 수 있습니다.
대규모 평가에서 개별 요청을 하나씩 처리하는 것은 비효율적입니다. 실행 엔진은 여러 요청을 하나의 배치(Batch)로 묶어 처리하여 처리량(Throughput)을 극대화합니다.
import asyncio
from dataclasses import dataclass
@dataclass
class EvalRequest:
instance_id: str
prompt: str
task_name: str
@dataclass
class EvalResult:
instance_id: str
output: str
metadata: dict
class ExecutionEngine:
"""평가 요청의 배칭, 병렬 실행, 캐싱을 관리합니다."""
def __init__(
self,
backend: ModelBackend,
batch_size: int = 32,
max_concurrent: int = 8,
cache_dir: str | None = None,
):
self.backend = backend
self.batch_size = batch_size
self.max_concurrent = max_concurrent
self.cache = EvalCache(cache_dir) if cache_dir else None
self._semaphore = asyncio.Semaphore(max_concurrent)
async def execute_all(self, requests: list[EvalRequest]) -> list[EvalResult]:
"""모든 평가 요청을 배치 단위로 실행합니다."""
# 캐시 히트 확인
cached, uncached = self._partition_cached(requests)
# 미처리 요청을 배치로 분할
batches = self._create_batches(uncached, self.batch_size)
# 배치를 병렬로 실행
results = list(cached)
batch_results = await asyncio.gather(
*(self._execute_batch(batch) for batch in batches)
)
for batch_result in batch_results:
results.extend(batch_result)
if self.cache:
for result in batch_result:
self.cache.store(result)
return results
async def _execute_batch(self, batch: list[EvalRequest]) -> list[EvalResult]:
"""단일 배치를 실행합니다."""
async with self._semaphore:
prompts = [req.prompt for req in batch]
outputs = self.backend.generate(prompts)
return [
EvalResult(
instance_id=req.instance_id,
output=output,
metadata={"task": req.task_name},
)
for req, output in zip(batch, outputs)
]
@staticmethod
def _create_batches(items: list, size: int) -> list[list]:
return [items[i:i + size] for i in range(0, len(items), size)]평가 실행은 비용이 큽니다. API 모델의 경우 토큰 비용이 발생하고, 로컬 모델의 경우 GPU 시간이 소요됩니다. 캐싱은 동일한 입력에 대한 중복 추론을 방지합니다.
캐시 키(Cache Key)는 모델 이름, 프롬프트 전문, 생성 파라미터(temperature, max_tokens 등)의 해시로 구성하는 것이 일반적입니다. temperature가 0이 아닌 경우 결과가 비결정적이므로 캐싱에 주의해야 합니다.
평가 결과의 집계는 단순한 평균 계산 이상의 과정을 포함합니다.
import numpy as np
from scipy import stats
class MetricsAggregator:
"""개별 인스턴스 결과를 태스크 수준 메트릭으로 집계합니다."""
METRIC_FUNCTIONS = {
"accuracy": lambda preds, refs: np.mean([p == r for p, r in zip(preds, refs)]),
"exact_match": lambda preds, refs: np.mean([p.strip() == r.strip() for p, r in zip(preds, refs)]),
}
def aggregate(
self,
predictions: list[str],
references: list[str],
metric_names: list[str],
) -> dict[str, float]:
"""지정된 메트릭들을 계산합니다."""
results = {}
for name in metric_names:
func = self.METRIC_FUNCTIONS.get(name)
if func is None:
raise ValueError(f"지원하지 않는 메트릭: {name}")
results[name] = func(predictions, references)
return results
def compute_confidence_interval(
self,
scores: list[float],
confidence: float = 0.95,
method: str = "bootstrap",
) -> tuple[float, float]:
"""신뢰 구간을 계산합니다."""
if method == "bootstrap":
n_bootstrap = 1000
bootstrap_means = []
for _ in range(n_bootstrap):
sample = np.random.choice(scores, size=len(scores), replace=True)
bootstrap_means.append(np.mean(sample))
lower = np.percentile(bootstrap_means, (1 - confidence) / 2 * 100)
upper = np.percentile(bootstrap_means, (1 + confidence) / 2 * 100)
return (lower, upper)
# Wilson score interval
n = len(scores)
mean = np.mean(scores)
z = stats.norm.ppf((1 + confidence) / 2)
se = z * np.sqrt(mean * (1 - mean) / n)
return (mean - se, mean + se)평가 결과 리포트는 다음과 같은 계층 구조를 가집니다.
EvaluationReport
+-- model_info: 모델 메타데이터
+-- run_config: 실행 설정
+-- timestamp: 실행 시각
+-- task_results[]
+-- task_name: 태스크 이름
+-- metrics: {metric_name: value}
+-- confidence_intervals: {metric_name: (lower, upper)}
+-- num_instances: 평가 인스턴스 수
+-- instance_results[]: 개별 인스턴스 상세 결과
실전에서 자주 사용되는 세 가지 설계 패턴을 살펴보겠습니다.
가장 단순한 형태로, 태스크를 하나씩 순서대로 실행합니다. 구현이 간단하고 디버깅이 쉬운 반면, 전체 실행 시간이 태스크 수에 비례하여 증가합니다.
def run_sequential(tasks: list[TaskConfig], backend: ModelBackend) -> dict:
results = {}
for task in tasks:
dataset = load_dataset(task.dataset_path, task.dataset_name, split=task.dataset_split)
prompts = build_prompts(task, dataset)
outputs = backend.generate(prompts)
metrics = compute_metrics(task.metrics, outputs, dataset["answer"])
results[task.task_name] = metrics
return results여러 태스크를 동시에 실행하고 결과를 모아서 집계합니다. 독립적인 태스크 간의 병렬성을 극대화합니다.
각 평가 단계가 이벤트를 발생시키고, 후속 처리기가 이벤트에 반응합니다. 로깅, 모니터링, 조기 종료 등의 기능을 유연하게 추가할 수 있습니다.
대부분의 상용 평가 하네스는 팬아웃/팬인 패턴을 기본으로 하되, 이벤트 기반 패턴의 요소를 결합하여 사용합니다. 3장에서 살펴볼 lm-evaluation-harness가 대표적인 예시입니다.
3장에서는 이러한 아키텍처 원칙이 실제로 구현된 대표적인 프레임워크인 lm-evaluation-harness를 심층 분석합니다. 200개 이상의 태스크와 25개 이상의 모델 백엔드를 지원하는 이 프레임워크의 내부 구조, 설치 방법, 커스텀 태스크 작성법까지 실전 중심으로 살펴봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
EleutherAI의 lm-evaluation-harness를 심층 분석합니다. 200개 이상의 태스크, 25개 이상의 모델 백엔드, HuggingFace 리더보드 백엔드로서의 역할, 설치부터 커스텀 태스크 작성까지 실전 가이드를 제공합니다.
300개 이상의 모델과 50개 이상의 벤치마크가 공존하는 시대, AI 평가 하네스가 왜 필요한지 그 정의와 핵심 구성요소, 평가 생태계 전체 지도를 살펴봅니다.
Stanford CRFM의 HELM을 분석합니다. 7가지 메트릭 차원, 16가지 핵심 시나리오, HELM Lite와 MedHELM 변형, 실행 방법과 결과 분석까지 종합적 평가 접근법을 탐구합니다.