Structured Output это способ “заставить” модель отвечать в строго заданном формате.
Пример. Имеется пачка неструктурированных объявлений о продаже недвижимости.
И мы хотим с помощью LLM их перевести в структурированные и положить в базу данных:
{ "Площадь": 35.6, "Этаж": 11, "Кол-во комнат": 1, "Адрес": "ул. Академика Королёва, 121" }
Чтобы добиться этого, есть три основных подхода:
Повторение (Instructor)
Исправление (BAML)
Ограничение (Outlines)
Самый известный представитель данного метода - библиотека 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.
Недостаток очевиден - такой подход может сожрать много токенов на попытки исправить ответ (особенно с мелкими моделями). И даже это не гарантирует результат.
Данный метод пытается исправить основной недостаток предыдущего метода :)
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 тестировать ваши шаблоны.
По научному этот метод называется 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) Второй способ работает в два этапа. Сначала вы просто задаете модели вопрос и она отвечает как хочет. Затем вы подсовываете первый ответ во второй запрос и просите вытащить из него ответ и задаете строгий формат вывода.
Начать нужно как минимум с Outlines (и Constrained Decoding). Возможно интеллекта вашей модели вполне хватит для решения ваших задач. Но если вы не можете залезть в мозги модели, то тогда переходите к Instructor. Если и он не справляется, то следующий кандидат - BAML. BAML несколько громоздкий для простых задач, его лучше использовать комплексно на больших проектах.
Мои курсы: Разработка LLM с нуля | Алгоритмы Машинного обучения с нуля
Источник

