LLM·트랜스포머

LLM / 중급

문법으로 출력을 제약하기

JSON Schema로는 표현되지 않는 출력 형태를 문맥자유문법으로 강제하는 방법, GBNF 문법을 쓸 때 자주 걸리는 좌재귀·공백·모호성 문제, 그리고 카탈로그에서 문법을 생성하는 실무 형태를 정리합니다.

PALDYN Team11 MIN READ

지난 글에서 제약이 오토마톤 상태로 토큰을 거른다는 것을 봤다. 그 오토마톤을 JSON Schema에서 만들었을 뿐, 원리 자체는 JSON과 아무 상관이 없다. 상태 기계를 만들 수 있는 규칙이면 무엇이든 제약이 될 수 있다. 그래서 출력이 JSON이 아니어야 하는 순간 — SQL 한 줄, 사내 명령어, 수식, 특정 마크업 — 문법을 직접 적는 쪽으로 간다.

언제 스키마로 부족한가

세 가지 신호가 있다. 첫째, 출력이 JSON이 아니다. SQL이나 정규식이나 설정 파일 문법을 뽑아야 하는 경우다. 둘째, JSON이긴 한데 값 안의 문자열에 다시 문법이 있다. {"query": "SELECT ..."}처럼 필드 값이 또 다른 언어인 경우, 스키마는 그 값이 문자열이라는 것까지만 보장한다. 셋째, 깊이가 정해지지 않은 재귀 구조가 필요하다.

제약을 거는 언어의 표현력

거꾸로, 표현력이 큰 쪽을 습관적으로 고르는 것도 손해다. 안에서 되는 일을 바깥 도구로 하면 컴파일 비용과 상태 수만 늘어난다. 값이 정해진 라벨 몇 개면 enum으로 끝내고, 객체 트리면 JSON Schema로 끝내고, 그것으로 안 되는 것만 문법으로 간다.

표현할 수 있는 것 컴파일 비용 쓰는 곳
enum · const 유한한 문자열 집합 거의 없음 분류 라벨, 상태값
정규식 고정 형식 문자열 낮음 날짜, 코드, 식별자
JSON Schema 모양이 정해진 트리 중간 API 응답, 추출 결과
문맥자유문법 재귀 구조, 임의 DSL 높음 SQL, 수식, 사내 명령어

문법은 이렇게 생겼다

로컬 추론 쪽에서 널리 쓰이는 표기가 GBNF다. 이름은 BNF에서 왔고, 실제로는 EBNF에 가까운 반복·선택 기호를 함께 쓴다.

root   ::= "SELECT " cols " FROM " name cond?
cols   ::= "*" | name (", " name)*
cond   ::= " WHERE " name op num
op     ::= "=" | ">" | "<"
name   ::= "orders" | "users" | "amount" | "id"
num    ::= [0-9]+

읽는 방법은 단순하다. ::= 왼쪽은 규칙 이름, 오른쪽은 그 규칙이 펼쳐지는 방법이다. |는 선택, ?는 있어도 되고 없어도 됨, *는 0회 이상 반복, [0-9]는 문자 범위다. 생성 중에 규칙이 펼쳐지면 남은 항목들이 스택에 쌓이고, 스택 맨 위가 다음에 무엇이 와야 하는지를 정한다.

규칙이 남은 일의 목록을 만든다

여기서 눈여겨볼 것은 name 규칙이다. 테이블과 컬럼 이름을 문법 안에 나열해 두면, 존재하지 않는 이름은 생성될 수 없다. SQL 생성에서 가장 흔한 실패가 없는 컬럼을 지어내는 것인데, 그 부류가 통째로 사라진다.

문법을 손으로 안 쓰는 것이 요령이다

위 문법에서 이름 목록을 사람이 유지하면 스키마가 바뀔 때마다 어긋난다. 실무에서는 카탈로그에서 문법을 만들어 낸다.

def build_sql_grammar(tables: dict[str, list[str]]) -> str:
    """{"orders": ["id", "amount"], "users": ["id", "email"]} 형태를 받는다."""
    table_alt = " | ".join(f'"{t}"' for t in tables)
    column_alt = " | ".join(
        f'"{c}"' for c in sorted({c for cols in tables.values() for c in cols})
    )
    return f"""
root   ::= "SELECT " cols " FROM " table cond?
cols   ::= "*" | column (", " column)*
cond   ::= " WHERE " column op num
op     ::= "=" | ">" | "<"
table  ::= {table_alt}
column ::= {column_alt}
num    ::= [0-9]+
"""

이렇게 만들면 스키마 마이그레이션이 문법에 자동으로 반영된다. 그리고 지난 글에서 본 캐시 문제를 여기서도 조심한다 — 이 함수를 요청마다 부르면 매번 새로 컴파일된다. 카탈로그 버전을 키로 캐시해 두고 스키마가 바뀔 때만 다시 만든다.

한계도 이 예시에 그대로 드러난다. column을 테이블과 무관하게 나열했으므로 SELECT email FROM orders가 문법상 유효하다. 어느 컬럼이 어느 테이블에 속하는지는 문맥 의존 규칙이라 문맥자유문법으로 못 적는다. 테이블별로 문법을 나눠 만들면 한 단계 좁힐 수 있지만, 조인이 들어오면 다시 안 된다. 이 층은 결국 받은 뒤에 카탈로그로 검증한다.

자주 걸리는 세 가지

좌재귀. 규칙이 자기 이름으로 시작하면 대부분의 구현이 무한 루프에 빠지거나 컴파일을 거부한다.

# 안 된다 — expr이 expr로 시작한다
expr ::= expr " + " term | term

# 반복으로 바꾼다
expr ::= term (" + " term)*

연산자 우선순위가 필요하면 층을 나눈다. expr ::= term (("+"|"-") term)*, term ::= factor (("*"|"/") factor)*, factor ::= num | "(" expr ")" 식이다. 이 형태는 좌재귀가 아니면서 우선순위를 표현한다.

공백. 문법은 공백을 자동으로 넘겨 주지 않는다. 규칙에 " "를 안 적으면 모델은 공백을 넣을 수 없고, SELECTamount가 나온다. 반대로 ws ::= " "*처럼 아무 데나 임의 공백을 허용하면 모델이 공백만 계속 뽑는 경로가 생겨서 출력이 늘어질 수 있다. 필요한 자리에 정확히 하나를 박아 두는 편이 안전하다.

모호성. 같은 문자열이 두 가지로 파싱되는 문법은 컴파일러가 상태를 훨씬 많이 만들거나 아예 실패한다. 선택지의 접두사가 겹치지 않게 쓰는 것이 요령이다. "SELECT" | "SELECTED"처럼 겹치는 대안은 파서를 어렵게 만든다.

문법과 JSON을 같이 쓰기

실무에서 가장 자주 필요한 형태는 "JSON 안에 다른 문법의 문자열을 넣는 것"이다. 두 가지 방법이 있다.

첫째는 호출을 나누는 것이다. 먼저 스키마로 메타데이터를 받고, 그다음 문법으로 SQL만 받는다. 각 호출의 제약이 단순해지고 실패했을 때 어디가 틀렸는지 바로 보인다. 대신 왕복이 두 번이다.

둘째는 문법 안에 JSON 구조를 직접 적는 것이다. 문맥자유문법은 JSON을 포함하므로 이게 된다.

root  ::= "{\"reason\": \"" text "\", \"sql\": \"" query "\"}"
text  ::= [^"\\]{1,200}
query ::= "SELECT " cols " FROM " table
cols  ::= "*" | column (", " column)*

한 번의 호출로 끝나지만 문법이 금세 지저분해지고, JSON 이스케이프를 문법 안에서 다뤄야 해서 실수하기 쉽다. 필드가 두세 개를 넘어가면 호출을 나누는 쪽이 낫다.

문법이 품질에 미치는 영향

제약이 셀수록 모델이 갈 수 있는 길이 좁아지고, 좁아진 만큼 원래 가려던 경로가 막힐 확률이 올라간다. SQL 문법을 예로 들면, 모델이 LEFT JOIN을 쓰려 했는데 문법에 조인이 없으면 어떻게든 단일 테이블 쿼리를 만든다. 그 쿼리는 문법적으로 완벽하고 실행도 되지만 질문에 대한 답은 아니다.

그래서 문법을 좁힐 때는 못 하는 요청을 거절할 길을 같이 열어 둔다.

root   ::= query | refusal
refusal ::= "UNSUPPORTED: " [^\n]{1,120}
query  ::= "SELECT " cols " FROM " table cond?

refusal 분기가 있으면 모델이 억지 쿼리를 만드는 대신 그쪽으로 갈 수 있다. 그리고 그 문자열이 로그에 쌓이면 문법에 무엇을 더해야 하는지가 데이터로 남는다. 문법을 넓히는 작업을 추측이 아니라 기록으로 하게 되는 셈이다.

정리

문법 기반 제약은 JSON Schema의 확장이 아니라 그 아래 놓인 더 일반적인 도구다. 판단은 두 단계로 한다. 먼저 지금 필요한 형태가 enum이나 정규식으로 표현되는지 보고, 안 되면 JSON Schema를 보고, 그것도 안 되면 문법을 쓴다. 그리고 문법으로도 못 적는 것 — 앞에서 선언한 이름만 쓴다든가, 컬럼이 그 테이블에 실제로 있다든가 하는 문맥 의존 규칙 — 은 애초에 생성 제약의 몫이 아니다.

그 몫을 다루는 것이 다음 글이다. 형태가 맞는 출력을 받은 다음에 무엇을 더 확인해야 하는지를 본다.


지난 글: 제약 디코딩: 스키마가 토큰을 막는 방식

다음 글: 형태는 맞는데 값이 틀릴 때


읽어주셔서 감사합니다. 😊

KEEP READING

이어 읽기

LLM·트랜스포머2026.08.06

구조화 출력: 모델의 답을 프로그램이 쓸 수 있는 형태로

프롬프트로 JSON을 부탁하는 것, JSON 모드, 스키마 강제 세 가지가 각각 무엇을 보장하고 무엇을 보장하지 않는지, 그리고 어느 단계에서도 사라지지 않는 실패가 무엇인지 정리합니다.

중급13 MIN
LLM·트랜스포머2026.08.06

제약 디코딩: 스키마가 토큰을 막는 방식

로짓 마스킹과 오토마톤 상태 전이로 스키마를 강제하는 원리, 토크나이저 경계 때문에 생기는 어려움, 컴파일·캐시 비용, 그리고 제약이 출력 품질을 오히려 떨어뜨리는 경우를 정리합니다.

중급12 MIN
LLM·트랜스포머2026.08.06

형태는 맞는데 값이 틀릴 때

스키마를 통과한 출력에서 남는 의미 오류를 잡는 네 층의 검증, 오류를 되먹여 재시도하는 방법과 그 상한, 근거 인용을 스키마에 심어 자동 대조를 가능하게 하는 설계를 정리합니다.

중급11 MIN
PALDYN / AI LAB

AI를 이해하고 배우는 데 필요한 개념, 수학, 논문과 실험을 연결해 기록합니다.

OfficialTech Blog© 2026 PALDYN