LangChain

Структурированный вывод | Курс LangChain урок 6

Структурированный вывод | Курс LangChain урок 6
Михаил Омельченко
Автор
Михаил Омельченко
Опубликовано 01.10.2026
0,0
Views 4

Цель урока: получать от агента не текст, а проверенный объект 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 агентов, пишите, обсудим условия

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы