MINERVA Phase C-7 — 스킬 테스트와 품질 게이트 (5계층 테스트 + 골든셋 회귀)

LLM은 비결정적이라 단위 테스트로는 부족하다 — 5계층 테스트와 lifecycle 단계별 게이트로 회귀를 잡는다

C27이 스킬을 정의하고 C28·C29가 카탈로그·조합을 만들었다면, C30은 그 모든 것을 신뢰 가능하게 한다. 본 편은 5계층 테스트(Schema·Snapshot·Golden Eval·Property-based·Pairwise), 골든셋 설계 원칙, CI/CD 자동 게이트(PR → 테스트 → promote), 회귀 처리 워크플로, C22 fused_score와의 결합, C26 lifecycle 단계별 어떤 테스트가 필요한지 정리한다. Phase C-7의 클로저로 스킬 생태계가 시간이 갈수록 안전해진다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 06일

1 왜 5계층인가

LLM은 비결정적이다 — 같은 입력에 같은 출력이 보장되지 않는다. 단위 테스트로 잡을 수 있는 것이 제한적이다.

잡고 싶은 회귀 단일 테스트로 잡을 수 있는가
input/output 형식이 깨짐 Schema test로 잡힘
응답 구조 (필드·타입)가 변함 Snapshot test로 잡힘
의미적 품질 (사실성·완성도)이 떨어짐 Golden eval (사람·LLM judge)
자명한 불변성 (refusal 안 됨, 길이 한도) 깨짐 Property-based test
두 변형 중 어느 게 더 좋은가 Pairwise comparison

각 계층이 다른 회귀를 잡음 — 단일 테스트로는 부족하고, 5계층 모두 운영해야 안전.

[Schema]      input·output 타입 (가장 빠름·가장 결정적)
[Snapshot]    응답 구조 (필드·길이·citation 수)
[Golden Eval] 의미 품질 (사실성·완성도)
[Property]    불변성 (refusal 안 됨, 정책 위반 X)
[Pairwise]    상대 비교 (A vs B, 어느 게 좋은가)

2 계층 1 — Schema Test

가장 결정적. input·output이 SKILL.md 명세와 일치하는지.

# tests/skill_schema.py
from pydantic import ValidationError

def test_skill_input_schema():
    skill = registry.get("summarize_doc")
    InputModel = skill._build_input_model()

    # 정상 입력
    InputModel(document="...", target_length=200)

    # 누락
    with pytest.raises(ValidationError):
        InputModel(target_length=200)

    # 타입 오류
    with pytest.raises(ValidationError):
        InputModel(document=123, target_length="long")


def test_skill_output_schema():
    skill = registry.get("summarize_doc")
    output = skill.execute(document=SAMPLE_DOC)

    # output schema 일치
    assert "summary" in output
    assert isinstance(output["summary"], str)
    assert "citations" in output
    assert isinstance(output["citations"], list)

C20 대화 로깅의 schema 검증과 같은 패턴 — Pydantic이 토대.

3 계층 2 — Snapshot Test

응답의 구조(키·타입·길이 범위·citation 수)가 회귀하지 않는지. 정확한 텍스트는 비교 안 함.

# tests/skill_snapshot.py
from jsonschema import validate

OUTPUT_SCHEMA = {
    "type": "object",
    "required": ["summary", "citations"],
    "properties": {
        "summary": {
            "type": "string",
            "minLength": 50,
            "maxLength": 2000,
        },
        "citations": {
            "type": "array",
            "minItems": 1,                       # 인용은 최소 1개
            "items": {
                "type": "object",
                "required": ["chunk_id"],
            },
        },
    },
}


def test_summarize_doc_snapshot():
    output = skill.execute(document=SAMPLE_DOC)
    validate(instance=output, schema=OUTPUT_SCHEMA)
    assert len(output["summary"].split()) >= 30          # 최소 30 단어
    assert len(output["citations"]) >= 2                  # 최소 2 인용

12-1편 snapshot 테스트 패턴이 토대 — JSON Schema validation으로 LLM 비결정성 우회.

4 계층 3 — Golden Eval

의미적 품질 회귀를 잡는다. 고정 입력·기대 trait으로:

# skills/summarize_doc/tests/golden.yaml
- id: gold_001
  input:
    document: "{full_text_of_doc_1}"
    target_length: 500
  expected_traits:
    contains_facts: ["분기 1회 검토", "vendor 사인 필수"]
    forbidden_phrases: ["가능성", "추정", "외부 자료"]
    citation_required: true
    min_summary_words: 80
    max_summary_words: 600
  acceptable_models: [gpt-4o, claude-3.5]

- id: gold_002
  input:
    document: "{korean_meeting_doc}"
    target_length: 200
  expected_traits:
    primary_language: ko
    contains_decisions: ["BOM 변경 승인"]
    no_speculation: true

평가:

# scripts/golden_eval.py
def run_golden_eval(skill_id: str, model_override=None) -> dict:
    skill = registry.get(skill_id)
    cases = load_golden(f"skills/{skill_id}/tests/golden.yaml")

    results = []
    for case in cases:
        output = skill.execute(**case["input"])
        check = check_traits(output["summary"], case["expected_traits"])
        results.append({"id": case["id"], **check})

    pass_rate = sum(r["pass"] for r in results) / len(results)
    return {"pass_rate": pass_rate, "details": results}


# CI에서
result = run_golden_eval("summarize_doc")
assert result["pass_rate"] >= 0.95, f"golden eval pass rate {result['pass_rate']}"

C22 응답 품질 평가 Golden Set 패턴 그대로 — 운영 monitoring과 같은 인프라 공유.

5 계층 4 — Property-Based Test

자명한 불변성. 어떤 입력에도 만족해야 하는 속성:

# tests/skill_properties.py
from hypothesis import given, strategies as st


@given(text=st.text(min_size=100, max_size=10000))
def test_summarize_no_refusal_for_normal_text(text):
    """일반 텍스트에 refusal 발생하면 안 됨."""
    output = skill.execute(document=text)
    assert not is_refusal(output["summary"]), f"unexpected refusal for {text[:50]}"


@given(text=st.text(min_size=100, max_size=10000))
def test_summarize_length_constraint(text):
    """target_length의 약 ±50% 안에 들어와야 함."""
    output = skill.execute(document=text, target_length=300)
    actual = count_tokens(output["summary"])
    assert 150 <= actual <= 450, f"length {actual} out of range"


@given(text=st.text(min_size=10, max_size=10_000_000))
def test_no_pii_leak(text):
    """input PII가 output에 그대로 노출 안 됨."""
    pii_in_input = extract_pii(text)
    output = skill.execute(document=text)
    pii_in_output = extract_pii(output["summary"])
    assert not (pii_in_input & pii_in_output), "PII leaked"

hypothesis가 다양한 입력 자동 생성. 단일 case로는 못 잡는 경계 케이스 발견.

6 계층 5 — Pairwise Comparison

두 변형 중 어느 게 더 좋은지. A/B 도입 전 사전 비교에 핵심.

# scripts/skill_pairwise.py
from collections import Counter


def pairwise_eval(skill_id: str, version_a: str, version_b: str,
                    cases: list[dict]) -> dict:
    a = registry.get(skill_id, version=version_a)
    b = registry.get(skill_id, version=version_b)

    judgments = Counter()
    for case in cases:
        out_a = a.execute(**case["input"])
        out_b = b.execute(**case["input"])

        # LLM judge — 순서 랜덤 (positional bias 회피)
        if random.random() < 0.5:
            verdict = llm_judge_pair(case["query"], out_a["summary"], out_b["summary"])
        else:
            verdict = llm_judge_pair(case["query"], out_b["summary"], out_a["summary"])
            verdict = flip_verdict(verdict)

        judgments[verdict] += 1

    return {
        "a_wins": judgments["A"],
        "b_wins": judgments["B"],
        "ties": judgments["TIE"],
        "win_rate_b": judgments["B"] / sum(judgments.values()),
    }

LLM judge 편향은 C22와 같은 방어 — 순서 랜덤·여러 judge·양방향 평균.

7 Golden Set 설계 원칙

원칙 이유
다양성 의도·세그먼트·언어·길이 분포 반영 (실제 운영 mix)
사람 라벨 전문가가 “이상적 응답”을 라벨링
edge case 우선 흔한 case는 자동, 드문 case는 사람
고정 후 갱신 일정 매분기 5~10% 교체 (drift 회피)
버전 관리 golden_set 자체도 git tracked + 변경 PR 필요
민감 정보 X 운영 PII는 합성 데이터로 대체
expected_traits로 정확한 텍스트가 아니라 trait (LLM 비결정성 수용)
# skills/summarize_doc/tests/golden.yaml — 골든셋 메타
schema_version: 2
last_updated: 2026-04-01
distribution:
  intents:
    knowledge_lookup: 0.4
    summarize: 0.35
    decision_support: 0.15
    translation: 0.10
  languages:
    ko: 0.65
    en: 0.30
    ja: 0.05
  difficulty:
    easy: 0.5
    medium: 0.3
    hard: 0.2

분포 명시가 중요 — 골든셋이 운영 분포를 반영해야 회귀 신호가 의미 있음.

8 CI/CD 자동 게이트

# .github/workflows/skill_test.yml
on:
  pull_request:
    paths: ["skills/**"]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -e .

      - name: Schema test (모든 스킬)
        run: pytest tests/skill_schema.py

      - name: Snapshot test (변경된 스킬)
        run: |
          for skill in $(git diff --name-only main HEAD | grep "skills/.*/SKILL.md" | xargs dirname); do
            python -m scripts.snapshot_test --skill $skill
          done

      - name: Golden eval (변경된 스킬)
        run: |
          for skill in $(...); do
            python -m scripts.golden_eval --skill $skill
          done

      - name: Property-based (변경된 스킬, hypothesis)
        run: pytest tests/skill_properties.py -k "$CHANGED_SKILLS"

      - name: Pairwise (변경된 스킬 — current active와 비교)
        run: |
          for skill in $(...); do
            python -m scripts.skill_pairwise --new ${skill}@PR --baseline ${skill}@active
          done

      - name: Post results to PR
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({...})

PR에 자동 코멘트 — 테스트 결과·pass rate·pairwise win rate. reviewer가 즉시 판단.

9 회귀 처리 워크플로

[Test 실패]
    ↓
[자동 분류]
├─ Schema 실패 → 즉시 reject (개발자 fix 필요)
├─ Snapshot 실패 → reject + 어느 필드 회귀 명시
├─ Golden eval pass rate ↓ → reject + 어느 case 실패 표시
├─ Property 실패 → reject + 깨진 invariant 표시
└─ Pairwise win < 0.5 → reject (regression vs baseline)
    ↓
[개발자 수정] 또는 [개발자 의도된 변경 정당화]
    ↓
[재테스트]
    ↓
[모두 통과] → reviewer 승인 → 머지 → draft 등록 → C26 lifecycle 진입

골든셋 자체가 outdated여서 실패한 경우: - 별도 PR로 golden.yaml 갱신 (변경 정당화 필수) - skill PR과 함께 머지 안 됨 (분리 — 더블 변경 방지)

10 C22 fused_score와 결합

운영 중 fused_score가 골든셋 baseline 대비 떨어지면 — drift 신호.

# 운영 monitoring (cron 매주)
def detect_skill_quality_drift(skill_id: str):
    operational = clickhouse_avg_fused_score(skill_id, days=7)
    baseline = golden_eval_baseline_score(skill_id)
    if operational < baseline * 0.9:
        alert_owner(skill_id, f"drift {operational:.2f} vs {baseline:.2f}")

골든셋은 사후 검증, fused_score는 실시간 — 둘이 보완. 한쪽만으로는 부족.

11 C26 Lifecycle 단계별 게이트

단계 필수 테스트
draft → canary Schema·Snapshot·Golden ≥ 95%·Property
canary → staged + 7일 운영 metrics·SRM·guardrail
staged → active + Pairwise vs current active 통계 유의
active → deprecated (테스트 X — 후속 버전이 active 됐을 때 자동)
deprecated → retired sunset 기간 동안 의존자 모두 마이그레이션 완료 확인

각 단계 게이트가 자동 — 사람 결정은 governance review 단계만.

12 MINERVA 적용

skills/{skill_id}/
├── SKILL.md
├── prompt.j2
├── fewshot.yaml
└── tests/
    ├── golden.yaml          # 계층 3
    ├── snapshot_schema.json # 계층 2
    └── properties.py        # 계층 4

app/skills/testing/
├── schema_test.py           # 계층 1 (자동)
├── snapshot.py               # 계층 2
├── golden_eval.py            # 계층 3
├── property.py               # 계층 4 (hypothesis)
├── pairwise.py               # 계층 5 (LLM judge)
└── ci_runner.py              # 5 계층 일괄 + 결과 통합

scripts/
├── skill_test_all.py         # 모든 스킬 일괄 (분기 회귀)
├── golden_set_audit.py       # 분포·노후 점검
└── pairwise_evaluate.py      # 임의 두 버전 비교

.github/workflows/
└── skill_test.yml             # PR 자동 트리거

12-1 테스트 패턴·C22 평가·07-1 GitHub Actions 위에 자연스럽게 얹힘.

13 Phase C-7 통합 요약

[C27] 스킬 정의 ─── 4 컴포넌트 + SKILL.md 표준
              │
              ↓
[C28] 레지스트리 ─── 등록·검색·resolve·의존성 그래프
              │
              ↓
[C29] 조합·라우팅 ─── Composition 4 + Router 4 패턴
              │
              ↓
[C30] 테스트·게이트 ─── 5 계층 테스트 + lifecycle 게이트 (이 글)
              │
              ↓
        다시 [C27] 새 스킬 도입

Phase C-7이 완성되면 150+개 스킬을 안전하게 운영할 수 있는 기반이 잡힌다 — 변경이 일주일에 5~10건 발생해도 감당 가능.

14 자주 발생하는 함정

14.1 Test Rot

골든셋이 만들어진 후 1년간 안 갱신 → 운영 use case와 어긋남.

해법: - 분기마다 5~10% 교체 의무 - 갱신 PR도 reviewer 검토 (덜한 case만 남기지 않게) - 운영 query 분포와 골든셋 분포 일치 모니터링

14.2 LLM Judge Bias

pairwise 평가의 LLM이 길이·자기 모델 편향. C22 함정 동일.

해법: 순서 랜덤·여러 judge·사람 calibration·rubric에 간결성 명시.

14.3 Flaky Tests

Property test가 가끔 실패 — hypothesis가 edge case 발견. 매 PR마다 다른 결과.

해법: - random seed 고정 (CI에서) - 새 edge case는 explicit golden case로 추가 - “왜 가끔 실패하는지” 분석 의무 (그냥 retry로 해결 X)

14.4 Golden Set Leakage

골든셋이 학습·prompt에 그대로 들어감 → 자동 통과. 회귀 신호 무력화.

해법: - 골든셋은 별도 저장 (학습 dataset과 분리) - prompt·fewshot에 절대 포함 X - 분기마다 golden set 변경 — 잠재적 leakage 방지

14.5 테스트만 통과·운영은 망가짐

골든셋·property test가 narrow하면 — 통과해도 운영에서 새 type의 query에 실패.

해법: - 운영 중 발견된 회귀를 즉시 골든셋에 추가 (자동 PR) - C22 weak cell 분석 → 새 골든셋 case - 분기마다 distribution audit

14.6 Pairwise 결과 해석 오류

A 51% : B 49% — “A가 약간 좋음”이 아니라 “차이 거의 없음”이 옳다 (통계 무의미).

해법: - pairwise 결과에 신뢰구간 (binomial CI) - 50% 포함 → “통계적 차이 없음” 표시 - C22 통계 검정 패턴 적용

14.7 CI 시간 폭발

5계층 × 150개 스킬 × 3 모델 = 매 PR마다 수백·수천 LLM call.

해법: - 변경된 스킬만 테스트 (git diff 활용) - 모든 스킬 회귀는 매일 cron에서 (PR 시점 X) - pairwise·golden은 사전 cache (같은 input → 같은 output 가정 시) - 무료·저비용 모델로 cheap test 분리

15 정리

영역 핵심
5계층 Schema·Snapshot·Golden·Property·Pairwise
골든셋 다양성·사람 라벨·edge case·분포 명시·git tracked
CI 게이트 PR → 5계층 자동 → 결과 PR 코멘트
회귀 처리 자동 분류 + 개발자 fix 또는 정당화
Lifecycle 결합 단계별 어떤 테스트 통과해야 하는지 명시
C22 결합 골든셋(사전) + fused_score(운영) 보완
함정 rot·judge bias·flaky·leakage·narrow·CI 폭발

16 응용 분야

시나리오 활용
새 스킬 draft → canary Schema·Snapshot·Golden 95%·Property
새 모델 도입 모든 스킬 골든셋 회귀 (acceptable_models 갱신)
Prompt hot-fix 변경 스킬만 5계층 + pairwise vs current active
분기 회귀 점검 모든 스킬 cron 회귀 + 분포 audit
운영 회귀 발견 C22 약점 → 골든셋 추가 → CI 강화
골든셋 갱신 별도 PR + reviewer + 분포 일치 검증

17 관련 주제

선행 학습 (선수 — Phase C-7 전체)

Cross-reference

18-LangGraph 시리즈 cross-reference

  • #22 시스템 프롬프트 평가 — pairwise·judge·rubric 이론적 토대

후속 (Phase C-8 진입)

  • C31~C33 지식 기반 관리 — 본 편 패턴을 문서·인덱스에 확장
  • 지식 컬렉션도 6단계 lifecycle + 5계층 테스트로 운영

Subscribe

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