7. Структурированный вывод с Pydantic
В предыдущих главах мы работали с выводом LLM в виде обычных текстовых строк. Это нормально работает для чат-ботов, где люди читают ответы, но при создании AI-агентов, где программы должны парсить и интерпретировать вывод LLM, нам нужны предсказуемые, структурированные данные. В этой главе вы узнаете, как использовать Pydantic-схемы, чтобы заставить LLM возвращать структурированные Python-объекты.
7.1) Зачем нужен структурированный вывод?
Проблема свободного текстового вывода LLM
Начнем с понимания того, почему ответы в виде обычного текста создают проблемы в реальных приложениях. Рассмотрим этот распространенный сценарий:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
# Спрашиваем LLM о продукте
message = HumanMessage(content="""
Извлеките информацию о продукте из этого текста:
"UltraWidget Pro стоит $299.99 и в настоящее время есть в наличии."
""")
response = llm.invoke([message])
print(response.content)Вывод:
Название продукта: UltraWidget Pro
Цена: $299.99
Наличие: В наличииВывод выглядит хорошо. Но теперь предположим, что вам нужно использовать эти данные в вашем Python-приложении. Как извлечь цену в виде числа? Как программно проверить наличие? Вы можете попробовать парсинг строк вот так:
# Хрупкий подход с парсингом
text = response.content
price_line = [line for line in text.split('\n') if 'Цена:' in line][0]
price_str = price_line.split('$')[1]
price = float(price_str) # Ненадежно - что если формат изменится?Этот парсинг вроде бы работает. Но на самом деле нет. Вот почему:
Почему этот подход не работает:
- LLM может отформатировать ответ по-другому в следующий раз ("Цена: 299.99 USD" или "Розничная цена: $299.99")
Вот примеры различных выводов, которые могут возникнуть для одного и того же промпта:
# Пример 1
"Продукт — UltraWidget Pro, цена $299.99, и он доступен."
# Пример 2
"Продукт: UltraWidget Pro
Стоимость: 299.99 долларов
Статус: Доступен"
# Пример 3
"UltraWidget Pro - $299.99 (в наличии)"
# Пример 4
"Я нашел UltraWidget Pro. Он стоит $299.99 и в настоящее время доступен для покупки."Когда ответ LLM меняется, вам нужна совершенно другая логика парсинга. Это затрудняет создание надежных приложений.
- Ответы LLM непредсказуемы: Один и тот же промпт может каждый раз производить разные форматы
- Парсинг строк сложнее, чем кажется: Вам нужно обрабатывать
$, пробелы, переносы строк, запятые и многое другое - Полное отсутствие типобезопасности: Вы не можете быть уверены, что
price— это float, string или None - Обработка ошибок затруднена: Если LLM говорит "Цена недоступна", ваш вызов
float()падает - Неподдерживаемо: Немного измените промпт, и вам придется переписывать весь код парсинга
Основная идея: Python нужны контракты, а не проза
Подумайте о том, когда вы общаетесь с API-сервером в Python. Когда вы вызываете конкретный REST API, вы ожидаете, что он вернет определенный JSON-ответ:
# Вы ожидаете эту структуру
{
"product_name": "UltraWidget Pro",
"price": 299.99,
"in_stock": true
}При создании AI-приложений вам нужен тот же принцип. Вывод LLM должен возвращаться как данные с определенной структурой, а не как свободный текст каждый раз.
Проза против контракта:
- Проза: Свободный естественный текст. Хорош для чтения людьми, но труден для обработки программами.
- Контракт: Данные с определенной структурой и типами. Обещание, что "эти поля будут существовать с этими типами."
Структурированный вывод означает определение контракта: "LLM, мне нужны именно эти поля, с именно этими типами, в именно этом формате."
Вот где появляется Pydantic. Pydantic — самая популярная библиотека валидации данных в Python, и LangChain использует ее для получения вывода LLM в структурированной форме.
Сдвиг ментальной модели:
- Раньше: "LLM, расскажи мне об этом продукте" → Парсинг непредсказуемого текста
- Теперь: "LLM, ответь в определенном формате" → Получение структурированного Python-объекта
Этот сдвиг от прозы к контрактам фундаментален для создания надежных AI-агентов. Когда агенту нужно принять решение о следующем действии на основе ответов LLM (например, купить, если есть в наличии, зарегистрироваться для уведомления, если нет), он должен получать ответы в определенном формате.
7.2) Ваш первый структурированный вывод
В 7.1 мы узнали, почему LLM должны отвечать с определенной структурой вместо свободного текста. Теперь давайте посмотрим, как это реализовать на практике.
Ключевая идея: Просто попросить LLM "пожалуйста, ответь в этом формате" недостаточно. Вам нужно определить точную структуру данных в коде Python и передать ее LLM через LangChain. Эта определенная структура данных называется схемой(schema).
Что такое схема?
Схема(schema) — это чертеж, который определяет структуру данных. Она указывает:
- Какие поля должны присутствовать
- Какой тип должно иметь каждое поле (строка, число, булево значение и т.д.)
- Какие ограничения применяются (обязательное или опциональное, допустимые диапазоны и т.д.)
В Python мы определяем схемы, используя класс BaseModel из Pydantic. Вот простейший возможный пример:
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolЭта схема говорит: "Объект ProductInfo должен иметь ровно три поля: product_name (строка), price (float) и in_stock (булево значение)."
Паттерн из трех шагов: Определить, Связать, Вызвать
Использование структурированного вывода просто. Просто запомните три шага:
- Определить: Создайте схему с Pydantic-классом
- Связать: Подключите схему к LLM, используя
.with_structured_output() - Вызвать: Вызовите
.invoke(), чтобы получить типизированный объект
Это стандартный шаблон, который вы будете использовать для большинства задач структурированного извлечения:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
# Шаг 1: Определяем схему
class ProductInfo(BaseModel):
product_name: str = Field(description="Полное название продукта")
price: float = Field(description="Цена в USD")
in_stock: bool = Field(description="Доступен ли продукт")
# Шаг 2: Связываем схему с LLM
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Шаг 3: Вызываем и получаем типизированный объект
message = HumanMessage(content="""
Извлеките информацию о продукте из этого текста:
"UltraWidget Pro стоит $299.99 и в настоящее время есть в наличии."
""")
result = structured_llm.invoke([message])
# result теперь объект ProductInfo, а не строка
print(type(result)) # <class '__main__.ProductInfo'>
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # TrueЧто только что произошло?
- Определение схемы: Мы определили поля и типы, которые хотим
- Связывание:
.with_structured_output(ProductInfo)настраивает LLM на использование структурированного вывода - Вызов и ответ: Когда вызывается
.invoke(), LangChain передает JSON Schema в LLM, и LLM отвечает JSON, соответствующим этой структуре - Автоматическое преобразование: LangChain преобразует JSON в объект
ProductInfo— код парсинга не нужен
Никакого парсинга. Никакого преобразования типов. Никаких ошибок.
Используйте этот паттерн из 3 шагов как ваш шаблон. Следуйте ему всякий раз, когда вам нужен структурированный вывод.
Описания полей: Ключ к управлению LLM
В примере определения схемы выше мы использовали Field(description="..."). Это описание — не просто документация. Это инструкции, которые LLM читает и которым следует.
В типичном использовании Pydantic описания Field опциональны:
# Обычный Pydantic - описание это документация для людей
class User(BaseModel):
name: str = Field(description="Имя пользователя") # Работает нормально без негоНо при работе с LLM они необходимы:
# С LLM - описание определяет поведение LLM
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Общая тональность: 'positive', 'negative' или 'neutral'"
)LLM читает это описание и использует его для принятия решения, как отвечать.
Давайте посмотрим это в действии:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Общая тональность: 'positive', 'negative' или 'neutral'"
)
main_issue: str = Field(
description="Основная жалоба или проблема, если есть. Используйте 'none', если проблемы не упомянуты."
)
urgency: str = Field(
description="Насколько срочна проблема: 'low', 'medium' или 'high'"
)
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(CustomerFeedback)
message = HumanMessage(content="""
Проанализируйте этот отзыв клиента:
"Продукт работает нормально, но доставка заняла 3 недели. Я уже пропустил дедлайн проекта. Пожалуйста, ответьте немедленно."
""")
result = structured_llm.invoke([message])
print(result.sentiment) # negative
print(result.main_issue) # Медленная доставка
print(result.urgency) # highКак описания формируют решения LLM:
- Описание
sentiment→ LLM узнает, что допустимые значения — 'positive', 'negative', 'neutral' → Проблема с доставкой вызвала пропущенный дедлайн, поэтому выбирает 'negative' - Описание
main_issue→ LLM получает инструкцию "найти основную жалобу" → Идентифицирует "медленная доставка" как проблему - Описание
urgency→ LLM узнает, что срочность должна быть 'low', 'medium' или 'high' → Видит "Пожалуйста, ответьте немедленно" и выбирает 'high'
Что происходит без описаний?
sentiment: str # Нет описанияLLM может вернуть "negative", "bad", "unsatisfied", "2/5", "disappointed" в непредсказуемых форматах, что затрудняет обработку значений вашим кодом.
Ключевой момент: Описания полей — это часть вашего кода, которая контролирует поведение LLM. Пишите их четко и конкретно.
Категориальные поля: Указание допустимых значений
В примере выше поле sentiment может иметь только три значения: 'positive', 'negative' или 'neutral'. Поля, которые должны быть одним из определенного набора значений, называются категориальными полями(categorical fields).
Для категориальных полей перечислите все возможные значения в описании:
sentiment: str = Field(
description="Тональность: точно 'positive', 'negative' или 'neutral' (в нижнем регистре)"
)Указывая "точно" и "(в нижнем регистре)", мы подчеркиваем, что LLM должна отвечать именно одним из этих трех значений.
Однако нет гарантии, что LLM всегда будет отвечать одним из указанных значений. Поэтому вы должны писать защитный код.
Случаи, когда LLM возвращает неожиданные значения:
result.sentiment = "Positive" # С заглавной буквы
result.sentiment = "NEGATIVE" # Все заглавные
result.sentiment = "good" # Совсем другое словоНаписание защитного кода:
allowed = {"positive", "negative", "neutral"}
# Преобразуем в нижний регистр и проверяем
sentiment = result.sentiment.lower()
if sentiment not in allowed:
sentiment = "neutral" # Используем значение по умолчанию для неожиданных значений
# Теперь sentiment гарантированно одно из допустимых значенийКлючевой вывод:
- Укажите допустимые значения в описании → LLM с большей вероятностью ответит правильно
- Валидируйте в коде → Безопасно обрабатывайте неожиданные значения
Примечание: Глава 18 показывает более сильные паттерны с использованием Python enum для принудительного применения.
Сравнение: Ручной парсинг против структурированного вывода
Давайте сравним одну и ту же задачу с и без структурированного вывода, чтобы увидеть разницу:
Ручной парсинг:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
message = HumanMessage(content="""
Извлеките название продукта, цену и наличие из:
"UltraWidget Pro стоит $299.99 и в настоящее время есть в наличии."
Формат: название | цена | наличие
""")
response = llm.invoke([message])
text = response.content
# Ручной парсинг
parts = text.split('|')
product_name = parts[0].strip()
price_str = parts[1].strip().replace('$', '')
price = float(price_str)
availability = parts[2].strip().lower()
in_stock = 'в наличии' in availability or 'доступен' in availability
print(f"Название: {product_name}")
print(f"Цена: ${price}")
print(f"В наличии: {in_stock}")Структурированный вывод:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class ProductInfo(BaseModel):
product_name: str = Field(description="Полное название продукта")
price: float = Field(description="Цена в USD")
in_stock: bool = Field(description="Доступен ли продукт")
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Извлеките информацию о продукте из:
"UltraWidget Pro стоит $299.99 и в настоящее время есть в наличии."
""")
result = structured_llm.invoke([message])
print(f"Название: {result.product_name}")
print(f"Цена: ${result.price}")
print(f"В наличии: {result.in_stock}")Ключевые различия:
- Нет логики парсинга: В структурированной версии нет кода парсинга
- Типобезопасность:
result.priceгарантированно float - Более простой код: Нет regex, нет разделения строк, нет ручного преобразования типов
- Валидация: Pydantic гарантирует наличие всех обязательных полей
- Поддерживаемость: Изменение схемы проще, чем обновление логики парсинга
7.3) Соображения при проектировании схем
Теперь, когда вы знаете, как использовать структурированный вывод, давайте узнаем, как проектировать хорошие схемы. Этот раздел охватывает практические принципы проектирования для различения обязательных и опциональных полей.
Обязательные поля
По умолчанию все поля в Pydantic-модели обязательны(required). Это означает, что LLM должна извлечь или вывести значение для каждого обязательного поля из промпта пользователя и предоставить его в ответе.
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolКогда вы используете эту схему, LLM попытается найти значения для всех трех полей (product_name, price, in_stock) во входном тексте.
Но что происходит, когда в промпте отсутствует информация для обязательного поля?
Мы могли бы ожидать следующее поведение:
- LLM не может найти информацию в промпте
- LLM опускает это поле из своего ответа
- LangChain не может создать валидный экземпляр
ProductInfo - Возникает
ValidationError
Однако это не всегда происходит.
Причина в том, что разные LLM могут обрабатывать отсутствующую информацию по-разному.
Некоторые LLM (такие как модели OpenAI) склонны генерировать значения, даже когда требуемая информация отсутствует в промпте. В этом случае ValidationError не возникает, но это может вызвать большие проблемы, потому что ваше Python-приложение может обрабатывать сфабрикованную информацию, как если бы она была реальной.
Мы рассмотрим, как решить эту проблему, в разделе 7.4: Когда что-то идет не так.
Пока просто имейте в виду, что не все LLM обрабатывают отсутствующую информацию одинаково.
Опциональные поля
Вам могут понадобиться поля, которые могут законно присутствовать или отсутствовать даже в нормальных случаях. Например, примечание к доставке (delivery_note) может быть или не быть предоставлено клиентом даже для валидного заказа.
Когда использовать Optional:
- Сами данные могут не существовать (например, когда разрешены анонимные отзывы, у анонимных отзывов нет имени рецензента)
- Вы хотите, чтобы LLM явно указывала на отсутствующую информацию, а не фабриковала значение
Чтобы сделать поле опциональным(optional), используйте тип Optional из модуля typing в Python:
from typing import Optional
class ProductReview(BaseModel):
rating: int
review_text: str
reviewer_name: Optional[str] = None # У анонимных отзывов нет имени рецензентаПримечание: Пользователи Python 3.10+ могут использовать
str | NoneвместоOptional[str].
Когда поле Optional:
- LLM может опустить его из ответа, если информация не найдена в промпте
- Опущенные поля устанавливаются в значение по умолчанию (
None)
Вот полный пример:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing import Optional
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
discount_percentage: Optional[float] = None
warranty_years: Optional[int] = None
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Извлеките информацию о продукте: "UltraWidget Pro стоит $299.99 и есть в наличии."
""")
result = structured_llm.invoke([message])
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True
print(result.discount_percentage) # None (не упомянуто)
print(result.warranty_years) # None (не упомянуто)Чеклист проектирования схем
Перед финализацией вашей схемы спросите себя:
Выбор полей:
- Действительно ли обязательные поля необходимы? (Что происходит, если это поле отсутствует в промпте?)
- Могут ли опциональные поля законно отсутствовать даже в нормальных случаях?
Спецификация полей:
- Имеет ли каждое поле четкое описание?
- Явно ли ограничены категориальные поля? (например, "должно быть точно 'A', 'B' или 'C'")
7.4) Когда что-то идет не так
Две проблемы могут возникнуть при использовании структурированного вывода:
- LLM опускает значения обязательных полей → Возникает ValidationError
- LLM фабрикует отсутствующую информацию → ValidationError не возникает, но ваш код обрабатывает некорректные данные
Этот раздел охватывает, как обрабатывать каждую проблему.
Понимание ошибок валидации
Когда в промпте пользователя отсутствует информация для обязательных полей, определенных в схеме, LLM не может предоставить значения для этих полей. Python-программа затем вызывает ValidationError:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ValidationError
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
llm = ChatAnthropic(model='claude-sonnet-4-5')
structured_llm = llm.with_structured_output(ProductInfo)
# Ввод с отсутствующей обязательной информацией
message = HumanMessage(content="""
Извлеките информацию о продукте из: "Виджет отличный! Очень рекомендую."
""")
try:
result = structured_llm.invoke([message])
print(result)
except ValidationError as e:
print("Возникла ValidationError")Примечание: Когда в промпте отсутствует информация для обязательных полей, некоторые LLM могут сфабриковать значения и предоставить их в ответе. В этом случае
ValidationErrorне возникнет, но возникнет большая проблема. Мы рассмотрим это в следующем разделе.
Когда в промпте отсутствует информация для обязательных полей и возникает ValidationError, это на самом деле полезно для вашего Python-приложения. Приложение может обнаружить, что произошла проблема, и обработать ошибку контролируемым образом. Стратегии восстановления от ошибок рассматриваются в главе 14 (восстановление от ошибок на уровне агента) и главе 17 (логика повторных попыток с управлением состоянием).
Большая проблема: LLM фабрикует отсутствующую информацию
Как мы обсуждали в разделе 7.3, некоторые LLM демонстрируют более опасное поведение: они фабрикуют значения и предоставляют их в ответах, когда информация отсутствует в промпте.
Как возникает проблема:
- В промпте отсутствует обязательная информация
- LLM все равно генерирует правдоподобно выглядящие значения
ValidationErrorНЕ возникает- Ваше Python-приложение обрабатывает сфабрикованные данные, как если бы они были реальными
Пример:
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
message = HumanMessage(content="""
Извлеките информацию о продукте из: "Виджет отличный!"
""")
# Когда некоторые LLM фабрикуют значения для product_name, price и in_stock
result = structured_llm.invoke([message])
# Ошибка не возникает!
print(result.product_name) # "виджет" (извлечено из текста)
print(result.price) # 0.0 (сфабриковано!)
print(result.in_stock) # False (сфабриковано!)
# Проблема: Вы не можете определить, какие значения реальные, а какие сфабрикованныеЭто хуже, чем ValidationError, потому что:
- Ваше Python-приложение продолжает выполнение с плохими данными
- Вы не знаете, какие поля реальные, а какие сфабрикованные
- Последующая логика может принимать неправильные решения на основе фальшивых данных
Решение: Используйте опциональные поля с валидацией
Решение состоит в том, чтобы определить все обязательные поля как Optional, затем использовать валидатор для проверки, что все обязательные поля имеют значения.
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
class ProductInfo(BaseModel):
# Эти поля на самом деле обязательны, но объявлены как Optional
# Реальная валидация происходит в валидаторе ниже
product_name: Optional[str] = None
price: Optional[float] = None
in_stock: Optional[bool] = None
@model_validator(mode='after')
def check_required_fields(self):
"""Валидируем, что все необходимые поля присутствуют"""
if self.product_name is None or self.price is None or self.in_stock is None:
raise ValueError("Все поля (product_name, price, in_stock) должны быть предоставлены")
return self
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Тестируем с неполными данными
message = HumanMessage(content="""
Извлеките информацию о продукте из: "Виджет отличный!"
""")
try:
result = structured_llm.invoke([message])
# Если мы здесь, все поля гарантированно присутствуют
print(f"Продукт: {result.product_name}")
print(f"Цена: ${result.price}")
except ValidationError as e:
# Обязательные поля отсутствуют - извлечение не удалось
print(f"Неполное извлечение: {e}")Что здесь происходит:
@model_validator— это декоратор Pydantic, который добавляет пользовательскую логику валидацииmode='after'означает, что валидация выполняется после того, как все поля были распарсены- Если какое-либо поле
None, мы вызываемValueError, чтобы сигнализировать о неполных данных - Pydantic автоматически оборачивает этот
ValueErrorвValidationError
Почему это работает:
Когда в промпте отсутствует информация для полей:
- LLM не фабрикует значения и опускает эти поля из ответа
- В этом случае эти поля становятся
None - Если эти поля на самом деле обязательны, валидатор Pydantic вызывает
ValueError - Pydantic оборачивает его как
ValidationError
Таким образом, ваше Python-приложение получает явную ошибку для обработки, а не сфабрикованные данные.
Ключевой вывод: Когда в промпте отсутствует информация для обязательных полей, получение ValidationError совершенно нормально и ожидаемо. Реальная опасность — это сфабрикованные данные. Используйте Optional поля с валидаторами, чтобы предотвратить фабрикацию отсутствующей информации LLM, при этом явно обнаруживая, когда обязательные поля отсутствуют.
Резюме главы:
В этой главе вы узнали, как преобразовать вывод LLM в надежные Python-объекты:
- Почему это важно: Парсинг свободного текста хрупок; вывод на основе схем обеспечивает типобезопасность
- Как это использовать: Определите схемы с Pydantic
BaseModel→ Свяжите с.with_structured_output() - Принципы проектирования: Выбирайте обязательные против опциональных полей, направляйте LLM с помощью описаний полей
- Обработка проблем: ValidationError нормальна; реальная опасность — это сфабрикованные данные