Модель как настраиваемый компонент | Курс LangChain урок 4
Цель урока: настраивать модель тремя способами, при создании, прикреплением к объекту и на один вызов. Читать профиль возможностей модели и менять модель под задачу через конфигурацию вызова и внутри агента. Ставить ограничитель частоты и таймаут с повторами. Считать расход по всем моделям приложения одним обработчиком.
Необходимые знания:
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 агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru