Structured Output
Агенты работают с данными, а не текстом. Чтобы передавать результаты LLM между компонентами системы, нужен предсказуемый формат: JSON, Pydantic-модели, типизированные структуры. Structured output — это не «попросить модель написать JSON», а надёжный технический контракт между LLM и вашим кодом.
Проблема: LLM — это текст, агент — это данные
По умолчанию LLM возвращает свободный текст. Парсить его руками ненадёжно: модель может поменять формат между вызовами, добавить преамбулу или завернуть JSON в markdown-блок.
- Модель добавляет
```json ... ```вокруг ответа - Пишет «Вот результат:» перед JSON
- Добавляет trailing comma (невалидный JSON)
- Меняет регистр ключей или называет поля по-другому
- Иногда вообще отвечает текстом вместо JSON
Надёжность разных методов получения структурированного вывода:
Методы: три подхода
- Работает везде
- Максимальная гибкость
- Ненадёжен (~55–82%)
- Нужен robust парсинг
- Не гарантирует схему
- Гарантированный JSON
- Strict mode: 100% схема
- Нативная поддержка
- Только OpenAI-совместимые
- Strict: ограничения схемы
- Надёжнее JSON mode
- Семантически понятен модели
- Работает у всех топ-провайдеров
- Чуть сложнее в коде
- Лишние токены на schema
Метод 1: промпт + robust парсинг
Даже с ненадёжным промптом можно получить стабильный результат — если написать хороший парсер с несколькими fallback-стратегиями.
import json
import re
from typing import TypeVar, Type
from pydantic import BaseModel, ValidationError
T = TypeVar("T", bound=BaseModel)
def extract_json(text: str) -> str | None:
"""
Пытается извлечь JSON из произвольного текста.
Стратегии по убыванию надёжности:
1. Весь текст — уже JSON
2. JSON в markdown-блоке ```json ... ```
3. Первый {...} в тексте
4. Первый [...] в тексте
"""
# 1. Прямой парсинг
try:
json.loads(text.strip())
return text.strip()
except json.JSONDecodeError:
pass
# 2. Markdown code block
md_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', text, re.DOTALL)
if md_match:
candidate = md_match.group(1).strip()
try:
json.loads(candidate)
return candidate
except json.JSONDecodeError:
pass
# 3. Первый JSON-объект {...}
brace_match = re.search(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', text, re.DOTALL)
if brace_match:
candidate = brace_match.group()
try:
json.loads(candidate)
return candidate
except json.JSONDecodeError:
pass
# 4. Жадный поиск — от первой { до последней }
start = text.find('{')
end = text.rfind('}')
if start != -1 and end > start:
candidate = text[start:end+1]
try:
json.loads(candidate)
return candidate
except json.JSONDecodeError:
pass
return None
def parse_llm_response(text: str, model: Type[T]) -> T:
"""
Парсим ответ LLM в Pydantic-модель с максимальным fallback.
Бросает ValueError если не удалось.
"""
raw = extract_json(text)
if raw is None:
raise ValueError(f"No JSON found in LLM response:\n{text[:200]}")
try:
return model.model_validate_json(raw)
except ValidationError as e:
# Попробуем исправить частые проблемы
# 1. Trailing commas: {"a": 1,} → {"a": 1}
fixed = re.sub(r',\s*([}\]])', r'\1', raw)
try:
return model.model_validate_json(fixed)
except ValidationError:
pass
# 2. Одинарные кавычки → двойные
fixed2 = re.sub(r"(?
import anthropic
import json
from pydantic import BaseModel, Field
client = anthropic.AsyncAnthropic()
class JobPosting(BaseModel):
title: str
company: str
location: str | None = None
salary_min: int | None = None
salary_max: int | None = None
skills: list[str] = Field(default_factory=list)
remote: bool = False
async def extract_job_posting(text: str) -> JobPosting:
schema = JobPosting.model_json_schema()
response = await client.messages.create(
model="claude-opus-4-6",
system=(
"Извлекай данные о вакансии из текста. "
"Отвечай только валидным JSON без пояснений.\n\n"
f"Схема:\n{json.dumps(schema, ensure_ascii=False, indent=2)}"
),
messages=[
{"role": "user", "content": text},
{"role": "assistant", "content": "{"}, # prefill — начинаем JSON
],
max_tokens=512,
temperature=0,
)
raw = "{" + response.content[0].text
return parse_llm_response(raw, JobPosting)
# Тест
job = await extract_job_posting(
"Senior Python Developer в Яндекс, Москва/удалёнка. "
"Зарплата 250-350к. Нужны: Python, FastAPI, PostgreSQL, Redis."
)
print(job.model_dump())
# title='Senior Python Developer', company='Яндекс',
# salary_min=250000, salary_max=350000, skills=['Python', 'FastAPI', ...], remote=True
Метод 2: OpenAI Structured Outputs
OpenAI предоставляет два режима: JSON mode (гарантирует валидный JSON, но не схему) и Structured Outputs (гарантирует точное соответствие схеме).
from openai import AsyncOpenAI
from pydantic import BaseModel
import json
client = AsyncOpenAI()
class SentimentResult(BaseModel):
sentiment: str # positive / negative / neutral
confidence: float # 0.0 - 1.0
keywords: list[str]
explanation: str
async def analyze_sentiment_json_mode(text: str) -> SentimentResult:
"""
JSON mode: response_format={"type": "json_object"}
Гарантирует валидный JSON, но не точную схему.
Нужно описать схему в системном промпте.
"""
response = await client.chat.completions.create(
model="gpt-4o",
response_format={"type": "json_object"}, # ← JSON mode
messages=[
{
"role": "system",
"content": (
"Анализируй тональность текста. "
"Отвечай JSON со строго следующими полями: "
"sentiment (positive/negative/neutral), "
"confidence (число 0.0-1.0), "
"keywords (массив слов), "
"explanation (строка)."
)
},
{"role": "user", "content": text},
],
temperature=0,
)
raw = response.choices[0].message.content
return SentimentResult.model_validate_json(raw)
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
from typing import Literal
client = AsyncOpenAI()
class ExtractedEntity(BaseModel):
text: str
type: Literal["person", "organization", "location", "date", "money"]
context: str
class NERResult(BaseModel):
entities: list[ExtractedEntity]
summary: str
async def extract_entities(text: str) -> NERResult:
"""
Structured Outputs: parse() метод + Pydantic модель.
Гарантирует 100% соответствие схеме через constrained decoding.
"""
response = await client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06", # Structured Outputs: gpt-4o-2024-08-06+
messages=[
{
"role": "system",
"content": "Извлекай именованные сущности из текста."
},
{"role": "user", "content": text},
],
response_format=NERResult, # ← передаём Pydantic модель напрямую
temperature=0,
)
# .parsed — уже провалидированная Pydantic-модель
return response.choices[0].message.parsed
result = await extract_entities(
"Илон Маск основал SpaceX в 2002 году в Хоторне, Калифорния. "
"Компания привлекла 100 миллионов долларов на старте."
)
for e in result.entities:
print(f"[{e.type}] {e.text}")
- Все поля должны быть
required— нельзя опциональные поля без default - Нет поддержки
anyOfс несколькими типами (кромеnull) - Максимум 100 объектных свойств
- Нет рекурсивных схем
- Только модели
gpt-4o-2024-08-06и новее
Метод 3: Anthropic tool use для структурированного вывода
У Anthropic нет JSON mode, но есть более надёжный способ: объявить «инструмент» с нужной схемой и заставить модель его «вызвать». Tool use семантически понятен модели — она знает, что нужен точный JSON.
import anthropic
import json
from pydantic import BaseModel, Field
from typing import Any
client = anthropic.AsyncAnthropic()
def pydantic_to_tool(
model: type[BaseModel],
tool_name: str,
description: str,
) -> dict:
"""Конвертируем Pydantic-модель в Anthropic tool schema."""
schema = model.model_json_schema()
# Убираем title на верхнем уровне (не нужен)
schema.pop("title", None)
return {
"name": tool_name,
"description": description,
"input_schema": schema,
}
async def structured_extract(
text: str,
output_model: type[BaseModel],
task_description: str,
tool_name: str = "extract_data",
) -> BaseModel:
"""
Используем tool use как structured output.
Модель вынуждена вызвать инструмент с валидными параметрами.
"""
tool = pydantic_to_tool(output_model, tool_name, task_description)
response = await client.messages.create(
model="claude-opus-4-6",
tools=[tool],
tool_choice={"type": "tool", "name": tool_name}, # принудительный вызов
messages=[{"role": "user", "content": text}],
max_tokens=1024,
temperature=0,
)
# Извлекаем tool_use блок
tool_block = next(
(b for b in response.content if b.type == "tool_use"),
None,
)
if tool_block is None:
raise ValueError("Model did not call the tool")
return output_model.model_validate(tool_block.input)
# ── Пример 1: извлечение резюме ──
class ResumeData(BaseModel):
name: str
email: str | None = None
phone: str | None = None
skills: list[str] = Field(default_factory=list)
years_experience: int | None = None
current_position: str | None = None
resume_text = """
Иван Петров
Senior Python Developer | ivan@example.com | +7 916 123-45-67
10 лет опыта в backend-разработке.
Стек: Python, FastAPI, PostgreSQL, Redis, Docker, Kubernetes.
"""
resume = await structured_extract(
text=resume_text,
output_model=ResumeData,
task_description="Извлеки контактные данные и навыки из резюме.",
)
print(resume.model_dump())
# ── Пример 2: классификация документа ──
from typing import Literal
class DocumentClassification(BaseModel):
category: Literal["contract", "invoice", "report", "letter", "other"]
subcategory: str | None = None
language: Literal["ru", "en", "other"]
urgency: Literal["low", "medium", "high", "critical"]
summary: str = Field(description="Краткое содержание в 1-2 предложениях")
action_required: bool
doc_text = "СЧЁТ-ФАКТУРА №1234 от 15.03.2025. Оплатить до 22.03.2025. Сумма: 150 000 руб."
classification = await structured_extract(
text=doc_text,
output_model=DocumentClassification,
task_description="Классифицируй документ и определи необходимые действия.",
)
print(classification.model_dump())
Проектирование схем для LLM
Не все схемы одинаково хорошо работают с LLM. Есть паттерны, которые повышают надёжность и качество извлечения.
from pydantic import BaseModel, Field
from typing import Literal
from datetime import date
# ── Плохая схема — мало подсказок ──
class BadSchema(BaseModel):
s: str
v: float
t: str
items: list
# ── Хорошая схема — богатые description ──
class OrderItem(BaseModel):
product_name: str = Field(description="Название товара как в тексте")
quantity: int = Field(description="Количество единиц, целое число")
unit_price: float = Field(description="Цена за единицу в рублях без НДС")
class OrderData(BaseModel):
order_number: str = Field(
description="Номер заказа или счёта, строка вида 'ORD-1234' или '№1234'"
)
order_date: str = Field(
description="Дата заказа в формате YYYY-MM-DD"
)
customer_name: str = Field(
description="Полное имя клиента или название компании"
)
items: list[OrderItem] = Field(
description="Список позиций заказа"
)
total_amount: float = Field(
description="Итоговая сумма в рублях"
)
currency: Literal["RUB", "USD", "EUR"] = Field(
default="RUB",
description="Валюта. Если не указана — RUB"
)
notes: str | None = Field(
default=None,
description="Дополнительные примечания или None если нет"
)
# ── Советы по проектированию схем ──
# 1. Используй Literal для перечислений вместо str
# Плохо: status: str → модель может написать "Active", "active", "активный"
# Хорошо: status: Literal["active", "inactive", "pending"]
# 2. Явные форматы для дат и чисел
# Плохо: date: str → "15 марта", "15.03.25", "2025-03-15"
# Хорошо: date: str = Field(description="Дата в формате YYYY-MM-DD")
# 3. None вместо пустой строки для отсутствующих данных
# Плохо: email: str → будет "" или "не указан"
# Хорошо: email: str | None = None
# 4. Вложенные объекты лучше плоских для сложных структур
class AddressBad(BaseModel):
city: str; street: str; building: str # плоско, нет контекста
class Address(BaseModel):
city: str = Field(description="Название города")
street: str = Field(description="Улица с приставкой: ул., пр., бул.")
building: str = Field(description="Номер дома, может включать корпус: '15к2'")
class PersonGood(BaseModel):
name: str
address: Address # вложенный объект — модель понимает структуру
| Паттерн | Плохо | Хорошо |
|---|---|---|
| Перечисления | status: str |
status: Literal["active","inactive"] |
| Отсутствие значения | email: str → "" |
email: str | None = None |
| Форматы | date: str |
date: str = Field(description="YYYY-MM-DD") |
| Имена полей | v, s, t |
value, status, timestamp |
| Числа | amount: str → «150 тыс» |
amount_rub: float с description |
Retry с исправлением ошибок
Даже надёжные методы иногда дают невалидный результат. Стандартный паттерн — показать модели ошибку валидации и попросить исправить.
import anthropic
import json
from pydantic import BaseModel, ValidationError
client = anthropic.AsyncAnthropic()
async def extract_with_retry(
text: str,
output_model: type[BaseModel],
system: str,
max_retries: int = 3,
) -> BaseModel:
"""
Structured extraction с retry.
При ValidationError — показываем модели ошибку и просим исправить.
"""
messages = [{"role": "user", "content": text}]
for attempt in range(max_retries):
response = await client.messages.create(
model="claude-opus-4-6",
system=system,
messages=messages + [
{"role": "assistant", "content": "{"}
],
max_tokens=1024,
temperature=0,
)
raw = "{" + response.content[0].text
raw_json = extract_json(raw)
if raw_json is None:
# Нет JSON — просим переделать
messages.append({"role": "assistant", "content": response.content[0].text})
messages.append({
"role": "user",
"content": "Твой ответ не содержит валидного JSON. Ответь только JSON-объектом."
})
continue
try:
return output_model.model_validate_json(raw_json)
except ValidationError as e:
# Показываем ошибку валидации и просим исправить
error_details = str(e)
messages.append({"role": "assistant", "content": raw_json})
messages.append({
"role": "user",
"content": (
f"JSON невалиден по схеме. Ошибки:\n{error_details}\n\n"
f"Исправь JSON. Схема:\n"
f"{json.dumps(output_model.model_json_schema(), ensure_ascii=False)}"
)
})
raise RuntimeError(f"Failed to extract valid {output_model.__name__} after {max_retries} attempts")
# ── Более мощный вариант: используем instructor ──
# pip install instructor
import instructor
from openai import AsyncOpenAI
# instructor автоматически оборачивает клиент и добавляет retry+validation
oai = instructor.from_openai(AsyncOpenAI())
ant = instructor.from_anthropic(anthropic.AsyncAnthropic())
class ProductInfo(BaseModel):
name: str
price_rub: float
in_stock: bool
category: str
# OpenAI через instructor — автоматический retry при ValidationError
product_oai = await oai.chat.completions.create(
model="gpt-4o",
response_model=ProductInfo, # ← вместо response_format
messages=[{
"role": "user",
"content": "iPhone 15 Pro 256GB, цена 99 990 руб., есть в наличии, категория: смартфоны"
}],
max_retries=3, # instructor повторяет при ошибке
)
# Anthropic через instructor
product_ant = await ant.messages.create(
model="claude-opus-4-6",
response_model=ProductInfo,
messages=[{
"role": "user",
"content": "Samsung Galaxy S24 Ultra, 89 990р, нет в наличии, электроника"
}],
max_tokens=512,
max_retries=3,
)
print(product_oai.model_dump())
print(product_ant.model_dump())
pip install instructor — оборачивает клиентов OpenAI и Anthropic,
добавляет автоматический retry с обратной связью по ошибкам валидации,
поддерживает streaming Pydantic-моделей, partial validation и многое другое.
Streaming структурированных данных
Иногда нужно начать обрабатывать данные ещё до полной генерации — например, показывать поля по мере их появления. Instructor поддерживает partial models.
import instructor
import anthropic
from pydantic import BaseModel
ant = instructor.from_anthropic(anthropic.AsyncAnthropic())
class ArticleAnalysis(BaseModel):
title: str
main_topic: str
key_points: list[str]
sentiment: str
word_count_estimate: int
tags: list[str]
async def stream_analysis(article_text: str):
"""Стримим структурированный анализ — поля появляются по мере генерации."""
# create_partial возвращает async iterator частично заполненных объектов
async for partial in ant.messages.create_partial(
model="claude-opus-4-6",
response_model=ArticleAnalysis,
messages=[{
"role": "user",
"content": f"Проанализируй статью:\n\n{article_text}"
}],
max_tokens=1024,
):
# partial — неполная модель, незаполненные поля = None
if partial.title:
print(f"\rЗаголовок: {partial.title}", end="")
if partial.main_topic:
print(f"\nТема: {partial.main_topic}", end="")
if partial.key_points:
print(f"\nПунктов: {len(partial.key_points)}", end="")
print("\nГотово!")
return partial # последний элемент — полная модель
Сложные паттерны: union types и discriminated unions
from pydantic import BaseModel, Field
from typing import Literal, Annotated, Union
import anthropic, instructor
ant = instructor.from_anthropic(anthropic.AsyncAnthropic())
# Разные типы событий агента
class ToolCallEvent(BaseModel):
event_type: Literal["tool_call"] = "tool_call"
tool_name: str
arguments: dict
reasoning: str = Field(description="Почему вызываем этот инструмент")
class ThoughtEvent(BaseModel):
event_type: Literal["thought"] = "thought"
content: str
confidence: float = Field(ge=0.0, le=1.0)
class AnswerEvent(BaseModel):
event_type: Literal["answer"] = "answer"
content: str
sources: list[str] = Field(default_factory=list)
is_final: bool = True
# Discriminated union — Pydantic выбирает тип по event_type
AgentEvent = Annotated[
Union[ToolCallEvent, ThoughtEvent, AnswerEvent],
Field(discriminator="event_type")
]
class AgentStep(BaseModel):
step_number: int
event: AgentEvent
async def parse_agent_step(description: str) -> AgentStep:
return await ant.messages.create(
model="claude-opus-4-6",
response_model=AgentStep,
messages=[{
"role": "user",
"content": f"Преобразуй описание в структуру шага агента:\n{description}"
}],
max_tokens=512,
)
step = await parse_agent_step(
"Шаг 3: агент решает запросить погоду через weather_api для Москвы"
)
print(step.event.event_type) # "tool_call"
print(step.event.tool_name) # "weather_api"
Production-ready экстрактор данных
import anthropic
import instructor
import logging
import time
from dataclasses import dataclass
from typing import TypeVar, Type
from pydantic import BaseModel
logger = logging.getLogger(__name__)
T = TypeVar("T", bound=BaseModel)
@dataclass
class ExtractionResult:
data: BaseModel
attempts: int
elapsed_sec: float
model_used: str
class DataExtractor:
"""
Production-ready экстрактор структурированных данных из текста.
Поддерживает Anthropic и OpenAI, автоматический retry, логирование.
"""
def __init__(
self,
provider: str = "anthropic",
model: str = "claude-opus-4-6",
max_retries: int = 3,
):
self.provider = provider
self.model = model
self.max_retries = max_retries
if provider == "anthropic":
self._client = instructor.from_anthropic(
anthropic.AsyncAnthropic()
)
elif provider == "openai":
from openai import AsyncOpenAI
self._client = instructor.from_openai(AsyncOpenAI())
else:
raise ValueError(f"Unknown provider: {provider}")
async def extract(
self,
text: str,
schema: Type[T],
system: str = "Извлекай структурированные данные из текста точно по схеме.",
temperature: float = 0,
) -> ExtractionResult:
t0 = time.monotonic()
kwargs = dict(
model=self.model,
response_model=schema,
messages=[{"role": "user", "content": text}],
max_tokens=2048,
max_retries=self.max_retries,
)
if self.provider == "anthropic":
kwargs["system"] = system # type: ignore
else:
kwargs["messages"] = [
{"role": "system", "content": system},
*kwargs["messages"],
]
result = await self._client.messages.create(**kwargs) \
if self.provider == "anthropic" \
else await self._client.chat.completions.create(**kwargs)
elapsed = time.monotonic() - t0
logger.info("Extracted %s in %.2fs", schema.__name__, elapsed)
return ExtractionResult(
data=result,
attempts=1, # instructor отслеживает внутри
elapsed_sec=elapsed,
model_used=self.model,
)
# Использование
extractor = DataExtractor(provider="anthropic")
class InvoiceData(BaseModel):
invoice_number: str
date: str
vendor: str
amount_total: float
currency: str = "RUB"
line_items: list[dict] = []
result = await extractor.extract(
text="Счёт №INV-2025-042 от 20.03.2025. ООО 'Рога и Копыта'. Итого: 45 600 руб.",
schema=InvoiceData,
)
print(f"Извлечено за {result.elapsed_sec:.2f}с:")
print(result.data.model_dump())
Проверь себя
Вопросы для самопроверки
- Чем OpenAI JSON mode отличается от Structured Outputs?
- Как Anthropic tool use используется для structured output? Зачем
tool_choice: forced? - Почему
Field(description=...)важен для LLM, а не только для документации? - Что делает
instructor? Какую проблему он решает? - Назови три fallback-стратегии в robust JSON-парсере.
Показать ответы
- JSON mode гарантирует валидный JSON-синтаксис, но не структуру полей. Structured Outputs (strict) гарантирует полное соответствие переданной схеме через constrained decoding.
- Объявляем «инструмент» с нужной схемой.
tool_choice: {"type":"tool","name":"..."}принуждает модель вызвать именно этот инструмент, что даёт надёжный структурированный вывод. - Description попадает в JSON Schema и становится частью контекста модели. Модель использует его как подсказку о том, что именно нужно положить в поле.
- instructor оборачивает API-клиенты и добавляет: автоматический retry при ValidationError, обратную связь об ошибках модели, поддержку streaming partial models.
- 1) Весь текст — уже JSON. 2) Вырезать из markdown-блока. 3) Жадный поиск от первой { до последней }.
Итог урока
- «Попроси написать JSON» ненадёжно (~55%). Нужны технические гарантии
- OpenAI JSON mode: гарантирует валидный JSON, не схему. Structured Outputs: 100% схема через constrained decoding
- Anthropic tool use с
tool_choice: forced— надёжный structured output без JSON mode - Пиши богатые схемы:
Literalвместо str,descriptionна каждом поле,Noneдля отсутствующих данных - Robust парсер: прямой parse → markdown block → greedy search → исправление trailing comma
- Retry с feedback: показываем модели ошибку валидации и просим исправить
- instructor — best practice: автоматический retry, streaming partial, discriminated unions