- Published on
LangChain+OpenAI Structured Outputs로 JSON 깨짐 0%
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM을 붙이다 보면 결국 한 번은 "JSON이 깨졌다"는 이슈를 맞습니다. 따옴표가 빠지거나, 필드명이 바뀌거나, 설명 문장이 JSON 앞뒤에 붙거나, 배열 대신 문자열이 오거나… 파싱 실패는 곧 장애입니다. 특히 워크플로우가 여러 단계로 이어지는 LangChain 기반 서비스에서는 한 번의 파싱 실패가 전체 체인을 멈추게 만들기도 합니다.
이 글은 LangChain과 OpenAI의 Structured Outputs를 활용해 JSON 깨짐을 사실상 0%로 수렴시키는 방법을 정리합니다. 핵심은 세 가지입니다.
- 모델 출력 형식을 스키마로 강제한다
- 스키마를 “모델이 지키기 쉬운 형태”로 설계한다
- **실패했을 때의 복구 루프(재시도/수정/관측)**를 체인에 내장한다
추가로 Structured Output 파싱에서 자주 터지는 케이스는 아래 글에서 더 깊게 다뤘습니다.
왜 JSON이 깨질까: 원인부터 정확히 분해하기
LLM 출력이 JSON 파서를 깨는 원인은 크게 네 가지로 수렴합니다.
1) “JSON만 출력” 지시가 약하다
프롬프트에 "JSON으로만 답해"를 적어도 모델은 친절하게 설명을 붙이려는 경향이 있습니다. 특히 디버깅/설명 성향이 강한 모델일수록 본문 앞뒤로 텍스트가 섞입니다.
2) 스키마가 모호하거나 과도하게 복잡하다
- optional 필드가 너무 많음
- union 타입이 많음
- 중첩이 깊음
additionalProperties가 열려 있음
모델이 “정확히 무엇을 채워야 하는지” 판단하기 어려우면, 필드명을 바꾸거나 구조를 단순화해서 내보내려다 깨집니다.
3) 출력 길이/토큰 압박
긴 답변에서 토큰이 부족하면 JSON이 중간에 끊기거나 닫는 괄호가 누락됩니다. 이는 재현이 어렵고, 운영에서 가장 괴롭습니다.
4) 후처리에서의 취약한 파싱
정규식으로 JSON 블록만 잘라내거나, 첫 {부터 마지막 }까지 자르는 식의 파서는 예외 케이스에 취약합니다. “운 좋으면 되지만” 운영에서는 결국 터집니다.
Structured Outputs는 1)과 4)를 강하게 해결하고, 2)와 3)는 스키마/운영 설계로 보완합니다.
Structured Outputs란: “문자열 JSON”이 아니라 “스키마 준수”
OpenAI Structured Outputs는 모델이 특정 JSON Schema를 만족하는 결과만 내도록 유도합니다. 중요한 포인트는 "그럴듯한 JSON 문자열"을 받는 게 아니라, 스키마를 기준으로 파싱 가능한 구조를 받는 것입니다.
LangChain에서는 보통 다음 두 방식으로 접근합니다.
- LangChain의 구조화 출력 기능(모델 래퍼)로 스키마를 강제
- 실패 시 재시도/수정 루프를 체인에 포함
아래 예제는 Python 기준으로 설명합니다.
실전 1: Pydantic 스키마로 출력 강제하기
가장 추천하는 방식은 Pydantic 모델을 스키마의 단일 진실 공급원(SSOT) 으로 두는 것입니다. 이렇게 하면 타입/필수 필드/제약을 코드 레벨에서 관리할 수 있습니다.
from typing import List, Literal, Optional
from pydantic import BaseModel, Field, conint
class Item(BaseModel):
sku: str = Field(..., description="Stock keeping unit")
name: str
quantity: conint(ge=1, le=999)
class OrderExtraction(BaseModel):
currency: Literal["KRW", "USD", "JPY"]
total_amount: int = Field(..., ge=0)
items: List[Item]
notes: Optional[str] = None
스키마 설계에서 운영 안정성을 높이는 팁은 아래에서 다룹니다(중요).
실전 2: LangChain에서 Structured Output 체인 구성
LangChain 버전과 OpenAI 래퍼에 따라 API가 조금씩 다르지만, 핵심은 "모델 호출 시 스키마를 함께 전달"하는 것입니다.
아래 코드는 구조화 출력을 기본으로 하고, 모델이 스키마를 만족하지 못하면 예외로 처리되도록 구성합니다.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(
model="gpt-4.1-mini",
temperature=0,
)
prompt = ChatPromptTemplate.from_messages([
("system", "You extract structured data. Output must match the schema."),
("user", "영수증 텍스트에서 주문 정보를 추출해줘.\n\n{receipt_text}"),
])
# LangChain 구현에 따라 메서드명은 다를 수 있습니다.
# 핵심은 'OrderExtraction' 같은 스키마를 모델 출력에 강제하는 것입니다.
structured_llm = llm.with_structured_output(OrderExtraction)
chain = prompt | structured_llm
result = chain.invoke({"receipt_text": "아메리카노 2잔 9,000원..."})
print(result)
# result는 보통 Pydantic 객체 또는 dict로 반환
여기서 중요한 점은 temperature=0 입니다. 구조화 출력에서는 창의성이 아니라 정합성이 중요합니다.
JSON 깨짐 0%에 가까워지려면: 스키마 설계가 절반이다
Structured Outputs를 써도 스키마가 나쁘면 실패합니다. 운영에서 실패율을 낮추는 스키마 설계 규칙은 다음과 같습니다.
1) optional 남발 금지, 필수 필드를 명확히
가능하면 필수 필드를 늘리고, 정말 없을 수 있는 값만 optional로 둡니다. 모델은 optional이 많을수록 "대충 빼도 된다"고 학습된 듯한 행동을 합니다.
- 좋음:
notes같은 부가 정보만 optional - 나쁨: 핵심 값인
items까지 optional
2) enum은 적극적으로, free-form 문자열은 최소화
예를 들어 통화는 KRW/USD처럼 enum으로 제한하세요. 문자열 자유도가 높을수록 모델은 변형을 만들어냅니다.
3) union 타입(예: str | list[str]) 피하기
모델 입장에선 어느 쪽이든 맞으니 일관성이 깨집니다. 가능하면 하나로 고정하고, 필요하면 별도 필드로 분리합니다.
4) 깊은 중첩 구조는 단계적으로
order -> items -> options -> modifiers -> ... 처럼 깊어질수록 실패율이 올라갑니다.
권장 패턴은 2단계입니다.
- 1차: 얕은 스키마로 핵심만 추출
- 2차: 필요한 경우 특정
item에 대해 상세 추출
5) 숫자는 숫자로, 날짜는 포맷을 고정
total_amount를 문자열로 받으면 "9,000원"처럼 섞여 들어옵니다. 숫자는 숫자로 받고, 필요한 경우 원문 문자열은 별도 필드로 둡니다.
운영 패턴: “깨지면 재시도”가 아니라 “깨지지 않게 + 깨지면 복구”
Structured Outputs를 쓰면 파싱 실패가 크게 줄지만, 운영에서는 네트워크/타임아웃/토큰 제한 같은 외부 요인으로 실패할 수 있습니다. 그래서 체인에 복구 전략을 넣어야 합니다.
1) 재시도는 “같은 프롬프트 반복”이 아니라 “수정 지시”로
단순 재시도는 같은 실패를 반복할 가능성이 큽니다. 실패 이유를 모델에게 주고, 스키마를 다시 상기시키는 방식이 효과적입니다.
from tenacity import retry, stop_after_attempt, wait_exponential
from pydantic import ValidationError
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
def invoke_structured(receipt_text: str):
try:
return chain.invoke({"receipt_text": receipt_text})
except ValidationError as e:
# 보통은 여기서 실패 원인 로깅 후, 프롬프트를 보강한 체인으로 재호출하는 전략을 씁니다.
# 예: "이전 출력이 스키마를 만족하지 못했습니다. 오류: ... 스키마를 엄격히 지키세요"
raise
팁: 재시도 시 temperature를 더 낮추거나(이미 0이면 유지), 출력 길이를 줄이도록 지시하면 토큰 관련 실패가 줄어듭니다.
2) 타임아웃/서킷브레이커로 연쇄 장애 방지
LLM 호출은 외부 의존성이므로, 지연이 누적되면 워커가 잠깁니다. API 서버라면 타임아웃과 동시성 제한을 반드시 둬야 합니다.
이건 LLM에 국한된 얘기는 아니고, 인프라 운영 전반에서 동일합니다. 예를 들어 트래픽 급증 시 upstream이 느려지면 499가 폭주할 수 있는데, 이런 연쇄 장애 패턴은 아래 글의 관점이 도움이 됩니다.
관측(Observability): “파싱 실패율 0%”를 수치로 증명하기
"안 깨지는 것 같다"가 아니라, 지표로 봐야 합니다. 추천하는 최소 지표는 다음입니다.
structured_output_success_totalstructured_output_validation_error_totalstructured_output_retry_totalllm_latency_ms(p50/p95/p99)llm_token_usage(input/output)
그리고 실패 샘플을 저장할 때는 개인정보/민감정보를 마스킹하세요. 영수증/상담 로그는 특히 위험합니다.
RAG와 결합할 때: 구조화 출력이 환각을 줄이는 방식
RAG에서 흔한 실수는 "검색 결과를 요약해서 JSON으로" 같은 프롬프트를 한 번에 처리하는 것입니다. 이 경우 모델은 검색 결과에 없는 필드를 채우려다 환각을 만들 수 있습니다.
권장 파이프라인은 다음입니다.
- 검색 결과에서 인용 가능한 근거 조각을 먼저 선정
- 그 근거 조각만을 입력으로 구조화 추출
- 각 필드에 근거
source_id를 붙여 검증 가능하게 만들기
이 접근은 아래 글의 citation 기반 검증과도 잘 맞습니다.
예시 스키마 아이디어:
from typing import List, Optional
from pydantic import BaseModel
class FieldWithCitation(BaseModel):
value: str
source_id: str
class AnswerWithCitations(BaseModel):
answer: FieldWithCitation
caveats: Optional[List[FieldWithCitation]] = None
이렇게 하면 "값"과 "근거"가 한 쌍으로 움직여서, 운영에서 검증과 디버깅이 쉬워집니다.
자주 터지는 함정 체크리스트
1) 모델이 스키마를 지키는데도 앱에서 깨지는 경우
- 응답을 문자열로 다시 직렬화했다가 재파싱하는 과정이 있는지 확인
- 로깅/마스킹 단계에서 따옴표를 건드리는지 확인
- gzip/streaming 처리 중 바이트가 잘리는지 확인
2) 한국어 숫자/통화 처리
"9,000원" 같은 입력은 모델이 숫자만 추출하도록 스키마와 프롬프트를 맞춰야 합니다.
- 프롬프트에 "금액은 원 단위 정수로"를 명시
- 스키마에서
int로 강제
3) 긴 입력(문서 통째로) 넣지 않기
입력이 길어질수록 토큰 압박이 커지고, 출력이 중간에 끊길 확률이 올라갑니다.
- 문서를 먼저 청크로 나누고
- 필요한 부분만 컨텍스트로 주고
- 구조화 출력은 “작은 단위”로 여러 번 호출
결론: “JSON을 잘 만들게 부탁”에서 “스키마로 강제”로
LangChain + OpenAI Structured Outputs 조합은 JSON 파싱 실패를 구조적으로 줄여줍니다. 하지만 진짜로 실패율을 0%에 가깝게 만들려면 다음을 함께 가져가야 합니다.
- 스키마를 단순하고 엄격하게 설계
temperature=0기반의 정합성 우선 전략- ValidationError 중심의 재시도/복구 루프
- 실패율/재시도율/토큰/지연을 지표로 관측
이 패턴을 적용하면 "가끔 JSON 깨져요"가 아니라, "깨지면 자동 복구되고, 실패는 지표로 관리된다" 수준까지 올릴 수 있습니다.