LLM 출력 파싱과 구조화 추출: 안정적인 JSON 추출 전략

AI 기술

LLM 파싱구조화 출력JSON 추출PydanticInstructor

이 글은 누구를 위한 것인가

  • LLM이 가끔 JSON 형식을 벗어나 파싱 에러가 나는 팀
  • Pydantic 모델로 LLM 출력을 타입 안전하게 받고 싶은 개발자
  • 복잡한 중첩 구조를 안정적으로 추출하고 싶은 엔지니어

들어가며

LLM에게 "JSON으로 반환하세요"라고 해도 마크다운 코드 블록을 감싸거나, 주석을 추가하거나, 빠진 콤마를 넣는다. 안정적인 파싱 전략이 없으면 프로덕션 에러율이 5-10%에 달한다.

이 글은 bluefoxdev.kr의 LLM 엔지니어링 패턴 을 참고하여 작성했습니다.


1. 파싱 전략 비교

[LLM 출력 파싱 방법]

1. 순진한 파싱 (json.loads):
   실패율: ~10%
   문제: 마크다운 래퍼, 주석, 후행 콤마

2. 강건한 파싱 (정규식 + 정제):
   마크다운 코드 블록 제거
   json5 라이브러리 (주석, 후행 콤마 허용)
   실패율: ~2%

3. XML 태그 방식:
   <json>...</json> 패턴 추출
   LLM이 따르기 더 쉬움
   실패율: ~1%

4. Claude tool_use:
   structured output 보장
   실패율: <0.1%
   단점: 약간 느림, 비용

5. Instructor 라이브러리:
   Pydantic 모델 직접 반환
   자동 재시도 + 검증
   실패율: <0.5%

[파싱 실패 대응]
  1회 실패: 재시도 (다른 temperature)
  2회 실패: 더 명확한 프롬프트로 재시도
  3회 실패: 기본값 반환 + 알림

2. 구조화 출력 구현

import anthropic
import json
import re
from pydantic import BaseModel, ValidationError
from typing import TypeVar, Type

client = anthropic.Anthropic()

T = TypeVar("T", bound=BaseModel)

def extract_json_from_text(text: str) -> str:
    """LLM 출력에서 JSON 추출"""
    
    # 마크다운 코드 블록 제거
    code_block = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
    if code_block:
        return code_block.group(1).strip()
    
    # XML 태그
    xml_tag = re.search(r"<json>([\s\S]*?)</json>", text)
    if xml_tag:
        return xml_tag.group(1).strip()
    
    # 중괄호 또는 대괄호로 시작하는 JSON
    json_match = re.search(r"([{\[][\s\S]*[}\]])", text)
    if json_match:
        return json_match.group(1)
    
    return text

def robust_json_parse(text: str) -> dict | list:
    """강건한 JSON 파싱 (후행 콤마, 주석 허용)"""
    
    extracted = extract_json_from_text(text)
    
    # 1시도: 표준 파싱
    try:
        return json.loads(extracted)
    except json.JSONDecodeError:
        pass
    
    # 2시도: 후행 콤마 제거
    cleaned = re.sub(r",\s*([}\]])", r"\1", extracted)
    try:
        return json.loads(cleaned)
    except json.JSONDecodeError:
        pass
    
    # 3시도: json5 (주석 허용)
    try:
        import json5
        return json5.loads(extracted)
    except Exception:
        pass
    
    raise ValueError(f"JSON 파싱 실패: {extracted[:200]}")

def llm_extract(
    prompt: str,
    output_model: Type[T],
    max_retries: int = 3,
    model: str = "claude-haiku-4-5-20251001",
) -> T:
    """Pydantic 모델로 LLM 출력 추출"""
    
    schema = output_model.model_json_schema()
    schema_str = json.dumps(schema, ensure_ascii=False, indent=2)
    
    for attempt in range(max_retries):
        full_prompt = f"""{prompt}

다음 JSON Schema를 따르는 JSON만 반환하세요. 다른 텍스트 없이:
{schema_str}"""
        
        response = client.messages.create(
            model=model,
            max_tokens=2000,
            messages=[{"role": "user", "content": full_prompt}],
        )
        
        try:
            raw = robust_json_parse(response.content[0].text)
            return output_model(**raw)
        except (ValueError, ValidationError) as e:
            if attempt == max_retries - 1:
                raise
            # 에러 피드백으로 재시도
            full_prompt = f"""{full_prompt}

이전 시도 결과가 검증에 실패했습니다: {e}
올바른 형식으로 다시 반환하세요."""
    
    raise RuntimeError("최대 재시도 초과")

def llm_extract_via_tools(
    prompt: str,
    output_model: Type[T],
) -> T:
    """tool_use로 구조화 출력 (가장 안정적)"""
    
    schema = output_model.model_json_schema()
    
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2000,
        tools=[{
            "name": "extract_data",
            "description": "요청된 데이터를 구조화된 형식으로 추출",
            "input_schema": schema,
        }],
        tool_choice={"type": "tool", "name": "extract_data"},
        messages=[{"role": "user", "content": prompt}],
    )
    
    tool_use = next(b for b in response.content if b.type == "tool_use")
    return output_model(**tool_use.input)

# 사용 예시
class ProductInfo(BaseModel):
    name: str
    price: float
    category: str
    in_stock: bool
    tags: list[str]

class ReviewSummary(BaseModel):
    overall_sentiment: str  # positive/neutral/negative
    key_positives: list[str]
    key_negatives: list[str]
    recommendation_score: float

def extract_product_from_text(product_description: str) -> ProductInfo:
    return llm_extract_via_tools(
        f"다음 상품 설명에서 정보를 추출하세요:\n{product_description}",
        ProductInfo,
    )

def summarize_reviews(review_text: str) -> ReviewSummary:
    return llm_extract(
        f"다음 리뷰들을 분석하세요:\n{review_text}",
        ReviewSummary,
        max_retries=3,
    )

마무리

LLM 출력 파싱의 안정성 순서: tool_use > Instructor > XML 태그 > 강건한 JSON 파싱 > 단순 json.loads. 프로덕션에서는 최소 3회 재시도와 에러 피드백을 구현하라. Pydantic으로 타입 검증을 추가하면 LLM이 반환한 데이터가 코드에서 안전하게 사용된다.