Детерминированный вывод: JSON Mode и работа со схемами

40 минут Урок 3

Введение: Конец эпохи Regex

Добро пожаловать на урок, который разделит вашу практику работы с LLM на «до» и «после». Если вы когда-либо писали промпты вроде «пожалуйста, верни ответ только в формате JSON, не добавляй никакого текста до и после», а потом писали регулярные выражения (Regex), чтобы вытащить этот JSON из ответа модели, — вы знаете, что такое боль. Боль нестабильности.

В архитектуре ИИ-решений нестабильность недопустима. Вы не можете строить надежный конвейер обработки данных, если один раз из ста модель решит добавить приветствие или забудет закрывающую фигурную скобку.

Gemini 3 меняет правила игры. Мы переходим от «уговоров» модели к детерминированному выводу (Deterministic Output). В этом уроке мы разберем, как заставить модель работать строго по вашей схеме данных, используя JSON Mode и управление схемами. Мы перестанем воспринимать LLM как генератор текста и начнем работать с ней как с интеллектуальным API, возвращающим типизированные объекты.

Концепция Constrained Decoding (Ограниченное декодирование)

Прежде чем писать код, важно понять, как это работает «под капотом». Обычно LLM предсказывает следующий токен на основе вероятности. В режиме свободного текста после токена { "age": модель может с некоторой вероятностью выдать 25, а может — "двадцать пять" или даже I don't know.

Когда мы включаем JSON Mode или передаем Schema, движок инференса Gemini применяет маску к возможным токенам. Если схема требует число (Integer), вероятность генерации любых токенов, кроме цифр, принудительно обнуляется. Модель физически не может сгенерировать текст, нарушающий структуру JSON. Это и есть Constrained Decoding.

Два уровня контроля в Gemini 3:

  1. JSON Mode (response_mime_type): Модель гарантирует валидный JSON, но структура полей зависит только от вашего промпта.
  2. Structured Output (response_schema): Модель гарантирует не только валидный JSON, но и строгое соответствие переданной схеме (типы данных, обязательные поля, вложенность).

Для архитектора ИИ-решений второй вариант является приоритетным.

python
import os
import google.generativeai as genai
from google.generativeai.types import GenerationConfig

# Базовая настройка API
genai.configure(api_key=os.environ["GOOGLE_API_KEY"])

model = genai.GenerativeModel("gemini-1.5-pro") # Или gemini-3-experimental

# Уровень 1: Простой JSON Mode
# Мы просто говорим модели: "Отвечай в формате JSON"

response = model.generate_content(
    "Перечисли 3 самых популярных языка программирования с годом их создания.",
    generation_config=GenerationConfig(
        response_mime_type="application/json"
    )
)

print(response.text)
# Результат будет валидным JSON, но ключи могут быть любыми (например, 'languages', 'list', 'items')
# Это подходит для быстрых прототипов, но опасно для продакшена.

Работа со схемами: Стандарт Pydantic

В экосистеме Python стандартом де-факто для описания структур данных является библиотека Pydantic. Gemini SDK имеет нативную интеграцию с ней. Это позволяет вам описывать ожидаемый ответ модели так же, как вы описываете обычные классы Python.

Почему это важно для архитектора:

  • Единый источник истины: Вы используете одни и те же модели Pydantic и для генерации ответа LLM, и для валидации API вашего бэкенда (например, в FastAPI).
  • Семантическая нагрузка: Имена полей и типы данных служат дополнительным контекстом для модели. Если поле называется is_urgent: bool, модель понимает задачу лучше, чем просто из текстового описания.
  • Типобезопасность: Вы сразу получаете объекты Python, а не сырой словарь (dict), что упрощает рефакторинг и поддержку кода.

python
import typing_extensions as typing
from pydantic import BaseModel, Field

# Описываем желаемую структуру ответа
class RecipeIngredient(BaseModel):
    name: str = Field(description="Название ингредиента")
    amount: float = Field(description="Количество в граммах или штуках")
    unit: str = Field(description="Единица измерения (г, мл, шт, ч.л.)")

class Recipe(BaseModel):
    title: str = Field(description="Креативное название блюда")
    difficulty: typing.Literal["Easy", "Medium", "Hard"] # Используем Enum для жесткого ограничения
    calories: int
    ingredients: list[RecipeIngredient]
    steps: list[str]

# Передаем класс схемы напрямую в generation_config
response = model.generate_content(
    "Придумай рецепт высокобелкового завтрака с авокадо.",
    generation_config=GenerationConfig(
        response_mime_type="application/json",
        response_schema=Recipe # <--- Магия здесь
    )
)

# В ответе мы получаем текст JSON, который идеально ложится в нашу схему
print(response.text)

# Валидация и превращение в объект
import json
recipe_obj = Recipe.model_validate_json(response.text)

print(f"Блюдо: {recipe_obj.title} (Сложность: {recipe_obj.difficulty})")
# Блюдо: Тост 'Сила Авокадо' (Сложность: Easy)

Тонкости настройки схем

Просто передать класс — это полдела. Как архитектор, вы должны уметь управлять нюансами поведения модели через схему. Рассмотрим критически важные аспекты.

1. Описания полей (Field Descriptions)

Обратите внимание на параметр description в функции Field в примере выше. Это не просто документация для разработчика. Это часть промпта. Gemini читает эти описания, чтобы понять семантику поля.

Пример: Если у вас есть поле score, модель может не понять, это оценка от 1 до 5 или от 1 до 100. Добавьте description="Оценка тональности текста от -1.0 (негатив) до 1.0 (позитив)", и точность модели вырастет в разы.

2. Enums (Перечисления)

Использование Literal или Enum — самый мощный способ классификации. Вместо того чтобы просить модель «напиши категорию товара», жестко ограничьте её списком. Это предотвращает появление категорий-дубликатов (например, «Smartphone» и «Smart Phone»).

3. Nullable поля и Optional

По умолчанию Gemini старается заполнить все поля. Если информации нет, она может начать галлюцинировать. Если поле необязательно, явно укажите Optional[type]. Это дает модели «легальный» способ пропустить поле, если данных недостаточно.

python
from typing import Optional
from pydantic import BaseModel, Field

class UserProfile(BaseModel):
    full_name: str
    age: Optional[int] = Field(default=None, description="Возраст пользователя, если указан явно")
    contact_email: Optional[str] = None
    
# Запрос с неполными данными
query = "Меня зовут Алекс, свяжитесь со мной."

response = model.generate_content(
    f"Извлеки данные: {query}",
    generation_config=GenerationConfig(
        response_mime_type="application/json",
        response_schema=UserProfile
    )
)

print(response.text)
# { "full_name": "Алекс", "age": null, "contact_email": null }
# Модель корректно вернула null вместо выдумывания возраста.

Продвинутый уровень: Рекурсивные схемы и графы знаний

Одной из сложнейших задач для LLM является построение связных структур, таких как деревья или графы. С помощью схем в Gemini мы можем определять рекурсивные типы данных. Это идеально подходит для задач разбора кода, анализа организационных структур или построения карт зависимостей.

Представьте, что вам нужно проанализировать структуру файловой системы или оглавление книги с неограниченной вложенностью. Без схемы модель часто сбивается с уровнями вложенности JSON. Со схемой она держит структуру железной хваткой.

python
from __future__ import annotations # Необходимо для рекурсивных ссылок в старых версиях Python
from typing import List

class FileNode(BaseModel):
    name: str
    type: typing.Literal["file", "folder"]
    size_kb: Optional[float] = None
    children: List[FileNode] = [] # Рекурсивная ссылка на саму себя

# Пример задачи: парсинг текстового описания проекта
project_desc = """
В корне проекта есть папка 'src', внутри которой лежит 'main.py' и папка 'utils'. 
В 'utils' есть 'helpers.py'. Также в корне лежит 'README.md'.
"""

response = model.generate_content(
    f"Построй структуру файлов на основе описания: {project_desc}",
    generation_config=GenerationConfig(
        response_mime_type="application/json",
        response_schema=FileNode
    )
)

# Результат будет корректным деревом JSON любой глубины.

Практические рекомендации (Best Practices)

  1. Температура (Temperature): При использовании строгого JSON Mode рекомендуется снижать температуру (например, до 0.1 или 0.0). Хотя Constrained Decoding ограничивает синтаксис, низкая температура помогает модели быть более точной в содержании полей.
  2. Обработка ошибок: Даже при использовании схем возможны сбои (например, 400 Bad Request, если модель не может согласовать свой «мыслительный процесс» с жесткой схемой, или срабатывание фильтров безопасности). Всегда оборачивайте вызов API в try-except и будьте готовы к ValidationError от Pydantic.
  3. Сложные ограничения: JSON Schema не может проверить всё (например, «сумма чисел в массиве должна быть равна 100»). Такие логические проверки вы должны выполнять в своем коде после получения объекта Pydantic.
  4. Token Count: Помните, что JSON — это многословный формат. Использование ключей вроде "description_of_the_element_in_detail" расходует токены вывода. Старайтесь делать ключи краткими, но понятными, перенося детали в description (оно не идет в вывод, а служит промптом).

Упражнение

Создайте систему классификации обращений в техподдержку.<br><br>1. Определите схему `SupportTicket` с полями:<br> - `summary` (краткое содержание),<br> - `priority` (High, Medium, Low),<br> - `category` (Billing, Technical, Feature Request),<br> - `sentiment_score` (число от 1 до 10),<br> - `suggested_action` (текст).<br>2. Напишите код, который принимает жалобу клиента (текст) и возвращает заполненный объект `SupportTicket`.<br>3. Протестируйте на тексте: «У меня опять списали деньги за подписку, которую я отменил месяц назад! Верните средства немедленно или я подам в суд!»

Вопрос

В чем главное отличие использования response_schema от простого response_mime_type="application/json" в Gemini?