AI 엔지니어링

골든셋·headless runner로 LLM 판정 게이트 회귀 테스트 작업기

Kir93 2026. 9. 15. 07:53
728x90
반응형

7월 13일, 게이트 마찰을 줄이겠다고 설정 문서 스물네 개를 한꺼번에 손봤다. 커맨드, 스킬, 가이드, 라이프사이클 계약. 그 더미 안에 판정 에이전트 파일의 네 줄짜리 diff가 섞여 있었다.

- verdict is PASS when no CRITICAL or WARNING, REVISE when any CRITICAL or WARNING exists
+ verdict is REVISE when any CRITICAL finding exists, PASS when no CRITICAL exists
+ (WARNING and INFO are report-only and never flip the verdict — CRITICAL is the only decision point)

의도한 변경이었다. 경고 하나에 리뷰가 멈춰 서는 게 성가셨으니까. 그런데 그날부터 WARNING만 있는 모든 diff가 통과로 바뀌었다는 사실을, 나는 아흐레 뒤에야 알았다. 코드였다면 테스트가 빨개졌을 것이다. 하지만 게이트의 채점 기준은 코드가 아니라 프롬프트와 문서로 되어 있고 프롬프트를 고쳐도 빨개지는 건 아무것도 없다.

그래서 만든 게 이 글의 주제다. 코드의 unit test에 대응하는, 채점하는 쪽의 회귀 그물. Claude Code나 비슷한 하네스로 리뷰·계획 게이트를 돌리고 있고, 그 게이트의 rubric이나 정책을 손볼 일이 있는 사람이라면 그대로 가져다 쓸 수 있는 구조다. (2026년 7월 기준 구현·실측)

여기서 LLM-as-judge는 모델이 낸 산출물을 사람 대신 다른 모델이 채점하는 구조를 말한다. 내 경우엔 세 종류를 쓴다. 스펙 문장의 모호성을 점수로 매기는 점수 모드, 스펙과 diff가 어긋났는지 분류하는 정합 모드, diff에서 결함을 찾아 심각도를 붙이는 리뷰 모드.

심판이 흔들리는데 무슨 수로 스냅샷을 뜨나

회귀 테스트의 전제는 "같은 입력 → 같은 출력"이다. LLM 심판은 그 전제를 지키지 않는다. 같은 diff를 세 번 넣으면 결함 개수가 2개, 3개, 2개로 나온다. 순진하게 exact-match로 비교하면 그물은 매 실행 빨개지고 빨간불이 기본값이 되는 순간 아무도 안 본다.

세 가지를 정하고 시작했다.

우선 자연어는 스냅샷에 넣지 않는다. 심판 출력에서 justification 같은 서술 필드는 매번 문장이 달라진다. 고정할 건 분류 라벨과 심각도 집합, 그리고 개수뿐이다.

점수는 밴드로, 분류는 다수결로 비교한다. 점수 모드는 한 번만 돌리고 값이 허용 밴드 안이면 통과, 라벨은 정확히 일치할 것. 분류 모드는 N번 돌려(기본 3회) 다수 라벨을 취한다. 단발 이상치는 회귀로 치지 않는다.

마지막으로 fixture는 라벨 경계에서 먼 것을 고른다. 애매한 케이스는 그 자체가 노이즈 발생기다. 경계에 걸린 결함은 매 실행 심각도를 왕복한다.

기대 스냅샷은 이렇게 짧다.

// golden/semantic-review/warning-edgecase.input.json — 고정 입력
{
  "mode": "semantic-review",
  "diff": "+ export function average(nums: number[]): number {\n+   return nums.reduce((a, b) => a + b, 0) / nums.length;\n+ }",
  "rules_in_scope": "none",
  "context": "빈 배열 입력이 가능한 호출 경로 존재. 크래시는 아니나 NaN 전파 위험.",
  "spec_acceptance": null
}
// golden/semantic-review/warning-edgecase.expected.json — 기대 스냅샷
{ "verdict": "PASS", "severities": ["WARNING"], "critical_count": 0 }

규칙이 하나 더 있다. 정답을 입력에 절대 넣지 않는다. recall을 채점하려고 "이 diff에는 결함이 세 개 심어져 있다"는 목록을 만들어 뒀는데, 그걸 context에 넣으면 심판이 커닝을 한다. 그 목록은 기대 스냅샷의 비교 제외 필드(_planted)에만 둔다. 비교기는 아는 필드만 읽으므로 나머지는 무해하다.

비교기 안에는 LLM이 없어야 한다

runner는 두 조각이다. 고정 입력으로 심판을 headless로 다시 부르는 셸 스크립트, 그리고 그 출력을 기대 스냅샷과 대조하는 Node 비교기. 심판은 세션 안에서만 사는 subagent라 순수 스크립트가 직접 못 부르는데, claude -p --agent <name> --output-format json 헤드리스 경로가 열려 있어서 우회했다.

비교기 쪽에는 모델을 한 톨도 넣지 않았다. 그물의 판정마저 흔들리면 아무것도 못 믿는다.

// N회 실행 결과에서 다수 라벨을 뽑는다. 단순 최빈값이라 동률이면 임의의 한쪽이 이긴다.
function majority(values) {
  const counts = new Map();
  for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
  let best = null;
  let bestN = -1;
  for (const [v, n] of counts) if (n > bestN) { best = v; bestN = n; }
  return { value: best, n: bestN, total: values.length };
}

// 분류 모드 비교: 기대 라벨과 같은지 + 그 다수가 '과반 초과'인지.
function compareVerdict(expected, runs) {
  const maj = majority(runs.map((r) => r.verdict));
  // edge case: N=2에서 1:1이면 최빈값은 나오지만 과반은 아니다.
  // 라벨이 흔들린다는 것 자체가 신호이므로 이때도 회귀로 본다.
  const strict = maj.n * 2 > maj.total;

  if (maj.value !== expected.verdict) {
    return { regression: true, why: `expected ${expected.verdict}, got ${maj.value} (${maj.n}/${maj.total})` };
  }
  if (!strict) {
    return { regression: true, why: `${maj.value} 가 과반 아님 (${maj.n}/${maj.total}) — label unstable` };
  }
  return { regression: false, why: `${maj.value} (majority ${maj.n}/${maj.total})` };
}

처음엔 심각도 집합과 개수까지 전부 hard check로 걸었다. 그랬더니 결함 하나가 CRITICAL과 WARNING 사이를 오가는 fixture에서 개수가 2와 3을 왕복했고 실행할 때마다 없는 회귀가 잡혔다. 지금은 그런 fixture의 critical_count를 스냅샷에서 통째로 뺀다. 비교기는 없는 필드를 건너뛰므로 라벨만 고정된다. 흔들리는 필드를 hard check로 두는 건 그물이 아니라 알람 고장이다.

그물이 실제로 잡은 것

여기서부터가 이 글을 쓰게 만든 부분이다. 만들 때는 "언젠가 쓸모 있겠지" 정도였는데, 두 달 동안 네 번 걸렸다. 그중 셋은 내가 몰랐던 변화였다.

7월 20일 — 그물이 자기가 깨진 걸 먼저 잡았다. 판정 계약을 모드별 파일로 쪼개면서 "호출자가 계약 전문을 프롬프트에 인라인해야 하며 심판은 계약 파일을 스스로 열지 않는다"가 새 규약이 됐다. runner는 옛날 방식 그대로 "네 리뷰 모드 계약대로 채점해"라고만 불렀고, 심판은 계약을 못 찾아 mode: error를 뱉었다. 고친 방향은 프롬프트를 다듬는 쪽이 아니었다. 실제 게이트 호출자가 하는 그대로 모드 계약 전문을 프롬프트에 통째로 붙여 넣었다. runner가 게이트 호출을 흉내 내면 계약이 바뀔 때마다 따로 깨진다. 재현해야 한다.

7월 22일 — 도입부의 그 네 줄. 계약을 고쳐 다시 돌리자 두 개가 움직였다. warning-edgecase fixture(빈 배열에서 NaN을 반환하는 average)가 REVISE에서 PASS로 내려왔고, React 훅 fixture의 심각도 집합에 CRITICAL이 새로 생겼다. 첫 번째는 7월 13일 정책 변경의 직접적인 귀결이다. 의도한 변화였다. 두 번째는 부수 효과였다. WARNING이 판정을 못 뒤집게 되자 심판이 "그럼 이건 CRITICAL이다" 쪽으로 밀렸다. 둘 다 회귀가 아니라고 판단하고 기대 스냅샷을 새로 떴다.

여기서 그물의 값어치가 분명해졌다. 막는 게 아니라, 바뀐 걸 아느냐를 묻는 장치다. 아흐레 동안 나는 리뷰 게이트가 이전과 다른 기준으로 코드를 통과시킨다는 걸 몰랐다.

7월 27일 — 설정은 한 글자도 안 고쳤는데 판정이 움직였다. 판단 클래스의 모델 pin을 Opus 5로 올렸더니, React 훅 fixture에서 심어둔 결함 두 개가 모두 CRITICAL로 올라와 개수가 1에서 2가 됐다(3회 중 2회 다수). 심은 적 없는 SSR 크래시까지 하나 더 찾아냈다. 게이트 성능이 좋아진 것이니 반가운 변화다. 다만 기준선을 다시 박지 않으면 그날부터 그물은 영구히 빨간불이다. 이때 runner에 EVAL_MODEL 오버라이드를 넣었다. 모델을 고정할 수 없으면 회귀 그물은 성립하지 않는다. 무엇이 바뀌었는지 지목할 수 없기 때문이다.

7월 30일 — 그물이 초록불을 위조하고 있었다. 요약 줄은 "fixture 10건 실행, regression 0건"이었는데, 실제로는 몇 건이 파싱조차 안 된 상태였다. 비교기는 심판 출력이 계약 위반이면 exit 2로 죽는데, runner가 그 실패를 그냥 흘려보내면서 실행 1건, 회귀 0건으로 셌다. 통과와 구분이 안 되는 실패는 실패보다 나쁘다.

# 이전: compare 실패도 조용히 "실행 1건 / 회귀 0건"으로 집계됐다.
# report="$(node compare.mjs "$mode" "$expected" "$@")"
# ran=$((ran + 1))

# 이후: exit 2(=출력 파싱 실패, 계약 위반)는 통과가 아니라 '무효 런'으로 따로 센다.
if report="$(node "$EVAL_DIR/compare.mjs" "$mode" "$expected" "$@" 2>&1)"; then
  echo "$report"
  ran=$((ran + 1))
  reg="$(printf '%s\n' "$report" | sed -n 's/^R2-RESULT: \([0-9]*\) .*/\1/p')"
  total_reg=$((total_reg + ${reg:-0}))
else
  echo "[$mode/$name] ⚠️ 판정 무효 (compare 실패): $(printf '%s\n' "$report" | head -n1)"
  invalid=$((invalid + 1))
fi

리포트는 이런 모양으로 나온다(형식 그대로, 수치는 7월 22일 실행을 재구성한 것).

[semantic-review] REGRESSION (runs=3)
  - verdict: expected REVISE, got majority PASS (3/3)
R2-RESULT: 1 regression(s)

==== R2 요약: fixture 10건 실행, regression 1건, 무효 0건 (비차단) ====

차단하지 않기로 한 게 이 그물을 살렸다

네 번 중 세 번이 "의도한 변화"였다. 이걸 mandatory 게이트로 만들었다면 세 번 다 내 커밋을 막았을 테고 나는 일주일 안에 그물을 껐을 것이다.

그래서 runner는 회귀를 찾든 못 찾든 exit 0으로 끝난다. 신호는 stdout 요약한 줄 뿐이고 판단은 사람이 한다. 라벨이 움직였으면 의도한 변경인지 보고, 맞으면 기대 스냅샷을 갱신하고 아니면 설정을 되돌린다. 대신 잊어버리는 문제는 알림으로 막았다. 게이트 채점 정의 파일을 건드린 커밋이면 post-commit hook이 한 줄 찍는다.

if printf '%s\n' "$changed" | grep -qE "$GATE_PATTERN"; then
  echo "⚠️  게이트 채점 파일 변경 감지 — 회귀 그물 권장: shared/eval/run.sh"
fi

이 hook에는 구멍이 있다. 저장소 커밋을 기준으로 하니 글로벌 설정을 직접 편집하고 미러에 커밋하지 않으면 못 잡는다. 알고 남겨둔 한계다. 편집 즉시 감지하려면 커밋이 아니라 툴 사용 시점에 거는 hook이 필요한데, 아직 안 만들었다.

비용도 정직하게 말해두면, 골든 셋, 열 개를 다 돌리면 분류 모드는 fixture당 3회씩 심판을 호출한다. 옵트인·온디맨드로만 돌리는 이유고, 골든 셋을 일부러 작게 유지하는 이유다.


프롬프트와 정책 문서는 채점 동작을 바꾸는 코드다. 코드처럼 회귀 테스트를 걸어야 한다. LLM 심판의 비결정성은 exact-match를 포기하고 허용 밴드와 다수결 라벨로 흡수하면 실용적인 그물이 된다. 그리고 이 그물은 반드시 비차단이어야 한다 — 잡히는 것 대부분이 회귀가 아니라 의도한 변화이기 때문이다.

게이트 rubric을 마지막으로 고친 게 언제인지 떠올려 보고, 그때 판정이 어떻게 달라졌는지 지금 fixture 두 개로 확인해 보라. 통과 하나, 실패 하나면 시작으로 충분하다.

참고

  • OpenAI Cookbook, Build an Agent Improvement Loop with Traces, Evals, and Codex — "나중에 다시 돌릴 수 있는 eval"로 평가를 재사용 가능한 테스트 스위트로 다루는 메커니즘. 이 글의 골든 셋 구조는 그 아이디어를 게이트 판정 도메인으로 옮긴 것이다.
반응형