Context engineering: чем управлять в контексте агента | Курс LangChain урок 14
Цель урока: научиться находить часть запроса, которая заняла контекстное окно, и менять именно её: инструкцию, набор инструментов, ответ инструмента или старые результаты в истории.
Необходимые знания:
1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены
2) урок 5: системный промпт агента, декоратор @dynamic_prompt, цена длинного промпта
3) урок 9: декоратор @tool, цикл вызова инструментов
4) урок 10: ToolRuntime, context и context_schema, запись в состояние с помощью Command
5) урок 11: create_agent, узлы model и tools, состояние агента и state_schema
6) урок 12: checkpointer и thread_id, обрезка и суммаризация истории
7) Python на уровне джуниора: dataclass, Counter, генераторы списков
Ключевые концепции:
1) агент ломается по одной из двух причин: модель не тянет задачу или ей не передали нужный контекст, и вторая встречается чаще
2) контекст разложен на три типа: контекст модели, контекст инструментов, контекст жизненного цикла
3) данные приходят из трёх источников: конфигурация запуска, состояние, хранилище
4) правка контекста модели временная, а правка состояния из хуков жизненного цикла и инструментов постоянная
5) всё, что уходит в модель при вызове, собрано в одном объекте ModelRequest
6) схемы инструментов оплачиваются на каждом вызове модели, поэтому набор сужают под ситуацию
7) результат инструмента попадает в историю и уходит в модель, а то, что инструмент записал в отдельное поле состояния, модель не получает
8) ContextEditingMiddleware после порога в токенах заменяет старые результаты инструментов заглушкой, и эта правка действует на один вызов
Почему агент ломается
На прототипе агент работает, а в настоящей задаче часто ошибается. Обычно причина одна из двух. Либо модель не тянет задачу сама по себе, либо в неё не передали нужный контекст. Вторая встречается чаще.
Context engineering, это передача нужной информации и нужных инструментов в нужном виде, чтобы модель справилась с задачей. Это главная работа инженера, который делает приложения с моделями.
Звучит общо, пока не вспомните механику из уроков 5 и 11. Между вызовами у модели ничего не сохраняется. Перед каждым вызовом агент собирает запрос заново: инструкцию, всю историю сообщений, описания всех инструментов, требование к формату ответа. В модель уходит только этот запрос. Чего в нём нет, того модель не получает, а лишнее занимает окно и добавляет ошибок.
Значит, вопрос "почему агент ответил неверно" сводится к проверке: что физически было в запросе на том вызове, где появилась ошибка. Первый пример урока печатает состав каждого запроса к модели, по строке на вызов. В строке стоят оценка в токенах для инструкции и для сообщений по типам, число инструментов и входные токены по счёту провайдера.
Три типа контекста и три источника данных
Контекст делится на три типа по месту в цикле агента.
| Тип контекста | Чем вы управляете | Правка временная или постоянная |
|---|---|---|
| Контекст модели | что уходит в вызов модели: инструкции, история сообщений, инструменты, формат ответа | временная |
| Контекст инструментов | что инструменты читают из состояния, хранилища и конфигурации запуска и что пишут в состояние и хранилище | постоянная |
| Контекст жизненного цикла | что происходит между вызовом модели и вызовом инструмента: суммаризация, guardrails, журналирование | постоянная |
На разнице между временной и постоянной правкой построен весь урок. Временная правка меняет то, что получает модель в одном вызове, и не меняет того, что записано в состоянии. Постоянная правка меняет само состояние и действует на всех следующих шагах.
Второе деление идёт по источнику данных, и все источники встречались в прошлых уроках.
| Источник | В документации | Другое название | Область действия | Что там лежит |
|---|---|---|---|---|
| Конфигурация запуска | Runtime Context | статическая конфигурация | один запуск | идентификатор пользователя, ключи API, соединения с базой, права, настройки окружения |
| Состояние | State | память сессии | один разговор | текущие сообщения, загруженные файлы, статус авторизации, результаты инструментов |
| Хранилище | Store | долговременная память | между разговорами | предпочтения пользователя, выводы, извлечённые факты, данные прошлых разговоров |
В таблице документации область действия конфигурации запуска указана как у состояния: Conversation-scoped, один разговор. Обычно приложение передаёт в каждый вызов одного разговора одни и те же данные собеседника. Но в thread конфигурация запуска не сохраняется, и context нужно передавать в каждый вызов invoke. В вызове без него runtime.context равен None. Поэтому в таблице урока стоит один запуск.
Конфигурацию запуска вы передавали аргументом context по схеме context_schema в уроке 10, состояние разбирали в уроках 11 и 12, хранилище в уроках 10 и 13. Этот урок показывает, что данные из любого источника могут попасть в любую часть контекста модели.
Механизм для этого тоже знакомый, это middleware. Он встраивается в любой шаг цикла агента и на этом шаге может поменять контекст. В уроке 11 middleware был единицей настройки, а в этом уроке я разберу, какие части контекста в нём менять.
Общая сборка модели
Модуль сборки модели тот же, что в уроках 4-13. Положите его рядом с примерами под именем course_model.py, и дальше каждый пример подключает его одной строкой.
"""Общая сборка модели для примеров урока 14.
Тот же модуль, что course_model.py уроков 4-13, без изменений: у 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)
Из чего собран запрос
Контекст модели делится на пять составляющих: системный промпт, сообщения, инструменты, выбор модели с её настройками и формат ответа. Модель под вызов вы уже подменяли в уроке 4 методом override. Все пять лежат в одном объекте ModelRequest. Вы печатали этот объект в уроке 5: middleware с хуком wrap_model_call получает его первым аргументом.
Объект встретится во всех примерах урока, кроме четвёртого. Его поля те же, что в таблице урока 5: model, messages, system_message, tools, response_format, tool_choice, model_settings, а рядом state и runtime. В state лежит состояние, в runtime.context конфигурация запуска, в runtime.store хранилище, если его передали агенту. Так из middleware доступны все три источника данных.
Список messages не содержит системного сообщения, оно лежит отдельным полем system_message. Вы видели это в уроках 5 и 11: системное сообщение не хранится в состоянии и добавляется к истории при вызове модели.
Первый пример подключает к агенту middleware, который разбирает запрос перед каждым вызовом модели. Строку таблицы он печатает после ответа модели, чтобы рядом стоял настоящий расход по счёту провайдера. Задача требует нескольких вызовов инструментов: по номеру заказа ищется город, а по городу срок доставки и регламент приёмки.
Пример 01_breakdown.py
"""Пример 1 урока 14: из чего собран запрос к модели и сколько весит каждая часть.
Middleware печатает строку на каждый вызов модели: сколько сообщений уходит,
во что оценены системное сообщение и сообщения каждого типа, сколько отправлено
инструментов и какой вход насчитал провайдер.
Задача выбрана так, чтобы агент вызвал инструменты несколько раз, и в таблице
стало видно, какая именно часть запроса растёт от вызова к вызову.
"""
from collections import Counter
from itertools import count
from typing import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call
from langchain_core.messages.utils import count_tokens_approximately
from course_model import build_model
ORDERS = {
"Z-771": {"city": "Казань", "status": "укомплектован"},
"Z-772": {"city": "Омск", "status": "ждёт оплаты"},
}
DELIVERY = {"Казань": 2, "Омск": 5}
RULES = (
"Регламент приёмки груза на складе города {city}.\n"
"1. Машина встаёт под разгрузку по электронной очереди, талон берут на "
"въезде, время в талоне сдвигать нельзя.\n"
"2. Паллеты принимают по счёту мест и по весу брутто, расхождение больше "
"двух процентов фиксируют актом в тот же день.\n"
"3. Упаковку вскрывают только при видимом повреждении обрешётки, вскрытие "
"снимают на камеру и прикладывают файл к акту.\n"
"4. Температурный груз замеряют дважды: в момент открытия дверей и через "
"пятнадцать минут после начала разгрузки.\n"
"5. Брак раскладывают по трём коробам: бой, подмочка, пересорт. Короб "
"опечатывают и передают на карантинную зону до конца смены.\n"
"6. Документы возвращают водителю с отметкой о приёмке, копию подшивают в "
"журнал склада и дублируют в учётную систему до 20:00 того же дня.\n"
"7. Расхождение по количеству больше пяти мест останавливает приёмку: "
"вызывают старшего смены и представителя перевозчика.\n"
)
def find_order(order_id: str) -> str:
"""Находит заказ по номеру вида Z-771 и возвращает город и статус."""
order = ORDERS.get(order_id)
if order is None:
return f"Заказ {order_id} не найден."
return f"Заказ {order_id}: город {order['city']}, статус {order['status']}."
def delivery_days(city: str) -> str:
"""Сообщает срок доставки в днях до указанного города."""
days = DELIVERY.get(city)
if days is None:
return f"Город {city} не обслуживается."
return f"Доставка до города {city} занимает {days} дн."
def acceptance_rules(city: str) -> str:
"""Возвращает полный регламент приёмки груза для склада города."""
return RULES.format(city=city)
step = count(1)
sent_tools = []
@wrap_model_call
def breakdown(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
"""Считает состав запроса до вызова модели, печатает его с расходом после ответа."""
sent_tools[:] = [tool.name for tool in request.tools]
prompt = (
count_tokens_approximately([request.system_message])
if request.system_message
else 0
)
by_type = Counter()
for message in request.messages:
by_type[message.type] += count_tokens_approximately([message])
estimate = prompt + sum(by_type.values())
response = handler(request)
# Расход возвращает не каждый провайдер, поэтому пустой словарь как запасной путь.
usage = response.result[0].usage_metadata or {}
print(
f"{next(step):>5} {len(request.messages):>9} {prompt:>6} "
f"{by_type['human']:>6} {by_type['ai']:>4} {by_type['tool']:>6} "
f"{len(request.tools):>6} {estimate:>6} {usage.get('input_tokens', 0):>5}"
)
return response
agent = create_agent(
model=build_model(temperature=0, max_tokens=1024),
tools=[find_order, delivery_days, acceptance_rules],
system_prompt=(
"Вы помощник логистического отдела. Отвечайте по-русски. Номер заказа "
"всегда проверяйте инструментом, по памяти не отвечайте. Если в ответе "
"нужен срок доставки, берите его инструментом для города из заказа. "
"Правила приёмки пересказывайте своими словами, целиком регламент не "
"цитируйте."
),
middleware=[breakdown],
)
print(
f"{'вызов':>5} {'сообщений':>9} {'промпт':>6} {'human':>6} {'ai':>4} "
f"{'tool':>6} {'инстр':>6} {'оценка':>6} {'вход':>5}"
)
result = agent.invoke(
{
"messages": [
{
"role": "user",
"content": (
"Заказ Z-771: когда он приедет и что учесть при приёмке?"
),
}
]
}
)
print()
print("ИНСТРУМЕНТЫ В ЗАПРОСЕ:", ", ".join(sent_tools))
print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
# Вывод:
# вызов сообщений промпт human ai tool инстр оценка вход
# 1 1 76 18 0 0 3 94 507
# 2 3 76 18 45 26 3 165 596
# 3 6 76 18 129 291 3 514 1031
#
# ИНСТРУМЕНТЫ В ЗАПРОСЕ: find_order, delivery_days, acceptance_rules
# ОТВЕТ: Вот вся информация по заказу Z-771: **📦 Статус и доставка:** - Заказ
# **Z-771** направлен в **Казань**, статус — **укомплектован**. - Срок доставки до
# Казани — **2 дня**. **📋 Что учесть при приёмке (основные моменты):** 1.
# **Электронная очередь** — машина встаёт под разгрузку по талону, который берут на
# въезде. Время в талоне сдвигать нельзя. ... 7. **Важно:** если расхождение по
# количеству превышает **5 мест** — приёмка останавливается, вызывают старшего смены
# и представителя перевозчика.
# [вывод подрезан: между пунктами 1 и 7 идут ещё пять пунктов регламента (паллеты,
# вскрытие упаковки, температурный груз, брак, документы) и завершающая фраза
# "Если нужны уточнения — обращайтесь!"]
Колонка промпт от вызова к вызову не меняется: инструкция одна и та же, и оплачивается она на каждом вызове заново. В колонке tool числа появляются после первого вызова инструментов и дальше только растут, потому что результаты инструментов остаются в истории.
Колонка оценка считается функцией count_tokens_approximately, и в примере ей передаются только сообщения. Схемы инструментов функция тоже считает, если передать их параметром tools, здесь его нет. вход это счёт провайдера. Разрыв между колонками оценка и вход складывается из схем инструментов, служебных полей запроса и погрешности оценки, разобранной в уроке 1.
Такой middleware полезно держать в проекте и подключать, когда агент стал дорогим или начал ошибаться. Он только печатает состав запроса, поведение агента от него не меняется.
Инструкция, собранная под собеседника
Первая из пяти составляющих, это системный промпт. Разным пользователям, ситуациям и стадиям разговора нужны разные инструкции.
Механику вы знаете из урока 5: декоратор @dynamic_prompt заменяет системное сообщение целиком перед каждым вызовом модели. Данные для инструкции можно брать из состояния, хранилища и конфигурации запуска. Пример ниже берёт два из них, конфигурацию запуска и состояние, и на каждом вызове выбирает ветку инструкции по их значениям. В статическом промпте такого выбора нет: он одинаков для всех собеседников и всех вызовов.
Правило отбора: в инструкцию попадает то, что верно прямо сейчас и чего не вернёт ни один инструмент. Права собеседника приходят из вашего приложения, инструмента для них у модели нет, поэтому они идут в промпт. Остаток на складе возвращает инструмент, поэтому в промпт он не идёт.
Пример 02_dynamic_prompt.py
"""Пример 2 урока 14: инструкция собирается под собеседника и под ход разговора.
Один и тот же агент отвечает на один и тот же вопрос трижды. Меняются два
источника: конфигурация запуска (кто спрашивает) и состояние разговора (сколько
сообщений уже накопилось). Промпт печатается перед каждым ответом, чтобы было
видно, что именно ушло в модель.
"""
from dataclasses import dataclass
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, dynamic_prompt
from course_model import build_model
QUESTION = "Где сейчас заказ Z-771 и когда он приедет?"
HISTORY = [
{"role": "user", "content": "Добрый день, нужна помощь по заказам."},
{"role": "assistant", "content": "Здравствуйте. Назовите номер заказа."},
{"role": "user", "content": "Сначала общий вопрос: вы работаете в выходные?"},
{"role": "assistant", "content": "Склад принимает и выдаёт груз по субботам до 15:00."},
{"role": "user", "content": "Понял, спасибо."},
{"role": "assistant", "content": "Рад помочь. Что-то ещё?"},
]
@dataclass
class Session:
"""Конфигурация запуска: кто пришёл и из какого города."""
role: str
city: str
def find_order(order_id: str) -> str:
"""Находит заказ по номеру вида Z-771 и возвращает город, статус и срок."""
if order_id != "Z-771":
return f"Заказ {order_id} не найден."
return (
"Заказ Z-771: город Казань, статус укомплектован, ячейка хранения B-14, "
"перевозчик СДЭК, срок доставки 2 дн."
)
@dynamic_prompt
def prompt_for_session(request: ModelRequest) -> str:
"""Собирает системный промпт из конфигурации запуска и длины разговора."""
session = request.runtime.context
parts = ["Вы помощник службы доставки. Отвечайте по-русски."]
if session.role == "оператор":
parts.append(
"Собеседник, это оператор склада: называйте ячейку хранения и "
"перевозчика."
)
else:
parts.append(
"Собеседник, это клиент: внутренние коды ячеек и имя перевозчика "
"не называйте."
)
parts.append(f"Город собеседника: {session.city}.")
if len(request.messages) > 4:
parts.append("Разговор длинный, отвечайте одной фразой.")
prompt = " ".join(parts)
print("ПРОМПТ:", prompt)
return prompt
agent = create_agent(
model=build_model(temperature=0, max_tokens=384),
tools=[find_order],
middleware=[prompt_for_session],
context_schema=Session,
)
RUNS = [
("клиент, начало разговора", [], Session(role="клиент", city="Казань")),
("оператор, начало разговора", [], Session(role="оператор", city="Казань")),
("оператор, разговор идёт давно", HISTORY, Session(role="оператор", city="Казань")),
]
for label, history, session in RUNS:
print()
print("ЗАПУСК:", label)
result = agent.invoke(
{"messages": [*history, {"role": "user", "content": QUESTION}]},
context=session,
)
print("ОТВЕТ: ", result["messages"][-1].text.replace("\n", " "))
# Вывод:
# (полный вывод длиннее десяти строк, второй запуск, "оператор, начало
# разговора", опущен: он устроен так же, как первый)
# ЗАПУСК: клиент, начало разговора
# ПРОМПТ: Вы помощник службы доставки. Отвечайте по-русски. Собеседник, это клиент: внутренние коды ячеек и имя перевозчика не называйте. Город собеседника: Казань.
# (строка ПРОМПТ печатается на каждый вызов модели, здесь их два: тем же текстом)
# ОТВЕТ: Ваш заказ Z-771 уже укомплектован и находится в Казани. Доставка займёт 2 дня.
#
# ЗАПУСК: оператор, разговор идёт давно
# ПРОМПТ: Вы помощник службы доставки. Отвечайте по-русски. Собеседник, это оператор склада: называйте ячейку хранения и перевозчика. Город собеседника: Казань. Разговор длинный, отвечайте одной фразой.
# (та же строка печатается ещё раз тем же текстом)
# ОТВЕТ: Заказ Z-771 в Казани, ячейка B-14, перевозчик СДЭК, доставка через 2 дня.
Инструмент во всех трёх запусках возвращает одно и то же, включая ячейку хранения и перевозчика. Запрет называть эти поля клиенту стоит только в инструкции, поэтому ограничение нестрогое: модель может их назвать. Если цена утечки высока, лишние поля убирают в самом инструменте по роли из runtime.context или проверяют ответ модели до того, как он уйдёт пользователю. Guardrails разбираются в уроке 17.
Функция под декоратором срабатывает перед каждым вызовом модели, а вызовов на один запуск агента бывает несколько. В третьем запуске ветка про длинный разговор включается уже на первом вызове, потому что история пришла на вход. Эта ветка срабатывает по длине request.messages, то есть по состоянию, поэтому при той же конфигурации запуска инструкция меняется по ходу разговора.
Набор инструментов и его цена
Третья из пяти составляющих, инструменты. Вторую, сообщения, правит пример 5 в разделе про редактирование контекста. Лишние инструменты перегружают контекст, и модель может чаще ошибаться, а без нужного инструмента задача не решается. Набор меняют по состоянию авторизации, правам пользователя, включённым возможностям или стадии разговора.
Лишние инструменты стоят и денег. Схема инструмента уходит в каждый запрос целиком: имя, описание и аргументы вместе с их описаниями. Восемь инструментов, это восемь схем в каждом вызове модели, и пример 3 ниже измеряет их цену.
Важное ограничение: все инструменты регистрируются заранее, параметром tools у create_agent или атрибутом tools у middleware. Отбор в wrap_model_call только сужает набор для конкретного вызова. Инструмент, созданный во время работы, тоже можно подключить, но для этого нужен второй хук, wrap_tool_call, и это отдельная тема.
Пример 3 берёт роль из конфигурации запуска и оставляет наблюдателю только те инструменты, чьё имя начинается с read_. Задача одна и та же для обеих ролей, поэтому разница во входных токенах на первом вызове, это цена лишних схем.
Пример 03_dynamic_tools.py
"""Пример 3 урока 14: набор инструментов под права собеседника и цена этого набора.
Агент один, инструментов восемь, задача одна и та же. Middleware оставляет в
запросе только те инструменты, которые разрешены роли из конфигурации запуска.
Наблюдатель печатает, сколько инструментов ушло в модель и во что провайдер
оценил вход.
"""
from dataclasses import dataclass
from typing import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call
from course_model import build_model
ORDERS = [
{"id": "Z-771", "city": "Казань", "status": "укомплектован"},
{"id": "Z-773", "city": "Казань", "status": "застрял на складе"},
{"id": "Z-774", "city": "Казань", "status": "застрял на складе"},
{"id": "Z-775", "city": "Омск", "status": "в пути"},
]
@dataclass
class Access:
"""Конфигурация запуска: под какой ролью работает собеседник."""
role: str
def read_orders(city: str) -> str:
"""Возвращает список заказов по городу вместе со статусом каждого."""
rows = [o for o in ORDERS if o["city"] == city]
if not rows:
return f"Заказов по городу {city} нет."
return "; ".join(f"{o['id']} - {o['status']}" for o in rows)
def read_stock(sku: str) -> str:
"""Возвращает остаток товара по артикулу на всех складах."""
return f"Артикул {sku}: Казань 7 шт., Омск 0 шт."
def read_routes(city: str) -> str:
"""Возвращает список маршрутов, которые проходят через город."""
return f"Через город {city} идут маршруты M-1 и M-4."
def read_notes(order_id: str) -> str:
"""Возвращает комментарии сотрудников к заказу."""
return f"Комментариев к заказу {order_id} нет."
def write_order(order_id: str, status: str) -> str:
"""Меняет статус заказа на указанный."""
return f"Заказ {order_id} переведён в статус {status}."
def cancel_order(order_id: str) -> str:
"""Отменяет заказ по номеру."""
return f"Заказ {order_id} отменён."
def refund_payment(order_id: str) -> str:
"""Возвращает деньги за заказ на карту плательщика."""
return f"По заказу {order_id} оформлен возврат."
def delete_order(order_id: str) -> str:
"""Удаляет заказ из системы без возможности восстановления."""
return f"Заказ {order_id} удалён."
ALL_TOOLS = [
read_orders,
read_stock,
read_routes,
read_notes,
write_order,
cancel_order,
refund_payment,
delete_order,
]
@wrap_model_call
def tools_by_role(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
"""Оставляет в запросе инструменты, разрешённые роли, и печатает цену набора."""
role = request.runtime.context.role
if role != "оператор":
request = request.override(
tools=[t for t in request.tools if t.name.startswith("read_")]
)
response = handler(request)
# Расход возвращает не каждый провайдер, поэтому пустой словарь как запасной путь.
usage = response.result[0].usage_metadata or {}
print(
f"{role:<10} {len(request.tools):>6} {usage.get('input_tokens', 0):>5} "
f"{', '.join(t.name for t in request.tools)}"
)
return response
agent = create_agent(
model=build_model(temperature=0, max_tokens=384),
tools=ALL_TOOLS,
system_prompt=(
"Вы помощник логистического отдела. Отвечайте по-русски, одной фразой. "
"Данные берите инструментами, по памяти не отвечайте."
),
middleware=[tools_by_role],
context_schema=Access,
)
QUESTION = "Сколько заказов застряло на складе в Казани?"
print(f"{'роль':<10} {'инстр':>6} {'вход':>5} инструменты в запросе")
for role in ("оператор", "наблюдатель"):
result = agent.invoke(
{"messages": [{"role": "user", "content": QUESTION}]},
context=Access(role=role),
)
answer = result["messages"][-1].text.replace("\n", " ")
print(f"{role:<10} ОТВЕТ: {answer}")
# Вывод:
# роль инстр вход инструменты в запросе
# оператор 8 739 read_orders, read_stock, read_routes, read_notes, write_order, cancel_order, refund_payment, delete_order
# оператор 8 852 read_orders, read_stock, read_routes, read_notes, write_order, cancel_order, refund_payment, delete_order
# оператор ОТВЕТ: В Казани на складе застряло 2 заказа (Z-773 и Z-774).
# наблюдатель 4 508 read_orders, read_stock, read_routes, read_notes
# наблюдатель 4 600 read_orders, read_stock, read_routes, read_notes
# наблюдатель ОТВЕТ: В Казани на складе застряло 2 заказа (Z-773 и Z-774).
У восьми учебных функций схемы короткие: описание функции в одну строку, у аргументов описаний нет. И всё равно четыре лишние схемы заняли около трети входа: 739 токенов против 508 при первом вызове. У инструмента с полутора десятками описанных полей схема в разы длиннее, а с каждым лишним инструментом в наборе разница растёт.
Отбор по роли убирает опасные действия только из запроса. Схемы delete_order в запросе наблюдателя нет, и обычно модель этот инструмент не вызывает. Но в узле tools зарегистрированы все восемь: если модель всё же вернёт вызов delete_order, он выполнится. Закрыть действие кодом можно проверкой роли в самом инструменте, с помощью ToolRuntime из урока 10. Второй способ, хук wrap_tool_call: в уроке 9 он перехватывал исключение инструмента. Код такой проверки покрывается тестом, а отбор набора и запрет в инструкции, как в примере 2, зависят от того, какой вызов вернёт модель.
Когда набор инструментов меняется от вызова к вызову, история остаётся общей. Если инструмент был доступен на прошлом вызове и его результат лежит в истории, модель получает и этот результат, и набор, где инструмента уже нет. Как модель ответит в такой ситуации, заранее неизвестно. Поэтому меняйте набор на границах, которые совпадают с логикой разговора: смена роли, авторизация, переход к следующему этапу.
Что инструмент отдаёт модели, а что кладёт в состояние
Теперь второй тип контекста из таблицы в начале урока, контекст инструментов. Инструмент читает состояние, хранилище и конфигурацию запуска, а пишет результат для модели, правки состояния и записи в хранилище.
Записи в хранилище разбирались в уроках 10 и 13. Результат для модели и правка состояния расходуют токены по-разному:
1) то, что инструмент вернул как результат, становится сообщением в истории и уходит в модель на каждом следующем вызове
2) то, что инструмент записал с помощью Command в отдельное поле состояния (в примере 4 это documents), в модель не уходит и токенов не тратит
Приём для агента, который работает с длинными документами: тяжёлый текст не возвращают модели целиком. Инструмент кладёт документ в состояние, а модели отдаёт выжимку: число строк, число знаков и список разделов. Второй инструмент по ключевому слову достаёт из состояния нужные строки. Пример 4 сравнивает двух агентов на одной задаче: один получает отчёт целиком, другой работает с выжимкой.
Пример 04_tool_returns.py
"""Пример 4 урока 14: что инструмент отдаёт модели и что он кладёт в состояние.
Два агента решают одну задачу по одному и тому же отчёту. Первый получает отчёт
целиком текстом инструмента, и этот текст уходит в каждый следующий запрос.
Второй получает выжимку, а полный текст уходит в состояние, откуда его достаёт
второй инструмент по ключевому слову.
Печатается число вызовов модели и сумма входных токенов за весь запуск.
"""
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
REPORT = (
"Отчёт по складу Казань за неделю.\n"
"Приёмка: принято 412 паллет, из них 38 по электронной очереди с опозданием.\n"
"Отгрузка: отгружено 389 паллет, средняя загрузка машины 84 процента.\n"
"Брак: выявлено 17 мест, из них бой 9, подмочка 5, пересорт 3.\n"
"Брак по перевозчикам: 11 мест у перевозчика М-1, 6 мест у перевозчика М-4.\n"
"Возвраты: оформлено 23 возврата, средний срок обработки 1,8 дня.\n"
"Инвентаризация: пересчитаны зоны A и B, расхождение 0,4 процента.\n"
"Персонал: средняя численность смены 14 человек, две смены неполные.\n"
"Техника: один погрузчик в ремонте с вторника, замена не выделена.\n"
"Температурная зона: два срыва режима, оба короче пятнадцати минут.\n"
"Документы: в учётную систему с опозданием попали 6 актов приёмки.\n"
"Планы: на следующую неделю заявлено 430 паллет приёмки и 400 отгрузки.\n"
)
QUESTION = "Откройте отчёт по складу Казань и скажите, что в нём написано про брак."
PROMPT = (
"Вы помощник руководителя склада. Отвечайте по-русски, одной-двумя фразами. "
"Данные берите инструментами, по памяти не отвечайте."
)
class DeskState(AgentState):
"""Состояние агента: к messages добавлено место для загруженных документов."""
documents: dict[str, str]
def open_report(name: str) -> str:
"""Возвращает полный текст отчёта по складу указанного города."""
if name != "Казань":
return f"Отчёта по складу {name} нет."
return REPORT
@tool
def load_report(name: str, runtime: ToolRuntime[None, DeskState]) -> Command:
"""Загружает отчёт по складу города в рабочую память и отдаёт его состав."""
if name != "Казань":
return Command(
update={
"messages": [
ToolMessage(
content=f"Отчёта по складу {name} нет.",
tool_call_id=runtime.tool_call_id,
)
]
}
)
lines = REPORT.splitlines()
topics = ", ".join(line.split(":")[0] for line in lines[1:])
digest = (
f"Отчёт по складу {name} загружен: {len(lines)} строк, "
f"{len(REPORT)} знаков. Разделы: {topics}. Чтобы прочитать нужный "
f"раздел, вызовите find_in_report с ключевым словом."
)
return Command(
update={
"documents": {**runtime.state.get("documents", {}), name: REPORT},
"messages": [
ToolMessage(content=digest, tool_call_id=runtime.tool_call_id)
],
}
)
@tool
def find_in_report(
name: str, keyword: str, runtime: ToolRuntime[None, DeskState]
) -> str:
"""Возвращает строки загруженного отчёта, в которых встречается слово."""
text = runtime.state.get("documents", {}).get(name)
if text is None:
return f"Отчёт по складу {name} ещё не загружен."
found = [line for line in text.splitlines() if keyword.lower() in line.lower()]
if not found:
return f"Слово '{keyword}' в отчёте по складу {name} не встречается."
return "\n".join(found)
def spent(messages) -> int:
"""Складывает входные токены по всем ответам модели в истории запуска."""
total = 0
for message in messages:
# Расход возвращает не каждый провайдер, поэтому пустой словарь как запасной путь.
usage = getattr(message, "usage_metadata", None) or {}
total += usage.get("input_tokens", 0)
return total
def model_calls(messages) -> int:
"""Считает ответы модели в истории запуска."""
return sum(1 for message in messages if message.type == "ai")
direct = create_agent(
model=build_model(temperature=0, max_tokens=384),
tools=[open_report],
system_prompt=PROMPT,
)
filtered = create_agent(
model=build_model(temperature=0, max_tokens=384),
tools=[load_report, find_in_report],
system_prompt=PROMPT,
state_schema=DeskState,
)
print(f"{'агент':<22} {'вызовов':>7} {'вход всего':>10}")
for label, agent in (("отчёт целиком", direct), ("выжимка и состояние", filtered)):
result = agent.invoke({"messages": [{"role": "user", "content": QUESTION}]})
messages = result["messages"]
answer = messages[-1].text.replace("\n", " ")
print(f"{label:<22} {model_calls(messages):>7} {spent(messages):>10}")
print(f"{'':<22} ОТВЕТ: {answer}")
print()
print("В СОСТОЯНИИ ВТОРОГО АГЕНТА ОСТАЛОСЬ:")
for name, text in result.get("documents", {}).items():
print(f" {name}: {len(text)} знаков")
# Вывод:
# агент вызовов вход всего
# отчёт целиком 2 1052
# ОТВЕТ: В отчёте по складу Казань указано, что выявлено 17 мест брака, из них: бой — 9, подмочка — 5, пересорт — 3. Также отмечено, что по перевозчикам: 11 мест брака у перевозчика М-1 и 6 мест у перевозчика М-4.
# выжимка и состояние 3 1727
# ОТВЕТ: В отчёте по складу Казань указано, что выявлено 17 мест брака: из них 9 — бой, 5 — подмочка, 3 — пересорт. Также отмечено, что 11 мест брака приходится на перевозчика М-1 и 6 мест — на перевозчика М-4.
#
# В СОСТОЯНИИ ВТОРОГО АГЕНТА ОСТАЛОСЬ:
# Казань: 785 знаков
Здесь приём проиграл: 1727 входных токенов у второго агента против 1052 у первого. Отчёт короткий, 785 знаков, а после open_report у первого агента был всего один вызов модели. Второму понадобился лишний вызов на поиск, и в нём заново оплачены инструкция, история и схемы двух инструментов. Приём выигрывает, когда документ в несколько раз больше выжимки и после загрузки идёт несколько вызовов модели. Полный текст тогда не уходит в модель на каждом из них.
Полный текст лежит в состоянии под своим ключом, и любой следующий инструмент этого запуска достанет его без обращения к внешней системе. Рабочие данные агента хранятся в состоянии, а в контекст модели кладут только то, что нужно на этом вызове.
Тот же приём переносится на хранилище из урока 13: выжимка идёт в модель, полный документ сохраняется в хранилище и доступен в следующих разговорах.
Редактирование контекста: правка на один вызов
Приёмы выше задают, что попадёт в запрос: инструкция, схемы инструментов, результат инструмента. Теперь случай, когда история уже длинная: агент отработал десять шагов, в истории лежат восемь результатов инструментов, и семь из них давно не нужны.
Урок 12 дал два ответа, обрезку и суммаризацию. Оба меняют состояние thread: следующий checkpoint пишется без удалённых сообщений, а в прежних checkpoints они остаются. Третий ответ, это правка на один вызов. Называется он context editing, и для него есть готовый middleware ContextEditingMiddleware. Работает он так: когда история превышает порог в токенах, содержимое старых результатов инструментов заменяется заглушкой, а несколько последних остаются нетронутыми.
Настраивается middleware списком стратегий правки. Сейчас стратегия одна, ClearToolUsesEdit, и она повторяет поведение механизма очистки использованных инструментов у Anthropic. Её параметры:
1) trigger, порог в токенах, после которого правка включается, по умолчанию сто тысяч. Способ счёта задаёт параметр самого middleware token_count_method, а своя функция в параметре token_counter его отменяет. При значении по умолчанию "approximate" функция count_tokens_approximately считает только сообщения истории, без системного сообщения и схем инструментов
2) keep, сколько последних результатов инструментов сохранить нетронутыми, по умолчанию три
3) clear_at_least, сколько токенов освободить, прежде чем правка остановится. По умолчанию ноль, и тогда заглушка ставится на все результаты, кроме последних keep
4) clear_tool_inputs, вычищать ли заодно аргументы вызова в сообщении модели, по умолчанию False
5) exclude_tools, имена инструментов, результаты которых не трогают никогда
6) placeholder, текст заглушки, по умолчанию [cleared]
Правка временная: middleware делает копию списка сообщений, правит копию и передаёт дальше новый запрос, собранный методом request.override(messages=...). Состояние при этом не меняется, и это частный случай деления из начала урока: правка контекста модели действует на один вызов, постоянная правка остаётся в состоянии. На странице документации об этом не сказано, а слово clearing в описании middleware можно принять за удаление из истории.
Пример 5 ставит порог низким, чтобы правка сработала в коротком разговоре. Пример печатает историю с двух сторон: то, что дошло до модели, и то, что осталось в thread.
Пример 05_context_editing.py
"""Пример 5 урока 14: встроенное редактирование контекста и его временность.
Три вопроса в одном thread, каждый с тяжёлым результатом инструмента. Middleware
ContextEditingMiddleware заменяет старые результаты заглушкой, когда история
превышает порог.
Наблюдатель стоит после редактора в списке middleware, поэтому получает уже
правленый запрос: то, что ушло в модель. В конце печатается состояние thread, где лежит
неправленая история.
"""
from itertools import count
from typing import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import (
ClearToolUsesEdit,
ContextEditingMiddleware,
ModelRequest,
ModelResponse,
wrap_model_call,
)
from langchain_core.messages.utils import count_tokens_approximately
from langgraph.checkpoint.memory import InMemorySaver
from course_model import build_model
REPORTS = {
"Казань": "бой 9, подмочка 5, пересорт 3",
"Омск": "бой 2, подмочка 1, пересорт 14",
"Пермь": "бой 4, подмочка 12, пересорт 2",
}
FILLER = (
"Приёмка идёт по электронной очереди, талон берут на въезде. Паллеты "
"принимают по счёту мест и по весу брутто. Упаковку вскрывают при видимом "
"повреждении обрешётки и снимают вскрытие на камеру. Температурный груз "
"замеряют дважды. Документы возвращают водителю с отметкой о приёмке, копию "
"подшивают в журнал склада и дублируют в учётную систему до 20:00 того же "
"дня. Расхождение больше пяти мест останавливает приёмку до прихода "
"старшего смены."
)
def warehouse_report(city: str) -> str:
"""Возвращает полный недельный отчёт по складу города."""
defects = REPORTS.get(city)
if defects is None:
return f"Отчёта по складу {city} нет."
return f"Отчёт по складу {city}. Регламент: {FILLER} Брак за неделю: {defects}."
step = count(1)
@wrap_model_call
def observer(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
"""Печатает то, что дошло до модели после работы редактора контекста."""
tool_messages = [m for m in request.messages if m.type == "tool"]
cleared = sum(
1
for m in tool_messages
if m.response_metadata.get("context_editing", {}).get("cleared")
)
shapes = ", ".join(f"{len(m.text):>4}" for m in tool_messages)
print(
f"{next(step):>5} {len(request.messages):>9} {len(tool_messages):>4} "
f"{cleared:>8} {count_tokens_approximately(request.messages):>7} {shapes}"
)
return handler(request)
agent = create_agent(
model=build_model(temperature=0, max_tokens=384),
tools=[warehouse_report],
system_prompt=(
"Вы помощник руководителя склада. Отвечайте по-русски, одной фразой. "
"Цифры берите инструментом, по памяти не отвечайте."
),
middleware=[
ContextEditingMiddleware(
edits=[ClearToolUsesEdit(trigger=300, keep=1)],
),
observer,
],
checkpointer=InMemorySaver(),
)
CONFIG = {"configurable": {"thread_id": "edit-1"}}
QUESTIONS = [
"Сколько брака за неделю на складе Казань?",
"А на складе Омск?",
"А на складе Пермь?",
]
print(
f"{'вызов':>5} {'сообщений':>9} {'tool':>4} {'очищено':>8} "
f"{'оценка':>7} длины результатов инструмента"
)
for question in QUESTIONS:
result = agent.invoke(
{"messages": [{"role": "user", "content": question}]},
CONFIG,
)
print()
print("ПОСЛЕДНИЙ ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
print()
print("В THREAD ПОСЛЕ ТРЁХ ВОПРОСОВ:")
for message in result["messages"]:
if message.type == "tool":
print(f" tool {len(message.text):>4} знаков {message.text[:38]}")
# Вывод:
# вызов сообщений tool очищено оценка длины результатов инструмента
# 1 1 0 0 15
# (вызовы 2 и 3 опущены: очистка ещё не сработала, оценка выросла до 232)
# 4 7 2 1 285 9, 523
# 5 9 2 1 320 9, 523
# 6 11 3 2 373 9, 9, 524
#
# ПОСЛЕДНИЙ ОТВЕТ: За неделю на складе Пермь зафиксирован брак: бой — 4, подмочка — 12, пересорт — 2.
#
# В THREAD ПОСЛЕ ТРЁХ ВОПРОСОВ:
# tool 524 знаков Отчёт по складу Казань. Регламент: При
# tool 523 знаков Отчёт по складу Омск. Регламент: Приём
# tool 524 знаков Отчёт по складу Пермь. Регламент: Приё
От порядка middleware в этом примере зависит, какой запрос получит наблюдатель. Хуки-обёртки вкладываются друг в друга по порядку списка: первый middleware оборачивает все остальные, последний стоит ближе всех к модели. Наблюдатель стоит вторым, поэтому получает запрос уже после работы редактора. Поменяйте их местами, и таблица покажет историю до правки, хотя модель получила другую.
Цена приёма в том, что содержимое старых результатов в модель больше не уходит. Если оно понадобится снова, модели придётся повторить вызов инструмента, иначе она ответит без этих данных. Для результатов, которые терять нельзя, есть exclude_tools, а число нетронутых последних результатов задаёт keep. Пометка об очистке ставится в служебные данные копии, в поле response_metadata["context_editing"], по ней наблюдатель примера 5 считает колонку очищено. В состоянии thread пометки нет, там лежит исходный результат инструмента.
Middleware не делает thread короче. В состоянии лежит всё, checkpointer записывает всё, и следующий вопрос в том же thread начнётся с полной истории. Постоянное сокращение дают обрезка и суммаризация из урока 12, и в одном агенте их можно подключить вместе с редактированием контекста.
Голый агент против собранного
Последний пример соединяет три приёма урока на одной задаче и считает разницу. Два агента, одна модель, один набор инструментов, три вопроса подряд, у каждого агента свой thread. У первого длинная статическая инструкция, все инструменты и нетронутая история. У второго инструкция собирается под роль, в запрос уходят только инструменты, разрешённые роли, а старые результаты чистятся по порогу.
Пример 06_together.py
"""Пример 6 урока 14: голый агент против собранного на одной и той же задаче.
Три вопроса подряд, у каждого агента свой thread, один набор инструментов, один
провайдер. Разница только в харнессе: у второго агента промпт собирается под роль,
в запрос уходят только разрешённые роли инструменты, а старые результаты
инструментов заменяются заглушкой по порогу.
Печатается число вызовов модели и сумма входных токенов за весь разговор.
"""
from dataclasses import dataclass
from typing import Callable
from langchain.agents import create_agent
from langchain.agents.middleware import (
ClearToolUsesEdit,
ContextEditingMiddleware,
ModelRequest,
ModelResponse,
dynamic_prompt,
wrap_model_call,
)
from langgraph.checkpoint.memory import InMemorySaver
from course_model import build_model
REPORTS = {
"Казань": "бой 9, подмочка 5, пересорт 3",
"Омск": "бой 2, подмочка 1, пересорт 14",
"Пермь": "бой 4, подмочка 12, пересорт 2",
}
FILLER = (
"Приёмка идёт по электронной очереди, талон берут на въезде. Паллеты "
"принимают по счёту мест и по весу брутто. Упаковку вскрывают при видимом "
"повреждении обрешётки и снимают вскрытие на камеру. Температурный груз "
"замеряют дважды. Документы возвращают водителю с отметкой о приёмке, копию "
"подшивают в журнал склада и дублируют в учётную систему до 20:00 того же "
"дня. Расхождение больше пяти мест останавливает приёмку до прихода "
"старшего смены."
)
LONG_PROMPT = (
"Вы помощник логистического отдела. Отвечайте по-русски, одной фразой. "
"Данные берите инструментами, по памяти не отвечайте. "
"Инструмент read_report отдаёт недельный отчёт по складу города вместе с "
"регламентом приёмки. Инструмент read_stock отдаёт остаток по артикулу. "
"Инструмент read_routes отдаёт маршруты через город. Инструмент "
"write_order меняет статус заказа, его разрешено звать только оператору "
"склада, и только после того, как оператор назвал номер заказа целиком. "
"Инструмент cancel_order отменяет заказ, его разрешено звать только "
"оператору склада и только после подтверждения суммы возврата. "
"Наблюдателю запрещено менять данные: если наблюдатель просит отменить "
"заказ или сменить статус, откажите и предложите обратиться к оператору."
)
@dataclass
class Access:
"""Конфигурация запуска: под какой ролью работает собеседник."""
role: str
def read_report(city: str) -> str:
"""Возвращает полный недельный отчёт по складу города."""
defects = REPORTS.get(city)
if defects is None:
return f"Отчёта по складу {city} нет."
return f"Отчёт по складу {city}. Регламент: {FILLER} Брак за неделю: {defects}."
def read_stock(sku: str) -> str:
"""Возвращает остаток товара по артикулу на всех складах."""
return f"Артикул {sku}: Казань 7 шт., Омск 0 шт."
def read_routes(city: str) -> str:
"""Возвращает список маршрутов, которые проходят через город."""
return f"Через город {city} идут маршруты M-1 и M-4."
def write_order(order_id: str, status: str) -> str:
"""Меняет статус заказа на указанный."""
return f"Заказ {order_id} переведён в статус {status}."
def cancel_order(order_id: str) -> str:
"""Отменяет заказ по номеру."""
return f"Заказ {order_id} отменён."
ALL_TOOLS = [read_report, read_stock, read_routes, write_order, cancel_order]
@dynamic_prompt
def prompt_for_role(request: ModelRequest) -> str:
"""Собирает короткую инструкцию под роль собеседника."""
base = (
"Вы помощник логистического отдела. Отвечайте по-русски, одной фразой. "
"Данные берите инструментами, по памяти не отвечайте."
)
if request.runtime.context.role == "оператор":
return base + " Менять статус и отменять заказ разрешено."
return base + " Менять данные вам нельзя, доступно только чтение."
@wrap_model_call
def tools_by_role(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
"""Оставляет в запросе инструменты, разрешённые роли собеседника."""
if request.runtime.context.role != "оператор":
request = request.override(
tools=[t for t in request.tools if t.name.startswith("read_")]
)
return handler(request)
def spent(messages) -> int:
"""Складывает входные токены по всем ответам модели в истории thread."""
total = 0
for message in messages:
# Расход возвращает не каждый провайдер, поэтому пустой словарь как запасной путь.
usage = getattr(message, "usage_metadata", None) or {}
total += usage.get("input_tokens", 0)
return total
bare = create_agent(
model=build_model(temperature=0, max_tokens=384),
tools=ALL_TOOLS,
system_prompt=LONG_PROMPT,
context_schema=Access,
checkpointer=InMemorySaver(),
)
tuned = create_agent(
model=build_model(temperature=0, max_tokens=384),
tools=ALL_TOOLS,
context_schema=Access,
middleware=[
prompt_for_role,
tools_by_role,
ContextEditingMiddleware(
edits=[ClearToolUsesEdit(trigger=300, keep=1)],
),
],
checkpointer=InMemorySaver(),
)
QUESTIONS = [
"Сколько брака за неделю на складе Казань?",
"А на складе Омск?",
"А на складе Пермь?",
]
print(f"{'агент':<10} {'вызовов':>7} {'вход всего':>10} последний ответ")
for label, agent in (("голый", bare), ("собранный", tuned)):
config = {"configurable": {"thread_id": f"together-{label}"}}
for question in QUESTIONS:
result = agent.invoke(
{"messages": [{"role": "user", "content": question}]},
config,
context=Access(role="наблюдатель"),
)
messages = result["messages"]
calls = sum(1 for message in messages if message.type == "ai")
answer = messages[-1].text.replace("\n", " ")
print(f"{label:<10} {calls:>7} {spent(messages):>10} {answer}")
# Вывод:
# агент вызовов вход всего последний ответ
# голый 6 6891 Брак за неделю на складе Пермь: бой 4, подмочка 12, пересорт 2.
# собранный 6 4471 Брак за неделю на складе Пермь: бой 4, подмочка 12, пересорт 2.
Разница между строками, 6891 против 4471 входных токенов, складывается из трёх изменений сразу, и приписывать её одному приёму нельзя. Вклад каждого виден, если добавлять приёмы по одному, как в практическом задании ниже, и смотреть, на сколько меняется колонка вход всего.
Диалог здесь короткий, три вопроса. Чем длиннее разговор, тем сильнее расходятся числа в колонке вход всего. Вызовов у обоих агентов поровну, и результаты инструментов в thread копятся у обоих. Голый агент отправляет их все в каждый вызов вместе с длинной инструкцией и пятью схемами, а собранный после порога заменяет старые результаты заглушкой.
Каждый middleware, это код, который выполняется во время ответа: ошибка в нём ломает ответ пользователю. Пока агент справляется на голой сборке, middleware ему не нужны.
Порядок действий, когда агент ломается
1) поставьте middleware из примера 1 и получите таблицу состава запроса
2) найдите колонку, которая растёт быстрее прочих или изначально велика. Своей колонки у схем инструментов нет, их цену показывает разрыв между колонками оценка и вход
3) возьмите приём под эту колонку из таблицы ниже
4) поменяйте что-то одно и повторите замер
| Что заняло окно | Что делать | Где разбирается |
|---|---|---|
| Системное сообщение | сборка инструкции под роль и стадию разговора | урок 5 и пример 2 этого урока |
| Схемы инструментов | отбор набора под права и стадию разговора | пример 3 |
| Результаты инструментов | выжимка в модель, полный текст в состояние | пример 4 |
| Старые результаты инструментов | ContextEditingMiddleware на один вызов |
пример 5 |
| История сообщений целиком | обрезка или суммаризация | урок 12 |
| Долговременная память | отбор при записи и срок годности | урок 13 |
Начинайте со статической инструкции и постоянного набора инструментов, а сборку под роль и отбор добавляйте по необходимости. Следите за числом вызовов модели, расходом токенов и задержкой. Берите готовые middleware вместо своих. И описывайте стратегию работы с контекстом словами, иначе через полгода никто не вспомнит, зачем в агенте пять middleware и почему в таком порядке.
Чего в этом уроке нет
Полный каталог встроенных middleware, это урок 15. Отбор инструментов моделью, middleware LLMToolSelectorMiddleware, назван там же. Он тоже сужает набор перед вызовом, как пример 3, только для этого делается отдельный вызов модели с последним сообщением пользователя. Модель для отбора можно задать свою, по умолчанию берётся модель агента. Свой middleware классом, хуки-узлы, прыжки агента и своя трассировка, это урок 16. Guardrails и пауза на подтверждение человеком, это урок 17.
Формат ответа тоже относится к контексту модели, и middleware меняет его тем же методом override по полю response_format. Схемы и стратегии разбирались в уроке 6, а выбирать схему можно по состоянию, хранилищу и конфигурации запуска. Ограничение: при стратегии ToolStrategy выбрать можно только из схем, переданных в create_agent, на новую схему агент поднимет ValueError.
Распространённые ошибки
Ошибка: правка запроса присваиванием вместо override
@wrap_model_call
def wrong(request, handler):
request.tools = [t for t in request.tools if t.name.startswith("read_")]
return handler(request)
Прямое присваивание полю ModelRequest объявлено устаревшим: объект задуман неизменяемым. Python покажет DeprecationWarning, если middleware лежит в запускаемом файле, из импортированного модуля предупреждение по умолчанию скрыто. Правку делают методом override, он возвращает новый запрос, а исходный остаётся прежним. Верный вид занимает столько же строк.
@wrap_model_call
def right(request, handler):
tools = [t for t in request.tools if t.name.startswith("read_")]
return handler(request.override(tools=tools))
Ошибка: middleware отдаёт инструмент, которого нет у агента
Хук wrap_model_call может положить в request.tools инструмент, которого нет в create_agent, но выполнить его агенту нечем. Если в агенте нет middleware с хуком wrap_tool_call, вызов модели не состоится: агент поднимет ValueError со списком неизвестных инструментов. Соберите полный список один раз, передайте его в create_agent и фильтруйте.
Ошибка: keep не меньше числа результатов инструментов в истории
ContextEditingMiddleware(edits=[ClearToolUsesEdit(trigger=300, keep=10)])
Порог сработает, а запрос не изменится: результатов инструментов не больше, чем keep, и очищать нечего. Настройка выглядит неработающей, а причина в том, что результатов в разговоре пока мало. Проверяйте приём на разговоре, где результатов инструментов заведомо больше, чем значение keep.
Ошибка: ждать от редактирования контекста, что thread станет короче
Редактор чистит только копию списка перед вызовом модели. В thread остаются полные результаты, сокращают его обрезка и суммаризация из урока 12.
Практическое задание
Возьмите пример 6 и разложите экономию по приёмам: сколько входных токенов снимает каждый. Подготовьте сборки агента:
1) голая из примера 6
2) с динамическим промптом вместо длинной инструкции
3) с промптом и отбором инструментов
4) с промптом, отбором и редактированием контекста
После этого:
1) прогоните три вопроса примера 6 подряд под ролью наблюдателя, по одному thread на сборку, temperature=0 у модели не меняйте
2) напечатайте таблицу: название сборки, число вызовов модели, входные токены всего по функции spent из примера 6, последний ответ
3) сравните последние ответы всех четырёх сборок по смыслу: совпадают ли в них числа
Проверка результата: если какая-то сборка даёт другой по смыслу ответ, вы нашли границу, за которой приём вредит, и это ценнее выигранных токенов. Учтите, что вторая сборка экономит только на длине инструкции. Строка, которую prompt_for_role собирает для наблюдателя, сняла бы столько же токенов, сколько вторая сборка, если передать её в system_prompt без middleware.
Усложнение. Возьмите голую и четвёртую сборки:
1) прогоните каждую дважды, под ролью оператора и под ролью наблюдателя, каждый прогон в новом thread
2) в каждом прогоне задайте три вопроса примера 6, а следом просьбу "Отмените заказ Z-771"
3) напечатайте из запроса текст инструкции и набор инструментов, так же, как пример 3 печатает набор. Для этого подойдёт отдельный middleware без правок запроса. Ставьте его в списке последним, как observer в примере 5: поставленный первым, он получит запрос до подстановки инструкции и до отбора
4) сравните ответы оператору на просьбу об отмене в голой и четвёртой сборках
5) найдите причину разницы: что в двух инструкциях сказано про отмену и что из роли собеседника попадает в запрос к модели в каждой сборке
6) решите, что в этой разнице выигрыш, а что потерянное условие
В четвёртой сборке у наблюдателя в наборе нет write_order и cancel_order, и модель отвечает ему отказом в тексте.
Итоги урока
Агент ломается по одной из двух причин: модель не тянет задачу или ей не передали нужный контекст. Чаще встречается вторая. Работа с контекстом, это набор проверяемых действий над конкретным запросом к модели.
Запрос лежит в одном объекте, ModelRequest, и виден из middleware с хуком wrap_model_call. Контекст модели состоит из пяти составляющих, данные приходят из трёх источников. Правка бывает двух видов: временная на один вызов и постоянная в состоянии.
Четыре приёма урока работают с тремя частями запроса: инструкцией, схемами инструментов и результатами инструментов. Инструкция собирается под роль и стадию разговора. Набор инструментов сужается под права, и это экономит токены и убирает опасные действия из запроса. Инструмент отдаёт модели выжимку, а полный документ кладёт в состояние. Старые результаты инструментов на один вызов заменяются заглушкой, это делает ContextEditingMiddleware.
Порядок работы важнее любого из приёмов: замер, одна правка, снова замер. Без замера не видно, сократил middleware расход или только добавил код на каждом вызове модели.
В уроке 15, "Встроенные middleware", разберу обработку ошибки инструмента и повтор его вызова после сбоя, запасную модель, список задач и поиск по файлам. Главное там, это порядок: один и тот же набор middleware в разном порядке даёт разное поведение агента.
Код урока
Примеры этого урока лежат в репозитории курса, папка lesson_14. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.
Предыдущий урок: Долговременная память: хранилище между сессиями
Следующий урок: Встроенные middleware: точки встраивания и порядок
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru