Structured Outputs для LLM: методы, инструменты, JSON Schema, Pydantic | AiManual
AiManual Logo Ai / Manual.
17 Янв 2026 Гайд

LLM Structured Outputs: Когда JSON — это не опция, а требование

Полное руководство по получению структурированных данных из LLM: JSON Schema, Pydantic, Outlines, ограниченная генерация и парсинг. Методы для production.

Почему «просто попросить JSON» — это путь в ад

Запускаете LLM в продакшене? Отправляете запрос «верни данные в JSON» и надеетесь? Поздравляю, вы только что подписались на ночные дежурства. Модель возвращает строку вместо числа. Добавляет лишние поля. Форматирует дату как «вчера». JSON ломается на первом же символе новой строки в текстовом поле. Это не баг — это фундаментальная проблема вероятностной генерации.

Структурированный вывод — это не про удобство. Это про гарантии. Без него ваша цепочка обработки данных превращается в хрупкую стеклянную башню.

Метод 1: Наивный парсинг (или как сломать всё быстро)

Самый простой способ получить структуру — попросить и распарсить. Работает ровно до первого edge-case.

# КАК НЕ НАДО ДЕЛАТЬ
import json
import re

prompt = "Сгенерируй пользователя в JSON: имя, возраст, email"
response = llm.generate(prompt)  # "Конечно! Вот JSON: {\"name\": \"Иван\", \"age\": \"двадцать пять\", \"email\": \"ivan@test.com\nлучший email\"}"

# Попытка найти JSON регуляркой (смешно)
json_match = re.search(r'\{.*\}', response, re.DOTALL)
if json_match:
    data = json.loads(json_match.group())  # JSONDecodeError: Expecting property name enclosed in double quotes
💡
Проблема не в том, что LLM «глупая». Проблема в том, что она генерирует текст, а не данные. Между этими концепциями — пропасть, которую нужно форсировать технически.

Метод 2: JSON Schema + Function Calling

OpenAI, Anthropic и другие провайдеры дали нам инструмент — вызов функций. По сути, это способ сказать модели: «Вот схема данных, которые мне нужны. Сгенерируй под неё аргументы».

# Пример с OpenAI
from openai import OpenAI
import json

client = OpenAI()

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "age": {"type": "integer", "minimum": 0},
        "email": {"type": "string", "format": "email"},
        "interests": {
            "type": "array",
            "items": {"type": "string"},
            "minItems": 1
        }
    },
    "required": ["name", "age", "email"]
}

response = client.chat.completions.create(
    model="gpt-4",
    messages=[{"role": "user", "content": "Опиши пользователя-разработчика"}],
    functions=[{
        "name": "extract_user_data",
        "description": "Извлечь данные пользователя",
        "parameters": schema
    }],
    function_call={"name": "extract_user_data"}
)

if response.choices[0].message.function_call:
    arguments = json.loads(response.choices[0].message.function_call.arguments)
    print(arguments["age"])  # Гарантированно integer
    # Но формат email не проверяется! Модель может написать "ivan(at)test.com"

JSON Schema работает, но с оговорками: модель следует схеме структурно, но не семантически. Она не валидирует формат email — просто генерирует строку, которая выглядит как email.

1Когда использовать JSON Schema

  • Работа с облачными API (OpenAI, Anthropic, Google)
  • Простая структура без сложных валидаций
  • Когда можно допустить постобработку (очистка данных)

2Когда избегать

  • Требуется строгая валидация форматов (даты, email, URL)
  • Работа с локальными моделями без поддержки function calling
  • Критическая важность соответствия схеме (финансовые данные)

Метод 3: Pydantic + промпт-инженерия

Pydantic — библиотека валидации данных, которая стала стандартом де-факто в Python. Сочетаем её с умными промптами.

from pydantic import BaseModel, EmailStr, conint
from typing import List
import json

class User(BaseModel):
    name: str
    age: conint(ge=0, le=120)  # Ограничения: 0-120
    email: EmailStr  # Валидация формата email!
    interests: List[str]
    is_developer: bool = True  # Значение по умолчанию

# Генерируем промпт с примером
system_prompt = f"""Ты должен вернуть данные в точном JSON формате.
Пример корректного ответа:
{json.dumps({"name": "Анна", "age": 30, "email": "anna@example.com", "interests": ["Python", "ML"], "is_developer": true}, ensure_ascii=False)}

Схема:
- name: строка
- age: целое число от 0 до 120
- email: валидный email
- interests: массив строк
- is_developer: boolean (true/false)
"""

response_text = llm.generate(system_prompt + "\nОпиши пользователя")

try:
    # Пытаемся найти и распарсить JSON
    json_str = extract_json(response_text)  # Ваша функция для извлечения JSON
    user = User(**json.loads(json_str))
    print(user.email)  # Гарантированно валидный EmailStr
except Exception as e:
    # Fallback: просим модель исправить
    correction_prompt = f"""Твой ответ не прошел валидацию: {str(e)}
Исправь JSON:
{response_text}"""
    # Отправляем на повторную генерацию...

Преимущество Pydantic — строгая валидация после получения данных. Недостаток — всё ещё вероятностная генерация. Модель может сгенерировать невалидный JSON, и вам придется обрабатывать ошибки.

Метод 4: Ограниченная генерация (Constrained Decoding)

Здесь начинается магия. Вместо того чтобы просить модель сгенерировать JSON и надеяться, мы заставляем её следовать схеме на уровне токенов. Библиотека Outlines — главный инструмент в этом арсенале.

# Установка: pip install outlines
import outlines
import outlines.models as models
import outlines.text.generate as generate
from pydantic import BaseModel

# Загружаем модель (поддерживает множество локальных моделей)
model = models.transformers("mistralai/Mistral-7B-Instruct-v0.2")

# Определяем структуру через Pydantic
class User(BaseModel):
    name: str
    age: int
    email: str

# Создаем генератор, который гарантирует JSON структуру
generator = generate.json(model, User)

# Генерируем — Outlines контролирует процесс токенизации
result = generator("Опиши пользователя-разработчика")

print(result)
# {"name": "Алексей", "age": 28, "email": "alexey@dev.com"}
# Гарантированно валидный JSON, соответствующий схеме
💡
Outlines не просит модель «пожалуйста, верни JSON». Он ограничивает пространство возможных следующих токенов только теми, которые ведут к валидному JSON согласно схеме. Это как рельсы для генерации.

Как работает ограниченная генерация

  1. Модель генерирует первый токен — должен быть { (начало JSON объекта)
  2. Следующий токен — либо закрывающая скобка } (пустой объект), либо открывающая кавычка для имени поля
  3. Если началось имя поля, система проверяет его соответствие схеме Pydantic
  4. После двоеточия разрешены только токены, соответствующие типу поля (строки, числа и т.д.)
  5. Процесс продолжается, пока не сгенерируется синтаксически корректный JSON

Outlines не гарантирует семантическую корректность (модель всё ещё может написать «возраст: -10»), но гарантирует структурную. Это огромный шаг вперёд.

Метод 5: Грамматики и конечные автоматы

Для самых сложных случаев, когда нужен не просто JSON, а специфический формат (SQL, арифметические выражения, DSL), используют формальные грамматики. Outlines поддерживает и это.

import outlines.models as models
import outlines.text.generate as generate

model = models.transformers("mistralai/Mistral-7B-Instruct-v0.2")

# Определяем грамматику для простого арифметического выражения
grammar = """
root ::= expression
expression ::= term ("+" term | "-" term)*
term ::= factor ("*" factor | "/" factor)*
factor ::= number | "(" expression ")"
number ::= [0-9]+
"""

generator = generate.cfg(model, grammar)
result = generator("Вычисли выражение: ")

print(result)  # Гарантированно корректное выражение типа "(12+3)*4"

Этот подход особенно полезен для DOM-пранинга или генерации SQL-запросов, где синтаксис должен быть безупречным.

Сравнение методов: что когда выбирать

МетодГарантииПроизводительностьСложностьИдеальный кейс
Наивный парсингНет гарантийБыстроНизкаяПрототипы, демо
JSON SchemaСтруктурныеСреднеСредняяОблачные API, простые данные
Pydantic + промптыВалидация после генерацииМедленно (ретраи)СредняяСложные схемы, постобработка
Outlines (ограниченная генерация)СинтаксическиеБыстроВысокаяПродакшен, локальные модели
ГрамматикиПолные (синтаксис)Зависит от грамматикиОчень высокаяSQL, DSL, формальные языки

Интеграция в продакшен-пайплайны

Структурированный вывод — не изолированная задача. Это часть семантического пайплайна. Вот как это выглядит в реальной системе:

  1. Валидация входных данных: Проверяем, что промпт содержит необходимую информацию
  2. Выбор стратегии генерации: На основе схемы данных выбираем метод (Outlines для локальных моделей, JSON Schema для OpenAI)
  3. Контролируемая генерация: Запускаем LLM с ограничениями
  4. Пост-валидация: Даже с Outlines проверяем семантику (возраст не отрицательный, email валиден)
  5. Логирование и мониторинг: Отслеживаем процент успешных генераций, частые ошибки
  6. Фолбэк-стратегии: Что делать, если модель не может сгенерировать валидные данные? Ретраи? Человек в петле?
# Упрощенная продакшен-система
from enum import Enum
from typing import Optional, Type

class GenerationStrategy(Enum):
    OUTLINES = "outlines"
    JSON_SCHEMA = "json_schema"
    GRAMMAR = "grammar"

class StructuredGenerator:
    def __init__(self, model, strategy: GenerationStrategy):
        self.model = model
        self.strategy = strategy
    
    def generate(self, prompt: str, schema: Type[BaseModel]) -> Optional[BaseModel]:
        # Шаг 1: Валидация промпта
        if not self._validate_prompt(prompt):
            return None
        
        # Шаг 2: Генерация с выбранной стратегией
        try:
            if self.strategy == GenerationStrategy.OUTLINES:
                result = self._generate_with_outlines(prompt, schema)
            else:
                result = self._generate_with_schema(prompt, schema)
            
            # Шаг 3: Семантическая валидация
            validated = self._semantic_validation(result, schema)
            return validated
            
        except Exception as e:
            # Шаг 4: Фолбэк
            return self._fallback_generation(prompt, schema, str(e))

Ошибки, которые сломают вашу систему

Эти ошибки я видел в десятках проектов. Не повторяйте их.

  • Доверять модели слепо: «Она же умная, поймет схему». Не поймет.
  • Игнорировать кодировки: JSON с кириллицей без ensure_ascii=False сломает всё.
  • Забыть про вложенные структуры: Массивы объектов, optional поля, union типы — всё это нужно тестировать отдельно.
  • Не предусматривать фолбэк: Что будет, если 5% запросов вернут невалидные данные? Система упадет или обработает?
  • Смешивать стратегии: Использовать Outlines для одной схемы и наивный парсинг для другой — гарантированная головная боль.

Локальные модели vs Облачные API

Выбор инструмента зависит от того, где работает ваша модель. Для локальных LLM с Tool Calling и моделей, которые влезают в 24 ГБ VRAM, Outlines — лучший выбор.

Для облачных API (OpenAI, Anthropic) используйте встроенный function calling с JSON Schema. Но помните: даже с функциональным вызовом нужна пост-валидация.

Что делать, когда ничего не работает

Бывает. Сложная схема, модель постоянно ошибается, Outlines не справляется. Ваши варианты:

  1. Упростить схему: Разбить на несколько запросов, убрать optional поля, уменьшить вложенность
  2. Fine-tuning: Дообучить модель на примерах именно вашей схемы
  3. Человек в петле: Сложные случаи отправлять на валидацию человеку, результаты использовать для дообучения
  4. Сменить модель: Некоторые модели лучше следуют инструкциям. Тестируйте разные LLM на вашей задаче

Будущее: что изменится через год

Structured outputs станут стандартной фичей всех LLM API. Мы увидим:

  • Нативные интеграции Pydantic в промпт-инженеринг
  • Автоматическую генерацию валидных примеров для few-shot обучения
  • Графические интерфейсы для построения схем данных
  • Стандартизацию форматов запросов (возможно, на основе JSON Schema)

Но главное — исчезнет сама концепция «неструктурированного вывода». LLM будут восприниматься не как текстовые генераторы, а как преобразователи данных с гарантированным форматом. Это изменит всё.

💡
Совет, который сэкономит месяц работы: начните с Outlines, даже если кажется сложно. Потратьте неделю на освоение — сэкономите месяцы на дебаггинге наивных решений.

И последнее: не верьте статьям, которые говорят «просто используйте этот промпт». Структурированный вывод — инженерная задача. Требует тестирования, итераций, мониторинга. Как и тестирование недетерминированных LLM, это сложно. Но без этого не бывает продакшена.