
Привет. Меня зовут Андрей Соколов, я руководитель команды LLM в AI VK, которая обучает и дорабатывает модели, а потом помогает другим командам интегрировать их в свои продукты. И сегодня я расскажу про такое направление в LLM, как структурированная генерация.
Что такое большие языковые модели?
Если вы не следили за всей этой нейросетевой суетой в последние годы, и не знаете, что такое большие языковые модели, то кратко объясню.

LLM — это очень большая нейросеть, которую мы долго и тщательно обучаем на огромном количестве данных. Мы показываем LLM примеры решения разных задач: резюмируй текст, переведи его, реши математическую задачу, напиши код и др.
Обычно для взаимодействия с моделью пользователь пишет промпт — текстовый запрос на естественном языке. То есть своими словами. В запросе может быть какой-то вопрос, или просьба сгенерировать какой-то текст, решить задачу или написать код.
Промпт:
Ты - детектор интенций и слотов голосового помощника. Твоя задача выделить из запроса пользователя его интенцию и соответствующие слоты. Результат верни в json формате.
Список доступных интенций и их слотов:
set_timer (time, volume, ringtone)
weather_forecast (date, time, location)
question_answering (question)
play_audio (audio_name)
chatter (message)
Входные данные:
Запрос пользователя:
че у нас по погодке в спб послезавтра
Ответ модели:
{
"intention": "weather_forecast","slots": {
"date": "послезавтра",
"time": "",
"location": "Санкт-Петербург"
}
}
Как модель формулирует удобочитаемые ответы?
Любая нейросеть состоит из множества слоев, финальный из которых — «out‑to‑vocabulary». Он позволяет понять, с какой вероятностью мы можем выбрать то или иное слово из нашего словаря. В словаре хранятся токены — не целые слова, а их фрагменты, символы пунктуации, разделители и прочие текстовые элементы. Другими словами, в конце цепочки вычислений создаётся пространство вероятностей, на основе которых из словаря выбираются токены, которые лучше всего, по мнению модели, подходят для ответа на промпт.

Какие задачи решают LLM?
LLM умеют отвечать на вопросы, объяснять различные термины, генерировать тексты на заданную тему. Также они могут решать задачи классификации, генерировать программный код.

Галлюцинирование модели
Однако при использовании LLM есть подводные камни. Мы ожидаем, что языковая модель вернёт нам ответ в конкретном формате. Иногда так и получается: мы просим сгенерировать JSON и получаем в ответ JSON; просим придумать N классов и получаем в ответ N классов. Но так бывает не всегда. В таких случаях говорят, что модель галлюцинирует. Например, сгенерированный JSON не получится распарсить, потому что в нём может не хватать какой‑нибудь запятой или кавычки.
Как быть в подобных ситуациях? Мириться с ними?
Нет. На помощь приходит структурированная генерация.
Структурированная генерация
Этот термин описывает возможность задать модели ожидаемый формат вывода. Например, можно попросить её генерировать ответы, которые будут проверяться определёнными регулярными выражениями:
/\"result\": \"(ok|toxic)\"/
Здесь мы хотим, чтобы при классификации модель всегда генерировала ключ result, ставила двоеточие и в кавычках генерировала либо ok
, либо toxic
.
Другой пример: можно указать, чтобы модель сгенерировал JSON, который удобно парсить. Или описать dataclass в формате Python:
... "Name": {"enum": ["John", "Paul"],"title": "Name", "type": "string"} ...
Это можно сконвертить в схему JSON и передать модели, чтобы она понимала, что именно и в каком формате нужно генерировать.
Более сложные решения: попросить отвечать кодом на формальных языках (SQL, Python, Java, C++ и др.), при этом чтобы код был корректный и удобочитаемый.
Добиться структурированной генерации можно разными способами.
Способ 1. Инструкции с форматами
Наименее надёжный способ. Если в ответ модель галлюцинирует — код не парсится, — то можно повторить запрос.
Ты - детектор интенций и слотов голосового помощника "Алиса". Твоя задача выделить из запроса пользователя его интенцию и соответствующие слоты. Результат верни в json формате.
Список доступных интенций и их слотов:
set_timer (time, volume, ringtone)
weather_forecast (date, time, location)
question_answering (question)
play_audio (audio_name)
chatter (message)
Однако этот подход не работает при детерминированной генерации. Если мы выбрали самую вероятную последовательность и она не распарсилась, то при перегенерировании самая вероятная последовательность будет точно такой же и снова не распарсится. Шанс на удачный ответ есть только при сэмплировании.
Способ 2. Две последовательные инструкции
Этот способ немного надёжнее. Сначала просим сгенерировать ответ в удобном для модели формате:
Ты — детектор интенций и слотов голосового помощника. Твоя задача выделить из запроса пользователя его интенцию и соответствующие слоты в удобном для тебя формате.
После этого отдельным промптом просим конвертировать полученный ответ в нужный нам формат, например, JSON:
Отформатируй предыдущий ответ со слотами и интенциями. Результат верни в json формате.
Список доступных интенций и их слотов:
set_timer (time, volume, ringtone)
weather_forecast (date, time, location)
question_answering (question)
play_audio (audio_name)
chatter (message)
Вероятность получить корректный ответ стала выше. Но выросли и трудозатраты, и потребление ресурсов: модель выполняет два запроса вместо одного. При этом мы всё‑равно не застрахованы от галлюцинаций.
Способ 3. Ограничение сэмплирования
Это самый совершенный способ на сегодняшний день, так работает большинство структурных генераций. Выше я упоминал про сэмплирование токенов из словаря на основе распределения вероятностей. Мы можем ограничивать это распределение. Например, запретить сэмплировать токены, которые начинаются с кавычки. Для этого нужно применить к ним маску и выбирать только из того, что нам подходит больше всего.
Формализовать процесс ограниченного сэмплирования помогают конечные автоматы. Допустим, мы хотим получить JSON, в котором есть два ключа:
name, принимающий значение
John
илиPaul
;age, принимающий значение 20 или 30.
{"name": "John", "age": 20} или {"name": "Paul", "age": 30}
Пример искусственный, и для такого кода избыточно использовать LLM, но так проще объяснить концепцию ограниченного сэмплирования.
Любой JSON-код можно описать в виде регулярного выражения:
/\{\"name\":(\"John\"|\"Paul\"),\"age\":(20|30)\}/
Если код большой или абстрактный, то превратить его в регулярное выражение сложно, но всё же возможно.
И тут можно вспомнить, что любое регулярное выражение — это конечный автомат: сущность, у которого есть начальное и промежуточные состояния, и есть переходы между ними (символы на иллюстрации ниже).

Указанные символы считаются корректными при каждом из переходов. Также у конечного автомата есть терминальные состояния, в которых заканчивается сопоставление.
Но как всё это преобразовать в токены? Ведь у нас они и не символы, и не слова, а нечто среднее. Рассмотрим простой подход. В каждом состоянии проверяем, какой из токенов словаря больше нам подходит в текущий момент. Накладываем маску, оставляем только подходящий нам токен и сэмплируем. После этого идём дальше по автомату и записываем токен в генерируемую последовательность.
Однако этот подход работает медленно, потому что в каждом состоянии нужно линейно проходить по словарю и выполнять много проверок. Чтобы ускорить работу, можно для каждого состояния заранее вычислить допустимые токены, скомпилировать индекс и выбирать из него. Например, в нулевом состоянии корректной будет фигурная скобка или фигурная скобка с кавычками. Сэмплировать будем только из этих двух токенов. Затем последовательно выполняем всё то же самое, пока не дойдём до развилки. Так работает библиотека Outlines, которую вы можете использовать для генерации.

Но у подхода есть недостатки. Если регулярное выражение сложное, индекс может очень сильно разрастись. Особенно в тех случаях, когда в выражении стоит точка, которая означает любой произвольный символ. А если после неё плюс или звёздочка, последовательность удлиняется катастрофически. В таком случае во всех состояниях может быть слишком много подходящих токенов, и из‑за этого генерация будет очень медленной.
Можно ли переделать конечный автомат так, чтобы он работал поверх токенов? Да. За подробностями отправляю вас к этой статье. Вкратце идея такова: можно воспользоваться трансдуктором — разновидностью конечного автомата, который позволяет кроме сопоставления отображать токены в символы в выходных данных.
Допустим, есть такое регулярное выражение:
/(foo)+d/
Оно сопоставляет слово food
, в котором foo
может последовательно повторяться сколько угодно раз. С точки зрения символов конечный автомат выглядит так:

А если конвертировать автомат в пространство токенов, то схема изменится:

Здесь тоже возникают проблемы с точками и звёздочками, тоже сильно разрастаются словари, но генерирование проходит гораздо быстрее.
Как это работает на примере outlines
Для применения этого подхода не нужно разбираться в теории конечных автоматов и углубляться в математику. Если вам нужна структурная генерация, достаточно взять Outlines или Guidance. Есть несколько библиотек, которые позволяют это делать.
prompt = "What is the IP address of the Google DNS servers? "
unguided = generate.continuation(model, max_tokens=30)(prompt)
guided = generate.regex(
model,
r"((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4⌋]\d|[01]?\d\d?)",
max_tokens=30
)(prompt)
В этом примере мы описываем промпт, выполняем последовательную генераций без каких-либо ограничений и с ограничением через regex. Регулярное выражение ищет корректные IP-адреса. Мы спрашиваем про IP-адрес DNS-сервера Google.
При использовании неструктурированной unguided-генерации мы, скорее всего, получим в ответ странный текст, а не адрес.
print(unguided)
# What is the IP address of the Google DNS servers?
# IP adress is a unique identification ...
А при использовании guided-генерации ответ будет корректный, хотя и ошибочный: 2.2.6.1 вместо 8.8.8.8 или 8.8.4.4.
print(guided)
# What is the IP address of the Google DNS servers?
# 2.2.6.1
Способ 4. Структурная генерация*
Этот способ называется Compressed Prefill. Он хорошо работает в случаях, когда нужно обработать очень большие JSON с многочисленными ключами.

Нам не нужно генерировать промпты и ключи, их определил пользователь. Он задал нам формат. Нам нужно сгенерировать только значения внутри кавычек. Тогда можно уменьшить размер конечных автоматов. Выделенные зелёным цветом строки можно посчитать через prefill и положить эти состояния в кеш.
Допустим, при движении по такому конечному автомату сэмплирован токен, который начинается на G. Мы понимаем, что других сущностей на G у нас нет, поэтому двигаться дальше не имеет смысла и нужно подставить значение Gryffindor. На этом мы останавливаемся и продолжаем генерацию следующего ключа. Благодаря этой идее в названии способа появилось слово compressed.
Работа с формальными языками программирования
Регулярные выражения конечного автомата не позволяют генерировать программный код. Для этого нужны контекстно‑свободные грамматики. Можно использовать автоматы с магазинной памятью (чтобы смотреть, что происходило в предыдущих состояниях) или парсеры LALR(1), LR(1).
Принцип работы такой же, как в случае с «обычными» конечными автоматами. Отличие лишь в том, что возникают нюансы на на морфологических стыках грамматических сущностей.
Также можно генерировать программный код с помощью Outlines или SynCode. Получается достаточно качественно.
Влияние структурированной генерации
Базовые способы структурированной генерации незначительно снижают скорость работы, потому что нужно время на компиляцию индекса и расчёт масок. Однако в случае Chunked Prefill (способ 4) скорость генерирования JSON возрастает в 2–3 раза.

Кроме того, последний способ структурированной генерации сильно влияет на качество.
Допустим, мы хотим сгенерировать две сущности: вызов функций foo
и bar
. Foo
будем вызывать со значением 123
, bar
— со значением 567
. Представим, что есть четыре способа токенизации последовательности:

Токенизатор может объединять то, что должно быть раздельным. В первом и последнем случае он объединил комбинации символов: o(1
и r(4
. Эти комбинации нельзя распарсить. Нужно разделить их, чтобы понять, где название функции, где скобка, а где значение. Исходя из грамматики, нам подходит только второй и третий вариант. Но если оценивать на основе вероятности, то LLM может выбрать первый вариант как самый вероятный с точки зрения перплексии. Если же первый вариант отбросить, модель будет выбирать между вторым и третьим, где bar
оценивается выше, чем foo
. Какой ответ правильный? И не сломана ли у нас генерация в этом случае?
Кроме того, от токенизации зависит и вероятностное распределение токенов:

При проходе по линейным последовательностям ключей мы можем сэмплировать по‑разному: собрать name собрать из целого токена или из токенов na
и me
. То же самое можно сказать про токены John
и Paul
. Мы не можем быть уверены в качестве генерирования, не ухудшится ли оно в зависимости от выбранного пути прохождения автомата.
Есть исследовательские работы, авторы которых измеряли качество генерации в разных задачах. Например, исследователи из Outlines утверждают, что структурированная генерация улучшает качество. Они использовали для измерений математические задачи из бенчмарка GSM8K. В этой задаче ответом всегда является число, поэтому структурированная генерация ограничивает нечисловые значения и результат получается лучше.

Однако есть другие работы, согласно которым добавление ограничений в виде генерации JSON или YAML снижает качество.

Что, где и когда использовать?
Из всего сказанного можно сделать простой вывод: пока не попробуете, не узнаете, как структурированная генерация повлияет на решение вашей задачи.
Обязательно следите за количеством ошибок при парсинге. Если часто не можете распарсить ответ LLM, это повод задуматься о структурированной генерации. Может быть так, что ваша LLM достаточно «умная», или формат очень простой, и тогда ошибок почти не будет.
Обязательно сравнивайте качество при guided‑ и unguided‑генерации. Иногда механизм guided может снижать качество, иногда улучшать, поэтому будьте осторожны. Если качество падает и доля ошибок высока, то нужно либо менять нейросеть, либо настроить текущую.
Наконец, используйте грамматику при генерировании кода. Обычно это повышает качество, потому что вы сможете парсить код и корректных случаев будет гораздо больше.
Комментарии (5)
vmkazakoff
02.06.2025 08:24Стойте. Но ведь в формате ОпенАИ (и всех совместимых БЯМ, а их большинство) уже есть инструмент для этого. Называется function calling.
Для примера мы хотим получить имя и возраст пользователя из свободного текста типа "меня зовут vmkazakoff, мне 40 лет и я мамкин программист", выбрав только нужное и убрав все лишнее. Причем в json.
Делаем примерно так:
tools = [ { "type": "function", "function": { "name": "extract_user_info", "description": "Извлекает имя и возраст пользователя из текста", "parameters": { "type": "object", "properties": { "name": { "type": "string", "description": "Имя пользователя" }, "age": { "type": "number", "description": "Возраст пользователя" } }, "required": ["name", "age"] } } } ]
И затем вызываем
response = client.chat.completions.create( model="gpt-4", messages=[{"role": "user", "content": "Меня зовут Иван, мне 30 лет"}], tools=tools, tool_choice={"type": "function", "function": {"name": "extract_user_info"}} )
В ответ всегда будет только валидный json.
Код писал дипсик, с телефона не проверял, но выглядит верно.
podvox23
02.06.2025 08:24Свежая разработка от Guidance https://github.com/guidance-ai/llguidance на rust
Заявлена высокая скорость. Поддержка Lark грамматики и тд
Разработчики OpenAI API недавно добавили в structured outputs. Появилась поддержка параллельного function calling в strict режиме
poriogam
Полезная библиотека для питона позволяет исправлять не совсем корректные джейсоны от ллм https://github.com/mangiucugna/json_repair/