왜 문제가 되는가
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나 다운스트림에 넣기 전 항상 파싱·검증을 통과시킨다.