Constrained sampling для LLM: как получить строгий JSON в 2026 | AiManual
AiManual Logo Ai / Manual.
10 Июл 2026 Гайд

Как заставить LLM генерировать строгий JSON: обзор методов констрейнта (constrained sampling)

Глубокий обзор грамматических констрейнтов, Outlines, Guidance, XGrammar и других методов гарантированного получения валидного JSON от LLM. Код, сравнение, ошиб

Почему "пожалуйста" не работает, а грамматика — да?

Ты когда-нибудь писал в промпте Ответь ровно одним 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.5RegEx-маскиВысокая (C-расширения)Нет (ручные шаблоны)Нет
llama.cpp GBNFГрамматика (GBNF)Высокая (C++)Ручная записьНет
VLLM 1.0 + XGrammarDFA-маскиМаксимальная (CUDA + батчи)Да (через API)Да
LMQL 0.9Констрайнты на типахСредняя (Python-компиляция)ОграниченнаяНет

Пять ошибок, которые поломают твой констрейнт

  1. Слишком сложная схема. Если JSON содержит вложенные optional-поля, грамматика раздувается. Производительность падает. Лучше разбить на несколько последовательных генераций.
  2. Путаница с инструкцией. Даже с грамматикой модель может вставить лишний текст до или после JSON, если не указан стоп-токен. Используй stop или обёртку изолированного режима.
  3. Игнорирование системного промпта. В некоторых моделях грамматика работает только если системный промпт не содержит противоречий (например, «ответь человеческим языком»). Настрой промпт в ноль.
  4. Выбор несовместимой схемы. XGrammar не поддерживает union-типы (oneOf) версии Draft-07. В Outlines — только Pydantic v2. Всегда смотри документацию конкретной версии.
  5. Забыли про эскапинг. Если в строке может быть кавычка, грамматика должна явно это разрешать. Иначе — ошибка на этапе компиляции.

Мы подробнее разбирали падение 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-вызовы. Когда в префиксе есть «думающий» токен (как цепочка рассуждений), грамматика может конфликтовать. Лучше разделяй: сначала свободный ответ, потом из него экстракт через констрейнт. Или используй встроенный семантический пайплайн.

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