Почему "пожалуйста" не работает, а грамматика — да?
Ты когда-нибудь писал в промпте Ответь ровно одним JSON-объектом — и получал в ответ «Конечно! Вот ваш JSON:
```json
{ "answer": 42 }? Знакомо. LLM по своей природе — болтушка. Её учили на терабайтах текста, где каждый JSON окружён пояснениями. Поэтому без внешнего принуждения модель будет лить воду.
Выход — constrained sampling (сэмплирование с ограничениями). Идея: на каждом шаге генерации токена мы разрешаем модели выбирать только те токены, которые не нарушают заданную грамматику (например JSON). Это не валидация после генерации — это встраивание правил в сам процесс декодирования. Никаких «перегенерируй, если сломалось».
Мы уже касались этой темы в статье «LLM Structured Outputs: Когда JSON — это не опция, а требование», а сегодня разберём четыре рабочих метода с реальным кодом.
Метод 1: Prompt engineering + пост-обработка — уже не торт
Да, самый простой путь — попросить красиво и потом распарсить. Но он ненадёжен. В 2026 году так делают только в угловых кейсах, когда нет контроля над инференсом. Регулярка json.loads(re.findall(r'\{.*\}', text, re.DOTALL)) — это боль и треш. Если строка содержит вложенные объекты или экранированные кавычки — всё.
Плюсы: не требует специальных движков. Минусы: модель может выдать ключ без кавычек, лишнюю запятую или длинное значение, которое не влезает в JSON. Для прода — не вариант.
Совет: если вынужден использовать пост-обработку, оберни вызов в цикл с повторными промптами. Но готовься к росту latency и cost.
Метод 2: Грамматический констрейнт на уровне токенов (Outlines, Guidance)
Самый популярный подход. Библиотеки вроде Outlines 3.1 и Guidance 0.5 позволяют задать JSON-схему (как Pydantic или TypedDict) и превратить её в контекстно-свободную грамматику. Во время генерации каждый следующий токен выбирается только из тех, которые соответствуют грамматике.
1Outlines 3.1: JSON Schema из Pydantic
from pydantic import BaseModel
from outlines import generate, models
class User(BaseModel):
name: str
age: int
model = models.Model("microsoft/Phi-4-mini-instruct")
generator = generate.json(model, User)
result = generator("Extract user: John is 28")
print(result) # User(name='John', age=28)
Внутри Outlines строит грамматику, которая запрещает модельку генерировать что-то кроме валидного JSON с типами str и int. Никаких лишних полей или неверных типов.
2Guidance 0.5: шаблоны с переменными
import guidance
from guidance import gen, models
llm = models.Transformers("HuggingFaceTB/SmolLM2-1.7B-Instruct")
program = guidance(
"""{
"name": "{{gen 'name' stop='"'}}",
"age": {{gen 'age' pattern='[0-9]+'}}
}"""
)
output = program()
print(output) # {"name": "Alice", "age": "35"}
Guidance даёт больше гибкости: можно комбинировать обычный текст с генерацией внутри токенов. Но её механизм — не полноценный грамматический констрейнт, а скорее регулярные маски. Для простых схем — идеально.
Метод 3: Встроенная поддержка в движках инференса (llama.cpp, VLLM, XGrammar)
С 2025 года почти все популярные движки обзавелись нативным грамматическим сэмплированием. Это самый производительный путь: правила работают прямо на C++ уровне, без лишних прослоек Python.
llama.cpp b4500+
Поддерживает грамматики в формате GBNF (аналог EBNF). К примеру, JSON-схема для задачи extract:
./llama-cli -m phi-4.Q4_K_M.gguf -p "Extract: John is 28" --grammar-file user.gbnf
Где user.gbnf — описание JSON. Результат — гарантированно валидный JSON без постобработки. Если запускаешь через llama.cpp на Vast.ai, скорость почти не падает.
VLLM 1.0 + XGrammar
Начиная с VLLM 0.8, появилась поддержка XGrammar — библиотеки от Meta, которая компилирует грамматику в детерминированный конечный автомат (DFA) и применяет маски прямо в батчевом режиме. В версии 1.0 (2026) это стало стандартом. Достаточно передать guided_json=... или guided_grammar=... при запросе:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="-")
response = client.chat.completions.create(
model="Llama-4-8B-Instruct",
messages=[{"role": "user", "content": "Extract user: Jane 30"}],
extra_body={"guided_json": {"type": "object", "properties": {"user": {"type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}}}}}
)
print(response.choices[0].message.content)
# {"user":{"name":"Jane","age":30}}
Важно: старый параметр response_format={ "type": "json_object" } без грамматики — это был буллшит, как мы разбирали в статье «Параметр strict в VLLM и llama.cpp: почему он ничего не делает и как с этим жить». В VLLM 1.0 настоящий guided_json таки включён, но только с поддержкой XGrammar.
Метод 4: LMQL — язык запросов для LLM
LMQL (Language Model Query Language) позволяет описывать ожидаемый вывод в виде блоков с типами. Она идёт ещё дальше: можно ограничивать не только формат, но и семантику (например, «выведи только положительные числа»). Но для простого JSON это избыточно.
from lmql import F
import lmql
@lmql.query
async def extract_user():
'''lmql
"Extract user: {name: String and name.len < 20} is {age: Number and age > 0}"
return (name, age)
'''
...(await extract_user())
Выглядит круто, но требует отдельного компилятора. Для прода чаще берут Outlines или XGrammar.
Сравнительная таблица методов на 10.07.2026
| Метод | Тип констрейнта | Производительность | Поддержка JSON Schema | Батчевая обработка |
|---|---|---|---|---|
| Outlines 3.1 | Грамматика (CFG) | Средняя (Python) | Да (Pydantic, TypedDict) | Ограниченная |
| Guidance 0.5 | RegEx-маски | Высокая (C-расширения) | Нет (ручные шаблоны) | Нет |
| llama.cpp GBNF | Грамматика (GBNF) | Высокая (C++) | Ручная запись | Нет |
| VLLM 1.0 + XGrammar | DFA-маски | Максимальная (CUDA + батчи) | Да (через API) | Да |
| LMQL 0.9 | Констрайнты на типах | Средняя (Python-компиляция) | Ограниченная | Нет |
Пять ошибок, которые поломают твой констрейнт
- Слишком сложная схема. Если JSON содержит вложенные optional-поля, грамматика раздувается. Производительность падает. Лучше разбить на несколько последовательных генераций.
- Путаница с инструкцией. Даже с грамматикой модель может вставить лишний текст до или после JSON, если не указан стоп-токен. Используй
stopили обёртку изолированного режима. - Игнорирование системного промпта. В некоторых моделях грамматика работает только если системный промпт не содержит противоречий (например, «ответь человеческим языком»). Настрой промпт в ноль.
- Выбор несовместимой схемы. XGrammar не поддерживает union-типы (oneOf) версии Draft-07. В Outlines — только Pydantic v2. Всегда смотри документацию конкретной версии.
- Забыли про эскапинг. Если в строке может быть кавычка, грамматика должна явно это разрешать. Иначе — ошибка на этапе компиляции.
Мы подробнее разбирали падение SAE Steering на структурированном выводе в статье «SAE Steering сломал JSON: почему популярный метод от Anthropic разрушает структурированный вывод» — там наглядно, как внешние воздействия ломают грамматику.
Что выбрать для прода?
Если твоя система — микросервис на Python на своих GPU, бери Outlines 3.1: простота интеграции и Pydantic. Если нужно максимальное RPS и батчи, ставь VLLM 1.0 с XGrammar. Для локальных экспериментов на ноутбуке — llama.cpp с GBNF, благо сгенерировать грамматику можно утилитой json2grammar.
Из интересного: в 2026 году появилась техника семантического констрейнта, когда грамматика учитывает не только синтаксис JSON, но и допустимые значения полей (например, возраст от 0 до 120). Outlines 3.1 уже поддерживает Pydantic-валидаторы — модель не выдаст age=200. Это следующий уровень.
Кстати, если размышляешь, стоит ли переходить на ISON для экономии токенов, советую почитать «ISON против JSON: как выжать из LLM-контекста максимум» — там показано, что констрейнты для ISON работают так же, но на 70% короче.
Напоследок, неочевидный совет: не пытайся заставить модель генерировать JSON из промпта, где уже есть другие LLM-вызовы. Когда в префиксе есть «думающий» токен (как цепочка рассуждений), грамматика может конфликтовать. Лучше разделяй: сначала свободный ответ, потом из него экстракт через констрейнт. Или используй встроенный семантический пайплайн.