Structured Output это способ “заставить” модель отвечать в строго заданном формате.
Пример. Имеется пачка неструктурированных объявлений о продаже недвижимости.
Продается однокомнатная квартира площадью 35,6 кв.м. на 11-м этаже 22-этажного монолитного дома по адресу: ул. Академика Королёва, 121. Год постройки — 2018, что гарантирует современное качество и надёжность конструкции. Дом оснащён подземной парковкой.
Квартира предлагает функциональную планировку: жилая площадь — 12 кв. м, просторная кухня — 17,6 кв. м. Высота потолков 3,15 м создаёт дополнительное ощущение пространства. Окна выходят на улицу, а комнаты изолированы, что обеспечивает комфорт и уединение.
И мы хотим с помощью LLM их перевести в структурированные и положить в базу данных:
{
"Площадь": 35.6,
"Этаж": 11,
"Кол-во комнат": 1,
"Адрес": "ул. Академика Королёва, 121"
}
Чтобы добиться этого, есть три основных подхода:
1. Повторение (Instructor)
Самый известный представитель данного метода – библиотека Instructor.
Она работает как посредник между вашим приложением и LLM:
-
Вы описываете структуру правильного ответа (через библиотеку Pydantic).
-
Instructor отправляет запрос и получает ответ:
-
Если ответ проходит проверку на структуру, то возвращает его вам.
-
Если ответ не прошел валидацию, Instructor автоматически отправляет модели ошибку и просит исправить. И так продолжается до тех пор, пока не будет получен правильный ответ или пока не закончатся попытки.
-

Пример использования:
import instructor
from openai import OpenAI
from pydantic import BaseModel, Field, field_validator
# Подключаем LLM
client = instructor.from_openai(
OpenAI(base_url="http://192.168.0.108:8000/v1", api_key="any"),
mode=instructor.Mode.TOOLS,
mode=instructor.Mode.MD_JSON
)
# Определяем структуру данных, которую хотим получить
class UserInfo(BaseModel):
name: str = Field(..., description="Имя пользователя")
age: int = Field(..., description="Возраст пользователя")
skills: list[str] = Field(..., description="Список профессиональных навыков")
@field_validator('age')
def validate_age(cls, v):
if v < 0:
raise ValueError('Age must be positive')
return v
# Отправляем запрос
result = client.chat.completions.create(
model="qwen3",
response_model=UserInfo,
messages=[
{
"role": "user",
"content": "Меня зовут Иван, мне 28 лет. Я эксперт в Python, Docker и Kubernetes."
}
],
max_retries = 3
)
print(result.model_dump_json(indent=3))
Здесь мы:
-
Подключаемся к LLM.
-
Описываем нужный формат ответа с помощью библиотеки Pydantic.
-
Формируем и отправляем запрос.
Обратите внимание на параметр max_retries – именно столько раз Instructor будет пытаться исправить ответ, если с первого раза он был неправильный.
Instructor поддерживает два основных режима работы:
-
TOOLS – в этом режиме вывод объявляется как функция (Function Calling) и модель вызывает ее, передавая ей параметры (поля описанные через Pydantic).
-
JSON – тут мы просим модель просто напечатать ответ в JSON и парсим его.
С таким подходом instructor может работать практически с любыми моделями, как локальными, так и по API. Даже если API не поддерживает Function Calling, Instructor всегда может попросить модель напечатать ответ в виде JSON.
Недостаток очевиден – такой подход может сожрать много токенов на попытки исправить ответ (особенно с мелкими моделями). И даже это не гарантирует результат.
instructor также унифицирует вызовы к разным API.
2. Исправление (BAML)
Данный метод пытается исправить основной недостаток предыдущего метода :)
BAML это не просто библиотека а целый фреймворк. Он состоит из своего собственного языка разметки (похожего на TS/Jinja), а также имеет свой “мягкий” парсер, который чинит сломанный JSON.
Работает он несколько сложнее, чем предыдущий метод:
1) Сначала инициируем новый проект: baml-cli init
2) BAML создаст три файла (которые вам нужно заполнить/доработать):
//baml_test/baml_src/clients.baml
client<llm> Qwen3 {
provider "openai-generic"
options {
base_url "http://192.168.0.108:8000/v1"
api_key "any"
model "qwen3"
}
}
//baml_test/baml_src/generators.baml
generator target {
output_type "python/pydantic"
output_dir "../"
version "0.214.0"
default_client_mode sync
}
//baml_test/baml_src/resume.baml
class Resume {
name string
email string
experience string[]
skills string[]
}
function ExtractResume(resume: string) -> Resume {
client "Qwen3"
prompt #"
Extract from this content:
{{ resume }}
{{ ctx.output_format }}
"#
}
Для чего они:
-
В clients.baml вы описываете как подключаться к LLM.
-
В generators.baml вы описываете как “компилировать” ваш проект.
-
В resume.baml (называться может как угодно – в данном примере мы парсим резюме поэтому и resume) мы объявляем функцию ExtractResume, в которой описываем:
-
Структуру правильного ответа
-
И функцию, которая объединяет: LLM-клиента, формат вывода и промт.
-
3) Затем в терминале запускаем baml-cli generate и BAML создаст в папке baml_client типизированный клиент (кучу py-файлов), который вы сможете запускать в своем коде:
import baml_client as client
raw_resume = 'Иван Петров. 10 лет. Кодил 20 лет. C#'
answer = client.b.ExtractResume(raw_resume)
Такой подход позволяет использовать даже мелкие модели. Почти любая LLM способна написать JSON. Но чем мельче модель, тем больше вероятность ошибки. А BAML аккуратно нивелирует этот недостаток, не тратя ни время ни токены.
Новый JSON он новый конечно не напишет, но вот мелкие типовые ошибки вполне исправит:
-
Забытые закрывающие скобки
-
Висячие запятые
-
Неверно экранированные символы
-
Лишние комментарии
-
Текст перед или после JSON
-
И т.д.
Из минусов: нужно учить новый синтаксис (DSL). Плюс требуется этап “компиляции” кода.
Работает с любыми API и локальными моделями.
Также есть удобный аддон для VS Code, в котором вы можете без запуска LLM тестировать ваши шаблоны.

В качестве проверки можете попросить в промте написать текст перед JSON. BAML это прекрасно переварит.
3. Ограничение (Outlines)

По научному этот метод называется Constrained Decoding (ограниченное декодирование) – самый “надежный” метод. А самая популярная библиотека для реализации – Outlines.
Если два предыдущих способа “просят” модель написать правильно. То Constrained Decoding ничего не просит, а заставляет модель выводить строго то, что нужно. Как это работает:
-
Вы описываете структуру правильного ответа (разными способами).
-
LLM работают итеративно. На каждом шаге, выдавая по одному токену за раз. А выбирают они эти токены из огромного словаря. И на каждом шаге LLM расставляет всем токенам вероятности появления. И чем выше вероятность, тем выше шанс, что LLM выберет этот токен. А Outlines на каждом шаге “маскирует” (обнуляет вероятности) всех токенов, которые нарушили бы схему. И модели остается выбор только из допустимых токенов.
Например, если ваша схема требует {"name": string}, то:
-
На первом шаге занулит все токены кроме открывающей фигурной скобки.
-
В последующих шагах разрешено будет написать только “name”.
-
И т.д.
А в коде это выглядит так:
from pydantic import BaseModel
from typing import Literal
from openai import OpenAI
import outlines
openai_client = OpenAI(base_url="http://192.168.0.108:8000/v1", api_key="any")
model = outlines.from_vllm(openai_client, "qwen3")
class Customer(BaseModel):
name: str
urgency: Literal["high", "medium", "low"]
issue: str
customer = model(
"Alice needs help with login issues ASAP",
Customer)
print(customer)
Данный метод работает на уровне логитов. А значит до этих логитов надо как-то добраться. Если библиотекой инференса является transformers, то Outlines напрямую доберется до логитов и занулит их. Если Outlines работает с API, то воспользуется их возможностями. Например, для vLLM через параметр extra_body.
Outlines поддерживает множество движков инференса: OpenAI, Ollama, vLLM, LlamaCpp, Transformers. А формат вывода может описываться разными способами: Regex, JSON Schema, Context-Free Grammar.
Плюсы: 100% гарантия соответствия схеме вывода (причем с первой попытки). Что идеально для небольших локальных моделей, так как не требует от модели быть “умной”, чтобы соблюдать формат.
Минусы: не всегда можно задействовать при работе с API (SO может просто не поддерживаться).
А теперь серьезная ложка дёгтя: есть исследования, которые показывают, что жесткое декодирование делает модель тупее :) Пример одного из последних: https://acl-bg.org/proceedings/2025/RANLP%202025/pdf/2025.ranlp-1.124.pdf
Но и есть парочка лайфхаков как обойти эту проблему. Например, вам нужно вывести строгий JSON, который начинается с открывающей фигурной скобки. Но модель может лучше ответить, если ей сначала дать немного подумать (CoT). Что тут можно сделать:
1) Вы можете дать ей подумать в самом JSON’е. Для этого вначале JSON заводите специальное поле для ризонинга, а уже дальше формируете нужный вам ответ:
{
"reasoning": string,
"answer": string/number
}
2) Второй способ работает в два этапа. Сначала вы просто задаете модели вопрос и она отвечает как хочет. Затем вы подсовываете первый ответ во второй запрос и просите вытащить из него ответ и задаете строгий формат вывода.
Популярные библиотеки инференса, такие как vLLM и Sglang, сами встраивают в свои движки распространенные библиотеки для Structured Output. Поэтому ничего дополнительно импортировать и осваивать не нужно, достаточно воспользоваться их документацией.
Вместо вывода
Начать нужно как минимум с Outlines (и Constrained Decoding). Возможно интеллекта вашей модели вполне хватит для решения ваших задач. Но если вы не можете залезть в мозги модели, то тогда переходите к Instructor. Если и он не справляется, то следующий кандидат – BAML. BAML несколько громоздкий для простых задач, его лучше использовать комплексно на больших проектах.
Мои курсы: Разработка LLM с нуля | Алгоритмы Машинного обучения с нуля
Автор: slivka_83


