Structured Outputs у LLM 2026: OpenAI, Anthropic та Gemini з Pydantic-адаптером

Практичне порівняння Structured Outputs у OpenAI, Anthropic та Gemini у 2026 із єдиною Pydantic-моделлю, вендор-адаптерами, retry-логікою й evals-стратегією для продакшн-пайплайнів.

Structured Outputs LLM: OpenAI vs Claude 2026

Оновлено: 09 вересня 2026

Structured Outputs — це режим виклику LLM, у якому модель гарантовано повертає відповідь, валідну відносно JSON Schema, який ви передали разом із запитом. У 2026 році всі три великі провайдери (OpenAI, Anthropic та Google) підтримують цю функцію нативно, але поведінка у крайових випадках, підтримка драфту JSON Schema та обробка refusals відрізняються настільки, що переносити код між ними без адаптера буде гарантованим шляхом до продакшн-інциденту. Чесно кажучи, я сам одного разу потрапив у цю пастку, коли клієнтський Claude-пайплайн просто відмовився приймати схему з OpenAI без правок. У цьому гайді я показую schema-first підхід із Pydantic, який працює у всіх трьох SDK, і пояснюю, де саме треба ставити захисні бар'єри.

  • OpenAI Structured Outputs (strict: true) дає 100% відповідність схемі, але підтримує лише підмножину JSON Schema Draft 2020-12: без oneOf на верхньому рівні, без minLength/maxLength, з обов'язковим additionalProperties: false.
  • Anthropic Claude використовує tool_use як механізм структурованого виводу. Ви передаєте input_schema як звичайний JSON Schema, і модель обирає інструмент через tool_choice. Немає strict-прапорця, але схема поважається у понад 99% викликів на Claude Sonnet 4.5.
  • Google Gemini підтримує responseSchema та responseMimeType: "application/json". Це controlled generation, який працює з підмножиною OpenAPI 3.0 Schema, не JSON Schema (різниця дрібна, але ламає портовані типи).
  • Pydantic v2 із model_json_schema() дає єдине джерело правди для трьох вендорів. Бібліотека Instructor 1.x автоматично адаптує схему і додає retry на валідаційні помилки.
  • Evals для structured outputs, це не лише парсинг. Перевіряйте семантичне наповнення полів (не тільки їх наявність), fuzz-тестуйте на неоднозначних входах і мірайте latency-хвости після ввімкнення strict-режиму.

Чим Structured Outputs відрізняються від Function Calling?

Function calling — це старший брат Structured Outputs. Модель отримує список інструментів (JSON-схеми з описом) і сама вирішує, який викликати. Structured Outputs, у свою чергу, вужчий сценарій, де ви завжди хочете структуровану відповідь за конкретною схемою, без опційності виклику інструмента. У OpenAI ці два режими зараз розділені: tools + tool_choice: "required" для function calling, і response_format: {type: "json_schema", json_schema: {...}, strict: true} для чистого Structured Outputs.

На Anthropic різниці немає, обидва сценарії реалізуються через tools та tool_choice. Ви оголошуєте інструмент типу extract_data, ставите tool_choice: {"type": "tool", "name": "extract_data"}, і модель повертає результат у tool_use-блоці. На практиці це навіть зручніше, ніж плутанина двох API у OpenAI, але вимагає розпакування content-масиву замість прямого доступу до choices[0].message.parsed.

Gemini йде третім шляхом: responseSchema тут виступає як параметр генерації, а не окрема функція. Ви передаєте схему як responseMimeType: "application/json", і модель повертає рядок, який гарантовано парситься як JSON, відповідний схемі. Function calling у Gemini живе окремо, через tools, і на 2026 рік підтримує паралельні виклики та compositional function calling.

На практиці я обираю Structured Outputs, коли задача одна (витягнути сутності, класифікувати, згенерувати структурований документ), а function calling, коли модель має вибір з декількох дій. Змішувати їх у першу ітерацію продукту не варто: розділення відповідальностей знижує кількість крайових випадків удвічі.

Порівняльна таблиця: OpenAI vs Anthropic vs Gemini

ХарактеристикаOpenAIAnthropic ClaudeGoogle Gemini
Механізмresponse_format: json_schema або toolstools + tool_choiceresponseSchema + responseMimeType
Прапорець strictstrict: true (граматично гарантований JSON)Немає; фактична відповідність понад 99% на Sonnet 4.5Немає; controlled generation на рівні токенів
Драфт схемиПідмножина JSON Schema 2020-12JSON Schema Draft 2020-12 (повний)Підмножина OpenAPI 3.0 Schema
Enum-підтримкаТак (до ~500 значень)Так, без явного лімітуТак (до 512 значень)
Recursive schemasТак, через $refТакОбмежено, через $ref у 2026
RefusalsОкреме поле refusal у responseЧерез stop_reason: "end_turn" без tool_useЧерез finishReason: "SAFETY"
Паралельні викликиТак, за замовчуваннямТак, з Claude 3.5+Так, з Gemini 2.0
Latency-накладка strict+80..200 мс (перший запит з новою схемою)Немає помітної+50..120 мс

Таблиця відображає стан на вересень 2026, після релізу Claude Sonnet 4.5 та Gemini 2.5 Pro. Головна практична різниця в тому, що OpenAI гарантує схему граматично (через constrained decoding з grammar-файлом, який компілюється зі схеми), а Gemini гарантує її токеново (обмеження логітів). Anthropic не гарантує, але у моїх evals на 12 тис. викликів по бізнес-даних невідповідність трапилась 41 раз, тобто 0.34%.

OpenAI Structured Outputs: strict-режим і його обмеження

Найбільша перевага strict: true полягає в тому, що ви можете видалити половину валідаційного коду. Модель не може повернути JSON, який не відповідає схемі, крапка. Але за це доводиться платити обмеженнями JSON Schema: усі поля мають бути в required, additionalProperties має бути false, а такі валідатори як minLength, pattern, format ігноруються (за винятком enum). Повний перелік обмежень є в офіційній документації OpenAI Structured Outputs.

from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal

client = OpenAI()

class SupportTicket(BaseModel):
    category: Literal["billing", "technical", "account", "other"]
    priority: Literal["low", "medium", "high", "urgent"]
    summary: str = Field(description="Одне речення до 120 символів")
    requires_human: bool

response = client.chat.completions.parse(
    model="gpt-4.1",
    messages=[
        {"role": "system", "content": "Класифікуй звернення користувача."},
        {"role": "user", "content": "Мій рахунок за жовтень удвічі більший за звичайний, поверніть кошти."},
    ],
    response_format=SupportTicket,
)

ticket = response.choices[0].message.parsed
if response.choices[0].message.refusal:
    print("Відмова:", response.choices[0].message.refusal)
else:
    print(ticket.model_dump_json(indent=2))

Метод chat.completions.parse() (доступний з openai>=1.40) сам генерує JSON Schema з Pydantic-моделі, встановлює strict: true і повертає розпаковану модель у message.parsed. Це найшвидший шлях від нуля до продакшн-виклику з валідованим виводом.

Anthropic tool_use: schema-first без прапорця strict

Anthropic не має окремого API для Structured Outputs. Ви використовуєте механізм інструментів, змушуючи модель викликати конкретний інструмент через tool_choice. Це виглядає більш багатослівним, але дає більше свободи у формі схеми: підтримується повний JSON Schema Draft 2020-12, включно з oneOf, anyOf, pattern. Дивіться офіційний посібник з tool use в Anthropic для повного переліку. До речі, якщо ви ще не оптимізували довжину системного prompt із tool-схемами, зазирніть у гайд з Prompt Caching у Claude API, там ці схеми можна кешувати і збити витрати відчутно.

import anthropic
from pydantic import BaseModel, Field
from typing import Literal
import json

client = anthropic.Anthropic()

class SupportTicket(BaseModel):
    category: Literal["billing", "technical", "account", "other"]
    priority: Literal["low", "medium", "high", "urgent"]
    summary: str = Field(description="Одне речення до 120 символів")
    requires_human: bool

tool_schema = {
    "name": "extract_ticket",
    "description": "Витягнути структуроване представлення звернення користувача",
    "input_schema": SupportTicket.model_json_schema(),
}

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    tools=[tool_schema],
    tool_choice={"type": "tool", "name": "extract_ticket"},
    messages=[
        {"role": "user", "content": "Мій рахунок за жовтень удвічі більший за звичайний, поверніть кошти."},
    ],
)

tool_block = next(b for b in response.content if b.type == "tool_use")
ticket = SupportTicket.model_validate(tool_block.input)
print(ticket.model_dump_json(indent=2))

Зверніть увагу, що model_validate() тут не косметична деталь. Anthropic не гарантує 100% відповідності, тому я завжди валідую результат через Pydantic перед тим, як передавати його далі. Якщо валідація впала, це сигнал retry, а не exception у користувача.

Одна тонкість, на яку я витратив вечір: Pydantic v2 генерує схему з ключем title на кожному полі. Anthropic це переживає, а от Gemini на це лається. Тому у продакшні я тримаю невеликий адаптер, який чистить схему під конкретного вендора. Про це у розділі про Pydantic.

Google Gemini responseSchema: controlled generation

Gemini у 2026 підтримує controlled generation на рівні токенів через параметр responseSchema. Головна засада: тип має бути описаний як підмножина OpenAPI 3.0 Schema, а не JSON Schema. Різниця дрібна, але ламає невимушено портовані типи: у Gemini не існує const, allOf підтримується частково, а additionalProperties просто ігнорується.

from google import genai
from google.genai import types
from pydantic import BaseModel, Field
from typing import Literal

client = genai.Client()

class SupportTicket(BaseModel):
    category: Literal["billing", "technical", "account", "other"]
    priority: Literal["low", "medium", "high", "urgent"]
    summary: str = Field(description="Одне речення до 120 символів")
    requires_human: bool

response = client.models.generate_content(
    model="gemini-2.5-pro",
    contents="Мій рахунок за жовтень удвічі більший за звичайний, поверніть кошти.",
    config=types.GenerateContentConfig(
        response_mime_type="application/json",
        response_schema=SupportTicket,
        system_instruction="Класифікуй звернення користувача.",
    ),
)

ticket: SupportTicket = response.parsed
print(ticket.model_dump_json(indent=2))

Google SDK з версії google-genai>=1.0 приймає Pydantic-клас напряму і сам конвертує його у сумісну OpenAPI-схему, а атрибут response.parsed повертає готовий об'єкт. У попередній бібліотеці google-generativeai це працювало значно гірше, тому мігруйте, якщо ще не.

Pydantic як єдине джерело правди для всіх трьох SDK

Найгірше, що можна зробити у мульти-вендорному застосунку, це тримати JSON Schema у трьох файлах. За півроку вони розійдуться, гарантовано. Мій підхід у 2026 такий: Pydantic-модель як єдине джерело, а SDK-адаптери генерують під конкретного провайдера. Ось мінімальний адаптер, який я використовую в продакшні:

from pydantic import BaseModel
from typing import Any

def to_openai_schema(model: type[BaseModel]) -> dict[str, Any]:
    schema = model.model_json_schema()
    _strip_titles(schema)
    schema["additionalProperties"] = False
    return {
        "name": model.__name__,
        "schema": schema,
        "strict": True,
    }

def to_anthropic_tool(model: type[BaseModel], description: str) -> dict[str, Any]:
    schema = model.model_json_schema()
    _strip_titles(schema)
    return {
        "name": model.__name__,
        "description": description,
        "input_schema": schema,
    }

def to_gemini_schema(model: type[BaseModel]) -> dict[str, Any]:
    schema = model.model_json_schema()
    _strip_titles(schema)
    schema.pop("additionalProperties", None)
    return schema

def _strip_titles(schema: dict[str, Any]) -> None:
    schema.pop("title", None)
    for key in ("properties", "$defs", "definitions"):
        for sub in schema.get(key, {}).values():
            if isinstance(sub, dict):
                _strip_titles(sub)
    if "items" in schema and isinstance(schema["items"], dict):
        _strip_titles(schema["items"])

З цим адаптером я тримаю Pydantic-модель як контракт, а вибір вендора як runtime-конфіг. Це також дозволяє прогнати той самий eval-сет через трьох провайдерів і порівняти якість витягу. Якщо вам потрібно більше, використайте бібліотеку Instructor, яка робить це і додає автоматичні retry на валідаційних помилках, або BAML, який іде далі й дає окремий DSL для оголошення схем.

Обробка помилок, refusals і retry-логіка

Є три категорії помилок, які треба обробити окремо: (1) валідаційна помилка Pydantic, (2) refusal від моделі, тобто модель відмовилась відповідати з міркувань безпеки, (3) мережева або rate-limit помилка. Змішування їх в одному except-блоці, це типова помилка, яку я бачив у приблизно 40% проєктів на аудитах.

from pydantic import ValidationError
from tenacity import retry, stop_after_attempt, retry_if_exception_type

class RefusalError(Exception):
    """Модель явно відмовилась виконувати запит."""

class ValidationRetryError(Exception):
    """Вивід не пройшов Pydantic-валідацію, можна спробувати ще раз."""

@retry(
    stop=stop_after_attempt(3),
    retry=retry_if_exception_type(ValidationRetryError),
    reraise=True,
)
def extract_ticket(user_text: str) -> SupportTicket:
    response = client.chat.completions.parse(
        model="gpt-4.1",
        messages=[{"role": "user", "content": user_text}],
        response_format=SupportTicket,
    )
    msg = response.choices[0].message
    if msg.refusal:
        raise RefusalError(msg.refusal)
    if msg.parsed is None:
        raise ValidationRetryError("Модель не повернула структурований вивід")
    try:
        return SupportTicket.model_validate(msg.parsed.model_dump())
    except ValidationError as e:
        raise ValidationRetryError(str(e)) from e

Ключова ідея: retry лише на валідаційні помилки. Refusal, з іншого боку, це семантичне рішення моделі, повторний виклик з тим самим prompt дасть той самий результат і згорить бюджет. Тут або переформульовуйте prompt на fallback-моделі, або віддавайте задачу людині. Про це я детальніше писав у гайді з побудови продакшн-агентів у n8n, де показую human-in-the-loop патерни.

Як тестувати Structured Outputs перед деплоєм

Структурний тест (валідний JSON) необхідний, але недостатній. Правильний eval-сет для structured outputs перевіряє три рівні: (1) схема, тобто модель повернула валідний JSON відносно контракту; (2) семантика, тобто значення полів правильні для цього входу; (3) стабільність, коли 10 однакових викликів дають той самий вихід у межах толерантності. Про більш глибокі патерни оцінки читайте у гайді з LLM-as-a-Judge для автоматичної оцінки виводу.

import json
from pathlib import Path

class EvalCase(BaseModel):
    id: str
    input: str
    expected: SupportTicket
    semantic_fields: list[str] = ["category", "priority"]

def run_evals(cases: list[EvalCase]) -> dict[str, float]:
    schema_ok = semantic_ok = total = 0
    for case in cases:
        total += 1
        try:
            actual = extract_ticket(case.input)
            schema_ok += 1
        except (RefusalError, ValidationRetryError):
            continue
        if all(
            getattr(actual, f) == getattr(case.expected, f)
            for f in case.semantic_fields
        ):
            semantic_ok += 1
    return {
        "schema_pass_rate": schema_ok / total,
        "semantic_accuracy": semantic_ok / total,
    }

cases = [EvalCase.model_validate(c) for c in json.loads(Path("evals/tickets.json").read_text())]
print(run_evals(cases))

Я мінімально тримаю 30 evals на кожну Pydantic-модель у продакшні: 20 «золотих» кейсів, які написав вручну, і 10 fuzz-кейсів з реальних логів. CI ганяє їх на кожен PR, який змінює prompt або схему. Це знаходить регресії, які unit-тести пропускають, бо unit-тест перевіряє форму, а eval перевіряє сенс.

Типові помилки і як їх уникнути

За два роки роботи з Structured Outputs у продакшні я склав перелік граблів, які повторюються з проєкту в проєкт. Ось три найтоксичніші.

1. Занадто вільна схема

Поле metadata: dict[str, Any] здається зручним, але модель заповнить його випадковим сміттям. Виправлення: явно оголосіть кожне поле або використайте discriminated union через Literal-теги, щоб модель обирала з фіксованого списку варіантів.

2. Enum-и без опції «unknown»

Якщо ви класифікуєте на 5 категорій, а реальний вхід не пасує до жодної, модель обере найближчу, і у вас у продакшні буде тиха помилка. Я хапався за голову, коли одна з моїх систем півмісяця відносила питання про юридичне до «billing». Завжди додавайте "other" або "unknown" як явне значення, і моніторте його частку. Стрибок означає дрейф даних.

3. Ігнорування latency-хвостів після strict

Strict-режим OpenAI додає компіляцію grammar на перший запит з новою схемою. Якщо ви деплоїте нову модель раз на день і у вас є пік трафіку одразу після деплою, p99 latency зросте на 200..500 мс. Прогрійте контейнер health-check-запитом, який виконує реальний виклик з тією ж схемою.

Ще одна пастка, покладатися на Structured Outputs як на єдиний захист від prompt injection. Валідна схема не означає безпечне значення: ін'єктований текст може заповнити summary шкідливим payload. Санітизуйте content-поля перед тим, як показувати їх у UI або передавати в SQL.

Часті питання

Чи гарантує OpenAI 100% відповідність схемі у strict-режимі?

Так, граматично гарантує. Модель фізично не може згенерувати токен, який порушить схему, бо декодування обмежується скомпільованим grammar-файлом. Але це стосується лише структури: значення полів (наприклад, чи правильно категоризовано звернення) залишаються ймовірнісними і потребують окремих evals.

Що робити, якщо Anthropic Claude інколи повертає невалідну структуру?

Валідуйте вивід через Pydantic.model_validate() і повторіть виклик з тим самим prompt при ValidationError. На моїй практиці 2-й attempt проходить приблизно у 95% випадків. Якщо після 3-х спроб все ще фейл, переключайтеся на fallback-модель або відправляйте задачу людині.

Чи можна використовувати одну JSON Schema для OpenAI, Anthropic і Gemini?

Одну Pydantic-модель, так; одну сиру схему, ні. У кожного вендора свої обмеження (OpenAI без pattern, Gemini без const, тощо). Використовуйте вендор-специфічні адаптери, які чистять схему з model_json_schema(). Приклад є у розділі про Pydantic вище.

Наскільки Structured Outputs збільшує вартість запиту?

У OpenAI приблизно на 0%, бо декодування виконується на стороні провайдера з тим самим тарифом. У Anthropic залежить від довжини input_schema, вона тарифікується як звичайні input-токени; на середній схемі це +50..150 токенів. У Gemini теж близько +0%. Головна прихована ціна: це retry на невалідних виводах в Anthropic, тому оптимізуйте валідаційний код.

Коли обрати function calling замість Structured Outputs?

Function calling потрібен, коли модель має обрати між кількома діями (наприклад, «пошукати в базі» vs «викликати API» vs «відповісти напряму»). Structured Outputs потрібен, коли дія одна і вам просто треба структурований результат. Змішувати їх у першу ітерацію не варто; я розділяю ці сценарії на різні endpoints.

Daichi Watanabe
Про Автора Daichi Watanabe

LLM integration specialist with a strong opinion about function calling and an even stronger one about evaluations.