Малые 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-файлы. Это даёт прозрачную историю действий и возможность ручного аудита.
Поток обработки запроса выглядит так:
- Пользователь отправляет сообщение.
- Семантический роутер вычисляет эмбеддинг запроса, сравнивает с эмбеддингами описаний всех инструментов и выбирает три наиболее релевантных.
- Компилятор объединяет GBNF-правила выбранных инструментов в одну грамматику с корневым правилом-выбором.
- Модель генерирует JSON, ограниченный этой грамматикой.
- Парсер извлекает вызов, агент выполняет инструмент, результат записывается в Obsidian-хранилище.
- Результат добавляется в контекст, цикл повторяется.
Почему 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. Грамматика содержит только их поля - модель выбирает из трёх вариантов вместо пятидесяти. Вероятность правильного вызова вырастает кратно.
Реализация роутера: эмбеддинги и быстрое сопоставление
Технически роутер работает так:
- При старте сессии для каждого инструмента вычисляется эмбеддинг его текстового описания (название + список параметров с типами). Эмбеддинги кэшируются в памяти.
- При поступлении запроса вычисляется его эмбеддинг.
- Для каждого инструмента считается косинусное сходство между эмбеддингом запроса и эмбеддингом описания.
- Инструменты сортируются по убыванию сходства, берутся топ-3.
- Если максимальное сходство ниже порога (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 поля присутствуют и непусты), типы значений, диапазоны чисел. При ошибке запускается стратегия восстановления:
- Повтор с тем же инструментом: если JSON синтаксически невалиден (редко, но возможно при обрыве), модель получает промпт «Исправь JSON, сохранив смысл параметров» и генерирует заново с той же грамматикой.
- Переформулировка запроса: если валидатор обнаруживает пустые обязательные поля, модель получает уточняющий промпт с требованием заполнить конкретные поля.
- 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) + GBNF | 99.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 в свой проект: практические шаги
Минимальный набор компонентов для повторения подхода:
- Парсер JSON Schema → генератор GBNF. Реализуется на любом языке. Ключевая логика: рекурсивный обход типов (object, array, string, number, integer, boolean, enum), генерация правил для каждого, сборка в строку GBNF. Для старта достаточно поддержки object, string и integer - это покрывает 80% инструментов.
- Интеграция с llama.cpp. Грамматика передаётся через параметр
grammarв вызовеllama_create_completionили эквивалентном API. Сервер llama.cpp (llama-server) поддерживает грамматики через полеgrammarв JSON-запросе - можно передавать скомпилированные правила как строку. - Роутер. Эмбеддинги можно получать через тот же llama.cpp (извлечение hidden states) или использовать отдельную embedding-модель. Для 50 инструментов достаточно косинусного сходства с пороговой фильтрацией.
- Цикл восстановления. Валидатор 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 специализированные модели) зависит от доступного железа и требований к точности.