견고한 Python 기반 LLM 구조화 출력 검증

느낌에 의존한 해석을 중단하고, 계약서를 검증하십시오.

Page content

대부분의 대규모 언어 모델(LLM) ‘구조화된 출력(structured output)’ 튜토리얼은 진지하지 않습니다. 이들은 사용자에게 정중하게 JSON을 요청한 후 모델이 잘 작동하기를 바라고 만듭니다. 그것은 검증(validation)이 아닙니다. 그것은 중괄호를 사용한 낙관주의에 불과합니다.

OpenAI의 공식 문서도 이 차이점을 명확히 구분합니다. JSON 모드는 유효한 JSON을 제공하지만, 구조화된 출력(Structured Outputs)은 스키마 준수를 강제합니다. OpenAI는 가능한 경우 JSON 모드 대신 구조화된 출력을 사용하도록 권장합니다.

structured output validation infographic

그렇다고 해서 페이로드가 신뢰할 수 있게 되는 것은 아닙니다. JSON 스키마는 구조와 허용되는 값을 정의하고, Pydantic은 Python에서 타입 기반 검증을 제공하며, OpenAI는 스키마 유효한 응답이라 해도 여전히 잘못된 값을 포함할 수 있다고 명시적으로 지적합니다. 게다가 거부(refusals)와 불완전한 출력은 예상한 형식을 우회할 수 있습니다. 프로덕션 환경에서 구조화된 출력 검증은 단순한 토글이 아니라 파이프라인입니다. 이 같은 경계선은 LLM 성능 엔지니어링 허브의 처리량, 재시도, 스케줄러 제한 등 더 넓은 이야기 속에도 존재해야 합니다.

구조화된 출력 검증은 계약(Contract)입니다

LLM에 대한 구조화된 출력 검증이란 미리 답변의 형태(shape)를 정의하고, 모델이 가능한 한 그 형태를 생성하도록 제한한 후, 애플리케이션이 이를 신뢰하기 전에 다시 결과를 검증한다는 것을 의미합니다. 실용적인 측면에서 이는 페이로드가 데이터베이스, UI, 큐, 또는 하위 서비스와 접촉하기 전에 필수 필드, 타입, 열거형(enum), 폐쇄된 객체 형태, 도메인 규칙을 확인한다는 것을 뜻합니다. JSON 스키마는 바로 이러한 구조적 검증을 위해 존재하며, Pydantic은 Python 타입 힌트에 대해 신뢰할 수 없는 데이터를 검증하도록 설계되었고, Python의 jsonschema 라이브러리는 스키마에 대해 인스턴스를 직접 검증하는 방법을 제공합니다.

두 가지 일반적인 사용 사례 사이에는 명확한 구분도 있습니다. 모델이 구조화된 형식으로 사용자에게 응답해야 한다면 구조화된 응답 형식을 사용하십시오. 모델이 애플리케이션의 도구(tool)나 함수를 호출해야 한다면 함수 호출(function calling)을 사용하십시오. OpenAI의 문서는 이 구분을 명확히 명시하고 있으며, 함수 호출의 경우 인자가 함수 스키마에 안정적으로 따르도록 strict: true를 활성화할 것을 권장합니다.

저의 확고한 견해는 간단합니다. 모든 구조화된 LLM 응답을 API 경계로 취급하십시오. 프롬프트 대신 계약 관점에서 생각하기 시작하면 아키텍처가 더 깔끔해지고, 버그 해결 비용이 저렴해지며, “왜 모델이 프로덕션 환경에서 새로운 필드를 만들어냈는가"라는 문제가 대부분 사라집니다. 이것이 “LLM을 위한 구조화된 출력 검증이란 무엇인가"에 대한 진정한 답변이며, “모델에게 정중하게 JSON을 요청하라"는 답변보다 훨씬 낫습니다.

JSON 모드는 검증이 아닙니다

이 글에서 기억해야 할 한 가지가 있다면 그것이 바로 이것입니다. JSON 모드는 스키마 검증이 아닙니다. OpenAI 도움말 센터에서는 JSON 모드가 출력이 특정 스키마와 일치한다는 것을 보장하지 않으며, 단지 유효한 JSON이며 오류 없이 파싱된다는 것만 보장한다고 명시합니다. 구조화된 출력 가이드도 더 깔끔한 방식으로 동일한 내용을 전합니다. JSON 모드와 구조화된 출력 모두 유효한 JSON을 생성할 수 있지만, 구조화된 출력만이 스키마 준수를 강제합니다.

이 차이점은 사람들이 인정하는 것보다 더 중요합니다. OpenAI는 구조화된 출력 출시 포스트에서 gpt-4o-2024-08-06이 구조화된 출력을 사용하여 복잡한 JSON 스키마 평가에서 100% 점수를 받았으며, 반면 gpt-4-0613은 40% 미만이었다고 보고했습니다. 이러한 숫자를 보편적인 진리로 취급할 필요는 없지만, 더 넓은 관점의 교훈을 볼 수 있습니다. 스키마 강제 적용은 실패 표면(failure surface)을 “무엇이든 일어날 수 있음"에서 “계약이 훨씬 더 엄격함"으로 변화시킵니다.

여전히 경계 사례(edge cases)가 존재하며, 이를 무시하는 것은 장난감 데모가 긴급 호출(pager duty)로 이어지는 원인입니다. OpenAI는 모델이 안전하지 않은 요청을 거부할 수 있으며, 이러한 거부는 일반적인 스키마 경로 외부에서 표출된다고 문서화하고 있습니다. 또한 max_output_tokens 도달이나 콘텐츠 필터 중단과 같은 불완전한 응답 사례도 문서화하고 있습니다. 따라서 “JSON 모드가 안정적인 LLM 출력에 충분한가"라는 FAQ에는 짧은 답변과 긴 답변이 있습니다. 짧은 답변은 ‘아니오’입니다. 긴 답변은 엄격한 구조화된 출력이라 할지라도 명시적인 실패 처리가 여전히 필요하다는 것입니다.

구조화된 출력이 여전히 깨지는 곳

스키마 강제 적용은 문제를 줄입니다. 하지만 문제를 완전히 삭제하지는 않습니다. 실제 트래픽에서는 프롬프트 문구와 거의 무관한 이유들로 인해 깨지거나 놀라운 페이로드를 여전히 마주하게 됩니다.

설계할 가치가 있는 실패 형태

모델과 클라이언트는 세부 사항에 대해 의견이 다릅니다. JSON 앞뒤로 추가적인 문체가 나올 수 있고, 페이로드 주변에 Markdown으로 둘러싸인 블록(fenced blocks)이 있을 수 있으며, 이름은 유효하지만 인자가 Pydantic 모델과 맞지 않는 JSON인 도구 호출(tool call)이 발생할 수 있습니다. 스트리밍은 미완성 버퍼를 검증하게 만들기 때문에 상황을 더 악화시킵니다. 방어적인 코드는 “바이트가 이미 내 모델과 일치한다"기보다 “문자열이 들어오며, 그 안에 JSON이 있을 수도 있다"고 가정해야 합니다.

제공업체 및 API 차이

모든 호스트가 동일한 구조화된 출력 인터페이스를 노출하지는 않습니다. 한 스택은 일급(class-first) 스키마 바인딩 완료를 제공할 수 있고, 다른 스택은 JSON 문법만 보장할 수 있으며, 로컬 런타임은 호스팅된 API보다 뒤처질 수 있습니다. 이것이 FAQ인 “Python에서 LLM JSON을 어떻게 검증합니까"가 존재한다면 제공업체의 강제 적용으로 시작하고 결국에는 Python 측 검증으로 끝나는 이유입니다. 벤더 간 비교에 대한 더 넓은 시각은 주요 LLM 제공업체 간 구조화된 출력 비교를 참조하십시오. 로컬에서 모델을 실행하는 경우, Ollama와 같은 도구로 추출한 후(Ollama를 사용한 Python 및 Go에서의 구조화된 LLM 출력), 와이어 포맷을 정규화한 후 동일한 검증 파이프라인이 적용됩니다. 런타임이 여전히 JSON을 기이한 접두사나 추론 추적(reasoning traces)으로 감싸는 경우, Ollama GPT-OSS 구조화된 출력 문제에 설명된 것과 동일한 종류의 파서 실패가 발생할 것을 예상해야 합니다.

실제로 작동하는 Python 스택

저의 추천은 의도적으로 지루합니다. 첫째, 모델 제공업체가 구조적 계약을 강제할 수 있다면 그렇게 하십시오. 둘째, Pydantic을 사용하여 Python에서 반환된 페이로드를 검증하십시오. 셋째, 스키마 allein으로는 증명할 수 없는 사실에 대해 명시적인 비즈니스 규칙 검증을 사용하십시오. 넷째, 플레이그라운드 스크린샷을 보여주고 끝내는 대신, 픽스처(fixture)와 적대적 예제(adversarial examples)로 계약을 테스트하십시오. OpenAI의 구조화된 출력 문서, Pydantic의 검증자 모델, Python의 jsonschema 도구, 그리고 OpenAI 자체의 구조화된 출력 평가 예제 모두 이 방향을 가리킵니다.

Pydantic은 Python의 적절한 중심 중력입니다. 이는 출력을 일반적인 Python 타입으로 모델링하고, model_json_schema()로 JSON 스키마를 생성하며, model_validate_json()로 원시 JSON을 검증할 수 있게 해줍니다. Pydantic 문서 또한 model_validate_json()이 먼저 json.loads(...)를 실행한 후 검증하는 것보다 일반적으로 더 나은 경로임을 명시합니다. 왜냐하면 이 두 단계 경로는 Python에서 추가적인 파싱 작업을 추가하기 때문입니다.

리포지토리에 독립적인 스키마 파일을 유지하거나, 모델 코드와 독립적으로 CI가 픽스처 페이로드를 검증하기를 원한다면, Python의 jsonschema 패키지는 jsonschema.validate(...)로 가장 간단한 계약 검증을 제공합니다. 이를 pre-commit에서 사용하고 싶다면, check-jsonschemajsonschema를 기반으로 구축된 CLI 및 pre-commit 훅으로 존재합니다. 이는 스키마 변경을 코드 변경처럼 검토받기를 원하는 팀에게 매우 적합합니다.

프레임워크는 배관(plumbing)을 줄일 수 있지만, 실제 검증의 필요성을 제거하지는 않습니다. LangChain은 이제 제공업체가 지원할 때 제공업체 네이티브 구조화된 출력을 자동으로 선택하고, 그렇지 않으면 도구 전략으로 폴백합니다. Instructor는 모델 호출 위에 Pydantic 응답 모델, 검증, 재시도, 다중 제공업체 지원을 레이어링합니다. Guardrails는 검증자와 입력-출력 가드 레이어에 집중합니다. 모두 유용한 도구들입니다. 하지만 스키마와 비즈니스 규칙은 여전히 당신에게 속합니다. 고수준 라이브러리 사이에서 선택 중이라면, Python용 BAML vs Instructor 비교가 이 글의 유용한 동반자입니다.

최소한의 OpenAI 및 Pydantic 예제

프로덕션에 적합한 가장 작은 예제에는 몇 가지 불가결한 요소가 있습니다. 가능한 한 폐쇄된 열거형(enum-like) 값 세트를 사용하십시오. 추가 키를 금지하십시오. 스키마가 인간에게 이해 가능하고 모델에게 더 읽기 좋도록 필드 설명을 추가하십시오. 루트 객체를 명시적이고 지루하게 유지하십시오. OpenAI는 중요한 키에 대해 명확한 이름과 제목, 설명을 권장하며, JSON 스키마는 값을 제한하기 위해 enum을 사용하고, Pydantic은 extra="forbid"로 객체 형태를 폐쇄할 수 있습니다.

from typing import Literal

from openai import OpenAI
from pydantic import BaseModel, ConfigDict, Field


class TicketClassification(BaseModel):
    model_config = ConfigDict(extra="forbid")

    category: Literal["billing", "bug", "how_to", "abuse"] = Field(
        description="Support ticket category."
    )
    priority: Literal["low", "medium", "high"] = Field(
        description="Operational urgency."
    )
    needs_human: bool = Field(
        description="Whether a human should review the case."
    )
    summary: str = Field(
        description="A one sentence summary of the issue."
    )


client = OpenAI()

response = client.responses.parse(
    model="gpt-4o-2024-08-06",
    input=[
        {
            "role": "system",
            "content": "Classify support tickets. Return only the structured result.",
        },
        {
            "role": "user",
            "content": "Customer reports duplicate charges after refreshing checkout.",
        },
    ],
    text_format=TicketClassification,
)

result = response.output_parsed
print(result.model_dump())

이 예제에서 두 가지 세부 사항은 놓치기 쉽지만 반드시 신경 써야 할 만합니다. Pydantic 측의 extra="forbid"는 OpenAI의 함수 호출 문서에서 엄격한 도구 스키마의 요구사항이기도 한 JSON 스키마의 additionalProperties: false 아이디어를 반영합니다. 그리고 열거형(enum)은 미적 요소가 아닙니다. 이는 모델이 코드가 이해하지 못하는 값을 발명하는 것을 막는 가장 간단한 방법 중 하나입니다.

OpenAI Python SDK는 Pydantic 모델을 text_format으로 제공하는 client.responses.parse(...)를 지원하며, 파싱된 객체는 response.output_parsed에서 반환됩니다. 동일한 SDK는 파싱된 객체가 message.parsed에 있는 client.chat.completions.parse(...)도 지원합니다. 최소한의 연결 코드로 직접적인 구조화된 데이터 추출을 원한다면, 이러한 헬퍼들이 가장 깔끔한 시작점입니다.

파싱, 정규화, 그리고 검증

구조화된 출력과 model_validate_json은 스택이 종단간(end-to-end)으로 정렬되어 있을 때 많은 파싱 고통을 제거합니다. 하지만 평범한 채팅 텍스트를 반환하는 제공업체, JSON을 펜스(fences)로 감싸는 모델, 또는 원시 완료 문자열을 저장하는 로깅 경로를 지원하는 순간, Pydantic이 실행되기 전에 텍스트를 딕셔너리로 변환하는 하나의 병목 지점이 필요합니다.

import json


def parse_json_from_llm_text(text: str) -> dict:
    cleaned = text.strip()
    if cleaned.startswith("```"):
        cleaned = cleaned.split("\n", 1)[1]
        cleaned = cleaned.rsplit("```", 1)[0].strip()

    # 객체 앞에 흔히 나타나는 "Sure, here is the JSON:"과 같은 접두사.
    if not cleaned.startswith("{") and "{" in cleaned and "}" in cleaned:
        start = cleaned.find("{")
        end = cleaned.rfind("}")
        if end > start:
            cleaned = cleaned[start : end + 1]

    return json.loads(cleaned)


ticket_dict = parse_json_from_llm_text(raw_completion_text)
ticket = TicketClassification.model_validate(ticket_dict)

이 헬퍼는 의도적으로 지루합니다. 페이로드가 여전히 단일 최상위 객체인 경우, 펜스된 “json ... ” 블록과 선행 자연어 서두를 처리합니다. 이는 완전한 JSON 추출기가 아닙니다. 모델이 문자열 값 내부에 중괄호를 중첩하면, 단순한 슬라이싱이 깨질 수 있으며, 올바른 해결책은 보통 더 엄격한 프롬프팅, 스키마 바인딩 완료, 또는 전용 파서 라이브러리입니다.

스트리밍 완료

채팅 토큰을 스트리밍하는 경우, 각 델타에 대해 json.loads 또는 model_validate_json을 실행하지 마십시오. API가 완료된 메시지를 보고할 때까지 버퍼링하십시오(클라이언트에서 스트림 종료 또는 finish_reason 확인). 텍스트를 연결한 후 한 번만 파싱하십시오. 동일한 규칙이 도구 호출 인자가 청크로 도착할 때도 적용됩니다. 인자 문자열이 완성된 후에만 검증하십시오.

chunks: list[str] = []
for chunk in completion_stream:
    delta = chunk.choices[0].delta.content or ""
    chunks.append(delta)
raw_completion_text = "".join(chunks)
ticket = TicketClassification.model_validate_json(raw_completion_text)

JSON 주변에 펜스나 잡음이 예상될 경우, raw_completion_text를 먼저 parse_json_from_llm_text를 통해 전달할 수 있습니다.

원시 문자열 파싱을 소유한 후, 다음 제약 사항은 종종 Python이 아니라 제공업체의 JSON 스키마 방언과 원격 API가 실제로 수용하는 것입니다.

제공업체 스키마 제한 (Python에서 영리하게 행동하기 전에)

임의의 스키마 생성기 출력을 API에 덤프하고 모든 JSON 스키마 기능이 지원된다고 가정하지 마십시오. OpenAI는 JSON 스키마의 하위 집합만 지원하며, 구조화된 출력의 경우 모든 필드가 필수여야 하고, 최상위 anyOf가 아닌 객체가 루트여야 하며, 중첩 깊이와 총 속성 수에 대한 제한을 문서화합니다. 제공업체 대상 스키마를 간단하게 유지하십시오. 이는 타협이 아닙니다. 이는 좋은 엔지니어링입니다.

제공업체 독립적인 검증 경로를 필요로 하거나, 저장된 픽스처와 모킹을 검증하기를 원한다면, Pydantic과 jsonschema는 여전히 훌륭한 조합입니다.

from jsonschema import validate as validate_json

schema = TicketClassification.model_json_schema()

payload = {
    "category": "bug",
    "priority": "high",
    "needs_human": True,
    "summary": "Checkout duplicates charges after refresh.",
}

validate_json(instance=payload, schema=schema)
ticket = TicketClassification.model_validate(payload)
print(ticket)

이 패턴은 모델 제공업체가 네이티브 구조화된 출력 강제 적용을 제공하지 않는 테스트, 계약 픽스처, 통합에서 특히 유용합니다. 단지 기억하십시오. 로컬에서 생성된 스키마는 특정 제공업체가 지원하는 하위 집합보다 더 넓을 수 있으므로, “로컬에서 유효함"은 자동으로 “모든 LLM API에서 수용됨"을 의미하지 않습니다. 또한 일부 제공업체는 스키마 아티팩트를 사전 처리하고 캐시하므로, 새 스키마에 대한 첫 번째 요청은 웜(warm) 요청보다 느릴 수 있습니다.

도구 호출은 두 번째 계약입니다

함수 또는 도구 호출은 다른 주요 구조화된 출력 형태입니다. 모델은 이름을 선택하고 당신이 제어하는 JSON 스키마와 일치해야 하는 인자를 전달합니다. OpenAI는 인자가 해당 스키마와 일치하도록 도구 정의에 strict: true를 권장합니다. 에이전트 중심 스택에서 잘못된 샘플링은 빠르게 유효하지 않은 도구 JSON으로 이어지므로, Qwen 및 Gemma용 에이전틱 추론 파라미터 참조를 사용하여 다단계 작업과 샘플러 설정을 정렬하십시오.

아래 스니펫은 이미 제공업체의 도구 호출 객체를 name 문자열과 arguments 딕셔너리로 매핑했다고 가정합니다. 예를 들어 채팅 완료에서 tool_calls[].function을 파싱하여(JSON 문자열 인자는 먼저 json.loads됨). dispatch_tool은 이 정규화 이후의 단계입니다.

Python에서 두 가지 실용적인 규칙이 도움이 됩니다. 첫째, 실행을 라우팅하기 전에 도구 이름을 명시적인 허용 목록(allowlist)에 대해 검증하십시오. 둘째, 임의의 키 접근이 아닌 테스트에서 사용하는 것과 동일한 Pydantic 모델로 인자 딕셔너리를 검증하십시오. 당신이 피하고자 하는 실패 모드는 “유효한 JSON 인자, 그러나 발화된 도구에 대해 잘못된 형태"이며, 이는 문자열 검증을 우회합니다.

from typing import Any, Callable

from pydantic import BaseModel

ToolHandler = Callable[[dict[str, Any]], str]


def dispatch_tool(
    *,
    name: str,
    arguments: dict[str, Any],
    handlers: dict[str, tuple[type[BaseModel], ToolHandler]],
) -> str:
    if name not in handlers:
        raise ValueError(f"unsupported tool {name}")
    model_cls, handler = handlers[name]
    validated = model_cls.model_validate(arguments)
    return handler(validated.model_dump())


handlers: dict[str, tuple[type[BaseModel], ToolHandler]] = {
    "classify_ticket": (
        TicketClassification,
        lambda data: f"queued as {data['category']}",
    ),
}

이 패턴은 라우팅과 검증을 한 곳에 유지합니다. 실제 핸들러는 더 복잡하겠지만, 분리는 동일해야 합니다: 허용된 이름, 타입화된 인자, 그리고 그 다음 부수 효과(side effects).

스키마 검증은 여전히 비즈니스 규칙이 필요합니다

유효한 객체는 올바른 객자와 동일하지 않습니다. OpenAI가 직접적으로 말합니다. 구조화된 출력은 JSON 객체 내부 값의 실수를 방지하지 않습니다. 이것이 FAQ인 “왜 스키마 검증과 비즈니스 규칙 검증이 모두 중요한가"에 직설적인 답변이 있는 이유입니다. 왜냐하면 응답이 스키마와 완벽하게 일치하더라도 비즈니스에 해로울 수 있는 방식으로 잘못될 수 있기 때문입니다.

현실적인 예제를 하나 들겠습니다. 구조는 유효할 수 있지만, 가격 로직은 여전히 nonsensical(무의미)할 수 있습니다.

from decimal import Decimal
from typing import Literal
from typing_extensions import Self

from pydantic import BaseModel, ConfigDict, Field, model_validator


class Offer(BaseModel):
    model_config = ConfigDict(extra="forbid")

    currency: Literal["USD", "EUR", "GBP"]
    amount: Decimal = Field(gt=0)
    original_amount: Decimal | None
    discounted: bool

    @model_validator(mode="after")
    def check_discount_logic(self) -> Self:
        if self.discounted:
            if self.original_amount is None:
                raise ValueError(
                    "original_amount is required when discounted is true"
                )
            if self.original_amount <= self.amount:
                raise ValueError(
                    "original_amount must be greater than amount"
                )
        return self

이 검증자는 실제 시스템에서 스키마만으로는 종종 잘 수행하지 못하는 작업을 수행합니다. 전체 모델이 파싱된 후 교차 필드 시맨틱스(cross-field semantics)를 확인합니다. Pydantic의 model_validator는 바로 이러한 전체 객체 검증을 위해 존재합니다. 기본값이 없는 Decimal | None 필드에 주목하십시오. 이는 필드가 존재하면서 동시에 null을 허용하므로, 엄격한 구조화된 출력 하에서 유사 옵션 값(optional-like values)에 대한 OpenAI의 문서화된 패턴과 일치합니다.

검증 실패가 자동으로 모델로 피드백되기를 원한다면, Instructor는 Pydantic 위의 실용적인 레이어입니다. 문서에는 검증 오류가 캡처되고, 피드백으로 포맷팅되며, 모델이 다시 시도하도록 요청하는 재시도 루프가 설명되어 있습니다.

import instructor

retrying_client = instructor.from_provider("openai/gpt-4o", max_retries=2)

offer = retrying_client.create(
    response_model=Offer,
    messages=[
        {
            "role": "user",
            "content": (
                "Extract the offer from this text. "
                "Was 49.00 USD, now 19.00 USD."
            ),
        }
    ],
)

이것은 기꺼이 권장할 몇몇 편의 기능 중 하나입니다. 실제 검증 오류와 연결된 자동 재시도는 유용합니다. 침묵된 강제(coercion)는 그렇지 않습니다. Instructor의 모델 레이어, 재시도 문서, 검증 문서 모두 동일한 아이디어에 집중하고 있으며, 그들은 그렇게 하는 것이 맞습니다.

프레임워크 없이 동일한 아이디어를 구현할 수 있습니다. 루프는 작습니다. 모델에게 요청하고, Pydantic으로 검증하며, 검증이 실패하면 오류 세부 사항을 후속 사용자 메시지에 보내고 수정된 JSON만 요청하십시오. 시도를 제한하고, 최종 실패를 로깅하며, 호출자에게 제어된 오류를 표출하십시오. 이미 responses.parse 또는 다른 스키마 바인딩 헬퍼에 의존한다면, 이 경로를 거의 사용하지 않을 수 있습니다. 그러나 여전히 JSON 모드, 오래된 채팅 엔드포인트, 또는 원시 문자열을 전달하는 모든 게이트웨이에는 중요합니다.

from openai import OpenAI
from pydantic import ValidationError

client = OpenAI()

messages = [
    {"role": "system", "content": "Return only JSON that matches the ticket schema."},
    {"role": "user", "content": "Customer reports duplicate charges after refreshing checkout."},
]

ticket: TicketClassification | None = None
for attempt in range(2):
    completion = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        messages=messages,
        response_format={"type": "json_object"},
    )
    raw_text = completion.choices[0].message.content or ""
    try:
        ticket = TicketClassification.model_validate_json(raw_text)
        break
    except ValidationError as exc:
        messages.append(
            {
                "role": "user",
                "content": f"Validation failed with {exc.errors()}. Return corrected JSON only.",
            }
        )
else:
    raise RuntimeError("exhausted structured output retries")

assert ticket is not None

실제 서비스에서는 추적 ID를 연결하고, 로그에서 고객 텍스트를 삭제하며, 복구 가능한 검증 오류와 거부 또는 불완전한 응답을 구별해야 합니다. 중요한 점은 재시도가 일반화된 “다시 시도” 메시지가 아니라 실제 검증자 출력에 의해 구동된다는 것입니다.

테스트, 재시도, 그리고 폐쇄적 실패(Fail Closed)

LLM 검증이 실패하면 어떻게 해야 할까요? 어깨를 으쓱이는 것이 아닙니다. 페이로드를 거부하고, 실패를 로깅하며, 작업이 재시도할 가치 있다면 제한된 시도로 재시도하고, 쓰레기를 수용 가능한 것처럼 보이는 것으로 정규화하는 대신 폐쇄적으로 실패(fail closed)하십시오. 이는 또한 많은 팀이 제공업체 문서가 해당 경로가 존재한다고 알려주었음에도 불구하고 거부와 불완전한 출력을 명시적으로 처리하는 것을 잊는 곳입니다.

OpenAI의 Responses API의 경우, 실패 처리는 사후 생각이 아니라 일급 코드여야 합니다. 변수는 이 글의 다른 곳에서 채팅 스트리밍에서 온 completion이 아니라 client.responses.create 또는 parse에서 온 response입니다.

if response.status == "incomplete":
    raise RuntimeError(response.incomplete_details.reason)

content = response.output[0].content[0]

if content.type == "refusal":
    raise RuntimeError(content.refusal)

이는 방어적인 과잉 엔지니어링이 아닙니다. 이는 문서화된 실패 모드와 직접적으로 정렬되어 있습니다. 모델이 거부하면, 당신은 스키마 유효한 페이로드를 보유하지 않습니다. 응답이 불완전하면, 당신은 스키마 유효한 페이로드를 보유하지 않습니다. 둘 다 제어 흐름에서 명시적인 분기로 취급하십시오.

또한 모델 호출 자체 외부에서 계약을 테스트해야 합니다.

import pytest
from jsonschema import validate as validate_json
from pydantic import ValidationError


def test_ticket_fixture_matches_schema():
    payload = {
        "category": "bug",
        "priority": "high",
        "needs_human": True,
        "summary": "Checkout duplicates charges after refresh.",
    }
    validate_json(instance=payload, schema=TicketClassification.model_json_schema())


def test_discount_logic_rejects_broken_offer():
    with pytest.raises(ValidationError):
        Offer.model_validate(
            {
                "currency": "USD",
                "amount": "19.00",
                "original_amount": "10.00",
                "discounted": True,
            }
        )


def test_ticket_rejects_unknown_category_string():
    with pytest.raises(ValidationError):
        TicketClassification.model_validate(
            {
                "category": "refund",
                "priority": "high",
                "needs_human": True,
                "summary": "Customer wants a refund.",
            }
        )


def test_ticket_rejects_extra_keys():
    with pytest.raises(ValidationError):
        TicketClassification.model_validate(
            {
                "category": "bug",
                "priority": "high",
                "needs_human": True,
                "summary": "Broken flow.",
                "severity": "critical",
            }
        )

이것이 Python에서 LLM 출력 검증을 위한 테스트 전략의 올바른 형태입니다. 계약의 모든 필드가 연습되도록 jsonschema로 골든 픽스처(golden fixtures)를 검증하십시오. Pydantic으로 시맨틱스를 검증한 후, 불법적인 열거형 문자열, 금지된 추가 키, 그리고 당신이关心的 교차 필드 모순과 같은 적대적 사례를 추가하십시오. 실제 모델 출력을 스냅샷한다면, PII를 제거하고 이를 회귀 픽스처로 취급하십시오.

팀이 OpenAI 스택에 머무른다면, Evals API에는 기계 가독성 형식에 의존하는 작업을 테스트하고 반복하는 데 특화된 구조화된 출력 평가 레시피도 포함되어 있습니다. 그리고 리포지토리에 원시 스키마 파일을 유지한다면, check-jsonschema를 CI 또는 pre-commit에 연결하십시오. 분위기(vibes)가 아닌 계약을 배포하십시오.

나중에 당신을 구하는 프로덕션 체크리스트

검증이 실패하면, FAQ 답변은 직설적입니다. 페이로드를 거부하고, 이유를 로깅하며, 작업이 또 다른 시도를 가치 있다면 표적 피드백으로 재시도하고, 나쁜 데이터를 큐로 강제하는 대신 폐쇄적으로 실패하십시오.

짧은 운영 체크리스트는 팀이 반복된 사고를 피하는 데 도움이 됩니다.

  • 제공업체에게 보낸 JSON 스키마의 스키마 버전 또는 해시를 로깅하여 실패를 정확하게 재현할 수 있도록 하십시오.
  • 로그에서 모델 입력과 출력을 삭제(redact)하십시오. 고객 텍스트가 누출되면 구조화된 로그는 무용지물입니다.
  • 거부율, 불완전한 응답률, 검증 실패율, 복구 성공률에 대한 카운터 또는 메트릭을 방출하십시오.那里的尖峰는 모델 또는 프롬프트 변경이 배포되었을 때 추측하는 것보다 낫습니다.

더 넓은 LLM 시스템용 가시성(Observability) 가이드는 카운터가 존재하면 이러한 신호를 대시보드, 추적, SLO 검토에 연결하는 데 도움이 됩니다.

최상의 관법은 복잡하지 않습니다. 가능하면 제공업체 측 구조화된 출력 또는 엄격한 도구 스키마를 사용하십시오. 필요할 때 원시 텍스트를 정규화하십시오. Pydantic으로 Python에서 계약을 미러링하십시오. 스키마가 증명할 수 없는 것에 대해 비즈니스 규칙 검증을 추가하십시오. 거부와 불완전한 응답을 일반적인 분기로 처리하십시오. 계약이 데모가 멈추고 소프트웨어가 될 때까지 테스트하십시오. 그보다 못한 것은 단지 프롬프트 엔지니어링 코스프레(cosplay)에 불과합니다.

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.