구조화 출력(Structured Output) — JSON Schema, Pydantic, Zod, 제약 디코딩(Constrained Decoding)
"모델에게 JSON으로 답해 달라고 정중히 부탁하기"는 최전선(frontier) 모델에서도 5~15% 정도는 실패합니다. 구조화 출력(Structured Outputs)은 제약 디코딩(Constrained Decoding)으로 이 간극을 메웁니다. 모델이 스키마(schema)를 위반하는 토큰을 애초에 출력하지 못하도록 막는 방식입니다. OpenAI의 strict 모드, Anthropic의 스키마가 부여된 tool_use, Gemini의 responseSchema, Pydantic AI의 output_type, 그리고 Zod의 .parse는 모두 같은 아이디어를 다섯 가지 표면 형태로 드러낸 것입니다. 이 강의에서는 학습자가 앞으로 모든 프로덕션 추출(extraction) 파이프라인에 사용할 스키마 검증기(validator)와 strict 모드 계약(contract)을 직접 만들어 봅니다.
유형: Build
언어: Python (표준 라이브러리, JSON Schema 2020-12 일부)
선수 지식: Phase 13 · 02 (함수 호출 심화, function calling deep dive)
예상 시간: 약 75분
학습 목표
- 추출 대상(extraction target)에 알맞은 제약 조건(
enum, min/max, required, pattern)을 활용해 JSON Schema 2020-12를 작성할 수 있습니다.
strict 모드와 제약 디코딩이 "생성 후 검증(validate after generation)"과 어떤 보장(guarantee)에서 다른지 설명할 수 있습니다.
- 세 가지 실패 모드인 파싱 오류(parse error), 스키마 위반(schema violation), 모델 거절(refusal)을 구분할 수 있습니다.
- 타입이 부여된 복구(repair)와 거절(refusal) 처리를 갖춘 추출 파이프라인을 실제로 출시(ship)할 수 있습니다.
문제
구매 주문서(purchase order) 이메일을 읽는 에이전트(agent)는 자유 텍스트를 {customer, line_items, total_usd} 같은 구조로 바꿔야 합니다. 이를 풀어가는 접근은 크게 세 가지가 있습니다.
접근 1: JSON으로 답해 달라고 프롬프트로 요청하기. "customer, line_items, total_usd 필드를 가진 JSON으로 답해 주세요" 같은 방식입니다. 최전선 모델에서는 85~95% 정도 잘 작동합니다. 그러나 여섯 가지 방식으로 실패합니다. 빠진 중괄호, 끝에 붙은 쉼표(trailing comma), 타입 불일치, 환각된 필드(hallucinated fields), 토큰 한도에서 잘려 나간 출력, 그리고 "Here is your JSON:" 같은 산문이 섞여 새어 나오는 경우입니다.
접근 2: 생성 후 검증. 자유롭게 생성하게 두고, 파싱한 뒤 스키마로 검증하고, 실패하면 재시도합니다. 신뢰성은 있지만 비용이 큽니다. 모든 재시도에 비용이 들고, 잘림 버그(truncation bug)가 발생할 때마다 한 턴(turn)이 더 소모됩니다.
접근 3: 제약 디코딩(Constrained Decoding). 프로바이더(provider)가 디코딩 시점에 스키마를 강제로 적용합니다. 유효하지 않은 토큰은 샘플링 분포(sampling distribution)에서 마스킹(mask)됩니다. 결과적으로 출력이 항상 파싱 가능하고 검증을 통과한다는 점이 보장됩니다. 실패는 단 하나의 모드, 즉 모델 거절(refusal)로 수렴합니다. 모델이 입력이 스키마에 맞지 않는다고 판단하는 경우입니다.
2026년의 모든 최전선 프로바이더는 어떤 형태로든 접근 3을 제공합니다.
- OpenAI.
response_format: {type: "json_schema", strict: true}와, 모델이 거절할 때 응답에 들어가는 refusal 필드.
- Anthropic.
tool_use 입력(input)에 대한 스키마 강제. stop_reason: "refusal" 같은 별도 신호는 없지만, 도구 호출 없이 end_turn으로 끝나는 것이 사실상의 거절 신호입니다.
- Gemini. 요청 수준의
responseSchema. 2026년 Gemini는 선택된 타입에 대해 토큰 수준 문법 제약(token-level grammar constraint)도 함께 제공합니다.
- Pydantic AI.
output_type=InvoiceModel로 지정하면 InvoiceModel 타입을 갖는 구조화된 RunResult를 돌려줍니다.
- Zod (TypeScript). 프로바이더 출력을 Zod 스키마로 검증하는 런타임 파서(runtime parser)입니다. OpenAI의
beta.chat.completions.parse와 짝을 이뤄 사용합니다.
공통점은 스키마를 한 번만 선언하고, 그 스키마를 처음부터 끝까지(end-to-end) 강제한다는 것입니다.
개념
JSON Schema 2020-12 — 공통 언어
모든 프로바이더는 JSON Schema 2020-12를 받아들입니다. 가장 자주 쓰는 구성 요소는 다음과 같습니다.
type: object, array, string, number, integer, boolean, null 중 하나.
properties: 필드 이름에서 하위 스키마(subschema)로 가는 매핑.
required: 반드시 존재해야 하는 필드 이름 목록.
enum: 허용된 값들의 닫힌 집합.
minimum / maximum (숫자), minLength / maxLength / pattern (문자열).
items: 배열의 모든 요소에 적용되는 하위 스키마.
additionalProperties: false이면 추가 필드를 금지합니다. 기본값은 모드에 따라 다릅니다.
OpenAI strict 모드는 여기에 세 가지 요구사항을 더합니다. 모든 속성(property)이 required에 포함되어야 하고, 모든 객체에 additionalProperties: false가 붙어 있어야 하며, 해결되지 않은(unresolved) $ref가 없어야 합니다. 이를 어기면 API는 요청 시점에 400 응답을 반환합니다.
Pydantic, 파이썬 바인딩
Pydantic v2는 데이터클래스(dataclass) 형태의 모델로부터 model_json_schema()를 통해 JSON Schema를 생성합니다. Pydantic AI는 이를 한 번 더 감싸 다음처럼 쓸 수 있게 해 줍니다.
class Invoice(BaseModel):
customer: str
line_items: list[LineItem]
total_usd: Decimal
에이전트 프레임워크는 가장자리(edge)에서 이 스키마를 OpenAI strict 모드, Anthropic의 input_schema, Gemini의 responseSchema 형식으로 변환해 줍니다. 모델의 출력은 타입이 부여된 Invoice 인스턴스로 돌아옵니다. 검증 오류는 타입이 부여된 오류 경로(error path)를 가진 ValidationError로 발생합니다.
Zod, 타입스크립트 바인딩
Zod (z.object({customer: z.string(), ...}))는 TypeScript에서 같은 역할을 합니다. OpenAI Node SDK는 zodResponseFormat(Invoice)를 제공하며, 이를 API의 JSON Schema 페이로드(payload)로 변환해 줍니다.
거절(Refusal)
strict 모드라고 해도 모델이 억지로 답하도록 강제할 수는 없습니다. 입력이 스키마에 맞을 수 없는 경우("이 이메일은 송장이 아니라 시였습니다" 같은 상황) 모델은 이유가 담긴 refusal 필드를 출력합니다. 코드는 이 거절을 실패가 아니라 일급(first-class) 결과로 다뤄야 합니다. 거절은 안전(safety) 신호로도 유용합니다. 보호된 콘텐츠(protected content)가 담긴 이메일에서 신용카드 번호를 추출하라는 요청을 받으면, 모델은 안전 사유가 붙은 거절을 반환합니다.
오픈 모델에서의 제약 디코딩
오픈 가중치(open-weights) 구현은 세 가지 기법을 사용합니다.
- 문법 기반 디코딩(grammar-based decoding):
outlines, guidance, lm-format-enforcer가 대표적입니다. 스키마에서 결정적 유한 오토마타(deterministic finite automaton; DFA)를 만들고, 매 스텝(step)마다 이 유한 상태 기계(FSM)를 위반할 토큰의 로짓(logit)을 마스킹합니다.
- JSON 파서와 로짓 마스킹: 스트리밍 JSON 파서를 모델과 같은 보폭(lockstep)으로 실행하면서, 매 스텝마다 다음 단계에서 허용되는 토큰 집합을 계산합니다.
- 검증자가 붙은 추측 디코딩(speculative decoding with a verifier): 가벼운 초안(draft) 모델이 토큰을 제안하면, 검증자(verifier)가 스키마를 강제합니다.
상용 프로바이더들은 내부적으로 이 가운데 하나를 선택해 사용합니다. 2026년 기준 최신 구현은 짧은 구조화 출력에서는 일반 생성보다 빠르고, 긴 출력에서는 거의 같은 속도를 보입니다.
세 가지 실패 모드
- 파싱 오류(Parse error). 출력이 유효한 JSON이 아닌 경우입니다.
strict 모드에서는 발생할 수 없지만, 비-strict 프로바이더에서는 여전히 일어납니다.
- 스키마 위반(Schema violation). 출력은 파싱되지만 스키마를 위반합니다.
strict 모드에서는 발생할 수 없으나, 그 밖의 환경에서는 흔합니다.
- 거절(Refusal). 모델이 거절합니다. 타입이 부여된 결과(typed outcome)로 처리해야 합니다.
재시도 전략
strict 모드 바깥(예: Anthropic tool_use, 비-strict OpenAI, 이전 버전 Gemini)에서는 다음과 같은 복구 패턴을 사용합니다.
generate -> parse -> validate -> if fail, inject error and retry, max 3x
대개 한 번의 재시도(retry)면 충분합니다. 세 번의 재시도는 약한 모델의 흔들림(flake)을 잡아냅니다. 세 번을 넘기는 경우는 스키마가 잘못되었다는 신호입니다. 모델이 어떤 입력에서는 만족시킬 수 없다는 뜻이므로, 프롬프트나 스키마를 고쳐야 합니다.
작은 모델 지원
제약 디코딩은 작은 모델에서도 잘 작동합니다. 문법 강제(grammar enforcement)가 적용된 3B 파라미터 오픈 모델은, 구조화 과제에서 단순 프롬프트(raw prompting)만 사용하는 70B 파라미터 모델보다 더 나은 성능을 보입니다. 이것이 구조화 출력이 프로덕션에서 중요한 가장 큰 이유입니다. 신뢰성을 모델 크기와 분리해 주기 때문입니다.
사용해보기
code/main.py는 표준 라이브러리만으로 만든 최소한의 JSON Schema 2020-12 검증기를 제공합니다. 지원 범위는 type, required, enum, min/max, pattern, items, additionalProperties입니다. 이 검증기는 Invoice 스키마를 감싸고 있고, 가짜 LLM 출력을 통과시켜 파싱 오류, 스키마 위반, 거절 경로(path)를 차례로 보여줍니다. 프로덕션에서는 가짜 출력을 실제 프로바이더 응답으로 교체하기만 하면 됩니다.
주의해서 볼 지점은 다음과 같습니다.
- 검증기는 경로(path)와 메시지를 가진 타입화된
[ValidationError] 리스트를 반환합니다. 이것이 바로 재시도 프롬프트에 다시 전달하고 싶은 형태입니다.
- 거절 분기(branch)는 재시도하지 않습니다. 로그를 남기고 타입이 부여된 거절을 반환합니다. Phase 14 · 09에서는 이 거절을 안전 신호로 활용합니다.
additionalProperties: false 검사는 적대적(adversarial) 테스트 입력에서 발동합니다. strict 모드가 환각된 필드를 막아 주는 이유를 직접 확인할 수 있습니다.
산출물 만들기
이 강의에서는 outputs/skill-structured-output-designer.md를 만듭니다. 송장, 지원 티켓, 이력서 같은 자유 텍스트 추출 대상이 주어지면, strict 모드와 호환되는(strict-mode-compatible) JSON Schema 2020-12와 이를 그대로 반영한 Pydantic 모델을 만들어 주는 스킬(skill)입니다. 여기에 타입이 부여된 거절 처리와 재시도 처리(stub)가 함께 포함됩니다.
연습문제
-
(쉬움) code/main.py를 실행합니다. total_usd가 음수인 네 번째 테스트 케이스를 추가합니다. 검증기가 minimum 제약 경로를 이유로 이를 거절하는지 확인합니다.
-
(중간) 식별자(discriminator)가 있는 oneOf를 지원하도록 검증기를 확장합니다. 흔한 예시는 kind 필드로 태깅된 상품(product) 또는 서비스(service) line_item입니다. strict 모드에는 미묘한 규칙들이 있으므로 OpenAI의 structured outputs 가이드를 함께 확인합니다.
-
(중간) 동일한 Invoice 스키마를 Pydantic BaseModel로 작성하고, model_json_schema()의 출력을 직접 작성한(hand-rolled) 스키마와 비교합니다. Pydantic이 기본값으로 설정하지만 직접 작성한 버전에서는 빠져 있는 필드 하나를 찾아냅니다.
-
(어려움) 거절률(refusal rate)을 측정합니다. 추출이 불가능해야 하는 입력 10개(예: 노래 가사, 수학 증명, 빈 이메일)를 만들고, 실제 프로바이더의 strict 모드에 통과시킵니다. 거절(refusal)과 환각된 출력(hallucinated output)의 개수를 셉니다. 이것이 거절 인지 재시도(refusal-aware retry)의 기준 자료(ground truth)가 됩니다.
-
(어려움) OpenAI의 structured outputs 가이드를 처음부터 끝까지 읽습니다. 일반 JSON Schema에서는 허용되지만 strict 모드에서는 명시적으로 금지하는 구성 요소(construct) 하나를 찾습니다. 그 금지된 구성 요소를 꼭 필요하지 않은 방식으로 사용하는 스키마를 만든 뒤, strict 호환 형태로 리팩터링(refactor)합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| JSON Schema 2020-12 | "스키마 명세" | 최신 프로바이더들이 공통으로 사용하는 IETF 초안 스키마 방언(dialect) |
| 엄격 모드(Strict mode) | "보장된 스키마" | 제약 디코딩으로 스키마를 강제하는 OpenAI 플래그(flag) |
| 제약 디코딩(Constrained decoding) | "로짓 마스킹" | 유효하지 않은 다음 토큰을 마스킹하는 디코드 시점(decode-time) 강제 |
| 거절(Refusal) | "모델이 거절함" | 입력이 스키마에 맞을 수 없을 때 반환되는 타입이 부여된 결과 |
| 파싱 오류(Parse error) | "유효하지 않은 JSON" | JSON으로 파싱되지 않는 출력. strict 모드에서는 불가능 |
| 스키마 위반(Schema violation) | "형태가 잘못됨" | 파싱은 되지만 타입 / required / enum / 범위를 위반한 출력 |
additionalProperties: false | "추가 필드 금지" | 알 수 없는 필드를 금지함. OpenAI strict 모드에서 필수 |
Pydantic BaseModel | "타입이 있는 출력" | JSON Schema를 생성하고 검증해 주는 Python 클래스 |
| Zod 스키마 | "TypeScript 출력 타입" | 프로바이더 출력을 검증하기 위한 TS 런타임 스키마 |
| 문법 강제(Grammar enforcement) | "오픈 가중치 제약 디코드" | outlines / guidance처럼 FSM 기반 로짓 마스킹을 사용하는 방식 |
더 읽을거리