LLM을 실제 파이프라인에 연결하는 순간 가장 먼저 부딪히는 벽은 “모델의 답이 문장”이라는 사실입니다. 다운스트림 코드가 필요한 건 if resp["risk"] == "high"처럼 바로 분기할 수 있는 필드입니다. 여기서 정규식으로 답을 긁어내기 시작하면, 그 파서는 곧 조직에서 가장 깨지기 쉬운 코드가 됩니다.
이 글은 LLM에게 구조화 출력(structured output)을 강제하는 세 층위를 실무 관점에서 정리합니다. 가장 약한 프롬프트 지시, 그보다 강한 도구/함수 호출(tool use), 문법 레벨에서 형식을 보장하는 제약 디코딩(constrained decoding)입니다. 예시는 Anthropic Claude API의 도구 호출을 기준으로 하지만, JSON 스키마로 출력을 고정한다는 개념은 다른 프로바이더에도 적용됩니다. 핵심은 하나입니다. “JSON으로 답해줘”라고 부탁하지 말고, 유효한 JSON 외에는 생성할 수 없는 경로를 설계하라는 것입니다.
왜 프롬프트만으로는 부족한가
가장 흔한 접근은 시스템 프롬프트에 “반드시 JSON으로만 답하라”고 적는 것입니다. 데모에서는 잘 돌아가지만 실패가 드물지만 확정적으로 발생합니다. 모델은 종종 코드펜스로 감싸거나, JSON 앞뒤에 설명을 덧붙이거나, 문자열 내부에 이스케이프되지 않은 따옴표를 흘립니다.
1%의 실패율도 하루 수만 건 배치에서는 수백 건의 파싱 예외가 되고, 이런 실패는 입력이 길거나 예외적인 가장 중요한 요청에서 몰립니다. 프롬프트 지시는 “최선을 다해달라는 요청”일 뿐 형식을 보장하지 못합니다. 대표적 실패 유형은 다음과 같습니다.
- 코드펜스 오염:
```json ... ```마크다운으로 감싸 파서가 실패. - 서두·후기 텍스트: JSON 앞뒤에 설명을 덧붙임.
- 트레일링 콤마·홑따옴표: 표준 JSON이 아닌 JS 스타일 리터럴.
- 스키마 드리프트: 필드명이 미묘하게 달라지거나 필수 필드가 누락.
1층: 프롬프트로 최선을 끌어내되, 파싱은 방어적으로
도구 호출을 쓸 수 없는 환경(예: 게이트웨이가 tools를 막아둔 경우)에서는 프롬프트가 유일한 수단입니다. 이때는 어시스턴트 프리필이 가장 효과적입니다. 어시스턴트 턴을 {로 미리 열어두면 서두 인사말을 붙일 자리가 사라집니다.
import anthropic, json
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=512,
system="너는 지원서를 분류한다. 오직 유효한 JSON 객체 하나만 출력한다.",
messages=[
{"role": "user", "content": application_text},
{"role": "assistant", "content": "{"}, # 프리필 → 서두 텍스트 차단
],
)
# 프리필한 "{" 는 응답에 포함되지 않으므로 다시 붙여준다
raw = "{" + resp.content[0].text
data = json.loads(raw) # 그래도 방어적 파싱은 유지
그럼에도 파서는 응답을 신뢰하지 않아야 합니다. json.loads가 실패하면 코드펜스를 제거하고 첫 {부터 마지막 }까지 잘라내는 폴백을 두되, 이 폴백의 발동률을 반드시 계측합니다. 발동률이 오르면 2층으로 가야 한다는 신호입니다.
2층: 도구 호출로 스키마를 계약으로 만든다
실무의 기본값은 여기여야 합니다. 도구 호출은 원래 “모델이 외부 함수를 부르게 하는” 기능이지만, 단일 추출용 도구를 정의해 그 인자로 답을 받는 패턴으로 전용하면 강력한 구조화 출력 장치가 됩니다. 도구의 input_schema가 곧 출력 계약이 되어, 모델은 그 스키마에 맞는 인자를 채웁니다.
extract_tool = {
"name": "record_application",
"description": "지원서 심사 결과를 기록한다.",
"input_schema": {
"type": "object",
"properties": {
"decision": {"type": "string", "enum": ["pass", "hold", "reject"]},
"risk_score": {"type": "integer", "minimum": 0, "maximum": 100},
"reasons": {"type": "array", "items": {"type": "string"},
"minItems": 1, "maxItems": 3},
"needs_human_review": {"type": "boolean"},
},
"required": ["decision", "risk_score", "needs_human_review"],
"additionalProperties": False, # 스키마 밖 필드 금지
},
}
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=512,
tools=[extract_tool],
# 이 도구를 반드시 쓰도록 강제 → 자유 텍스트 경로를 닫는다
tool_choice={"type": "tool", "name": "record_application"},
messages=[{"role": "user", "content": application_text}],
)
결정적인 부분은 tool_choice를 특정 도구로 강제하는 것입니다. 기본값(auto)은 자유 텍스트로 답할 여지가 남지만, 도구를 지정하면 응답이 반드시 tool_use 블록으로 돌아옵니다.
# tool_use 블록의 input 은 이미 파싱된 dict — 별도 json.loads 불필요
block = next(b for b in resp.content if b.type == "tool_use")
result = block.input # {"decision": "hold", "risk_score": 42, ...}
도구 호출의 진짜 이점은 프로바이더가 인자를 스키마에 맞춰 직렬화해 준다는 데 있습니다. 코드펜스나 서두 텍스트가 끼어들 수 없습니다. 다만 enum·minimum 같은 제약의 준수까지 늘 보장되는 것은 아니므로 애플리케이션 검증은 계속 필요합니다(4층).
스키마를 설계하는 법: 모델이 틀리기 어렵게
같은 정보라도 스키마 설계에 따라 정확도가 크게 달라집니다. 원칙은 “자유도를 최소화하고 의미를 이름에 담는다”는 것입니다.
- 자유 문자열보다
enum: 값이 유한하면enum으로 못박아 오탈자와 표현 편차를 없앱니다. - 불리언 플래그를 명시적으로: “판단 애매하면?”을
needs_human_review필드로 흡수해 억지 분류를 막습니다. - description은 필드 안에: 각
property의description으로 프롬프트를 덜 오염시키며 의미를 전달합니다. - 중첩은 얕게: 3단 이상 깊은 객체는 누락·오배치가 늘어나므로 평탄한 편이 안정적입니다.
필드 정렬도 정확도에 영향을 줍니다. LLM은 좌에서 우로 토큰을 생성하므로, 아래처럼 결론(sentiment)을 근거(evidence) 뒤에 배치하면 방금 적은 근거를 조건으로 결론을 내는 값싼 사고 사슬 효과를 얻습니다.
{
"properties": {
"evidence": { "type": "array", "items": { "type": "string" },
"description": "본문에서 인용한 근거 조각" },
"sentiment": { "type": "string", "enum": ["negative","neutral","positive"],
"description": "evidence 를 종합한 최종 판정" }
},
"required": ["evidence", "sentiment"],
"additionalProperties": false
}
3층: 제약 디코딩으로 문법을 강제한다
가장 강한 보장은 디코딩 단계에서 문법에 어긋나는 토큰을 아예 못 뽑게 막는 것입니다. 오픈 웨이트 모델을 직접 서빙한다면 각 스텝의 로짓에 마스크를 씌워 다음 토큰을 문법이 허용하는 것으로만 제한할 수 있고, 잘못된 JSON은 확률적으로 불가능해집니다.
# vLLM 계열: pydantic 스키마를 넘겨 문법 제약 디코딩
from pydantic import BaseModel
from enum import Enum
class Decision(str, Enum):
passed = "pass"; hold = "hold"; reject = "reject"
class Review(BaseModel):
decision: Decision
risk_score: int # 0..100 은 별도 검증
needs_human_review: bool
# 서버가 스키마에 안 맞는 토큰을 매 스텝 마스킹 → 출력은 항상 유효 JSON
params = {"guided_json": Review.model_json_schema(), "temperature": 0}
제약 디코딩은 형식은 확실히 보장하지만 부작용도 있습니다. 문법이 특정 토큰을 강제로 밀어내면 모델의 분포가 왜곡되어 자유 서술 필드에서 내용 품질이 떨어질 수 있고, 마스킹 오버헤드로 지연이 늘 수 있습니다. 그래서 “형식 실패를 용납할 수 없는 필드(enum·불리언·숫자)에만 강한 제약을 걸고, 자유 텍스트는 느슨하게” 두는 절충이 흔합니다.
4층: 그래도 항상 검증한다 — 스키마 준수 ≠ 의미 정합
어떤 층위를 쓰든 애플리케이션은 응답을 다시 검증해야 합니다. 형식이 유효한 것과 값이 올바른 것은 다른 문제입니다. risk_score가 정수여도 도메인상 불가능한 값일 수 있으니, 프로바이더가 enum·minimum을 항상 강제한다고 가정하지 마십시오.
from pydantic import BaseModel, field_validator
class Review(BaseModel):
decision: Decision # 앞서 정의한 Enum 재사용
risk_score: int
reasons: list[str]
needs_human_review: bool
@field_validator("risk_score")
@classmethod
def in_range(cls, v: int) -> int:
if not 0 <= v <= 100: # 스키마가 놓쳐도 여기서 잡는다
raise ValueError("risk_score out of range")
return v
review = Review.model_validate(block.input) # 실패하면 재시도 트리거
검증 실패는 예외로 끝내지 말고 피드백 재시도 루프로 연결합니다. 검증기 에러 메시지를 다음 요청에 되먹여 모델이 스스로 고치게 합니다. 다만 재시도는 상한을 두고, 계속 실패하면 사람 검토 큐로 보내 무한 루프와 비용 폭주를 막습니다.
def extract_with_retry(text, max_retries=2):
messages = [{"role": "user", "content": text}]
for attempt in range(max_retries + 1):
block = next(b for b in call_with_tool(messages).content
if b.type == "tool_use")
try:
return Review.model_validate(block.input)
except Exception as e:
if attempt == max_retries:
route_to_human(text, block.input) # 상한 도달 → 사람에게
raise
# 실패 이유를 되먹여 자기 교정 유도
messages += [
{"role": "assistant", "content": [block.model_dump()]},
{"role": "user",
"content": f"검증 실패: {e}. 스키마를 지켜 다시 기록하라."},
]
비용·지연 관점의 트레이드오프
구조화 출력은 공짜가 아닙니다. 도구 스키마는 매 요청에 입력 토큰으로 포함되므로 크고 장황하면 비용이 붙습니다. 고정 스키마라면 프리픽스 캐시로 재사용해 비용을 낮출 수 있지만, 요청마다 동적으로 바꾸면 캐시가 깨집니다.
- 스키마 안정성 = 캐시 친화성: 고정 스키마는 프리픽스 캐시로 재사용, 동적은 캐시 무효화.
- 재시도 = 배수 비용: 재시도 1회는 실패분의 요청을 2배로 만드니, 재시도율을 낮추도록 스키마를 개선하는 편이 쌉니다.
- 제약 디코딩 = 지연 증가: 형식 보장이 필요한 경로에만 적용.
정리하면 재시도로 비용을 태우기 전에 스키마 설계로 실패 자체를 줄이는 것이 우선입니다. 재시도율·폴백 발동률·검증 실패율을 대시보드에 올려두면 어느 층위로 올라가야 할지가 데이터로 드러납니다.
마무리: 부탁이 아니라 경로를 설계하라
LLM 구조화 출력의 핵심 원칙은 하나로 수렴합니다. 올바른 형식을 부탁하지 말고, 올바른 형식만 나올 수 있는 경로를 만들라는 것입니다. 프롬프트 지시(1층)는 최후의 수단이고, 실무의 기본값은 도구 호출을 tool_choice로 강제하는 2층이며, 형식 실패를 용납할 수 없는 필드엔 제약 디코딩(3층)을 얹습니다.
어떤 층위를 쓰든 스키마 준수와 의미 정합은 다른 문제임을 잊지 마십시오. 도메인 검증(4층)과 상한 있는 자기 교정 재시도를 항상 함께 둡니다. 스키마는 작고 평탄하게, 자유도는 enum과 불리언으로 좁게, 근거는 결론보다 앞에. 이 원칙만 지켜도 취약한 파서는 사라집니다.