LangChain

Модель как настраиваемый компонент | Курс LangChain урок 4

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

Цель урока: настраивать модель тремя способами, при создании, прикреплением к объекту и на один вызов. Читать профиль возможностей модели и менять модель под задачу через конфигурацию вызова и внутри агента. Ставить ограничитель частоты и таймаут с повторами. Считать расход по всем моделям приложения одним обработчиком.

Необходимые знания:

1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены, типы исключений из langchain_core.exceptions знакомы

2) урок 1: токены и деньги, usage_metadata, model.profile как источник размера окна

3) урок 2: init_chat_model, четыре пакета экосистемы, первый взгляд на create_agent

4) урок 3: сообщения, text и content_blocks, tool_calls и ToolMessage

5) Python на уровне джуниора: словари, распаковка **kwargs, декораторы, обработка исключений, замер времени

Ключевые концепции:

1) три места, где задаётся параметр модели, и что из них переживает вызов

2) профиль модели как справка о возможностях и способ её дописать

3) настраиваемая модель: имя модели становится значением конфигурации

4) выбор модели под задачу: словарём настроек и middleware внутри агента

5) инструменты на стороне провайдера: один шаг диалога вместо цикла

6) устойчивость соединения: timeout, max_retries и то, какие отказы повторяются

7) ограничение частоты запросов через InMemoryRateLimiter: запрос уходит, когда накопилось разрешение, а разрешения копятся с заданной скоростью

8) счёт расхода по нескольким моделям через UsageMetadataCallbackHandler


Зачем настраивать то, что и так отвечает

Три урока подряд модель была одна и та же, собранная одной функцией с одним параметром. Она отвечала, и этого хватало: пока задача одна, настройка без необходимости, это лишний код.

Необходимость появляется, когда задач становится несколько. Классификация обращения в одно слово и разбор договора на три страницы, это разные требования к длине ответа, к цене и к тому, насколько страшно ошибиться. Отправить их в одну модель с одними настройками можно. Платите вы за это либо деньгами за дорогую модель там, где хватило бы дешёвой, либо качеством там, где дешёвой не хватает.

В этом уроке разберу параметры модели: какой на что влияет, какой у вас окажется бутафорским и как переставлять их на лету.

Начну с общего модуля. Он тот же, что в уроках 1, 2 и 3, но чуть больше. У build_model появился необязательный первый аргумент с именем модели. Рядом появилась функция gateway_kwargs, она нужна там, где модель собирается без имени, а такое в этом уроке будет. Положите модуль рядом с примерами в course_model.py, дальше каждый пример зовёт сборку одной строкой.

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)

Три места, где задаётся параметр

Параметр модели задаётся в трёх местах, и различаются они сроком жизни настройки.

1) при создании модели. Настройка сохраняется, пока существует объект модели, и действует на каждый её вызов

2) прикреплением к модели, метод bind. Возвращается обёртка вокруг модели с добавленными аргументами, исходная модель не меняется

3) на один вызов, именованным аргументом у invoke. Действует на этот запрос и ни на какой другой

Передачу параметра прямо в invoke документация показывает только для reasoning_effort: его, как и temperature, задают при создании модели или на отдельный вызов. Для max_tokens работает тот же путь, это видно по примеру ниже.

Проверю все три на одном параметре, который видно по счётчику токенов.

Пример 01_parameters.py

from course_model import build_model

QUESTION = "Опишите очередь задач в трёх предложениях."


def show(title, response):
    """Печатает то, по чему видно, подействовал параметр или нет."""
    usage = response.usage_metadata or {}
    finish = response.response_metadata.get("finish_reason")
    print(title)
    print(f"  выход:  {usage.get('output_tokens')} токенов, finish_reason={finish!r}")
    print(f"  начало: {response.text[:60]!r}")
    print()


# 1. Параметр при создании модели: действует на все вызовы этой модели.
short = build_model(temperature=0, max_tokens=32)
show("MAX_TOKENS=32 ПРИ СОЗДАНИИ", short.invoke(QUESTION))

# 2. Параметр, прикреплённый через bind: получается обёртка вокруг модели
#    с добавленным аргументом. Исходная модель не меняется.
longer = short.bind(max_tokens=200)
show("ТА ЖЕ МОДЕЛЬ, BIND(MAX_TOKENS=200)", longer.invoke(QUESTION))

# 3. Параметр на один вызов: уходит в запрос поверх настроек модели.
show("ТА ЖЕ МОДЕЛЬ, MAX_TOKENS=200 НА ВЫЗОВ", short.invoke(QUESTION, max_tokens=200))

# 4. Исходная модель не изменилась ни от bind, ни от параметра на вызов.
show("ИСХОДНАЯ МОДЕЛЬ ПОСЛЕ ОБОИХ ПЕРЕОПРЕДЕЛЕНИЙ", short.invoke(QUESTION))

# 5. Настройки видно и без запроса к провайдеру: они лежат на объекте модели.
print("НАСТРОЙКИ НА ОБЪЕКТЕ МОДЕЛИ")
print(f"  имя модели:  {short.model_name}")
print(f"  max_tokens:  {short.max_tokens}")
print(f"  temperature: {short.temperature}")
print(f"  timeout:     {short.request_timeout}")
print(f"  max_retries: {short.max_retries}")

# Вывод:
# MAX_TOKENS=32 ПРИ СОЗДАНИИ
#   выход:  32 токенов, finish_reason='length'
#   начало: ''
#
# ТА ЖЕ МОДЕЛЬ, BIND(MAX_TOKENS=200)
#   выход:  108 токенов, finish_reason='stop'
#   начало: 'Очередь задач — это структура данных, работающая по принципу'
#
# ТА ЖЕ МОДЕЛЬ, MAX_TOKENS=200 НА ВЫЗОВ
#   выход:  122 токенов, finish_reason='stop'
#   начало: 'Очередь задач — это структура данных, работающая по принципу'
#
# ИСХОДНАЯ МОДЕЛЬ ПОСЛЕ ОБОИХ ПЕРЕОПРЕДЕЛЕНИЙ
#   выход:  32 токенов, finish_reason='length'
#   начало: 'Очередь задач — это структура данных, работающая по принципу'
#
# НАСТРОЙКИ НА ОБЪЕКТЕ МОДЕЛИ
#   имя модели:  deepseek/deepseek-v4-flash
#   max_tokens:  32
#   temperature: 0.0
#   timeout:     None
#   max_retries: None

Все три способа сработали: счётчик выхода и finish_reason меняются вслед за потолком. В отличие от температуры из урока 1, этот параметр на модели курса работает.

Потолок задаёт границу, а не длину ответа. Указание max_tokens=32 не значит, что ответ выйдет ровно на 32 токена: короткий ответ кончится сам и до потолка не дойдёт.

Строку "начало" читайте вместе с finish_reason. Пустая строка сама по себе ничего не говорит, а вместе с finish_reason она показывает, чем кончился вызов.

Вызов кончается одним из двух способов, и оба штатные.

1) finish_reason='stop': модель сказала всё, что хотела, и в потолок не упёрлась. В строке "начало" стоит текст ответа

2) finish_reason='length': генерацию оборвал потолок. Текст при этом либо обрывается на полуслове, либо не приходит вовсе

Пустая строка "начало" рядом с посчитанными токенами выхода, это второй случай в крайнем виде: токены сгенерированы и оплачены, а текста в ответе нет. Ушли они на рассуждение: DeepSeek присылает его в поле reasoning_content, а ChatOpenAI это поле не извлекает (урок 3). Второй вид того же случая стоит в четвёртом блоке вывода: finish_reason='length', и текст обрезан потолком.

Какой из двух видов придёт, заранее не известно. На прогоне для урока первый вызов вернул пустой текст, а на повторных прогонах текст пришёл во всех четырёх блоках. Пустая строка в выводе не значит, что вы сломали пример. Отсюда три правила.

1) пустой text, это не баг. Сначала смотрите finish_reason и счётчик выхода, они объясняют пустоту

2) max_tokens ставится по нижней границе полезного ответа, а не по желанию сэкономить. Тридцать токенов, это часто не короткий ответ, а его отсутствие

3) в коде, который показывает ответ пользователю, ветка на пустой текст нужна всегда, и она разбирается в блоке ошибок этого урока

Четвёртый замер показывает, что ни bind, ни параметр на вызов исходную модель не меняют. Это важно, потому что модель в приложении обычно одна на всех. Пятый блок печатает настройки прямо с объекта, без запроса в сеть: так проверяется, дошел ли параметр до модели.

И тонкость про bind. Метод возвращает не модель, а обёртку _ChatModelBinding (наследник RunnableBinding из langchain_core.runnables, проверяйте через isinstance). Настройки с неё читаются обманчиво: longer.max_tokens вернёт 32, значение исходной модели. Обращение к атрибуту обёртка передаёт дальше, а прикреплённые аргументы лежат отдельно, в longer.kwargs. В документации обёртка не описана, её устройство видно только в исходном коде langchain-core.

Что делает каждый параметр

Стандартный набор параметров собран в таблице ниже. Слово "стандартный" здесь означает, что имя одинаково у всех провайдеров, а не то, что каждый провайдер его исполняет.

Параметр Что задаёт Если не задавать
model имя модели у провайдера. Допустима форма "провайдер:модель" в одну строку обязателен, кроме настраиваемой модели
api_key ключ доступа. Обычно приходит из переменной окружения берётся из переменной окружения провайдера
temperature случайность выбора следующего токена значение провайдера по умолчанию
max_tokens потолок длины ответа в токенах значение провайдера по умолчанию
timeout сколько секунд ждать ответа, потом отмена запроса ждёте столько, сколько решит клиент провайдера
max_retries сколько раз повторить неудавшийся запрос значение по умолчанию, разбор в разделе про устойчивость соединения

К этому набору каждый пакет добавляет своё: например, параметр use_responses_api у ChatOpenAI решает, идти в Responses API провайдера или в Chat Completions. Полный список параметров конкретной модели приведён на её странице интеграции.

Важно про адрес, если вы, как и я, ходите через шлюз. Подставить base_url можно любому адресу, совместимому с OpenAI. Но model_provider="openai" метит в официальную спецификацию, и поля, характерные для маршрутизаторов, могут не сохраниться. Для них есть отдельные интеграции, langchain-openrouter и langchain-litellm.

Профиль модели: справка о том, что она умеет

Узнать, что модель умеет, код может пробой: отправить картинку и получить отказ. Каждая такая проба стоит запроса, времени и денег.

Профиль модели, это словарь возможностей, который лежит в объекте модели и доступен до первого запроса, атрибутом profile. Профиль появился в langchain 1.1 и помечен в коде langchain-core как бета: формат может измениться.

Данные в профиле не выдуманы фреймворком и не запрошены у провайдера. Их источник, это открытый проект models.dev, дополненный полями, нужными LangChain. Приходят они вместе с пакетом интеграции. Отсюда два следствия. Чтение профиля не делает запроса в сеть. Данных по имени модели может не быть вовсе, так было в уроке 1: у модели курса профиль пришёл пустым. У шлюза имя выглядит как провайдер/модель, и такого имени в данных пакета нет.

Пример 02_profile.py

from langchain_openai import ChatOpenAI

from course_model import build_model

FIELDS = (
    "max_input_tokens",
    "max_output_tokens",
    "tool_calling",
    "structured_output",
    "reasoning_output",
    "reasoning_effort_levels",
    "temperature",
    "image_inputs",
    "pdf_inputs",
)

# ПОДСТАВЬТЕ СВОИ ЧИСЛА, ниже они условные. Профиль пишется руками там, где данных
# по имени модели в пакете интеграции нет. Значения берутся со страницы вашего
# провайдера, а не из головы: фреймворк их не проверяет и на запрос не влияет.
COURSE_PROFILE = {
    "max_input_tokens": 128000,
    "max_output_tokens": 8192,
    "tool_calling": True,
    "structured_output": True,
    "reasoning_output": True,
    "temperature": False,
    "image_inputs": False,
}


def show_profile(title, profile):
    """Печатает профиль по одному полю на строку, с пометкой отсутствующих."""
    print(title)

    if not profile:
        print("  профиля нет: данных по этому имени модели в пакете нет")
        print()
        return

    for field in FIELDS:
        if field in profile:
            print(f"  {field:<24} {profile[field]}")
        else:
            print(f"  {field:<24} поля нет")

    print()


show_profile("ПРОФИЛЬ МОДЕЛИ КУРСА", build_model().profile)

# Ключ здесь не используется: профиль читается из данных пакета, запрос
# к провайдеру не уходит.
known = ChatOpenAI(model="gpt-5.5", api_key="not-used-no-request-is-made")
show_profile("ПРОФИЛЬ gpt-5.5 ИЗ ДАННЫХ ПАКЕТА", known.profile)

custom = build_model(profile=COURSE_PROFILE)
show_profile("ПРОФИЛЬ, ЗАДАННЫЙ РУКАМИ", custom.profile)

# Правка готового профиля: новый словарь и копия модели. Так исходная модель
# не меняется, что важно, если её объект уже кому-то отдали.
patched_profile = custom.profile | {"max_input_tokens": 64000}
patched = custom.model_copy(update={"profile": patched_profile})

print("ПОСЛЕ ПРАВКИ ЧЕРЕЗ MODEL_COPY")
print(f"  у копии:    {patched.profile['max_input_tokens']}")
print(f"  у исходной: {custom.profile['max_input_tokens']}")
print()

# Профиль, это справка о модели, а не рычаг управления ею.
print("ЧТО ПРОФИЛЬ НЕ ДЕЛАЕТ")
print(f"  в профиле temperature: {custom.profile['temperature']}")
print("  применит провайдер параметр или нет, профиль не решает")

# Вывод:
# ПРОФИЛЬ МОДЕЛИ КУРСА
#   профиля нет: данных по этому имени модели в пакете нет
#
# ПРОФИЛЬ gpt-5.5 ИЗ ДАННЫХ ПАКЕТА
#   max_input_tokens         1050000
#   max_output_tokens        128000
#   tool_calling             True
#   structured_output        True
#   reasoning_output         True
#   reasoning_effort_levels  ['none', 'low', 'medium', 'high', 'xhigh']
#   temperature              False
#   image_inputs             True
#   pdf_inputs               True
#
# ПРОФИЛЬ, ЗАДАННЫЙ РУКАМИ
#   max_input_tokens         128000
#   max_output_tokens        8192
#   tool_calling             True
#   structured_output        True
#   reasoning_output         True
#   reasoning_effort_levels  поля нет
#   temperature              False
#   image_inputs             False
#   pdf_inputs               поля нет
#
# ПОСЛЕ ПРАВКИ ЧЕРЕЗ MODEL_COPY
#   у копии:    64000
#   у исходной: 128000
#
# ЧТО ПРОФИЛЬ НЕ ДЕЛАЕТ
#   в профиле temperature: False
#   применит провайдер параметр или нет, профиль не решает

Свой профиль задаётся параметром profile при создании модели. Числа берутся со страницы своего провайдера. Значения фреймворк не проверяет и в запрос не отправляет, а на незнакомое имя поля выдаёт предупреждение UserWarning.

Готовый профиль правится копией. Профиль, это обычный словарь, и его можно изменить. Но лучше собрать новый словарь оператором | и сделать копию модели через model_copy, чтобы не портить объект, если он уже кому-то отдан.

Данные можно исправить и в источнике, тогда правильные числа получат все. Источник, это models.dev, а в пакет интеграции данные переносит утилита langchain-model-profiles командой langchain-profiles refresh. Это работа над пакетом, а не над вашим приложением.

У профиля четыре применения, и во всех его читает код, до запроса к модели.

1) middleware суммаризации умеет включать сжатие истории по размеру окна из профиля. Он разбирается в уроке 12, порог там задан числом сообщений, а не окном

2) стратегия структурированного вывода в create_agent выбирается сама, по поддержке родного структурированного вывода, урок 6

3) вход можно отсеять до отправки: по поддерживаемым типам данных и максимальному числу входных токенов

4) в Deep Agents Code переключатель моделей показывает только те, у которых профиль сообщает о вызове инструментов и о текстовом входе и выходе

Отсюда правило: профиль, это справка, а не рычаг. "temperature": False не отключает температуру, а сообщает, что модель её не поддерживает. И наоборот, True не заставит провайдера применить параметр: проверяется это тем же замером, что в уроке 1.

Полный перечень полей приведён в справочнике API на тип ModelProfile. При обновлении пакета перечень может измениться.

Настраиваемая модель: имя как значение конфигурации

Пока модель одна, её имя задано в коде или в переменной окружения. Как только моделей становится две, появляется соблазн развести их по разным объектам и таскать оба через приложение. Работает, но плохо масштабируется: каждая новая модель, это новый объект и новая ветка в коде.

Другой способ, это настраиваемая модель: объект модели, у которого часть параметров не задана при создании, а приходит в момент вызова, внутри конфигурации. Создаётся она тем же init_chat_model, только без имени модели: если имя не указано, настраиваемыми по умолчанию становятся model и model_provider.

Пример 03_configurable.py

import os

from langchain.chat_models import init_chat_model

from course_model import gateway_kwargs

MODEL_NAME = os.environ["MODEL_NAME"]
QUESTION = "Ответьте одним словом: столица Франции?"

# 1. Модель без имени. Имя и провайдер становятся настраиваемыми по умолчанию,
#    а остальные параметры остаются такими, какими заданы здесь.
configurable = init_chat_model(temperature=0, **gateway_kwargs())

print("ВЫЗОВ С ИМЕНЕМ МОДЕЛИ В КОНФИГУРАЦИИ")
response = configurable.invoke(QUESTION, config={"configurable": {"model": MODEL_NAME}})
print(f"  ответ: {response.text.strip()!r}")
print(f"  модель в ответе: {response.response_metadata.get('model_name')}")
print()

# 2. Тот же объект без конфигурации. Имени модели взять неоткуда.
print("ТОТ ЖЕ ОБЪЕКТ БЕЗ КОНФИГУРАЦИИ")
try:
    configurable.invoke(QUESTION)
except Exception as error:
    print(f"  {type(error).__name__}: {str(error)[:200]}")
else:
    print("  вызов прошёл: у вашей сборки имя модели откуда-то взялось")
print()

# 3. Настраиваемые поля перечислены явно, ключи конфигурации получили префикс.
#    Модель по умолчанию задана, поэтому вызов без конфигурации работает.
answers = init_chat_model(
    model=MODEL_NAME,
    configurable_fields=("model", "max_tokens"),
    config_prefix="answer",
    temperature=0,
    max_tokens=32,
    **gateway_kwargs(),
)

LONG_QUESTION = "Опишите очередь задач в трёх предложениях."

default_run = answers.invoke(LONG_QUESTION)
print("ПО УМОЛЧАНИЮ, MAX_TOKENS=32")
print(f"  выход: {(default_run.usage_metadata or {}).get('output_tokens')} токенов")
print()

with_config = answers.invoke(
    LONG_QUESTION,
    config={"configurable": {"answer_max_tokens": 300}},
)
print("ПЕРЕОПРЕДЕЛЕНИЕ НА ВЫЗОВ, ANSWER_MAX_TOKENS=300")
print(f"  выход: {(with_config.usage_metadata or {}).get('output_tokens')} токенов")
print()

# 4. Ключ без префикса до модели не доходит, и молча: ошибки не будет.
without_prefix = answers.invoke(
    LONG_QUESTION,
    config={"configurable": {"max_tokens": 300}},
)
print("КЛЮЧ БЕЗ ПРЕФИКСА, MAX_TOKENS=300")
print(f"  выход: {(without_prefix.usage_metadata or {}).get('output_tokens')} токенов")
print("  столько же, сколько по умолчанию: ключ не подошёл под префикс")

# Вывод:
# ВЫЗОВ С ИМЕНЕМ МОДЕЛИ В КОНФИГУРАЦИИ
#   ответ: 'Париж'
#   модель в ответе: deepseek/deepseek-v4-flash
#
# ТОТ ЖЕ ОБЪЕКТ БЕЗ КОНФИГУРАЦИИ
#   TypeError: _init_chat_model_helper() missing 1 required positional argument: 'model'
#
# ПО УМОЛЧАНИЮ, MAX_TOKENS=32
#   выход: 32 токенов
#
# ПЕРЕОПРЕДЕЛЕНИЕ НА ВЫЗОВ, ANSWER_MAX_TOKENS=300
#   выход: 119 токенов
#
# КЛЮЧ БЕЗ ПРЕФИКСА, MAX_TOKENS=300
#   выход: 32 токенов
#   столько же, сколько по умолчанию: ключ не подошёл под префикс

Первое. Конфигурация вызова, это не тот же словарь, что параметры модели. Параметры модели идут именованными аргументами, а настраиваемые поля кладутся в словарь config под ключ configurable. В том же config рядом лежат callbacks, tags и metadata из последнего раздела урока.

Второе. Модель без имени и без конфигурации вызвать нельзя. Собрать объект не из чего. Ошибка приходит в момент вызова, а не создания: TypeError из внутренней функции сборки, а не типизированное исключение фреймворка из урока 0. У настраиваемой модели почти всегда задают модель по умолчанию.

Третье. Набор настраиваемых полей задаётся списком, и это не формальность. Значение configurable_fields="any" делает настраиваемым всё. При "any" подменить можно и api_key, и base_url, отправив запрос с вашим ключом на чужой адрес. Если конфигурация приходит извне, перечисляйте поля поимённо.

Четвёртое. Ключ без префикса пропадает без ошибки. Параметр config_prefix разводит ключи нескольких настраиваемых моделей. Ключ без префикса до модели не доходит, это и показывает четвёртый блок примера. В реальном коде такая опечатка выглядит как "параметр не работает".

У настраиваемой модели можно заранее вызвать bind_tools и with_structured_output, хотя самой модели ещё нет. Вызовы запоминаются и применяются, когда модель соберётся из конфигурации в момент вызова. Инструменты это урок 9, структурированный вывод, урок 6.

Одна модель, три задачи, разные настройки

Соберу из настраиваемой модели то, ради чего она нужна: маршрутизатор задач.

Задач несколько, у каждой свои требования, а объект модели один, меняются только значения конфигурации, имя модели в том числе. Создавать объект под каждую комбинацию не нужно.

Пример 04_router.py

import os

from langchain.chat_models import init_chat_model

from course_model import gateway_kwargs

FAST_MODEL = os.environ["MODEL_NAME"]
STRONG_MODEL = os.getenv("MODEL_NAME_STRONG") or FAST_MODEL

TASKS = [
    (
        "классификация",
        "Отнесите обращение к одной категории: оплата, доставка, возврат. "
        "Обращение: деньги списали дважды. Ответьте одним словом.",
        {"model": FAST_MODEL, "max_tokens": 16},
    ),
    (
        "короткая справка",
        "Что такое очередь задач? Одно предложение.",
        {"model": FAST_MODEL, "max_tokens": 120},
    ),
    (
        "разбор с доводами",
        "Сравните очередь задач и стек по трём признакам, с доводами.",
        {"model": STRONG_MODEL, "max_tokens": 400},
    ),
]

router = init_chat_model(
    configurable_fields=("model", "max_tokens"),
    temperature=0,
    **gateway_kwargs(),
)

if STRONG_MODEL == FAST_MODEL:
    print("MODEL_NAME_STRONG не задана: обе роли идут на одну модель.")
    print("Маршрутизация от этого не ломается, но разницу видно только в параметрах.")
    print()

for title, prompt, settings in TASKS:
    response = router.invoke(prompt, config={"configurable": settings})
    usage = response.usage_metadata or {}
    print(f"{title.upper()}")
    print(f"  настройки: {settings}")
    print(f"  модель в ответе: {response.response_metadata.get('model_name')}")
    print(f"  вход {usage.get('input_tokens')}, выход {usage.get('output_tokens')}")
    print(f"  ответ: {response.text.strip()[:100]!r}")
    print()

# Вывод:
# КЛАССИФИКАЦИЯ
#   настройки: {'model': 'deepseek/deepseek-v4-flash', 'max_tokens': 16}
#   модель в ответе: deepseek/deepseek-v4-flash
#   вход 42, выход 4
#   ответ: 'Оплата'
#
# КОРОТКАЯ СПРАВКА
#   настройки: {'model': 'deepseek/deepseek-v4-flash', 'max_tokens': 120}
#   модель в ответе: deepseek/deepseek-v4-flash
#   вход 14, выход 58
#   ответ: 'Очередь задач — это структура данных или механизм, который организует и управляет выполнением операц'
#
# РАЗБОР С ДОВОДАМИ
#   настройки: {'model': 'anthropic/claude-sonnet-5', 'max_tokens': 400}
#   модель в ответе: anthropic/claude-sonnet-5
#   вход 30, выход 400
#   ответ: '# Сравнение очереди задач и стека

## 1. Принцип обработки элементов

**Очередь (Queue)** — FIFO (Fi'

Здесь появляется новая переменная курса, MODEL_NAME_STRONG, необязательная. Если есть вторая модель подороже, положите её имя рядом с MODEL_NAME, и маршрутизация разведёт задачи по двум моделям. Если её нет, пример отработает на одной модели и сообщит об этом. В выводе вторая модель, это Claude Sonnet 5, и temperature=0 шлюз курса принял без ошибки. По документации Anthropic, на которую опирается урок 1, эта модель на нестандартную температуру отвечает ошибкой 400. Раз ошибки нет, шлюз передаёт параметр не как есть, а что именно он с ним делает, снаружи не видно.

Потолки сработали так же, как в примере 1, сразу на двух моделях. Классификация уложилась в четыре токена из шестнадцати, короткая справка в пятьдесят восемь из ста двадцати, а разбор с доводами упёрся в свои четыреста. На повторном прогоне классификация и справка вернули пустой текст: DeepSeek потратил весь потолок на рассуждение. Отсюда рабочая мерка: потолок ставится по самой длинной задаче этого вида, с запасом на рассуждение. Запас стоит копейки, а его нехватка стоит пустого ответа.

Таблица задач стала данными, а не кодом: добавить четвёртую задачу, это дописать строку в список, а не написать ветку if.

Название "маршрутизатор" здесь бытовое, а не термин фреймворка: как мультиагентный паттерн, где решение о направлении принимает модель, он разбирается в уроке 19 (выйдет позже).

Выбор модели на лету внутри агента

Маршрутизатор из прошлого раздела решает, куда отправить задачу, до вызова. Внутри агента этого мало: он делает несколько шагов подряд, и на третьем условия могут отличаться от первого, разговор разросся, задача оказалась сложнее, чем выглядела.

Это называется динамическим выбором модели: модель выбирается во время исполнения по текущему состоянию и контексту, через middleware, который оборачивает вызов модели. Middleware, это уроки 15 и 16, агент целиком, урок 11, а здесь нужно одно место: точка, где можно подменить модель на конкретном вызове.

Пример 05_dynamic_model.py

import os
from typing import Callable

from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call

from course_model import build_model

fast = build_model(os.environ["MODEL_NAME"], temperature=0, max_tokens=256)
strong = build_model(
    os.getenv("MODEL_NAME_STRONG") or os.environ["MODEL_NAME"],
    temperature=0,
    max_tokens=512,
)


def get_weather(city: str) -> str:
    """Возвращает погоду в указанном городе."""
    return f"В городе {city} всегда солнечно!"


@wrap_model_call
def pick_model(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    """Короткий разговор ведёт дешёвая модель, разросшийся, дорогая."""
    message_count = len(request.state["messages"])
    chosen = strong if message_count > 2 else fast
    print(f"  [middleware] сообщений в состоянии: {message_count}, модель: {chosen.model_name}")
    return handler(request.override(model=chosen))


agent = create_agent(
    model=fast,
    tools=[get_weather],
    system_prompt="Вы помощник. Отвечайте по-русски.",
    middleware=[pick_model],
)

print("ПРОГОН АГЕНТА")
result = agent.invoke(
    {"messages": [{"role": "user", "content": "Какая погода в Сан-Франциско?"}]}
)
print()

for message in result["messages"]:
    print(f"  {type(message).__name__}: {message.text[:70]!r}")

print()
print("ОТВЕТ:", result["messages"][-1].text[:120])

# Вывод:
# ПРОГОН АГЕНТА
#   [middleware] сообщений в состоянии: 1, модель: deepseek/deepseek-v4-flash
#   [middleware] сообщений в состоянии: 3, модель: anthropic/claude-sonnet-5
#
#   HumanMessage: 'Какая погода в Сан-Франциско?'
#   AIMessage: ''
#   ToolMessage: 'В городе Сан-Франциско всегда солнечно!'
#   AIMessage: 'В Сан-Франциско сейчас солнечно! ☀️ Отличная погода для прогулки по го'
#
# ОТВЕТ: В Сан-Франциско сейчас солнечно! ☀️ Отличная погода для прогулки по городу.

Декоратор @wrap_model_call превращает функцию в middleware вокруг каждого вызова модели. Функция получает два аргумента. В request лежит то, что уйдёт в модель. handler и есть вызов модели, и сколько раз его позвать, решаете вы.

1) ни разу: модель не вызывается, ответ возвращает сам middleware

2) один раз: обычный вызов

3) несколько раз: повтор

Подмена модели делается методом override у запроса: весь динамический выбор, это строка handler(request.override(model=chosen)). Печать внутри middleware оставлена намеренно. По ней видно, сколько раз агент сходил в модель и в какую. Здесь вы впервые в курсе видите цикл агента изнутри, а не только его результат.

Первый AIMessage в списке пуст, но это не та пустота, что в примере 1. Модель вернула вызов инструмента, а он лежит в отдельном поле tool_calls (уроки 2 и 3). Вызов инструмента узнаётся по непустому tool_calls, а не по пустому тексту. Текст и tool_calls независимы и могут прийти вместе.

Если MODEL_NAME_STRONG у вас не задана, обе строки middleware напечатают одно имя модели. Подмена при этом всё равно происходит, её видно по числу сообщений в состоянии.

Одно ограничение касается структурированного вывода. Если он включён, модель, которую подставляет middleware, не должна быть заранее связана с инструментами через bind_tools. Инструменты в этом случае отдаются агенту.

Порог message_count > 2 взят маленьким, чтобы middleware сработал на коротком прогоне. Число сообщений, это самое грубое правило выбора, оно годится для примера. В работе модель выбирают по длине контекста, типу задачи или неудаче на предыдущем шаге. Для отказа модели есть готовый ModelFallbackMiddleware, его разберу в уроке 15.

Инструменты на стороне провайдера

До сих пор речь шла о том, как настроить модель. Есть настройка, которая меняет не поведение модели, а границу ответственности: инструменты на стороне провайдера.

Обычный вызов инструмента выглядит так: модель просит, ваш код исполняет, результат возвращается модели отдельным сообщением. Это цикл, и он весь ваш, разбирается в уроке 9.

Серверный инструмент устроен иначе. Модель ходит в поиск по сети или в интерпретатор кода на стороне провайдера, там же читает результат и возвращает вам готовый ответ. Это один шаг диалога, парных сообщений ToolMessage отправлять не нужно. Задаётся такой инструмент не функцией, а словарём с типом, и уходит он через тот же bind_tools.

Пример 06_server_side_tool.py

from course_model import build_model

model = build_model(temperature=0)

profile = model.profile or {}
print("ЧТО ГОВОРИТ ПРОФИЛЬ ДО ЗАПРОСА")
print(f"  tool_calling: {profile.get('tool_calling', 'поля нет')}")
print("  отдельного поля про серверные инструменты в профиле нет")
print()

# Инструмент провайдера описывается словарём с типом, а не функцией Python.
server_tool = {"type": "web_search"}
model_with_tools = model.bind_tools([server_tool])

print("ЗАПРОС С ИНСТРУМЕНТОМ web_search")

try:
    response = model_with_tools.invoke("What was a positive news story from today?")
except Exception as error:
    print(f"  провайдер отказал: {type(error).__name__}")
    print(f"  {str(error)[:300]}")
    print("  Инструмента на стороне этого провайдера нет, идём своими инструментами")
    print("  (урок 9), а поиск в сети делаем сами.")
else:
    print("  типы блоков:", [block["type"] for block in response.content_blocks])
    print()

    for block in response.content_blocks:
        if block["type"] == "server_tool_call":
            print(f"  вызов:     {block.get('name')} {block.get('args')}")
        elif block["type"] == "server_tool_result":
            print(f"  результат: {block.get('status')}")
        elif block["type"] == "text":
            print(f"  текст:     {block['text'][:150]!r}")
            for annotation in block.get("annotations") or []:
                print(f"    источник: {annotation.get('url')}")

    print()
    print("  вызовов инструментов для нас:", len(response.tool_calls))
    print("  ToolMessage отправлять не нужно: провайдер всё сделал у себя")

# Вывод:
# ЧТО ГОВОРИТ ПРОФИЛЬ ДО ЗАПРОСА
#   tool_calling: поля нет
#   отдельного поля про серверные инструменты в профиле нет
#
# ЗАПРОС С ИНСТРУМЕНТОМ web_search
#   провайдер отказал: OpenAIModelNotFoundError
#   Error code: 404 - {'detail': 'Not Found'}
#   Инструмента на стороне этого провайдера нет, идём своими инструментами
#   (урок 9), а поиск в сети делаем сами.

Отказ здесь, это нормальный исход, и пример написан так, чтобы отказ был напечатан, а не свалился трассировкой.

Причина отказа точнее, чем "провайдер не умеет". Под словарь {"type": "web_search"} пакет сам переключает клиент на Responses API провайдера, ведь встроенные инструменты доступны там, а не в Chat Completions. У шлюза курса этого адреса нет, он ответил кодом 404, и это превратилось в OpenAIModelNotFoundError. Отказал не инструмент, а точка входа, по которой за ним пошли. Похожий отказ разобран в уроке 3, на примере с картинкой.

Если у вашего провайдера инструмент сработал, ответ придёт контент-блоками, знакомыми по уроку 3. Среди них server_tool_call с именем инструмента и аргументами, server_tool_result со статусом и text с полем annotations, где лежат ссылки на источники. Список инструментов конкретного провайдера приведён на его странице интеграции.

Первые строки примера показывают пробел в профиле: поля про серверные инструменты там нет, а tool_calling сообщает только о том, что модель умеет вернуть вызов вашего инструмента. Проверить наличие серверного инструмента до запроса можно только по странице провайдера.

Устойчивость соединения: таймаут и повторы

Между вашим кодом и моделью лежит сеть, и она не обязана работать. В уроке 0 разобраны типы отказов, теперь на очереди то, что фреймворк делает с ними.

Модели повторяют неудавшиеся запросы с нарастающей паузой. Повторяются сетевые ошибки, лимит частоты с кодом 429 и ошибки сервера 5xx. Не повторяются клиентские ошибки вроде 401 и 404: сколько ни отправляйте неверный ключ, он не станет верным.

Настраивается это уже знакомыми по таблице max_retries и timeout. Для долгих задач при ненадёжной сети поднимайте число повторов до десяти-пятнадцати и не забывайте про checkpointer (урок 12), чтобы не терять прогресс.

Пример 07_resilience.py

import time

from langchain_core.exceptions import (
    ContextOverflowError,
    ModelAPIError,
    ModelAuthenticationError,
    ModelConnectionError,
    ModelInvalidRequestError,
    ModelNotFoundError,
    ModelPermissionDeniedError,
    ModelRateLimitError,
    ModelTimeoutError,
)

from course_model import build_model

EXCEPTIONS = (
    ModelAuthenticationError,
    ModelPermissionDeniedError,
    ModelInvalidRequestError,
    ModelNotFoundError,
    ModelRateLimitError,
    ModelAPIError,
    ModelConnectionError,
    ModelTimeoutError,
    ContextOverflowError,
)

print("ЧТО ФРЕЙМВОРК СЧИТАЕТ ПРИГОДНЫМ ДЛЯ ПОВТОРА")

for exception_type in EXCEPTIONS:
    mark = "повторяем" if exception_type.is_retryable else "не повторяем"
    print(f"  {exception_type.__name__:<28} {mark}")

print()

# Таймаут в одну десятитысячную секунды не успеет никто. Повторы выключены,
# чтобы измерить одну попытку.
print("ТАЙМАУТ БЕЗ ПОВТОРОВ")
impatient = build_model(temperature=0, timeout=0.0001, max_retries=0)
started = time.perf_counter()

try:
    impatient.invoke("Здравствуйте")
except ModelTimeoutError as error:
    print(f"  ModelTimeoutError за {time.perf_counter() - started:.2f} с")
    print(f"  is_retryable: {error.is_retryable}")
except Exception as error:
    print(f"  пришёл другой тип: {type(error).__name__}: {str(error)[:150]}")
else:
    print("  ответ успел прийти, что для такого таймаута неожиданно")

print()

# Тот же безнадёжный запрос, но с тремя повторами. Смотрите на время.
print("ТОТ ЖЕ ТАЙМАУТ С ТРЕМЯ ПОВТОРАМИ")
patient = build_model(temperature=0, timeout=0.0001, max_retries=3)
started = time.perf_counter()

try:
    patient.invoke("Здравствуйте")
except Exception as error:
    print(f"  {type(error).__name__} за {time.perf_counter() - started:.2f} с")

print()
print("НАСТРОЙКИ, КОТОРЫЕ ВИДНО НА ОБЪЕКТЕ")
default_model = build_model(temperature=0)
print(f"  max_retries по умолчанию: {default_model.max_retries}")
print(f"  timeout по умолчанию:     {default_model.request_timeout}")
print("  None означает, что число берёт клиент провайдера, а не LangChain")

# Вывод:
# ЧТО ФРЕЙМВОРК СЧИТАЕТ ПРИГОДНЫМ ДЛЯ ПОВТОРА
#   ModelAuthenticationError     не повторяем
#   ModelPermissionDeniedError   не повторяем
#   ModelInvalidRequestError     не повторяем
#   ModelNotFoundError           не повторяем
#   ModelRateLimitError          повторяем
#   ModelAPIError                повторяем
#   ModelConnectionError         повторяем
#   ModelTimeoutError            повторяем
#   ContextOverflowError         не повторяем
#
# ТАЙМАУТ БЕЗ ПОВТОРОВ
#   ModelTimeoutError за 0.05 с
#   is_retryable: True
#
# ТОТ ЖЕ ТАЙМАУТ С ТРЕМЯ ПОВТОРАМИ
#   OpenAITimeoutError за 3.17 с
#
# НАСТРОЙКИ, КОТОРЫЕ ВИДНО НА ОБЪЕКТЕ
#   max_retries по умолчанию: None
#   timeout по умолчанию:     None
#   None означает, что число берёт клиент провайдера, а не LangChain

Крошечный таймаут здесь, это способ проверить, что исключение поднимается и ловится по типу.

Сначала про имена в средних блоках, иначе вывод выглядит противоречивым. Во втором блоке напечатано ModelTimeoutError, потому что исключение поймано по этому типу и имя напечатано примером, а в третьем печатается настоящее имя класса, OpenAITimeoutError. Противоречия нет: пакет langchain-openai поднимает свой класс OpenAITimeoutError, а он наследуется одновременно от ModelTimeoutError из LangChain и от APITimeoutError из SDK OpenAI. Отсюда правило из урока 0: ловите тип, а не строку сообщения и не имя класса.

Разница во времени между вторым и третьим блоком, это цена повторов. Отсюда следствие, о котором забывают: таймаут и повторы перемножаются, и таймаут в 120 секунд с пятнадцатью повторами, это ожидание, за которое ваш пользователь успеет уйти.

Теперь про значение по умолчанию. По документации LangChain модели по умолчанию повторяют запрос до шести раз. В закреплённой версии пакета интеграции OpenAI поле max_retries объявлено со значением None. Клиенту оно передаётся только тогда, когда вы задали его сами. В самом клиенте OpenAI константа по умолчанию равна двум. По выводу это и видно: последний блок печатает None, а не шестёрку. Задавайте max_retries явно, на значение по умолчанию не полагайтесь.

Повторы на уровне модели, это не единственный механизм. У агента есть middleware ModelRetryMiddleware и ModelFallbackMiddleware. Первый повторяет вызов и по умолчанию смотрит на тот же признак is_retryable. Второй переключается на запасную модель при любом исключении, признак он не проверяет. Оба разобраны в уроке 15: запасная модель с примером. Отказоустойчивость в уроке 23 (выйдет позже).

Ограничение частоты запросов

Ограничение частоты стоит на стороне провайдера, и упереться в него нетрудно: цикл по сотне вопросов делает это за секунды. Ошибка приходит типизированная, ModelRateLimitError, с is_retryable, равным True: запрос можно повторить позже. Но время на ожидание уже потеряно, поэтому лучше в лимит не упираться вовсе.

Ограничитель задают при создании модели, параметром rate_limiter. Встроенный класс InMemoryRateLimiter работает по алгоритму token bucket:

1) разрешения на запрос копятся с постоянной скоростью

2) каждый запрос забирает одно разрешение

3) если разрешений нет, запрос ждёт, пока накопится новое

"Token" здесь означает разрешение на запрос. С токенами модели, за которые вы платите, это слово не связано.

Пример 08_rate_limiter.py

import time

from langchain.rate_limiters import InMemoryRateLimiter

from course_model import build_model

rate_limiter = InMemoryRateLimiter(
    requests_per_second=0.5,  # один запрос раз в две секунды
    check_every_n_seconds=0.1,  # как часто просыпаться и смотреть, можно ли
    max_bucket_size=1,  # сколько запросов разрешено выпустить пачкой
)

model = build_model(temperature=0, max_tokens=16, rate_limiter=rate_limiter)

print("ТРИ ЗАПРОСА ПОДРЯД, РАЗРЕШЕНО 0.5 ЗАПРОСА В СЕКУНДУ")
started = time.perf_counter()
previous = started

for number in range(1, 4):
    model.invoke("Ответьте одним словом: да или нет?")
    now = time.perf_counter()
    print(
        f"  запрос {number}: {now - previous:.1f} с от предыдущего, "
        f"{now - started:.1f} с от начала"
    )
    previous = now

print()
print("ОГРАНИЧИТЕЛЬ СЧИТАЕТ ЗАПРОСЫ, А НЕ ТОКЕНЫ")
print(f"  requests_per_second:  {rate_limiter.requests_per_second}")
print(f"  max_bucket_size:      {rate_limiter.max_bucket_size}")
print("  длина запроса на паузу не влияет, лимит провайдера по токенам он не знает")

# Вывод:
# ТРИ ЗАПРОСА ПОДРЯД, РАЗРЕШЕНО 0.5 ЗАПРОСА В СЕКУНДУ
#   запрос 1: 4.3 с от предыдущего, 4.3 с от начала
#   запрос 2: 1.0 с от предыдущего, 5.3 с от начала
#   запрос 3: 2.2 с от предыдущего, 7.5 с от начала
#
# ОГРАНИЧИТЕЛЬ СЧИТАЕТ ЗАПРОСЫ, А НЕ ТОКЕНЫ
#   requests_per_second:  0.5
#   max_bucket_size:      1
#   длина запроса на паузу не влияет, лимит провайдера по токенам он не знает

У ограничителя три параметра:

1) requests_per_second, скорость, с которой копятся разрешения

2) check_every_n_seconds, как часто проверять, появилось ли разрешение

3) max_bucket_size, сколько разрешений может накопиться, то есть сколько запросов уйдёт пачкой

Не считайте разности между строками этого вывода: ограничитель ждёт разрешения перед запросом, а печатаются моменты завершения, и между ними попадает сетевое время. Смотреть надо на время от начала: разрешения выдаются раз в две секунды, поэтому третий запрос не мог стартовать раньше шестой секунды.

Точнее это видно без сети. Метод rate_limiter.acquire() ждёт разрешение и забирает его, запрос к модели при этом не уходит. Три таких вызова подряд завершились на второй, четвёртой и шестой секунде.

Разрешений на старте нет. Счётчик разрешений в исходном коде начинается с нуля, поэтому первый запрос тоже ждёт две секунды, это и показывает замер acquire().

Ограничитель считает запросы, а не их размер. Ограничить расход по токенам он не поможет. Если у провайдера есть лимит по токенам в минуту, в него можно упереться и с ограничителем, и тогда остаются повторы и учёт расхода.

Ограничитель работает в пределах одного процесса. Разрешения он хранит в памяти процесса. Потоки одного процесса могут делить его без опаски, а другой процесс о нём не знает. Если приложение запущено в четырёх процессах, у каждого свой ограничитель и своя скорость. Провайдер же считает запросы всех четырёх вместе, и частота у него может вырасти вчетверо.

Счёт токенов и денег, когда моделей стало несколько

В уроке 1 деньги считались по одной модели обработчиком UsageMetadataCallbackHandler, и раскладка счёта по именам моделей там ничего не давала. Теперь ролей две, и раскладка нужна.

Обработчик передаётся в вызов через конфигурацию, ключом callbacks. В том же словаре есть ещё три ключа:

1) run_name, имя вызова в трейсе

2) tags, список меток, по которым вызовы можно отбирать

3) metadata, словарь с произвольными парами ключ-значение

По этим полям в уроке 21 (выйдет позже) вы будете искать вызов в трейсе LangSmith.

Пример 09_usage_and_money.py

import os

from langchain_core.callbacks import (
    UsageMetadataCallbackHandler,
    get_usage_metadata_callback,
)

from course_model import build_model

# ПОДСТАВЬТЕ СВОИ СТАВКИ: доллары за миллион токенов, строка на модель.
# Имя модели пишется так, как оно приходит в ответе провайдера.
RATES_BY_MODEL = {
    # "имя-модели-как-в-ответе": {"input": 0.0, "input_cached": 0.0, "output": 0.0},
}

# Запасные ставки: модель курса на 22.09.2026, ночной тариф, как в уроке 1.
# Ими считается модель, которой нет в RATES_BY_MODEL, её строка в счёте помечается.
DEFAULT_RATES = {"input": 0.15, "input_cached": 0.003, "output": 0.60}

FAST_NAME = os.environ["MODEL_NAME"]
STRONG_NAME = os.getenv("MODEL_NAME_STRONG") or FAST_NAME

fast = build_model(FAST_NAME, temperature=0, max_tokens=64)
strong = build_model(STRONG_NAME, temperature=0, max_tokens=256)

TASKS = [
    (
        "классификация",
        fast,
        "Отнесите обращение к одной категории: оплата, доставка, возврат. "
        "Обращение: деньги списали дважды. Ответьте одним словом.",
    ),
    (
        "короткая справка",
        fast,
        "Что такое очередь задач? Одно предложение.",
    ),
    (
        "разбор с доводами",
        strong,
        "Сравните очередь задач и стек по трём признакам, с доводами.",
    ),
]

callback = UsageMetadataCallbackHandler()

print("ПРОГОН ТРЁХ ЗАДАЧ")

for title, model, prompt in TASKS:
    response = model.invoke(
        prompt,
        config={
            "callbacks": [callback],
            "run_name": title,
            "tags": ["lesson-04", "router"],
            "metadata": {"task": title},
        },
    )
    usage = response.usage_metadata or {}
    print(f"  {title:<20} выход {usage.get('output_tokens')} токенов")

print()
print("РАСХОД ПО МОДЕЛЯМ")

total_money = 0.0

for model_name, usage in callback.usage_metadata.items():
    rates = RATES_BY_MODEL.get(model_name)
    guessed = rates is None
    rates = rates or DEFAULT_RATES
    cached = (usage.get("input_token_details") or {}).get("cache_read", 0)
    fresh = usage["input_tokens"] - cached
    money = (
        fresh * rates["input"]
        + cached * rates["input_cached"]
        + usage["output_tokens"] * rates["output"]
    ) / 1_000_000
    total_money += money

    print(f"  модель: {model_name}")
    print(f"    вход:  {usage['input_tokens']} токенов, из них из кеша {cached}")
    print(f"    выход: {usage['output_tokens']} токенов")
    print(f"    всего: {usage['total_tokens']} токенов")
    mark = "  ставок для этой модели нет, считано запасными" if guessed else ""
    print(f"    деньги: ${money:.6f}{mark}")

print(f"  итого по всем моделям: ${total_money:.6f}")
print()

print("ТО ЖЕ САМОЕ МЕНЕДЖЕРОМ КОНТЕКСТА")

with get_usage_metadata_callback() as usage_callback:
    fast.invoke("Ответьте одним словом: да или нет?")
    strong.invoke("Ответьте одним словом: да или нет?")

    for model_name, usage in usage_callback.usage_metadata.items():
        print(
            f"  {model_name}: вход {usage['input_tokens']}, "
            f"выход {usage['output_tokens']}"
        )

names = len(callback.usage_metadata)

print()
print("СКОЛЬКО ИМЁН В СЧЁТЕ:", names)

if names == 1:
    print("Одно имя означает, что обе роли ушли на одну модель.")
else:
    print("Имён столько, сколько разных моделей вы позвали, счёт разложен по ним.")

# Вывод:
# ПРОГОН ТРЁХ ЗАДАЧ
#   классификация        выход 64 токенов
#   короткая справка     выход 64 токенов
#   разбор с доводами    выход 256 токенов
#
# РАСХОД ПО МОДЕЛЯМ
#   модель: deepseek/deepseek-v4-flash
#     вход:  56 токенов, из них из кеша 0
#     выход: 128 токенов
#     всего: 184 токенов
#     деньги: $0.000085  ставок для этой модели нет, считано запасными
#   модель: anthropic/claude-sonnet-5
#     вход:  30 токенов, из них из кеша 0
#     выход: 256 токенов
#     всего: 286 токенов
#     деньги: $0.000158  ставок для этой модели нет, считано запасными
#   итого по всем моделям: $0.000243
#
# ТО ЖЕ САМОЕ МЕНЕДЖЕРОМ КОНТЕКСТА
#   deepseek/deepseek-v4-flash: вход 14, выход 64
#   anthropic/claude-sonnet-5: вход 21, выход 4
#
# СКОЛЬКО ИМЁН В СЧЁТЕ: 2
# Имён столько, сколько разных моделей вы позвали, счёт разложен по ним.

Обе задачи дешёвой модели дошли до потолка 64, хотя просили одно слово и одно предложение: DeepSeek тратит токены на рассуждение, как в примере 1. Для счёта это ничего не меняет, в выход попали все 64 токена.

Ставки лежат в словаре по имени модели. Если две модели считать по одной ставке, счёт ошибётся во столько раз, во сколько различаются их цены. По таблице урока 1 Claude Sonnet 5 дороже модели курса больше чем в десять раз. В примере словарь RATES_BY_MODEL пуст, поэтому обе модели посчитаны по запасным ставкам дешёвой модели, и дорогая вышла дешевле, чем есть. Об этом говорит пометка в строке счёта. Впишите свои имена и ставки, и пометка исчезнет.

Ставок в API нет. В списке моделей шлюза курса есть идентификатор, владелец, длина контекста и признак подписки, а цены нет. Ставки берутся из прайса того, кому вы платите, и вписываются руками. Раз в месяц итог сверяется со счётом в личном кабинете, как в уроке 1.

Ключом в счёте служит имя модели из ответа провайдера. Если провайдер его не прислал, вызов в счёт не попадёт вовсе: ни ошибки, ни строки. Проверить это можно печатью числа ключей, что последняя строка примера и делает. Ключ счёта задан в исходном коде langchain-core: response_metadata["model_name"].

Менеджер контекста считает так же, как обработчик. Разница в записи: внутри блока with обработчик подключается к каждому вызову модели сам, и config писать не нужно. Менеджер удобен, когда нужен расход целого куска кода. Обработчик удобен, когда в счёт должны попасть только выбранные вызовы.

Со стримингом связана отдельная оговорка. С адресом шлюза ChatOpenAI не включает расход в потоковом режиме: stream_usage остаётся None, его задают явно. Без этого код, который считает деньги на invoke, на stream потеряет вызовы: в счёт они не попадут. Про стриминг в уроке 7.

Распространённые ошибки

Код в этом разделе как есть не запустится. Это фрагменты, отдельных файлов для них нет. Имена курса в них настоящие, например build_model и InMemoryRateLimiter. А prompt, questions и show_to_user нигде не объявлены, на их месте должны стоять ваши переменные и функции.

Ошибка 1: не проверять, что получилось, раз ошибки не было

# Неправильно: параметр задан, исход не проверен
model = build_model(temperature=0.2, max_tokens=50)
answer = model.invoke(prompt)
show_to_user(answer.text)   # а здесь может оказаться пустая строка

Что происходит: два разных исхода, и оба без единой ошибки в логах. Либо провайдер принял параметр и не применил его, как температуру в уроке 1, и тогда ответ приходит длиннее ожидаемого. Либо применил, и ответ упёрся в потолок. Тогда токены выхода посчитаны, а текст обрывается на полуслове или не приходит вовсе, как в примере 1 этого урока.

Почему так: список стандартных параметров, это про имена, а не про поведение, и оба исхода приходят без ошибки. Читать надо весь ответ: finish_reason говорит, чем кончилась генерация, usage_metadata говорит, за что списали деньги, а text может оказаться пустым, и тогда причину объясняют два первых поля.

# Правильно: решение принимается по finish_reason, пустой текст проверяется отдельно
model = build_model(temperature=0.2, max_tokens=50)
answer = model.invoke(prompt)

if answer.response_metadata.get("finish_reason") == "length" or not answer.text:
    # Потолок оказался мал: повтор с большим, а не обрезка того, чего нет
    answer = model.invoke(prompt, max_tokens=400)

if answer.text:
    show_to_user(answer.text)
else:
    # Второй раз пусто: пользователю нужна понятная строка, а не пустой экран
    show_to_user("Модель не вернула текст. Попробуйте переформулировать запрос.")

Ошибка 2: ключ конфигурации без префикса

# Неправильно: у модели config_prefix="answer", а ключ написан без префикса
model.invoke(prompt, config={"configurable": {"max_tokens": 300}})

Что происходит: ничего. Модель отвечает со старыми настройками, ошибки нет, и выглядит это как "настраиваемая модель не настраивается".

Почему так: ключи конфигурации отбираются по префиксу, а всё остальное считается настройками не для этой модели. Ошибку здесь фреймворк не поднимает: в одном словаре конфигурации собраны настройки нескольких моделей и других частей приложения.

# Правильно: префикс из создания модели повторяется в ключе
model.invoke(prompt, config={"configurable": {"answer_max_tokens": 300}})

Ошибка 3: configurable_fields="any" на конфигурации, пришедшей извне

# Неправильно: настраиваемым стало всё, а конфигурация пришла из запроса
model = init_chat_model("openai:gpt-5.5", configurable_fields="any")
model.invoke(prompt, config={"configurable": request.json()["model_settings"]})

Что происходит: вместе с безобидной температурой чужая сторона задаёт base_url. Ваш запрос вместе с вашим ключом уходит на чужой адрес.

Почему так: "any" означает буквально все поля конструктора модели, включая адрес и ключ.

# Правильно: поля перечислены поимённо, лишнее до модели не доедет
model = init_chat_model(
    "openai:gpt-5.5",
    configurable_fields=("model", "temperature", "max_tokens"),
)

Ошибка 4: ограничитель частоты создаётся на каждый вызов

# Неправильно: у каждого вызова свой ограничитель, общего счёта нет
from concurrent.futures import ThreadPoolExecutor

def ask(question):
    limiter = InMemoryRateLimiter(requests_per_second=0.5, max_bucket_size=1)
    model = build_model(rate_limiter=limiter)
    return model.invoke(question)

with ThreadPoolExecutor(max_workers=8) as pool:
    answers = list(pool.map(ask, questions))

Что происходит: ограничитель не ограничивает. Восемь потоков, восемь ограничителей, и каждый выдаёт своё первое разрешение через две секунды. Вместо одного запроса раз в две секунды провайдер получает восемь сразу и может ответить ModelRateLimitError.

Почему так: счёт разрешений привязан к объекту ограничителя. Новый объект, это новый счёт, и общего у них нет. В последовательном цикле ошибка не видна: новый ограничитель стартует без разрешений, и каждый запрос всё равно ждёт две секунды. По той же причине ограничитель не работает между процессами.

# Правильно: один ограничитель и одна модель на все потоки, ограничитель потокобезопасен
limiter = InMemoryRateLimiter(requests_per_second=0.5, max_bucket_size=1)
model = build_model(rate_limiter=limiter)

with ThreadPoolExecutor(max_workers=8) as pool:
    answers = list(pool.map(model.invoke, questions))

Практическое задание

Напишите скрипт model_report.py, который печатает сводку по модели: что она умеет, как настроена и сколько стоил прогон.

Требования:

1) соберите настраиваемую модель с полями ("model", "max_tokens") и своим префиксом конфигурации, модель по умолчанию задайте из MODEL_NAME

2) опишите три задачи списком кортежей: название, текст запроса, словарь настроек. Настройки должны отличаться хотя бы значением max_tokens

3) перед прогоном напечатайте справку о модели: значение profile для имени из MODEL_NAME, а если профиля нет, напечатайте об этом строку и подставьте свой профиль параметром profile

4) прогоните три задачи через одну модель, передавая настройки конфигурацией вызова. В каждый вызов передайте обработчик расхода, имя вызова в run_name и метку задачи в metadata

5) поставьте ограничитель частоты InMemoryRateLimiter со скоростью один запрос в секунду и напечатайте общее время прогона

6) в конце напечатайте таблицу расхода по моделям и итог в деньгах, ставки возьмите свои

7) любой отказ провайдера должен печататься строкой с типом исключения, а не падать трассировкой. Ловите типы из langchain_core.exceptions и печатайте признак is_retryable

Как проверить результат:

1) в выводе три блока задач, и выход в токенах у каждой задачи не больше её max_tokens

2) общее время прогона не меньше трёх секунд: разрешений на старте нет, и три запроса получают их на первой, второй и третьей секунде

3) таблица расхода содержит хотя бы одну строку, и сумма денег больше нуля

4) если испортить имя модели в конфигурации одной из задач, скрипт печатает строку с типом отказа и признаком повторяемости, а остальные две задачи отрабатывают. Тип придёт от вашего провайдера. Шлюз курса отвечает на неизвестное имя кодом 400, и печатается OpenAIInvalidRequestError с is_retryable=False. Другой провайдер может ответить кодом 404, тогда придёт наследник ModelNotFoundError. Верны оба исхода: проверяется, что скрипт не упал и назвал причину

Подсказка: чтобы не собирать модель заново под каждую задачу, держите одну настраиваемую модель и меняйте только словарь под ключом configurable, как в примерах 3 и 4.

Итоги урока

Теперь вы умеете обращаться с моделью как с настраиваемым компонентом, а не как с чёрным ящиком, который отвечает.

Параметр задаётся в одном из трёх мест: при создании модели, прикреплением через bind или на один вызов. Ни bind, ни аргумент на вызов исходную модель не портят. Одинаковое имя параметра у разных провайдеров не значит одинаковое поведение, проверяется это замером. На модели курса max_tokens сработал, а при упоре в потолок ответ приходит оборванным или вовсе пустым, с посчитанными и оплаченными токенами выхода. Поэтому исход читается по finish_reason и счётчику, а не по длине строки.

Профиль модели (profile) отвечает без запроса в сеть, что модель умеет. Данные идут из models.dev вместе с пакетом. Поэтому у имени вида провайдер/модель профиля может не быть: тогда вы пишете его руками параметром profile, а правите копией через model_copy. Профиль, это справка, а не рычаг.

Настраиваемая модель переносит имя и часть параметров из кода в конфигурацию вызова. Поля перечисляйте поимённо ("any" открывает и адрес, и ключ), а префикс разводит несколько моделей, и ключ без него пропадает без ошибки. На этом строится маршрутизатор, где задачи, это данные, а не ветки кода, а внутри агента то же самое делает middleware с @wrap_model_call и override.

Инструменты на стороне провайдера меняют границу ответственности: поиск выполняется у провайдера за один шаг, без парных ToolMessage. Отсутствие такой возможности у вашего провайдера, это нормально.

Отказы сети фреймворк берёт на себя частично: повторяются сеть, лимит частоты и ошибки сервера, не повторяются неверный ключ и неизвестная модель. Таймаут и повторы перемножаются, а на значение по умолчанию у числа повторов не полагайтесь. Лимит частоты держит InMemoryRateLimiter, он считает запросы, а не токены.

Счёт денег вырос из строки в таблицу: UsageMetadataCallbackHandler раскладывает расход по именам моделей. Ставки заводите по одной на модель, иначе дорогая модель в счёте выглядит дешевле, чем есть.

В примерах урока модели передавалась строка, системное сообщение было только у агента в примере 5. В уроке 5, "Системный промпт и контекст вместо шаблонов", разберу, что стало с шаблонами промптов. Там же покажу, что физически уходит в модель при каждом вызове и как собирать системный промпт на лету.

Код урока

Примеры этого урока лежат в репозитории курса, папка lesson_04. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.


Предыдущий урок: Сообщения и контент-блоки

Следующий урок: Системный промпт и контекст вместо шаблонов ---

Подписывайтесь на мой Telegram канал

Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия

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

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

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

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

Пишите info@aisferaic.ru

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