왜 문제가 되는가

LLM을 파이프라인에 넣으면 가장 먼저 부딪히는 벽이 "출력이 매번 조금씩 다르다"는 점이다. 프롬프트로 "JSON으로만 답하라"고 지시해도 모델은 ```json 코드펜스를 붙이거나, 설명 문장을 앞에 덧붙이거나, 후행 콤마·홑따옴표·잘린 문자열을 내보낸다. 사람 눈에는 사소하지만 json.loads()는 예외로 죽고, 그 결과 배치 작업 전체가 재시도 루프에 빠진다.

핵심은 두 가지 계층을 분리하는 것이다. (1) 구조 강제(constrained decoding)로 애초에 스키마를 벗어난 토큰을 못 만들게 하고, (2) 검증(validation)으로 파싱된 값이 도메인 규칙까지 만족하는지 확인한다. 강제만으로는 "나이가 -3"을 못 막고, 검증만으로는 파싱 실패를 못 막는다.

접근 방식 비교

방식구조 보장지연/비용비고
프롬프트 지시만없음(확률적)낮음재시도 로직 필수
function/tool calling높음낮음대부분 모델이 스키마 기반 출력 지원
constrained decoding
(Outlines·llguidance 등)
매우 높음중간로컬 모델에서 문법 자체를 제약
사후 검증(Pydantic)보장 안 함낮음강제와 함께 써야 의미 있음

스키마부터 정의한다

스키마를 코드로 먼저 선언하고, 그 스키마를 API 요청과 검증 양쪽에서 재사용하는 것이 가장 유지보수하기 좋다. Pydantic은 JSON Schema를 자동 생성해 준다.

from pydantic import BaseModel, Field, field_validator

class Invoice(BaseModel):
    vendor: str
    total: float = Field(ge=0)
    currency: str = Field(pattern=r"^[A-Z]{3}$")
    line_items: list[str] = Field(min_length=1)

    @field_validator("currency")
    @classmethod
    def known_currency(cls, v):
        if v not in {"KRW", "USD", "JPY", "EUR"}:
            raise ValueError(f"unsupported currency: {v}")
        return v

# 이 한 줄로 API에 넘길 JSON Schema가 만들어진다
schema = Invoice.model_json_schema()

API에 스키마를 강제한다

상용 API 대부분은 응답 형식을 스키마로 고정하는 옵션을 제공한다. 아래는 스키마를 그대로 넘겨 모델이 반드시 그 구조로만 출력하게 하는 예시다. additionalProperties: false와 모든 필드의 required 지정이 중요하다. 이걸 빠뜨리면 모델이 임의 키를 추가하거나 필드를 생략한다.

import json
from anthropic import Anthropic

client = Anthropic()

# 스키마 기반 tool을 하나 정의하고, 모델이 반드시 이 tool을 쓰게 강제한다
tool = {
    "name": "emit_invoice",
    "description": "추출한 청구서 데이터를 구조화해 반환",
    "input_schema": schema,
}

resp = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    tools=[tool],
    tool_choice={"type": "tool", "name": "emit_invoice"},  # 강제
    messages=[{"role": "user", "content": raw_document}],
)

# tool_use 블록의 input은 스키마를 만족하는 dict로 들어온다
raw = next(b.input for b in resp.content if b.type == "tool_use")
invoice = Invoice.model_validate(raw)   # 도메인 규칙까지 재검증

tool_choice로 특정 tool을 강제하면 모델이 산문으로 새는 경로 자체가 막힌다. 그리고 넘어온 값을 다시 model_validate로 통과시켜, 스키마가 못 잡는 통화 화이트리스트·범위 같은 의미 규칙을 확인한다.

검증 실패를 다루는 루프

강제를 걸어도 검증은 실패할 수 있다(예: 근거가 없어 총액을 지어냄). 이때 무작정 같은 요청을 반복하지 말고, 에러 메시지를 모델에게 되먹임하는 것이 재시도 성공률을 크게 올린다.

def extract(document, max_retries=2):
    messages = [{"role": "user", "content": document}]
    for _ in range(max_retries + 1):
        raw = call_model(messages)          # 위의 tool 호출
        try:
            return Invoice.model_validate(raw)
        except ValidationError as e:
            # 실패 사유를 다음 턴 입력으로 넣어 자가 수정 유도
            messages.append({"role": "assistant",
                             "content": json.dumps(raw)})
            messages.append({"role": "user",
                             "content": f"검증 실패: {e.errors()}. 수정해 다시 반환."})
    raise RuntimeError("스키마 검증 재시도 초과")

로컬 모델은 디코딩 단계에서 막는다

자체 호스팅 모델이라면 API의 tool 강제 대신 디코딩 문법 제약을 쓸 수 있다. 문법에 맞지 않는 토큰의 확률을 0으로 눌러, 잘못된 JSON이 생성 자체가 불가능해진다. vLLM·Outlines·llguidance 계열이 이 방식을 지원한다.

from outlines import models, generate

model = models.transformers("meta-llama/Llama-3.1-8B-Instruct")
generator = generate.json(model, Invoice)   # 스키마를 문법으로 컴파일
invoice = generator(prompt)                 # 반드시 Invoice 형태로만 나옴

주의점

  • 스키마는 좁게. enum·pattern·범위를 명시할수록 강제와 검증이 함께 강해진다. str 하나로 열어두면 아무 값이나 통과한다.
  • 강제 ≠ 정답. 스키마 준수는 "형식이 맞다"일 뿐 "내용이 맞다"가 아니다. 근거 필드(source_span)를 함께 요구해 환각을 추적하라.
  • 토큰 절단. 출력이 길면 max_tokens에서 잘려 미완성 JSON이 나온다. tool calling은 이 경우 stop_reason이 max_tokens로 오니 반드시 확인하고 재시도한다.
  • 중첩 깊이. 깊고 재귀적인 스키마는 constrained decoding에서 지연이 급증한다. 평평한 구조로 설계하고 필요하면 두 번에 나눠 추출한다.
  • 검증을 신뢰 경계로. LLM 출력은 외부 입력과 동일하게 다뤄, DB나 다운스트림에 넣기 전 항상 파싱·검증을 통과시킨다.