LLM의 구조화된 출력 (Structured output): JSON 모드, 함수 호출 (Function calling), 그리고 문법 제약
요약
LLM이 생성하는 토큰 기반 출력이 JSON 스키마를 벗어나 발생하는 프로덕션 환경의 오류 문제를 다룹니다. API 호출, 데이터 추출, 에이전트 루프 등에서 구조화된 출력이 필수적인 이유와 이를 해결하기 위한 접근 방식을 설명합니다.
핵심 포인트
- LLM은 스키마를 제약이 아닌 제안으로 인식하여 형식이 깨질 수 있음
- 잘못된 출력은 API 런타임 에러, 데이터 파이프라인 중단, 에이전트 루프 실패를 유발함
- 프로덕션 시스템에서는 토큰 수준에서 스키마를 강제하는 메커니즘이 필요함
- 함수 호출 및 ETL 작업에서 구조화된 출력의 신뢰성은 필수적임
LLM의 구조화된 출력 (Structured output): JSON 모드, 함수 호출 (Function calling), 그리고 문법 제약 디코딩 (Grammar-constrained decoding)
당신은 자연어 요청을 API 호출로 변환하는 챗봇을 배포했습니다. 한 사용자가 "내일 오후 7시에 4명 예약해줘"라고 말합니다. 당신의 프롬프트(Prompt)는 LLM에게 {"restaurant": string, "party_size": int, "time": string, "date": string}와 같은 JSON을 생성하도록 요청합니다. 한 번은 {"restaurant": "Olive Garden", "party_size": 4, "time": "19:00", "date": "2026-06-15"}를 반환하여 유효한 JSON이며 모든 것이 정상적으로 작동합니다. 하지만 다음 요청인 "토요일 정오에 딤섬"은 {"restaurant": "Dim Sum House", "party_size": 2, "time": "12:00", "date": "Saturday"}를 생성한 뒤, -- also, what's the dress code?라는 자유 형식의 부연 설명을 덧붙입니다. 이제 당신의 JSON 파서(Parser)는 오류를 발생시키고, 다운스트림 파이프라인(Downstream pipeline)은 충돌하며, 새벽 2시에 당신의 Slack 채널에는 알람이 울려댑니다.
문제는 근본적입니다: LLM은 데이터 구조가 아니라 토큰(Token)을 생성합니다. 당신이 요청하는 어떤 스키마(Schema)도 제약(Constraint)이 아닌 제안(Suggestion)일 뿐입니다. 구조화된 출력에 의존하는 프로덕션 시스템(Production systems)은 단순히 프롬프트 수준이 아니라 토큰 수준에서 스키마를 강제하는 메커니즘이 필요합니다.
이것이 프로덕션 LLM 애플리케이션에서 중요한 이유
구조화된 출력이 타협 불가능한 세 가지 시나리오:
-
API 래퍼(wrappers) 및 함수 호출 (Function calling). 사용자를 대신해 도구(tool)를 호출하는 LLM은 반드시 도구의 JSON 스키마 (JSON Schema)와 일치하는 인자(arguments)를 생성해야 합니다. 잘못된 형식의 인자는 도구에서의 런타임 에러(runtime error), 재시도(retry), 또는 무음 실패(silent failure)를 의미합니다. 대규모 운영 시에는 단 2%의 형식 오류율만으로도 지속적인 장애 알림(incident alerts)이 발생하게 됩니다.
-
데이터 추출 및 ETL 파이프라인. 10,000개의 고객 지원 티켓을 LLM에 입력하고
{customer_id, sentiment, category, urgency}를 추출하도록 요청한다고 가정해 봅시다. 만약 데이터의 3%에서 추가 필드가 나타나거나, 필드가 누락되거나, JSON이 아닌 산문(prose)이 포함된다면, 데이터 파이프라인은 해당 데이터를 조용히 누락시키거나 누군가가 나중에 깨지게 될 정규 표현식(regex) 임시방편을 작성해야만 합니다. -
다단계 에이전트 루프 (Multi-step agent loops). 검색 도구를 호출하고, 결과를 읽은 다음, 또 다른 도구를 호출하는 에이전트는 각 단계의 출력이 파싱(parseable) 가능해야 합니다. 만약 2단계에서 함수 호출 대신 자유 텍스트(free text)를 생성한다면 루프는 중단됩니다. 모든 재시도는 토큰(tokens), 지연 시간(latency), 그리고 비용을 소모합니다.
구조화된 출력을 위한 세 가지 접근 방식
오늘날 개발자들은 LLM이 구조화된 데이터를 생성하도록 강제하는 세 가지 주요 방법을 가지고 있습니다. 이 방법들은 신뢰성, 지연 시간, 그리고 모델과의 통합 깊이 측면에서 차이가 있습니다.
| 방법 | 강제 수준 (Enforcement level) | 지연 시간 오버헤드 (Latency overhead) | 모델 지원 (Model support) | 스키마 표현력 (Schema expressiveness) |
|---|---|---|---|---|
| 프롬프트 전용 JSON 모드 (Prompt-only JSON mode) | 없음 (제안) | 제로 (Zero) | 모든 모델 | 무제한 |
| ... |
프롬프트 전용 방식은 프로토타입을 처음 만들 때 사용하는 방식입니다. API 레벨의 구조화된 출력은 오늘날 대부분의 팀이 프로덕션에서 사용하는 방식입니다. 문법 제약 디코딩 (Grammar-constrained decoding)은 샘플링 루프 (sampling loop)를 직접 제어할 수 있는 로컬 및 셀프 호스팅 모델을 위한 신흥 표준입니다.
프롬프트 전용 JSON 모드 (Prompt-only JSON mode)
가장 간단한 접근 방식은 모델에게 JSON을 출력하라고 말하고 모델이 이를 준수하기를 바라는 것입니다.
당신은 데이터 추출 어시스턴트입니다.
요청된 필드를 추출하고 오직 유효한 JSON만 출력하세요.
설명, 마크다운 형식, 또는 추가 텍스트를 포함하지 마세요.
능력 있는 모델의 경우 이 방식이 약 85~95%의 확률로 작동하지만, 실패 모드(failure modes)는 매우 짜증스럽습니다. 마지막에 붙는 쉼표(trailing commas, 유효한 JSON은 아니지만 일부 파서가 허용함), JSON을 둘러싼 마크다운 코드 펜스(markdown code fences), JSON 앞뒤의 설명 텍스트, 닫는 중괄호 누락, 그리고 이스케이프 처리되지 않은 따옴표를 포함하는 문자열 값 등이 그 예입니다.
치명적인 결함은 프롬프트 전용 모드(prompt-only mode)가 토큰 생성 과정(token generation process)과 전혀 상호작용하지 않는다는 점입니다. 만약 모델이 필드 값의 중간 단계에 있고, 다음에 올 가장 확률 높은 토큰이 "fix"(자유 형식의 사과 문구의 시작)라면, 모델은 해당 토큰을 생성할 것입니다. 프롬프트는 단지 문맥(context)일 뿐이며, 확률 분포(probability distribution)를 제약하지는 않습니다.
API 레벨의 구조화된 출력 (JSON 모드 및 함수 호출 (Function calling))
OpenAI는 2024년 중반에 JSON 모드(JSON mode)를 도입했으며, 업계의 나머지 기업들도 이를 따랐습니다. API는 JSON 스키마(JSON Schema)를 포함하는 response_format 파라미터를 받습니다. 내부적으로 제공업체는 스키마와 비교했을 때 유효하지 않은 JSON을 생성할 수 있는 토큰을 재샘플링(resampling)하거나 마스킹(masking)하는 검증기(validator)를 사용합니다.
from openai import OpenAI
client = OpenAI()
...
출력은 스키마와 일치하는 유효한 JSON임이 보장되거나, 그렇지 않으면 API가 에러를 반환합니다. 'strict' 플래그를 사용하면 추가적인 속성(extra properties)이 출력되지 않도록 강제합니다.
함수 호출 (Function calling)도 유사하게 작동합니다. 도구 정의(tool definitions)를 JSON 스키마 객체로 등록하면, 모델은 구조화된 tool_calls 배열을 반환합니다. 제공업체가 토큰 수준의 강제(token-level enforcement)를 처리합니다.
tools = [{
"type": "function",
"function": {
...
모델은 다음과 같은 형태를 반환합니다:
{
"name": "book_restaurant",
"arguments": "{\"restaurant\":\"Olive Garden\",\"party_size\":4,\"time\":\"19:00\",\"date\":\"2026-06-15\"}"
...
Anthropic Claude의 도구 사용(tool use), Gemini의 함수 호출 (function calling), 그리고 Mistral의 함수 호출 (function calling) 모두 동일한 패턴을 따릅니다. 스키마는 클라이언트 측(client-side)에서 정의되고, 제공업체가 토큰 수준에서 검증하며, 출력은 항상 파싱 가능한(parseable) 상태로 유지됩니다.
문법 제약 디코딩 (Grammar-constrained decoding)
로컬 및 셀프 호스팅 (self-hosted) 모델의 경우, 강제 적용 (enforcement) 과정을 샘플링 루프 (sampling loop) 자체로 밀어 넣을 수 있습니다. 문법 제약 디코딩 (Grammar-constrained decoding)은 각 단계에서 토큰 확률 분포 (token probability distribution)를 수정하여, 문법(grammar) 또는 스키마(schema)와 비교했을 때 유효하지 않은 다음 문자를 생성할 수 있는 모든 토큰을 0으로 만듭니다 (zeroing out).
# Outlines를 사용하여 생성을 Pydantic 모델로 제한하기
from pydantic import BaseModel, constr
from outlines import models, generate
...
Outlines는 JSON 스키마 (JSON Schema) 또는 Pydantic 모델을 문맥 자유 문법 (CFG, context-free grammar)으로 변환한 다음, 이 CFG를 사용하여 각 생성 단계에서 토큰 어휘 (token vocabulary)를 가지치기 (prune) 하는 방식으로 작동합니다. 스키마의 유효한 연속 (valid continuations)을 나타내는 토큰들만 유지됩니다.
동일한 아이디어가 JSON뿐만 아니라 임의의 문법 (arbitrary grammars)에도 적용됩니다:
# llama.cpp를 사용한 문법 제약 생성
# GBNF (Grammar-Based Negative-dFidence) 형식
grammar = """
...
Ollama는 GBNF 문법을 기본적으로 지원합니다. vLLM은 --guided-decoding-backend 플래그를 제공합니다 (옵션: outlines, lm-format-enforcer, xgrammar). 핵심적인 통찰은 문법 제약 디코딩이 구조화된 출력을 사후 처리 (post-processing) 단계가 아닌, 샘플링 시간 (sampling-time)의 속성으로 만든다는 점입니다.
생성 중에 토큰 마스크 (token mask)가 작동하는 방식은 다음과 같습니다:
A[생성 시작] --> B[모델 순전파 (forward pass)로부터<br/>다음 토큰 로짓 (logits) 가져오기]
B --> C[문법 마스크 적용:<br/>유효하지 않은 구조를 생성할<br/>토큰들을 0으로 만들기]
...
모든 토큰은 샘플링되기 전에 스키마와 대조하여 확인됩니다. 만약 스키마가 73번 위치에서 숫자를 기대하는데 모델이 쉼표 (comma)를 제안한다면, 해당 토큰은 마스킹 (masked out) 처리되며 대신 그다음으로 적합한 유효한 토큰이 샘플링됩니다.
비교: 어떤 방법을 사용해야 할까요?
| 기준 | 프롬프트 전용 (Prompt-only) | API JSON/함수 호출 (function call) | 문법 제약 (Grammar-constrained) |
|---|---|---|---|
| 신뢰도 (Reliability) | 85-95% | ~99.9% | >99.9% |
| ... |
일반적인 실수 (Common pitfalls)
Strict mode (엄격 모드)를 사용하는 중첩된 스키마 (Nested schemas). OpenAI의 strict JSON 모드는 추가 속성 (extra properties)을 거부합니다. 만약 스키마에 additionalProperties: true가 설정되어 있거나, 모델이 때때로 null로 채우는 선택적 필드 (optional fields)에 의존하고 있다면, strict mode는 에러를 반환할 것입니다. 먼저 strict: false로 테스트한 다음, 점진적으로 제한을 강화하세요.
문법 컴파일 시간 (Grammar compilation time). Outlines와 Guidance는 생성이 시작되기 전에 스키마를 상태 머신 (state machine)으로 컴파일합니다. allOf / oneOf가 깊게 중첩된 복잡한 스키마의 경우, 이 과정이 2~10초 정도 걸릴 수 있습니다. 스키마를 재사용한다면 컴파일된 문법 (grammar)을 캐싱하세요.
토큰 마스킹 (Token masking) vs 재샘플링 (Resampling). 일부 구현체 (초기 Guidance)는 재샘플링 (resampling) 방식을 사용했습니다. 즉, 출력이 유효하지 않으면 다시 생성하는 방식입니다. 이는 느리고 예측 불가능합니다. 처음부터 유효하지 않은 토큰을 생성하지 않는 토큰 마스킹 (token-masking) 방식 (Outlines, xgrammar, llama.cpp GBNF)을 선호하세요.
문법 백엔드와의 모델 호환성 문제 (Model incompatibility with grammar backends). 모든 Hugging Face 모델 아키텍처가 Outlines의 transformers 백엔드에서 작동하는 것은 아닙니다. 지원되지 않는 모델 유형에 대한 에러가 발생하면, llamacpp 백엔드로 전환하거나 vLLM의 가이드 디코딩 (guided decoding)을 대신 사용하세요.
사용하지 말아야 할 때 (When NOT to use it)
구조화된 출력 (Structured output)은 다음과 같은 상황에서는 적절한 도구가 아닙니다:
-
개방형 창의적 텍스트가 필요한 경우. 이야기 쓰기나 브레인스토밍 세션은 문법 제약 (Grammar-constrained)을 받아서는 안 됩니다. 자유로운 텍스트가 목표인 작업에서 제약 조건은 모델의 출력 품질과 다양성을 저하시킵니다.
-
스키마 (Schema)가 빈번하게 변경되는 경우. 문법 컴파일 (Grammar compilation) 및 테스트는 오버헤드를 추가합니다. 하루에도 여러 번 스키마를 반복해서 수정하고 있다면, 먼저 프롬프트 전용 (Prompt-only) JSON 방식을 사용하고, 스키마가 안정화된 후에 강제 적용 (Enforcement) 기능을 추가하세요.
-
사용 중인 모델의 API가 이를 지원하지 않는 경우. 모든 제공업체가 JSON 모드나 함수 호출 (Function calling)을 제공하는 것은 아닙니다. 이를 지원하지 않는 경우, 프롬프트 전용 방식을 사용하거나 로컬 검증 및 재시도 루프 (Validation + retry loop)를 실행해야 하며, 이는 지연 시간 (Latency)과 비용을 증가시킵니다.
-
가끔 발생하는 파싱 실패 (Parse failures)를 허용할 수 있는 유스케이스인 경우. 모든 출력을 사람이 검토하거나 다운스트림 시스템 (Downstream system)에 강력한 에러 핸들링 (Error handling) 기능이 있다면, 문법 제약 디코딩 (Grammar-constrained decoding)의 복잡성을 감수할 가치가 없을 수도 있습니다.
-
지연 시간 (Latency)이 절대적인 최우선 순위인 경우. 문법 마스킹 (Grammar masking)은 토큰당 약간의 오버헤드를 추가합니다. 높은 처리량 (Throughput)에서 100ms 미만의 응답 요구 사항이 있다면, 관대한 파서 (Lenient parser)를 사용하는 프롬프트 전용 방식이 실용적인 선택일 수 있습니다. 최적화하기 전에 먼저 측정하세요.
요약 (TL;DR)
- **프롬프트 전용 JSON (Prompt-only JSON)**은 약 85~95%의 확률로 작동하며 프로토타이핑에는 괜찮지만, 대규모 운영 환경 (Production at scale)에서는 실패할 수 있습니다.
- API 레벨의 JSON 모드 / 함수 호출 (Function calling) (OpenAI, Anthropic, Gemini, Mistral)은 무시할 만한 수준의 지연 시간 오버헤드로 토큰 레벨의 강제 적용을 제공합니다. 운영 환경이나 제공업체가 이를 지원하는 경우 이 방식을 사용하세요.
- 문법 제약 디코딩 (Grammar-constrained decoding) (Outlines, Guidance, llama.cpp GBNF, vLLM guided decoding)은 샘플링 (Sampling) 단계에서 스키마를 강제합니다. 자체 호스팅 모델 (Self-hosted models) 및 민감한 데이터 시나리오에 가장 적합합니다.
- 토큰 마스킹 (Token masking)이 재샘플링 (Resampling)보다 낫습니다. 실패 시 다시 생성하기보다 유효하지 않은 토큰을 마스킹하는 프레임워크를 선호하세요.
- 오버헤드를 측정하세요. 문법 컴파일 및 토큰당 마스킹은 지연 시간을 추가합니다. 확정하기 전에 사용자의 스키마와 모델로 테스트하십시오.
다음 포스트
10,000개의 실제 사용자 요청으로 구성된 테스트 코퍼스 (test corpus)를 대상으로 문법 제약 디코딩 (grammar-constrained decoding)의 출력을 평가한 파이프라인 — 우리가 신뢰성을 어떻게 측정했는지, 무엇이 문제를 일으켰는지, 그리고 실제 운영 환경에서의 지연 시간 예산 (latency budget)은 어떠했는지에 대해 다룹니다.
만약 구조화된 출력 (structured output)이 잘못되었거나(또는 잘 작동했던) 운영 사례가 있다면, 다음 포스트에서 독자들의 경험을 모아 정리할 예정입니다 — 여러분의 경험담을 댓글로 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기