Перейти к основному содержанию

Structured Outputs или Function Calling в OpenAI: что выбрать

Structured Outputs задаёт форму ответа, а Function Calling передаёт приложению запрос на работу. Строгие аргументы функции тоже используют Structured Outputs; выбор зависит от того, нужны ли внешние данные и кому предназначен финальный объект.

LaoZhang AI TeamОпубликованоОбновлено 8 мин чтения
Содержание
Два пути получения ответа: готовый объект и запрос инструмента с исполнением в приложении

Для готового объекта, который прочитает программа, выбирайте Structured Outputs через text.format. Для получения внешних данных или выполнения операции выбирайте Function Calling: модель запрашивает функцию, ваше приложение проверяет запрос и исполняет код. Гибрид нужен, когда после внешней работы требуется ещё и финальный объект определённой формы.

Разница не в наличии JSON Schema. В OpenAI строгие аргументы функции сами используют механизм Structured Outputs. Поэтому противопоставление «надёжный JSON против ненадёжного вызова функции» не описывает текущий интерфейс. Нужно определить, какой результат вы ждёте: данные для потребителя или запрос на работу приложения. Это разделение прямо следует из руководства OpenAI.

ЗадачаС чего начатьЧто считать результатом
Извлечь артикул из уже переданного сообщенияStructured OutputsОбъект с артикулом или явно обозначенным отсутствием данных
Проверить остаток товара на складеFunction CallingПриложение прочитало источник и вернуло результат конкретного вызова
Проверить остаток и подготовить карточку фиксированной формыFunction Calling + Structured OutputsКарточка согласуется с результатом чтения
Показать уже готовую запись складаОбычный код приложенияЕсли преобразование однозначно, модель для форматирования не нужна

Не добавляйте инструмент только ради фигурных скобок. И не принимайте правильно оформленные аргументы за подтверждение выполненного действия.

Две похожие схемы отвечают на разные вопросы

Схема text.format описывает ответ модели. Например, программа получает sku, availability и units. Схема tools[].parameters описывает аргументы функции, которую модель просит вызвать: например, только sku для lookup_stock.

В Responses API описание function tool плоское: type, name, description, parameters, strict находятся в одном объекте. Ответ с запросом функции содержит элемент function_call; его arguments — JSON-строка. Приложение возвращает function_call_output с исходным call_id. Поле id самого элемента — другой идентификатор. Полный цикл описан в документации Function Calling.

Можно разработать собственный диспетчер поверх Structured Outputs: модель выдаёт объект с названием операции и параметрами, а приложение его разбирает и вызывает обработчик. Это рабочая архитектура, если вы определили протокол и проверки. Но JSON не исполняет функцию самостоятельно, а маршрутизация, история, сопоставление результатов и повторы становятся частью вашего протокола. Нативный Function Calling уже задаёт сообщения вызова и результата. Сам по себе выбор собственного диспетчера не доказывает выигрыш в точности, скорости или стоимости.

Что именно гарантирует strict: true

Явный strict: true закрепляет соответствие поддерживаемой схеме. Для function tools нужны additionalProperties: false у каждого объекта и все свойства в required. Бизнес-опциональное поле остаётся обязательным ключом, но его значение может быть null. Неподдерживаемая строгая схема вызывает ошибку запроса, а не обещание «модель как-нибудь справится». Условия strict mode важны и при обновлении старой интеграции.

Если strict пропущен, текущий Responses API пытается привести схему к строгому режиму, когда это возможно. При несовместимости он переходит к best-effort Function Calling; у инструмента в ответе будет strict: false. В Chat Completions режим по умолчанию остаётся нестрогим. Явный strict: false отключает строгий режим и в Responses. Поэтому отсутствие поля нельзя считать гарантией strict, а Function Calling нельзя объявлять нестрогим по определению.

Structured Outputs поддерживает подмножество JSON Schema: корень должен быть объектом, а не anyOf; вложенные объекты закрываются, поля перечисляются в required. Не переносите без проверки произвольную схему с allOf, not или if/then/else. Допустимые ограничения строк, чисел и массивов также отличаются для обычных и fine-tuned моделей. У fine-tuned моделей при нескольких параллельных вызовах strict отключается — это отдельное условие, а не свойство всех инструментов. Актуальные правила приведены в Supported schemas.

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

Финальный типизированный ответ: неизвестное значение должно быть допустимым

Возьмём карточку остатка. Если артикул отсутствует, нельзя заставлять модель выбирать между «есть» и «нет»: оба ответа будут выдумкой. В нашей схеме null означает отсутствие установленного значения, а reason поясняет причину. Число 0 означает известный нулевой остаток и не заменяет неизвестное.

Иллюстрация различия между неизвестным остатком, подтверждённым нулём и известным количеством

Схематический пример: отсутствующие данные и проверенный нулевой остаток требуют разных значений.

Ниже первый блок Python задаёт схему и конструктор запроса. Следующий продолжает тот же файл. Код не создаёт SDK-клиент и не отправляет сетевые запросы: он позволяет проверить важную логику на вымышленных ответах. Для реальной интеграции request должен возвращать Responses-ответ в виде словаря, а model — указывать доступную вашему проекту модель с поддержкой нужных функций.

python
import json

CARD_SCHEMA = {
    "type": "object",
    "properties": {
        "sku": {"type": ["string", "null"]},
        "availability": {
            "type": ["string", "null"],
            "enum": ["in_stock", "out_of_stock", None],
        },
        "units": {"type": ["integer", "null"]},
        "reason": {"type": "string"},
    },
    "required": ["sku", "availability", "units", "reason"],
    "additionalProperties": False,
}
FORMAT = {
    "type": "json_schema", "name": "stock_card",
    "strict": True, "schema": CARD_SCHEMA,
}


def final_request(model, history):
    return {
        "model": model,
        "input": history,
        "text": {"format": FORMAT},
    }


unknown_card = {
    "sku": None, "availability": None, "units": None,
    "reason": "Нужен артикул товара",
}

В availability недостаточно добавить null только в type: enum тоже ограничивает допустимые значения. Поэтому в Python-описании есть None, которое при JSON-сериализации превращается в null. Если перечисление содержит только строки, значение null ему не соответствует. Это обычное правило пересечения ограничений JSON Schema; локальная проверка не подтверждает принятие схемы сервером OpenAI.

Когда в исходном сообщении уже содержатся все данные, передавайте их вместе с инструкцией не придумывать неизвестное в final_request. Если артикул не указан, приложение может сразу показать unknown_card. Если проверенные данные дают однозначную карточку, её можно собрать кодом. Structured Outputs полезен, когда модель действительно должна извлечь или преобразовать информацию, а потребителю нужен стабильный формат.

В REST-форме Responses используется именно text.format. В Python-хелпере responses.parse тип задают через text_format=BaseModel, а в JavaScript — через text.format=zodTextFormat(...). response_format принадлежит интерфейсу Chat Completions; вложенность этих форматов нельзя смешивать. Примеры SDK показывают это различие, но не заменяют проверку совместимости вашей установленной версии.

Function Calling: приложение обязано завершить работу

Пользователь спрашивает: «Сколько осталось товара K-14?» Внешнее чтение необходимо, если ответ должен отражать склад. Ниже источник — маленький вымышленный каталог в памяти, а не реальная база. lookup_stock действительно читает этот каталог; для специального демонстрационного артикула моделирует таймаут. Неизвестный товар возвращает ошибку, а не случайное число.

Обработчик принимает только артикулы, явно разрешённые для текущего запроса. В настоящем сервисе такое множество формируется после проверки пользователя и его доступа к товарам; модель не должна расширять доступ собственными аргументами.

python
TOOL = {
    "type": "function", "name": "lookup_stock",
    "description": "Прочитать остаток по явно указанному артикулу",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {"sku": {"type": "string"}},
        "required": ["sku"], "additionalProperties": False,
    },
}
CATALOG = {"K-14": 8, "K-15": 0}


def lookup_stock(args, allowed_skus):
    if (not isinstance(args, dict) or set(args) != {"sku"}
            or not isinstance(args["sku"], str)):
        return {"ok": False, "error": "BAD_ARGUMENTS"}
    sku = args["sku"]
    if sku not in allowed_skus:
        return {"ok": False, "error": "NOT_ALLOWED"}
    if sku == "DEMO-TIMEOUT":
        raise TimeoutError("Демонстрационный таймаут")
    if sku not in CATALOG:
        return {"ok": False, "error": "NOT_FOUND"}
    return {"ok": True, "sku": sku, "units": CATALOG[sku]}


def ensure_completed(response):
    if response.get("error"):
        raise RuntimeError("API_ERROR")
    if response.get("status") != "completed":
        reason = (response.get("incomplete_details") or {}).get("reason")
        raise RuntimeError(f"NOT_COMPLETED:{reason}")
    for item in response.get("output", []):
        if item.get("type") == "message":
            for part in item.get("content", []):
                if part.get("type") == "refusal":
                    raise RuntimeError("REFUSAL:" + part.get("refusal", ""))


def dispatch(response, allowed_skus, cache):
    ensure_completed(response)
    replies, records = [], []
    for item in response.get("output", []):
        if item.get("type") != "function_call":
            continue
        call_id = item.get("call_id")
        if not isinstance(call_id, str) or not call_id:
            raise RuntimeError("MISSING_CALL_ID")
        signature = (item.get("name"), item.get("arguments"))
        if call_id in cache:
            old_signature, result = cache[call_id]
            if signature != old_signature:
                raise RuntimeError("CALL_ID_CONFLICT")
        else:
            if item.get("name") != "lookup_stock":
                result = {"ok": False, "error": "UNKNOWN_TOOL"}
            else:
                try:
                    args = json.loads(item["arguments"])
                    result = lookup_stock(args, allowed_skus)
                except (ValueError, TypeError, KeyError):
                    result = {"ok": False, "error": "BAD_ARGUMENTS"}
                except TimeoutError:
                    result = {"ok": False, "error": "STOCK_TIMEOUT"}
            cache[call_id] = (signature, result)
        records.append(result)
        replies.append({
            "type": "function_call_output", "call_id": call_id,
            "output": json.dumps(result, ensure_ascii=False),
        })
    return replies, records


def parse_card(response, expected_sku, record):
    ensure_completed(response)
    if any(i.get("type") == "function_call" for i in response["output"]):
        raise RuntimeError("UNEXPECTED_FINAL_CALL")
    texts = [p["text"] for i in response["output"]
             if i.get("type") == "message"
             for p in i.get("content", []) if p.get("type") == "output_text"]
    if len(texts) != 1:
        raise ValueError("EXPECTED_ONE_JSON_TEXT")
    card = json.loads(texts[0])
    if not isinstance(card, dict) or set(card) != set(CARD_SCHEMA["required"]):
        raise ValueError("BAD_CARD_KEYS")
    if card["sku"] is not None and not isinstance(card["sku"], str):
        raise ValueError("BAD_SKU_TYPE")
    if card["availability"] not in ("in_stock", "out_of_stock", None):
        raise ValueError("BAD_AVAILABILITY")
    if card["units"] is not None and type(card["units"]) is not int:
        raise ValueError("BAD_UNITS_TYPE")
    if not isinstance(card["reason"], str):
        raise ValueError("BAD_REASON_TYPE")
    if card["sku"] != expected_sku:
        raise ValueError("WRONG_SKU")
    if record and record.get("ok") is True:
        units = record["units"]
        availability = "in_stock" if units > 0 else "out_of_stock"
        if (record["sku"] != expected_sku or card["units"] != units
                or card["availability"] != availability):
            raise ValueError("CARD_CONTRADICTS_SOURCE")
    elif (card["units"] is not None or card["availability"] is not None
          or not card["reason"].strip()):
        raise ValueError("UNKNOWN_MUST_STAY_UNKNOWN")
    return card


def stock_flow(request, model, sku):
    if not isinstance(sku, str) or not sku.strip():
        return dict(unknown_card)
    history = [
        {"role": "developer", "content": (
            "Прочитай остаток через lookup_stock. Не придумывай значения. "
            "При ошибке units и availability должны быть null; поясни причину."
        )},
        {"role": "user", "content": f"Проверь остаток товара {sku}"},
    ]
    cache, records = {}, []
    for _ in range(3):
        response = request({
            "model": model, "input": history, "tools": [TOOL],
            "tool_choice": "required" if not records else "auto",
        })
        replies, batch = dispatch(response, {sku}, cache)
        # Сохраняем ВСЕ элементы, включая reasoning, а не только вызовы.
        history.extend(response["output"])
        if not replies:
            if not records:
                raise RuntimeError("NO_REQUIRED_LOOKUP")
            break
        history.extend(replies)
        records.extend(batch)
    else:
        raise RuntimeError("TOOL_ROUND_LIMIT")
    final_body = final_request(model, history)
    final_body.update({"tools": [TOOL], "tool_choice": "none"})
    final = request(final_body)
    return parse_card(final, sku, records[-1])

Этот контур обрабатывает ноль, один и несколько вызовов, а также дополнительные раунды. Все результаты получают исходные call_id. История включает весь output, в том числе элементы reasoning, которые нужно сохранять при продолжении соответствующих моделей. В Python-документации используется такой способ накопления истории; в текущем JavaScript-примере SDK есть toResponseInputItems(response.output). Слепое копирование Python-операции в JavaScript не подтверждает допустимые типы SDK. Официальные примеры продолжения задают нужный контекст.

У нашей однопозиционной карточки несколько вызовов допустимы только для разрешённого артикула. После повторных чтений выбран последний результат. Для сравнения нескольких товаров нужен список карточек и отдельное сопоставление каждой записи; нельзя перенести эту политику выбора последней записи на другой сценарий.

Кэш по call_id предотвращает повторное исполнение того же вызова внутри этого запуска; повтор с другим содержимым останавливает процесс. Это не долговечная гарантия «ровно один раз». Два разных call_id могут обозначать одно бизнес-действие, а после перезапуска кэш исчезнет. Для резервирования или оплаты нужен устойчивый ключ операции, подтверждение разрешения и проверка результата в целевой системе.

Гибрид нужен после инструмента, а не вместо него

В примере финальный запрос добавляет text.format и tool_choice: "none". Модель получает результат чтения, затем выдаёт карточку. Объект проходит parse_card: даже schema-valid значение units: 80 отклоняется, если каталог вернул 8.

Иллюстрация сопоставления вызовов и результатов по call_id с проверкой количества в итоговой карточке

Иллюстрация демонстрационного контура: результат каждого вызова связан с его идентификатором, а число в карточке сверяется с источником.

Для простого отображения остатка проще собрать карточку обычным кодом. Второй запрос модели оправдан, когда нужен ещё и содержательный разбор полученных данных: например, объяснение доступности вместе с извлечением сведений из нескольких источников. Строгая схема оформляет этот результат, а проверка приложения связывает его с источниками. Она не делает второй запрос обязательным для каждого инструмента.

Чтобы самостоятельно прогнать основной путь без API, добавьте третий блок в тот же файл:

python
from copy import deepcopy


def done(output):
    return {"status": "completed", "error": None, "output": output}


def text_response(card):
    return done([{"type": "message", "content": [
        {"type": "output_text", "text": json.dumps(card, ensure_ascii=False)}
    ]}])


def call(call_id, sku):
    return {"type": "function_call", "id": "item_" + call_id,
            "call_id": call_id, "name": "lookup_stock",
            "arguments": json.dumps({"sku": sku})}


class DemoResponses:
    def __init__(self):
        self.step = 0
        self.requests = []

    def __call__(self, body):
        self.requests.append(deepcopy(body))
        self.step += 1
        if self.step == 1:
            return done([
                {"type": "reasoning", "id": "rs_demo", "summary": []},
                call("call_A", "K-14"), call("call_B", "K-14"),
            ])
        if self.step == 2:
            replies = [i for i in body["input"]
                       if i.get("type") == "function_call_output"]
            assert [i["call_id"] for i in replies] == ["call_A", "call_B"]
            assert any(i.get("type") == "reasoning" for i in body["input"])
            return done([])
        assert body["tool_choice"] == "none"
        assert body["text"]["format"] == FORMAT
        return text_response({
            "sku": "K-14", "availability": "in_stock", "units": 8,
            "reason": "Данные демонстрационного каталога",
        })


demo = DemoResponses()
print(json.dumps(stock_flow(demo, "DEMO_MODEL", "K-14"), ensure_ascii=False))

Вывод — карточка с sku: "K-14", availability: "in_stock" и units: 8. DEMO_MODEL здесь просто метка для подставного транспорта, не имя модели OpenAI. Локально проверены синтаксис и ветви обработчиков на искусственных ответах; реальные запросы, принятие схемы OpenAI, точность модели и совместимость установленного SDK этим не проверялись.

Отказ, неполный ответ и ошибка инструмента — разные ветки

Проверяйте состояние ответа до разбора JSON и исполнения функций. Ответ со status: "completed" всё ещё может содержать блок refusal. Отказ — отдельный результат, а не карточка со статусом «нет товара». При status: "incomplete" смотрите incomplete_details.reason, в частности max_output_tokens или content_filter; частично полученный JSON нельзя дополнять до бизнес-успеха. Эти случаи описаны в руководстве Structured Outputs.

В нашем примере ensure_completed останавливает такие ответы. Ошибка функции, напротив, возвращается как результат конкретного call_id, и карточка должна сохранить неизвестность. Таймаут склада превращается в STOCK_TIMEOUT; проверка результата не разрешает скрыть его за известным остатком. Транспортная ошибка самого запроса должна обрабатываться адаптером request: исключение прерывает поток, а результат не считается полученным.

СитуацияСледующее действие
Нет артикулаЗапросить артикул; не делать фиктивное чтение
Получен отказ моделиПоказать отдельное состояние отказа
Ответ неполныйОстановить разбор; решить, допустим ли новый запрос
Склад вернул таймаутСохранить неизвестный остаток; при разрешённом повторе ограничить число попыток
Карточка расходится с источникомОтклонить карточку и не показывать неверное значение
Неясен результат изменяющей операцииСначала проверить состояние операции; не повторять запись вслепую

tool_choice: "auto" допускает ноль, один или несколько вызовов; required требует один или больше, а принудительный выбор функции задаёт конкретное имя. parallel_tool_calls: false ограничивает ответ нулём или одним вызовом, но сам по себе не требует вызов. Ни одна из этих настроек не выдаёт разрешение на операцию и не подтверждает её успех. Условия tool choice относятся к запросу модели, а не к факту исполнения.

Проверяйте форму, смысл и внешний результат отдельно

Для нашего каталога проверки различаются вполне конкретно:

  1. Ответ завершён, ошибки и отказ обработаны.
  2. У карточки ровно нужные поля; units — целое число либо null, значение availability допустимо.
  3. Артикул совпадает с запросом, остаток — с прочитанной записью, неизвестное не превращено в ноль.
  4. Чтение действительно вызвано и его результат связан с конкретным call_id. При записи нужен ещё подтверждённый результат операции, а не только сообщение модели.

Разбор JSON покрывает лишь часть второго пункта. Наличие output_parsed тоже не доказывает правильность значений или выполненное действие. Для собственных проверок полезны отсутствующий артикул, неизвестный инструмент, недопустимые аргументы, нулевой остаток, таймаут, повторный и конфликтующий call_id, несколько вызовов, отказ, обе причины incomplete и объект правильной формы с неверным числом. Именно такие искусственные случаи отличают проверку обработчика от проверки одного счастливого пути.

Частые вопросы

Может ли Structured Outputs заменить Function Calling?

Да, если вы сознательно строите собственный протокол: структурированный объект обозначает запрос, а диспетчер приложения проверяет и исполняет его. Исполнение всё равно остаётся в приложении. Нативный Function Calling уже предоставляет типы сообщений и call_id; строгие аргументы в OpenAI тоже используют Structured Outputs. Выбор собственного протокола не даёт автоматической гарантии большей надёжности. Официальное разделение помогает выбрать исходный интерфейс.

Гарантирует ли required, что данные известны и функция выполнена?

Нет. В схеме required требует ключ, поэтому неизвестное значение нужно выразить явно, например через null. В tool_choice слово required требует запрос функции в нормальном завершённом ответе. Приложение ещё должно проверить и исполнить этот запрос; отказ, неполный ответ и ошибка не становятся успешной операцией.

Почему не использовать JSON mode?

Он обеспечивает валидный JSON с документированными исключениями, но не соответствие вашей схеме. При Responses это text.format: {"type": "json_object"}. Нужна явная инструкция генерировать JSON, а строка JSON должна присутствовать в контексте: иначе возможен поток пробелов или ошибка API. Если Structured Outputs поддерживается, OpenAI рекомендует его. JSON mode не решает проверку смысла и исполнения.

Нужно ли переносить весь проект на Responses ради этой схемы?

Chat Completions продолжает поддерживаться, но поля и форматы сообщений отличаются. Для нового проекта OpenAI рекомендует Responses; у GPT-6 Astra и GPT-6.1 Sol вызовы инструментов требуют именно Responses, что не означает такого требования для всех текстовых запросов. Руководство Function Calling уточняет условия моделей. Для существующего цикла используйте полный разбор переноса Function Calling на Responses API. Настройка проекта и ключа — отдельная задача, описанная в руководстве по API-ключу OpenAI; приведённые там региональные условия имеют собственную дату проверки.

Источники2

Внешние страницы, на которые ссылается это руководство, в порядке упоминания. Последнее обновление: 7 окт. 2026 г..

  1. 1.руководства OpenAIdevelopers.openai.com/api/docs/guides/structured-outputs
  2. 2.документации Function Callingdevelopers.openai.com/api/docs/guides/function-calling
Переход от Assistant, Thread и Run к конфигурации приложения, Responses и явному циклу инструментов
Руководства по API

Миграция с OpenAI Assistants API на Responses API: история, функции и откат

После отключения Assistants перенос начинается с ваших резервных копий, а не с выгрузки Thread через API. Ниже — преобразование сохранённых сообщений, цикл функций для Responses и проверка состояния, поиска и отката.

11 мин
Вызов функции проходит проверку и обработчик приложения, а результат возвращается модели с исходным call_id
Руководства по API

Как перенести вызов функций с Chat Completions на Responses API: полный контур на Python

Для миграции нужно восстановить весь цикл: принять function_call, проверить и выполнить функцию, вернуть function_call_output с тем же call_id и получить завершённый ответ. Пример на Python отделяет запись в приложении от обещания модели.

9 мин
Карта получения API-ключа: поддерживаемая страна ведет к Platform, project key, отдельному billing и первому ответу
Руководства по API

Как получить API-ключ ChatGPT в 2026 году: официальный путь и ограничения для России

Ключ, который обычно называют ChatGPT API key, создается в OpenAI Platform. Но сначала нужно проверить фактическую страну: России нет в текущем официальном списке. Для поддерживаемых стран настройка завершается после project key, API billing и успешного Responses-запроса.

5 мин
GPT‑6 Astra API: согласовать адрес сервиса, ключ проекта и имя модели, затем проверить completed и непустой output_text
ChatGPT и OpenAI

Доступ к GPT-6 Astra API: ключ, первый запрос и ошибки подключения

По данным OpenAI на 5 сентября 2026 года, GPT-6 Astra доступна в API. Разберём, какой ключ нужен для прямого подключения, кому подходит этот способ и как проверить завершённый ответ. Для России действуют отдельные ограничения доступности OpenAI.

6 мин