들어가며 — 파싱은 99퍼센트만 성공해도 실패입니다
LLM 출력을 파이프라인의 중간 단계로 쓰기 시작하면, 자연어 품질보다 파싱 성공률이 먼저 문제가 됩니다. 요약이 조금 밋밋한 것은 견딜 수 있지만, 하루 10만 건 중 1퍼센트가 파싱에 실패하면 그건 1천 건의 예외 처리입니다.
이 문제가 까다로운 이유는 실패가 매번 다른 모습으로 온다는 데 있습니다. 어제는 마크다운 펜스로 감싸서 왔고, 오늘은 뒤에 친절한 설명 문장이 붙었고, 내일은 중간에서 잘립니다. 그때마다 문자열 처리를 하나씩 덧붙이다 보면 정규식 스무 줄짜리 파서가 생기고, 그것도 결국 새는 곳이 생깁니다.
해결책은 방어를 겹으로 쌓되 각 겹이 무엇을 막고 무엇을 못 막는지 명확히 아는 것입니다. 이 글은 실패 유형을 먼저 분류하고, 그다음 네 겹의 방어를 순서대로 배치합니다.
실패 유형을 먼저 분류합니다
로그를 열어 파싱 실패 케이스를 100건만 모아 분류하면, 대응 우선순위가 바로 정해집니다. 대체로 여섯 가지로 나뉩니다.
| 실패 유형 | 증상 | 원인 | 가장 값싼 방어 |
|---|---|---|---|
| 코드 펜스 포장 | 백틱 세 개와 json 표기로 감싸서 옴 | 학습 데이터의 마크다운 습관 | 파싱 전 펜스 제거 |
| 앞뒤 설명 문장 | "요청하신 결과입니다" 같은 서문 | 대화형 정렬의 부작용 | 사전 채움, 정지 시퀀스 |
| 잘림 | 객체가 닫히지 않고 끝남 | 토큰 상한 도달 | 종료 사유 확인, 상한 재산정 |
| 잘못된 이스케이프 | 문자열 안 따옴표와 개행 처리 오류 | 긴 자유 텍스트 필드 | 필드 분리, 제약 디코딩 |
| 환각 필드 | 스키마에 없는 키가 추가됨 | 스키마 제약 부재 | 엄격 스키마, 알 수 없는 키 거부 |
| 타입 불일치 | 숫자를 문자열로, 날짜 형식 제각각 | 타입 명시 부족 | 필드 이름과 설명에 형식 명시 |
이 표에서 중요한 건 마지막 열이 전부 다르다는 점입니다. 하나의 대책으로 전부 막으려다 보면 과잉 설계가 됩니다. 실제 분포를 보고 상위 두세 개만 처리해도 실패율이 대개 한 자릿수 아래로 떨어집니다.
앞의 두 유형은 특히 값싸게 막힙니다.
import json, re
FENCE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def extract_json(text):
"""1) 펜스 제거 2) 그래도 안 되면 첫 객체 구간만 잘라 시도"""
cleaned = FENCE.sub("", text).strip()
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
start, end = cleaned.find("{"), cleaned.rfind("}")
if start != -1 and end > start:
return json.loads(cleaned[start:end + 1])
raise ValueError("JSON을 찾을 수 없음")
이건 임시 처방입니다. 근본 대책은 아래 계층에서 다루지만, 이 열 줄이 오늘 밤의 알림을 멈춰 주는 것도 사실입니다.
방어의 네 겹 — 지시, 예시, 스키마, 제약
방어 수단을 강도 순으로 놓으면 이렇습니다. 위로 갈수록 강하고, 아래일수록 싸고 어디서나 됩니다.
첫째 겹은 프롬프트 지시입니다. "JSON만 출력하고 다른 텍스트는 붙이지 마십시오"는 놀라울 만큼 잘 듣습니다. 여기에 두 가지를 더하면 효과가 커집니다. 정지 시퀀스를 지정해 객체가 닫힌 뒤 이어지는 설명을 잘라 내고, 어시스턴트 응답을 여는 중괄호로 미리 시작해 두는 사전 채움을 씁니다. 사전 채움은 서문을 물리적으로 불가능하게 만듭니다.
둘째 겹은 예시입니다. 원하는 형태를 한두 개 보여 주는 것이 형식 준수에 미치는 효과는 지시문 몇 줄보다 큽니다. 특히 열거값, 날짜 형식, 빈 값의 표현 방식처럼 말로 설명하면 장황해지는 규칙에서 그렇습니다.
셋째 겹은 함수 호출 또는 도구 스키마입니다. 자유 텍스트로 JSON을 요청하는 대신, 인자 스키마를 가진 도구를 정의하고 그 도구를 호출하게 만듭니다. 제공자 쪽에서 스키마를 강제해 주므로 앞의 실패 유형 대부분이 사라집니다. API를 쓰신다면 이 계층부터 시작하는 것이 맞습니다.
넷째 겹은 제약 디코딩입니다. 스키마를 유한 상태 기계나 문법으로 컴파일하고, 매 스텝마다 그 상태에서 허용되지 않는 토큰의 확률을 0으로 만듭니다. 출력이 문법을 위반하는 것이 원리적으로 불가능해집니다.
# 오픈 모델을 직접 서빙한다면 제약 디코딩은 서버 옵션으로 켭니다
vllm serve <모델경로> --guided-decoding-backend xgrammar
# 요청 측에서 스키마를 넘깁니다 (예: OpenAI 호환 엔드포인트)
# "guided_json": { ...JSON Schema... }
# "guided_choice": ["긍정", "부정", "중립"]
# "guided_regex": "\\d{4}-\\d{2}-\\d{2}"
분류처럼 출력이 몇 개의 라벨 중 하나인 작업에서는 guided_choice가 특히 강력합니다. 출력 공간이 유한하므로 파싱이라는 개념 자체가 사라집니다.
제약 디코딩이 보장하는 것과 보장하지 않는 것
여기서 가장 중요한 구분을 하겠습니다. 제약 디코딩은 문법적 유효성을 보장합니다. 의미적 정확성은 보장하지 않습니다.
스키마가 price 필드를 정수로 요구하면 정수가 나옵니다. 그 정수가 문서에 적힌 가격인지는 아무도 보장하지 않습니다. 오히려 상황이 미묘하게 나빠질 수 있습니다. 제약이 없을 때 모델은 "문서에서 가격을 찾을 수 없습니다"라고 형식을 어기며 알려 주기라도 하는데, 제약이 걸리면 그 탈출구가 막혀 어떤 숫자든 내놓아야 합니다. 형식 오류가 값 오류로 바뀐 것이고, 값 오류는 감지하기 훨씬 어렵습니다.
그래서 제약 디코딩을 켤 때 반드시 함께 해야 하는 일이 있습니다. 스키마에 "모름"을 표현할 자리를 만드는 것입니다.
# 나쁨: 값이 없어도 반드시 숫자를 내야 한다
{"price": {"type": "integer"}}
# 좋음: 없음을 표현할 수 있다
{"price": {"type": ["integer", "null"]},
"price_found": {"type": "boolean"}}
두 번째 유의점은 문법이 모델의 자연스러운 생성 경로와 충돌할 때입니다. 제약은 확률 분포를 자르는 것이지 모델의 이해를 바꾸는 것이 아니므로, 모델이 가고 싶어 하는 경로가 전부 막히면 남은 것 중 가장 덜 나쁜 토큰이 선택됩니다. 스키마가 복잡하고 부자연스러울수록 이 현상이 커집니다. 제약 디코딩을 켰더니 형식 오류는 사라졌는데 정확도가 떨어졌다면, 스키마를 단순하게 만드는 것이 먼저입니다.
세 번째는 성능입니다. 스텝마다 허용 토큰 마스크를 계산해야 하므로 오버헤드가 있습니다. 최근 구현들은 문법을 미리 컴파일하고 캐시해서 이 비용을 대부분 상쇄하지만, 스키마가 매 요청 달라지면 컴파일 비용이 그대로 드러납니다. 스키마 수가 유한하다면 재사용되도록 만드십시오.
스키마 설계가 곧 프롬프트입니다
모델은 스키마를 제약으로만 받는 것이 아니라 텍스트로 읽습니다. 그래서 필드 이름과 설명은 지시문과 같은 무게를 가집니다. 여기서 얻는 정확도 개선이 프롬프트를 다듬어 얻는 것보다 큰 경우가 많습니다.
from pydantic import BaseModel, Field
from typing import Literal, Optional
# 나쁜 스키마
class BadTicket(BaseModel):
type: str # 무엇이든 들어온다
date: str # 형식 제각각
info: dict # 구조가 없다
# 좋은 스키마
class Ticket(BaseModel):
reasoning: str = Field(description="분류 근거를 두 문장 이내로")
category: Literal["결제", "배송", "환불", "계정", "기타"]
urgency: Literal["낮음", "보통", "높음"]
order_id: Optional[str] = Field(None, description="주문번호. 본문에 없으면 null")
due_date_iso: Optional[str] = Field(None, description="YYYY-MM-DD 형식")
네 가지가 달라졌습니다.
자유 문자열을 열거형으로 바꿨습니다. 이건 정확도와 파싱 안정성을 동시에 올립니다. 다운스트림에서 "결제"와 "결제 문의"를 같은 것으로 매핑하는 코드도 사라집니다.
필드 이름에 형식을 넣었습니다. due_date_iso는 date보다 훨씬 강한 지시입니다. 이름만으로 형식이 전달되면 설명문에 쓸 토큰이 절약됩니다.
없을 수 있는 값을 널 허용으로 만들었습니다. 필수 문자열로 두면 모델은 빈 문자열이나 그럴듯한 가짜 주문번호를 만들어 냅니다.
그리고 가장 효과가 큰 변경은 reasoning을 맨 앞에 둔 것입니다. 생성은 왼쪽에서 오른쪽으로 진행되므로, 앞에 놓인 필드가 뒤 필드의 조건이 됩니다. 근거를 먼저 쓰게 하면 분류 결과가 그 근거에 조건부로 생성됩니다. 같은 필드를 맨 뒤에 놓으면 이미 정해진 답에 대한 사후 설명이 되어 정확도 개선 효과가 사라집니다. JSON 객체의 키 순서는 의미상 무관하지만, 생성 순서는 전혀 무관하지 않습니다.
한 가지 더. 중첩은 얕게 유지하십시오. 3단계 이상 중첩된 객체는 형식 오류율과 값 오류율이 함께 올라가고, 제약 디코딩의 문법도 커집니다. 중첩이 필요해 보이면 대체로 호출을 두 번으로 나누는 편이 낫습니다.
검증-재시도 루프를 제대로 구현하기
방어를 다 쌓아도 실패는 남습니다. 남은 실패를 처리하는 방식이 성공률을 크게 가릅니다.
핵심은 재시도할 때 무엇을 바꾸느냐입니다. 같은 프롬프트로 그냥 다시 부르는 것은 온도가 0이 아닐 때만 의미가 있고, 그마저도 같은 실패를 반복할 확률이 높습니다. 오류 메시지를 대화에 되돌려 주면 성공률이 확연히 달라집니다.
import json
from pydantic import ValidationError
def generate_structured(client, messages, model_cls, max_attempts=3, max_tokens=1024):
history = list(messages)
last_error = None
for attempt in range(max_attempts):
resp = client.create(messages=history, max_tokens=max_tokens)
text = resp.content
finish = resp.finish_reason
if finish == "length":
# 잘림은 재생성으로 안 풀린다. 상한을 올리거나 작업을 쪼갠다.
max_tokens = min(max_tokens * 2, 8192)
history.append({"role": "user",
"content": "출력이 잘렸습니다. 더 짧게, 설명 없이 다시 출력하십시오."})
continue
try:
return model_cls.model_validate(extract_json(text))
except (ValueError, json.JSONDecodeError, ValidationError) as e:
last_error = e
# 실패한 출력과 구체적 오류를 함께 되돌려 준다
history.append({"role": "assistant", "content": text})
history.append({"role": "user", "content":
f"위 출력이 스키마 검증에 실패했습니다.\n오류: {e}\n"
f"오류만 수정한 JSON을 다른 텍스트 없이 출력하십시오."})
raise RuntimeError(f"{max_attempts}회 시도 후 실패: {last_error}")
세 가지를 짚겠습니다.
오류 메시지는 구체적이어야 합니다. "검증 실패"가 아니라 "urgency 필드의 값 '매우 높음'은 허용된 값이 아닙니다"까지 전달해야 모델이 고칠 수 있습니다. pydantic이나 jsonschema의 오류 문자열을 그대로 넘기면 대체로 충분합니다.
실패한 출력 자체를 대화에 넣어야 합니다. 모델이 자기가 뭘 썼는지 봐야 수정이 됩니다.
잘림은 다른 실패입니다. 같은 방식으로 재시도하면 같은 지점에서 또 잘립니다. 종료 사유를 확인해 분기해야 합니다.
재시도의 비용도 계산해 두면 좋습니다.
def retry_math(p_fail, max_attempts):
expected_calls = sum(p_fail ** i for i in range(max_attempts))
residual = p_fail ** max_attempts
return expected_calls, residual
for p in (0.04, 0.15):
calls, res = retry_math(p, 3)
print(f"1회 실패율 {p:.0%} → 평균 호출 {calls:.3f}회 (비용 +{calls - 1:.1%}), 최종 실패율 {res:.4%}")
# 1회 실패율 4% → 평균 호출 1.042회 (비용 +4.2%), 최종 실패율 0.0064%
# 1회 실패율 15% → 평균 호출 1.172회 (비용 +17.2%), 최종 실패율 0.3375%
여기서 독립 시행을 가정했다는 점은 짚고 넘어가야 합니다. 실제 실패는 상관되어 있습니다. 특정 입력이 어려워서 실패한 것이라면 두 번째 시도도 같은 이유로 실패할 확률이 높습니다. 그래서 위 최종 실패율은 낙관적인 추정치이고, 실제 값은 로그에서 재야 합니다. 재시도 횟수를 3회 이상으로 늘리는 것이 거의 도움이 안 되는 이유도 이 상관성 때문입니다.
출력 상한과 잘림 대비
잘림은 조용한 실패입니다. 파싱 오류로 드러나면 그나마 다행이고, 배열의 마지막 항목만 사라진 채로 유효한 JSON이 되는 경우가 최악입니다. 검증도 통과하고 알림도 안 울리는데 데이터가 빠져 있습니다.
def estimate_output_tokens(schema_fields, avg_value_tokens, list_len=1):
"""구조 오버헤드 + 값. 실제 값은 토크나이저로 재는 것이 정확하다."""
per_item = sum(len(name) // 3 + 4 + avg_value_tokens for name in schema_fields)
return int(per_item * list_len * 1.25) # 25% 여유
fields = ["reasoning", "category", "urgency", "order_id", "due_date_iso"]
print(estimate_output_tokens(fields, avg_value_tokens=12, list_len=1)) # 116
print(estimate_output_tokens(fields, avg_value_tokens=12, list_len=20)) # 2325
목록을 뽑는 작업에서 항목 수가 예측 불가능하면 상한 산정이 근본적으로 불가능합니다. 이럴 때 쓸 수 있는 방법이 셋 있습니다.
스키마에 항목 수 상한을 명시하고 초과분은 다음 호출로 넘기는 페이지네이션을 만듭니다. 아니면 JSON 배열 대신 한 줄에 객체 하나씩 출력하는 방식으로 바꿔, 잘려도 마지막 줄만 버리면 되게 합니다. 마지막으로 배열 길이를 세는 필드를 앞쪽에 두고 실제 배열 길이와 비교해 잘림을 사후 검증합니다.
어느 쪽이든 종료 사유 확인은 필수입니다. 이 값을 확인하지 않는 파이프라인은 잘림을 절대 감지하지 못합니다.
마치며 — 형식은 강제하고 의미는 검증하십시오
정리하면 한 문장입니다. 형식은 생성 단계에서 강제하고, 의미는 생성 이후에 검증하십시오.
형식 강제는 도구 스키마와 제약 디코딩으로 원리적으로 해결됩니다. 여기에 시간을 오래 쓸 이유가 없습니다. 반면 값이 맞는지, 없는 값을 지어내지 않았는지, 열거값이 실제 상황과 대응하는지는 스키마가 절대 잡아 주지 않습니다. 제약 디코딩을 도입한 뒤 파싱 실패율이 0이 되는 순간이 위험한 이유가 여기 있습니다. 대시보드의 빨간불이 꺼졌을 뿐, 틀린 값은 그대로 흘러갑니다.
그러니 파싱 성공률 옆에 값 정확도 지표를 같이 놓으십시오. 그리고 스키마를 손볼 때는 필드 이름과 순서부터 보십시오. 프롬프트를 열 번 고치는 것보다 필드 하나를 앞으로 옮기는 편이 나은 경우가 자주 있습니다.
현재 단락 (1/112)
LLM 출력을 파이프라인의 중간 단계로 쓰기 시작하면, 자연어 품질보다 파싱 성공률이 먼저 문제가 됩니다. 요약이 조금 밋밋한 것은 견딜 수 있지만, 하루 10만 건 중 1퍼센트가...