Структурированный вывод | Курс LangChain урок 6
Цель урока: получать от агента не текст, а проверенный объект Python по заданной вами схеме. Выбирать между двумя стратегиями получения такого ответа. Понимать, что делает агент, когда модель схему нарушила.
Необходимые знания:
1) урок 0: окружение собрано, переменные MODEL_NAME и MODEL_BASE_URL заполнены
2) урок 2: init_chat_model и первый взгляд на create_agent
3) урок 3: типы сообщений, AIMessage, ToolMessage и свойство text
4) урок 4: профиль модели model.profile и параметры вызова
5) урок 5: системный промпт, состояние агента и ключ messages
6) Pydantic на базовом уровне: BaseModel, Field, аннотации типов, валидатор поля
Ключевые концепции:
1) response_format это параметр create_agent, а разобранный ответ приходит отдельным ключом состояния structured_response
2) схему задают классом Pydantic, dataclass, TypedDict или словарём JSON Schema, и от выбора зависит, что вернётся
3) две стратегии, ProviderStrategy и ToolStrategy, и правило, по которому фреймворк выбирает между ними
4) ограничения схемы лежат в двух местах: в JSON Schema, которую модель получает с запросом, и в валидаторах Pydantic, о которых модель не знает
5) handle_errors и повтор запроса после нарушения схемы
6) объединение схем: модель выбирает ту, что подходит обращению
7) чего структурированный вывод не гарантирует
Зачем схема, если о формате можно попросить словами
Урок 5 закончился на неудобном месте. Вы попросили модель отвечать строкой вида "категория|срочность|очередь", и форму строки она удержала во всех девяти ответах прогона. Беда в том, что это результат одного прогона, а не гарантия: механизма, который держал бы форму, в промпте нет.
Что произойдёт на десятитысячном обращении, никто не знает. Модель может добавить вежливое вступление перед меткой, обернуть строку в кавычки, перевести метку на английский или поставить двоеточие вместо черты. Ваш код после этого вызывает split("|") и получает либо исключение, либо, что хуже, неверные данные.
Структурированный вывод, это способ получить ответ в заданном формате не уговорами, а механикой. Вы описываете форму ответа схемой, передаёте её агенту параметром response_format и получаете разобранный объект вместо строки, которую надо разбирать самому.
Механизмов для этого два: у одного формат держит провайдер модели, у другого фреймворк через вызов инструментов.
Общая сборка модели
Модуль сборки модели тот же, что в уроках 4 и 5. Положите его рядом с примерами под именем course_model.py.
"""Общая сборка модели для примеров урока 6.
Тот же модуль, что course_model.py уроков 4 и 5: у build_model есть
необязательный первый аргумент с именем модели, а рядом лежит gateway_kwargs()
для примеров, которые собирают модель сами.
Файл .env берётся тот же, что в уроке 0. Положите его рядом с этой папкой или
выше по дереву: load_dotenv() ищет файл начиная с папки этого модуля и поднимается
вверх.
"""
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
load_dotenv()
def gateway_kwargs():
"""Возвращает аргументы доступа к провайдеру: имя, адрес, ключ.
Нужны примерам, которые вызывают init_chat_model сами. У настраиваемой модели
имени модели при создании нет, поэтому build_model ей не подходит.
"""
base_url = os.getenv("MODEL_BASE_URL")
if base_url:
return {
"model_provider": "openai",
"base_url": base_url,
"api_key": os.environ["OPENAI_API_KEY"],
}
return {}
def build_model(model_name=None, **kwargs):
"""Собирает модель курса.
model_name без значения означает модель из переменной MODEL_NAME. Явное имя
нужно примерам, где моделей в приложении больше одной.
Все именованные аргументы уходят в init_chat_model как есть: temperature,
max_tokens, timeout, max_retries, rate_limiter, profile и прочее из раздела
Parameters.
"""
model_name = model_name or os.environ["MODEL_NAME"]
access = gateway_kwargs()
if access:
# Путь для любого адреса, совместимого с OpenAI Chat Completions API.
return init_chat_model(model=model_name, **access, **kwargs)
# Путь напрямую к провайдеру.
return init_chat_model(model_name, **kwargs)
Схема и параметр response_format
Схема это обычный класс Pydantic. Вместе с запросом модели уходят имя класса, его докстринг и описания полей из Field(description=...). Это инструкция для модели, а не комментарий для коллеги, поэтому пишите их так же аккуратно, как системный промпт.
От вида схемы зависит, что вернёт агент:
1) модель Pydantic, класс на основе BaseModel. Возвращается экземпляр класса
2) dataclass с аннотациями типов. Возвращается экземпляр dataclass
3) TypedDict. Возвращается словарь
4) словарь с описанием JSON Schema. Его ключи верхнего уровня title и description становятся именем и описанием схемы. Возвращается словарь
Первые три вида проверяются на стороне Python: ответ модели проходит через Pydantic, и значение вне Literal отбраковывается. Словарь JSON Schema возвращается как пришёл, без проверки. По документации LangChain dataclass возвращает словарь, но в закреплённой версии приходит экземпляр dataclass.
В уроке везде первый вид, к модели Pydantic пишутся валидаторы полей.
Разобранный ответ приходит не текстом последнего сообщения, а отдельным ключом состояния structured_response. Агент возвращает словарь состояния, и структура лежит в нём рядом с messages.
Пример 1: первая схема
Пример 01_schema.py
"""Пример 1 урока 6: схема вместо разбора текста вручную.
Агенту задан параметр response_format со схемой на Pydantic. Разобранный ответ
приходит отдельным ключом состояния, а не текстом последнего сообщения.
"""
from typing import Literal
from langchain.agents import create_agent
from pydantic import BaseModel, Field
from course_model import build_model
TEXT = "Деньги списали дважды за один заказ 4412, верните лишнее сегодня же."
class Ticket(BaseModel):
"""Разобранное обращение в службу поддержки."""
category: Literal["оплата", "доставка", "возврат", "прочее"] = Field(
description="Тема обращения"
)
urgency: Literal["низкая", "средняя", "высокая"] = Field(
description="Срочность обращения"
)
summary: str = Field(description="Суть обращения одним предложением, по-русски")
model = build_model(temperature=0, max_tokens=1024)
agent = create_agent(model=model, tools=[], response_format=Ticket)
result = agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
answer = result["structured_response"]
print("ТИП ОТВЕТА:", type(answer).__name__)
print("КАТЕГОРИЯ:", answer.category)
print("СРОЧНОСТЬ:", answer.urgency)
print("СУТЬ:", answer.summary)
print()
print("КЛЮЧИ СОСТОЯНИЯ:", sorted(result))
print("ТИПЫ СООБЩЕНИЙ:", [type(message).__name__ for message in result["messages"]])
print("ТЕКСТ ПОСЛЕДНЕГО СООБЩЕНИЯ:", repr(result["messages"][-1].text))
# Вывод:
# ТИП ОТВЕТА: Ticket
# КАТЕГОРИЯ: оплата
# СРОЧНОСТЬ: высокая
# СУТЬ: Двойное списание средств за заказ 4412, клиент требует возврат в срочном порядке
#
# КЛЮЧИ СОСТОЯНИЯ: ['messages', 'structured_response']
# ТИПЫ СООБЩЕНИЙ: ['HumanMessage', 'AIMessage', 'ToolMessage']
# ТЕКСТ ПОСЛЕДНЕГО СООБЩЕНИЯ: "Returning structured response: category='оплата' urgency='высокая' summary='Двойное списание средств за заказ 4412, клиент требует возврат в срочном порядке'"
Смотрите на три вещи в выводе.
Первое, тип ответа. Это не словарь и не строка, а экземпляр вашего класса. Поля category и urgency объявлены через Literal, и значения вне списка до вас не дойдут: их отбракует Pydantic.
Второе, ключи состояния. Их два, messages и structured_response. Второй появляется только тогда, когда у агента задан response_format.
Третье, текст последнего сообщения. Там служебная строка, а не ответ, и разбирать её не надо. Откуда она берётся, видно в примере 2.
Кто держит формат
Параметр response_format принимает такие значения.
| Значение | Что означает |
|---|---|
ToolStrategy[...] |
получать структуру через вызов инструмента |
ProviderStrategy[...] |
получать структуру нативным средством провайдера |
| тип схемы | выбрать стратегию автоматически по возможностям модели |
None |
структурированный вывод не запрошен |
Когда вы передаёте голую схему, как в примере 1, выбор делает фреймворк. Правило такое: ProviderStrategy, если выбранные модель и провайдер поддерживают нативный структурированный вывод, и ToolStrategy во всех остальных случаях. Нативный структурированный вывод есть, например, у OpenAI, Anthropic, xAI и Gemini.
Словарь с описанием JSON Schema документация LangChain велит оборачивать в явную стратегию. В закреплённой версии это не обязательно: голую схему код заворачивает в автоматический выбор стратегии, словарь тоже, и ответ приходит словарём по схеме.
Поддержку фреймворк с версии 1.1 читает из профиля модели, ключ structured_output. Профиль это словарь возможностей модели, вы смотрели на него в уроке 4. Если данных нет, задайте профиль вручную при создании модели:
custom_profile = {
"structured_output": True,
# ...
}
model = init_chat_model("...", profile=custom_profile)
Если у агента есть инструменты, для ProviderStrategy модель должна поддерживать их одновременно со структурированным выводом.
Чего документация не говорит, а код закреплённой версии делает: когда профиль поддержку не подтвердил, имя модели сверяется со списком образцов FALLBACK_MODELS_WITH_STRUCTURED_OUTPUT в langchain/agents/factory.py. В списке 16 образцов, и они привязаны к конкретным версиям моделей, а не к семействам: gpt-4o и claude-sonnet-4-5 в нём есть, а gpt-4 и claude-sonnet-5 нет. Не совпало ни с чем, значит поддержки нет и берётся ToolStrategy. Модель курса в этот список не входит, поэтому при голой схеме у неё выбирается ToolStrategy.
Пример 2: какую стратегию выбрал агент
Пример 02_strategy.py
"""Пример 2 урока 6: какую стратегию агент выбрал сам.
Голая схема в response_format означает "выбери стратегию сам". Выбор виден по
двум вещам: по профилю модели, откуда фреймворк читает поддержку нативного
вывода, и по следам в переписке. Пара "AIMessage с вызовом инструмента плюс
ToolMessage" означает ToolStrategy.
"""
from typing import Literal
from langchain.agents import create_agent
from pydantic import BaseModel, Field
from course_model import build_model
TEXT = "Когда приедет заказ 4412? Обещали вчера."
class Ticket(BaseModel):
"""Разобранное обращение в службу поддержки."""
category: Literal["оплата", "доставка", "возврат", "прочее"] = Field(
description="Тема обращения"
)
urgency: Literal["низкая", "средняя", "высокая"] = Field(
description="Срочность обращения"
)
summary: str = Field(description="Суть обращения одним предложением, по-русски")
def show(message) -> str:
"""Одна строка на сообщение: тип, вызовы инструментов, начало текста."""
text = message.text.replace("
", " ")
calls = [call["name"] for call in getattr(message, "tool_calls", [])]
return f"{type(message).__name__:<14} {calls} {text[:70]!r}"
model = build_model(temperature=0, max_tokens=1024)
print("ИМЯ МОДЕЛИ:", getattr(model, "model_name", None))
profile = model.profile
if profile is None:
print("ПРОФИЛЬ МОДЕЛИ: данных нет, значение None")
else:
print("ПОЛЕЙ В ПРОФИЛЕ:", len(profile))
print("ПОДДЕРЖКА structured_output:", profile.get("structured_output"))
print()
agent = create_agent(model=model, tools=[], response_format=Ticket)
result = agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
print("ПЕРЕПИСКА")
for number, message in enumerate(result["messages"], start=1):
print(f" {number}. {show(message)}")
print()
print("РАЗОБРАННЫЙ ОТВЕТ:", result["structured_response"])
# Вывод:
# ИМЯ МОДЕЛИ: deepseek/deepseek-v4-flash
# ПРОФИЛЬ МОДЕЛИ: данных нет, значение None
#
# ПЕРЕПИСКА
# 1. HumanMessage [] 'Когда приедет заказ 4412? Обещали вчера.'
# 2. AIMessage ['Ticket'] ''
# 3. ToolMessage [] "Returning structured response: category='доставка' urgency='высокая' s"
#
# РАЗОБРАННЫЙ ОТВЕТ: category='доставка' urgency='высокая' summary='Клиент спрашивает о статусе доставки заказа 4412, который обещали вчера, но не привезли.'
Стратегию видно по переписке. ToolStrategy оставляет в состоянии пару сообщений: AIMessage с вызовом инструмента, названного по имени вашей схемы, и ToolMessage со служебным текстом. ProviderStrategy не оставляет ни того, ни другого, у неё ответ приходит текстом сообщения.
Отсюда и текст последнего сообщения в примере 1: последним там стоит служебное сообщение инструмента. По умолчанию в нём строка Returning structured response: ..., свой текст задаётся параметром tool_message_content.
ToolStrategy: схема становится инструментом
Идея этой стратегии объясняет почти всё её поведение. Схема превращается в описание инструмента: имя инструмента, это имя класса, описание это докстринг, аргументы это поля. Инструмент отдаётся модели вместе с остальными, и модель "вызывает" его, подставляя в аргументы разобранные данные.
Никакой функции за этим инструментом нет. Агент перехватывает вызов, берёт аргументы, проверяет их по схеме и кладёт результат в structured_response. В переписку при этом добавляется сообщение инструмента: у каждого вызова инструмента в истории агента должен быть свой ToolMessage, об этом шла речь в уроке 3.
Параметры ToolStrategy:
1) schema, схема ответа. Кроме четырёх видов схемы, названных выше, здесь допустимо объединение типов, к нему вернусь в примере 7
2) tool_message_content, свой текст сообщения инструмента
3) handle_errors, что делать, когда разбор не удался. Значение по умолчанию True, подробно параметр разобран в примерах 5 и 6
Пример 3: ToolStrategy, названная явно
Пример 03_tool_strategy.py
"""Пример 3 урока 6: ToolStrategy, названная явно.
Схема превращается в инструмент, модель "вызывает" его аргументами по схеме, а
агент разбирает аргументы и кладёт объект в structured_response. Параметр
tool_message_content меняет текст сообщения инструмента, который остаётся в
переписке.
"""
from typing import Literal
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy
from pydantic import BaseModel, Field
from course_model import build_model
TEXT = "Хочу вернуть куртку из заказа 7781, размер не подошёл."
class Ticket(BaseModel):
"""Разобранное обращение в службу поддержки."""
category: Literal["оплата", "доставка", "возврат", "прочее"] = Field(
description="Тема обращения"
)
urgency: Literal["низкая", "средняя", "высокая"] = Field(
description="Срочность обращения"
)
summary: str = Field(description="Суть обращения одним предложением, по-русски")
def show(message) -> str:
"""Одна строка на сообщение: тип, вызовы инструментов, начало текста."""
text = message.text.replace("
", " ")
calls = [call["name"] for call in getattr(message, "tool_calls", [])]
return f"{type(message).__name__:<14} {calls} {text[:70]!r}"
model = build_model(temperature=0, max_tokens=1024)
default_agent = create_agent(
model=model,
tools=[],
response_format=ToolStrategy(Ticket),
)
custom_agent = create_agent(
model=model,
tools=[],
response_format=ToolStrategy(
schema=Ticket,
tool_message_content="Обращение разобрано и передано в очередь возвратов.",
),
)
print("ТЕКСТ СООБЩЕНИЯ ИНСТРУМЕНТА ПО УМОЛЧАНИЮ")
result = default_agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
for number, message in enumerate(result["messages"], start=1):
print(f" {number}. {show(message)}")
print()
print("АРГУМЕНТЫ ВЫЗОВА, ИЗ КОТОРЫХ СОБРАН ОБЪЕКТ")
for message in result["messages"]:
for call in getattr(message, "tool_calls", []):
print(f" {call['name']}: {call['args']}")
print()
print("СВОЙ ТЕКСТ СООБЩЕНИЯ ИНСТРУМЕНТА")
custom = custom_agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
for number, message in enumerate(custom["messages"], start=1):
print(f" {number}. {show(message)}")
print()
print("РАЗОБРАННЫЙ ОТВЕТ:", custom["structured_response"])
# Вывод:
# ТЕКСТ СООБЩЕНИЯ ИНСТРУМЕНТА ПО УМОЛЧАНИЮ
# 1. HumanMessage [] 'Хочу вернуть куртку из заказа 7781, размер не подошёл.'
# 2. AIMessage ['Ticket'] ' '
# 3. ToolMessage [] "Returning structured response: category='возврат' urgency='средняя' su"
#
# АРГУМЕНТЫ ВЫЗОВА, ИЗ КОТОРЫХ СОБРАН ОБЪЕКТ
# Ticket: {'category': 'возврат', 'urgency': 'средняя', 'summary': 'Возврат куртки из заказа 7781, не подошёл размер'}
#
# СВОЙ ТЕКСТ СООБЩЕНИЯ ИНСТРУМЕНТА
# 1. HumanMessage [] 'Хочу вернуть куртку из заказа 7781, размер не подошёл.'
# 2. AIMessage ['Ticket'] ''
# 3. ToolMessage [] 'Обращение разобрано и передано в очередь возвратов.'
#
# РАЗОБРАННЫЙ ОТВЕТ: category='возврат' urgency='средняя' summary='Возврат куртки из заказа 7781, не подошёл размер'
Посмотрите на блок с аргументами вызова. Это и есть тот JSON, который выдала модель, и именно из него собран объект. Сама модель никакого объекта Python не создавала, она сформировала вызов инструмента с аргументами, а разбор сделал фреймворк.
Второй прогон отличается от первого одной строкой в настройке, и в переписке меняется только текст сообщения инструмента. На разобранный ответ это не влияет вовсе. Зачем тогда параметр: сообщение остаётся в истории разговора и, если разговор продолжается, уходит в модель при следующих вызовах. Строка со служебным словарём там не нужна.
ProviderStrategy: формат держит провайдер
У второй стратегии всё иначе. Схема переводится в JSON Schema и уходит провайдеру отдельным параметром запроса, response_format с типом json_schema. Дальше формат держит провайдер на своей стороне. Насколько жёстко, зависит от провайдера и от параметра strict.
Со строгим режимом у поддерживающих его провайдеров формат соблюдается ещё при генерации ответа, до того как он дойдёт до вас. Без строгого режима ответ проверяет Pydantic на вашей стороне, а словарь JSON Schema не проверяется вовсе.
Параметры ProviderStrategy:
1) schema, те же четыре вида схемы
2) strict, необязательный признак строгого соблюдения схемы. Поддерживают его не все провайдеры, среди поддерживающих OpenAI и xAI. Значение по умолчанию, это None, то есть выключено. Параметр появился в langchain 1.2, в курсе закреплена 1.4.2
Отдельно про равнозначность. Если провайдер нативный вывод для вашей модели поддерживает, то response_format=Ticket и response_format=ProviderStrategy(Ticket) работают одинаково. Разница в том, что случится при отсутствии поддержки: голая схема откатится на ToolStrategy, а явно названная ProviderStrategy попробует отправить запрос как есть.
Пример 4: ProviderStrategy, названная явно
Пример 04_provider_strategy.py
"""Пример 4 урока 6: ProviderStrategy, названная явно.
Здесь схему держит не фреймворк, а сам провайдер: ему уходит параметр
response_format с JSON Schema. Поддержка есть не у каждого адреса, поэтому
вызов обёрнут в защитную ветку: отказ печатается строкой, а не роняет скрипт.
"""
from typing import Literal
from langchain.agents import create_agent
from langchain.agents.structured_output import ProviderStrategy
from pydantic import BaseModel, Field
from course_model import build_model
TEXT = "Хочу вернуть куртку из заказа 7781, размер не подошёл."
class Ticket(BaseModel):
"""Разобранное обращение в службу поддержки."""
category: Literal["оплата", "доставка", "возврат", "прочее"] = Field(
description="Тема обращения"
)
urgency: Literal["низкая", "средняя", "высокая"] = Field(
description="Срочность обращения"
)
summary: str = Field(description="Суть обращения одним предложением, по-русски")
def show(message) -> str:
"""Одна строка на сообщение: тип, вызовы инструментов, начало текста."""
text = message.text.replace("
", " ")
calls = [call["name"] for call in getattr(message, "tool_calls", [])]
return f"{type(message).__name__:<14} {calls} {text[:70]!r}"
print("ЧТО УХОДИТ ПРОВАЙДЕРУ")
strategy = ProviderStrategy(Ticket)
sent = strategy.to_model_kwargs()["response_format"]
print(" тип формата:", sent["type"])
print(" имя схемы:", sent["json_schema"]["name"])
print(" поля схемы:", sorted(sent["json_schema"]["schema"]["properties"]))
print()
model = build_model(temperature=0, max_tokens=1024)
agent = create_agent(model=model, tools=[], response_format=ProviderStrategy(Ticket))
print("ПРОГОН С НАТИВНЫМ ВЫВОДОМ")
try:
result = agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
except Exception as error: # noqa: BLE001
# Адрес провайдера может не принимать response_format с JSON Schema.
print(" отказ:", type(error).__name__)
print(" текст:", str(error)[:300])
else:
for number, message in enumerate(result["messages"], start=1):
print(f" {number}. {show(message)}")
print()
print(" РАЗОБРАННЫЙ ОТВЕТ:", result["structured_response"])
# Вывод:
# ЧТО УХОДИТ ПРОВАЙДЕРУ
# тип формата: json_schema
# имя схемы: Ticket
# поля схемы: ['category', 'summary', 'urgency']
#
# ПРОГОН С НАТИВНЫМ ВЫВОДОМ
# 1. HumanMessage [] 'Хочу вернуть куртку из заказа 7781, размер не подошёл.'
# 2. AIMessage [] '{ "category": "возврат", "summary": "Возврат куртки из заказа 7781'
#
# РАЗОБРАННЫЙ ОТВЕТ: category='возврат' urgency='средняя' summary='Возврат куртки из заказа 7781, не подошёл размер.'
Первый блок вывода печатается без обращения к сети и показывает, что именно уходит провайдеру под именем response_format.
Второй блок отправляет запрос модели, и закончиться он может так:
1) адрес принимает запрос. В переписке нет ни вызова инструмента, ни сообщения инструмента, разобранный ответ собран из текста ответа модели. Это вывод выше
2) адрес параметр не принимает. Защитная ветка печатает строку с типом исключения
3) запрос принят, но ответ не уложился в потолок max_tokens. На одном из прогонов этого же кода пришёл LengthFinishReasonError с 1024 токенами выхода: разобранной структуры нет, а вызов оплачен
За адресом, совместимым с OpenAI, может стоять что угодно, поэтому поддержку нативного вывода проверяйте на своём провайдере.
Какую стратегию выбирать
Короткий ответ: не выбирайте руками, пока не появилась причина. Передавайте схему голой и дайте фреймворку решить, он решает по возможностям модели, а вы не переписываете код при смене провайдера.
Назвать стратегию явно стоит, когда важно одно из различий ниже.
| Ось сравнения | ProviderStrategy |
ToolStrategy |
|---|---|---|
| Кто держит формат | провайдер на своей стороне | фреймворк после ответа |
| Где нужна поддержка | нативный структурированный вывод у модели | вызов инструментов у модели |
| След в переписке | обычный ответ текстом | вызов инструмента плюс сообщение инструмента |
| Несколько схем на выбор | нет | да, через объединение типов |
| Повтор при нарушении схемы | нет | да, настраивается handle_errors |
| Свой текст в истории | нет | да, tool_message_content |
Две последние строки таблицы разбираются дальше. У ProviderStrategy параметра повтора нет, формат держит провайдер. Если разбор всё-таки сорвался, например провайдер вернул не тот JSON, агент бросит StructuredOutputValidationError, и до вашего кода дойдёт исключение, а не структура.
Почему схема ломается
Ограничения схемы лежат в двух местах, и модель видит только одно из них.
Первое место, это JSON Schema. В неё переводятся типы полей, перечисления Literal, границы чисел из Field(ge=..., le=...), обязательность. Модель получает эти ограничения вместе с описанием инструмента, а проверяет их Pydantic на вашей стороне.
Второе место, это валидаторы Pydantic. В JSON Schema они не переводятся никак. Модель о них не знает и узнать может только одним способом, из текста ошибки.
Отсюда правило: всё, что не выражается в JSON Schema, модель угадывает. Не хотите повторных запросов, выражайте ограничения так, чтобы они попали в схему, и дублируйте их словами в описании поля.
Что делает агент при нарушении. Разбор падает, агент кладёт в переписку сообщение инструмента с текстом ошибки и просьбой исправиться и отправляет переписку обратно в модель. В следующий вызов уходят и прошлый вызов инструмента, и текст ошибки. Это и есть повтор: отдельного механизма для него нет, это обычный шаг цикла агента, только с ошибкой в переписке.
Текст ошибки собирается по образцу Error: {error}
Please fix your mistakes.
Пример 5: схема сломалась, агент повторил
Пример 05_retry.py
"""Пример 5 урока 6: схема сломалась, агент повторил запрос.
В схеме два вида ограничений. Первые выражаются в JSON Schema, и модель видит
их вместе с описанием полей. Вторые заданы в валидаторе Pydantic, и модель о них
не знает вовсе: узнать она может только из текста ошибки. Здесь стоит второе,
поэтому первый вызов почти наверняка не пройдёт проверку.
"""
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy
from pydantic import BaseModel, Field, field_validator
from course_model import build_model
TEXT = "Оплатил заказ 4412 на 5300 рублей, товар не пришёл, верните деньги."
class Refund(BaseModel):
"""Заявка на возврат денег."""
order_id: str = Field(description="Номер заказа из обращения")
amount: float = Field(description="Сумма к возврату в рублях")
@field_validator("order_id")
@classmethod
def check_order_id(cls, value: str) -> str:
"""Внутренний формат номера, которого нет в JSON Schema."""
if not value.startswith("ORD-"):
message = "Номер заказа записывается с префиксом ORD-, например ORD-1234"
raise ValueError(message)
return value
def show(message) -> str:
"""Одна строка на сообщение: тип, вызовы инструментов, начало текста."""
text = message.text.replace("
", " ")
calls = [call["name"] for call in getattr(message, "tool_calls", [])]
return f"{type(message).__name__:<14} {calls} {text[:110]!r}"
print("ЧТО ИЗ СХЕМЫ ВИДИТ МОДЕЛЬ")
print(" ", Refund.model_json_schema()["properties"])
print()
model = build_model(temperature=0, max_tokens=1024)
agent = create_agent(
model=model,
tools=[],
response_format=ToolStrategy(Refund), # handle_errors=True по умолчанию
)
result = agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
print("ПЕРЕПИСКА")
for number, message in enumerate(result["messages"], start=1):
print(f" {number}. {show(message)}")
print()
print("ВЫЗОВОВ МОДЕЛИ:", sum(1 for m in result["messages"] if type(m).__name__ == "AIMessage"))
print("РАЗОБРАННЫЙ ОТВЕТ:", result["structured_response"])
# Вывод:
# ЧТО ИЗ СХЕМЫ ВИДИТ МОДЕЛЬ
# {'order_id': {'description': 'Номер заказа из обращения', 'title': 'Order Id', 'type': 'string'}, 'amount': {'description': 'Сумма к возврату в рублях', 'title': 'Amount', 'type': 'number'}}
#
# ПЕРЕПИСКА
# 1. HumanMessage [] 'Оплатил заказ 4412 на 5300 рублей, товар не пришёл, верните деньги.'
# 2. AIMessage ['Refund'] ' '
# 3. ToolMessage [] "Error: Failed to parse structured output for tool 'Refund': Failed to parse data to Refund: 1 validation error"
# 4. AIMessage ['Refund'] ''
# 5. ToolMessage [] "Returning structured response: order_id='ORD-4412' amount=5300.0"
#
# ВЫЗОВОВ МОДЕЛИ: 2
# РАЗОБРАННЫЙ ОТВЕТ: order_id='ORD-4412' amount=5300.0
Первый блок печатает то, что модель видит из схемы. Про префикс ORD- там нет ни слова, потому что задан он в валидаторе, а валидатор в JSON Schema не попадает.
Дальше смотрите на переписку. Строка 3, ToolMessage с текстом Error: Failed to parse..., означает, что первый ответ не прошёл проверку и модель получила текст ошибки. Печать сообщение обрезает, целиком оно заканчивается словами Please fix your mistakes. Число вызовов модели показывает, во сколько заходов удалось собрать объект. Если в вашем прогоне модель попала с первого раза, это не отменяет механики: добавьте в валидатор проверку построже и повторите.
Каждый повтор, это ещё один платный вызов модели, и в него уходит вся переписка целиком, вместе с сообщением об ошибке. Считать деньги вы учились в уроках 1 и 4, здесь достаточно помнить, что жёсткая проверка, невидимая для модели, оплачивается лишними шагами.
handle_errors: виды значения и одно расхождение
Параметр handle_errors у ToolStrategy задаёт, что агент делает при ошибке разбора. Виды значения:
1) True, ловить все ошибки и повторять со стандартным текстом. Это значение по умолчанию
2) строка, ловить все ошибки и повторять с вашим текстом
3) тип исключения, ловить его и его наследников, остальные пробрасывать наружу
4) кортеж типов исключений, то же самое для нескольких
5) функция от исключения, возвращающая текст сообщения. Повтор идёт всегда, а в функции можно разобрать случай и ответить по-разному
6) False, ничего не ловить, все исключения выходят наружу
В handle_errors попадают исключения этих классов, у них общий родитель StructuredOutputError:
1) StructuredOutputValidationError, аргументы вызова не прошли проверку по схеме
2) MultipleStructuredOutputsError, модель вернула сразу несколько структурированных ответов, когда ожидался один
Эти классы и их общий родитель StructuredOutputError импортируются из langchain.agents.structured_output.
Третий вид значения, тип исключения, документация показывает так:
ToolStrategy(
schema=ProductRating,
handle_errors=ValueError # Only retry on ValueError, raise others
)
В закреплённой версии ошибка проверки схемы приходит классом StructuredOutputValidationError, а он наследуется от StructuredOutputError и Exception, но не от ValueError. Проверка типа не совпадёт, повтора не будет, исключение выйдет наружу. Не берите строчку из документации как рабочий образец, проверьте её на своём случае.
Пример 6: три режима на одной сломанной схеме
Пример 06_handle_errors.py
"""Пример 6 урока 6: три режима handle_errors на одной сломанной схеме.
Схема та же, что в примере 5, поэтому первый ответ модели проверку не проходит.
Меняется только то, что агент делает дальше: повторяет с текстом ошибки,
повторяет со своим текстом или отдаёт исключение наружу.
ОТДЕЛЬНО ПРО ПЕРВЫЙ РЕЖИМ. Документация показывает handle_errors=ValueError как
способ повторять только на ошибках разбора. В закреплённой версии пакета ошибка
разбора приходит классом StructuredOutputValidationError, он наследуется от
StructuredOutputError, а тот от Exception, но не от ValueError. Значит условие
не совпадает и повтора не будет. Прогон ниже показывает, что получается.
"""
from langchain.agents import create_agent
from langchain.agents.structured_output import (
StructuredOutputValidationError,
ToolStrategy,
)
from pydantic import BaseModel, Field, field_validator
from course_model import build_model
TEXT = "Оплатил заказ 4412 на 5300 рублей, товар не пришёл, верните деньги."
class Refund(BaseModel):
"""Заявка на возврат денег."""
order_id: str = Field(description="Номер заказа из обращения")
amount: float = Field(description="Сумма к возврату в рублях")
@field_validator("order_id")
@classmethod
def check_order_id(cls, value: str) -> str:
"""Внутренний формат номера, которого нет в JSON Schema."""
if not value.startswith("ORD-"):
message = "Номер заказа записывается с префиксом ORD-, например ORD-1234"
raise ValueError(message)
return value
model = build_model(temperature=0, max_tokens=1024)
def run(title: str, handle_errors) -> None:
"""Прогоняет одно обращение с заданным режимом обработки ошибок."""
print(title)
agent = create_agent(
model=model,
tools=[],
response_format=ToolStrategy(schema=Refund, handle_errors=handle_errors),
)
try:
result = agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
except Exception as error: # noqa: BLE001
print(" исключение:", type(error).__name__)
print(" текст:", str(error)[:160])
print()
return
calls = sum(1 for m in result["messages"] if type(m).__name__ == "AIMessage")
print(" вызовов модели:", calls)
for message in result["messages"]:
if type(message).__name__ == "ToolMessage":
print(" сообщение инструмента:", repr(message.text[:120]))
print(" разобранный ответ:", result.get("structured_response"))
print()
run("РЕЖИМ 1: handle_errors=ValueError, как показано в документации", ValueError)
run(
"РЕЖИМ 2: handle_errors=StructuredOutputValidationError, настоящий класс ошибки",
StructuredOutputValidationError,
)
run(
"РЕЖИМ 3: handle_errors со своим текстом",
"Номер заказа записывается с префиксом ORD-. Повторите ответ.",
)
# Вывод:
# РЕЖИМ 1: handle_errors=ValueError, как показано в документации
# исключение: StructuredOutputValidationError
# текст: Failed to parse structured output for tool 'Refund': Failed to parse data to Refund: 1 validation error for Refund
# order_id
# Value error, Номер заказа записыва
#
# РЕЖИМ 2: handle_errors=StructuredOutputValidationError, настоящий класс ошибки
# вызовов модели: 2
# сообщение инструмента: "Error: Failed to parse structured output for tool 'Refund': Failed to parse data to Refund: 1 validation error for Refun"
# сообщение инструмента: "Returning structured response: order_id='ORD-4412' amount=5300.0"
# разобранный ответ: order_id='ORD-4412' amount=5300.0
#
# РЕЖИМ 3: handle_errors со своим текстом
# вызовов модели: 2
# сообщение инструмента: 'Номер заказа записывается с префиксом ORD-. Повторите ответ.'
# сообщение инструмента: "Returning structured response: order_id='ORD-4412' amount=5300.0"
# разобранный ответ: order_id='ORD-4412' amount=5300.0
Сравните три блока. Первый это тот самый образец из документации. Второй отличается от него одним словом, настоящим классом ошибки, и ведёт себя так, как от первого и ожидалось. Третий показывает, зачем нужен свой текст. Стандартное сообщение пересказывает исключение Pydantic, а свою строку вы пишете сами и можете прямо назвать в ней правило, которого модель не видит в схеме.
Счётчика попыток у повтора нет. Если модель раз за разом не проходит валидатор, повторы идут один за другим, и каждый платный. Потолок у агента есть: create_agent ставит графу 9999 шагов, но до него набегут тысячи вызовов модели. Число вызовов ограничивает middleware с лимитом вызовов модели, это урок 11.
Две схемы на выбор
ToolStrategy умеет то, чего ProviderStrategy не умеет вовсе: принять объединение типов и дать модели выбрать подходящую схему. Каждая схема из объединения становится отдельным инструментом, а выбор делает модель по содержанию обращения.
Пригодится это там, где ответы бывают разной формы. Обращение в поддержку бывает претензией, из которой собирается заявка, или просьбой перезвонить, и поля у этих ответов разные, а не общие с пропусками.
Отсюда же берётся вторая ошибка из списка выше. Модель может вызвать две схемы сразу, и тогда агент кладёт в переписку сообщение об ошибке на каждый вызов, с текстом про несколько структурированных ответов.
Пример 7: объединение двух схем
Пример 07_union.py
"""Пример 7 урока 6: две схемы на выбор.
ToolStrategy принимает объединение типов, и каждая схема превращается в свой
инструмент. Модель выбирает одну из них по содержанию обращения. Третье
обращение написано так, что подходят обе схемы сразу: это тот случай, ради
которого во фреймворке есть отдельная ошибка про несколько ответов.
"""
from typing import Literal, Union
from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy
from pydantic import BaseModel, Field
from course_model import build_model
MESSAGES = [
"Деньги списали дважды за заказ 4412, верните лишнее.",
"Позвоните мне на +7 999 123-45-67 после шести вечера, вопрос по доставке.",
"Деньги списали дважды за заказ 4412, и перезвоните на +7 999 123-45-67 вечером.",
]
class Ticket(BaseModel):
"""Обращение, на которое поддержка отвечает письмом."""
category: Literal["оплата", "доставка", "возврат", "прочее"] = Field(
description="Тема обращения"
)
urgency: Literal["низкая", "средняя", "высокая"] = Field(
description="Срочность обращения"
)
summary: str = Field(description="Суть обращения одним предложением, по-русски")
class CallbackRequest(BaseModel):
"""Просьба перезвонить: клиент хочет разговор с человеком."""
phone: str = Field(description="Номер телефона из обращения")
reason: str = Field(description="Причина звонка одним предложением, по-русски")
time_hint: str = Field(description="Когда клиенту удобно, словами из обращения")
def show(message) -> str:
"""Одна строка на сообщение: тип, вызовы инструментов, начало текста."""
text = message.text.replace("
", " ")
calls = [call["name"] for call in getattr(message, "tool_calls", [])]
return f"{type(message).__name__:<14} {calls} {text[:90]!r}"
model = build_model(temperature=0, max_tokens=1024)
agent = create_agent(
model=model,
tools=[],
response_format=ToolStrategy(Union[Ticket, CallbackRequest]),
)
for number, text in enumerate(MESSAGES, start=1):
print(f"ОБРАЩЕНИЕ {number}: {text}")
result = agent.invoke({"messages": [{"role": "user", "content": text}]})
for position, message in enumerate(result["messages"], start=1):
print(f" {position}. {show(message)}")
answer = result.get("structured_response")
print(" ВЫБРАННАЯ СХЕМА:", type(answer).__name__)
print(" РАЗОБРАННЫЙ ОТВЕТ:", answer)
print()
# Вывод:
# ОБРАЩЕНИЕ 1: Деньги списали дважды за заказ 4412, верните лишнее.
# 1. HumanMessage [] 'Деньги списали дважды за заказ 4412, верните лишнее.'
# 2. AIMessage ['Ticket'] ''
# 3. ToolMessage [] "Returning structured response: category='оплата' urgency='высокая' summary='Двойное списан"
# ВЫБРАННАЯ СХЕМА: Ticket
# РАЗОБРАННЫЙ ОТВЕТ: category='оплата' urgency='высокая' summary='Двойное списание средств за заказ 4412, клиент просит вернуть лишнюю сумму'
#
# ОБРАЩЕНИЕ 2: Позвоните мне на +7 999 123-45-67 после шести вечера, вопрос по доставке.
# 1. HumanMessage [] 'Позвоните мне на +7 999 123-45-67 после шести вечера, вопрос по доставке.'
# 2. AIMessage ['CallbackRequest'] ''
# 3. ToolMessage [] "Returning structured response: phone='+7 999 123-45-67' reason='Вопрос по доставке' time_h"
# ВЫБРАННАЯ СХЕМА: CallbackRequest
# РАЗОБРАННЫЙ ОТВЕТ: phone='+7 999 123-45-67' reason='Вопрос по доставке' time_hint='после шести вечера'
#
# ОБРАЩЕНИЕ 3: Деньги списали дважды за заказ 4412, и перезвоните на +7 999 123-45-67 вечером.
# 1. HumanMessage [] 'Деньги списали дважды за заказ 4412, и перезвоните на +7 999 123-45-67 вечером.'
# 2. AIMessage ['Ticket', 'CallbackRequest'] ' '
# 3. ToolMessage [] 'Error: Model incorrectly returned multiple structured responses (Ticket, CallbackRequest) '
# 4. ToolMessage [] 'Error: Model incorrectly returned multiple structured responses (Ticket, CallbackRequest) '
# 5. AIMessage ['Ticket'] ''
# 6. ToolMessage [] "Returning structured response: category='оплата' urgency='высокая' summary='Двойное списан"
# ВЫБРАННАЯ СХЕМА: Ticket
# РАЗОБРАННЫЙ ОТВЕТ: category='оплата' urgency='высокая' summary='Двойное списание денег за заказ 4412'
Первые два обращения написаны так, что подходящая схема очевидна, и по строке ВЫБРАННАЯ СХЕМА видно, что выбрала модель. Третье собрано из двух первых нарочно: в нём есть и претензия по деньгам, и просьба перезвонить. Смотрите, сколько в нём вызовов и появилось ли сообщение об ошибке с несколькими ответами.
Развилка на две схемы означает, что на пограничном обращении модель может вызвать обе, и это лишний платный шаг, как в третьем обращении примера 7. Если пограничных обращений у вас много, одна схема с полем-признаком обходится без этого повтора.
Схема и инструмент вместе
Последний пример ближе к рабочей задаче. Агент разбирает обращение, при необходимости ходит в инструмент за статусом заказа и возвращает готовую запись, в которой лежит вложенный объект.
Из прошлых уроков здесь системный промпт и инструмент, подключённый обычной функцией, как в уроках 2 и 5. Подробно инструменты разбираются в уроке 9. Новое здесь вложенная схема: поле order объявлено типом другой модели Pydantic и может остаться пустым, потому что номер заказа в обращении бывает не назван.
Чего документация не говорит, а код закреплённой версии делает: при ToolStrategy агент передаёт модели tool_choice="any", и ChatOpenAI отправляет его провайдеру как "required". На каждом шаге модель обязана вызвать какой-нибудь инструмент: либо ваш, либо схему. Ответить одним текстом, без вызова, она не может.
Пример 8: разбор обращений с походом в инструмент
Пример 08_desk.py
"""Пример 8 урока 6: схема плюс инструмент, близко к рабочей задаче.
Агент разбирает обращение, при необходимости ходит за статусом заказа и
возвращает готовую запись со вложенным объектом. Поле order необязательное: в
обращении номера заказа может не быть вовсе, и схема это допускает.
ВАЖНАЯ ДЕТАЛЬ ПРО ToolStrategy ВМЕСТЕ С ИНСТРУМЕНТАМИ. С ToolStrategy агент
выставляет модели tool_choice="any", то есть на каждом
шаге модель обязана вызвать какой-нибудь инструмент. Свободным текстом она
ответить не может: либо идёт в инструмент, либо отдаёт ответ по схеме. Это
видно в переписке первого обращения.
"""
from typing import Literal
from langchain.agents import create_agent
from pydantic import BaseModel, Field
from course_model import build_model
ORDERS = {
"4412": "в доставке, ожидается 18.09.2026, сумма 5300 рублей",
"7781": "доставлен 12.09.2026, сумма 990 рублей",
}
MESSAGES = [
"Где мой заказ 4412? Обещали вчера.",
"Верните деньги за заказ 7781, куртка не подошла.",
"Подскажите, вы доставляете в выходные?",
]
def find_order(order_id: str) -> str:
"""Возвращает статус и сумму заказа по его номеру."""
return ORDERS.get(order_id, f"заказ {order_id} не найден")
class OrderFact(BaseModel):
"""Данные заказа, полученные инструментом."""
order_id: str = Field(description="Номер заказа")
status: str = Field(description="Статус заказа так, как его вернул инструмент")
class DeskAnswer(BaseModel):
"""Готовая запись службы поддержки по одному обращению."""
category: Literal["оплата", "доставка", "возврат", "прочее"] = Field(
description="Тема обращения"
)
urgency: Literal["низкая", "средняя", "высокая"] = Field(
description="Срочность обращения"
)
order: OrderFact | None = Field(
default=None,
description="Данные заказа, если в обращении назван его номер",
)
reply: str = Field(description="Ответ клиенту, одно-два предложения, по-русски")
def show(message) -> str:
"""Одна строка на сообщение: тип, вызовы инструментов, начало текста."""
text = message.text.replace("
", " ")
calls = [call["name"] for call in getattr(message, "tool_calls", [])]
return f"{type(message).__name__:<14} {calls} {text[:70]!r}"
model = build_model(temperature=0, max_tokens=1024)
agent = create_agent(
model=model,
tools=[find_order],
system_prompt=(
"Вы оператор службы поддержки интернет-магазина. Если в обращении назван "
"номер заказа, сначала посмотрите его статус инструментом, и только потом "
"составляйте ответ. Отвечайте по-русски."
),
response_format=DeskAnswer,
)
for number, text in enumerate(MESSAGES, start=1):
print(f"ОБРАЩЕНИЕ {number}: {text}")
result = agent.invoke({"messages": [{"role": "user", "content": text}]})
answer = result.get("structured_response")
if answer is None:
# Схема не собралась: печатаем переписку и идём дальше.
print(" структурированного ответа нет")
for position, message in enumerate(result["messages"], start=1):
print(f" {position}. {show(message)}")
print()
continue
if number == 1:
for position, message in enumerate(result["messages"], start=1):
print(f" {position}. {show(message)}")
print(" тема:", answer.category, " срочность:", answer.urgency)
print(" заказ:", answer.order.order_id if answer.order else "не назван")
print(" статус:", answer.order.status if answer.order else "-")
print(" ответ клиенту:", answer.reply)
print(
" вызовов модели:",
sum(1 for m in result["messages"] if type(m).__name__ == "AIMessage"),
)
print()
# Вывод:
# ОБРАЩЕНИЕ 1: Где мой заказ 4412? Обещали вчера.
# 1. HumanMessage [] 'Где мой заказ 4412? Обещали вчера.'
# 2. AIMessage ['find_order'] ''
# 3. ToolMessage [] 'в доставке, ожидается 18.09.2026, сумма 5300 рублей'
# 4. AIMessage ['DeskAnswer'] ''
# 5. ToolMessage [] "Returning structured response: category='доставка' urgency='средняя' o"
# тема: доставка срочность: средняя
# заказ: 4412
# статус: в доставке, ожидается 18.09.2026, сумма 5300 рублей
# ответ клиенту: Здравствуйте! Ваш заказ №4412 находится в доставке, ожидаемая дата получения — 18.09.2026. Приносим извинения за задержку.
# вызовов модели: 2
#
# ОБРАЩЕНИЕ 2: Верните деньги за заказ 7781, куртка не подошла.
# тема: возврат срочность: средняя
# заказ: не назван
# статус: -
# ответ клиенту: Здравствуйте! Заказ №7781 был доставлен, сумма к возврату составит 990 рублей. Для оформления возврата, пожалуйста, сообщите причину и подтвердите, что товар не был в использовании.
# вызовов модели: 2
#
# ОБРАЩЕНИЕ 3: Подскажите, вы доставляете в выходные?
# тема: доставка срочность: низкая
# заказ: не назван
# статус: -
# ответ клиенту: Да, мы доставляем заказы и в выходные дни. Доставка осуществляется без перерывов и выходных.
# вызовов модели: 1
У первого обращения напечатана вся переписка, и в ней видно цикл целиком: вызов инструмента, ответ инструмента, вызов схемы, служебное сообщение. У третьего обращения номера заказа нет, и поле order остаётся пустым, схема это разрешает. У второго обращения номер назван прямо в тексте, сумма в ответе клиенту взята из инструмента, а поле order в выводе выше всё равно пустое. На другом прогоне того же кода модель это поле заполнила. Отсюда правило: необязательное поле модель заполняет не всегда. Нужен номер в базе, делайте поле обязательным или доставайте номер из текста обращения кодом.
Поход в инструмент плюс сборка ответа, это два платных вызова модели, и оба несут в себе всю переписку. Схема тоже стоит денег: её описание уходит в модель на каждом шаге и оплачивается входными токенами, как системный промпт из урока 5.
Чего структурированный вывод не гарантирует
Форму, но не содержание. Схема гарантирует, что urgency окажется одним из трёх слов, и ничего не говорит о том, верное ли это слово. Модель, уверенно пишущая неправду, будет писать её в валидные поля, и об этом я говорил в уроке 1. В примере 8 ответ на третье обращение обещает доставку в выходные, хотя данных о доставке у агента нет. Поле reply заполнено по схеме, а содержание модель придумала.
Оговорка для тех, кто придёт сюда из старых статей. Раньше ту же задачу решали разборщиками вывода, PydanticOutputParser и его соседями из langchain_core.output_parsers. Классы в коде по-прежнему есть, но документация LangChain их больше не разбирает и советует вместо них вызов инструментов или средства структурированного вывода.
Схему можно задать и модели напрямую, без агента, методом with_structured_output. Он нужен там, где агент избыточен: один вызов, одна классификация, никакого цикла. У ChatOpenAI параметр method принимает три значения, json_schema, function_calling и json_mode, а include_raw=True возвращает вместе с разобранным ответом исходное сообщение с расходом токенов.
Распространённые ошибки
Разборы ниже, это фрагменты, а не файлы. Схема, модель, агент и текст обращения в них считаются уже объявленными, как в примерах выше, поэтому скопировать и запустить такой кусок целиком не получится.
Ошибка: ищут структуру в тексте последнего сообщения
# Неправильно: разбирают текст, ради избавления от которого всё и делалось
result = agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
data = json.loads(result["messages"][-1].text)
# Правильно
result = agent.invoke({"messages": [{"role": "user", "content": TEXT}]})
data = result["structured_response"]
Почему так: при ToolStrategy последним в переписке лежит служебное сообщение инструмента, а не ответ. В его тексте строка Returning structured response: ... или ваш tool_message_content, а не JSON, и разбор сломается на первом же прогоне.
Ошибка: схема словарём там, где нужна проверка значений
# Неправильно: аргументы вызова для словаря не проверяются, повторов не будет
agent = create_agent(model=model, tools=[], response_format=ticket_schema)
# Правильно: та же схема классом Pydantic
agent = create_agent(model=model, tools=[], response_format=Ticket)
Почему так: при ToolStrategy аргументы вызова для словаря возвращаются как есть. Значение вне перечисления дойдёт до вашего кода, а handle_errors бездействует, повторять нечего.
Ошибка: handle_errors=ValueError в расчёте ловить ошибки схемы
# Неправильно: ошибка схемы не наследуется от ValueError, повтора не будет
response_format = ToolStrategy(schema=Refund, handle_errors=ValueError)
# Правильно
from langchain.agents.structured_output import StructuredOutputValidationError
response_format = ToolStrategy(
schema=Refund,
handle_errors=StructuredOutputValidationError,
)
Почему так: разобрано в примере 6. Это тот случай, когда образец из документации приводит к обратному результату. Поэтому запомните не строчку, а способ проверки: напишите схему, которая гарантированно ломается, и посмотрите, повторяет агент или падает. Чтобы ловить обе ошибки структурированного вывода, укажите их общего родителя, StructuredOutputError.
Ошибка: класс схемы без докстринга и описаний полей
# Неправильно: модель видит имена полей и типы, и больше ничего
class T(BaseModel):
c: str
u: str
# Правильно: имя, докстринг и описания, это инструкция для модели
class Ticket(BaseModel):
"""Разобранное обращение в службу поддержки."""
category: Literal["оплата", "доставка", "возврат", "прочее"] = Field(
description="Тема обращения"
)
urgency: Literal["низкая", "средняя", "высокая"] = Field(
description="Срочность обращения"
)
Почему так: при ToolStrategy имя класса становится именем инструмента, докстринг, его описанием, а поля, аргументами. Для T модель получает имя T, пустое описание и два строковых поля c и u, и что в них класть, ей остаётся угадывать. Ошибки при этом не будет, ответ формально соответствует схеме. В правильном варианте Literal вдобавок превращается в перечисление допустимых значений.
Практическое задание
Напишите скрипт triage.py, который разбирает обращения в поддержку по схеме и показывает, сколько вызовов модели потребовал разбор.
Требования:
1) объявите схему Issue на Pydantic: тема обращения через Literal из четырёх значений, срочность через Literal из трёх, короткая сводка строкой и номер заказа строкой или None, со значением по умолчанию None
2) на номер заказа поставьте валидатор Pydantic, который требует ваш внутренний формат номера, например префикс ORD- и четыре цифры
3) сделайте агента с ToolStrategy и своим текстом в tool_message_content
4) прогоните три обращения: одно с номером заказа в правильном виде, одно с номером в чужом виде, одно без номера вовсе
5) по каждому обращению напечатайте разобранный объект, число вызовов модели и тексты всех сообщений инструмента
6) вторым прогоном повторите то же самое с handle_errors=False и поймайте исключение, напечатав его класс
7) любой отказ провайдера печатайте строкой с типом исключения, а не роняйте скрипт
Как проверить результат:
1) в первом прогоне разобранный объект получен по всем трём обращениям
2) у обращения с номером в чужом виде вызовов модели больше, чем у остальных, и среди сообщений инструмента есть сообщение с текстом ошибки
3) во втором прогоне то же обращение даёт исключение класса StructuredOutputValidationError, а два других проходят как раньше
4) обращение без номера заказа даёт объект с пустым полем номера, а не отказ
Подсказка: чтобы валидатор гарантированно сработал, не описывайте формат номера в description поля, иначе модель попадёт с первого раза и повтора вы не увидите. И пропускайте в валидаторе None: модель может передать пустой номер явно, и проверка вида value.startswith(...) упадёт на нём с AttributeError.
Итоги урока
Теперь ответ агента, это данные, а не текст, который надо разбирать самому.
Схема задаётся классом Pydantic и передаётся параметром response_format, а разобранный объект приходит ключом состояния structured_response. Имя класса, докстринг и описания полей уходят модели как инструкция, поэтому пишите их так же внимательно, как системный промпт.
Две стратегии. ProviderStrategy отдаёт схему провайдеру, и формат держится на его стороне. Доступна она не везде, а насколько жёстко держится формат, зависит от провайдера и параметра strict. ToolStrategy превращает схему в инструмент, работает с любой моделью, умеющей вызывать инструменты, и оставляет в переписке пару сообщений, по которой её видно. Голая схема в параметре означает автоматический выбор: по профилю модели, а если профиль поддержку не подтвердил, по списку имён. Это разумное значение по умолчанию.
Ограничения из JSON Schema модель получает вместе с запросом. О валидаторах Pydantic она не знает и узнаёт о них только из текста ошибки, после первого неудачного ответа. Повтор после нарушения, это обычный шаг агента с сообщением об ошибке в переписке. Настраивается он параметром handle_errors, счётчика попыток у него нет. В уроке показано, где документация расходится с закреплённой версией. Dataclass возвращает экземпляр, а не словарь, словарь JSON Schema не нужно оборачивать в стратегию, а образец с handle_errors=ValueError не повторяет, а падает.
Во всех примерах урока invoke возвращал результат целиком, когда агент заканчивал работу. Пользователь всё это время видит пустой экран, а в примере 8 это два полных вызова модели подряд. Показывать ответ по частям, по мере появления, invoke не умеет.
Это тема урока 7, "Стриминг: поток вместо ожидания": режимы потока и что из них показывать пользователю. Там же станет видно, что структурированного ответа в потоке нет: объект собирается только к концу.
Код урока
Примеры этого урока лежат в репозитории курса, папка lesson_06. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.
Предыдущий урок: Системный промпт и контекст вместо шаблонов
Следующий урок: Стриминг: поток вместо ожидания ---
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru