В Python-проектах удобно описывать контракт через Pydantic. Он решает три задачи:
- Документирует ожидаемый объект.
- Валидирует ответ модели.
- Даёт единый тип для остального backend-кода.
Пример: извлечение данных из письма клиента.
from datetime import date
from enum import Enum
from pydantic import BaseModel, Field, EmailStr
class Urgency(str, Enum):
low = "low"
normal = "normal"
high = "high"
class ClientRequest(BaseModel):
company_name: str | None = None
contact_email: EmailStr | None = None
requested_service: str = Field(max_length=200)
deadline: date | None = None
urgency: Urgency = Urgency.normal
budget_mentioned: bool = False
summary: str = Field(max_length=500)Важно: схема не должна быть фантазией разработчика. Она должна соответствовать тому, что реально нужно downstream-процессу.
Если менеджеру нужно только понять срочность и тему обращения, не надо извлекать двадцать полей. Чем больше полей, тем выше вероятность ошибки, стоимость и сложность evals.
Минимальный цикл structured output
- Сформировать prompt с задачей.
- Передать схему ответа провайдеру или использовать tool/function calling.
- Получить ответ.
- Валидировать через Pydantic.
- Если ошибка формата — сделать ограниченный repair retry.
- Если ошибка повторяется — fallback в ручную обработку.
- Сохранить сырой ответ, валидированный объект и версию prompt-а.
Псевдокод:
def extract_client_request(email_text: str) -> ClientRequest | None:
raw = llm_generate_structured(
task="Extract client request fields from email",
input=email_text,
schema=ClientRequest.model_json_schema(),
prompt_version="client_request_extract:v3",
)
try:
return ClientRequest.model_validate(raw)
except ValidationError as exc:
repaired = repair_structured_output(raw, exc, ClientRequest)
if repaired:
return repaired
send_to_manual_queue(email_text, raw, str(exc))
return NoneПравило: structured output без валидации — это декорация. Валидация без fallback — это ночной инцидент, который просто ждёт своего часа.
Чеклист главы
- Контракт описан в Pydantic и является единственным источником истины о формате ответа.
- Схема минимальна: каждое поле нужно конкретному downstream-процессу.
- Ответ модели валидируется всегда, даже если провайдер «гарантирует» схему.
- Есть ограниченный repair retry и fallback в ручную обработку.
- Сырой ответ, валидированный объект и версия prompt-а сохраняются.
- Optional-поля осознанны:
Noneозначает «в письме этого не было», а не «модель забыла».
Практикум
Задача 8.1 — экстрактор заявок из писем. ⭑⭑
Реализуйте полный цикл structured output из главы: функция принимает текст письма клиента и возвращает ClientRequest | None. Соберите 15 тестовых писем: обычные, без контактов, с двумя услугами сразу, с «сроком вчера», на разговорном языке, с эмодзи и подписью-простынёй.
Критерии приёмки:
- Все 15 писем обрабатываются без исключений.
- Письмо без дедлайна даёт
deadline=None, а не выдуманную дату — проверено тестом. - При искусственно сломанной схеме (подмените ответ модели невалидным JSON) срабатывает repair retry, потом fallback, и оба события видны в логе.
-
EmailStrотвергает мусор вида «пишите в телегу @vasya» — и вы решили, куда девать такой контакт.
Подсказки:
- Спрячьте вызов модели за интерфейсом — тогда сломанные ответы легко подсовывать в тестах без реальных запросов.
- Придумайте письмо, где модель захочет «дофантазировать» бюджет. Поле
budget_mentioned: boolчестнее поляbudget: int | None.
Задача 8.2 — схема как документация. ⭑
Отдайте свой контракт из задачи 8.1 коллеге (или перечитайте сами через день) без какого-либо контекста. Пусть он по одной только схеме расскажет, что делает система и какие у неё граничные случаи.
Критерии приёмки:
- Каждое поле имеет
descriptionвField(...), по которому понятно его назначение без чтения prompt-а. - Названия полей не требуют пояснений голосом.
-
model_json_schema()выдаёт схему, которую не стыдно отправить провайдеру: описания попадают в JSON Schema и работают как инструкции модели.
Подсказки:
descriptionполей Pydantic — это часть prompt-а. Часто он влияет на качество извлечения сильнее, чем «основной» текст задачи.