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

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

- URL: https://blog.laozhang.ai/ru/posts/structured-outputs-vs-function-calling
- Published: 2026-07-28
- Updated: 2026-10-07
- Author: LaoZhang AI Team (https://blog.laozhang.ai/ru/about)
- Topic: Руководства по API
- Tags: OpenAI API, Structured Outputs, Function Calling, Responses API, JSON Schema

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

Разница не в наличии JSON Schema. В OpenAI строгие аргументы функции сами используют механизм Structured Outputs. Поэтому противопоставление «надёжный JSON против ненадёжного вызова функции» не описывает текущий интерфейс. Нужно определить, какой результат вы ждёте: данные для потребителя или запрос на работу приложения. Это разделение прямо следует из [руководства OpenAI](https://developers.openai.com/api/docs/guides/structured-outputs).

| Задача | С чего начать | Что считать результатом |
|---|---|---|
| Извлечь артикул из уже переданного сообщения | 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](https://developers.openai.com/api/docs/guides/function-calling#handling-function-calls).

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

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

Явный `strict: true` закрепляет соответствие поддерживаемой схеме. Для function tools нужны `additionalProperties: false` у каждого объекта и все свойства в `required`. Бизнес-опциональное поле остаётся обязательным ключом, но его значение может быть `null`. Неподдерживаемая строгая схема вызывает ошибку запроса, а не обещание «модель как-нибудь справится». [Условия strict mode](https://developers.openai.com/api/docs/guides/function-calling#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](https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas).

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

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

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

![Иллюстрация различия между неизвестным остатком, подтверждённым нулём и известным количеством](https://blog.laozhang.ai/posts/ru/structured-outputs-vs-function-calling/img/unknown-stock.webp)

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

Ниже первый блок 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](https://developers.openai.com/api/docs/guides/structured-outputs) показывают это различие, но не заменяют проверку совместимости вашей установленной версии.

## 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. [Официальные примеры продолжения](https://developers.openai.com/api/docs/guides/function-calling#incorporating-results-into-response) задают нужный контекст.

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

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

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

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

![Иллюстрация сопоставления вызовов и результатов по call_id с проверкой количества в итоговой карточке](https://blog.laozhang.ai/posts/ru/structured-outputs-vs-function-calling/img/call-source-check.webp)

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

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

Чтобы самостоятельно прогнать основной путь без 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](https://developers.openai.com/api/docs/guides/structured-outputs).

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

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

`tool_choice: "auto"` допускает ноль, один или несколько вызовов; `required` требует один или больше, а принудительный выбор функции задаёт конкретное имя. `parallel_tool_calls: false` ограничивает ответ нулём или одним вызовом, но сам по себе не требует вызов. Ни одна из этих настроек не выдаёт разрешение на операцию и не подтверждает её успех. [Условия tool choice](https://developers.openai.com/api/docs/guides/function-calling#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. Выбор собственного протокола не даёт автоматической гарантии большей надёжности. [Официальное разделение](https://developers.openai.com/api/docs/guides/structured-outputs) помогает выбрать исходный интерфейс.

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

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

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

Он обеспечивает валидный JSON с документированными исключениями, но не соответствие вашей схеме. При Responses это `text.format: {"type": "json_object"}`. Нужна явная инструкция генерировать JSON, а строка `JSON` должна присутствовать в контексте: иначе возможен поток пробелов или ошибка API. Если Structured Outputs поддерживается, OpenAI [рекомендует его](https://developers.openai.com/api/docs/guides/structured-outputs#json-mode). JSON mode не решает проверку смысла и исполнения.

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

Chat Completions продолжает поддерживаться, но поля и форматы сообщений отличаются. Для нового проекта OpenAI рекомендует Responses; у GPT-6 Astra и GPT-6.1 Sol вызовы инструментов требуют именно Responses, что не означает такого требования для всех текстовых запросов. [Руководство Function Calling](https://developers.openai.com/api/docs/guides/function-calling) уточняет условия моделей. Для существующего цикла используйте [полный разбор переноса Function Calling на Responses API](https://blog.laozhang.ai/ru/posts/chat-completions-to-responses-api-function-calling-migration). Настройка проекта и ключа — отдельная задача, описанная в [руководстве по API-ключу OpenAI](https://blog.laozhang.ai/ru/posts/openai-api-key-free-trial); приведённые там региональные условия имеют собственную дату проверки.

## Источники

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

- [руководства OpenAI](https://developers.openai.com/api/docs/guides/structured-outputs) (developers.openai.com)
- [документации Function Calling](https://developers.openai.com/api/docs/guides/function-calling) (developers.openai.com)
