왜 중간 결과 검증이 필요한가

프롬프트 체인은 한 단계의 출력이 다음 단계의 입력이 된다. 문제는 LLM 출력이 확률적이라는 점이다. 3단계 체인에서 각 단계 성공률이 90%라면 전체 성공률은 약 73%로 떨어진다. 게다가 중간 단계가 조용히 잘못된 값을 뱉으면(예: 빈 배열, 잘린 JSON, 환각된 ID) 오류가 최종 단계까지 전파되어 원인 추적이 어려워진다. 검증 게이트는 각 단계 경계에서 출력을 검사해, 잘못된 값이 다음 단계로 넘어가기 전에 차단하는 장치다.

검증 게이트의 기본 구조

게이트는 세 가지 판정을 한다. (1) 구조 검증 — 형식이 스키마에 맞는가, (2) 내용 검증 — 값이 도메인 규칙을 만족하는가, (3) 정합성 검증 — 입력과 논리적으로 일치하는가. 구조 검증은 결정론적 코드로, 내용·정합성 검증은 규칙 또는 별도 LLM 판정으로 처리한다. 핵심은 "게이트를 통과하지 못한 출력은 다음 단계로 넘기지 않는다"는 불변식이다.

스키마 기반 구조 검증

가장 저렴하고 빠른 1차 방어선은 스키마 검증이다. Pydantic으로 파싱하면 형식 오류를 즉시 예외로 잡을 수 있다.

from pydantic import BaseModel, field_validator

class ExtractResult(BaseModel):
    entities: list[str]
    confidence: float

    @field_validator("entities")
    @classmethod
    def non_empty(cls, v):
        if not v:
            raise ValueError("entities가 비어 있음")
        return v

    @field_validator("confidence")
    @classmethod
    def in_range(cls, v):
        if not 0.0 <= v <= 1.0:
            raise ValueError("confidence 범위 위반")
        return v

def gate_structure(raw: str) -> ExtractResult:
    # 파싱 실패 시 ValidationError 발생 → 게이트 차단
    return ExtractResult.model_validate_json(raw)

게이트 정책을 설정으로 분리

단계마다 최소 신뢰도, 재시도 횟수, 실패 시 동작이 다르므로 코드가 아닌 설정으로 관리하면 튜닝이 쉽다.

gates:
  extract:
    min_confidence: 0.7
    max_retries: 2
    on_fail: retry        # retry | fallback | abort
    semantic_check: false
  summarize:
    min_confidence: 0.6
    max_retries: 1
    on_fail: fallback
    fallback_value: "요약 생성 실패"
    semantic_check: true  # 원문과 정합성 LLM 판정

실패 시 재시도와 폴백

게이트가 차단하면 무조건 에러를 던지지 말고 정책에 따라 분기한다. 재시도 시에는 실패 사유를 프롬프트에 되먹여 자기 교정을 유도하는 것이 효과적이다.

def run_stage(name, prompt, cfg):
    last_err = None
    for attempt in range(cfg["max_retries"] + 1):
        raw = call_llm(prompt if attempt == 0
                       else f"{prompt}\n\n이전 오류: {last_err}. 수정해서 다시.")
        try:
            result = gate_structure(raw)
            if result.confidence < cfg["min_confidence"]:
                raise ValueError(f"신뢰도 미달 {result.confidence}")
            return result
        except (ValueError, Exception) as e:
            last_err = str(e)
    # 재시도 소진
    if cfg["on_fail"] == "fallback":
        return cfg["fallback_value"]
    raise RuntimeError(f"{name} 게이트 실패: {last_err}")

검증 방식 비교

방식비용지연잡는 오류
스키마 검증거의 없음1ms 미만형식·타입·범위
규칙 기반낮음수 ms도메인 제약 위반
LLM 판정높음(추가 호출)수백 ms~초의미·정합성 오류

순서가 중요하다. 저렴한 스키마 검증을 먼저 통과시킨 뒤에만 비싼 LLM 판정을 호출해야 불필요한 비용을 줄인다.

운영 시 주의점

첫째, LLM 판정 게이트는 그 자체가 확률적이므로 무한 신뢰 대상이 아니다. 판정 게이트를 통과한 비율과 최종 품질을 별도로 모니터링해 게이트가 과하게 통과시키는지 확인한다. 둘째, 재시도는 반드시 상한을 둔다. 상한 없는 재교정 루프는 비용과 지연을 폭증시킨다. 셋째, 게이트 차단·재시도·폴백은 모두 구조화 로그로 남겨 어느 단계에서 몇 번 실패했는지 추적 가능하게 한다. 이 지표가 없으면 어느 게이트의 임계값을 조정해야 하는지 판단할 근거가 사라진다. 넷째, 폴백 값은 다음 단계가 안전하게 소비할 수 있는 형태여야 한다. 폴백이 또 다른 오류를 유발하면 게이트의 의미가 없다.