Свой middleware: хуки, прыжки и своё состояние | Курс LangChain урок 16
Цель урока: написать свой middleware для любой из шести точек цикла и завершать запуск прыжком jump_to. Держать своё поле в состоянии агента и читать в middleware конфигурацию запуска.
Необходимые знания:
1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены
2) урок 9: декоратор @tool, цикл вызова инструментов, ToolMessage и его поле status
3) урок 10: context и context_schema как конфигурация запуска, runtime внутри инструмента
4) урок 11: create_agent, узлы графа, AgentState и свои поля в аргументе state_schema у агента
5) урок 14: ModelRequest, пять составляющих запроса и метод override
6) урок 15: шесть точек встраивания, порядок middleware в списке middleware, каталог готовых middleware
7) Python на уровне джуниора: декораторы, наследование, свойства класса, TypedDict
Ключевые концепции:
1) декоратор собирает middleware из одной функции, класс объединяет несколько хуков и настройку в __init__
2) хук-узел получает (state, runtime) и возвращает правку состояния словарём
3) хук-обёртка получает (request, handler) и решает, вызывать ли handler и сколько раз
4) атрибут state_schema у middleware добавляет поля в состояние агента: их передают на входе и читают в результате
5) прыжок jump_to из хука-узла выполняется по условному ребру графа, ребро задаёт объявленный can_jump_to
6) wrap_tool_call может вернуть готовый ToolMessage без запуска инструмента
7) обёртка вызова модели пишет в состояние, возвращая ExtendedModelResponse с Command, обёртка инструмента возвращает Command
8) атрибут trace_policy задаёт, что хук пишет в трассировку, вплоть до пустого входа и выхода
Когда каталога не хватает
Каталог из урока 15 закрывает задачи, общие для всех приложений: повтор, запасная модель, сжатие истории, лимиты. Задачи своего приложения, например правило тарифа в системном сообщении или строку аудита после ответа, решает свой middleware в тех же шести точках.
Общая сборка модели
Рядом с примерами положите course_model.py из прежних уроков.
"""Общая сборка модели для примеров урока 16.
Тот же модуль, что course_model.py прежних уроков, без изменений. У 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)
Декоратор или класс
Middleware пишется функцией с декоратором или классом от AgentMiddleware.
На каждую точку свой декоратор: @before_agent, @before_model, @after_model, @after_agent, @wrap_model_call, @wrap_tool_call. Есть ещё @dynamic_prompt из урока 5, надстройка над wrap_model_call для подмены системного промпта.
Декоратор строит класс на месте и берёт имя middleware из имени функции, отсюда имена узлов графа вида count_turns.before_model. Аргументы state_schema, tools и name есть у всех шести декораторов хуков, can_jump_to только у четырёх хуков-узлов: before_agent, before_model, after_model, after_agent.
Хук-узел принимает (state, runtime) и возвращает словарь правок или None. Хук-обёртка принимает (request, handler) и возвращает ответ, который уйдёт дальше по цепочке.
Пример 1 собирает из функций два первых своих middleware. count_turns срабатывает перед вызовом модели и печатает его номер, time_tool оборачивает вызов инструмента и замеряет время. Декоратор превращает каждую функцию в middleware, и оба передаются в create_agent списком middleware.
Пример 01_decorators.py
"""Пример 1 урока 16: свой middleware двумя декораторами.
Хук-узел before_model считает вызовы модели и печатает, сколько сообщений
истории она получает. Хук-обёртка wrap_tool_call охватывает вызов инструмента:
замеряет время и печатает имя вместе с аргументами.
Обе функции становятся middleware и уходят в create_agent обычным
списком middleware.
"""
import time
from collections.abc import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import (
AgentState,
ToolCallRequest,
before_model,
wrap_tool_call,
)
from langchain.messages import ToolMessage
from langgraph.runtime import Runtime
from langgraph.types import Command
from course_model import build_model
MODULES = {
"/src/reports": 7305,
"/src/billing": 2140,
"/src/auth": 860,
}
turns = {"count": 0}
def module_size(path: str) -> str:
"""Возвращает число строк модуля репозитория по пути."""
lines = MODULES.get(path)
if lines is None:
return f"модуля {path} в репозитории нет"
return f"{path}: {lines} строк"
@before_model
def count_turns(state: AgentState, runtime: Runtime) -> dict | None:
"""Перед вызовом модели печатает его номер и длину истории."""
turns["count"] += 1
print(
f"[before_model] вызов модели {turns['count']}, "
f"сообщений в истории: {len(state['messages'])}"
)
return None
@wrap_tool_call
def time_tool(
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Замеряет время вызова инструмента и печатает имя с аргументами."""
started = time.perf_counter()
result = handler(request)
spent_ms = (time.perf_counter() - started) * 1000
print(
f"[wrap_tool_call] {request.tool_call['name']}"
f"({request.tool_call['args']}) за {spent_ms:.1f} мс"
)
return result
agent = create_agent(
model=build_model(temperature=0, max_tokens=256),
tools=[module_size],
system_prompt=(
"Вы помощник по разбору легаси. Размеры модулей узнавайте инструментом. "
"Отвечайте по-русски одной фразой."
),
middleware=[count_turns, time_tool],
)
result = agent.invoke(
{
"messages": [
{
"role": "user",
"content": "Что больше по числу строк, /src/reports или /src/auth?",
}
]
}
)
print()
print("имя middleware у декоратора:", count_turns.name, "и", time_tool.name)
print("ответ:", result["messages"][-1].text)
# Вывод:
# [before_model] вызов модели 1, сообщений в истории: 1
# [wrap_tool_call] module_size({'path': '/src/reports'}) за 0.4 мс
# [wrap_tool_call] module_size({'path': '/src/auth'}) за 0.3 мс
# [before_model] вызов модели 2, сообщений в истории: 4
#
# имя middleware у декоратора: count_turns и time_tool
# ответ: Модуль `/src/reports` (7305 строк) значительно больше модуля `/src/auth` (860 строк).
Класс от AgentMiddleware берут, когда декоратора мало:
1) несколько хуков делят данные в атрибутах self
2) настройка (пороги, имена, свои модели) приходит в __init__
3) нужны синхронный и асинхронный варианты одного хука, before_model и abefore_model
4) нужны атрибуты trace_policy или transformers, у декораторов таких аргументов нет
create_agent читает атрибуты класса при сборке графа: state_schema расширяет состояние, tools добавляет агенту инструменты (так устроен TodoListMiddleware из урока 15), trace_policy задаёт вид трассировки, transformers регистрирует преобразователи потока.
Шесть хуков в одном классе
Пример 2 показывает порядок срабатывания хуков и узлы собранного графа.
Пример 02_class_six_hooks.py
"""Пример 2 урока 16: один класс со всеми шестью хуками.
Middleware ничего не меняет, он только записывает в журнал каждое своё срабатывание.
По журналу видно, какие хуки срабатывают один раз за запуск, какие при каждом
вызове модели и как обёртки охватывают вызовы изнутри.
Внизу печатаются узлы собранного графа: хуки-узлы там есть, хуки-обёртки нет.
"""
from collections.abc import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import (
AgentMiddleware,
AgentState,
ModelRequest,
ModelResponse,
ToolCallRequest,
)
from langchain.messages import ToolMessage
from langgraph.runtime import Runtime
from langgraph.types import Command
from course_model import build_model
OWNERS = {"/src/reports": "нет", "/src/billing": "Ирина", "/src/auth": "Павел"}
def module_owner(path: str) -> str:
"""Возвращает владельца модуля репозитория по пути."""
return f"{path}: владелец {OWNERS.get(path, 'неизвестен')}"
class AuditMiddleware(AgentMiddleware):
"""Пишет строку журнала на каждое срабатывание своих хуков."""
def __init__(self) -> None:
super().__init__()
self.journal: list[str] = []
def before_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self.journal.append("before_agent")
return None
def before_model(self, state: AgentState, runtime: Runtime) -> dict | None:
self.journal.append(f"before_model, сообщений {len(state['messages'])}")
return None
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
self.journal.append(f"wrap_model_call: вход, инструментов {len(request.tools)}")
response = handler(request)
self.journal.append(f"wrap_model_call: выход, сообщений {len(response.result)}")
return response
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
name = request.tool_call["name"]
self.journal.append(f"wrap_tool_call: вход, {name}")
result = handler(request)
self.journal.append(f"wrap_tool_call: выход, {name}")
return result
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
last = state["messages"][-1]
calls = len(getattr(last, "tool_calls", []) or [])
self.journal.append(f"after_model, запрошено вызовов инструмента {calls}")
return None
def after_agent(self, state: AgentState, runtime: Runtime) -> dict | None:
self.journal.append("after_agent")
return None
audit = AuditMiddleware()
agent = create_agent(
model=build_model(temperature=0, max_tokens=256),
tools=[module_owner],
system_prompt=(
"Вы помощник по разбору легаси. Владельцев узнавайте инструментом. "
"Отвечайте по-русски одной фразой."
),
middleware=[audit],
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "Кто владелец модуля /src/billing?"}]}
)
print("ЖУРНАЛ MIDDLEWARE")
for number, line in enumerate(audit.journal, start=1):
print(f" {number:>2}. {line}")
print()
print("узлы собранного графа:")
for node in agent.get_graph().nodes:
print(f" {node}")
print()
print("ответ:", result["messages"][-1].text)
# Вывод:
# ЖУРНАЛ MIDDLEWARE
# 1. before_agent
# 2. before_model, сообщений 1
# 3. wrap_model_call: вход, инструментов 1
# 4. wrap_model_call: выход, сообщений 1
# 5. after_model, запрошено вызовов инструмента 1
# 6. wrap_tool_call: вход, module_owner
# 7. wrap_tool_call: выход, module_owner
# 8. before_model, сообщений 3
# 9. wrap_model_call: вход, инструментов 1
# 10. wrap_model_call: выход, сообщений 1
# 11. after_model, запрошено вызовов инструмента 0
# 12. after_agent
#
# узлы собранного графа:
# __start__
# model
# tools
# AuditMiddleware.before_agent
# AuditMiddleware.before_model
# AuditMiddleware.after_model
# AuditMiddleware.after_agent
# __end__
#
# ответ: Владелец модуля `/src/billing` — Ирина.
Строки wrap_tool_call стоят между after_model одного вызова модели и before_model следующего: инструмент работает между двумя вызовами модели.
Узлы в графе есть только у четырёх хуков-узлов, имя узла собрано из имени middleware и хука. Обёртки выполняются внутри узлов model и tools, поэтому в схеме графа их нет.
Асинхронные варианты называются с приставкой a: abefore_agent, abefore_model, aafter_model, aafter_agent, awrap_model_call, awrap_tool_call. create_agent считает хук написанным, если метод класса отличается от метода AgentMiddleware, и асинхронный вариант засчитывается за тот же хук.
Своё поле состояния
Счётчик в глобальном словаре из примера 1 один на весь процесс, пропадает при перезапуске и общий для всех threads. Без checkpointer поле состояния действует один вызов invoke: начальное значение приходит на входе, конечное возвращается в результате. С checkpointer оно своё у каждого thread и сохраняется вместе с сообщениями.
Своё поле объявляется подклассом AgentState в атрибуте state_schema. create_agent сливает схемы всех middleware с базовой, и поле middleware становится полем агента.
В уроке 11 поле добавлялось аргументом state_schema у create_agent. Поле, нужное одному middleware, удобнее объявить у него: middleware переносится в другой проект вместе с полем.
Пример 03_own_state.py
"""Пример 3 урока 16: своё поле состояния у middleware.
Middleware объявляет подкласс AgentState с двумя своими полями и ставит его
атрибутом state_schema. Поля хранятся в состоянии агента наравне с messages:
их можно передать при вызове, прочитать в хуке и увидеть в результате.
Печать показывает, какие поля попали в результат: поля jump_to среди них нет.
"""
from typing_extensions import NotRequired
from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, AgentState
from langgraph.runtime import Runtime
from course_model import build_model
TESTS = {"/src/reports": 0, "/src/billing": 41, "/src/auth": 12}
def module_tests(path: str) -> str:
"""Возвращает число тестов модуля репозитория по пути."""
count = TESTS.get(path)
if count is None:
return f"модуля {path} в репозитории нет"
return f"{path}: тестов {count}"
class BudgetState(AgentState):
"""Состояние агента плюс два счётчика middleware."""
model_calls: NotRequired[int]
tool_calls: NotRequired[int]
class BudgetMiddleware(AgentMiddleware[BudgetState]):
"""Считает вызовы модели и запрошенные ею вызовы инструментов."""
state_schema = BudgetState
def before_model(self, state: BudgetState, runtime: Runtime) -> dict | None:
print(
f"[before_model] сделано вызовов модели: {state.get('model_calls', 0)}, "
f"заказано вызовов инструмента: {state.get('tool_calls', 0)}"
)
return None
def after_model(self, state: BudgetState, runtime: Runtime) -> dict | None:
last = state["messages"][-1]
return {
"model_calls": state.get("model_calls", 0) + 1,
"tool_calls": state.get("tool_calls", 0)
+ len(getattr(last, "tool_calls", []) or []),
}
agent = create_agent(
model=build_model(temperature=0, max_tokens=256),
tools=[module_tests],
system_prompt=(
"Вы помощник по разбору легаси. Число тестов узнавайте инструментом, "
"по одному модулю за вызов. Отвечайте по-русски одной фразой."
),
middleware=[BudgetMiddleware()],
)
result = agent.invoke(
{
"messages": [
{
"role": "user",
"content": "Сколько тестов в /src/reports и сколько в /src/auth?",
}
],
"model_calls": 0,
"tool_calls": 0,
}
)
print()
print("поля результата:", ", ".join(sorted(result)))
print("вызовов модели:", result["model_calls"])
print("вызовов инструмента:", result["tool_calls"])
print("ответ:", result["messages"][-1].text)
# Вывод:
# [before_model] сделано вызовов модели: 0, заказано вызовов инструмента: 0
# [before_model] сделано вызовов модели: 1, заказано вызовов инструмента: 2
#
# поля результата: messages, model_calls, tool_calls
# вызовов модели: 2
# вызовов инструмента: 2
# ответ: В `/src/reports` тестов 0, а в `/src/auth` — 12.
# (формулировка ответа меняется от запуска к запуску, у читателя она может быть другой)
Начальное значение поля передаётся в том же словаре, что и messages, конечное читается из результата по имени.
У поля без редьюсера новое значение затирает старое, поэтому счётчик увеличивается от прежнего значения из состояния. Свой редьюсер ставится в Annotated, как add_messages у messages в уроке 11.
Поля jump_to в результате нет, хотя оно есть в базовом AgentState: оно помечено приватным и эфемерным, наружу не отдаётся и между шагами графа не сохраняется.
Прыжки по циклу
Хук-узел, вернув в словаре ключ jump_to, отправляет запуск в другую точку графа:
1) "end", к концу запуска. Если у агента есть хуки after_agent, прыжок ведёт к хуку последнего из таких middleware в списке. После него отработают хуки after_agent остальных, от конца списка к началу
2) "tools", к узлу инструментов
3) "model", к узлу модели. Если перед моделью стоят хуки before_model, прыжок ведёт к первому из них
Цели прыжка объявляются заранее. В классе метод хука помечается декоратором hook_config(can_jump_to=[...]), у декоратора хука для этого есть аргумент can_jump_to. При сборке графа узел с объявлением получает условные рёбра: к этим целям и к следующему узлу по умолчанию. Без объявления ребро из узла обычное и поле jump_to не читает.
Ошибки при этом нет: прыжок не выполняется, и запуск идёт дальше по обычному ребру. Одно ребро читает jump_to и без объявления. У агента с инструментами это ребро после хука after_model первого из middleware с этим хуком, поэтому прыжок из такого хука сработает и без пометки. Пример 4 запускает один и тот же middleware с пометкой и без неё и печатает рёбра графа.
Пример 04_jump.py
"""Пример 4 урока 16: прыжок к концу запуска и прыжок без объявления.
Middleware считает вызовы модели и после первого вызова возвращает
jump_to="end". Один и тот же код запускается дважды: в первом middleware хук
помечен hook_config(can_jump_to=["end"]), во втором пометки нет.
Пометка задаёт условное ребро в графе. Без неё узел соединён обычным ребром,
и jump_to из before_model никто не читает.
"""
from typing_extensions import NotRequired
from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, AgentState, hook_config
from langchain.messages import AIMessage
from langgraph.runtime import Runtime
from course_model import build_model
OWNERS = {
"/src/reports": "нет",
"/src/billing": "Ирина",
"/src/auth": "Павел",
"/src/gateway": "Сергей",
}
def module_owner(path: str) -> str:
"""Возвращает владельца модуля репозитория по пути."""
return f"{path}: владелец {OWNERS.get(path, 'неизвестен')}"
class TurnState(AgentState):
"""Состояние агента плюс счётчик вызовов модели."""
turns: NotRequired[int]
class TurnLimitMiddleware(AgentMiddleware[TurnState]):
"""Завершает запуск после заданного числа вызовов модели."""
state_schema = TurnState
def __init__(self, max_turns: int) -> None:
super().__init__()
self.max_turns = max_turns
def _verdict(self, state: TurnState) -> dict | None:
"""Общее тело хука: решение об остановке по числу сделанных вызовов модели."""
if state.get("turns", 0) >= self.max_turns:
print(f" [{self.name}] запрошен прыжок к концу запуска")
return {
"messages": [
AIMessage("Лимит вызовов модели исчерпан, отвечаю тем, что успел собрать.")
],
"jump_to": "end",
}
return None
@hook_config(can_jump_to=["end"])
def before_model(self, state: TurnState, runtime: Runtime) -> dict | None:
return self._verdict(state)
def after_model(self, state: TurnState, runtime: Runtime) -> dict | None:
return {"turns": state.get("turns", 0) + 1}
class SilentTurnLimitMiddleware(TurnLimitMiddleware):
"""Тот же middleware, у которого хук не объявил, что умеет прыгать."""
def before_model(self, state: TurnState, runtime: Runtime) -> dict | None:
return self._verdict(state)
QUESTION = (
"Назовите владельцев модулей /src/reports, /src/billing, /src/auth и "
"/src/gateway."
)
model = build_model(temperature=0, max_tokens=256)
def run(label: str, layer: AgentMiddleware) -> None:
"""Запускает агента с одним middleware и печатает, чем кончился запуск."""
agent = create_agent(
model=model,
tools=[module_owner],
system_prompt=(
"Вы помощник по разбору легаси. Владельцев узнавайте инструментом. "
"Отвечайте по-русски одной фразой."
),
middleware=[layer],
)
print(label)
for edge in agent.get_graph().edges:
if edge.source.endswith(".before_model"):
kind = "условное" if edge.conditional else "обычное"
print(f" ребро {kind}: {edge.source} -> {edge.target}")
result = agent.invoke({"messages": [{"role": "user", "content": QUESTION}], "turns": 0})
ai = sum(1 for message in result["messages"] if message.type == "ai")
tools = sum(1 for message in result["messages"] if message.type == "tool")
print(f" записано вызовов модели: {result['turns']}")
print(f" сообщений AI: {ai}, ответов инструмента: {tools}")
print(f" последнее сообщение: {result['messages'][-1].text}")
print()
run("MIDDLEWARE С hook_config(can_jump_to=['end'])", TurnLimitMiddleware(max_turns=1))
run("ТОТ ЖЕ MIDDLEWARE БЕЗ ПОМЕТКИ", SilentTurnLimitMiddleware(max_turns=1))
# Вывод:
# MIDDLEWARE С hook_config(can_jump_to=['end'])
# ребро условное: TurnLimitMiddleware.before_model -> __end__
# ребро условное: TurnLimitMiddleware.before_model -> model
# [TurnLimitMiddleware] запрошен прыжок к концу запуска
# записано вызовов модели: 1
# сообщений AI: 2, ответов инструмента: 4
# последнее сообщение: Лимит вызовов модели исчерпан, отвечаю тем, что успел собрать.
#
# ТОТ ЖЕ MIDDLEWARE БЕЗ ПОМЕТКИ
# ребро обычное: SilentTurnLimitMiddleware.before_model -> model
# [SilentTurnLimitMiddleware] запрошен прыжок к концу запуска
# записано вызовов модели: 2
# сообщений AI: 3, ответов инструмента: 4
# последнее сообщение: # У владельцев модулей
#
# Модули:
# - `/src/billing` — владелец **Ирина**
# - `/src/auth` — владелец **Павел**
# - `/src/gateway` — владелец **Сергей**
# - `/src/reports` — владелец не указан
# (последнее сообщение второго запуска пишет модель, у вас текст будет другим)
С пометкой из узла before_model выходят два условных ребра, к модели и к концу. Без пометки ребро одно, обычное, к модели: другого выхода из узла нет.
Сообщение, возвращённое вместе с jump_to: "end", попадает в историю в обоих запусках. С пометкой оно становится последним ответом агента. Без пометки модель получает его в истории и отвечает после него, поэтому во втором запуске три сообщения AI.
Ответ без запуска инструмента
Запрос ToolCallRequest в хуке wrap_tool_call содержит поля tool_call, tool, state и runtime. tool_call это словарь вызова от модели с ключами name, args и id. tool хранит объект инструмента или None, если такого инструмента у агента нет. Изменённую копию запроса возвращает метод override, как у ModelRequest в уроке 14.
Обёртка вызывает handler один раз для обычного вызова, несколько раз для повтора, с изменённым запросом для подмены аргументов. Может не вызывать вовсе и вернуть свой ToolMessage: так устроены LLMToolEmulator из урока 15 и кеш из примера 5.
Свой ToolMessage содержит tool_call_id из запроса: по этому идентификатору ответ связывается с вызовом в истории.
Пример 05_tool_cache.py
"""Пример 5 урока 16: обёртка вокруг инструмента, отвечающая без запуска.
Хук wrap_tool_call решает, вызывать ли handler. Если такой вызов уже был,
middleware возвращает готовый ToolMessage и тело инструмента не выполняется.
Агент вызывается дважды с одним и тем же вопросом. Счётчик внутри инструмента
показывает, сколько раз тело инструмента выполнилось на самом деле.
"""
import json
from collections.abc import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import ToolCallRequest, wrap_tool_call
from langchain.messages import ToolMessage
from langgraph.types import Command
from course_model import build_model
OWNERS = {"/src/reports": "нет", "/src/billing": "Ирина", "/src/auth": "Павел"}
runs = {"count": 0}
CACHE: dict[tuple[str, str], str] = {}
def module_owner(path: str) -> str:
"""Возвращает владельца модуля репозитория по пути."""
runs["count"] += 1
return f"{path}: владелец {OWNERS.get(path, 'неизвестен')}"
@wrap_tool_call
def cache_tool_calls(
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
"""Отдаёт прежний ответ инструмента, если вызов повторяется дословно."""
key = (
request.tool_call["name"],
json.dumps(request.tool_call["args"], sort_keys=True, ensure_ascii=False),
)
if key in CACHE:
print(f"[кеш] {key[0]}{key[1]}: ответ из кеша, инструмент не вызывается")
return ToolMessage(
content=CACHE[key],
tool_call_id=request.tool_call["id"],
name=request.tool_call["name"],
)
result = handler(request)
if isinstance(result, ToolMessage) and result.status == "success":
CACHE[key] = result.text
return result
agent = create_agent(
model=build_model(temperature=0, max_tokens=256),
tools=[module_owner],
system_prompt=(
"Вы помощник по разбору легаси. Владельцев узнавайте инструментом. "
"Отвечайте по-русски одной фразой."
),
middleware=[cache_tool_calls],
)
QUESTION = "Кто владелец модуля /src/billing?"
for number in (1, 2):
print(f"ВЫЗОВ АГЕНТА {number}")
result = agent.invoke({"messages": [{"role": "user", "content": QUESTION}]})
print(f" запусков тела инструмента всего: {runs['count']}")
print(f" записей в кеше: {len(CACHE)}")
print(f" ответ: {result['messages'][-1].text}")
print()
# Вывод:
# ВЫЗОВ АГЕНТА 1
# запусков тела инструмента всего: 1
# записей в кеше: 1
# ответ: Владелец модуля /src/billing — Ирина.
#
# ВЫЗОВ АГЕНТА 2
# [кеш] module_owner{"path": "/src/billing"}: ответ из кеша, инструмент не вызывается
# запусков тела инструмента всего: 1
# записей в кеше: 1
# ответ: Владелец модуля /src/billing — Ирина.
В рабочем приложении словарь процесса заменяют Redis или таблицей. Аргументы в ключе сортируются (sort_keys=True), иначе тот же вызов с другим порядком ключей не найдётся в кеше.
Кешируется только успешный ответ, иначе вызов, упавший из-за сети, возвращался бы из кеша до конца процесса.
Свой контекст и запись в состояние из обёртки
Хук-обёртка возвращает ответ модели или инструмента, словарь правок вернуть ему некуда. Для записи в состояние wrap_model_call возвращает ExtendedModelResponse с полями model_response (ответ модели) и command (Command с правкой состояния). wrap_tool_call возвращает Command напрямую.
Команды из wrap_model_call проходят редьюсеры графа: сообщения из команды добавляются к ответу модели. Если одно поле за вызов модели пишут два middleware, полю нужен редьюсер, иначе запуск падает с InvalidUpdateError. С редьюсером команды применяются изнутри в наружу, последней идёт команда внешнего middleware. Когда внешний middleware повторяет вызов, команды прошлых попыток отбрасываются. Переход goto в такой команде не поддерживается, запуск падает с NotImplementedError: прыгают хуки-узлы ключом jump_to.
Конфигурацию запуска (пользователь, тариф, роль) middleware читает из request.runtime.context в обёртках и из runtime.context в хуках-узлах. Схема объявляется у агента аргументом context_schema, значение передаётся при вызове аргументом context, как в уроке 10.
Параметр класса AgentMiddleware[UsageState, SupportContext] даёт только подсказку типов, схему контекста он не регистрирует.
В документации раздел Custom context, где middleware объявляет свою схему контекста, есть только для JavaScript. В пакете для Python context_schema есть только у create_agent.
Пример 06_context_and_command.py
"""Пример 6 урока 16: свой контекст и правка состояния из обёртки.
Middleware читает конфигурацию запуска из request.runtime.context и дописывает
к системному сообщению правило тарифа. После ответа модели он считает
потраченные токены и записывает их в состояние, возвращая ExtendedModelResponse
с Command: у хука-обёртки других способов записать в состояние нет.
Тип контекста объявлен параметром класса: AgentMiddleware[UsageState,
SupportContext]. Сам класс контекста уходит в create_agent аргументом
context_schema, а значение приходит при вызове аргументом context.
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing_extensions import NotRequired
from langchain.agents import create_agent
from langchain.agents.middleware import (
AgentMiddleware,
AgentState,
ExtendedModelResponse,
ModelRequest,
ModelResponse,
)
from langchain.messages import SystemMessage
from langgraph.types import Command
from course_model import build_model
OWNERS = {"/src/reports": "нет", "/src/billing": "Ирина", "/src/auth": "Павел"}
PLAN_RULES = {
"free": "Тариф free: отвечайте одним предложением, без пояснений.",
"pro": "Тариф pro: после ответа добавьте абзац с рекомендацией, что делать дальше.",
}
def module_owner(path: str) -> str:
"""Возвращает владельца модуля репозитория по пути."""
return f"{path}: владелец {OWNERS.get(path, 'неизвестен')}"
@dataclass
class SupportContext:
"""Конфигурация запуска: кто спрашивает и по какому тарифу."""
user_id: str
plan: str
class UsageState(AgentState):
"""Состояние агента плюс расход токенов, накопленный middleware."""
spent_tokens: NotRequired[int]
class PlanMiddleware(AgentMiddleware[UsageState, SupportContext]):
"""Правило тарифа в системном сообщении и счёт токенов в состоянии."""
state_schema = UsageState
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ExtendedModelResponse:
plan = request.runtime.context.plan
blocks = list(request.system_message.content_blocks) if request.system_message else []
blocks.append({"type": "text", "text": PLAN_RULES[plan]})
response = handler(request.override(system_message=SystemMessage(content=blocks)))
usage = getattr(response.result[-1], "usage_metadata", None) or {}
spent = request.state.get("spent_tokens", 0) + usage.get("total_tokens", 0)
return ExtendedModelResponse(
model_response=response,
command=Command(update={"spent_tokens": spent}),
)
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[module_owner],
system_prompt=(
"Вы помощник по разбору легаси. Владельцев узнавайте инструментом. "
"Отвечайте по-русски."
),
middleware=[PlanMiddleware()],
context_schema=SupportContext,
)
QUESTION = "Кто владелец модуля /src/reports?"
for plan in ("free", "pro"):
result = agent.invoke(
{"messages": [{"role": "user", "content": QUESTION}]},
context=SupportContext(user_id="u-101", plan=plan),
)
print(f"ТАРИФ {plan.upper()}")
print(f" токенов за запуск: {result['spent_tokens']}")
print(f" ответ: {result['messages'][-1].text}")
print()
# Вывод:
# ТАРИФ FREE
# токенов за запуск: 812
# ответ: Владелец модуля /src/reports не назначен.
#
# ТАРИФ PRO
# токенов за запуск: 888
# ответ: Владелец модуля `/src/reports` не назначен (не указан в системе).
#
# **Рекомендация:** Обратитесь к тимлиду команды разработки или в отдел управления конфигурациями, чтобы уточнить актуального ответственного за этот модуль, и при необходимости зарегистрировать владельца в системе.
# (числа и текст рекомендации у читателя будут свои)
Системное сообщение собирается из блоков. Строковый system_prompt агент превращает в SystemMessage, а у агента без промпта request.system_message равен None, поэтому пример проверяет его перед чтением. Новый блок дописывается к списку, и блоки других middleware сохраняются.
Кеширование промпта своим middleware
Тем же приёмом включается кеш промпта у моделей Anthropic: к последнему текстовому блоку системного сообщения добавляется ключ cache_control со значением {"type": "ephemeral"}. Начало запроса до этой точки провайдер кеширует, и следующий запрос с тем же началом стоит дешевле.
Запускаемого примера нет: у DeepSeek, модели курса, кеш префикса включён по умолчанию, это разбиралось в уроке 1. Сколько входных токенов пришло из кеша, показывает ключ cache_read в usage_metadata["input_token_details"]. Расходы разбираются в уроке 24 (ещё не вышел).
Что middleware показывает в трассировке
Каждый хук middleware пишется в трассировку отдельным участком со входом и выходом. По умолчанию во вход попадает всё, что хук получил, включая всю историю сообщений. Для счётчика вызовов она лишняя и занимает место при каждом вызове модели.
Атрибут trace_policy принимает TracePolicy с функциями process_inputs и process_outputs. Функция получает записываемое значение и возвращает то, что попадёт в трассировку. Готовая omit_payload возвращает пустой словарь, участок и его время остаются.
# Иллюстрация: код не запускается отдельным файлом.
from langchain.agents.middleware import AgentMiddleware, TracePolicy, omit_payload
class QuietAudit(AgentMiddleware):
"""Участок в трассировке остаётся, история сообщений в него не попадает."""
trace_policy = TracePolicy(process_inputs=omit_payload)
def after_model(self, state, runtime):
return None
Общую политику для всех middleware ставит функция configure_trace_policy при старте приложения, аргумент None её снимает. Своя политика middleware перекрывает общую целиком, без слияния по полям.
Своя функция может заменить значение, например текст его длиной. Секреты этим средством не скрывают, для них есть средства сервиса трассировки. Результат виден только при включённой трассировке, её разбор в уроке 21 (ещё не вышел).
Свои преобразователи потока
Атрибут transformers перечисляет фабрики преобразователей потока событий. Агент добавляет их при сборке графа, и проекция middleware появляется в stream.extensions без правки вызывающего кода. Контракт StreamTransformer, регистрация в middleware (пример 7) и полный порядок регистрации с флагом before_builtins разобраны в уроке 8. Преобразователи middleware встают в порядке списка после ToolCallTransformer и преобразователя субагентов и перед переданными в create_agent аргументом transformers.
Распространённые ошибки
Фрагменты этого раздела не самостоятельные файлы: импорты опущены, model и module_owner взяты из примеров выше. AuditMiddleware в ошибке 1 принимает метку tag, а свойство name появляется только в исправленном варианте.
Ошибка 1: два экземпляра своего middleware в одном списке
# Неправильно: оба экземпляра называются AuditMiddleware
agent = create_agent(
model=model,
tools=[module_owner],
middleware=[AuditMiddleware("вход"), AuditMiddleware("выход")],
)
Что происходит: сборка падает с AssertionError: Please remove duplicate middleware instances., хотя экземпляры настроены по-разному.
Почему так: middleware различаются по имени, а имя по умолчанию берётся из имени класса. Исправляется свойством name, как у встроенных ToolCallLimitMiddleware и PIIMiddleware.
# Правильно: имя включает отличающий параметр
class AuditMiddleware(AgentMiddleware):
def __init__(self, tag):
super().__init__()
self.tag = tag
@property
def name(self):
return f"{self.__class__.__name__}[{self.tag}]"
Ошибка 2: хук-узел объявлен с одним аргументом
# Неправильно: у функции нет второго параметра
@before_model
def only_state(state):
return None
Что происходит: агент собирается, а запуск падает с TypeError: only_state() takes 1 positional argument but 2 were given.
Почему так: хук-узел всегда вызывается с двумя позиционными аргументами, даже когда runtime не нужен.
Ошибка 3: обёртка не вернула результат handler
# Неправильно: результат получен и потерян
@wrap_model_call
def log_call(request, handler):
response = handler(request)
print(f"сообщений в ответе: {len(response.result)}")
Что происходит: запуск падает с AttributeError: 'NoneType' object has no attribute 'result', и по сообщению причину не найти.
Почему так: функция без return возвращает None, и он уходит внешнему middleware и узлу вместо ответа модели.
Практическое задание
Соберите middleware AuditMiddleware, который ведёт журнал работы агента и ограничивает число вызовов инструментов.
Требования:
1) класс от AgentMiddleware со своей схемой состояния: поля model_calls, tool_calls и audit_log со списком строк
2) у audit_log свой редьюсер в Annotated, складывающий списки, чтобы записи копились
3) after_model считает вызовы модели и заказанные ею вызовы инструментов, как в примере 3
4) wrap_tool_call пишет в журнал имя инструмента, аргументы и длину ответа и возвращает Command с ответом инструмента и записью журнала
5) before_agent пишет в журнал начало запуска, after_agent итог: сколько было вызовов модели и инструментов
6) before_model с объявленным can_jump_to=["end"] завершает запуск, когда tool_calls достиг max_tool_calls, и кладёт в историю сообщение об исчерпанном бюджете
7) max_tool_calls приходит в __init__, свойство name включает его значение
8) конфигурация запуска с полем user_id в context_schema, идентификатор попадает в каждую запись журнала
Как проверить результат:
1) на вопросе про два модуля при max_tool_calls=4 запуск завершается сам, записи audit_log идут в порядке срабатывания хуков
2) на вопросе про шесть модулей при том же max_tool_calls=4 срабатывает ограничение, последнее сообщение истории говорит про бюджет. Проверка стоит перед вызовом модели, поэтому вызовы, которые модель уже заказала одним сообщением, выполняются все, и tool_calls может оказаться больше max_tool_calls
3) уберите hook_config у before_model и повторите второй запуск: агент дойдёт до ответа модели, как во втором запуске примера 4
4) временно уберите свойство name и поставьте в список два экземпляра с разными max_tool_calls: сборка падает с AssertionError, со свойством проходит. Запускать такого агента не нужно: внешняя обёртка получит от handler объект Command внутренней обёртки, и длину ответа из него не посчитать
Подсказка: редьюсер пишется функцией из двух аргументов, старого значения и нового, и ставится как Annotated[list[str], ваша_функция].
Итоги урока
1) свой middleware пишется функцией с декоратором или классом от AgentMiddleware. Класс нужен для хуков с общими данными в self, настройки в __init__, синхронного и асинхронного вариантов одного хука и атрибутов trace_policy и transformers
2) хук-узел возвращает словарь правок состояния, хук-обёртка решает, вызывать ли handler, сколько раз и с каким запросом
3) своё поле состояния объявляется подклассом AgentState в state_schema: его передают на входе, читают в хуках и получают в результате
4) прыжок jump_to выполняется по условному ребру после хука, такое ребро даёт объявление can_jump_to. Есть ли оно, видно по рёбрам из get_graph(), как в примере 4
5) обёртка пишет в состояние, возвращая ExtendedModelResponse с Command или сам Command, а свой ToolMessage из wrap_tool_call даёт ответ без запуска инструмента
6) что хук пишет в трассировку, задаёт TracePolicy
В уроке 17, "Подтверждение человеком и guardrails", запуск будет останавливаться и ждать ответа человека, а свой middleware встанет между моделью и опасным действием.
Код урока
Примеры этого урока лежат в репозитории курса, папка lesson_16. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.
Предыдущий урок: Встроенные middleware: точки встраивания и порядок
Следующий урок: Подтверждение человеком и guardrails
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru