Structured Outputs у LLM 2026: OpenAI, Anthropic та Gemini з Pydantic-адаптером
Практичне порівняння Structured Outputs у OpenAI, Anthropic та Gemini у 2026 із єдиною Pydantic-моделлю, вендор-адаптерами, retry-логікою й evals-стратегією для продакшн-пайплайнів.
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
Характеристика
OpenAI
Anthropic Claude
Google Gemini
Механізм
response_format: json_schema або tools
tools + tool_choice
responseSchema + responseMimeType
Прапорець strict
strict: true (граматично гарантований JSON)
Немає; фактична відповідність понад 99% на Sonnet 4.5
Немає; controlled generation на рівні токенів
Драфт схеми
Підмножина JSON Schema 2020-12
JSON 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.
Anthropic тихо змінив TTL prompt cache з 60 до 5 хвилин — і це збільшило витрати багатьох команд на 30–60%. Розбираємо, як перебудувати кешування у 2026: коли 1-годинний TTL, як уникнути беззвучних промахів, де ставити cache_control, як виміряти hit rate. З Python-кодом і реальними розрахунками.