Структурированный вывод: JSON Schema и строгая типизация ответов

40 минут Урок 4

Введение: Конец эры регулярных выражений

Добро пожаловать на один из самых важных уроков этого модуля. Если вы когда-либо пытались интегрировать LLM (Large Language Models) в реальное программное обеспечение до появления нативной поддержки JSON, вы знаете эту боль. Вы пишете в промпте: «Пожалуйста, верни только JSON, без лишних слов». А модель отвечает: «Конечно! Вот ваш JSON: {...}».

И всё. Ваш парсер падает. Ваше приложение выдает ошибку. Вы пишете регулярные выражения (RegEx), чтобы вырезать фигурные скобки, но модель вдруг решает использовать одинарные кавычки вместо двойных или добавляет комментарий внутри JSON.

В Gemini 3 API мы переходим от «просьб» к контрактам. Мы больше не надеемся, что модель вернет правильную структуру. Мы гарантируем это на уровне API. Сегодня мы разберем, как использовать Structured Output, задействуем мощь JSON Schema и научимся связывать это с библиотекой Pydantic для создания надежных, типизированных приложений.

Зачем нам нужна строгая типизация ответов?

Текст — это интерфейс для людей. Данные — это интерфейс для машин. Когда вы строите агента, который должен сохранить запись в базу данных, вызвать внешний API или отрендерить интерфейс, вам нужна предсказуемость.

  • Детерминизм структуры: Вы всегда знаете, какие поля придут и какого они будут типа.
  • Экономия токенов: Модели не нужно генерировать вводные слова («Вот список, который вы просили...»), она сразу генерирует данные.
  • Уменьшение галлюцинаций: Когда модель ограничена схемой, она с меньшей вероятностью придумает несуществующие поля или данные, не подходящие под формат.

В экосистеме Gemini это реализуется через параметр response_schema и установку MIME-типа ответа в application/json.

python
import os
import google.generativeai as genai
from google.ai.generativelanguage_v1beta.types import content

# Настройка API ключа
genai.configure(api_key=os.environ["GEMINI_API_KEY"])

# Определение простой схемы через словарь (raw dict)
# Это базовый уровень, позже мы перейдем к Pydantic
response_schema = {
    "type": "object",
    "properties": {
        "recipe_name": {"type": "string"},
        "ingredients": {
            "type": "array",
            "items": {"type": "string"}
        },
        "calories": {"type": "integer"}
    },
    "required": ["recipe_name", "ingredients", "calories"]
}

# Инициализация модели с конфигурацией генерации
model = genai.GenerativeModel(
    'gemini-1.5-pro', # Используем актуальную версию Pro для лучшего следования инструкциям
    generation_config=genai.GenerationConfig(
        response_mime_type="application/json",
        response_schema=response_schema
    )
)

response = model.generate_content(
    "Придумай рецепт полезного завтрака с авокадо."
)

print(response.text)

Интеграция с Pydantic: Золотой стандарт Python-разработки

Использование словарей для описания схем (как в примере выше) работает, но это громоздко и чревато ошибками. В Python-сообществе стандартом де-факто для валидации данных является библиотека Pydantic.

Gemini API умеет принимать классы Pydantic напрямую. Это дает вам огромные преимущества:

  1. Чистота кода: Вы описываете структуру данных как Python-класс.
  2. Автодокументация: Поля description в Pydantic передаются модели как часть контекста. Это означает, что описание поля служит микро-промптом для модели.
  3. Валидация на выходе: Вы сразу получаете объект Python, готовый к использованию в бизнес-логике, а не просто JSON-строку.

Давайте посмотрим, как это выглядит на практике при создании анализатора резюме.

python
import typing_extensions as typing
from pydantic import BaseModel, Field

# 1. Описываем структуру данных, которую хотим получить
class WorkExperience(BaseModel):
    company: str = Field(description="Название компании")
    role: str = Field(description="Должность")
    years: int = Field(description="Количество полных лет работы")
    skills_used: list[str] = Field(description="Список технологий или навыков, использованных на этом месте")

class CandidateProfile(BaseModel):
    full_name: str = Field(description="Полное имя кандидата")
    summary: str = Field(description="Краткое профессиональное резюме в 2 предложения")
    experience: list[WorkExperience] = Field(description="История работы, отсортированная от последней к первой")
    seniority_level: typing.Literal["Junior", "Middle", "Senior", "Lead"] = Field(
        description="Оценка уровня сеньорности на основе опыта"
    )

# 2. Передаем класс Pydantic прямо в generation_config
model = genai.GenerativeModel(
    'gemini-1.5-pro',
    generation_config=genai.GenerationConfig(
        response_mime_type="application/json",
        response_schema=CandidateProfile
    )
)

# 3. Имитируем сырой текст резюме
raw_resume_text = """
Меня зовут Алекс Иванов. Я занимаюсь бэкендом уже 5 лет. 
Последние 2 года работал в TechCorp, где лидил команду питонистов, 
строили микросервисы на FastAPI. До этого 3 года пилил монолит на Django в OldSchool LLC.
"""

response = model.generate_content(f"Извлеки информацию из этого текста: {raw_resume_text}")

# Преобразуем JSON ответ обратно в объект Pydantic для проверки
# В реальном приложении можно использовать CandidateProfile.model_validate_json(response.text)
print(response.text)

Тонкости работы с Enums и вложенностью

Обратите внимание на поле seniority_level в примере выше. Мы использовали typing.Literal. Это критически важный момент.

Когда вы задаете Literal['Junior', 'Middle'...], вы жестко ограничиваете словарь модели. Она физически не сможет вернуть значение "Expert" или "Intern", если его нет в списке допустимых значений схемы. Это работает как классификатор «из коробки».

Советы по проектированию схем:

  • Вложенность: Gemini отлично справляется с глубокой вложенностью, но старайтесь не делать её глубже 3-4 уровней, чтобы не размывать внимание модели.
  • Описания (Descriptions): Никогда не оставляйте поля пустыми. Поле age: int может быть понято двояко (возраст человека или стаж работы?). Field(description="Возраст кандидата в годах") убирает двусмысленность.
  • Опциональные поля: Если поле не обязательно, используйте Optional[type]. Модель может вернуть null, если информации в тексте недостаточно.

Упражнение

Создайте структуру данных для 'Умного помощника покупок'.<br><br>Вам нужно:<br>1. Описать Pydantic-модели для `Product` (название, цена, категория, является ли экологичным).<br>2. Категория должна быть ограничена списком: 'Food', 'Electronics', 'Clothing'.<br>3. Главная модель `ShoppingList` должна содержать список продуктов и общую оценочную стоимость.<br>4. Напишите код, который принимает список покупок в свободной форме (строка) и возвращает структурированный объект JSON.

Обработка ошибок и крайних случаев

Даже при строгой схеме модель — это вероятностная машина. В 99.9% случаев с response_schema вы получите валидный JSON. Но что делать в оставшихся 0.1%?

В продакшене вы должны оборачивать парсинг ответа в блок try-except. Если model_validate_json (метод Pydantic) падает с ошибкой валидации, это означает, что модель нарушила контракт. В архитектуре агентов это сигнал для самокоррекции (Self-Correction).

Паттерн самокоррекции выглядит так:
1. Получили JSON.
2. Pydantic выдал ошибку (например, поле `price` — строка, а не число).
3. Вы отправляете эту ошибку обратно модели с текстом: «Ты вернула неверный формат. Ошибка: {error_message}. Исправь это.»
4. Gemini 3 отлично справляется с исправлением собственных синтаксических ошибок.

Вопрос

Почему использование typing.Literal в определении схемы Pydantic особенно полезно при работе с LLM?