Structured Output: как получать от LLM валидный JSON и не парсить ответ регулярками

Когда LLM возвращает текст, который нужно разобрать программно, возникает проблема: модель не гарантирует, что ответ будет валидным JSON. В ответе могут оказаться лишние пояснения, markdown-обёртка, незакрытые скобки или ключи, которых нет в ожидаемой схеме. Регулярные выражения и ручной парсинг — хрупкое решение, которое ломается при малейшем изменении формата.

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Модель имитирует «человеческий» JSONConstrained 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 для бизнес-логики.

Проверка результата​


После получения ответа стоит проверить три вещи:

  1. Синтаксисjson.loads() не выбрасывает исключение.
  2. Схема — Pydantic-валидация или jsonschema.validate() проходит.
  3. Семантика — значения попадают в допустимые диапазоны, перечисления корректны, строки не пустые.

Первые два пункта автоматизируются. Третий требует бизнес-логики, но именно он ловит случаи, когда модель вернула формально валидный, но бессмысленный ответ (например, "age": -5 при отсутствии ограничения minimum в схеме).

Ограничения, о которых стоит помнить​


  • Structured output не решает проблему галлюцинаций. Модель может вернуть идеально валидный JSON с выдуманными данными.
  • Чем строже схема, тем меньше «свободы» у модели. Если задача требует рассуждения перед ответом, constrained decoding может ухудшить качество — модель не может «подумать» в свободной форме.
  • Для задач с chain-of-thought лучше использовать двухэтапный подход: сначала свободный ответ с рассуждением, затем извлечение структурированных данных из него.
 
Назад
Верх Низ