$ sudo teach IT

До сих пор модель отвечала свободным текстом. Человеку так удобно, а программе — нет. Допустим, вы разбираете входящие заявки: нужно достать имя клиента, телефон и тему обращения и записать их в таблицу. Если попросить модель «верни имя и телефон», один раз она ответит «Имя: Иван, телефон: ...», другой раз добавит вежливое вступление, третий — переставит поля. Разбирать такие ответы регулярными выражениями — путь к постоянным поломкам.

Structured outputs решают эту проблему: вы описываете схему ответа, а модель гарантированно возвращает данные строго по ней. После урока вы сможете получать от модели готовые объекты Python с нужными полями и типами и встраивать модель в программы так же надёжно, как обычную функцию.

Что уже умеем

Вы умеете отправлять запросы через client.responses.create, задавать instructions, вести диалог через историю сообщений и previous_response_id. Во всех этих случаях результатом был текст в response.output_text. Сегодня результатом станет объект с полями.

Что вы узнаете в рамках статьи

  • почему разбирать свободный текст ответа ненадёжно;
  • как описать схему ответа классом Pydantic;
  • как получить ответ по схеме методом client.responses.parse и полем output_parsed;
  • как задать списки, вложенные объекты и фиксированный набор значений;
  • как подсказать модели смысл полей и что делать, если данных нет.

Ключевые термины

  • Structured outputs — режим, в котором модель возвращает ответ строго по заданной JSON-схеме.
  • JSON — текстовый формат данных из объектов «ключ — значение» и списков.
  • JSON Schema — описание структуры JSON: какие поля есть, какого они типа, какие обязательны.
  • Pydantic — библиотека Python для описания структур данных классами с проверкой типов.
  • Модель Pydantic — класс-наследник BaseModel, поля которого задают схему ответа.
  • output_parsed — поле ответа, в котором лежит готовый объект Pydantic.

Почему свободный текст ненадёжен

Попробуем извлечь данные «по-старому»:

from openai import OpenAI

client = OpenAI()

response = client.responses.create(
    model="gpt-6-luna",
    instructions="Извлеки из заявки имя и телефон. Верни JSON.",
    input="Здравствуйте! Меня зовут Олег, перезвоните на +7 900 123-45-67.",
)
print(response.output_text)

Что увидим в консоли: скорее всего, что-то похожее на JSON. Но иногда он будет обёрнут в блок кода с тройными кавычками, иногда ключи окажутся name и phone, а иногда имя и телефон, иногда перед JSON появится фраза «Вот результат:». Вызов json.loads на таком ответе рано или поздно упадёт. Просьба в инструкциях — это пожелание, а не гарантия.

Схема ответа через Pydantic

Pydantic позволяет описать нужную структуру обычным классом. Пакет openai превращает класс в JSON Schema, передаёт её модели и разбирает ответ обратно в объект. Если Pydantic ещё не установлен, выполните pip install pydantic.

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI()

class Lead(BaseModel):
    name: str
    phone: str
    topic: str

response = client.responses.parse(
    model="gpt-6-luna",
    instructions="Извлеки данные клиента из заявки.",
    input="Здравствуйте! Меня зовут Олег, перезвоните на +7 900 123-45-67, "
          "хочу узнать про доставку.",
    text_format=Lead,
)

lead = response.output_parsed
print(lead)
print(lead.name, "|", lead.phone)

Что увидим:

name='Олег' phone='+7 900 123-45-67' topic='доставка'
Олег | +7 900 123-45-67

Разберём, что изменилось. Вместо create вызывается client.responses.parse, а схема передаётся параметром text_format. Результат берётся не из output_text, а из output_parsed, и это уже объект класса Lead: к полям обращаемся через точку, редактор подсказывает их имена, а типы гарантированы. Модель физически не может вернуть лишнее поле или пропустить обязательное — схема ограничивает генерацию.

Списки, вложенность и фиксированные значения

Схемы могут быть сложнее. Поле может быть списком, другим классом Pydantic или одним значением из заранее заданного набора — для этого используют Literal из модуля typing.

from typing import Literal
from pydantic import BaseModel

class Item(BaseModel):
    product: str
    quantity: int

class Order(BaseModel):
    customer: str
    items: list[Item]
    urgency: Literal["low", "normal", "high"]

response = client.responses.parse(
    model="gpt-6-luna",
    instructions="Разбери заказ из сообщения клиента.",
    input="Это Мария. Срочно нужно 3 картриджа и 2 пачки бумаги к завтрашнему утру!",
    text_format=Order,
)

order = response.output_parsed
print(order.customer, order.urgency)
for item in order.items:
    print(f"- {item.product}: {item.quantity}")

Что увидим:

Мария high
- картридж: 3
- бумага: 2

Поле urgency может принять только одно из трёх значений, поэтому его безопасно использовать в условиях if: опечатки вроде "High" или "срочно" исключены. Это особенно ценно для классификации: категория обращения, тональность, приоритет. Поле quantity объявлено как int, и вы получаете число, а не строку «3 шт.».

Описания полей и отсутствующие данные

Модель видит имена полей, но не всегда понимает, что вы под ними имеете в виду. Подсказку можно дать через Field(description=...): описание попадёт в схему, и модель его прочитает. А если данных в тексте может не быть, объявите поле как допускающее None — иначе модель будет вынуждена что-то придумать, чтобы заполнить обязательное поле.

from pydantic import BaseModel, Field

class Ticket(BaseModel):
    category: Literal["billing", "delivery", "technical", "other"]
    summary: str = Field(description="Суть проблемы одним предложением")
    order_id: str | None = Field(description="Номер заказа, если он указан, иначе null")

response = client.responses.parse(
    model="gpt-6-luna",
    instructions="Классифицируй обращение в поддержку.",
    input="Не могу войти в личный кабинет, пишет 'неверный пароль'.",
    text_format=Ticket,
)
print(response.output_parsed)

Что увидим:

category='technical' summary='Пользователь не может войти в личный кабинет из-за ошибки пароля.' order_id=None

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

Частые ошибки

  • Вызывать create и читать output_parsed. Разбор по схеме делает метод client.responses.parse с параметром text_format; у create поле output_parsed не заполнится.
  • Передавать в text_format объект вместо класса. Нужен сам класс Lead, а не Lead() и не строка "Lead".
  • Делать все поля обязательными. Если данных может не быть, используйте str | None, иначе модель начнёт их выдумывать.
  • Использовать str там, где значения ограничены. Для категорий и статусов берите Literal — тогда в коде не придётся сравнивать с десятком вариантов написания.

Наиболее частые вопросы

  • Можно ли обойтись без Pydantic? — Можно передать JSON Schema вручную, но классы Pydantic короче, понятнее и сразу дают объект с проверкой типов.
  • Занимает ли схема токены? — Да, схема передаётся модели и учитывается во входных токенах. Длинные описания полей немного увеличивают стоимость.
  • Гарантирует ли схема правильность данных? — Она гарантирует структуру и типы. Содержимое генерирует модель, поэтому его по-прежнему нужно проверять.
  • Можно ли использовать structured outputs в диалоге? — Да, parse принимает те же input, instructions и previous_response_id, что и create.

Итоги

Отличный результат! Теперь модель для вас не только собеседник, но и надёжный компонент программы: вы описываете схему классом Pydantic, вызываете client.responses.parse и получаете готовый объект с нужными полями, списками и ограниченными значениями. Вы знаете, как подсказать модели смысл поля и как не заставлять её выдумывать отсутствующие данные.

Этот навык — прямая подготовка к главной теме модуля. Чтобы модель могла вызывать инструменты, она должна возвращать имя функции и аргументы строго по схеме. В следующем уроке вы подключите function calling и напишете настоящий цикл агента своими руками.

На следующий урок