- BrainTools - https://www.braintools.ru -

Виды Structured Output и способы их реализации

Structured Output это способ “заставить” модель отвечать в строго заданном формате.

Пример. Имеется пачка неструктурированных объявлений о продаже недвижимости.

Продается однокомнатная квартира площадью 35,6 кв.м. на 11-м этаже 22-этажного монолитного дома по адресу: ул. Академика Королёва, 121. Год постройки — 2018, что гарантирует современное качество и надёжность конструкции. Дом оснащён подземной парковкой.

Квартира предлагает функциональную планировку: жилая площадь — 12 кв. м, просторная кухня — 17,6 кв. м. Высота потолков 3,15 м создаёт дополнительное ощущение пространства. Окна выходят на улицу, а комнаты изолированы, что обеспечивает комфорт и уединение.

И мы хотим с помощью LLM их перевести в структурированные и положить в базу данных:

{
  "Площадь": 35.6,
  "Этаж": 11,
  "Кол-во комнат": 1,
  "Адрес": "ул. Академика Королёва, 121"
}

Чтобы добиться этого, есть три основных подхода:

  1. Повторение (Instructor) [1]

  2. Исправление (BAML) [2]

  3. Ограничение (Outlines) [3]

1. Повторение (Instructor)

Самый известный представитель данного метода – библиотека Instructor [4].

Она работает как посредник между вашим приложением и LLM:

  • Вы описываете структуру правильного ответа (через библиотеку Pydantic).

  • Instructor отправляет запрос и получает ответ:

    • Если ответ проходит проверку на структуру, то возвращает его вам.

    • Если ответ не прошел валидацию, Instructor автоматически отправляет модели ошибку [5] и просит исправить. И так продолжается до тех пор, пока не будет получен правильный ответ или пока не закончатся попытки.

Виды Structured Output и способы их реализации - 1

Пример использования:

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.

  • Формируем и отправляем запрос.

Обратите внимание [6] на параметр max_retries – именно столько раз Instructor будет пытаться исправить ответ, если с первого раза он был неправильный.

Instructor поддерживает два основных режима работы:

  • TOOLS – в этом режиме вывод объявляется как функция (Function Calling) и модель вызывает ее, передавая ей параметры (поля описанные через Pydantic).

  • JSON – тут мы просим модель просто напечатать ответ в JSON и парсим его.

С таким подходом instructor может работать практически с любыми моделями, как локальными, так и по API. Даже если API не поддерживает Function Calling, Instructor всегда может попросить модель напечатать ответ в виде JSON.

Недостаток очевиден – такой подход может сожрать много токенов на попытки исправить ответ (особенно с мелкими моделями). И даже это не гарантирует результат.

instructor также унифицирует вызовы к разным API.

2. Исправление (BAML)

Данный метод пытается исправить основной недостаток предыдущего метода :)

BAML [7] это не просто библиотека а целый фреймворк. Он состоит из своего собственного языка разметки (похожего на 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 тестировать ваши шаблоны.

Виды Structured Output и способы их реализации - 2

В качестве проверки можете попросить в промте написать текст перед JSON. BAML это прекрасно переварит.

3. Ограничение (Outlines)

Виды Structured Output и способы их реализации - 3

По научному этот метод называется Constrained Decoding (ограниченное декодирование) – самый “надежный” метод. А самая популярная библиотека для реализации – Outlines [8].

Если два предыдущих способа “просят” модель написать правильно. То 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 [9]

Но и есть парочка лайфхаков как обойти эту проблему. Например, вам нужно вывести строгий JSON, который начинается с открывающей фигурной скобки. Но модель может лучше ответить, если ей сначала дать немного подумать (CoT). Что тут можно сделать:

1) Вы можете дать ей подумать в самом JSON’е. Для этого вначале JSON заводите специальное поле для ризонинга, а уже дальше формируете нужный вам ответ:

{
  "reasoning": string,
  "answer": string/number
}

2) Второй способ работает в два этапа. Сначала вы просто задаете модели вопрос и она отвечает как хочет. Затем вы подсовываете первый ответ во второй запрос и просите вытащить из него ответ и задаете строгий формат вывода.

Популярные библиотеки инференса, такие как vLLM и Sglang, сами встраивают в свои движки распространенные библиотеки для Structured Output. Поэтому ничего дополнительно импортировать и осваивать не нужно, достаточно воспользоваться их документацией.

Вместо вывода

Начать нужно как минимум с Outlines (и Constrained Decoding). Возможно интеллекта [10] вашей модели вполне хватит для решения ваших задач. Но если вы не можете залезть в мозги модели, то тогда переходите к Instructor. Если и он не справляется, то следующий кандидат – BAML. BAML несколько громоздкий для простых задач, его лучше использовать комплексно на больших проектах.


Мои курсы: Разработка LLM с нуля [11] | Алгоритмы Машинного обучения с нуля [12]

Автор: slivka_83

Источник [13]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/23428

URLs in this post:

[1] Повторение (Instructor): #1

[2] Исправление (BAML): #2

[3] Ограничение (Outlines): #3

[4] Instructor: https://github.com/567-labs/instructor/tree/main

[5] ошибку: http://www.braintools.ru/article/4192

[6] внимание: http://www.braintools.ru/article/7595

[7] BAML: https://docs.boundaryml.com/home

[8] Outlines: https://github.com/dottxt-ai/outlines

[9] https://acl-bg.org/proceedings/2025/RANLP%202025/pdf/2025.ranlp-1.124.pdf: https://acl-bg.org/proceedings/2025/RANLP%202025/pdf/2025.ranlp-1.124.pdf

[10] интеллекта: http://www.braintools.ru/article/7605

[11] Разработка LLM с нуля: https://stepik.org/a/231306/pay?promo=5e79340ae02bce0d

[12] Алгоритмы Машинного обучения с нуля: https://stepik.org/a/68260/pay?promo=b997c468b105096d

[13] Источник: https://habr.com/ru/articles/978534/?utm_source=habrahabr&utm_medium=rss&utm_campaign=978534

www.BrainTools.ru

Rambler's Top100