검증자 모델: 답을 고르는 눈을 따로 기른다
규칙 검증기, 결과 보상 모델(ORM), 과정 보상 모델(PRM), 자기 검증의 특성과 한계를 비교하고, 여러 후보 중 정답을 골라내는 검증 계층을 실무에서 어떻게 구성할지 코드와 함께 정리합니다.
지난 글에서 강화학습 훈련이 채점 가능한 문제에서만 성립한다고 정리했는데, 그 “채점”을 담당하는 부품을 검증자(verifier)라고 부른다. 그런데 검증자는 훈련에만 쓰이는 것이 아니다. 후보를 여러 개 뽑아 놓고 하나를 고를 때, 에이전트가 다음 단계로 넘어가도 되는지 판단할 때, 사용자에게 답을 보여도 되는지 결정할 때 전부 같은 부품이 필요하다. 생성 품질보다 검증 품질이 시스템 성능을 결정하는 경우가 실무에서는 오히려 더 많다.
왜 생성보다 검증이 중요한가
앞서 여러 번 나온 구조를 다시 보면, 컴퓨트를 늘려 정확도를 사는 모든 기법이 같은 지점에 의존한다. 후보를 N개 만드는 것은 쉽다. 온도를 올려 N번 호출하면 끝이다. 어려운 것은 그중 맞는 것을 고르는 일이다.
이 격차를 수치로 보면 명확하다. 후보 8개 중 적어도 하나가 정답인 비율을 pass@8, 실제로 고른 답이 정답인 비율을 정확도라고 할 때, 실무에서 흔히 보는 형태는 이렇다.
| 검증 방식 | 정확도 | pass@8 대비 회수율 |
|---|---|---|
| 첫 번째 후보 사용 | 52% | 60% |
| 다수결 | 66% | 76% |
| 자기 검증 프롬프트 | 61% | 71% |
| 학습된 검증자 | 78% | 90% |
| 규칙 검증기(테스트 실행) | 84% | 97% |
| pass@8 상한 | 87% | 100% |
후보 안에는 이미 정답이 87% 들어 있는데, 고르는 방식에 따라 52%부터 84%까지 갈린다. 생성 모델을 바꾸지 않고도 30%포인트가 움직인다. 모델 업그레이드로 얻기 어려운 폭이다.
네 가지 검증 방식
순서가 중요하다. 위쪽일수록 신뢰도가 높고 비용이 싸므로, 가능한 부분을 위에서 최대한 소진한 뒤 남은 것만 아래로 내리는 것이 원칙이다.
규칙·실행 검증기는 답이 명확한 형식을 가질 때 쓴다. 코드면 실행, JSON이면 스키마 검증, SQL이면 파싱 후 실행 계획 확인, 수식이면 심볼릭 계산으로 대조한다. 판정이 확실하고 비용이 거의 없다. 문제는 적용 가능한 작업이 한정된다는 점인데, 실무에서는 적용 가능한 부분을 과소평가하는 경우가 훨씬 많다. 예를 들어 “이 문서에서 금액을 추출하라”는 작업도 추출된 금액이 원문에 문자열로 존재하는지 확인하는 것만으로 상당수의 환각을 걸러 낼 수 있다.
**결과 보상 모델(ORM)**은 문제와 최종 답을 함께 입력받아 “이 답이 맞을 확률”을 출력하는 별도 모델이다. 정답 라벨만 있으면 학습 데이터를 만들 수 있어서 비용이 낮다. 대신 중간 과정을 보지 않으므로 어디서 틀렸는지는 알려 주지 못한다.
**과정 보상 모델(PRM)**은 풀이의 각 단계마다 점수를 매긴다. 오류가 발생한 지점을 짚어 주므로 그 단계에서 생성을 끊고 다시 시작할 수 있다.
자기 검증은 같은 모델에게 자기 답을 검토시키는 것이다. 추가 부품이 필요 없어 매력적이지만, 모델이 자기 실수를 못 보는 경향이 강해서 단독으로는 신뢰하기 어렵다.
ORM과 PRM의 실질적 차이
차이는 정확도가 아니라 개입 시점이다. ORM은 다 만들고 나서 버릴지 말지만 정한다. PRM은 만드는 도중에 끊는다.
이 차이가 비용으로 이어진다. 5단계 풀이의 3단계에서 틀렸다면, ORM 방식은 5단계를 전부 생성한 뒤 0점을 주고 버린다. PRM 방식은 3단계에서 점수가 무너지는 것을 보고 4·5단계를 아예 생성하지 않는다. 후보를 16개 뽑는 상황이라면 이 절약이 상당하다.
대신 PRM은 학습 데이터를 만들기가 훨씬 어렵다. 단계마다 옳고 그름을 라벨링해야 하는데, 사람이 하기엔 비싸고 자동화하려면 각 단계에서 여러 번 롤아웃해 도달 가능성을 추정하는 식의 우회가 필요하다. 그래서 실무에서 PRM을 자체 구축하는 경우는 드물고, 트리 탐색을 반드시 써야 하는 상황이 아니면 ORM으로 충분한 경우가 많다.
검증 계층을 실제로 짜기
핵심은 하나만 쓰지 않는 것이다. 값싼 것부터 순서대로 걸러 낸다.
from dataclasses import dataclass
from typing import Callable
@dataclass
class Candidate:
text: str
answer: str | None
score: float = 0.0
rejected_by: str | None = None
def rule_filter(c: Candidate, task: dict) -> bool:
"""비용 0에 가까운 하드 필터 — 통과 못 하면 즉시 탈락"""
if c.answer is None:
return False
if task["type"] == "json" and not validate_schema(c.answer, task["schema"]):
return False
if task["type"] == "extraction" and c.answer not in task["source_text"]:
return False # 원문에 없는 값 = 환각
return True
def select_best(
candidates: list[Candidate],
task: dict,
orm: Callable[[str, str], float],
) -> Candidate | None:
# 1단계: 규칙으로 거른다 (비용 거의 0)
alive = []
for c in candidates:
if rule_filter(c, task):
alive.append(c)
else:
c.rejected_by = "rule"
if not alive:
return None
if len(alive) == 1:
return alive[0]
# 2단계: 완전히 같은 답끼리 묶어 합의 가중치를 만든다
from collections import Counter
votes = Counter(c.answer for c in alive)
n = len(alive)
# 3단계: 남은 후보만 ORM에 태운다 (비용 발생 구간)
for c in alive:
model_score = orm(task["prompt"], c.text)
consensus = votes[c.answer] / n
c.score = 0.7 * model_score + 0.3 * consensus
best = max(alive, key=lambda c: c.score)
# 4단계: 최고점도 낮으면 답하지 않는다
return best if best.score >= 0.5 else None
세 가지가 중요하다.
규칙 필터를 먼저 태운다. ORM 호출은 돈이 든다. 스키마를 위반한 후보를 굳이 모델에 태울 이유가 없다. 실제 트래픽에서 이 단계가 후보의 20~40%를 걸러 내는 경우가 흔하다.
합의와 모델 점수를 섞는다. ORM 점수만 쓰면 검증자 모델의 편향에 그대로 노출된다. 다수결은 다른 종류의 신호이므로 섞으면 서로의 실패를 보완한다. 가중치 0.7/0.3은 출발점이고, 자기 데이터로 조정해야 한다.
기권을 허용한다. 마지막 두 줄이 실무에서 가장 큰 차이를 만든다. 최고점 후보의 점수가 낮다는 것은 “이 문제에서 우리 시스템이 헤매고 있다”는 뜻이다. 이때 억지로 답을 내면 틀린 답을 자신 있게 내놓는 최악의 결과가 된다. None을 반환하고 상위 계층에서 사람에게 넘기거나 더 큰 모델로 에스컬레이션하는 편이 낫다.
자기 검증을 그나마 쓸 만하게 만들기
별도 검증자를 만들 여력이 없다면 자기 검증이 유일한 선택지가 된다. 그냥 “이 답이 맞나요?”라고 물으면 대부분 “네”라고 답하므로, 몇 가지 장치가 필요하다.
답을 만든 컨텍스트를 지운다. 같은 대화에 이어서 물으면 모델이 자기 답을 방어한다. 검증은 별도 호출로, 문제와 답만 주고 풀이 과정은 빼고 던진다.
긍정이 아니라 반증을 요구한다. “맞는지 확인하라”가 아니라 “이 답이 틀렸다면 그 이유는 무엇인가”로 묻는다. 반증을 못 찾을 때만 통과로 본다.
독립적으로 다시 풀게 한다. 가장 강력한 형태다. 같은 문제를 다른 방법으로 풀게 하고 결과가 일치하는지 본다. 검산과 같은 원리다.
VERIFY_PROMPT = """다음 문제와 제출된 답이 있다.
문제: {question}
제출된 답: {answer}
이 답이 틀렸다고 가정하고, 틀렸을 만한 구체적 이유를 찾아라.
반례나 계산 오류를 찾지 못했다면 마지막 줄에 VERDICT: PASS,
찾았다면 VERDICT: FAIL 을 쓴다.
추측으로 FAIL을 쓰지 말고, 근거를 제시할 수 있을 때만 쓴다."""
def adversarial_verify(question: str, answer: str, n_votes: int = 3) -> bool:
"""독립 호출 3개의 다수결 — 하나라도 근거 있는 FAIL이면 신뢰도가 떨어진다"""
verdicts = []
for _ in range(n_votes):
out = call_model(
VERIFY_PROMPT.format(question=question, answer=answer),
temperature=1.0,
)
verdicts.append("VERDICT: PASS" in out)
return sum(verdicts) >= 2
temperature=1.0으로 매번 다르게 접근하게 하는 것이 요점이다. 온도 0으로 세 번 부르면 같은 답이 세 번 나와서 투표의 의미가 없다.
이렇게 해도 자기 검증은 규칙 검증기나 학습된 ORM에 미치지 못한다. 앞의 표에서 자기 검증이 다수결보다도 낮았던 것을 기억할 필요가 있다. 보조 신호로 쓰되, 이것 하나에 판정을 맡기지는 말아야 한다.
검증자를 평가하는 법
검증자도 모델이므로 별도로 평가해야 한다. 그런데 검증자 평가는 생성 모델 평가와 지표가 다르다.
정확도만 보면 안 된다. 후보의 80%가 오답인 데이터에서 무조건 “틀렸다”고 답하는 검증자도 정확도 80%가 나온다. 봐야 할 것은 다음 두 가지다.
선택 정확도. 후보 집합을 주고 검증자가 고른 답이 실제로 정답인 비율이다. 이것이 최종 시스템 성능에 직결되는 유일한 지표다.
회수율. 앞 표의 마지막 열이다. 후보 안에 정답이 있었을 때 그것을 실제로 골라낸 비율. 이 값이 낮으면 생성을 아무리 개선해도 최종 성능이 안 오른다.
여기에 하나 더, **점수 보정(calibration)**을 확인해야 한다. 검증자가 0.9를 준 답이 실제로 90% 맞고, 0.5를 준 답이 50% 맞아야 임계값 기반 기권이 의미가 있다. 대부분의 학습된 검증자는 과신하는 방향으로 치우쳐 있어서, 운영 데이터로 임계값을 다시 잡아야 한다.
def calibration_report(scores: list[float], labels: list[bool], bins: int = 10):
"""예측 점수 구간별 실제 정답률을 본다"""
for i in range(bins):
lo, hi = i / bins, (i + 1) / bins
idx = [j for j, s in enumerate(scores) if lo <= s < hi]
if not idx:
continue
actual = sum(labels[j] for j in idx) / len(idx)
print(f"[{lo:.1f}, {hi:.1f}) n={len(idx):4d} 실제 정답률={actual:.2f}")
이 표를 한 번 뽑아 보면 기권 임계값을 어디에 둘지가 곧바로 보인다. 0.5 이상 구간의 실제 정답률이 0.6밖에 안 된다면 임계값을 0.7로 올려야 한다.
정리
후보를 여러 개 만드는 것보다 그중 맞는 것을 고르는 일이 어렵고, 시스템 성능의 상당 부분이 여기서 결정된다. 그런데 실무에서는 생성 프롬프트를 다듬는 데 시간을 다 쓰고 검증은 다수결로 대충 끝내는 경우가 많다.
시작은 규칙 검증기다. “이 작업에서 기계적으로 확인할 수 있는 것이 정말 없는가”를 한 번 더 따져 보면 대개 하나쯤은 나온다. 추출 값이 원문에 있는지, 숫자 합이 맞는지, 참조한 문서 ID가 실제로 존재하는지 같은 것들이다. 이 필터 하나가 학습된 검증자보다 나은 경우도 드물지 않다.
그다음이 기권 임계값이다. 확신이 없을 때 답하지 않는 것만으로 체감 품질이 크게 달라진다.
지난 글: 추론 능력은 어떻게 훈련되는가: 검증 가능한 보상과 강화학습
다음 글: 사고 예산 제어: 얼마나 생각하게 할 것인가
읽어주셔서 감사합니다. 😊