왜 출력 형식이 자꾸 깨지는가
LLM에게 "JSON으로만 답해"라고 프롬프트로 지시해도, 실전에서는 앞뒤로 설명 문장이 붙거나 트레일링 콤마, 닫히지 않은 괄호, 마크다운 코드펜스가 섞여 들어온다. 파서는 예외를 던지고, 재시도 로직은 지연과 비용을 키운다. 근본 원인은 디코딩이 확률적이기 때문이다. 모델은 매 토큰마다 어휘 전체에 확률을 분포시키고, 온도나 샘플링에 따라 형식을 깨는 토큰이 언제든 선택될 수 있다. 프롬프트는 이 분포에 편향을 줄 뿐, 형식을 보장하지 못한다.
문법 제약 디코딩의 원리
GBNF(GGML BNF)는 llama.cpp 계열에서 쓰는 문법 정의 형식이다. 핵심은 디코딩 단계에서 문법이 허용하지 않는 토큰의 확률(logit)을 강제로 0으로 마스킹하는 것이다. 즉, 현재 파싱 상태에서 문법적으로 유효한 다음 토큰만 후보로 남긴다. 모델이 아무리 엉뚱한 토큰을 높게 평가해도 문법 밖 토큰은 선택될 수 없다. 결과적으로 출력은 반드시 문법을 만족한다. 프롬프트 기반 "부탁"이 아니라 디코더 레벨의 "보장"이라는 점이 결정적 차이다.
간단한 GBNF 작성
아래는 감정 분류 결과를 고정된 JSON 스키마로 강제하는 문법이다. 열거형(enum)까지 문법으로 못박아 허용 값 외에는 생성 자체가 불가능하다.
root ::= "{" ws "\"label\":" ws label "," ws "\"score\":" ws number ws "}"
label ::= "\"positive\"" | "\"negative\"" | "\"neutral\""
number ::= "0" | "1" | "0." [0-9]+
ws ::= [ \t\n]*
llama.cpp에서 적용하기
서버 모드에서는 요청 본문에 문법을 직접 실어 보낼 수 있다. 스키마를 가진 엔드포인트마다 문법 파일을 두고 로드하는 방식이 관리에 유리하다.
curl http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d '{
"prompt": "리뷰: 배송이 느렸지만 제품은 만족스럽다. 감정 분석 결과:",
"grammar": "root ::= \"{\" \"\\\"label\\\":\" label \"}\"\nlabel ::= \"\\\"positive\\\"\" | \"\\\"negative\\\"\" | \"\\\"neutral\\\"\"",
"temperature": 0.2,
"n_predict": 32
}'
JSON Schema로 자동 변환
손으로 BNF를 쓰기 번거롭다면 JSON Schema를 GBNF로 변환해 쓰는 편이 안전하다. llama-cpp-python은 이를 내장 지원한다.
from llama_cpp import Llama
from llama_cpp.llama_grammar import LlamaGrammar
import json
schema = {
"type": "object",
"properties": {
"label": {"enum": ["positive", "negative", "neutral"]},
"score": {"type": "number", "minimum": 0, "maximum": 1},
},
"required": ["label", "score"],
}
llm = Llama(model_path="model.gguf", n_ctx=2048)
grammar = LlamaGrammar.from_json_schema(json.dumps(schema))
out = llm("리뷰 감정 분석 결과:", grammar=grammar, max_tokens=64, temperature=0.2)
print(json.loads(out["choices"][0]["text"])) # 항상 파싱 성공
다른 방법과 비교
| 방법 | 형식 보장 | 재시도 비용 | 적용 범위 |
|---|---|---|---|
| 프롬프트 지시 | 없음(확률적) | 높음 | 모든 API |
| 함수 호출/툴 스키마 | 대체로 안정적 | 중간 | 지원 모델만 |
| GBNF 제약 디코딩 | 구조적 보장 | 거의 없음 | 로컬 추론 엔진 |
주의점
문법은 형식만 보장할 뿐 내용의 정확성은 보장하지 않는다. 유효한 JSON이 나와도 값이 틀릴 수 있으니 의미 검증은 별도로 둬야 한다. 또 문법이 과도하게 빡빡하면 모델이 표현하고 싶은 올바른 답을 막아 품질이 떨어질 수 있다. 예컨대 숫자 자릿수를 너무 좁히면 정답을 못 내놓는다. 열거형은 좁히되 자유 텍스트 필드는 여유를 둬라. 성능 측면에서는 매 토큰마다 후보 마스킹을 계산하므로 복잡한 재귀 문법은 디코딩 오버헤드가 생긴다. 마지막으로 토크나이저 경계와 문법 경계가 어긋나면 예기치 않게 생성이 막힐 수 있으니, 실제 모델로 충분히 검증한 뒤 배포하는 것이 안전하다.
정리
출력 형식 문제는 프롬프트 튜닝으로 완전히 없애기 어렵다. 로컬 추론 환경이라면 GBNF 제약 디코딩이 형식 오류를 구조적으로 제거하는 가장 확실한 수단이다. JSON Schema 변환을 활용해 유지보수 비용을 낮추고, 내용 검증과 문법 범위 설계를 병행하면 재시도 없는 안정적인 파이프라인을 만들 수 있다.