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 전체)
- C27 스킬 정의 — 테스트 단위
- C28 스킬 레지스트리 — CI 자동 등록 토대
- C29 스킬 조합 — composition·router도 테스트 대상
Cross-reference
- 12-1 고급 테스트 패턴 — snapshot 토대
- C22 응답 품질 평가 — Golden Set·LLM judge·fused_score
- C26 에이전트 생명주기 — 단계별 테스트 게이트
- 07-1 GitHub Actions — CI 자동 트리거
18-LangGraph 시리즈 cross-reference
- #22 시스템 프롬프트 평가 — pairwise·judge·rubric 이론적 토대
후속 (Phase C-8 진입)
- C31~C33 지식 기반 관리 — 본 편 패턴을 문서·인덱스에 확장
- 지식 컬렉션도 6단계 lifecycle + 5계층 테스트로 운영