Перейти к содержанию
Новое AiManual теперь в MAX Подписаться
Публикация AiManual

GBNF Grammar Compiler: Как заставить 8B модели надежно вызывать инструменты — технический разбор архитектуры и кода

Разбор GBNF Grammar Compiler из проекта Eris: компиляция JSON Schema в GBNF-правила, семантический роутер и цикл восстановления. Как 8B-модели на llama.cpp пере

Коротко

Что будет в материале

  1. 01

    Почему малые модели проваливают tool calling и как GBNF решает эту проблему

  2. 02

    Архитектура Eris: как Rust, llama.cpp и Obsidian-хранилище работают вместе

  3. 03

    Грамматический компилятор: превращаем JSON Schema в GBNF-правила

  4. 04

    Семантический роутер: как сузить грамматику до 3 инструментов из 50

Малые LLM (7-8B параметров) проваливают tool calling. Модель получает описание инструмента и должна вернуть JSON для его вызова, но вместо этого генерирует текст, обернутый в markdown-блоки, придумывает несуществующие поля, пропускает закрывающие скобки или добавляет пояснения до и после JSON. Few-shot примеры снижают частоту ошибок, но не устраняют их полностью - на сотне вызовов 5-10 всё равно окажутся невалидными. Тонкая настройка помогает, но привязана к конкретной модели и дорога в поддержке.

Решение - принудительно ограничить пространство генерации. Вместо того чтобы просить модель «создать JSON», мы передаём семплеру грамматику, которая разрешает только токены, соответствующие схеме конкретного инструмента. Формат GBNF в llama.cpp позволяет описать эту грамматику декларативно, а компилятор JSON Schema → GBNF строит её автоматически на старте сессии. Результат: 8B-модель физически не способна сгенерировать некорректный вызов - любой сгенерированный токен гарантированно продолжает валидную JSON-структуру.

Автор проекта Eris - агента на Rust с Obsidian-совместимым хранилищем - реализовал этот подход для ~50 инструментов и добавил семантический роутер, который на каждом шаге сужает грамматику до трёх релевантных инструментов. Это радикально снижает сложность грамматики и повышает точность выбора. Разберём архитектуру, код компилятора и цикл восстановления.

Почему малые модели проваливают tool calling и как GBNF решает эту проблему

Типичный сценарий: агент получает запрос пользователя, выбирает инструмент и должен вернуть JSON с параметрами. Модель видит описание схемы в системном промпте - например, {"name": "search", "parameters": {"query": "string", "limit": "integer"}}. Ожидается {"query": "котики", "limit": 5}. В реальности 8B-модель часто выдаёт:

  • Code fences: ```json {\"query\": \"котики\"} ``` - парсер ломается на markdown-разметке;
  • Выдуманные поля: {"search_query": "котики", "max_results": 5} - модель «переименовала» параметры;
  • Пропущенные скобки: {"query": "котики", "limit": 5 - не хватило токенов или внимания;
  • Лишний текст: Я выполню поиск: {"query": "котики"} - модель смешивает рассуждение с действием.

Корень проблемы - у малых моделей слабая способность удерживать точный синтаксис при генерации. Они «знают», как выглядит JSON, но не могут воспроизвести его без ошибок на каждом вызове. Стандартные методы не дают 100% надёжности: few-shot снижает вероятность ошибки, но не исключает её; fine-tuning требует обучающей выборки и привязывает решение к конкретной модели.

GBNF (GGML BNF) - формат контекстно-свободной грамматики, встроенный в llama.cpp. Он позволяет задать правила, которым должен следовать каждый генерируемый токен. Семплер на каждом шаге маскирует недопустимые токены, оставляя только те, что продолжают вывод в соответствии с грамматикой. Для JSON это означает: модель не может закрыть строку, пока не поставит кавычку; не может пропустить запятую между полями; не может выдумать имя поля, которого нет в правилах.

Ключевая идея проекта Eris - не писать GBNF-правила вручную, а компилировать их из JSON Schema каждого инструмента. На старте сессии парсер обходит ~50 схем, генерирует для каждой набор GBNF-правил и кэширует их. Когда роутер выбирает инструмент, его грамматика активируется - и модель вынуждена генерировать строго соответствующий схеме JSON.

Архитектура Eris: как Rust, llama.cpp и Obsidian-хранилище работают вместе

Eris - агент, полностью написанный на Rust. Выбор языка продиктован требованиями к скорости и контролю над памятью: Python-обёртки вокруг llama.cpp добавляют накладные расходы на сериализацию и GIL, что критично при плотном цикле «запрос-инструмент-ответ». Rust позволяет держать модель в памяти, переиспользовать аллокации и обрабатывать грамматики без копирования строк.

Инференс идёт через нативные биндинги llama.cpp. Целевая конфигурация - Gemma 4 12B (Q4_K_M квантизация) на 16 ГБ VRAM. Модель занимает около 7.5 ГБ, остальное уходит на KV-кэш и контекст. При активной грамматике скорость генерации падает незначительно - маскирование токенов выполняется на CPU и стоит доли миллисекунды на шаг.

Хранилище агента совместимо с Obsidian: заметки, результаты вызовов инструментов и контекст диалога пишутся в markdown-файлы. Это даёт прозрачную историю действий и возможность ручного аудита.

Поток обработки запроса выглядит так:

  1. Пользователь отправляет сообщение.
  2. Семантический роутер вычисляет эмбеддинг запроса, сравнивает с эмбеддингами описаний всех инструментов и выбирает три наиболее релевантных.
  3. Компилятор объединяет GBNF-правила выбранных инструментов в одну грамматику с корневым правилом-выбором.
  4. Модель генерирует JSON, ограниченный этой грамматикой.
  5. Парсер извлекает вызов, агент выполняет инструмент, результат записывается в Obsidian-хранилище.
  6. Результат добавляется в контекст, цикл повторяется.

Почему Rust и llama.cpp: скорость и контроль на 16GB VRAM

Сравнение с Python-аналогами на той же конфигурации (Gemma 4 12B, 16 ГБ VRAM):

  • Потребление памяти: Rust-версия Eris держит baseline 8.2 ГБ против 10.5+ ГБ у Python-обёрток (разница - в накладных расходах на объекты, списки токенов и копирование буферов).
  • Задержка на шаг: от получения ответа модели до начала выполнения инструмента - 3-5 мс в Rust против 15-40 мс в Python (парсинг JSON, валидация, обращение к хранилищу).
  • Пропускная способность: на 100 последовательных вызовах инструментов Rust-агент выполняет цикл на 30-40% быстрее за счёт отсутствия GIL и асинхронного I/O.

Rust даёт жёсткие гарантии корректности работы с памятью при интеграции с C-библиотекой llama.cpp - нет риска use-after-free при передаче указателей на грамматики между вызовами.

Грамматический компилятор: превращаем JSON Schema в GBNF-правила

Сердце решения - компилятор, который принимает JSON Schema инструмента и возвращает строку с GBNF-правилами. Рассмотрим пошагово на примере схемы поискового инструмента:

{
  "type": "object",
  "properties": {
    "query": { "type": "string" },
    "limit": { "type": "integer", "minimum": 1, "maximum": 100 }
  },
  "required": ["query"]
}

Компилятор обходит схему рекурсивно. Корневой тип object создаёт правило для фигурных скобок и списка свойств. Для каждого свойства генерируется правило: ключ-строка, двоеточие, значение по типу. Обязательные поля идут в фиксированном порядке, опциональные - через альтернативу с пустой строкой. Результат для этой схемы (упрощённо):

root ::= "{" ws "\"query\"" ws ":" ws string "," ws "\"limit\"" ws ":" ws integer "}"
string ::= "\"" [^"\\]* "\""
integer ::= [0-9]+
ws ::= [ \t\n]*

Модель физически не может сгенерировать поле search_term вместо query - грамматика разрешает только токены, продолжающие строку "query". Не может пропустить запятую - правило жёстко задаёт разделители. Не может выдать limit: "abc" - правило integer принимает только цифры.

Реальный код компилятора из проекта Eris (упрощённый фрагмент на Rust):

fn compile_schema(schema: &Schema) -> String {
    let mut rules = String::from("root ::= object\n");
    rules.push_str(&compile_object(&schema.root));
    rules
}

fn compile_object(obj: &ObjectSchema) -> String {
    let mut rule = String::from("object ::= \"{\" ws ");
    let props: Vec<&String> = obj.properties.keys().collect();
    for (i, key) in props.iter().enumerate() {
        if i > 0 { rule.push_str(" \",\" ws "); }
        rule.push_str(&format!("\"\\\"{}\\\"\" ws \":\" ws ", key));
        let prop = &obj.properties[*key];
        rule.push_str(&compile_type(prop));
    }
    rule.push_str(" \"}\"");
    rule
}

Компилятор кэширует правила при старте сессии. Для 50 инструментов с типичными схемами (3-8 полей) генерация занимает менее 10 мс и выполняется однократно.

Обработка сложных схем: вложенные объекты, массивы и опциональные поля

Реальные инструменты редко ограничиваются плоскими объектами. Рассмотрим схему поиска с фильтрами:

{
  "type": "object",
  "properties": {
    "query": { "type": "string" },
    "filters": {
      "type": "object",
      "properties": {
        "date_from": { "type": "string", "format": "date" },
        "tags": {
          "type": "array",
          "items": { "type": "string" },
          "maxItems": 5
        }
      }
    }
  },
  "required": ["query"]
}

Компилятор обрабатывает вложенность рекурсивно: для filters создаётся отдельное правило filters_object, для tags - правило массива с повторением элемента до 5 раз. Опциональные поля (всё, кроме query) оборачиваются в альтернативу: ("," ws "\"filters\"" ws ":" ws filters_object | "") - модель может либо включить поле, либо пропустить его.

Ограничения подхода:

  • Рекурсивные схемы (объект содержит массив объектов того же типа) требуют ручной развёртки до фиксированной глубины. Компилятор Eris поддерживает до 3 уровней вложенности, чего достаточно для большинства инструментов.
  • Динамические ключи (свойства с patternProperties или additionalProperties) не могут быть скомпилированы в строгую грамматику - для них используется fallback на общий JSON-грамматику без ограничения имён полей.
  • Строковые форматы (email, URI, date) проверяются постфактум валидатором - грамматика гарантирует только строковый тип, но не семантику значения.

Семантический роутер: как сузить грамматику до 3 инструментов из 50

Если скомпилировать грамматику для всех 50 инструментов в одну, корневое правило будет содержать 50 альтернатив, каждая - со своим набором полей. Для 8B-модели это создаёт две проблемы: во-первых, семплер вынужден выбирать из тысяч допустимых токенов на каждом шаге, что размывает вероятности; во-вторых, модель может начать смешивать поля разных инструментов - например, взять query от search и path от file_read.

Семантический роутер решает это сужением пространства выбора. На каждом шаге диалога он вычисляет эмбеддинг запроса пользователя (используется та же модель, что и для генерации - её внутреннее представление текста), сравнивает с предварительно вычисленными эмбеддингами описаний всех инструментов и отбирает три наиболее близких по косинусному сходству. Затем компилятор строит объединённую грамматику только для этих трёх.

Пример: запрос «найди заметки про Rust за прошлую неделю» активирует инструменты search_notes, list_recent_files и get_date_range. Грамматика содержит только их поля - модель выбирает из трёх вариантов вместо пятидесяти. Вероятность правильного вызова вырастает кратно.

Реализация роутера: эмбеддинги и быстрое сопоставление

Технически роутер работает так:

  1. При старте сессии для каждого инструмента вычисляется эмбеддинг его текстового описания (название + список параметров с типами). Эмбеддинги кэшируются в памяти.
  2. При поступлении запроса вычисляется его эмбеддинг.
  3. Для каждого инструмента считается косинусное сходство между эмбеддингом запроса и эмбеддингом описания.
  4. Инструменты сортируются по убыванию сходства, берутся топ-3.
  5. Если максимальное сходство ниже порога (0.3), роутер возвращает fallback - общую JSON-грамматику без привязки к инструментам.

Оверхед от роутера минимален: вычисление эмбеддинга запроса - один forward-проход модели (50-100 мс на Gemma 4 12B), сравнение с 50 кэшированными векторами - доли миллисекунды. На фоне полного цикла «генерация + выполнение инструмента» (2-5 секунд) это незаметно.

Сравнение с подходом «все 50 инструментов в одной грамматике» на синтетическом тесте из 200 запросов: с роутером точность выбора правильного инструмента - 94%, без роутера - 71%. С роутером модель ни разу не смешала поля разных инструментов; без роутера - 12 случаев гибридных JSON.

Цикл восстановления: что делать, если даже GBNF не помог?

Грамматика исключает синтаксические ошибки, но не все проблемы. Возможные сбои:

  • Превышение лимита токенов внутри строки: модель начинает генерировать длинный текст в поле query, упирается в max_tokens, и вывод обрывается на незакрытой кавычке.
  • Семантически неверный выбор: грамматика гарантирует валидный JSON для инструмента A, но по смыслу нужен был инструмент B.
  • Пустые обязательные поля: модель генерирует {"query": ""}, что формально валидно, но бесполезно.

Цикл восстановления Eris обнаруживает эти ситуации и пытается исправить их автоматически. После генерации JSON проходит через валидатор: проверяется соответствие схеме (все required поля присутствуют и непусты), типы значений, диапазоны чисел. При ошибке запускается стратегия восстановления:

  1. Повтор с тем же инструментом: если JSON синтаксически невалиден (редко, но возможно при обрыве), модель получает промпт «Исправь JSON, сохранив смысл параметров» и генерирует заново с той же грамматикой.
  2. Переформулировка запроса: если валидатор обнаруживает пустые обязательные поля, модель получает уточняющий промпт с требованием заполнить конкретные поля.
  3. Fallback на общий JSON: если две попытки не дали валидного результата, грамматика ослабляется до общей JSON-грамматики (любые ключи, любые типы), и модель генерирует произвольный JSON, который затем парсится и проверяется эвристиками.

Код обработчика ошибок в проекте (упрощённо):

fn recover_from_error(
    error: &ValidationError,
    tool: &Tool,
    context: &Context
) -> Result {
    match error {
        ValidationError::MissingField(field) => {
            let prompt = format!("Заполни поле '{}' в JSON для инструмента '{}'",
                field, tool.name);
            retry_with_prompt(prompt, tool, context)
        }
        ValidationError::InvalidJson => {
            retry_with_prompt("Исправь JSON", tool, context)
        }
        _ => fallback_to_generic_json(context),
    }
}

На практике цикл восстановления срабатывает редко: при использовании роутера и грамматики доля сбойных вызовов падает до 1-3%, и большинство из них исправляется первой же повторной попыткой.

Результаты: насколько надёжнее стали 8B-модели на реальных задачах

Тестирование проводилось на Gemma 4 12B (Q4_K_M, 16 ГБ VRAM) с набором из 50 инструментов - от поиска по заметкам до вызова внешних API. Сравнивались три конфигурации: базовая (системный промпт с JSON Schema), с GBNF-грамматикой для всех инструментов, и с роутером + GBNF.

КонфигурацияВалидный JSONПравильный инструментКорректные параметры
Базовая (few-shot в промпте)78%71%63%
GBNF для всех 50 инструментов99.5%71%68%
Роутер (топ-3) + GBNF99.7%94%91%

GBNF практически устраняет синтаксические ошибки - 99.5%+ валидного JSON. Роутер добавляет семантическую точность: правильный инструмент выбирается в 94% случаев против 71% без роутера. Корректные параметры (все поля заполнены верно, типы соблюдены) - 91% с роутером против 63% в базовой конфигурации.

Пример диалога, где базовая модель ошибалась: запрос «удали заметку про встречу во вторник». Базовая модель генерировала {"note_title": "встреча", "date": "вторник"} - выдуманные поля. С GBNF и роутером: {"path": "daily/2026-07-28.md", "mode": "delete"} - правильный инструмент file_ops с валидными параметрами.

Потребление памяти на Gemma 4 12B: модель - 7.5 ГБ, KV-кэш при контексте 8K токенов - 1.2 ГБ, грамматики и роутер - менее 100 МБ. Итого 8.8 ГБ из 16 доступных. Задержка генерации вызова инструмента - 200-500 мс (10-30 токенов JSON).

Ограничения и когда подход может не сработать

GBNF Grammar Compiler эффективен в определённых границах:

  • Очень большие схемы (более 100 полей, глубокая вложенность): грамматика становится громоздкой, семплер тратит больше времени на маскирование токенов, а модель может терять контекст внутри длинного JSON. Рекомендуется разбивать такие инструменты на несколько более мелких.
  • Креативность внутри строковых полей: грамматика ограничивает структуру, но не содержание. Если модель склонна к галлюцинациям в значениях полей (например, выдумывает несуществующие пути к файлам), GBNF это не исправит - нужна пост-валидация.
  • Качество исходной JSON Schema: если схема описана неточно (например, все поля опциональны, хотя часть критична), компилятор построит грамматику, допускающую пустые объекты. Схемы должны быть строгими.
  • Выбор модели: Gemma 4 12B, Qwen 3.6 27B и аналоги показывают лучшие результаты. Модели младше 7B параметров могут путаться даже в простых грамматиках - сказывается ограниченная способность удерживать контекст.

Для проверки стабильности модели на длинных диалогах с инструментами полезно изучить методику тестирования из статьи о потере контекста в Qwen 3.6 27B - там разбираются пять причин деградации инструкций и даётся чек-лист конфигурации llama.cpp.

Как внедрить GBNF Grammar Compiler в свой проект: практические шаги

Минимальный набор компонентов для повторения подхода:

  1. Парсер JSON Schema → генератор GBNF. Реализуется на любом языке. Ключевая логика: рекурсивный обход типов (object, array, string, number, integer, boolean, enum), генерация правил для каждого, сборка в строку GBNF. Для старта достаточно поддержки object, string и integer - это покрывает 80% инструментов.
  2. Интеграция с llama.cpp. Грамматика передаётся через параметр grammar в вызове llama_create_completion или эквивалентном API. Сервер llama.cpp (llama-server) поддерживает грамматики через поле grammar в JSON-запросе - можно передавать скомпилированные правила как строку.
  3. Роутер. Эмбеддинги можно получать через тот же llama.cpp (извлечение hidden states) или использовать отдельную embedding-модель. Для 50 инструментов достаточно косинусного сходства с пороговой фильтрацией.
  4. Цикл восстановления. Валидатор JSON Schema (любая библиотека) + логика повторных попыток с модифицированным промптом.

Советы по отладке грамматик:

  • Всегда проверяйте сгенерированные GBNF-правила на синтаксис - llama.cpp молча игнорирует некорректные правила и возвращает ошибку только при попытке генерации.
  • Начинайте с простых схем (2-3 поля) и постепенно усложняйте.
  • Логируйте сгенерированные JSON вместе с грамматикой - это поможет найти расхождения между ожидаемой схемой и фактическими правилами.
  • Частая ошибка: забыть экранировать обратные слеши и кавычки в строковых литералах внутри GBNF.

Альтернативы GBNF: constrained decoding в vLLM, Outlines, Guidance

GBNF - не единственный способ принудительной генерации структурированного вывода. Краткое сравнение:

  • vLLM guided decoding: поддерживает JSON Schema через guided_json. Работает на серверных GPU, требует vLLM-стека. Плюс - интеграция с экосистемой Python. Минус - не работает с llama.cpp, привязка к vLLM.
  • Outlines: библиотека, компилирующая JSON Schema в конечный автомат и маскирующая токены. Гибкая, поддерживает сложные схемы. Минус - медленная компиляция схем на старте, интеграция с llama.cpp через кастомные серверы.
  • Guidance: DSL для шаблонной генерации. Позволяет встраивать логику в промпт. Минус - ориентирован на Python, не даёт жёстких гарантий валидности JSON без ручного описания грамматики.

GBNF выигрывает в экосистеме llama.cpp: нативная поддержка, минимальный оверхед, работа на CPU и GPU, совместимость с GGUF-моделями. Для проектов, уже использующих llama.cpp, это путь наименьшего сопротивления. Для тех, кто работает с vLLM на серверных GPU, guided decoding может быть удобнее.

Проект BTL-3 Compact демонстрирует альтернативный подход - модель, изначально натренированную для надёжного tool calling, сжатую до 8.39 ГБ. Сравнение подходов (грамматики vs специализированные модели) зависит от доступного железа и требований к точности.

Подписаться на канал