GBNF 문법을 사용하여 소형 로컬 모델이 JSON 환각(hallucinating)하는 것을 방지하는 방법
요약
소형 로컬 LLM이 JSON 형식을 준수하지 못해 발생하는 에이전트 도구 호출 오류를 GBNF 문법을 통해 해결하는 방법을 다룹니다. 프롬프트 엔지니어링 대신 토큰 수준에서 생성 가능한 토큰을 제한하여 구조화된 출력을 강제하는 기술적 접근법을 제시합니다.
핵심 포인트
- 소형 모델은 JSON 닫기 누락, 잘못된 키 사용 등 형식 오류가 빈번함
- GBNF 문법은 샘플링 단계에서 유효하지 않은 토큰을 마스킹하여 오류를 원천 차단함
- 프롬프트 지시보다 강력한 토큰 수준의 하드 제약(Hard Constraint) 방식임
- llama.cpp 환경에서 로컬 에이전트의 신뢰성을 높이는 핵심 기술임
Or: 8B 모델이 에이전트의 도구 프로토콜을 계속 깨뜨리는 이유와 토큰 수준에서 할 수 있는 조치.
Originally published on eris-system.dev/blog/gbnf-grammars
저는 Rust로 작성된 로컬 우선(local-first) AI 에이전트인 Eris를 구축해 왔습니다. 이 에이전트는 사용자의 기기에 위치하며, Obsidian과 호환되는 Markdown 볼트를 읽고 쓰며, 구조화된 JSON 프로토콜을 통해 도구(메모리, 알림, 웹 검색, 이메일, 캘린더)를 호출합니다. LLM 백엔드는 llama.cpp이며, 모델은 8-26B GGUF이고, 명시적으로 동의하지 않는 한 아무것도 기기를 벗어나지 않습니다.
프로젝트 전체에서 가장 어려웠던 엔지니어링 문제는 오케스트레이터(orchestrator), TUI, 또는 시맨틱 메모리 레이어가 아니었습니다. 그것은 바로 이것이었습니다:
소형 로컬 모델로부터 신뢰할 수 있는, 스키마를 준수하는 유효한 JSON을 출력하게 만드는 것.
이 글은 제가 GBNF 문법(세션별 컴파일 및 턴별 축소)으로 이 문제를 어떻게 해결했는지에 대한 내용이며, 왜 이 접근 방식이 로컬 모델에서 도구 호출 에이전트를 구축하는 모든 사람에게 일반화된다고 생각하는지에 대한 것입니다.
문제점: 소형 모델은 JSON 처리가 부실하다
만약 로컬 8B 또는 심지어 12B 모델로 에이전트를 구축해 본 적이 있다면, 그 과정을 알고 있을 겁니다. 모델에게 JSON으로 응답하라고 지시합니다. 대부분의 경우 그렇게 합니다. 그런데 47번째 턴에서 다음과 같은 일이 발생합니다:
- 닫는 중괄호(closing brace)를 잊어버림
- 배열에 후행 쉼표(trailing comma) 추가
- 존재하지 않는 도구 이름(
vault:read대신vault_read)을 지어냄 - JSON을 마크다운 코드 울타리 안에 감쌈
- 유효한 JSON을 출력한 뒤 대화형 텍스트 단락을 추가함
- `
OpenAI와 Anthropic은 이를 서버 측 함수 호출 (function-calling) API로 해결합니다. 하지만 우리는 로컬에서 실행 중입니다. 우리에게는 그런 사치가 없습니다. 대신 우리는 더 나은 것을 가지고 있습니다.
GBNF: 토큰 수준에서의 생성 제약 (constraining generation at the token level)
lama.cpp는 GBNF 문법 (GBNF grammars)을 지원합니다. 이는 샘플러 (sampler)가 출력할 수 있는 토큰을 제한하는 BNF와 유사한 형식입니다. 만약 특정 토큰이 문법의 유효한 파싱 (parse)으로 확장될 수 없는 문자열을 생성할 가능성이 있다면, 샘플링이 이루어지기 _전_에 해당 토큰은 마스킹 (masked out) 처리됩니다. 모델은 말 그대로 잘못된 형식의 출력을 생성할 수 없습니다. '보통 생성하지 않는 것'이 아니라, '할 수 없는' 것입니다.
이는 모델에게 'JSON으로 응답해 주세요'라고 프롬프트 엔지니어링 (prompt-engineering)을 하는 것과는 근본적으로 다른 방식입니다. 이는 모든 단일 토큰 위치에서 강제되는 샘플러에 대한 강력한 제약 (hard constraint)입니다.
Eris는 세션 시작 시 프로토콜 엔벨로프 (protocol envelope)의 정확한 형태를 정의하는 GBNF 문법을 컴파일합니다. 다음은 src/engine/grammar/envelope.rs에서 가져온 뼈대입니다:
const STATIC_GRAMMAR: &str = r#"root ::= "{" ws thought-kv "," ws status-kv "," ws message-kv "," ws toolcalls-kv ws "}"
ws ::= [ \t\n]*
...
모든 LLM 응답은 반드시 정확히 네 개의 키를 가진 JSON 객체여야 합니다: thought (사고 과정 (chain-of-thought), 항상 존재), status (네 가지 열거형 (enum) 값 중 하나), message_to_user (문자열 또는 null), 그리고 tool_calls (name+args 객체의 배열)입니다. 모델은 이 범위를 벗어날 수 없습니다. 다섯 번째 키를 추가할 수 없습니다. tool_calls의 철자를 틀릴 수도 없습니다. 열거형 (enum)에 존재하지 않는 상태 (status) 값을 출력할 수도 없습니다.
도구 호출 (tool call)의 형태 자체는 우리가 정적 모드 (static mode, args를 위한 임의의 JSON 객체)에 있는지, 아니면 동적 모드 (dynamic mode, 도구별 타입이 지정된 args)에 있는지에 따라 추가됩니다:
/// 도구별 동적 args (dynamic-args) 버전: tool-call이 `tool-with-args`로 분기됩니다.
const TOOL_CALL_RULES_DYNAMIC: &str = r#"tool-call-list ::= "" | tool-call ("," ws tool-call)*
tool-call ::= "{" ws "\"name\"" ws ":" ws tool-with-args ws "}"
...
여기서 tool-with-args에 주목하세요. 이 부분이 흥미로워지는 지점입니다.
JSON 스키마를 GBNF 규칙으로 컴파일하기
도구 인자(Tool arguments)는 소형 모델들이 정말로 어려움을 겪는 부분입니다. vault:read 호출에는 {"relative_path": "some/file.md"}가 필요합니다. web:fetch 호출에는 {"url": "https://..."}가 필요합니다. 제약 조건(constraints)이 없다면, 모델은 기꺼이 {"file": "notes.md"}와 같이 vault:read를 호출할 것입니다 (잘못된 키 이름, 스키마 검증 실패, 복구 턴 낭비).
Eris는 여기서 한 걸음 더 나아갑니다. 시작 시점에 각 도구의 JSON 스키마(JSON Schema)를 GBNF 규칙으로 컴파일합니다. 스키마는 schemars를 통해 Rust 구조체(structs)로부터 가져오므로, 타입(types)이 신뢰할 수 있는 단일 원천(source of truth)이 됩니다. 다음은 src/engine/grammar/schema_to_gbnf.rs에 있는 컴파일러 진입점입니다:
pub fn schema_to_gbnf_rule(
tool_name: &str,
schema: &RootSchema,
...
GbnfRule은 단순히 규칙 이름과 규칙 본문으로 구성된 (String, String) 튜플(tuple)입니다. 컴파일러는 JSON 스키마 트리를 순회하며 발견되는 각 타입에 대해 GBNF 생성 규칙(productions)을 방출(emits)합니다. 객체(Objects)는 필수 필드는 무조건적으로, 선택적 필드는 (...)?로 감싸진 키-값 시퀀스(key-value sequences)가 됩니다. 열거형(enums)이 포함된 문자열은 GBNF 선택 규칙(alternations)이 됩니다. 배열(Arrays)은 헬퍼 리스트 규칙을 할당받습니다. $ref 포인터는 정의 맵(definitions map)을 기준으로 해결(resolved)됩니다.
객체 컴파일러가 가장 핵심적인 부분입니다. 이는 속성(properties)을 정렬하고(필수 항목 우선, 그다음 선택 항목, 각 그룹 내에서는 알파벳순), 적절한 쉼표 처리를 포함하여 방출합니다:
fn compile_object(
validation: Option<&ObjectValidation>,
parent_rule_name: &str,
...
따라서 다음과 같은 Rust 구조체의 경우:
#[derive(JsonSchema, Deserialize)]
struct VaultWriteArgs {
relative_path: String,
...
컴파일러는 대략 다음과 같은 GBNF 규칙을 생성합니다:
vault-write-args ::= "{" ws "\"content\"" ws ":" ws json-string
ws "," ws "\"mode\"" ws ":" ws ("\"overwrite\"" | "\"append\"")
ws "," ws "\"relative_path\"" ws ":" ws json-string ws "}"
만약 모델이 도구 이름으로 "vault:write"를 출력하면, 샘플러(sampler)는 인자(args)가 vault-write-args를 따르도록 _강제(forces)_합니다. 모델은 잘못된 키를 사용할 수 없습니다. 문자열이 필요한 곳에 숫자를 전달할 수 없습니다. overwrite나 append가 아닌 모드(mode) 값을 사용할 수도 없습니다. 스키마(schema)가 말 그대로 문법(grammar)의 일부이기 때문입니다.
배열(Arrays)도 마찬가지로 작동합니다. tags: Vec<String>를 가진 도구의 경우, 컴파일러는 다음과 같은 헬퍼 리스트 규칙(helper list rule)을 생성합니다:
fn compile_array(
validation: Option<&ArrayValidation>,
parent_rule_name: &str,
...
이는 다음과 같은 결과를 생성합니다:
test-array-args-tags-list ::= json-string ("," ws json-string)*
깊이 가드(MAX_DEPTH = 2)는 컴파일러가 재귀적 스키마(recursive schemas)를 무한히 추적하는 것을 방지합니다. 만약 스키마가 너무 복잡하면(깊게 중첩된 oneOf, 재귀적 타입, 고정된 키가 없는 자유 형식의 additionalProperties 등), 컴파일러는 None을 반환하며 해당 도구는 일반적인 json-object 규칙으로 폴백(fallback)됩니다. 충돌(crash)이 아닌 우아한 성능 저하(Graceful degradation)를 구현한 것입니다.
턴당 문법 축소(Per-turn grammar narrowing): 진정한 비결
소형 모델의 성능을 실질적으로 끌어올리는 핵심은 바로 이것입니다: Eris는 매 턴마다 모델에게 50개의 도구가 포함된 전체 문법을 제공하지 않습니다.
각 LLM 호출 전에 오케스트레이터(orchestrator)는 ToolRouter를 실행합니다. 이는 벡터 메모리(vector memory)와 동일한 임베딩 모델(embedding model)을 사용하는 의미론적 유사도 검색(semantic similarity search)입니다. 사용자의 메시지는 임베딩되어 각 도구에 대해 미리 계산된 벡터(도구 이름, 설명, 라우팅 힌트로 구축됨)와 비교됩니다. 코사인 유사도(cosine similarity) 임계값을 넘는 도구들만이 선택됩니다.
그 후 GBNF 문법은 오직 선택된 도구들만을 포함하도록 다시 컴파일됩니다. 만약 사용자가 '지금 몇 시야?'라고 말한다면, 문법의 tool-with-args 선택 사항(alternation)에는 clock:now만 포함될 수 있습니다. 모델은 원하더라도 vault:write를 호출할 수 없습니다. 해당 토큰들은 샘플러에서 아예 사용할 수 없기 때문입니다.
이 서브셋 캐시(subset cache)는 src/orchestrator/core/llama_gbnf_subset.rs에 위치합니다:
pub(crate) fn get_or_compile_subset(
&self,
gatekeeper: &Gatekeeper,
...
다른 턴(turn)에서 동일한 도구 세트를 사용하나요? 캐시 히트(Cache hit)가 발생하여 재컴파일(recompilation)을 수행하지 않습니다. Arc<str>를 통해 컴파일된 문법(grammar)을 저렴한 비용으로 유지합니다.
그리고 src/orchestrator/core/step.rs의 메인 인지 루프(cognitive loop)에서 오케스트레이터(orchestrator)가 각 LLM 호출 시 어떤 문법을 사용할지 선택하는 방식은 다음과 같습니다:
let (grammar_override, attach_session_grammar) = if !self.config.is_llamacpp() {
(None, true) // Ollama: 문법 지원 없음, 건너뜀
} else if !tools_needed {
...
네 개의 분기(branch)와 네 개의 서로 다른 문법 범위(grammar scope)가 존재합니다. !tools_needed 케이스가 흥미로운데, 라우터(router)가 사용자가 단순히 채팅 중이라고 판단할 때(관련된 도구가 없을 때), 문법은 여전히 프로토콜 엔벨로프(protocol envelope)를 강제하지만 빈 도구 목록(empty tool list)으로 컴파일합니다. 이는 tool_calls가 오직 []가 될 수 있음을 의미합니다. 라우터가 '이것은 단순한 대화이다'라고 말할 때 모델은 도구 호출(tool call)을 환각(hallucinate)할 수 없습니다.
이것은 두 가지 효과를 가져옵니다:
- 신뢰성(Reliability)이 크게 향상됩니다. 선택지가 적을수록 실수가 줄어듭니다. 3개의 도구 중에서 선택하는 8B 모델은 50개 중에서 선택하는 모델보다 훨씬 더 신뢰할 수 있습니다.
- 생성(Generation) 속도가 빨라집니다. 문법이 작을수록 각 토큰(token) 위치에서 제약된 샘플러(constrained sampler)가 수행해야 할 작업이 줄어듭니다.
게이트키퍼(Gatekeeper): 심층 방어 (defence in depth)
문법 강제(Grammar enforcement)는 필요하지만 충분하지는 않습니다. 완벽한 문법이 있더라도 모델은 여전히 의미론적으로 잘못된 인자(semantically wrong arguments)로 도구를 호출할 수 있습니다(존재하지 않는 유효한 경로를 호출하거나, 현재 에이전트 상태에 부적절한 동작을 수행하는 경우).
Eris는 그 위에 게이트키퍼(Gatekeeper) 계층을 추가합니다 (src/tools/gatekeeper.rs). 모든 도구 호출은 실행 전에 이를 통과합니다:
pub async fn execute_tool(
&self,
state: &AgentState,
...
인자 정규화(arg normalization)도 언급할 가치가 있습니다. 소형 모델들은 빈번하게 약간 잘못된 키(key) 이름을 사용합니다: query 대신 q, relative_path 대신 path, max_headlines 대신 top_n 등입니다. 복구(recovery)를 위한 턴을 낭비하는 대신, 게이트키퍼는 검증(validation) 전에 일반적인 별칭(aliases)을 조용히 매핑합니다:
fn normalize_tool_args(tool_name: &str, mut args: Value) -> Value {
let Some(obj) = args.as_object_mut() else { return args };
...
이는 실용적인 양보입니다. 문법(grammar)은 _구조(structure)_를 강제하고, 정규화기(normalizer)는 _명칭 드리프트(naming drift)_를 처리합니다. 스키마 검증기(schema validator)는 이 두 가지를 모두 통과하여 새어 나오는 모든 것을 잡아냅니다.
실제 적용 사례
Ollama 백엔드(문법 미사용)에서는 12B 모델을 사용한 긴 세션에서 턴당 약 3~5%의 프로토콜 실패가 발생합니다. 잘못된 형식의 JSON, 지어낸 도구 이름, JSON 객체 뒤에 붙는 불필요한 산문 등이 이에 해당합니다. 각 실패는 복구 턴(파싱 에러와 함께 다시 프롬프트 입력)을 소모하며, 이는 컨텍스트 윈도우(context window)를 낭비하고 지연 시간(latency)을 증가시킵니다.
GBNF를 사용하는 llama.cpp 백엔드에서는 프로토콜 실패가 극적으로 감소합니다. 짧거나 중간 길이의 세션은 사실상 깨끗합니다. 하지만 실패가 전혀 없다고 주장하지는 않겠습니다. 모델이 일관성을 잃기 시작하는 긴 컨텍스트에서는 문법으로 제약된 출력(grammar-constrained output)조차 저하될 수 있습니다. 문법은 구문적(syntactic) 유효성(JSON이 파싱되고, 키가 올바르며, 열거형(enum) 값이 존재함)을 보장하지만, 모든 문자열 필드를 쓰레기 값으로 채우거나 28k 토큰 지점에서 맥락을 놓쳐 동일한 도구 호출을 루프 내에서 반복하는 모델을 고칠 수는 없습니다.
이것이 Eris가 문법에만 의존하지 않는 이유입니다. 스키마 위반, 빈 도구 결과, 의미론적 헛소리(semantic nonsense)를 잡아내고, 모델에 구체적인 수정 사항을 전달하며, 제한된 예산 내에서 재시도하는 제한된 복구 루프(bounded recovery loop)가 존재합니다. 문법은 쉬운 실패들(잘못된 JSON, 지어낸 도구 이름)을 제거했습니다. 복구 루프는 나머지를 처리합니다. 이 메커니즘에 대해서는 후속 포스트에서 더 자세히 다루겠습니다.
세션 안정성의 차이는 여전히 엄청납니다. Ollama에서의 100턴 세션은 신뢰할 수 없을 정도로 저하되지만, 문법을 사용하는 llama.cpp에서의 동일한 세션은 훨씬 더 오랫동안 사용 가능한 상태를 유지합니다. 다만, 이것이 만능 해결책(silver bullet)이라고 말하는 사람은 믿지 마세요.
제한된 생성(Bounded generation): 나머지 절반
완벽한 문법(grammar)이 있더라도, 모델이 응답 중간에 컨텍스트 윈도우(context window)를 가득 채워버리면 생성 과정에 문제가 생길 수 있습니다. llama.cpp는 이를 잘라내게(truncate) 되며, 이로 인해 닫는 중괄호가 생성되지 못하면서 지금까지는 유효했던 JSON이 유효하지 않은 상태가 됩니다.
Eris는 응답이 실제로 완료될 수 있도록 컨텍스트 윈도우에 여유 공간을 남기도록 호출당 n_predict(생성할 최대 토큰 수)를 조정하여 설정합니다. 문법(grammar)은 구조를 보장하고, 제한된 생성(bounded generation)은 그 구조가 닫힐 수 있는 공간을 보장합니다.
아키텍처 개요
문법(grammar)이 전체적인 그림에서 어디에 위치하는지에 대한 맥락을 위해:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기