Попросите LLM сгенерировать JSON-объект, и рано или поздно он выдаст trailing comma, неэкранированный перевод строки внутри string, или bare word там, где должна быть quoted key. Эта ошибка — не баг в модели. Это следствие того, как работает autoregressive sampling: на каждом шаге модель видит каждый токен в своём vocabulary как кандидата, включая токены, которые сделали бы частичный вывод синтаксически invalid.

Это можно исправить, ограничив сам token vocabulary. Вместо того чтобы давать модели выбирать из тридцати двух тысяч токенов, дайте ей только подмножество, которое держит partial parse в валидном состоянии. Техника называется grammar-constrained decoding, и она превращает LLM из probabilistic text generator в syntax-aware code synthesizer.

Почему prompt engineering не может решить проблему валидности синтаксиса

Большинство разработчиков начинают с очевидного подхода: добавить “Output valid JSON only” в system prompt, повторить schema в user message, и надеяться. Это помогает, но не даёт гарантии.

Модель не parse-ит свой собственный вывод во время генерации. Она предсказывает следующий токен на основе probability distribution по vocabulary, скондиционированной на prompt и всё сгенерированное ранее. Токен вроде , может иметь высокую вероятность после закрывающей скобки, потому что запятые часто встречаются на этой позиции в обучающих данных. Является ли запятая синтаксически легальной в этой конкретной точке этого конкретного JSON-объекта — не часть decision process модели.

Даже fine-tuned модели с RLHF лишь сдвигают probability mass в сторону generally correct patterns. Они не устраняют invalid paths. Если вам нужна гарантия, а не высокая вероятность, нужно менять сам sampling mechanism.

Как работает grammar-constrained decoding

Ключевая идея проста: поддерживать parser state параллельно с генерацией LLM, и на каждой позиции токена маскировать все токены, которые перевели бы парсер в error state.

На шаге t модель выдаёт logits-вектор по всему vocabulary. Обычно вы применяете temperature scaling и делаете sampling. С constrained decoding вы сначала спрашиваете grammar engine: учитывая уже сгенерированные токены, какие токены легальны дальше? Engine возвращает bitmask по vocabulary. Вы устанавливаете logits illegal токенов в минус бесконечность, запускаете softmax по остатку, и сэмплируете из отфильтрованного распределения.

Grammar engine не обязана parse-ить полный вывод после факта. Она parse-ит инкрементально. После того как каждый токен принят, она обновляет свою внутреннюю state machine. Когда парсер достигает accepting state, генерация может остановиться. Когда он находится в non-accepting state, генерация продолжается.

Это означает, что модель всё ещё может свободно выбирать среди всех синтаксически валидных продолжений. Она сохраняет креативность внутри grammar. Она просто не может выйти за её пределы.

Как выглядит реализация

Вот упрощённый Python sketch с использованием lark, современной parsing library, для построения grammar mask. В продакшене вы использовали бы более быстрый engine вроде outlines, guidance или встроенную поддержку GBNF в llama.cpp, но принцип идентичен.

from lark import Lark, Token
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch

# Define a tiny grammar for a simple config DSL.
grammar = r"""
    start: pair+
    pair: KEY "=" VALUE
    KEY: /[a-z_]+/
    VALUE: /"[^"]*"/
"""

parser = Lark(grammar, parser="lalr")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")

def legal_next_tokens(partial_text: str) -> set[int]:
    """Return token IDs that do not cause a parse error."""
    legal = set()
    for token_id in range(tokenizer.vocab_size):
        candidate = tokenizer.decode([token_id], skip_special_tokens=True)
        try:
            # Try parsing partial_text + candidate as a prefix.
            parser.parse(partial_text + candidate)
            legal.add(token_id)
        except Exception:
            # Some exceptions are expected mid-parse.
            # A production engine tracks parser state, not exceptions.
            pass
    return legal

# Generate one token at a time with grammar masking.
prompt = "Generate a config: "
input_ids = tokenizer.encode(prompt, return_tensors="pt")
generated = input_ids.clone()

for _ in range(50):
    outputs = model(generated)
    logits = outputs.logits[:, -1, :]

    partial = tokenizer.decode(generated[0], skip_special_tokens=True)
    legal_ids = legal_next_tokens(partial)

    mask = torch.full_like(logits, float("-inf"))
    for tid in legal_ids:
        mask[0, tid] = logits[0, tid]

    next_token = torch.argmax(mask, dim=-1).unsqueeze(0)
    generated = torch.cat([generated, next_token], dim=-1)

    # Stop if the parser is in an accepting state.
    if parser.parse(partial):
        break

print(tokenizer.decode(generated[0], skip_special_tokens=True))

Это naive implementation. Реальная система не итерирует весь vocabulary на каждом шаге. Она строит finite-state automaton из grammar заранее и предвычисляет, какие vocabulary-токены валидны из каждого automaton state. Во время генерации mask — это один table lookup.

Где constrained decoding вписывается в bounded context

Bounded contexts в Domain-Driven Design определяются их ubiquitous language. Этот язык часто формализуется как маленькая DSL: query syntax, rule grammar, config format или expression language. Когда вы просите LLM генерировать или редактировать артефакты внутри этого контекста, вы хотите, чтобы он говорил на языке правильно.

Grammar constraints обеспечивают boundary. LLM не может выдумать fields, которых нет в вашей DSL, не может emit mismatched brackets и не может производить значения вне разрешённого enum set. Синтаксис становится compile-time guarantee, а не runtime prayer.

Это особенно ценно, когда вывод LLM напрямую поступает в парсер или интерпретатор. Если ваша DSL парсится strict recursive descent parser, один unexpected token прерывает весь pipeline. Constrained decoding устраняет этот failure mode.

Трейд-оффы, о которых стоит знать

Constrained decoding не бесплатен. Overhead зависит от того, как вы реализуете grammar engine.

Throughput. Построение mask на каждом forward pass добавляет latency. Быстрые engines вроде outlines или llama.cpp с GBNF предвычисляют token-to-state mappings, снижая стоимость на шаг примерно до 5-10% overhead. Naive реализации, которые re-parse на каждом кандидат-токене, могут замедлить генерацию на порядки.

Grammar expressiveness. Не всякий синтаксис легко выразить в grammar, которая чисто отображается на token masks. Контекстно-зависимые правила вроде “этот identifier должен быть объявлен ранее в scope” не захватываются стандартными context-free grammars. Синтаксис можно ограничить grammar. Семантику без дополнительной machinery ограничить нельзя.

Model compatibility. Некоторые inference APIs, особенно хостинговые cloud APIs, не экспонируют logits и не разрешают custom masking. API OpenAI предлагает JSON mode, который является hardcoded grammar constraint специально для JSON, но свою grammar принести нельзя. Для custom DSL обычно нужен local inference с vLLM, llama.cpp или аналогичным engine.

Partial token problems. Token boundary не всегда совпадает с grammar boundary. Grammar может ожидать quoted string, но следующий токен может быть "hel, за которым следует lo". Grammar engine должна рассуждать о partial token matches, а не только о целых токенах. Production libraries решают это, отображая токены на character prefixes и проверяя prefix validity против grammar.

Как реально реализовать это сегодня

Вам не нужно писать grammar engine с нуля. Несколько libraries берут на себя сложные части.

outlines — самый доступный для пользователей Python. Вы определяете Pydantic-модель или регулярное выражение, и он компилирует constraint в эффективный finite-state automaton, интегрирующийся с Hugging Face transformers и vLLM.

from outlines import models, generate

model = models.transformers("microsoft/Phi-3-mini-4k-instruct")
generator = generate.regex(model, r'\{[a-z_]+\}')
result = generator("Extract the key: ")

guidance от Microsoft предлагает более богатую систему templating. Вы чередуете grammar constraints с prompt templates, и он обрабатывает masking внутри себя.

llama.cpp поддерживает GBNF (GGML BNF), формат grammar, похожий на BNF. Вы передаёте .gbnf-файл во время inference, и engine принуждает его на уровне C++. Это самый быстрый вариант для local inference.

jsonformer и instructor — более лёгкие альтернативы специально для JSON. Они используют grammar-like constraints, но ограничены JSON schema. Если ваша bounded-context DSL случайно имеет форму JSON, они — самая простая точка входа.

Что constrained decoding не исправляет

Grammar-constrained LLM всё ещё может генерировать семантически неверный вывод. Он может emit валидный SQL query, ссылающийся на несуществующую таблицу, или валидный config, устанавливающий invalid port number. Синтаксис — необходимый guardrail, но не достаточный.

Вам всё ещё нужны validation layers под LLM. Parse вывод, сделайте type-check против вашей domain model, и reject или retry, если семантика неверна. Constrained decoding сужает failure modes с “syntax error” до “logic error”. Это большое улучшение, но не free pass.

Кроме того, grammar constraints не делают модель умнее. Если grammar слишком permissive, модель всё ещё может забрести в бессмысленный, но синтаксически валидный territory. Если grammar слишком restrictive, у модели нет валидного пути выразить правильный ответ, и вы получаете low-probability garbage или repetitive loops. Проектирование grammar — часть engineering-работы.

Начинайте с формата вывода, а не с модели

Прежде чем тюнить prompt или переходить на более крупную модель, спросите себя, проблема действительно в reasoning или в синтаксисе. Если LLM понимает, что вы хотите, но иногда форматирует это неверно, grammar constraints — правильное решение. Они дешевле fine-tuning, надёжнее prompt engineering, и дают гарантию, которую sampling сам по себе не может.

Определите grammar для вашей bounded-context DSL, подключите её к constrained decoder, и позвольте модели генерировать в рамках. Вывод всё ещё будет иногда удивлять вас, но никогда не будет syntax error.

FAQ

Что такое grammar-constrained decoding?

Grammar-constrained decoding — это техника, которая фильтрует vocabulary LLM на каждом шаге генерации с помощью формальной grammar. Учитываются только токены, сохраняющие частичный вывод синтаксически валидным, гарантируя, что итоговый вывод соответствует grammar.

Снижает ли constrained decoding качество вывода?

Нет для задач, связанных с синтаксисом. Модель всё ещё свободно выбирает среди всех грамматически валидных продолжений. Для открытого творческого письма constraints ухудшили бы качество. Для кода, config или генерации DSL они улучшают и correctness, и reliability.

Могу ли я использовать это с API OpenAI или Claude?

OpenAI предлагает JSON mode, который является built-in grammar constraint специально для JSON. Свою grammar принести нельзя. Anthropic в настоящее время не экспонирует grammar constraints. Для custom DSL обычно нужен local inference с vLLM, llama.cpp или аналогичным engine.

Какие форматы grammar поддерживаются?

Распространённые форматы включают EBNF, BNF, PEG и GBNF. Libraries вроде outlines принимают регулярные выражения и Pydantic-модели. llama.cpp использует GBNF. Выбирайте формат, который поддерживает ваша inference engine.