LLM вернул валидный JSON — и сломал заказ: 3 уровня проверки structured output

JSON Schema решает только половину задачи. Модель может вернуть синтаксически идеальный объект, где скидка равна 240%, дата доставки находится в прошлом, а SKU не существует. Разберём, как

Почему «валидный JSON» ещё ничего не гарантирует
Почему «валидный JSON» ещё ничего не гарантирует

проверять structured output на трёх уровнях и когда repair-loop становится дороже обычной ошибки.

Structured output сильно упростил интеграцию LLM с backend. Вместо регулярных выражений и попыток вытащить объект из markdown мы получаем данные, которые соответствуют JSON Schema. Но схема отвечает только на вопросы формы: есть ли поле, является ли цена числом, входит ли статус в enum.

Она не знает, что:

• скидка не может быть больше стоимости заказа;
• дата доставки должна быть позже даты создания;
• SKU должен существовать именно в текущем каталоге;
• выбранный склад должен обслуживать адрес клиента;
• итоговая сумма должна совпадать с суммой строк.
Если объект после schema validation сразу уходит в ERP, CRM или платёжный сервис, LLM фактически получает право записывать бизнес-данные без проверки.

Три уровня валидации

Я разделяю проверку на три независимых слоя.

Уровень 1 — структура. Типы, обязательные поля, enum, длина строк и допустимые диапазоны. Здесь работают JSON Schema или Pydantic.

Уровень 2 — бизнес-инварианты. Связи между полями: сумма строк равна total, скидка не делает цену отрицательной, даты идут в правильном порядке.

Уровень 3 — внешняя консистентность. Проверка по актуальным источникам: существует ли SKU, доступен ли товар, имеет ли пользователь право на операцию.

Смешивать всё в одном prompt неудобно. Модель может учитывать правила, но не должна быть единственным механизмом их исполнения.

Первый слой: строгая модель данных

Для примера возьмём заказ, который LLM собирает из свободного текста пользователя.

КОД:
from datetime import date
from decimal import Decimal
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field

class OrderLine(BaseModel):
model_config = ConfigDict(extra="forbid")

sku: str = Field(min_length=1, max_length=64)
quantity: int = Field(ge=1, le=100)
unit_price: Decimal = Field(gt=0, max_digits=12, decimal_places=2)

class OrderDraft(BaseModel):
model_config = ConfigDict(extra="forbid")

customer_id: str
currency: Literal["RUB", "USD", "EUR"]
delivery_date: date
discount: Decimal = Field(ge=0)
total: Decimal = Field(ge=0)
lines: list[OrderLine] = Field(min_length=1, max_length=50)

`extra="forbid"` важен: неизвестные поля не должны тихо игнорироваться. Иначе модель может вернуть `approved_by_manager=true`, а часть системы случайно начнёт доверять этому полю позже.

Для денег лучше использовать Decimal, а не float. Ошибка округления в демо выглядит мелочью, но в сверке тысяч заказов превращается в реальные расхождения.

Второй слой: бизнес-инварианты

Проверки между полями лучше держать рядом с доменной логикой, а не прятать в prompt.

КОД:
from dataclasses import dataclass
from datetime import date
from decimal import Decimal

@dataclass
class ValidationIssue:
code: str
path: str
message: str
repairable: bool

def validate_business(order: OrderDraft) -> list[ValidationIssue]:
issues = []

subtotal = sum(
line.unit_price * line.quantity
for line in order.lines
)
expected_total = subtotal - order.discount

if order.discount > subtotal:
issues.append(ValidationIssue(
code="discount_exceeds_subtotal",
path="discount",
message="Discount cannot exceed subtotal",
repairable=False,
))

if order.total != expected_total:
issues.append(ValidationIssue(
code="total_mismatch",
path="total",
message=f"Expected {expected_total}",
repairable=True,
))

if order.delivery_date <= date.today():
issues.append(ValidationIssue(
code="delivery_date_in_past",
path="delivery_date",
message="Delivery date must be in the future",
repairable=True,
))

return issues

У ошибки есть машинный code, path и флаг repairable. Текст для логов удобен человеку, но маршрутизация должна опираться на стабильный код.

Третий слой: актуальные данные

Даже идеальный объект может ссылаться на несуществующий товар. Эти проверки требуют обращения к базе или внешнему API.

КОД:
async def validate_external(order, catalog, permissions):
issues = []
skus = [line.sku for line in order.lines]
products = await catalog.get_many(skus)

for line in order.lines:
product = products.get(line.sku)

if product is None:
issues.append(ValidationIssue(
code="unknown_sku",
path=f"lines[{line.sku}].sku",
message="SKU does not exist",
repairable=True,
))
continue

if line.unit_price != product.current_price:
issues.append(ValidationIssue(
code="stale_price",
path=f"lines[{line.sku}].unit_price",
message="Price differs from catalog",
repairable=False,
))

if not await permissions.can_create_order(order.customer_id):
issues.append(ValidationIssue(
code="permission_denied",
path="customer_id",
message="Operation is not allowed",
repairable=False,
))

return issues

Проверку цены нельзя отдавать repair-loop: источник истины уже есть в каталоге. Backend должен подставить актуальное значение сам или попросить пользователя подтвердить изменение.

Какие ошибки можно отправлять на repair

Repair — это новый LLM-вызов с исходным объектом и компактным списком ошибок. Он подходит, когда нужно переинтерпретировать пользовательский текст: исправить формат даты, выбрать существующий SKU из предложенных кандидатов, восстановить пропущенное поле.

Не стоит просить модель «починить»:

• права доступа;
• актуальную цену;
• недостаточный остаток;
• запрет compliance;
• любое правило, где backend уже знает правильный ответ.
Иначе мы платим за второй вызов, чтобы модель угадала значение, которое уже лежит в базе.

Безопасный repair-loop

КОД:
MAX_REPAIRS = 1

async def build_order(user_text: str) -> OrderDraft:
candidate = await llm_extract(user_text)

for attempt in range(MAX_REPAIRS + 1):
order = OrderDraft.model_validate(candidate)
issues = validate_business(order)
issues += await validate_external(
order, catalog, permissions
)

blocking = [i for i in issues if not i.repairable]
if blocking:
raise DomainValidationError(blocking)

if not issues:
return order

if attempt == MAX_REPAIRS:
raise RepairLimitExceeded(issues)

candidate = await llm_repair(
original=user_text,
candidate=candidate,
issues=[{
"code": i.code,
"path": i.path,
"message": i.message,
} for i in issues],
)

raise AssertionError("unreachable")

Количество ремонтов должно быть ограничено. Бесконечный цикл «модель исправила одну ошибку и создала другую» быстро съедает latency и бюджет.

Не передавайте модели лишние внутренние данные

Для repair достаточно кода ошибки, пути и безопасного контекста. Не нужно отправлять полный объект пользователя, внутренние идентификаторы, stack trace или результаты других клиентов.

Если SKU неизвестен, можно дать несколько разрешённых кандидатов. Если доступа нет, ответ должен формировать backend — модель не должна видеть детали ACL.

Как считать стоимость валидации

Schema validation почти бесплатна. Дорогие части — внешние запросы и повторные LLM-вызовы. Поэтому полезно измерять:

• долю ответов, прошедших все три слоя с первого раза;
• ошибки по code и модели;
• repair rate и repair success rate;
• дополнительные токены и latency repair-вызова;
• долю ошибок, которые backend мог исправить без модели;
• количество объектов, остановленных до записи во внешнюю систему.
Если repair запускается для 30% запросов, проблема обычно не в пользователях. Скорее всего, слишком слабый schema, неудачный prompt или модель не подходит для задачи.

Fail closed или fail open

Для черновика заметки можно сохранить частичный результат и показать предупреждение. Для оплаты, заказа, прав доступа и отправки сообщения лучше fail closed: ничего не записывать, пока обязательные проверки не пройдены.

Полезно заранее классифицировать каждое действие:

• read-only — можно вернуть частичный ответ;
• reversible write — допустим черновик с флагом проверки;
• irreversible write — только после всех проверок и явного разрешения.
Практический чек-лист

• Запретить неизвестные поля и включить строгие типы.
• Разделить schema, business и external validation.
• Использовать стабильные machine-readable коды ошибок.
• Отправлять на repair только ошибки интерпретации.
• Ограничить repair одной дополнительной попыткой.
• Не доверять модели цены, права и остатки.
• Не выполнять побочные эффекты до завершения проверки.
• Измерять стоимость repair и ошибки по моделям.
Structured output убирает парсинг, но не заменяет доменную модель. Самая безопасная архитектура — считать ответ LLM недоверенным вводом: валидировать его так же строго, как публичный API-запрос.

Как у вас устроена проверка structured output: ограничиваетесь JSON Schema или уже разделяете структурные, бизнес- и внешние ошибки?