Сервис разбирает 10 000 обращений в сутки. Модель правильно определяет категорию в 97% случаев. Оставшиеся 300 ответов попадают в backend: в одном сумма записана строкой "1 200 руб.", в другом дата относится к прошлому году, в третьем модель придумала customer_id. Даже высокая точность модели оставляет вполне рабочую очередь аварий.
Поэтому ответ проходит три отдельных этапа: структурную валидацию, нормализацию и проверку бизнес-инвариантов. Смешивать их в одном Pydantic-валидаторе удобно первые два дня. На третий становится непонятно, модель ошиблась или код молча переписал её ответ.
Три границы
Структурная валидация отвечает на простой вопрос: объект соответствует контракту? Здесь проверяются типы, обязательные поля, enum и запрет лишних ключей.
Нормализация приводит допустимые варианты к канонической форме. Пробелы в телефоне исчезают, валюта становится кодом ISO 4217, дата получает часовой пояс. Нормализация должна быть детерминированной и не должна додумывать отсутствующие данные.
Бизнес-валидация проверяет объект относительно вашей системы: существует ли клиент, разрешена ли категория для этого канала, не превышает ли сумма лимит.
from datetime import date
from decimal import Decimal
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
class Currency(StrEnum):
RUB = "RUB"
USD = "USD"
EUR = "EUR"
class PaymentRequest(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
customer_id: int
amount: Decimal = Field(gt=0, max_digits=12, decimal_places=2)
currency: Currency
due_date: date | None = None
evidence: str = Field(min_length=1, max_length=300)Поле evidence хранит фрагмент исходного текста, на котором основано решение. Оно не доказывает истинность, зато позволяет быстро проверить спорный результат и не искать его во всём документе.
Нормализация без гадания
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
@dataclass(frozen=True)
class NormalizeResult:
value: dict
changes: list[str]
def normalize(raw: dict) -> NormalizeResult:
value = dict(raw)
changes: list[str] = []
if isinstance(value.get("currency"), str):
old = value["currency"]
value["currency"] = old.strip().upper()
if old != value["currency"]:
changes.append("currency:strip+upper")
if isinstance(value.get("amount"), str):
old = value["amount"]
compact = old.replace(" ", "").replace(",", ".")
try:
value["amount"] = Decimal(compact)
changes.append("amount:string_to_decimal")
except InvalidOperation:
pass
return NormalizeResult(value, changes)Код не превращает «примерно тысяча» в 1000. Для этого понадобилось бы новое вероятностное решение. Его следует оформить отдельным шагом и измерять отдельно.
Ошибка должна иметь маршрут
Полезная классификация отказов:
invalid_input: исходный документ пуст, повреждён или превышает лимит;invalid_output: модель вернула объект вне контракта;business_reject: объект корректен по форме, но нарушает правило продукта;transient: таймаут, 429, 5xx, обрыв соединения;unknown: ошибка, для которой ещё нет явной политики.
Ретраить стоит только transient и, один раз, invalid_output с текстом ошибки. Повторный вызов на пустом документе лишь покупает второй одинаковый отказ.
async def accept_payment(raw: dict, repo) -> PaymentRequest:
normalized = normalize(raw)
payment = PaymentRequest.model_validate(normalized.value)
if not await repo.customer_exists(payment.customer_id):
raise BusinessReject("unknown customer_id")
if payment.due_date and payment.due_date < date.today():
raise BusinessReject("due_date is in the past")
audit_log.info("llm_output_accepted", changes=normalized.changes)
return paymentFailure modes
Молчаливая коррекция опаснее явного отказа: если код заменил неизвестную валюту на RUB, инцидент обнаружится в платеже. Второй частый сбой связан с зависимостью проверки от текущего времени. Передавайте часы в валидатор или фиксируйте дату в тестах. Третий возникает при изменении справочника: старый ответ становится невалидным между генерацией и записью. Транзакция и повторная проверка перед записью закрывают это окно.
Храните сырой ответ, нормализованный объект, список преобразований, версию схемы и итог проверки. Секреты и персональные данные перед записью редактируйте. Без такого следа спор о причине ошибки быстро превращается в археологию по логам.
Правило главы
Каждое автоматическое исправление должно быть детерминированным, наблюдаемым и обратимым. Если исправление требует догадки, верните объект модели, отправьте его на ручную проверку или откажите. Backend не должен незаметно становиться второй моделью.
Чеклист главы
- Структурная, нормализующая и бизнес-проверка разделены.
- Лишние поля запрещены, числовые ограничения заданы явно.
- Нормализатор не создаёт отсутствующие факты.
- Ошибки классифицированы, политика retry задана для каждого класса.
- Сырой и нормализованный ответы связаны общим
request_id. - В тестах зафиксированы время, справочники и версия схемы.
Практикум
Задача 11.1. Конвейер приёмки заявки.
Соберите обработчик заявки с полями email, budget, currency, deadline, category и evidence. Подготовьте 40 ответов: корректные, с лишними ключами, локальными форматами чисел, прошедшей датой и несуществующей категорией.
Критерии приёмки:
- Для каждого ответа известен итоговый класс ошибки или валидный объект.
- Повторный запуск нормализации не меняет результат.
- Ни одна бизнес-ошибка не вызывает автоматический retry.
- В журнале видны исходный ответ, преобразования и версия контракта без персональных данных.
- Есть тест на изменение справочника между генерацией и записью.