LLM의 구조화된 출력(Structured Output)과 싸우는 것을 멈춘 방법 (그리고 배운 점)
요약
LLM의 비정형 텍스트 출력으로 인한 JSON 파싱 오류와 환각 문제를 해결하기 위해 스키마 기반의 구조화된 출력 방식을 제안합니다. Pydantic과 instructor 라이브러리를 활용하여 모델의 출력을 검증된 스키마로 강제함으로써 성공률을 99.5%까지 높이는 방법을 다룹니다.
핵심 포인트
- 프롬프트 엔지니어링만으로는 LLM의 JSON 출력 형식을 완벽히 제어하기 어려움
- Pydantic과 instructor 라이브러리를 활용한 스키마 기반 제어 방식 권장
- 구조화된 출력 방식 도입 시 성공률은 크게 상승하나 약간의 지연 시간과 비용 발생
- Function/Tool calling을 지원하는 모델에서 가장 효과적으로 작동함
내 CI 파이프라인을 망가뜨린 환각 (Hallucination)
두 달 전, 저는 내부 문서화 어시스턴트를 구축하고 있었습니다. 아이디어는 간단했습니다. 함수의 docstring(문서화 문자열)과 몇 가지 컨텍스트를 입력하면 name, description, parameters, example이 포함된 깔끔한 JSON 객체를 출력하는 것이었습니다. 제가 사용하던 LLM (API를 통한 GPT-4)은 이를 수행할 수 있었습니다—때때로 말이죠. 하지만 약 30%의 확률로 다음과 같은 문제가 발생했습니다:
- JSON 앞뒤에 추가 텍스트를 뿌림
- 큰따옴표 대신 작은따옴표를 사용함 (유효하지 않은 JSON)
- 요청하지 않은 완전히 가짜인 파라미터(parameter)를 포함함
- 괄호를 닫는 것을 잊어버림
세 번째 카테고리가 제 파이프라인을 망가뜨렸습니다. 가짜 파라미터들이 우리 API 스키마(schema)로 파싱되어 유효하지 않은 OpenAPI 명세(spec)를 생성했고, 결국 CI 빌드를 실패하게 만들었습니다. 우리는 코드를 작성하는 시간보다 검증하는 데 더 많은 시간을 쓰고 있었습니다.
처음에 시도했던 것들 (그리고 실패한 이유)
1. 더 나은 프롬프트(prompt)로 빌기
저는
모델의 자연스러운 텍스트 출력(text output)과 싸우는 대신, 저는 접근 방식을 뒤집었습니다. 스키마 기반의 프롬프트(schema-backed prompt)와 출력을 검증된 구조로 강제하는 파서(parser)를 사용하여, 처음부터 모델의 출력 형식을 제한한 것입니다. 핵심 기술은 다음과 같습니다:
- 예상되는 JSON 스키마(schema)를 정의합니다 (예: Pydantic 사용).
- 시스템 프롬프트(system prompt)에 스키마를 명확하고 기계가 읽을 수 있는 설명으로 직렬화(serialize)합니다.
- 모델에게 해당 스키마와 일치하는 JSON 객체를 출력하도록 요청합니다.
instructor나lmql같은 라이브러리를 사용하여 JSON을 안정적으로 추출합니다.
다음은 실시간으로 검증을 수행하는 instructor(OpenAI를 위한 얇은 래퍼(wrapper))를 사용한 최소한의 Python 예시입니다:
from pydantic import BaseModel, Field
from typing import List
import openai
...
내부적으로 instructor는 스키마를 포함하는 수정된 프롬프트를 전송하며, OpenAI의 함수 호출(function calling) 기능을 사용하여 반드시 검증되어야 하는 JSON 응답을 가져옵니다. 만약 검증에 실패하면, 에러 메시지와 함께 자동으로 재시도(retry)를 보냅니다. 정규 표현식(regex)도, 추측도 필요 없습니다.
결과
- 성공률이 70%에서 약 99.5%로 상승했습니다.
- 나머지 0.5%는 모델이 _내용(content)_을 오해하는 경우(예: 잘못된 파라미터 타입)이지만, 여전히 제가 쉽게 수정할 수 있는 유효한 JSON을 생성합니다.
- 추가적인 함수 호출(function-call) 왕복(roundtrip)으로 인해 지연 시간(latency)이 약 100ms 증가했지만, 재시도 루프(retry loop)를 제거했기 때문에 전반적으로는 더 빠릅니다.
배운 점과 트레이드오프 (Trade-offs)
- 이 방식은 function/tool calling을 지원하는 모델에서만 작동합니다. 로컬 모델이나 이를 노출하지 않는 제공업체를 사용하는 경우, 다른 접근 방식(예: grammar-based sampling)이 필요합니다.
- 추가적인 API 호출 비용이 발생합니다. 구조화된 생성(structured generation)은 내부적으로 두 번째 호출을 사용합니다. 대량의 요청을 처리하는 저비용 앱의 경우, 이것이 이상적이지 않을 수 있습니다.
- 스키마(Schema) 설계가 중요합니다. 스키마가 너무 복잡하면(깊은 중첩, 거대한 enum), 모델이 과부하가 걸리거나 환각 (hallucination)을 일으킬 수 있습니다. 평탄하고(flat) 명시적으로 유지하세요.
- 이 기술은 이론적으로는 모델에 구애받지 않지만(model-agnostic), 실제로는 그렇지 않습니다. GPT-4o와 Claude 3.5로 테스트했을 때는 둘 다 잘 작동했습니다. 더 작은 모델(예: GPT-3.5)은 여전히 준수하는 데 어려움을 겪습니다.
이 방식을 사용하지 말아야 할 때
출력이 순수하게 자연어인 챗봇(예: 친근한 이메일 답장)을 구축하는 경우, 구조화된 생성은 과합니다. 모델이 자유롭게 쓰도록 두고 필요한 부분만 파싱하세요. 또한, 가끔 발생하는 재시도를 허용할 수 있고 요청량이 적다면, 단순한 try-catch 접근 방식이 구현과 디버깅 면에서 더 간단합니다.
하지만 LLM 출력을 타입이 지정된 데이터(typed data)로 소비하는 자동화 파이프라인을 구축하고 있고, 쉼표 하나가 빠져서 CI(지속적 통합)가 실패하는 고통을 느껴본 적이 있다면, 이 패턴은 구원자가 될 것입니다.
다음에 한다면 다르게 할 점
- 첫날부터 스키마 기반 검증(schema-driven validation)으로 시작하세요. 즉시 구조화된 출력 라이브러리를 사용했어야 할 때, 프롬프트 엔지니어링(prompt engineering)에 일주일이라는 시간을 낭비했습니다.
- 프로덕션에 배포하기 전에 엣지 케이스(edge cases)에 대한 작은 테스트 세트를 구축하세요 (중첩된 객체, 빈 리스트, 중복 키 등).
- 스키마 드리프트(schema drift)를 모니터링하세요. 모델이 변경되거나 스키마를 업데이트하면 기존 프롬프트가 깨질 수 있습니다. 몇 가지 샘플 입력을 실행하고 출력 구조를 확인하는 간단한 테스트를 CI에 추가하세요.
저는 여전히 대안적인 도구들을 실험하고 있습니다. 최근에 테스트한 한 서비스(ai.interwestinfo.com)는 유사한 구조화된 생성 (Structured Generation) API를 제공하지만, 함수 호출 (Function-calling) 레이어를 완전히 추상화합니다. 흥미롭긴 하지만, 중요한 것은 기술 그 자체입니다.
여러분도 LLM 출력의 신뢰성 문제와 비슷한 문제를 겪은 적이 있나요? 출력을 일관되게 유지하기 위해 어떤 설정을 사용하시나요? 여러분에게 효과적이었던 방법이 무엇인지 꼭 듣고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기