구조화된 출력 (Structured Outputs): LLM 응답을 수동으로 파싱하던 방식에서 벗어나는 법
요약
LLM의 비정형 응답으로 인한 파싱 오류 문제를 해결하기 위해 OpenAI의 Structured Outputs 기능을 활용하는 방법을 다룹니다. Pydantic과 Django를 결합하여 모델이 JSON 스키마를 엄격히 준수하도록 강제함으로써 안정적인 데이터 파이프라인을 구축하는 사례를 소개합니다.
핵심 포인트
- LLM의 자유 형식 응답은 마크다운 코드 펜스나 필드명 변경 등 파싱 오류를 유발함
- OpenAI의 Structured Outputs를 사용하면 생성 시점에 JSON 스키마 준수를 보장할 수 있음
- Pydantic을 활용해 스키마를 정의하면 타입이 지정된 데이터를 안정적으로 확보 가능함
- Django 애플리케이션 내 문서 처리 파이프라인에 적용하여 운영 안정성을 높임
우리가 대화하는 모든 팀은 동일한 이야기의 변형된 버전을 가지고 있습니다. 그들은 테스트 단계에서 잘 작동하는 LLM 통합 시스템을 구축합니다. 그러다 운영(production)에 들어간 지 3주가 지나면, 무언가 약간 다르게 돌아오기 시작합니다. 모델이 JSON을 코드 블록으로 감싸거나, "status": "complete" 대신 "status": "Completed"를 사용하거나, 다운스트림 파서(downstream parser)를 망가뜨리는 추가 키를 포함하는 식입니다. 전체 파이프라인이 무너집니다.
이 포스트는 우리가 그 문제를 어떻게 처리하는지에 관한 것입니다. 구체적으로, 운영 중인 Django 애플리케이션에서 LLM으로부터 신뢰할 수 있는 타입 지정 데이터(typed data)를 얻기 위해 어떻게 구조화된 출력 (Structured Outputs)을 사용하는지, 그리고 이 접근 방식에 여전히 한계가 있는 부분은 어디인지에 대해 다룹니다.
자유 형식 텍스트(free-text) LLM 응답 파싱의 문제점
LLM에게 "JSON을 반환해줘"라고 요청하면, 대개는 그렇게 합니다. 하지만 그러지 않을 때가 있습니다.
실패 모드(failure modes)는 충분히 많이 경험하고 나면 예측 가능합니다:
- 모델이 출력을 마크다운 코드 펜스(markdown code fence,
json ...)로 감쌉니다. - 필드 이름이 미세하게 변합니다 (
customer_idvscustomerIdvscustomer id). - 선택적 필드(Optional fields)가 일관성 없이 나타나기도 하고 사라지기도 합니다.
- 모델이 JSON 앞이나 뒤에 대화형 문장을 추가합니다.
- 엣지 케이스(edge cases)에서 숫자 필드가 문자열로 반환됩니다.
이 중 놀라운 것은 없습니다. 모델은 텍스트 예측기(text predictor)이지, JSON 직렬화기(JSON serialiser)가 아니기 때문입니다. 모델의 출력을 신뢰할 수 있는 구조화된 데이터로 취급하려면, 생성 시점에 구조를 강제하거나, 모든 변형을 처리하는 방어적인 파싱(defensive parsing) 코드를 작성해야 합니다. 두 번째 경로는 시간이 지날수록 심화되는 유지보수 문제입니다.
구조화된 출력은 생성 시점에 스키마(schema)를 강제합니다
더 깔끔한 접근 방식은 모델이 생성할 수 있는 것을 제한하는 것입니다. OpenAI의 구조화된 출력 (Structured Outputs) 기능(2024년 말부터 사용 가능)을 사용하면 API에 JSON 스키마를 전달할 수 있으며, 모델은 해당 스키마를 준수하는 출력을 반환한다고 보장됩니다. 코드 펜스도, 멋대로 추가된 필드도, 타입 불일치도 없습니다.
우리는 Pydantic으로 스키마를 정의하고 이를 API에 직접 전달합니다:
from pydantic import BaseModel
from openai import OpenAI
from typing import Literal
...
반환 값은 적절한 Pydantic 모델 인스턴스입니다. result.company_name에 직접 접근하거나, 이를 Django 시리얼라이저 (serializer)에 전달하거나, JSONField에 저장할 수 있습니다. 이는 파싱해야 하는 문자열이 아니라 타입이 지정된 데이터 (typed data)입니다.
실제 Django 파이프라인에서의 모습
우리는 업로드된 계약서 및 비즈니스 문서에서 주요 필드를 추출한 뒤, 사람이 검토하도록 라우팅하는 문서 처리 파이프라인 (document processing pipeline)에서 이 패턴을 사용합니다.
# models.py
from django.db import models
...
여기서 핵심적인 결정 사항은 다음과 같습니다: 신뢰도가 낮은 추출 결과는 자동으로 사람의 검토 단계로 라우팅됩니다. 신뢰도 (confidence) 필드는 스키마 (schema)의 일부입니다. 우리는 모델이 불확실성을 스스로 보고하도록 지시하며, 그 결과에 따라 조치를 취합니다. 이는 우리의 에이전트 (agent) 설계 원칙과 동일합니다. 사람의 검토 경로는 단순한 예외 처리 (fallback)가 아니라, 일급 시민 (first-class)으로서 취급됩니다.
거부 (Refusals) 처리하기
구조화된 출력 (structured outputs)이 방지할 수 없는 유일한 경우는 모델의 거부 (refusal)입니다. 만약 모델이 입력 내용이 자신의 콘텐츠 정책을 위반한다고 판단하면, response.choices[0].message.parsed는 None이 되고, response.choices[0].message.refusal에 거부 메시지가 포함됩니다.
이에 대해서는 명시적인 처리가 필요합니다:
message = response.choices[0].message
if message.refusal:
...
실제로 문서 추출 작업에서 거부 사례는 드뭅니다. 거부는 고객 지원 티켓, 포럼 게시물, 모더레이션되지 않은 사용자 콘텐츠와 같이 플래그(flag)가 지정될 수 있는 콘텐츠에 대해 분류 (classification)나 분석을 수행할 때 더 흔하게 발생합니다. 만약 귀하의 파이프라인이 이러한 종류의 입력을 처리한다면, 거부 처리를 조기에 테스트하십시오.
Anthropic의 대응 방식: 도구 사용 (tool use)
만약 Anthropic의 Claude 모델(우리가 일부 작업에 사용하기도 하는 모델)을 사용하고 있다면, 이에 상응하는 메커니즘은 도구 사용 (tool use)입니다. JSON 스키마를 사용하여 도구를 정의하고, 모델이 항상 이를 호출하도록 지시하면, 메시지 내용 대신 도구 호출 (tool call)을 통해 구조화된 출력을 얻을 수 있습니다.
import anthropic
import json
...
tool_choice 파라미터는 모델이 산문(prose)으로 응답하는 대신 항상 지정된 도구 호출 (tool call)을 수행하도록 강제합니다. 이 파라미터가 없다면 모델은 어떤 때는 도구를 호출하고 어떤 때는 텍스트로 답변할 수 있으며, 이는 프로덕션 파이프라인 (production pipeline)에서 유용하지 않습니다.
구조화된 출력이 해결하지 못하는 것들
명확히 해둘 몇 가지 사항이 있습니다:
잘못된 프롬프트를 해결해주지는 않습니다. 만약 시스템 프롬프트 (system prompt)가 특정 필드에 무엇이 포함되어야 하는지 모호하다면, 구조는 일관되지만 의미론적 (semantics) 내용은 일관되지 않을 것입니다. confidence: "high"는 당신이 의도한 의미가 아니라, 모델이 추론한 의미가 무엇이든 간에 그 의미를 나타낼 뿐입니다. 스키마 설계 (schema design)와 프롬프트 설계 (prompt design)는 함께 이루어져야 합니다.
환각 (hallucination)을 방지하지는 못합니다. 모델은 여전히 계약 금액을 지어내거나 날짜를 잘못 지정할 수 있습니다. 당신은 신뢰할 수 있는 형태의 데이터를 얻는 것이지, 데이터의 정확성은 여전히 모델의 추론 (reasoning) 능력과 소스 텍스트의 품질에 달려 있습니다. 중요한 필드의 경우, 추출된 값을 소스 텍스트와 대조하는 검증 단계를 추가하세요.
지연 시간 (latency)을 증가시킵니다. 제약된 디코딩 (constrained decoding)을 통한 구조화된 출력 생성은 제약이 없는 생성 (unconstrained generation)보다 약간 더 느립니다. 실시간 사용자 대상 기능의 경우, 이 패턴을 도입하기 전에 이를 측정하십시오. 백그라운드 처리 파이프라인 (background processing pipelines)의 경우에는 일반적으로 문제가 되지 않습니다.
솔직한 요약
구조화된 출력은 특별한 기술이 아닙니다. LLM으로부터 타입이 지정된 데이터 (typed data)가 필요할 때 선택해야 하는 올바른 기본값일 뿐입니다. 자유 형식 텍스트 파싱 (free-text parsing)은 장기적으로 유지보수 시간과 프로덕션 장애를 초래하는 함정입니다.
데이터베이스, API 또는 다른 시스템으로 데이터를 출력하는 LLM 통합 (LLM integration)을 구축하고 있다면: Pydantic 스키마를 정의하고, response_format을 사용하며, 거부 (refusals) 상황을 처리하고, 신뢰도가 낮은 결과는 사람의 검토로 라우팅하십시오. 그것이 정석적인 패턴입니다. 한 번 보고 나면 복잡하지 않지만, 시스템이 얼마나 안정적으로 작동하는지에 있어 의미 있는 차이를 만들어냅니다.
Lycore는 기업을 위한 프로덕션 AI 시스템(production AI systems)을 구축합니다 — 문서 지능 (document intelligence), 에이전트 (agents), RAG 파이프라인 (RAG pipelines), 그리고 Django, React, Flutter, .NET 기반의 맞춤형 LLM 통합 (custom LLM integrations)을 제공합니다. 귀하의 사용 사례 (use case)에 대해 논의하고 싶다면 문의해 주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기