ToolRuntime и Runtime: окружение вызова в инструменте | Курс LangChain урок 10
Цель урока: передавать инструменту данные запуска, которых нет в схеме для модели и которые нельзя подменить репликой в разговоре. Читать из инструмента состояние разговора и хранилище, писать в состояние. Узнавать в чужом коде прежние способы такой передачи.
Необходимые знания:
1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены
2) урок 5: system_prompt, middleware и декоратор @wrap_model_call, объект ModelRequest, параметр context_schema у агента и аргумент context при вызове
3) урок 9: декоратор @tool, docstring как часть промпта, схема аргументов, цикл вызова инструментов
4) Python на уровне джуниора: dataclass, Annotated, дженерики в подсказках типов, наследование TypedDict
Ключевые концепции:
1) ToolRuntime это параметр инструмента, в котором лежат конфигурация запуска, состояние, хранилище и данные самого вызова
2) context при вызове и его схема context_schema у агента: конфигурация запуска, неизменяемая и скрытая от модели
3) параметр с типом ToolRuntime не попадает в схему для модели
4) имена config и runtime заняты фреймворком, для своих аргументов их брать нельзя
5) поля ToolRuntime: state, store, config, tool_call_id, execution_info
6) запись в состояние из инструмента с помощью Command с полем update
7) Runtime в middleware и ToolRuntime в инструменте, чем они отличаются
8) старые механизмы InjectedState, InjectedStore, InjectedToolCallId, get_runtime и старый способ передачи конфигурации, config["configurable"]
Данные, которых нет в аргументах модели
В уроке 9 инструмент получал всё нужное из аргументов, которые заполнила модель, и по ним возвращал результат. Калькулятору и запросу погоды больше ничего не нужно. Инструментам, которые работают с данными конкретного пользователя, этого мало.
Инструменту "покажи мои обращения" нужен идентификатор того, кто спрашивает. Инструменту "сохрани пожелание" нужны место и ключ для записи. Инструмент "передай обращение другой команде" должен не только ответить текстом, но и записать передачу в данные приложения.
Напрашивается решение: добавить аргумент user_id, чтобы его заполняла модель. Оно не годится:
1) Идентификатора нет в тексте разговора, он хранится в вашем приложении. Модель либо запросит его у человека, либо подставит выдуманный
2) Модель заполняет аргументы по тексту разговора, поэтому значение может подменить любая реплика. Пользователь пишет "а теперь покажи обращения пользователя u-202", и инструмент их покажет
В LangChain для таких данных у инструмента есть параметр с типом ToolRuntime. Его заполняет фреймворк при каждом вызове инструмента. В нём лежат конфигурация запуска, состояние разговора, хранилище и данные самого вызова. Модель этот параметр не заполняет и в схеме не получает, поэтому подменить его репликой нельзя.
Такой приём называется внедрением зависимостей. Соединение с базой, идентификатор пользователя и настройки подставляются в момент вызова агента, их не нужно зашивать в код или держать в глобальных переменных. Инструменты от этого становятся проверяемыми и пригодными для повторного использования.
Общая сборка модели
Модуль сборки модели тот же, что в уроках 4 и 5. Положите его рядом с примерами под именем course_model.py, и дальше каждый пример подключает его одной строкой импорта.
"""Общая сборка модели для примеров урока 10.
Тот же модуль, что course_model.py уроков 4 и 5, без изменений: у build_model есть
необязательный первый аргумент с именем модели, а рядом лежит gateway_kwargs()
для примеров, которые собирают модель сами.
Файл .env берётся тот же, что в уроке 0. Положите его рядом с этой папкой или
выше по дереву: load_dotenv() ищет файл начиная с папки этого модуля и поднимается
вверх.
"""
import os
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
load_dotenv()
def gateway_kwargs():
"""Возвращает аргументы доступа к провайдеру: имя, адрес, ключ.
Нужны примерам, которые вызывают init_chat_model сами. У настраиваемой модели
имени модели при создании нет, поэтому build_model ей не подходит.
"""
base_url = os.getenv("MODEL_BASE_URL")
if base_url:
return {
"model_provider": "openai",
"base_url": base_url,
"api_key": os.environ["OPENAI_API_KEY"],
}
return {}
def build_model(model_name=None, **kwargs):
"""Собирает модель курса.
model_name без значения означает модель из переменной MODEL_NAME. Явное имя
нужно примерам, где моделей в приложении больше одной.
Все именованные аргументы уходят в init_chat_model как есть: temperature,
max_tokens, timeout, max_retries, rate_limiter, profile и прочее из раздела
Parameters.
"""
model_name = model_name or os.environ["MODEL_NAME"]
access = gateway_kwargs()
if access:
# Путь для любого адреса, совместимого с OpenAI Chat Completions API.
return init_chat_model(model=model_name, **access, **kwargs)
# Путь напрямую к провайдеру.
return init_chat_model(model_name, **kwargs)
Конфигурация запуска: context и context_schema
Конфигурация запуска (в терминах фреймворка runtime context), это данные, с которыми запущен агент: идентификатор пользователя, его отдел, соединение с базой, ключ доступа к внутреннему сервису. За время запуска они не меняются и на следующий вызов invoke сами не переходят. В уроке 5 вы уже передавали их аргументом context и читали в middleware как request.runtime.context. Здесь тот же механизм со стороны инструмента.
Собирается он из таких частей:
1) Форма данных: обычный dataclass с полями, в примерах используется именно он
2) Схема у агента: параметр context_schema при создании, он задаёт тип объекта и превращает переданный словарь в объект этого класса
3) Значение при вызове: аргумент context у invoke и у stream, рядом с обычным словарём сообщений
Без context_schema значение тоже дойдёт до runtime.context, но как есть: словарь останется словарём. Пример 1 показывает все три части на одном инструменте.
Пример 01_context.py
"""Пример 1 урока 10: конфигурация запуска через context и context_schema.
Один и тот же вопрос от двух разных пользователей. Идентификатора пользователя
нет в схеме для модели, поэтому модель не может его подставить. Он приходит в
агента отдельным аргументом context, а инструмент читает его через runtime.context.
"""
from dataclasses import dataclass
from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from course_model import build_model
@dataclass
class SessionContext:
"""Конфигурация запуска: кто спрашивает и из какого отдела."""
user_id: str
department: str
TICKETS = {
"u-101": ["T-5501 не приходит письмо о доставке", "T-5502 задвоился платёж"],
"u-202": ["T-6100 не открывается отчёт по продажам"],
}
@tool
def list_my_tickets(runtime: ToolRuntime[SessionContext]) -> str:
"""Вернуть список обращений текущего пользователя."""
user_id = runtime.context.user_id
tickets = TICKETS.get(user_id, [])
if not tickets:
return "Открытых обращений нет."
return "\n".join(tickets)
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[list_my_tickets],
system_prompt="Вы помощник службы поддержки. Отвечайте по-русски и коротко.",
context_schema=SessionContext,
)
QUESTION = "Какие у меня открыты обращения?"
for user_id in ("u-101", "u-202"):
result = agent.invoke(
{"messages": [{"role": "user", "content": QUESTION}]},
context=SessionContext(user_id=user_id, department="support"),
)
answer = result["messages"][-1].text.replace("\n", " ")
print(f"ПОЛЬЗОВАТЕЛЬ {user_id}: {answer}")
print()
print("АРГУМЕНТЫ, КОТОРЫЕ ВИДИТ МОДЕЛЬ:", list_my_tickets.args)
# Вывод:
# ПОЛЬЗОВАТЕЛЬ u-101: Вот ваши открытые обращения: 1. **T-5501** — не приходит письмо о доставке 2. **T-5502** — задвоился платёж Могу подробнее разобрать любое из них. Что вас интересует?
# ПОЛЬЗОВАТЕЛЬ u-202: У вас открыто одно обращение: **T-6100** — не открывается отчёт по продажам. Могу я чем-то ещё помочь?
#
# АРГУМЕНТЫ, КОТОРЫЕ ВИДИТ МОДЕЛЬ: {}
В выводе вопрос один и тот же, а списки обращений разные. Последняя строка печатает пустой словарь: в схеме для модели у инструмента нет ни одного аргумента.
Тип в квадратных скобках, SessionContext в записи ToolRuntime[SessionContext], сообщает редактору, объект какого типа лежит в runtime.context. Редактор после этого подсказывает поля, а опечатку в имени поля видно до запуска. При вызове фреймворк этот тип не проверяет, и без скобок код тоже работает.
Вызов инструмента по-прежнему не гарантирован: в ответе модели его может не оказаться, и тогда на вопрос про обращения придёт текст без списка. Это поведение цикла из урока 9, к конфигурации запуска оно не относится.
Что из подписи инструмента попадает в схему для модели
Устройство невидимого параметра объясняет две ошибки из конца урока. Схемы аргументов у инструмента разные для фреймворка и для модели:
1) Полная схема, tool.args_schema. По ней фреймворк собирает вызов функции, внедряемые параметры в ней есть
2) Схема для модели, tool.tool_call_schema, и её короткая форма tool.args. Она уходит провайдеру вместе с именем и описанием инструмента, внедряемых параметров в ней нет
Из схемы для модели параметр вырезается по типу. Тип ToolRuntime в коде фреймворка наследует служебный класс _DirectlyInjectedToolArg, и этого родителя достаточно: пометка в Annotated не нужна.
Параметр с именем config или runtime из схемы для модели не вырезается. Но имя меняет то, что фреймворк подставит при вызове, поэтому для своих аргументов эти имена брать нельзя.
| Имя параметра | Чем занято |
|---|---|
config |
передача RunnableConfig внутрь инструмента |
runtime |
параметр ToolRuntime: состояние, конфигурация запуска, хранилище |
Имя runtime фреймворк проверяет отдельно от типа: параметр с таким именем получает ToolRuntime при любой аннотации. Аргумент runtime: str попадёт в схему для модели как строка, и модель его заполнит. Перед вызовом узел инструментов заменит эту строку объектом ToolRuntime. Проверка аргументов его не пропустит, потому что это не строка, и функция не выполнится. Модель получит ToolMessage со статусом error.
Пример 02_hidden_args.py
"""Пример 2 урока 10: что из подписи инструмента попадает в схему для модели.
Сети здесь нет, модель не вызывается. Для каждого инструмента сравниваются две
схемы: полная, по которой фреймворк собирает вызов, и та, что уходит в модель.
Последние два инструмента названы зарезервированными именами, и по разнице
между схемами видно, к чему это приводит.
"""
from typing import Any
from langchain.tools import ToolRuntime, tool
@tool
def weather_plain(city: str) -> str:
"""Вернуть погоду в городе."""
return f"В городе {city} ясно."
@tool
def weather_with_runtime(city: str, runtime: ToolRuntime) -> str:
"""Вернуть погоду в городе и номер вызова инструмента."""
return f"В городе {city} ясно. Вызов {runtime.tool_call_id}."
@tool
def weather_bad_runtime(city: str, runtime: str) -> str:
"""Вернуть погоду в городе с пометкой о режиме работы."""
return f"В городе {city} ясно. Режим {runtime}."
@tool
def weather_bad_config(city: str, config: dict) -> str:
"""Вернуть погоду в городе с учётом настроек."""
return f"В городе {city} ясно. Настройки {config}."
def full_schema_fields(some_tool: Any) -> list[str]:
"""Имена полей полной схемы инструмента.
У инструмента, собранного из функции, args_schema это модель Pydantic.
Защитная ветка нужна на случай схемы, заданной словарём JSON Schema.
"""
schema = some_tool.args_schema
if isinstance(schema, dict):
return sorted(schema.get("properties", {}))
return sorted(schema.model_fields)
TOOLS = [
weather_plain,
weather_with_runtime,
weather_bad_runtime,
weather_bad_config,
]
print(f"{'инструмент':<22} {'полная схема':<34} схема для модели")
for item in TOOLS:
full = ", ".join(full_schema_fields(item))
visible = ", ".join(sorted(item.args))
print(f"{item.name:<22} {full:<34} {visible}")
print()
print("ПОДРОБНО ПРО weather_with_runtime")
print(" тип поля runtime в полной схеме:", weather_with_runtime.args_schema.model_fields["runtime"].annotation)
print(" описание для модели:", weather_with_runtime.description)
# Вывод:
# инструмент полная схема схема для модели
# weather_plain city city
# weather_with_runtime city, runtime city
# weather_bad_runtime city, runtime city, runtime
# weather_bad_config city, config city, config
#
# ПОДРОБНО ПРО weather_with_runtime
# тип поля runtime в полной схеме: <class 'langgraph.prebuilt.tool_node.ToolRuntime'>
# описание для модели: Вернуть погоду в городе и номер вызова инструмента.
Пример работает без сети и без модели, поэтому его удобно запускать каждый раз, когда сомневаетесь, что именно уходит провайдеру.
В таблице у первого инструмента схемы совпадают. У второго параметр runtime есть в полной схеме и отсутствует в схеме для модели. У последних двух зарезервированное имя оказывается в обеих.
Параметр runtime объявляйте с типом ToolRuntime. Если указать тип данных, которые из него достаёте, например SessionContext, параметр останется в схеме для модели и заполнять его будет модель, подробнее в конце урока.
В таблице Reserved argument names на странице документации про инструменты два имени: config и runtime. В docstring декоратора @tool в langchain-core 1.6.3 другая тройка: config, run_manager и callbacks, а runtime там не упоминается.
Свой аргумент config с другим типом, например config: dict, до функции не доходит: она вызывается без него и падает с TypeError про недостающий позиционный аргумент. С аргументом runtime модель получает ToolMessage со статусом error, это разобрано выше. Аргументы run_manager и callbacks вырезаются из схемы без предупреждения, и своего значения функция в них не получит. В callbacks придёт менеджер обратных вызовов фреймворка, а run_manager не передаётся вовсе, и без значения по умолчанию вызов падает с TypeError.
Служебные имена под свои данные не занимайте. Если в чужом инструменте есть аргумент config, runtime, run_manager или callbacks, проверьте его по tool.args.
Состояние разговора внутри инструмента
Состояние, в терминах фреймворка state, это короткая память: данные, которые меняются по ходу разговора. Без памяти сессии они существуют один запуск агента. Обязательное поле у состояния одно, messages, туда складывается вся переписка.
Инструменту состояние нужно, когда ответ зависит от уже сказанного в разговоре. Например, сводка разговора, проверка, задавал ли пользователь этот вопрос раньше, или счётчик вызовов инструмента за один запуск.
Внутри инструмента состояние лежит в runtime.state, это обычный словарь.
Пример 03_state.py
"""Пример 3 урока 10: инструмент читает состояние разговора.
Инструмент подсчитывает сообщения разговора к моменту вызова и возвращает
сводку. Это короткая память: она существует один запуск агента и обнуляется
вместе с ним.
"""
from collections import Counter
from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from course_model import build_model
@tool
def conversation_summary(runtime: ToolRuntime) -> str:
"""Вернуть сводку по текущему разговору: сколько сообщений и каких."""
messages = runtime.state["messages"]
kinds = Counter(type(message).__name__ for message in messages)
first = messages[0].text.replace("\n", " ") if messages else ""
if len(first) > 40:
first = first[:37] + "..."
parts = ", ".join(f"{name} {count}" for name, count in sorted(kinds.items()))
return f"Сообщений {len(messages)} ({parts}). Первое: {first!r}"
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[conversation_summary],
system_prompt=(
"Вы помощник по разговору. Про состояние разговора отвечайте только по "
"данным инструмента, ничего не придумывайте. Отвечайте по-русски и коротко."
),
)
DIALOG = [
{"role": "user", "content": "Здравствуйте, у меня задвоился платёж по заказу 4412."},
{"role": "assistant", "content": "Здравствуйте. Проверяю списания по заказу 4412."},
{"role": "user", "content": "Сколько сообщений уже в нашем разговоре?"},
]
result = agent.invoke({"messages": DIALOG})
print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
print()
print("СОСТОЯНИЕ ПОСЛЕ ПРОГОНА")
for number, message in enumerate(result["messages"], start=1):
text = message.text.replace("\n", " ")
if len(text) > 50:
text = text[:47] + "..."
print(f" {number}. {type(message).__name__:<14} {text!r}")
# Вывод:
# ОТВЕТ: В нашем разговоре пока 4 сообщения. По вашему вопросу о задвоенном платеже по заказу 4412 я уже начал проверку — уточните, пожалуйста, какую именно информацию вам нужно получить?
#
# СОСТОЯНИЕ ПОСЛЕ ПРОГОНА
# 1. HumanMessage 'Здравствуйте, у меня задвоился платёж по заказу...'
# 2. AIMessage 'Здравствуйте. Проверяю списания по заказу 4412.'
# 3. HumanMessage 'Сколько сообщений уже в нашем разговоре?'
# 4. AIMessage ' '
# 5. ToolMessage 'Сообщений 4 (AIMessage 2, HumanMessage 2). Перв...'
# 6. AIMessage ' В нашем разговоре пока 4 сообщения. По вашему ...'
В выводе состояние после прогона, в нём шесть сообщений. Инструмент вызван раньше, когда их было четыре: три из DIALOG и под номером 4 ответ модели с вызовом инструмента. Сообщения 5 и 6, ответ инструмента и финальный ответ модели, добавились после вызова.
Прошлые реплики доступны инструменту и без памяти сессии: в состояние попадает всё, что передано в messages при запуске. Как сделать, чтобы переписка накапливалась между вызовами сама, разбирается в уроке 12.
Своя схема состояния задаётся параметром state_schema и разбирается в уроке 11, вместе с устройством агента. Для записи из инструмента ниже хватит одного дополнительного поля.
Инструмент, который пишет в состояние
Обычный инструмент возвращает строку, и она превращается в ToolMessage. Иногда результат нужен и вашему приложению, причём отдельным полем состояния.
Для этого инструмент возвращает Command с полем update. В словаре обновления вы перечисляете поля состояния, которые надо изменить.
В обновление обязательно кладите ToolMessage с полем tool_call_id: без него узел инструментов остановит запуск с ValueError. Идентификатор вызова лежит в runtime.tool_call_id.
Пример 04_state_write.py
"""Пример 4 урока 10: инструмент пишет в состояние.
Обычный инструмент возвращает строку, и она становится сообщением для модели.
Этот инструмент возвращает Command с полем update и записывает в состояние,
какой команде передано обращение. Поле escalated_to добавлено в своей схеме
состояния, поэтому после прогона его видно рядом с messages.
"""
from langchain.agents import AgentState, create_agent
from langchain.messages import ToolMessage
from langchain.tools import ToolRuntime, tool
from langgraph.types import Command
from course_model import build_model
class SupportState(AgentState):
"""Состояние агента поддержки: к messages добавлено поле передачи."""
escalated_to: str
@tool
def escalate(team: str, runtime: ToolRuntime[None, SupportState]) -> Command:
"""Передать обращение другой команде. Название команды на латинице."""
return Command(
update={
"escalated_to": team,
"messages": [
ToolMessage(
content=f"Обращение передано команде {team}.",
tool_call_id=runtime.tool_call_id,
)
],
}
)
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[escalate],
system_prompt=(
"Вы первая линия поддержки. Вопросы про деньги и списания передавайте "
"команде billing, вопросы про доставку команде logistics. Отвечайте "
"по-русски и коротко."
),
state_schema=SupportState,
)
result = agent.invoke(
{
"messages": [
{"role": "user", "content": "С меня дважды списали деньги за заказ 4412."}
]
}
)
print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
print("КЛЮЧИ СОСТОЯНИЯ:", sorted(result))
print("ПОЛЕ escalated_to:", result.get("escalated_to", "<поля нет>"))
print()
print("СООБЩЕНИЯ В СОСТОЯНИИ")
for number, message in enumerate(result["messages"], start=1):
text = message.text.replace("\n", " ")
if len(text) > 50:
text = text[:47] + "..."
print(f" {number}. {type(message).__name__:<14} {text!r}")
# Вывод:
# ОТВЕТ: Ваш вопрос передан команде billing. Они разберутся с двойным списанием по заказу 4412 и свяжутся с вами.
# КЛЮЧИ СОСТОЯНИЯ: ['escalated_to', 'messages']
# ПОЛЕ escalated_to: billing
#
# СООБЩЕНИЯ В СОСТОЯНИИ
# 1. HumanMessage 'С меня дважды списали деньги за заказ 4412.'
# 2. AIMessage ' Я передам ваш вопрос команде billing, так как ...'
# 3. ToolMessage 'Обращение передано команде billing.'
# 4. AIMessage ' Ваш вопрос передан команде billing. Они разбер...'
В ключах состояния рядом с messages появилось escalated_to, и приложение читает название команды как обычное поле словаря, без разбора текста ответа.
В записи ToolRuntime[None, SupportState] первый параметр в скобках, это тип конфигурации запуска, второй это тип состояния. Конфигурация здесь не нужна, поэтому на её месте None.
Модель может запросить несколько вызовов инструментов за один шаг, и они выполняются параллельно. Если два таких вызова пишут одно и то же поле, итоговое значение задаёт редьюсер поля: функция, которая сводит два значения в одно. У поля messages редьюсер есть, поэтому оба ToolMessage добавляются в список. У поля без редьюсера за шаг может быть только одно значение, а второе останавливает запуск с InvalidUpdateError. Устройство редьюсеров разбирается в уроке 11.
Хранилище: то, что переживает запуск
Без памяти сессии состояние обнуляется вместе с запуском агента. Хранилище, в терминах фреймворка store, сохраняется дольше: записанное в одном разговоре достаётся в другом.
Внутри инструмента хранилище лежит в runtime.store, а записи в нём адресуются парой из пространства имён и ключа. Само хранилище передаётся агенту параметром store при создании.
Если агент собран без хранилища, поле runtime.store равно None, и обращение к нему без проверки остановит весь запуск агента.
Пример 05_store.py
"""Пример 5 урока 10: инструмент читает и пишет долгую память.
Два запуска агента подряд. Сообщения первого запуска во второй не попадают,
а хранилище у них общее, поэтому записанное в первом запуске достаётся во
втором.
"""
from dataclasses import dataclass
from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from langgraph.store.memory import InMemoryStore
from course_model import build_model
NAMESPACE = ("preferences",)
@dataclass
class SessionContext:
"""Конфигурация запуска: кто спрашивает."""
user_id: str
@tool
def save_preference(preference: str, runtime: ToolRuntime[SessionContext]) -> str:
"""Запомнить пожелание пользователя о том, как ему отвечать."""
if runtime.store is None:
return "Хранилище не подключено, запомнить нечем."
runtime.store.put(NAMESPACE, runtime.context.user_id, {"preference": preference})
return f"Запомнил: {preference}"
@tool
def get_preference(runtime: ToolRuntime[SessionContext]) -> str:
"""Вернуть ранее сохранённое пожелание пользователя."""
if runtime.store is None:
return "Хранилище не подключено, вспомнить нечем."
item = runtime.store.get(NAMESPACE, runtime.context.user_id)
if item is None:
return "Пожеланий не сохранено."
return item.value["preference"]
store = InMemoryStore()
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[save_preference, get_preference],
system_prompt=(
"Вы помощник. Пожелания пользователя о форме ответа сохраняйте "
"инструментом и доставайте инструментом, по памяти не отвечайте. "
"Отвечайте по-русски и коротко."
),
context_schema=SessionContext,
store=store,
)
context = SessionContext(user_id="u-101")
first = agent.invoke(
{"messages": [{"role": "user", "content": "Запомните: отвечайте мне без списков."}]},
context=context,
)
print("ЗАПУСК 1:", first["messages"][-1].text.replace("\n", " "))
print(" сообщений в состоянии:", len(first["messages"]))
second = agent.invoke(
{"messages": [{"role": "user", "content": "Как я просил вам отвечать?"}]},
context=context,
)
print("ЗАПУСК 2:", second["messages"][-1].text.replace("\n", " "))
print(" сообщений в состоянии:", len(second["messages"]))
print()
item = store.get(NAMESPACE, "u-101")
print("В ХРАНИЛИЩЕ ПОСЛЕ ДВУХ ЗАПУСКОВ:", item.value if item else "<пусто>")
# Вывод:
# ЗАПУСК 1: Запомнил. Буду отвечать без списков.
# сообщений в состоянии: 4
# ЗАПУСК 2: Вы просили отвечать без списков. Учту это в дальнейшем.
# сообщений в состоянии: 4
#
# В ХРАНИЛИЩЕ ПОСЛЕ ДВУХ ЗАПУСКОВ: {'preference': 'Отвечать без списков'}
Во втором запуске в состоянии снова четыре сообщения: разговор начался заново, реплик первого запуска в нём нет. Пожелание при этом нашлось, его отдало хранилище. В этом и разница между состоянием и хранилищем.
Ключ здесь взят из конфигурации запуска, runtime.context.user_id: конфигурация указывает, чьи это записи, а хранилище держит их содержимое. InMemoryStore хранит всё в словаре внутри процесса, и при перезапуске записи пропадают. Такое хранилище подходит для примеров и тестов, а в рабочем приложении нужно хранилище на базе данных, например PostgresStore. Что имеет смысл держать в долгой памяти и как не копить в ней лишнее, разбирается в уроке 13.
Runtime и ToolRuntime: два объекта, одна конфигурация
До сих пор речь шла про инструменты, но конфигурация запуска нужна и middleware: промпт под пользователя, проверка прав перед вызовом модели, запись в журнал. В middleware конфигурацию передаёт объект Runtime. Декоратор @before_model делает middleware из функции, которая выполняется перед каждым вызовом модели. Такая функция получает два параметра: состояние и runtime. В обёртке @wrap_model_call из урока 5 тот же объект лежит в request.runtime.
ToolRuntime это отдельный класс, от Runtime он не наследуется: проверка isinstance(runtime, Runtime) в инструменте вернёт False. Часть полей у них общая, остальные у каждого свои. Собственные поля Runtime, например previous и control, в этом уроке не нужны, и в таблице их нет.
| Что | Runtime (middleware) |
ToolRuntime (инструмент) |
|---|---|---|
context |
есть | есть |
store |
есть | есть |
stream_writer |
есть | есть |
execution_info, server_info |
есть | есть |
state |
нет, состояние приходит отдельным параметром хука | есть |
config |
нет | есть |
tool_call_id |
нет | есть |
tools |
нет | есть, все инструменты агента |
В execution_info лежат сведения о запуске: thread_id, run_id, номер попытки node_attempt. Поле thread_id берётся из config["configurable"] и пустое, если thread_id при вызове не передан. Поле server_info заполняет сервер LangSmith Deployment, когда агент работает на нём: в поле лежат идентификаторы ассистента и графа, а при настроенной авторизации ещё и данные пользователя. При вызове invoke из своего кода, как в примерах этого урока, оно равно None.
Пример 06_runtime_objects.py
"""Пример 6 урока 10: два объекта, Runtime и ToolRuntime, в одном прогоне.
Middleware получает Runtime, инструмент получает ToolRuntime. Конфигурация
запуска, хранилище и запись в поток у них общие, а состояние, объект config,
идентификатор вызова и список инструментов есть только у второго.
Чекпойнтер подключён, как в приложении с памятью сессии, а thread_id передан
в config: из него заполняется поле execution_info.thread_id. Память сессии
разбирается в уроке 12.
"""
from dataclasses import dataclass
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import before_model
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.runtime import Runtime
from course_model import build_model
@dataclass
class SessionContext:
"""Конфигурация запуска."""
user_id: str
@before_model
def show_runtime(state: AgentState, runtime: Runtime[SessionContext]) -> None:
"""Печатает поля Runtime перед вызовом модели."""
info = runtime.execution_info
print("MIDDLEWARE, объект Runtime")
print(" context.user_id:", runtime.context.user_id)
print(" store:", runtime.store)
print(" server_info:", runtime.server_info)
print(" есть ли поле config:", hasattr(runtime, "config"))
print(" execution_info.thread_id:", info.thread_id if info else None)
print(" сообщений в состоянии:", len(state["messages"]))
return None
@tool
def whoami(runtime: ToolRuntime[SessionContext]) -> str:
"""Вернуть идентификатор текущего пользователя."""
info = runtime.execution_info
configurable = runtime.config.get("configurable", {})
print("ИНСТРУМЕНТ, объект ToolRuntime")
print(" context.user_id:", runtime.context.user_id)
print(" tool_call_id:", runtime.tool_call_id)
print(" сообщений в состоянии:", len(runtime.state["messages"]))
print(" config configurable thread_id:", configurable.get("thread_id"))
print(" execution_info.thread_id:", info.thread_id if info else None)
print(" execution_info.node_attempt:", info.node_attempt if info else None)
print(" инструментов в runtime.tools:", len(runtime.tools))
return runtime.context.user_id
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[whoami],
system_prompt="Вы помощник. Отвечайте по-русски одним предложением.",
context_schema=SessionContext,
middleware=[show_runtime],
checkpointer=InMemorySaver(),
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "Под каким идентификатором я вошёл?"}]},
config={"configurable": {"thread_id": "session-1"}},
context=SessionContext(user_id="u-101"),
)
print()
print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
# Вывод:
# MIDDLEWARE, объект Runtime
# context.user_id: u-101
# store: None
# server_info: None
# есть ли поле config: False
# execution_info.thread_id: session-1
# сообщений в состоянии: 1
# ИНСТРУМЕНТ, объект ToolRuntime
# context.user_id: u-101
# tool_call_id: call_06889f8154da40d38e463a3a
# сообщений в состоянии: 2
# config configurable thread_id: session-1
# execution_info.thread_id: session-1
# execution_info.node_attempt: 1
# инструментов в runtime.tools: 1
# MIDDLEWARE, объект Runtime
# context.user_id: u-101
# store: None
# server_info: None
# есть ли поле config: False
# execution_info.thread_id: session-1
# сообщений в состоянии: 3
#
# ОТВЕТ: Вы вошли под идентификатором **u-101**.
Middleware печатает дважды, потому что модель вызывается дважды: первый ответ содержит вызов инструмента, второй собирается по его результату.
Строки context.user_id и config configurable thread_id в выводе инструмента показывают два разных источника. thread_id задаёт разговор, а в context лежат данные этого конкретного запуска. Передавайте их вместе: постоянный thread_id на весь разговор и объект context при каждом вызове.
Поле runtime.tools, это список всех инструментов, которые агент может вызвать. Здесь в нём один whoami. В документации это поле не описано.
Старые механизмы внедрения
Всё, что выше, собрано вокруг одного параметра. Так было не всегда, и в чужом коде встречаются отдельные механизмы для тех же задач:
1) InjectedState, аннотация на аргумент, в который подставляется состояние или одно его поле
2) InjectedStore, то же самое для хранилища
3) InjectedToolCallId, то же самое для идентификатора вызова
4) get_runtime(), функция, которую вызывают прямо в теле инструмента, чтобы получить объект Runtime
Первые три механизма пишутся меткой рядом с типом аргумента, например Annotated[dict, InjectedState], а get_runtime() вызывается в теле функции. В новом коде всё это заменяет один параметр ToolRuntime.
Старые механизмы работают и сейчас. В версиях, на которых собран курс, ни один из них не удалён, InjectedState, InjectedStore и InjectedToolCallId импортируются из langchain.tools, а предупреждения об устаревании нет.
Пример 7 собирает один и тот же инструмент двумя способами и запускает агента с каждым.
Пример 07_legacy_injection.py
"""Пример 7 урока 10: старые механизмы внедрения рядом с нынешним.
Два инструмента делают одно и то же. Первый собран по-старому, четырьмя разными
механизмами: InjectedState, InjectedStore, InjectedToolCallId и вызов
get_runtime внутри тела. Второй берёт то же самое из одного параметра
ToolRuntime. Схема для модели у обоих должна быть одинаковой.
Каждый агент получает свой единственный инструмент, иначе выбор между ними
остаётся за моделью и прогон перестаёт быть сравнением.
"""
from dataclasses import dataclass
from typing import Annotated
from langchain.agents import create_agent
from langchain.tools import (
InjectedState,
InjectedStore,
InjectedToolCallId,
ToolRuntime,
tool,
)
from langgraph.runtime import get_runtime
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from course_model import build_model
NAMESPACE = ("plans",)
@dataclass
class SessionContext:
"""Конфигурация запуска."""
user_id: str
@tool
def old_style_report(
topic: str,
state: Annotated[dict, InjectedState],
store: Annotated[BaseStore, InjectedStore],
tool_call_id: Annotated[str, InjectedToolCallId],
) -> str:
"""Собрать служебную справку по теме обращения."""
try:
user_id = get_runtime(SessionContext).context.user_id
except Exception as error: # noqa: BLE001
# Защитная ветка: get_runtime работает только внутри прогона графа.
user_id = f"<{type(error).__name__}>"
item = store.get(NAMESPACE, user_id)
plan = item.value["plan"] if item else "неизвестен"
return (
f"тема={topic} пользователь={user_id} тариф={plan} "
f"сообщений={len(state['messages'])} вызов={tool_call_id[:8]}"
)
@tool
def new_style_report(topic: str, runtime: ToolRuntime[SessionContext]) -> str:
"""Собрать служебную справку по теме обращения."""
user_id = runtime.context.user_id
item = runtime.store.get(NAMESPACE, user_id) if runtime.store else None
plan = item.value["plan"] if item else "неизвестен"
return (
f"тема={topic} пользователь={user_id} тариф={plan} "
f"сообщений={len(runtime.state['messages'])} "
f"вызов={runtime.tool_call_id[:8]}"
)
store = InMemoryStore()
store.put(NAMESPACE, "u-101", {"plan": "premium"})
QUESTION = "Соберите справку по теме billing."
for report_tool in (old_style_report, new_style_report):
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[report_tool],
system_prompt=(
"Вы помощник поддержки. Справку собирайте инструментом и возвращайте "
"пользователю ровно ту строку, которую вернул инструмент, без правок."
),
context_schema=SessionContext,
store=store,
)
print(f"ИНСТРУМЕНТ {report_tool.name}")
print(" видит модель:", sorted(report_tool.args))
try:
result = agent.invoke(
{"messages": [{"role": "user", "content": QUESTION}]},
context=SessionContext(user_id="u-101"),
)
except Exception as error: # noqa: BLE001
# Защитная ветка: прежняя схема могла перестать работать.
print(f" ОТКАЗ: {type(error).__name__}: {error}")
continue
tool_results = [
message.text.replace("\n", " ")
for message in result["messages"]
if type(message).__name__ == "ToolMessage"
]
print(" вернул инструмент:", tool_results)
print(" ответ агента:", result["messages"][-1].text.replace("\n", " "))
# Вывод:
# ИНСТРУМЕНТ old_style_report
# видит модель: ['topic']
# вернул инструмент: ['тема=billing пользователь=u-101 тариф=premium сообщений=2 вызов=call_8e8']
# ответ агента: тема=billing пользователь=u-101 тариф=premium сообщений=2 вызов=call_8e8
# ИНСТРУМЕНТ new_style_report
# видит модель: ['topic']
# вернул инструмент: ['тема=billing пользователь=u-101 тариф=premium сообщений=2 вызов=call_ba6']
# ответ агента: тема=billing пользователь=u-101 тариф=premium сообщений=2 вызов=call_ba6
В строке "видит модель" у обоих инструментов один topic. У старого варианта три служебных параметра, и все три из схемы для модели вырезаны: метки Injected* наследуют класс InjectedToolArg, по нему фреймворк и узнаёт внедряемый аргумент.
При переписывании старого кода все четыре механизма заменяются одним параметром ToolRuntime. Если агент собран без хранилища, InjectedStore останавливает запуск с ValueError, а runtime.store в той же ситуации равен None. Поэтому после переписывания нужна проверка if runtime.store.
Старый способ: config["configurable"]
До версии 1 конфигурацию запуска передавали словарём configurable внутри config. Этот способ оставлен для совместимости, а в новых приложениях и при переезде на версию 1 берите context. Внутри инструмента старое значение лежит в runtime.config["configurable"]. Поле config есть у ToolRuntime, а у Runtime в middleware его нет.
Пример 08_configurable.py
"""Пример 8 урока 10: старый способ, config configurable, рядом с новым.
Один и тот же агент запускается дважды. В первом запуске идентификатор
пользователя приходит аргументом context, во втором тем способом, которым
это делали до версии 1: словарём configurable внутри config. Инструмент
возвращает оба источника одной строкой, поэтому видно, что в каждом запуске
пришло, а что нет.
"""
from dataclasses import dataclass
from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from course_model import build_model
@dataclass
class SessionContext:
"""Конфигурация запуска."""
user_id: str
@tool
def whoami(runtime: ToolRuntime[SessionContext]) -> str:
"""Вернуть идентификатор текущего пользователя из обоих источников."""
# Защитная ветка: без аргумента context поле runtime.context равно None.
from_context = getattr(runtime.context, "user_id", None)
from_configurable = runtime.config.get("configurable", {}).get("user_id")
return f"context={from_context} configurable={from_configurable}"
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[whoami],
system_prompt=(
"Вы помощник. Идентификатор пользователя берите инструментом и "
"возвращайте ровно ту строку, которую он вернул, без правок."
),
context_schema=SessionContext,
)
QUESTION = "Под каким идентификатором я вошёл?"
def run(title, **invoke_kwargs):
"""Один запуск агента со своим способом передачи данных."""
print(title)
result = agent.invoke(
{"messages": [{"role": "user", "content": QUESTION}]}, **invoke_kwargs
)
for message in result["messages"]:
if type(message).__name__ == "ToolMessage":
print(" инструмент вернул:", message.text.replace("\n", " "))
print(" ответ агента:", result["messages"][-1].text.replace("\n", " "))
run("ЗАПУСК 1, новый способ: аргумент context", context=SessionContext(user_id="u-101"))
print()
run(
"ЗАПУСК 2, старый способ: config configurable",
config={"configurable": {"user_id": "u-303"}},
)
# Вывод:
# ЗАПУСК 1, новый способ: аргумент context
# инструмент вернул: context=u-101 configurable=None
# ответ агента: Вы вошли под идентификатором: `context=u-101 configurable=None`
#
# ЗАПУСК 2, старый способ: config configurable
# инструмент вернул: context=None configurable=u-303
# ответ агента: Вы вошли под идентификатором: **u-303**
В каждом запуске заполнен свой источник, и по строке "инструмент вернул" видно, какой именно.
Системный промпт просит вернуть строку инструмента без правок, но модель выполняет такую просьбу не дословно. В первом запуске она добавила к строке свою фразу и обратные кавычки, во втором оставила от строки один идентификатор и выделила его жирным. Поэтому в проверках и при разборе ответа на последнюю реплику не опирайтесь. Берите данные из ToolMessage, в выводе это строка "инструмент вернул": её модель не меняет.
У context есть схема, поэтому редактор подсказывает поля, а опечатка в имени поля даёт ошибку. Код на config["configurable"] при этом не ломается: оба способа работают в одном агенте, как в примере 8, поэтому старый код можно переводить на context постепенно.
runtime.config это обычный RunnableConfig, поэтому инструменту доступно и остальное его содержимое: метки, метаданные, thread_id. Это пригодится при записи в журнал.
Распространённые ошибки
Разборы ниже даны фрагментами, отдельных файлов у них нет. Модель, класс SessionContext и агент в них считаются уже определёнными, как в примерах выше.
Ошибка: свой аргумент назван runtime
# Неправильно: имя занято фреймворком
@tool
def render(template: str, runtime: str) -> str:
"""Отрисовать шаблон в заданном режиме."""
return f"{template} в режиме {runtime}"
# Правильно: своему аргументу своё имя
@tool
def render(template: str, mode: str) -> str:
"""Отрисовать шаблон в заданном режиме."""
return f"{template} в режиме {mode}"
Почему так: имя runtime фреймворк проверяет отдельно от типа, и параметр с этим именем получает ToolRuntime при любой аннотации. Модель заполнит его как строку, а перед вызовом на его место встанет объект ToolRuntime. Проверка аргументов его не пропустит, функция не выполнится, и модель получит ToolMessage со статусом error. Аргумент config с любым типом, кроме RunnableConfig, до функции не доходит: она вызывается без него и падает с TypeError, и весь запуск агента останавливается. Пример 2 показывает, что оба имени попадают в схему для модели.
Ошибка: тип данных вместо ToolRuntime
# Неправильно: аннотация описывает то, что лежит внутри
@tool
def whoami(runtime: SessionContext) -> str:
"""Вернуть идентификатор пользователя."""
return runtime.user_id
# Правильно: аннотация описывает окружение, данные достаются из поля
@tool
def whoami(runtime: ToolRuntime[SessionContext]) -> str:
"""Вернуть идентификатор пользователя."""
return runtime.context.user_id
Почему так: из схемы для модели параметр вырезается по типу. SessionContext это обычный dataclass, поэтому параметр останется в схеме вместе с полем user_id, и заполнять его будет модель. Если назвать такой параметр иначе, например ctx, значение от модели дойдёт до функции, и получится та самая подмена, с которой начинался урок. С именем runtime перед вызовом на его место встанет объект ToolRuntime, проверка аргументов его не пропустит, и функция не выполнится.
Ошибка: Command без ToolMessage
# Неправильно: поле обновили, а ответа на вызов инструмента нет
@tool
def escalate(team: str, runtime: ToolRuntime[None, SupportState]) -> Command:
"""Передать обращение другой команде."""
return Command(update={"escalated_to": team})
# Правильно: в обновление кладётся и сообщение с идентификатором вызова
@tool
def escalate(team: str, runtime: ToolRuntime[None, SupportState]) -> Command:
"""Передать обращение другой команде."""
return Command(
update={
"escalated_to": team,
"messages": [
ToolMessage(
content=f"Обращение передано команде {team}.",
tool_call_id=runtime.tool_call_id,
)
],
}
)
Почему так: на каждый вызов инструмента в истории должен стоять ответ ToolMessage с тем же tool_call_id. Узел инструментов проверяет это сам: Command без такого сообщения останавливает запуск с ValueError "Expected to have a matching ToolMessage in Command.update". ToolMessage кладётся в то же обновление состояния, вместе с остальными полями.
Ошибка: обращение к хранилищу без проверки
# Неправильно: агент собран без store, и поле пустое
@tool
def remember(fact: str, runtime: ToolRuntime) -> str:
"""Запомнить факт о пользователе."""
runtime.store.put(("facts",), "last", {"fact": fact})
return "Запомнил"
# Правильно
@tool
def remember(fact: str, runtime: ToolRuntime) -> str:
"""Запомнить факт о пользователе."""
if runtime.store is None:
return "Хранилище не подключено, запомнить нечем."
runtime.store.put(("facts",), "last", {"fact": fact})
return "Запомнил"
Почему так: runtime.store заполняется только тогда, когда агенту передали параметр store. Без него runtime.store.put падает с AttributeError, и, как в уроке 9, исключение из тела инструмента останавливает весь вызов agent.invoke. Поэтому проверка if runtime.store нужна всегда.
Практическое задание
Напишите скрипт runtime_desk.py: агент службы доставки, у которого данные запуска не попадают в схему для модели.
Требования:
1) объявите dataclass с тремя полями: идентификатор пользователя, город и роль (client или operator). Укажите его в context_schema, а значение передайте аргументом context
2) сделайте инструмент my_orders, который возвращает заказы текущего пользователя из словаря в модуле. Идентификатор берите из runtime.context, аргументов у инструмента быть не должно
3) сделайте инструмент cancel_order, который принимает номер заказа и возвращает Command. В обновление положите поле cancelled со списком отменённых номеров и ToolMessage с tool_call_id. Поле объявите в своей схеме состояния и передайте её агенту параметром state_schema
4) пользователю с ролью client отмена запрещена: инструмент возвращает ему строку с отказом и Command не создаёт. Роль проверяйте по runtime.context.role
5) добавьте middleware с декоратором @before_model: перед каждым вызовом модели он печатает роль пользователя и число сообщений в состоянии. Роль берите из Runtime, состояние из параметра хука. Если конфигурация запуска не передана, middleware печатает об этом строку и не падает
6) запустите один и тот же запрос "отмените мой последний заказ" дважды, для роли client и для роли operator
7) в конце каждого запуска напечатайте ключи состояния и значение поля cancelled
Как проверить результат:
1) my_orders.args пустой словарь, то есть в схеме для модели аргументов нет
2) при роли client поля cancelled в состоянии нет или оно пустое, при роли operator в нём номер заказа
3) middleware печатает столько раз, сколько было вызовов модели, и роль в его выводе совпадает с той, что передана в context
4) если убрать аргумент context из вызова, запуск доходит до конца без AttributeError: middleware печатает строку об отсутствии конфигурации, инструменты сообщают, что конфигурация запуска не передана
Подсказка: для четвёртого пункта проверки пригодится getattr(runtime.context, "role", None), как в примере 8. В middleware подойдёт та же запись: без аргумента context поле runtime.context у Runtime тоже равно None.
Итоги урока
Инструменту в LangChain доступны не только аргументы, которые заполнила модель. Параметр с типом ToolRuntime даёт ему конфигурацию запуска, состояние, хранилище и данные самого вызова. Из схемы для модели этот параметр вырезается по типу, поэтому подменить его репликой нельзя.
Конфигурация запуска складывается из dataclass с полями, параметра context_schema у агента и аргумента context при вызове. Внутри инструмента она лежит в runtime.context. Идентификатор пользователя и другие данные, от которых зависит доступ, передавайте именно так.
Рядом в том же объекте лежат состояние разговора runtime.state, хранилище runtime.store, объект config в поле runtime.config, идентификатор вызова runtime.tool_call_id и сведения о запуске runtime.execution_info. Чтобы записать что-то в состояние, инструмент возвращает Command с полем update, и в это обновление обязательно кладётся ToolMessage.
В middleware конфигурацию запуска передаёт объект Runtime. Конфигурация, хранилище и запись в поток у него те же, а состояния, объекта config и идентификатора вызова инструмента нет: состояние приходит хуку отдельным параметром.
Имена config, runtime, run_manager и callbacks заняты фреймворком, для своих аргументов их брать нельзя. Старые механизмы InjectedState, InjectedStore, InjectedToolCallId и get_runtime работают и сейчас, в чужом коде они встречаются, и все заменяются одним параметром ToolRuntime. Словарь config["configurable"] тоже продолжает работать, а в новом коде берите context.
Слово "агент" звучало всё время, а его части вы использовали по отдельности: состояние, middleware, хранилище, цикл вызова инструментов. Что такое агент целиком и из каких узлов он собран, я пока не разбирал. Когда агент останавливается и как устроена его схема состояния с редьюсерами полей, тоже осталось в стороне.
В уроке 11, "Первый агент", покажу create_agent изнутри: цикл модель-инструменты, состояние агента и свой подкласс состояния в параметре state_schema. Там же объясню, чем агент отличается от цепочки и по какой рамке из шести категорий настраивается всё остальное в курсе.
Код урока
Примеры этого урока лежат в репозитории курса, папка lesson_10. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.
Предыдущий урок: Инструменты и цикл вызова
Следующий урок: Первый агент: цикл, состояние и рамка настройки
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru