Почему «просто попросить 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
Метод 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, соответствующий схеме
Как работает ограниченная генерация
- Модель генерирует первый токен — должен быть
{(начало JSON объекта) - Следующий токен — либо закрывающая скобка
}(пустой объект), либо открывающая кавычка для имени поля - Если началось имя поля, система проверяет его соответствие схеме Pydantic
- После двоеточия разрешены только токены, соответствующие типу поля (строки, числа и т.д.)
- Процесс продолжается, пока не сгенерируется синтаксически корректный 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, формальные языки |
Интеграция в продакшен-пайплайны
Структурированный вывод — не изолированная задача. Это часть семантического пайплайна. Вот как это выглядит в реальной системе:
- Валидация входных данных: Проверяем, что промпт содержит необходимую информацию
- Выбор стратегии генерации: На основе схемы данных выбираем метод (Outlines для локальных моделей, JSON Schema для OpenAI)
- Контролируемая генерация: Запускаем LLM с ограничениями
- Пост-валидация: Даже с Outlines проверяем семантику (возраст не отрицательный, email валиден)
- Логирование и мониторинг: Отслеживаем процент успешных генераций, частые ошибки
- Фолбэк-стратегии: Что делать, если модель не может сгенерировать валидные данные? Ретраи? Человек в петле?
# Упрощенная продакшен-система
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 не справляется. Ваши варианты:
- Упростить схему: Разбить на несколько запросов, убрать optional поля, уменьшить вложенность
- Fine-tuning: Дообучить модель на примерах именно вашей схемы
- Человек в петле: Сложные случаи отправлять на валидацию человеку, результаты использовать для дообучения
- Сменить модель: Некоторые модели лучше следуют инструкциям. Тестируйте разные LLM на вашей задаче
Будущее: что изменится через год
Structured outputs станут стандартной фичей всех LLM API. Мы увидим:
- Нативные интеграции Pydantic в промпт-инженеринг
- Автоматическую генерацию валидных примеров для few-shot обучения
- Графические интерфейсы для построения схем данных
- Стандартизацию форматов запросов (возможно, на основе JSON Schema)
Но главное — исчезнет сама концепция «неструктурированного вывода». LLM будут восприниматься не как текстовые генераторы, а как преобразователи данных с гарантированным форматом. Это изменит всё.
И последнее: не верьте статьям, которые говорят «просто используйте этот промпт». Структурированный вывод — инженерная задача. Требует тестирования, итераций, мониторинга. Как и тестирование недетерминированных LLM, это сложно. Но без этого не бывает продакшена.