Когда LLM возвращает текст, который нужно разобрать программно, возникает проблема: модель не гарантирует, что ответ будет валидным JSON. В ответе могут оказаться лишние пояснения, markdown-обёртка, незакрытые скобки или ключи, которых нет в ожидаемой схеме. Регулярные выражения и ручной парсинг — хрупкое решение, которое ломается при малейшем изменении формата.
Structured Output — это набор подходов, которые заставляют модель возвращать данные в заданном формате. Они работают на разных уровнях: от инструкций в промпте до ограничений на этапе генерации токенов.
Даже если в системном сообщении написано «Верни только JSON без пояснений», модель может:
Причина в том, что LLM генерирует токены последовательно, выбирая на каждом шаге наиболее вероятный следующий токен. Модель не «знает» о JSON-валидности как о формальном ограничении — она лишь имитирует паттерн, который видела в обучающих данных. Чем длиннее и сложнее ожидаемая структура, тем выше вероятность отклонения.
Самый простой подход — явно указать формат в промпте и привести пример ожидаемого ответа.
Ты — классификатор обращений. Верни JSON с полями:
Пример ответа:
{"category": "billing", "urgency": 3, "summary": "Клиент не может найти счёт за март"}
Не добавляй ничего кроме JSON.
Этот метод работает для простых схем и моделей с хорошей инструкционной настройкой. Но он не даёт формальной гарантии: модель всё ещё может отклониться.
Когда использовать: прототипирование, простые схемы с 2–4 полями, модели с высоким instruction-following (GPT-4, Claude 3+).
Некоторые провайдеры предоставляют режим, в котором модель гарантированно возвращает синтаксически валидный JSON. Например, OpenAI реализует параметр
python
import openai
response = openai.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": "Верни JSON с полями name и age."},
{"role": "user", "content": "Иван, 30 лет"}
]
)
В режиме
Ограничение: JSON mode не принимает JSON Schema как вход. Он лишь переключает декодер в режим, где генерируются только токены, допустимые в JSON-грамматике.
Более строгий вариант — передать JSON Schema, и модель будет генерировать только токены, которые соответствуют этой схеме. OpenAI реализует это через
python
response = openai.chat.completions.create(
model="gpt-4o",
response_format={
"type": "json_schema",
"json_schema": {
"name": "user_info",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"],
"additionalProperties": False
}
}
},
messages=[
{"role": "user", "content": "Иван, 30 лет"}
]
)
При
Ограничения strict mode:
Для self-hosted моделей (через vLLM, llama.cpp, Outlines, Guidance) constrained decoding работает аналогично: на этапе сэмплирования токенов применяется маска, которая пропускает только токены, допустимые грамматикой.
Библиотека Outlines позволяет задать схему через Pydantic-модель:
python
import outlines
from pydantic import BaseModel
class UserInfo(BaseModel):
name: str
age: int
model = outlines.models.transformers("mistralai/Mistral-7B-v0.1")
generator = outlines.generate.json(model, UserInfo)
result = generator("Иван, 30 лет")
Здесь
Преимущества локального constrained decoding:
Недостатки:
Если API не поддерживает structured output или схема слишком сложна, остаётся вариант: получить свободный ответ и провалидировать его.
python
import json
from pydantic import BaseModel, ValidationError
class UserInfo(BaseModel):
name: str
age: int
def parse_response(text: str) -> UserInfo:
## Попытка извлечь JSON из ответа
text = text.strip()
if text.startswith(""):
## Убираем markdown-обёртку
lines = text.split("\n")
text = "\n".join(lines[1:-1])
try:
data = json.loads(text)
return UserInfo(**data)
except (json.JSONDecodeError, ValidationError):
## Fallback: повторный запрос или логирование
raise ValueError(f"Не удалось распарсить ответ: {text[:200]}")
Этот подход не гарантирует успех, но позволяет обработать большинство ответов и явно отловить ошибки.
После получения ответа стоит проверить три вещи:
Первые два пункта автоматизируются. Третий требует бизнес-логики, но именно он ловит случаи, когда модель вернула формально валидный, но бессмысленный ответ (например,
Structured Output — это набор подходов, которые заставляют модель возвращать данные в заданном формате. Они работают на разных уровнях: от инструкций в промпте до ограничений на этапе генерации токенов.
Почему обычный промпт не гарантирует валидный JSON
Даже если в системном сообщении написано «Верни только JSON без пояснений», модель может:
- Добавить текст до или после JSON-блока.
- Обернуть ответ в markdown-код-блок с тройными обратными кавычками.
- Сгенерировать trailing comma, которую
json.loads()не примет.
- Использовать одинарные кавычки вместо двойных.
- Вернуть ключи, которых нет в схеме, или пропустить обязательные.
Причина в том, что LLM генерирует токены последовательно, выбирая на каждом шаге наиболее вероятный следующий токен. Модель не «знает» о JSON-валидности как о формальном ограничении — она лишь имитирует паттерн, который видела в обучающих данных. Чем длиннее и сложнее ожидаемая структура, тем выше вероятность отклонения.
Уровни решения задачи
1. Prompt-level: инструкция и few-shot примеры
Самый простой подход — явно указать формат в промпте и привести пример ожидаемого ответа.
Ты — классификатор обращений. Верни JSON с полями:
- category: string (одна из: "billing", "technical", "general")
- urgency: integer от 1 до 5
- summary: string, не более 100 символов
Пример ответа:
{"category": "billing", "urgency": 3, "summary": "Клиент не может найти счёт за март"}
Не добавляй ничего кроме JSON.
Этот метод работает для простых схем и моделей с хорошей инструкционной настройкой. Но он не даёт формальной гарантии: модель всё ещё может отклониться.
Когда использовать: прототипирование, простые схемы с 2–4 полями, модели с высоким instruction-following (GPT-4, Claude 3+).
2. JSON Mode на уровне API
Некоторые провайдеры предоставляют режим, в котором модель гарантированно возвращает синтаксически валидный JSON. Например, OpenAI реализует параметр
response_format в Chat Completions API.python
import openai
response = openai.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": "Верни JSON с полями name и age."},
{"role": "user", "content": "Иван, 30 лет"}
]
)
В режиме
json_object модель не вернёт текст вне JSON-блока. Однако это гарантирует только синтаксическую валидность, а не соответствие конкретной схеме. Модель может вернуть {"name": "Иван", "age": 30, "extra_field": true} — валидный JSON, но с лишним ключом.Ограничение: JSON mode не принимает JSON Schema как вход. Он лишь переключает декодер в режим, где генерируются только токены, допустимые в JSON-грамматике.
3. Structured Outputs с JSON Schema
Более строгий вариант — передать JSON Schema, и модель будет генерировать только токены, которые соответствуют этой схеме. OpenAI реализует это через
response_format с типом json_schema:python
response = openai.chat.completions.create(
model="gpt-4o",
response_format={
"type": "json_schema",
"json_schema": {
"name": "user_info",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
},
"required": ["name", "age"],
"additionalProperties": False
}
}
},
messages=[
{"role": "user", "content": "Иван, 30 лет"}
]
)
При
strict: true модель не может сгенерировать ключи вне схемы, пропустить обязательные поля или нарушить типы. Это достигается на уровне декодирования: на каждом шаге генерации маска допустимых токенов сужается в соответствии с текущим состоянием JSON-автомата.Ограничения strict mode:
- Не все JSON Schema конструкции поддерживаются. Например,
oneOf,anyOf,patternPropertiesмогут быть недоступны или работать иначе.
- Схемы с глубокой вложенностью или большим числом перечислений увеличивают задержку.
additionalProperties: falseобязателен для strict-режима.
4. Constrained Decoding (локальные модели)
Для self-hosted моделей (через vLLM, llama.cpp, Outlines, Guidance) constrained decoding работает аналогично: на этапе сэмплирования токенов применяется маска, которая пропускает только токены, допустимые грамматикой.
Библиотека Outlines позволяет задать схему через Pydantic-модель:
python
import outlines
from pydantic import BaseModel
class UserInfo(BaseModel):
name: str
age: int
model = outlines.models.transformers("mistralai/Mistral-7B-v0.1")
generator = outlines.generate.json(model, UserInfo)
result = generator("Иван, 30 лет")
Здесь
outlines.generate.json строит конечный автомат из Pydantic-схемы и на каждом шаге генерации фильтрует логиты, оставляя только токены, которые ведут к валидному продолжению.Преимущества локального constrained decoding:
- Работает с любой open-weight моделью.
- Нет зависимости от API провайдера.
- Можно использовать сложные грамматики (не только JSON, но и regex, EBNF).
Недостатки:
- Требует вычислительных ресурсов для инференса.
- Маскировка токенов добавляет overhead к скорости генерации.
- Не все модели одинаково хорошо следуют схеме: маленькие модели могут «застревать» в повторяющихся паттернах.
5. Post-processing с валидацией
Если API не поддерживает structured output или схема слишком сложна, остаётся вариант: получить свободный ответ и провалидировать его.
python
import json
from pydantic import BaseModel, ValidationError
class UserInfo(BaseModel):
name: str
age: int
def parse_response(text: str) -> UserInfo:
## Попытка извлечь JSON из ответа
text = text.strip()
if text.startswith(""):
## Убираем markdown-обёртку
lines = text.split("\n")
text = "\n".join(lines[1:-1])
try:
data = json.loads(text)
return UserInfo(**data)
except (json.JSONDecodeError, ValidationError):
## Fallback: повторный запрос или логирование
raise ValueError(f"Не удалось распарсить ответ: {text[:200]}")
Этот подход не гарантирует успех, но позволяет обработать большинство ответов и явно отловить ошибки.
Типичные ошибки при работе со structured output
| Ошибка | Причина | Решение |
|---|---|---|
| Модель возвращает markdown-обёртку | JSON mode не включён или промпт не запрещает форматирование | Использовать response_format или явно запрещать markdown |
| Лишние ключи в ответе | additionalProperties не задан или равен true | Установить additionalProperties: false в схеме |
| Trailing comma | Модель имитирует «человеческий» JSON | Constrained decoding исключает эту возможность |
| Числа приходят как строки | Модель не различает типы без strict-схемы | Указать "type": "integer" в JSON Schema |
| Пустой ответ или отказ | Схема противоречива или слишком ограничительна | Упростить схему, проверить required поля |
Как выбрать подход
- Прототип или простая задача — prompt-level инструкция + post-processing с Pydantic.
- Продакшен с API-провайдером — Structured Outputs с JSON Schema (если провайдер поддерживает).
- Локальная модель — Outlines, Guidance или vLLM с grammar-based sampling.
- Сложная или нестандартная схема — комбинация: constrained decoding для базовой структуры + post-processing для бизнес-логики.
Проверка результата
После получения ответа стоит проверить три вещи:
- Синтаксис —
json.loads()не выбрасывает исключение.
- Схема — Pydantic-валидация или
jsonschema.validate()проходит.
- Семантика — значения попадают в допустимые диапазоны, перечисления корректны, строки не пустые.
Первые два пункта автоматизируются. Третий требует бизнес-логики, но именно он ловит случаи, когда модель вернула формально валидный, но бессмысленный ответ (например,
"age": -5 при отсутствии ограничения minimum в схеме).Ограничения, о которых стоит помнить
- Structured output не решает проблему галлюцинаций. Модель может вернуть идеально валидный JSON с выдуманными данными.
- Чем строже схема, тем меньше «свободы» у модели. Если задача требует рассуждения перед ответом, constrained decoding может ухудшить качество — модель не может «подумать» в свободной форме.
- Для задач с chain-of-thought лучше использовать двухэтапный подход: сначала свободный ответ с рассуждением, затем извлечение структурированных данных из него.
