Встроенные middleware: точки встраивания и порядок | Курс LangChain урок 15
Цель урока: собрать агента из готовых middleware, понимая, в какую точку цикла встраивается каждый, и ставить их в списке в нужном порядке.
Необходимые знания:
1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены
2) урок 9: декоратор @tool, цикл вызова инструментов, обычная функция как инструмент
3) урок 11: create_agent, узлы model и tools, middleware как единица настройки, рамка из шести категорий, ModelCallLimitMiddleware
4) урок 12: SummarizationMiddleware и цена сжатия истории
5) урок 14: три типа контекста и динамический набор инструментов
6) Python на уровне джуниора: замыкания, словари, работа с исключениями
Ключевые концепции:
1) точек встраивания шесть: before_agent, before_model, wrap_model_call, wrap_tool_call, after_model, after_agent
2) хуки before_* и after_* становятся узлами графа, хуки wrap_* оборачивают вызов внутри уже существующего узла
3) middleware может не встраиваться в цикл и только приносить агенту инструменты
4) порядок в списке middleware задаёт поведение: before_* идут сверху вниз, after_* снизу вверх, wrap_* вкладываются друг в друга, и первый middleware оказывается самым внешним
5) ToolErrorMiddleware показывает модели ошибку инструмента вместо падения агента
6) ToolRetryMiddleware повторяет упавший вызов и ставится внутри обработчика ошибок
7) ModelFallbackMiddleware подставляет запасную модель, когда основная не ответила
8) в каталоге встроенных middleware есть готовое для отказоустойчивости, контекста, лимитов, планирования и guardrails
Зачем брать готовый middleware
Разберу, в какую точку цикла встраивается готовый middleware и что меняется от перестановки middleware в списке.
В готовом middleware уже отлажены детали: пауза между повторами со случайным разбросом, безопасная граница сжатия истории, отдельный путь для синхронного и асинхронного вызова. Свой middleware пишут, когда задачи нет в каталоге, это урок 16.
Шесть точек встраивания
Хук-узел выполняется в своей точке цикла и возвращает правку состояния: before_agent и after_agent один раз за запуск агента, before_model и after_model при каждом вызове модели.
Хук-обёртка получает запрос и решает, когда и сколько раз вызвать то, что внутри, и что вернуть наружу. Это wrap_model_call вокруг вызова модели и wrap_tool_call вокруг вызова инструмента.
Хук-узел работает до или после вызова: before_model может поменять вход или завершить запуск, а к моменту after_model модель уже ответила и токены оплачены. Обёртка может повторить вызов, подменить модель, перехватить исключение или ответить без вызова.
Фабрика агента находит хуки middleware, сравнивая его методы с методами AgentMiddleware. Пример 1 тем же приёмом печатает карту хуков и модель не вызывает.
Рядом с примерами положите course_model.py из прежних уроков.
"""Общая сборка модели для примеров урока 15.
Тот же модуль, что 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)
Пример 01_hook_map.py
"""Пример 1 урока 15: карта точек встраивания у встроенных middleware.
Модель здесь не вызывается. Пример спрашивает у самих классов, какие хуки они
переопределяют, и печатает это таблицей.
Приём взят из сборки агента: фабрика решает, подключать middleware к точке или нет,
сравнением метода класса с методом базового AgentMiddleware. Асинхронный
вариант хука считается тем же хуком, поэтому проверяются оба имени.
"""
from pathlib import Path
from langchain.agents.middleware import (
AgentMiddleware,
ContextEditingMiddleware,
FilesystemFileSearchMiddleware,
HumanInTheLoopMiddleware,
LLMToolEmulator,
LLMToolSelectorMiddleware,
ModelCallLimitMiddleware,
ModelFallbackMiddleware,
ModelRetryMiddleware,
PIIMiddleware,
SummarizationMiddleware,
TodoListMiddleware,
ToolCallLimitMiddleware,
ToolErrorMiddleware,
ToolRetryMiddleware,
)
# Шесть точек встраивания, по колонке таблицы на каждую.
HOOKS = (
"before_agent",
"before_model",
"wrap_model_call",
"wrap_tool_call",
"after_model",
"after_agent",
)
SHORT = {
"before_agent": "b_ag",
"before_model": "b_md",
"wrap_model_call": "w_md",
"wrap_tool_call": "w_tl",
"after_model": "a_md",
"after_agent": "a_ag",
}
LAYERS = (
ToolErrorMiddleware,
ToolRetryMiddleware,
ModelRetryMiddleware,
ModelFallbackMiddleware,
SummarizationMiddleware,
ContextEditingMiddleware,
ModelCallLimitMiddleware,
ToolCallLimitMiddleware,
TodoListMiddleware,
LLMToolSelectorMiddleware,
LLMToolEmulator,
FilesystemFileSearchMiddleware,
PIIMiddleware,
HumanInTheLoopMiddleware,
)
def implements(layer, hook):
"""Переопределяет ли middleware этот хук, в синхронном или асинхронном виде."""
async_hook = "a" + hook
sync_own = getattr(layer, hook) is not getattr(AgentMiddleware, hook)
async_own = getattr(layer, async_hook) is not getattr(AgentMiddleware, async_hook)
return sync_own or async_own
header = "".join(f"{SHORT[hook]:>6}" for hook in HOOKS)
print(f"{'middleware':<32}{header}")
for layer in LAYERS:
marks = "".join(f"{'X' if implements(layer, hook) else '.':>6}" for hook in HOOKS)
print(f"{layer.__name__:<32}{marks}")
print()
print("узел графа появляется только у before_* и after_*,")
print("хуки wrap_* оборачивают вызов внутри уже существующих узлов")
# Вторая работа middleware: принести агенту свои инструменты. Это свойство экземпляра,
# а не класса, поэтому здесь middleware создаются.
print()
print("инструменты, которые middleware добавляет агенту:")
WITH_TOOLS = (
TodoListMiddleware(),
FilesystemFileSearchMiddleware(root_path=str(Path(__file__).parent)),
ToolRetryMiddleware(),
)
for layer in WITH_TOOLS:
names = [tool.name for tool in layer.tools] or ["нет"]
print(f" {layer.name:<32}{', '.join(names)}")
# Вывод:
# middleware b_ag b_md w_md w_tl a_md a_ag
# ToolErrorMiddleware . . . X . .
# ToolRetryMiddleware . . . X . .
# ModelRetryMiddleware . . X . . .
# ModelFallbackMiddleware . . X . . .
# SummarizationMiddleware . X . . . .
# ContextEditingMiddleware . . X . . .
# ModelCallLimitMiddleware . X . . X .
# ToolCallLimitMiddleware . . . . X .
# TodoListMiddleware . . X . X .
# LLMToolSelectorMiddleware . . X . . .
# LLMToolEmulator . . . X . .
# FilesystemFileSearchMiddleware . . . . . .
# PIIMiddleware . X . . X .
# HumanInTheLoopMiddleware . . . X X .
#
# узел графа появляется только у before_* и after_*,
# хуки wrap_* оборачивают вызов внутри уже существующих узлов
#
# инструменты, которые middleware добавляет агенту:
# TodoListMiddleware write_todos
# FilesystemFileSearchMiddleware glob_search, grep_search
# ToolRetryMiddleware нет
По карте видно: повтор и запасная модель работают обёртками вокруг вызова, сжатие истории готовит вход в before_model, лимиты считают по ответу модели в after_model.
У FilesystemFileSearchMiddleware нет ни одной точки: он только приносит инструменты, они во втором блоке вывода.
Узел графа появляется только у хука-узла, с именем вида ИмяКласса.before_model. Обёртки выполняются внутри узлов model и tools, поэтому на схеме графа из урока 11 их не видно.
Порядок в списке решает
Порядок в списке middleware работает так:
1) хуки before_* выполняются в порядке списка, сверху вниз
2) хуки after_* выполняются в обратном порядке, снизу вверх
3) хуки wrap_* вкладываются друг в друга, и первый middleware списка оказывается самым внешним
Это касается обёрток: для внешнего middleware всё, что делает внутренний, выглядит одним вызовом. Если внутренний поймал исключение и вернул сообщение об ошибке, до внешнего исключение не дойдёт.
Поэтому middleware, который гасит ошибку, ставится снаружи, а тот, что повторяет вызов, внутри.
Ошибка инструмента: уронить агента или показать модели
По умолчанию узел tools отдаёт модели на исправление только ошибки разбора аргументов. Исключение, брошенное самим инструментом, роняет агента.
Падение базы данных лучше увидеть исключением, чем пересказом модели. Но с частью отказов модель справится сама: не тот идентификатор, пустой период, год вне архива.
Такие отказы ToolErrorMiddleware превращает в сообщение для модели. Его обработчик получает исключение и запрос: вернул текст, и модель получает сообщение об ошибке, вернул None, и исключение идёт дальше, как без middleware. Так в модель попадают только выбранные вами исключения.
Пример 02_tool_error.py
"""Пример 2 урока 15: падение инструмента с одним middleware и без него.
Инструмент читает учётную книгу и падает на годах, которых нет в архиве.
Первый запуск идёт без middleware, второй с ToolErrorMiddleware.
Middleware не чинит инструмент. Он превращает исключение в сообщение об ошибке,
которое получает модель, и решение остаётся за ней.
"""
from langchain.agents import create_agent
from langchain.agents.middleware import ToolErrorMiddleware
from course_model import build_model
def read_ledger(year: int) -> str:
"""Читает учётную книгу склада за указанный год."""
if year < 2019:
raise ValueError(f"архив за {year} год не оцифрован")
return f"Книга за {year} год: 412 приходных записей, 87 списаний."
def on_error(exc, request):
"""Что получит модель вместо исключения. None означает "пропустить дальше"."""
if isinstance(exc, ValueError):
name = request.tool_call["name"]
return (
f"Инструмент {name} завершился с ошибкой {type(exc).__name__}: "
f"такого года в архиве нет. Самый ранний оцифрованный год, это 2019."
)
# Остальные исключения идут дальше и роняют агента.
return None
SYSTEM_PROMPT = (
"Вы архивариус склада. Отвечайте по-русски, двумя-тремя фразами. "
"Если инструмент сообщил об ошибке, поступайте по тому, что в ней написано."
)
QUESTION = "Сколько списаний было в 2017 году?"
model = build_model(temperature=0, max_tokens=384)
plain = create_agent(model=model, tools=[read_ledger], system_prompt=SYSTEM_PROMPT)
guarded = create_agent(
model=model,
tools=[read_ledger],
system_prompt=SYSTEM_PROMPT,
middleware=[ToolErrorMiddleware(on_error)],
)
print("ЗАПУСК БЕЗ MIDDLEWARE")
try:
result = plain.invoke({"messages": [{"role": "user", "content": QUESTION}]})
except Exception as exc: # noqa: BLE001 - пример печатает тип падения
print(f" запуск упал: {type(exc).__name__}: {exc}")
else:
print(" запуск дошёл до конца:", result["messages"][-1].text)
print()
print("ЗАПУСК С MIDDLEWARE")
result = guarded.invoke({"messages": [{"role": "user", "content": QUESTION}]})
for message in result["messages"]:
if message.type == "tool":
print(f" {message.name}: status={message.status}")
print(" ответ:", result["messages"][-1].text.replace("\n", " "))
# Вывод:
# ЗАПУСК БЕЗ MIDDLEWARE
# запуск упал: ValueError: архив за 2017 год не оцифрован
#
# ЗАПУСК С MIDDLEWARE
# read_ledger: status=error
# ответ: В архиве нет данных за 2017 год — самый ранний оцифрованный год, это 2019.
Обработчик в примере отдаёт модели тип исключения и подсказку, а не текст исключения: в тексте бывает путь к файлу или кусок запроса.
Сам ToolErrorMiddleware ничего не повторяет, повтором занимается ToolRetryMiddleware.
Повтор и обработка вместе: порядок решает
ToolRetryMiddleware вызывает упавший инструмент заново, пока не кончатся попытки. Что повторять и чем закончить, задают параметры:
1) max_retries, число повторов после первой попытки, по умолчанию два, то есть три запуска всего
2) retry_on, кортеж типов исключений или функция, которая по исключению решает, повторять ли вызов. По умолчанию повторяется любое исключение, кроме ModelError, у которого is_retryable равен False
3) on_failure, что делать, когда попытки кончились: "continue" вернёт модели сообщение об ошибке, "error" поднимет исключение, своя функция вернёт свой текст
4) tools, список инструментов или их имён, к которым middleware применяется. Без него middleware действует на все инструменты
Паузу задают initial_delay (по умолчанию секунда) и backoff_factor (множитель роста, два, ноль даёт постоянную паузу). Потолок ставит max_delay (минута), случайный разброс в четверть в обе стороны даёт jitter.
С обработчиком ошибок повтор ставится внутрь, с on_failure="error", чтобы исключение после последней попытки дошло до обработчика. Какая позиция в списке значит "внутрь", показывает пример 3: инструмент в нём всегда падает и считает свои запуски.
Пример 03_order.py
"""Пример 3 урока 15: тот же набор middleware в двух порядках.
Повтор упавшего инструмента и обработка его ошибки. Middleware те же, разница
только в порядке внутри списка middleware.
Инструмент падает всегда и считает свои запуски. По отношению числа запусков
к числу вызовов, которые сделала модель, видно, отработали повторы или нет.
"""
from langchain.agents import create_agent
from langchain.agents.middleware import ToolErrorMiddleware, ToolRetryMiddleware
from course_model import build_model
attempts = {"count": 0}
def fetch_index(path: str) -> str:
"""Загружает индекс модулей репозитория по пути."""
attempts["count"] += 1
raise ConnectionError(f"хранилище не ответило на запрос {path}")
def on_error(exc, request):
"""Превращает отказ хранилища в сообщение для модели."""
if isinstance(exc, ConnectionError):
return (
f"Инструмент {request.tool_call['name']} недоступен "
f"({type(exc).__name__}). Повторять вызов не нужно, сообщите об "
f"этом пользователю."
)
return None
def retry_layer():
"""Повтор с короткой паузой, чтобы пример отработал за секунды."""
return ToolRetryMiddleware(
max_retries=2,
initial_delay=0.2,
backoff_factor=1.0,
jitter=False,
on_failure="error",
)
SYSTEM_PROMPT = (
"Вы помощник по разбору легаси. Отвечайте по-русски одной фразой. "
"Если инструмент сообщил об ошибке, не повторяйте вызов."
)
QUESTION = "Загрузите индекс модулей по пути /src и скажите, что получилось."
model = build_model(temperature=0, max_tokens=256)
def run(label, middleware):
"""Собирает агента с заданным порядком middleware, отправляет вопрос и печатает строку отчёта."""
attempts["count"] = 0
agent = create_agent(
model=model,
tools=[fetch_index],
system_prompt=SYSTEM_PROMPT,
middleware=middleware,
)
result = agent.invoke({"messages": [{"role": "user", "content": QUESTION}]})
calls = sum(1 for message in result["messages"] if message.type == "tool")
per_call = attempts["count"] / calls if calls else 0
print(f"{label:<24}{calls:>8}{attempts['count']:>10}{per_call:>12.1f}")
return result
print(f"{'порядок middleware':<24}{'вызовов':>8}{'запусков':>10}{'на вызов':>12}")
first = run("повтор, обработка", [retry_layer(), ToolErrorMiddleware(on_error)])
second = run("обработка, повтор", [ToolErrorMiddleware(on_error), retry_layer()])
print()
print("ответ при порядке 'повтор, обработка':", first["messages"][-1].text)
print("ответ при порядке 'обработка, повтор':", second["messages"][-1].text)
# Вывод:
# порядок middleware вызовов запусков на вызов
# повтор, обработка 1 1 1.0
# обработка, повтор 1 3 3.0
#
# ответ при порядке 'повтор, обработка': Инструмент сообщил об ошибке подключения — не удалось загрузить индекс модулей по пути `/src`.
# ответ при порядке 'обработка, повтор': Инструмент не смог загрузить индекс модулей по пути /src из-за ошибки подключения (ConnectionError).
Ответ в колонке "на вызов": в одном порядке инструмент запущен один раз, и повторов нет, хотя middleware повторов подключён. В другом трижды: первая попытка и два повтора.
Когда повтор стоит первым, он внешний: обработчик внутри гасит исключение, и повторять нечего. Когда первым стоит обработчик, повтор внутри исчерпывает попытки, а исключение ловит обработчик.
Документация LangChain тут противоречит себе. Страница о собственных middleware и код фабрики говорят, что первый в списке оборачивает остальные. А раздел об ошибке инструмента на странице встроенных middleware считает внутренним тот, что раньше в списке, и ставит повтор первым. Пример 3 подтверждает код фабрики.
Порядок middleware проверяйте счётчиком запусков инструмента, как в примере 3: сборка с неработающими повторами предупреждений не выдаёт.
Когда падает не инструмент, а модель
При временном отказе модели (сетевой сбой, превышение частоты запросов, ответ шлюза с кодом 5xx) помогает повтор. ModelRetryMiddleware принимает те же параметры, что ToolRetryMiddleware, кроме tools. Когда попытки кончились, по умолчанию он возвращает AIMessage с текстом исключения, и пользователь получает его вместо ответа. Свой текст задаёт функция в on_failure, а on_failure="error" поднимет исключение.
При устойчивом отказе, когда модель снята с обслуживания или перегружена, нужна другая модель. ModelFallbackMiddleware перебирает запасные по очереди, пока одна не ответит, а если не ответила ни одна, поднимает последнее исключение.
Строку с именем запасной модели middleware отдаёт в init_chat_model без адреса и ключа, поэтому при своём адресе провайдера, как в курсе, передавайте объект модели.
Пример 04_model_fallback.py
"""Пример 4 урока 15: запасная модель вместо упавшей основной.
Основная модель заведомо сломана: имя модели такое, которого у провайдера нет.
Первый запуск показывает, чем это кончается без middleware, второй, что делает
ModelFallbackMiddleware.
Запасная модель передаётся готовым объектом, а не строкой: строку middleware отдаёт
в init_chat_model сам, без адреса и ключа шлюза курса.
"""
from langchain.agents import create_agent
from langchain.agents.middleware import ModelFallbackMiddleware
from course_model import build_model
SYSTEM_PROMPT = "Вы помощник разработчика. Отвечайте по-русски одной фразой."
QUESTION = "Назовите две причины, по которым вызов модели может упасть."
broken = build_model("no-such-model-in-this-gateway", temperature=0, max_tokens=256)
spare = build_model(temperature=0, max_tokens=256)
plain = create_agent(model=broken, tools=[], system_prompt=SYSTEM_PROMPT)
with_fallback = create_agent(
model=broken,
tools=[],
system_prompt=SYSTEM_PROMPT,
middleware=[ModelFallbackMiddleware(spare)],
)
def answered_by(message):
"""Имя модели из ответа. Провайдер может его не прислать."""
return message.response_metadata.get("model_name", "провайдер не сообщил")
print("ЗАПУСК БЕЗ MIDDLEWARE")
try:
result = plain.invoke({"messages": [{"role": "user", "content": QUESTION}]})
except Exception as exc: # noqa: BLE001 - пример печатает тип падения
print(f" запуск упал: {type(exc).__name__}")
else:
print(" запуск дошёл до конца:", result["messages"][-1].text)
print()
print("ЗАПУСК С MIDDLEWARE")
result = with_fallback.invoke({"messages": [{"role": "user", "content": QUESTION}]})
answer = result["messages"][-1]
print(" отвечала модель:", answered_by(answer))
print(" ответ:", answer.text.replace("\n", " "))
# Вывод:
# ЗАПУСК БЕЗ MIDDLEWARE
# запуск упал: OpenAIInvalidRequestError
#
# ЗАПУСК С MIDDLEWARE
# отвечала модель: deepseek/deepseek-v4-flash
# ответ: Вызов модели может упасть из-за сбоя в работе серверной инфраструктуры или превышения лимита запросов.
Запасная модель отвечает иначе: у неё свой стиль, свои возможности и цена. Если она слабее основной, качество ответов на время аварии упадёт, а в журнале этого не видно.
Список задач: планирование в состоянии агента
Без списка модель держит многошаговую задачу, только пока история короткая: потом один шаг делает дважды, а другой пропускает.
TodoListMiddleware добавляет инструмент write_todos и дописывает к вашему системному промпту кусок о нём. Список лежит в поле todos состояния, у пункта текст и статус pending, in_progress или completed. Вызов заменяет список целиком, поэтому два вызова write_todos в одном ответе модели отклоняются оба, и модель получает сообщение об ошибке.
Пример 05_todo_list.py
"""Пример 5 урока 15: список задач, который агент ведёт сам.
TodoListMiddleware добавляет агенту инструмент write_todos и кусок системного
промпта про то, как им пользоваться. Поле todos хранится в состоянии агента.
Задача требует пройти по всем модулям репозитория и для каждого узнать три
вещи по отдельности, а потом свести это в один вывод. Шаги внутри модуля
зависят друг от друга: владельца и тесты спрашивают про уже найденный модуль.
Модулей несколько, поэтому такую задачу удобнее вести по списку.
"""
from langchain.agents import create_agent
from langchain.agents.middleware import TodoListMiddleware
from course_model import build_model
MODULES = {
"billing": {"lines": 4120, "owner": "Северов", "tests": 12},
"auth": {"lines": 890, "owner": "Липатова", "tests": 41},
"reports": {"lines": 7305, "owner": "владелец не назначен", "tests": 0},
}
def list_modules() -> str:
"""Возвращает список модулей репозитория."""
return ", ".join(sorted(MODULES))
def module_size(name: str) -> str:
"""Возвращает число строк кода в модуле."""
if name not in MODULES:
return f"Модуля {name} в репозитории нет."
return f"{name}: {MODULES[name]['lines']} строк."
def module_owner(name: str) -> str:
"""Возвращает владельца модуля и число тестов на него."""
if name not in MODULES:
return f"Модуля {name} в репозитории нет."
info = MODULES[name]
return f"{name}: владелец {info['owner']}, тестов {info['tests']}."
SYSTEM_PROMPT = (
"Вы разбираете чужой репозиторий. Отвечайте по-русски. "
"Работу над многошаговой задачей начинайте с плана."
)
QUESTION = (
"Проверьте все модули репозитория по очереди: для каждого узнайте размер, "
"владельца и число тестов. Отдельно выпишите модули без владельца и "
"модули, где тестов меньше десяти. В конце скажите, какой модуль чинить "
"первым и почему."
)
agent = create_agent(
model=build_model(temperature=0, max_tokens=768),
tools=[list_modules, module_size, module_owner],
system_prompt=SYSTEM_PROMPT,
middleware=[TodoListMiddleware()],
)
result = agent.invoke({"messages": [{"role": "user", "content": QUESTION}]})
tool_calls = [
call["name"]
for message in result["messages"]
if message.type == "ai"
for call in message.tool_calls
]
print("вызовы инструментов:", ", ".join(tool_calls) or "их не было")
print()
print("список задач в конце запуска:")
for item in result.get("todos") or []:
print(f" [{item['status']:<11}] {item['content']}")
print()
print("ответ:", result["messages"][-1].text.replace("\n", " "))
# Вывод:
# вызовы инструментов: list_modules, module_size, module_size, module_size,
# module_owner, module_owner, module_owner
#
# список задач в конце запуска:
#
# ответ: Все данные собраны. Подведу итоги. --- ## Сводка по модулям | Модуль
# | Размер (строк) | Владелец | Тестов | ... ## Какой модуль чинить первым и почему
# **Первым нужно чинить модуль `reports`.** Причины: 1. **Нет владельца** ...
# 2. **Нет тестов (0)** ... Таким образом, `reports` — самый уязвимый модуль ...
# [вывод подрезан: ответ модели идёт таблицей и разбором на три десятка строк]
Заводить ли план, решает модель: во встроенном промпте сказано, что на простую задачу в несколько шагов список не нужен.
В этом запуске write_todos не вызван и список пуст, но все три модуля пройдены и вывод собран. Просьба в системном промпте начинать с плана модель не обязывает: если план нужен всегда, требуйте его прямо и проверяйте запуском.
Бывает и наоборот: план заведён, ответ полный, а пункты остались в in_progress и pending. Статусы ставит модель, и с фактом они могут расходиться, поэтому судите о запуске по ответу и вызовам.
Поле todos видно снаружи: в потоке событий, в checkpoints из урока 12, в интерфейсе, где пользователь следит за долгой задачей.
Поиск по файлам: middleware без единого хука
FilesystemFileSearchMiddleware в цикл не встраивается и приносит два инструмента: glob_search ищет файлы по образцу имени, grep_search по содержимому регулярным выражением. Оба работают только внутри каталога root_path.
Образец со слэшем в начале или с .. отбрасывается, а каждое совпадение проверяется, не вывела ли ссылка за корень. Пути модель получает относительно корня, настоящее расположение каталога ей не передаётся.
Без установленного ripgrep содержимое ищут регулярные выражения Python, а use_ripgrep=False включает этот путь всегда.
Пример 06_file_search.py
"""Пример 6 урока 15: поиск по файлам без единого своего инструмента.
FilesystemFileSearchMiddleware вовсе не встраивается в цикл. Он приносит
агенту два инструмента, glob_search и grep_search, и ограничивает их корнем,
за который они не выходят.
Корень здесь, это папка с примерами урока, то есть агент ищет по тем файлам,
которые вы только что запускали. Поиск идёт средствами Python: так результат
не зависит от того, установлен у вас ripgrep или нет.
"""
from pathlib import Path
from langchain.agents import create_agent
from langchain.agents.middleware import FilesystemFileSearchMiddleware
from course_model import build_model
ROOT = Path(__file__).parent
SYSTEM_PROMPT = (
"Вы разбираете незнакомый репозиторий. Пользуйтесь поиском по файлам, "
"а не догадками. Отвечайте по-русски, двумя-тремя фразами."
)
QUESTION = (
"Сколько в репозитории файлов с расширением .py и в каких из них "
"встречается ToolRetryMiddleware?"
)
agent = create_agent(
model=build_model(temperature=0, max_tokens=512),
tools=[],
system_prompt=SYSTEM_PROMPT,
middleware=[
FilesystemFileSearchMiddleware(root_path=str(ROOT), use_ripgrep=False),
],
)
result = agent.invoke({"messages": [{"role": "user", "content": QUESTION}]})
print("что агент спрашивал у поиска:")
for message in result["messages"]:
if message.type == "ai":
for call in message.tool_calls:
print(f" {call['name']}({call['args']})")
print()
print("ответ:", result["messages"][-1].text.replace("\n", " "))
# Вывод:
# что агент спрашивал у поиска:
# glob_search({'pattern': '**/*.py'})
# grep_search({'pattern': 'ToolRetryMiddleware', 'include': '*.py'})
#
# ответ: В репозитории 7 файлов с расширением `.py`: `course_model.py`, `05_todo_list.py`, `06_file_search.py`, `04_model_fallback.py`, `03_order.py`, `02_tool_error.py`, `01_hook_map.py`. Из них `ToolRetryMiddleware` встречается в двух файлах: `01_hook_map.py` и `06_file_search.py`.
glob_search отвечает на вопрос "что здесь есть", grep_search на вопрос "где это встречается". Режим вывода grep_search (files_with_matches, content или count) определяет, сколько текста попадёт в контекст.
Каталог: остальные middleware
Остальные middleware пакета langchain:
| Middleware | Что делает | Когда брать |
|---|---|---|
SummarizationMiddleware |
заменяет старую часть истории сводкой при достижении порога | длинные сессии, разобран в уроке 12 |
ContextEditingMiddleware с ClearToolUsesEdit |
вычищает старые результаты инструментов, оставляя последние keep штук |
много вызовов инструментов с длинными ответами, разобран в уроке 14 |
ModelCallLimitMiddleware |
потолок вызовов модели на запуск и на thread, exit_behavior задаёт, завершить запуск или поднять исключение |
бюджет на запуск, разобран в уроке 11 |
ToolCallLimitMiddleware |
потолок вызовов инструментов, на все сразу или на один, имя задаёт tool_name |
дорогой внешний вызов, который агент делает по многу раз |
ModelRetryMiddleware |
повторяет упавший вызов модели с растущей паузой | временные отказы провайдера |
LLMToolSelectorMiddleware |
перед каждым вызовом модели отбирает подходящие инструменты по последнему сообщению пользователя. Отбор стоит отдельного вызова модели, по умолчанию той же, что у агента. Параметры max_tools и always_include |
десяток инструментов и больше, из которых на запрос нужны два |
ProviderToolSearchMiddleware |
инструменты из searchable_tools или с пометкой extras={"defer_loading": True} модель находит серверным поиском провайдера по мере надобности |
много инструментов и модель, у которой этот поиск есть |
LLMToolEmulator |
вместо настоящего вызова получает от модели правдоподобный ответ инструмента | разработка, когда внешний сервис недоступен или дорог |
ShellToolMiddleware |
даёт агенту постоянную сессию терминала, политика исполнения выбирается из HostExecutionPolicy, DockerExecutionPolicy и CodexSandboxExecutionPolicy |
запуск команд, сборка, тесты |
PIIMiddleware |
находит персональные данные и прячет их выбранной стратегией | всё, что идёт от живых пользователей, разбирается в уроке 17 |
HumanInTheLoopMiddleware |
прерывает запуск перед вызовом инструмента и ждёт решения человека | необратимые действия, разбирается в уроке 17 |
Политика ShellToolMiddleware по умолчанию запускает команды прямо на машине с правами пользователя процесса, без ограничений по файлам и сети. Изоляцию дают CodexSandboxExecutionPolicy, она ограничивает файловую систему и системные вызовы, и DockerExecutionPolicy, она запускает терминал в отдельном контейнере. Агент выполнит любую команду, которую придумает модель, поэтому политику выбирайте осознанно.
В пакете deepagents, отдельной сборке поверх create_agent, есть ещё FilesystemMiddleware, SubAgentMiddleware и RubricMiddleware, последний в состоянии беты. В курсе этот пакет не ставится, делегирование отдельному агенту будет темой урока 19, он ещё не вышел.
Распространённые ошибки
Код этого раздела не запускается: search_db, model и on_error в нём заглушки, импорты опущены.
Ошибка 1: middleware повторов подключён, а повторов нет
# Неправильно: гасящий middleware оказался внутри повторяющего
agent = create_agent(
model=model,
tools=[search_db],
middleware=[
ToolRetryMiddleware(max_retries=3, on_failure="error"),
ToolErrorMiddleware(on_error),
],
)
Что происходит: инструмент запускается один раз, без повторов и без предупреждений. Внутренний обработчик гасит исключение, и внешнему middleware повторять нечего.
# Правильно: гасящий middleware снаружи, повторяющий внутри
agent = create_agent(
model=model,
tools=[search_db],
middleware=[
ToolErrorMiddleware(on_error),
ToolRetryMiddleware(max_retries=3, on_failure="error"),
],
)
Ошибка 2: от ToolErrorMiddleware ждут перехвата ошибок в аргументах
# Неправильно: обработчик ждёт ошибку разбора аргументов
def on_error(exc, request):
if isinstance(exc, ValidationError):
return "Аргументы не подошли, проверьте типы."
return None
Что происходит: условие с ValidationError не срабатывает. Ошибки разбора аргументов узел tools отдаёт модели до запуска функции, до middleware доходят только исключения из тела инструмента.
# Правильно: обработчик разбирает исключения своего кода
def on_error(exc, request):
if isinstance(exc, (ConnectionError, TimeoutError)):
return f"Источник недоступен ({type(exc).__name__}), попробуйте позже."
return None
Практическое задание
Соберите агента archeolog.py, который разбирает незнакомый каталог с кодом и не падает от первой ошибки.
Требования:
1) поиск по файлам с FilesystemFileSearchMiddleware: корень в каталоге из аргумента командной строки, use_ripgrep выключен
2) свой инструмент module_report(path), который возвращает число строк файла и изображает ненадёжное хранилище: первый запуск на каждый файл бросает ConnectionError. Со второго запуска на файле длиннее тысячи строк он бросает ValueError: файл слишком велик для разбора целиком
3) ToolErrorMiddleware: ValueError превращается в подсказку пользоваться grep_search, ConnectionError в сообщение, что хранилище недоступно и повторять вызов не нужно
4) ToolRetryMiddleware с max_retries=2, retry_on=(ConnectionError,) и on_failure="error", поставленный так, чтобы повторы выполнялись
5) TodoListMiddleware и системный промпт с требованием начинать с плана
6) после запуска агента печатайте три блока: список задач с итоговыми статусами, имена вызванных инструментов по порядку и ответ пользователю
Как проверить результат:
1) на вопросе "какие модули здесь самые большие и что в них происходит" агент доходит до ответа по всем крупным файлам каталога. Список задач бывает и пустым: план модель заводит по своему решению
2) добавьте в каталог файл длиннее тысячи строк. В истории появится сообщение со статусом error и подсказкой про grep_search, посмотрите, вызвал ли его агент. module_report на этом файле запустится дважды: сбой хранилища и ValueError, которого нет в retry_on
3) поменяйте местами middleware из пунктов 3 и 4 и посчитайте запуски module_report счётчиком, как в примере 3. В правильном порядке два запуска на файл: маленький даёт число строк, большой подсказку про grep_search. В обратном один запуск, и на любой файл приходит сообщение о недоступном хранилище
4) если каталог пустой, агент отвечает, что искать нечего, и не падает
Итоги урока
1) готовый middleware встраивается в одну или в несколько из шести точек, а бывает, что ни в одну. Какие точки у него есть, показывает карта хуков из примера 1
2) порядок в списке меняет поведение. Обработчик ошибок ставится первым, повтор после него. Проверить порядок можно счётчиком запусков инструмента
3) для отказоустойчивости есть четыре middleware: ToolErrorMiddleware, ToolRetryMiddleware, ModelRetryMiddleware и ModelFallbackMiddleware
4) план задачи ведёт TodoListMiddleware, поиск по файлам даёт FilesystemFileSearchMiddleware
Своих задач в каталоге не найти. Например, дописать правило тарифа к системному сообщению, записать строку в журнал после ответа или передать разговор человеку, если в нём есть слово "увольнение". Такие middleware пишут сами. Это тема урока 16, "Свой middleware": декораторы и классы, все шесть хуков, прыжки jump_to, своё поле состояния и трассировка.
Код урока
Примеры этого урока лежат в репозитории курса, папка lesson_15. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.
Предыдущий урок: Context engineering: чем управлять в контексте агента
Следующий урок: Свой middleware
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru