LangChain

Первый агент: цикл, состояние и рамка настройки | Курс LangChain урок 11

Первый агент: цикл, состояние и рамка настройки | Курс LangChain урок 11
Михаил Омельченко
Автор
Михаил Омельченко
Опубликовано 08.10.2026
5,0
Views 10

Цель урока: собрать агента функцией create_agent, разбирать его работу по шагам графа, добавлять свои поля в состояние параметром state_schema и ставить границу, за которой агент обязан остановиться.

Необходимые знания:

1) урок 0: окружение собрано, ключ работает, переменные MODEL_NAME и MODEL_BASE_URL заполнены

2) урок 5: системный промпт агента и middleware как способ настройки

3) урок 7: поток агента, режим updates, форма chunk при version="v2"

4) урок 9: декоратор @tool, цикл вызова инструментов, return_direct

5) урок 10: параметр ToolRuntime, запись в состояние объектом Command с полем update

6) Python на уровне джуниора: TypedDict и наследование, Annotated, модуль operator

Ключевые концепции:

1) агент это модель плюс харнесс: всё, что стоит вокруг цикла вызова инструментов

2) create_agent собирает граф из двух рабочих узлов, model и tools

3) запуск заканчивается, когда модель ответила без tool_calls, или раньше: при return_direct из урока 9 и при срабатывании предохранителя

4) агент возвращает словарь состояния, и вся история запуска лежит в messages

5) свои поля состояния описываются в подклассе AgentState, и этот подкласс передаётся параметром state_schema

6) от редьюсера поля зависит, складываются правки или новая затирает прежнюю

7) естественная остановка и два предохранителя: потолок шагов графа recursion_limit и бюджет вызовов модели ModelCallLimitMiddleware

8) настройка харнесса разложена на шесть категорий, и по ним идут следующие уроки курса


Зачем понадобилось слово "агент"

Десять уроков вы собирали части: модель, сообщения, структурированный вывод, поток, инструменты, конфигурацию запуска. Каждая часть отвечает на вопрос "как сделать один шаг". Ни одна не отвечает на вопрос "сколько шагов нужно и в каком порядке".

Ответ на этот вопрос даёт агент: он вызывает модель и инструменты по кругу, пока задача не решена. Вместе с агентом появляется второе понятие, харнесс. Так называют всё, что стоит вокруг цикла: системный промпт, набор инструментов и middleware, от которых зависит поведение модели. Задача харнесса в том, чтобы на каждом шаге модель получала контекст, нужный для текущей задачи.

Отсюда формула: агент равен сумме модели и харнесса. Модель меняется одной строкой кода, харнесс вы собираете сами, и от него зависит, получится работающий агент или демонстрация.

Вторая половина ответа, это граница между сценарием и агентом. Она проведена по тому, откуда берётся путь. У сценария путь предопределён кодом и шаги идут в заданном порядке, как в цепочках из урока 2. У агента путь складывается из ответов модели: какой инструмент она запросит следующим и когда перестанет запрашивать. Поэтому агента ставят там, где заранее неизвестно, какие шаги понадобятся для решения.

Значит, сценарий и агент существуют рядом, и выбор между ними, это выбор между предсказуемостью и гибкостью. Платить приходится в обоих случаях: у сценария дорого обходится поддержка ветвлений в коде, у агента лишние вызовы модели. Цену в токенах вы увидите в примере ниже.

Общая сборка модели

Модуль сборки модели тот же, что в уроках 4-10. Положите его рядом с примерами под именем course_model.py, и дальше каждый пример получает модель одной строкой, вызовом build_model.

"""Общая сборка модели для примеров урока 11.

Тот же модуль, что course_model.py уроков 4-10, без изменений: у 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)

Первый агент: три параметра и один вызов

Минимальный агент настраивается тремя параметрами: model, tools, system_prompt. В model идёт либо строка вида "провайдер:модель", либо готовый объект модели. В tools идёт список, и туда принимается любой вызываемый объект Python, инструмент LangChain или словарь с описанием инструмента. Значит, декоратор @tool из урока 9 не всегда нужен: обычной функции с аннотациями и docstring достаточно.

В первом примере одного вызова инструмента не хватит. Город доставки известен только после поиска заказа, а срок доставки зависит от города. Порядок шагов нигде не записан, его выбирает модель.

Пример 01_first_agent.py

"""Пример 1 урока 11: первый агент на create_agent.

Два инструмента, между которыми есть зависимость: город доставки известен только
после поиска заказа. Сколько раз вызвать инструменты и в каком порядке, определяют
ответы модели.

Печатается не только ответ, но и то, что вернул агент целиком: ключи состояния и
вся цепочка сообщений.
"""

from langchain.agents import create_agent

from course_model import build_model

ORDERS = {
    "4412": {"city": "Казань", "item": "наушники"},
    "5190": {"city": "Омск", "item": "клавиатура"},
}

DELIVERY_DAYS = {"Казань": 2, "Омск": 4}


def find_order(number: str) -> str:
    """Находит заказ по номеру и возвращает товар и город доставки."""
    order = ORDERS.get(number)
    if order is None:
        return f"Заказ {number} не найден."
    return f"Заказ {number}: {order['item']}, город доставки {order['city']}."


def delivery_days(city: str) -> str:
    """Возвращает срок доставки в город в днях."""
    days = DELIVERY_DAYS.get(city)
    if days is None:
        return f"Срок доставки в город {city} неизвестен."
    return f"Доставка в город {city} занимает {days} дня."


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[find_order, delivery_days],
    system_prompt=(
        "Вы оператор службы доставки. Отвечайте по-русски, коротко и по делу. "
        "Номера заказов и сроки берите только из инструментов."
    ),
)

result = agent.invoke(
    {"messages": [{"role": "user", "content": "Когда приедет мой заказ 4412?"}]}
)

print("ТИП РЕЗУЛЬТАТА:", type(result).__name__)
print("КЛЮЧИ СОСТОЯНИЯ:", sorted(result))
print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
print()

print("ЦЕПОЧКА СООБЩЕНИЙ")
model_calls = 0
for number, message in enumerate(result["messages"], start=1):
    kind = type(message).__name__
    if kind == "AIMessage":
        model_calls += 1
    calls = getattr(message, "tool_calls", None)
    if calls:
        names = [f"{call['name']}({call['args']})" for call in calls]
        print(f"  {number}. {kind:<12} вызывает {names}")
        continue
    text = message.text.replace("\n", " ")
    if len(text) > 60:
        text = text[:57] + "..."
    print(f"  {number}. {kind:<12} {text!r}")

print()
print("ВЫЗОВОВ МОДЕЛИ:", model_calls)

# Вывод:
# ТИП РЕЗУЛЬТАТА: dict
# КЛЮЧИ СОСТОЯНИЯ: ['messages']
# ОТВЕТ: Заказ **4412** — **наушники**, доставка в **Казань**. Срок доставки в ваш город составляет **2 дня**.   Чтобы сказать точную дату, подскажите, когда был оформлен заказ?
#
# ЦЕПОЧКА СООБЩЕНИЙ
#   1. HumanMessage 'Когда приедет мой заказ 4412?'
#   2. AIMessage    вызывает ["find_order({'number': '4412'})"]
#   3. ToolMessage  'Заказ 4412: наушники, город доставки Казань.'
#   4. AIMessage    вызывает ["delivery_days({'city': 'Казань'})"]
#   5. ToolMessage  'Доставка в город Казань занимает 2 дня.'
#   6. AIMessage    'Заказ **4412** — **наушники**, доставка в **Казань**. Сро...'
#
# ВЫЗОВОВ МОДЕЛИ: 3

Важнее самого ответа то, в каком виде он вернулся. invoke отдаёт словарь состояния, и текст для пользователя вы достаёте из него сами, последним сообщением. В состоянии лежит вся история запуска, и по ней видно, откуда взялся ответ.

Системного сообщения в списке нет, хотя промпт вы задали. Оно добавляется в начало списка при вызове модели и в историю не пишется. В уроке 5 это разбиралось со стороны промпта, здесь та же механика видна со стороны состояния.

По длине списка видна и цена. Каждый вызов модели получает всю историю целиком, вместе с прошлыми ответами инструментов. Агент с пятью вызовами инструментов подряд обращается к модели шесть раз и каждый раз оплачивает всю накопленную историю. Поэтому объём пересылки и счёт растут быстрее, чем число шагов.

Цикл изнутри: узлы model и tools

Узел модели вызывает модель со списком сообщений, к которому применён системный промпт. Если в ответном AIMessage есть tool_calls, граф идёт в узел инструментов. Узел инструментов выполняет их и дописывает результаты в список как ToolMessage. Дальше модель вызывается снова. Так продолжается, пока в ответе есть вызовы инструментов, а потом агент возвращает состояние со всем списком сообщений.

У графа два рабочих узла, model и tools. Их имена встречались вам в уроке 7, когда вы фильтровали поток. По ним удобно следить за циклом: в режиме updates каждая правка состояния приходит вместе с именем узла, который её сделал.

Пример 02_loop_steps.py

"""Пример 2 урока 11: цикл модель-инструменты по шагам.

Тот же агент, что в примере 1, но запущенный потоком в режиме updates: после
каждого шага графа приходит правка состояния вместе с именем узла, который её
сделал. Видно, из каких узлов собран запуск и на чём он заканчивается.
"""

from langchain.agents import create_agent

from course_model import build_model

ORDERS = {
    "4412": {"city": "Казань", "item": "наушники"},
    "5190": {"city": "Омск", "item": "клавиатура"},
}

DELIVERY_DAYS = {"Казань": 2, "Омск": 4}


def find_order(number: str) -> str:
    """Находит заказ по номеру и возвращает товар и город доставки."""
    order = ORDERS.get(number)
    if order is None:
        return f"Заказ {number} не найден."
    return f"Заказ {number}: {order['item']}, город доставки {order['city']}."


def delivery_days(city: str) -> str:
    """Возвращает срок доставки в город в днях."""
    days = DELIVERY_DAYS.get(city)
    if days is None:
        return f"Срок доставки в город {city} неизвестен."
    return f"Доставка в город {city} занимает {days} дня."


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[find_order, delivery_days],
    system_prompt=(
        "Вы оператор службы доставки. Отвечайте по-русски, коротко и по делу. "
        "Номера заказов и сроки берите только из инструментов."
    ),
)

step = 0
last_message = None

for chunk in agent.stream(
    {"messages": [{"role": "user", "content": "Когда приедет мой заказ 4412?"}]},
    stream_mode="updates",
    version="v2",
):
    if chunk["type"] != "updates":
        continue

    for node, update in chunk["data"].items():
        step += 1
        # У служебных ключей вроде __interrupt__ правка приходит не словарём.
        messages = update.get("messages", []) if isinstance(update, dict) else []
        if not messages:
            print(f"шаг {step}: узел {node}, правка без сообщений: {update!r}")
            continue
        for message in messages:
            last_message = message
            calls = getattr(message, "tool_calls", None)
            if calls:
                names = [call["name"] for call in calls]
                print(f"шаг {step}: узел {node}, запрошены инструменты {names}")
            else:
                text = message.text.replace("\n", " ")
                if len(text) > 50:
                    text = text[:47] + "..."
                print(f"шаг {step}: узел {node}, {type(message).__name__} {text!r}")

print()
print("ШАГОВ ВСЕГО:", step)
print(
    "ПОСЛЕДНЕЕ СООБЩЕНИЕ ЗАПРОСИЛО ИНСТРУМЕНТЫ:",
    bool(getattr(last_message, "tool_calls", None)),
)

# Вывод:
# шаг 1: узел model, запрошены инструменты ['find_order']
# шаг 2: узел tools, ToolMessage 'Заказ 4412: наушники, город доставки Казань.'
# шаг 3: узел model, запрошены инструменты ['delivery_days']
# шаг 4: узел tools, ToolMessage 'Доставка в город Казань занимает 2 дня.'
# шаг 5: узел model, AIMessage ' Ваш заказ №4412 (наушники) будет доставлен в К...'
#
# ШАГОВ ВСЕГО: 5
# ПОСЛЕДНЕЕ СООБЩЕНИЕ ЗАПРОСИЛО ИНСТРУМЕНТЫ: False

Естественный выход из цикла задаёт ответ модели. Пока модель просит инструменты, граф крутится, а когда перестаёт просить, граф идёт к концу. Никакого "агент понял, что закончил" внутри нет: на условном ребре проверяется поле tool_calls. Остальные выходы, return_direct из урока 9 и предохранители из этого урока, вы ставите сами.

У такого устройства есть обратная сторона. Если модель по какой-то причине раз за разом просит инструмент, цикл остановится только на потолке по умолчанию, а у create_agent это 9999 шагов. Свою границу для этого случая ставите вы, способы разобраны в разделе "Где агент должен остановиться".

Сценарий и агент на одной задаче

На первый взгляд агент подходит к любой задаче лучше сценария, но это впечатление обманчиво. Когда путь к ответу известен заранее, агент только добавляет расходы: лишние вызовы модели и лишние токены. Появляется и неопределённость, которой у сценария с прописанными в коде шагами не было.

В третьем примере обе сборки работают на одних данных. Сценарий написан обычной функцией. Номер заказа достаётся регулярным выражением, по нему берутся заказ и срок доставки, а модель вызывается один раз, чтобы сформулировать ответ. Агенту те же данные доступны в виде инструментов, а в каком порядке их запрашивать, определяет модель.

Пример задаёт обеим сборкам одни и те же два вопроса. Первый, с номером заказа, сценарий разбирает так, как задумано. Во втором номера нет, и на нём видна разница. Регулярное выражение сценария ничего не находит, поэтому ему остаётся только попросить номер у пользователя. Агенту доступен ещё инструмент delivery_days, который узнаёт срок по названию города. Обратится ли к нему модель, станет ясно только после запуска.

Пример 03_workflow_vs_agent.py

"""Пример 3 урока 11: сценарий и агент на одной задаче.

Одни и те же данные и две сборки. В сценарии порядок шагов задан кодом: найти
номер, взять заказ, взять срок, попросить модель сформулировать ответ. В агенте
порядок выбирает модель.

Два запроса. Первый целиком подходит под сценарий. Во втором номера заказа нет, и
видно, чем сборки отличаются. Рядом с каждым ответом печатается цена: сколько раз
вызвали модель и сколько токенов на это ушло.
"""

import re

from langchain.agents import create_agent
from langchain_core.callbacks import UsageMetadataCallbackHandler

from course_model import build_model

ORDERS = {
    "4412": {"city": "Казань", "item": "наушники"},
    "5190": {"city": "Омск", "item": "клавиатура"},
}

DELIVERY_DAYS = {"Казань": 2, "Омск": 4}


def lookup_order(number):
    """Данные заказа или None. Общий источник для сценария и для инструмента."""
    return ORDERS.get(number)


def lookup_days(city):
    """Срок доставки в днях или None."""
    return DELIVERY_DAYS.get(city)


def find_order(number: str) -> str:
    """Находит заказ по номеру и возвращает товар и город доставки."""
    order = lookup_order(number)
    if order is None:
        return f"Заказ {number} не найден."
    return f"Заказ {number}: {order['item']}, город доставки {order['city']}."


def delivery_days(city: str) -> str:
    """Возвращает срок доставки в город в днях."""
    days = lookup_days(city)
    if days is None:
        return f"Срок доставки в город {city} неизвестен."
    return f"Доставка в город {city} занимает {days} дня."


SYSTEM_PROMPT = (
    "Вы оператор службы доставки. Отвечайте по-русски, коротко и по делу. "
    "Номера заказов и сроки берите только из фактов, которые вам дали."
)

# Агенту факты во входе не дают, он берёт их из инструментов, как в примере 1.
AGENT_PROMPT = (
    "Вы оператор службы доставки. Отвечайте по-русски, коротко и по делу. "
    "Номера заказов и сроки берите только из инструментов."
)

model = build_model(temperature=0, max_tokens=512)

agent = create_agent(
    model=model,
    tools=[find_order, delivery_days],
    system_prompt=AGENT_PROMPT,
)


def run_workflow(question, callback):
    """Порядок шагов задан здесь, в коде, и не зависит от вопроса."""
    calls = 0

    found = re.search(r"\d{4}", question)
    if found is None:
        return "Не вижу номера заказа, уточните его.", calls

    order = lookup_order(found.group())
    if order is None:
        return f"Заказ {found.group()} не найден.", calls

    days = lookup_days(order["city"])
    facts = (
        f"Заказ {found.group()}: {order['item']}, город {order['city']}, "
        f"срок доставки {days} дня."
    )

    calls += 1
    answer = model.invoke(
        [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"Факты: {facts}\nВопрос: {question}"},
        ],
        config={"callbacks": [callback]},
    )
    return answer.text, calls


def run_agent(question, callback):
    """Порядок шагов выбирает модель, код задаёт только набор инструментов."""
    result = agent.invoke(
        {"messages": [{"role": "user", "content": question}]},
        config={"callbacks": [callback]},
    )
    calls = sum(
        1 for message in result["messages"] if type(message).__name__ == "AIMessage"
    )
    return result["messages"][-1].text, calls


def total_tokens(callback):
    """Сумма токенов по всем моделям, которые вызвали под этим обработчиком."""
    return sum(usage["total_tokens"] for usage in callback.usage_metadata.values())


QUESTIONS = [
    "Когда приедет мой заказ 4412?",
    "Сколько дней идёт доставка в Омск?",
]

for question in QUESTIONS:
    print("=" * 70)
    print("ВОПРОС:", question)

    for title, runner in (("сценарий", run_workflow), ("агент  ", run_agent)):
        callback = UsageMetadataCallbackHandler()
        answer, calls = runner(question, callback)
        print(
            f"  {title} | вызовов модели: {calls} | токенов: {total_tokens(callback)}"
        )
        print(f"           {answer.replace(chr(10), ' ')}")

# Вывод:
# ======================================================================
# ВОПРОС: Когда приедет мой заказ 4412?
#   сценарий | вызовов модели: 1 | токенов: 106
#            Заказ 4412 приедет через 2 дня.
#   агент   | вызовов модели: 3 | токенов: 1537
#             Ваш заказ №4412 (наушники) доставляется в Казань. Срок доставки — 2 дня. Уточните, с какого дня считать, чтобы назвать точную дату.
# ======================================================================
# ВОПРОС: Сколько дней идёт доставка в Омск?
#   сценарий | вызовов модели: 0 | токенов: 0
#            Не вижу номера заказа, уточните его.
#   агент   | вызовов модели: 2 | токенов: 926
#             Доставка в Омск занимает **4 дня**.

Первый вопрос сценарий закрыл одним вызовом модели и 106 токенами, агент тремя вызовами и 1537 токенами. Второй вопрос сценарий не разобрал, а модель агента запросила delivery_days по названию города, и ответ пришёл за два вызова модели. У вас второй ответ может выйти другим, и это не ошибка. Промпт, набор инструментов и их описания смещают выбор модели, но закрепить его не могут. Если отдать агенту промпт сценария, "сроки берите только из фактов, которые вам дали", модель при этом вопросе может попросить номер заказа и не вызвать инструмент. Поэтому у агента ставят предохранители: потолок шагов и бюджет вызовов из раздела "Где агент должен остановиться" ниже. Подтверждение действий человеком разобрано в уроке 17.

Отсюда правило выбора. Если набор вопросов закрыт и к каждому ответу ведёт один путь, сценарий дешевле и предсказуемее, и агент там не нужен. Если вопросов много и путь у каждого свой, поддерживать ветвления в коде становится дороже, чем платить за лишние вызовы модели.

Есть ещё вариант: сценарий на верхнем уровне и агент внутри одного шага. Ветвление держит код, а свободу получает та часть, где она нужна. Собирается это уже средствами LangGraph.

Состояние агента и своя схема

Состояние хранит данные текущего запуска агента. Обязательное поле в AgentState одно, messages с полной историей диалога. Два других поля необязательные. structured_response появляется вместе с response_format. jump_to служебное: им middleware передают переходы внутри графа, и наружу оно не выходит. Историю ведёт редьюсер add_messages, привязанный к полю messages в исходном коде схемы. Новые сообщения он дописывает в конец списка, а сообщение с id, который уже есть в списке, ставит на место старого. На странице агентов в документации это поле названо "только дополняемым", но в langgraph 1.2.11 сообщение с тем же id заменяется.

Объект, разобранный по response_format, кладётся в structured_response, подробно это разбиралось в уроке 6. Здесь важнее другое: схема состояния не фиксирована и растёт вместе с настройками агента.

Свои поля добавляются подклассом AgentState, который передаётся параметром state_schema. Есть и второй способ: middleware объявляет поля в своём атрибуте state_schema, рядом с хуками, которые эти поля читают и пишут. Такой middleware вы соберёте в уроке 16. Параметр агента нужен тогда, когда поле принадлежит агенту целиком и ни к одному middleware не привязано.

Что происходит с полем при записи, задаёт редьюсер. Это функция с двумя аргументами: в первом текущее значение поля, во втором правка от узла, а результат функции становится новым значением. Если поле объявлено без редьюсера, правка заменяет старое значение, но за один шаг графа такое поле может принять только одну правку. Список, который должен расти, объявляют с operator.add. Если нужна замена, которая выдерживает несколько правок за шаг, редьюсер пишут сами, обычно это функция в одну строку.

В четвёртом примере к схеме добавлены три своих поля. Запрос составлен так, чтобы модель вызвала инструмент дважды в одном ответе и обе правки пришли одним шагом графа.

Пример 04_state_schema.py

"""Пример 4 урока 11: своя схема состояния и редьюсеры полей.

Схема наследуется от AgentState и добавляет три поля: warehouse приходит со
входа и читается инструментом, checked накапливает артикулы редьюсером
operator.add, last_sku хранит последний артикул своим редьюсером.

Запрос сделан так, чтобы модель вызвала инструмент дважды в одном ответе. Правки
от обоих вызовов приходят в одном шаге графа, и видно, как каждый редьюсер их сводит.
"""

import operator
from typing import Annotated

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

STOCK = {
    "A-100": {"Казань": 7, "Омск": 0},
    "B-200": {"Казань": 0, "Омск": 12},
}


def last_wins(_current: str, update: str) -> str:
    """Редьюсер: в состоянии остаётся та правка, что пришла последней."""
    return update


class StockState(AgentState):
    """Состояние агента склада: к messages добавлены три своих поля."""

    warehouse: str
    checked: Annotated[list[str], operator.add]
    last_sku: Annotated[str, last_wins]


@tool
def check_stock(sku: str, runtime: ToolRuntime[None, StockState]) -> Command:
    """Проверяет остаток товара по артикулу. Артикул вида A-100."""
    warehouse = runtime.state.get("warehouse", "неизвестен")
    left = STOCK.get(sku, {}).get(warehouse)

    if left is None:
        answer = f"Артикул {sku} на складе {warehouse} не заведён."
    else:
        answer = f"Артикул {sku}, склад {warehouse}: остаток {left} шт."

    return Command(
        update={
            "checked": [sku],
            "last_sku": sku,
            "messages": [
                ToolMessage(content=answer, tool_call_id=runtime.tool_call_id)
            ],
        }
    )


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[check_stock],
    system_prompt=(
        "Вы кладовщик. Остатки берите только из инструмента, по одному вызову на "
        "артикул. Отвечайте по-русски одной строкой."
    ),
    state_schema=StockState,
)

result = agent.invoke(
    {
        "messages": [
            {"role": "user", "content": "Проверьте остатки по артикулам A-100 и B-200."}
        ],
        "warehouse": "Казань",
    }
)

first_call = next(m for m in result["messages"] if getattr(m, "tool_calls", None))

print("КЛЮЧИ СОСТОЯНИЯ:", sorted(result))
print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
print("ВЫЗОВОВ ИНСТРУМЕНТА В ПЕРВОМ ОТВЕТЕ МОДЕЛИ:", len(first_call.tool_calls))
print()
print("warehouse (поле входа):", result.get("warehouse"))
print("checked (operator.add):", result.get("checked"))
print("last_sku (last_wins):  ", result.get("last_sku"))
print()

print("ВЫЗОВЫ ИНСТРУМЕНТА В ИСТОРИИ")
for message in result["messages"]:
    if type(message).__name__ == "ToolMessage":
        print("  ", message.text.replace("\n", " "))

# Вывод:
# КЛЮЧИ СОСТОЯНИЯ: ['checked', 'last_sku', 'messages', 'warehouse']
# ОТВЕТ: Артикул A-100 — 7 шт., артикул B-200 — 0 шт. Оба на складе Казань.
# ВЫЗОВОВ ИНСТРУМЕНТА В ПЕРВОМ ОТВЕТЕ МОДЕЛИ: 2
#
# warehouse (поле входа): Казань
# checked (operator.add): ['A-100', 'B-200']
# last_sku (last_wins):   B-200
#
# ВЫЗОВЫ ИНСТРУМЕНТА В ИСТОРИИ
#    Артикул A-100, склад Казань: остаток 7 шт.
#    Артикул B-200, склад Казань: остаток 0 шт.

Поле warehouse пришло со входа, вместе с сообщением пользователя, и инструмент прочитал его из runtime.state. Своя схема расширяет не только то, что агент возвращает, но и то, что он принимает: поля вашего подкласса попадают и во входную схему графа.

На двух других полях видна разница редьюсеров. Каждое из них получило по две правки, и обе пришли одним шагом: модель запросила оба вызова в первом же ответе. Поле с operator.add сохранило обе правки, поле с last_wins оставило последнюю. Редьюсер задаётся полю при объявлении схемы.

Инструмент, который пишет в состояние, должен работать и тогда, когда модель вызовет его несколько раз в одном ответе. Поле без редьюсера в этом случае роняет запуск с InvalidUpdateError, разбор в блоке ошибок.

Где агент должен остановиться

Естественная остановка у агента одна: модель ответила без вызовов инструментов. Раньше цикл завершает return_direct из урока 9, если вы его поставили. Остальные границы, предохранители, тоже ставите вы.

Первый предохранитель стоит в самом движке графа. Потолок шагов, параметр recursion_limit, ограничивает число шагов графа за один запуск. Шаг графа, это один такт, в котором отрабатывают запланированные на него узлы. У агента без middleware это узел model или узел tools, и ещё один шаг занимает вход в граф. В потоке updates вход не виден, поэтому пяти шагам из примера 2 нужен потолок от шести. Каждый хук middleware, который встаёт в граф узлом, занимает свой шаг, поэтому с middleware потолок нужен выше. Когда потолок исчерпан, поднимается GraphRecursionError. Документация LangGraph называет значение по умолчанию в тысячу шагов, но в langchain 1.4.2 create_agent задаёт своему графу 9999. Поэтому потолок ставьте сами. Ключ кладётся на верхний уровень config, рядом с configurable.

Второй предохранитель считает вызовы модели, то есть деньги. Это middleware ModelCallLimitMiddleware. Параметр run_limit ограничивает вызовы за один запуск, параметр thread_limit за весь диалог. Чтобы счёт за диалог сохранялся между запусками, агенту нужен checkpointer, он появится в уроке 12. Этот middleware в уроке 12 не используется, соединить их вы сможете сами. Что делать, когда лимит исчерпан, задаёт параметр exit_behavior. Значение по умолчанию "end" мягко завершает запуск, значение "error" поднимает исключение. Для вызовов инструментов есть такой же middleware ToolCallLimitMiddleware, параметром tool_name его можно ограничить одним инструментом.

Пятый пример задаёт агенту один и тот же вопрос трижды: без ограничений, с потолком шагов и с бюджетом вызовов.

Пример 05_stop.py

"""Пример 5 урока 11: три способа остановить агента.

Один и тот же вопрос задаётся трижды. Первый запуск заканчивается сам: модель
перестаёт просить инструменты. Второму поставлен потолок шагов графа, и он падает
с GraphRecursionError. Третьему поставлен бюджет вызовов модели, и он заканчивается
без исключения, но последнее сообщение в истории добавлено middleware.
"""

from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware
from langgraph.errors import GraphRecursionError

from course_model import build_model

ORDERS = {"4412": {"city": "Казань", "item": "наушники"}}
DELIVERY_DAYS = {"Казань": 2}

QUESTION = {"messages": [{"role": "user", "content": "Когда приедет мой заказ 4412?"}]}


def find_order(number: str) -> str:
    """Находит заказ по номеру и возвращает товар и город доставки."""
    order = ORDERS.get(number)
    if order is None:
        return f"Заказ {number} не найден."
    return f"Заказ {number}: {order['item']}, город доставки {order['city']}."


def delivery_days(city: str) -> str:
    """Возвращает срок доставки в город в днях."""
    days = DELIVERY_DAYS.get(city)
    if days is None:
        return f"Срок доставки в город {city} неизвестен."
    return f"Доставка в город {city} занимает {days} дня."


SYSTEM_PROMPT = (
    "Вы оператор службы доставки. Отвечайте по-русски, коротко и по делу. "
    "Номера заказов и сроки берите только из инструментов."
)

model_kwargs = {"temperature": 0, "max_tokens": 512}
tools = [find_order, delivery_days]


def describe(result):
    """Сколько сообщений в истории и чем она закончилась."""
    last = result["messages"][-1]
    text = last.text.replace("\n", " ")
    if len(text) > 70:
        text = text[:67] + "..."
    return f"сообщений: {len(result['messages'])}, последнее: {type(last).__name__} {text!r}"


print("1. БЕЗ ОГРАНИЧЕНИЙ")
plain = create_agent(
    model=build_model(**model_kwargs), tools=tools, system_prompt=SYSTEM_PROMPT
)
print("  ", describe(plain.invoke(QUESTION)))

print()
print("2. ПОТОЛОК ШАГОВ ГРАФА: recursion_limit=2")
try:
    plain.invoke(QUESTION, config={"recursion_limit": 2})
except GraphRecursionError as error:
    print("   отказ:", type(error).__name__)
    print("   текст:", str(error).split("\n")[0])

print()
print("3. БЮДЖЕТ ВЫЗОВОВ МОДЕЛИ: run_limit=1, exit_behavior='end'")
limited = create_agent(
    model=build_model(**model_kwargs),
    tools=tools,
    system_prompt=SYSTEM_PROMPT,
    middleware=[ModelCallLimitMiddleware(run_limit=1, exit_behavior="end")],
)
print("  ", describe(limited.invoke(QUESTION)))

# Вывод:
# 1. БЕЗ ОГРАНИЧЕНИЙ
#    сообщений: 6, последнее: AIMessage 'Ваш заказ №4412 (наушники) будет доставлен в Казань через 2 дня.'
#
# 2. ПОТОЛОК ШАГОВ ГРАФА: recursion_limit=2
#    отказ: GraphRecursionError
#    текст: Recursion limit of 2 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.
#
# 3. БЮДЖЕТ ВЫЗОВОВ МОДЕЛИ: run_limit=1, exit_behavior='end'
#    сообщений: 4, последнее: AIMessage 'Model call limits exceeded: run limit (1/1)'

Второй и третий запуск заканчиваются по-разному. Потолок шагов роняет запуск исключением, и история, накопленная до падения, теряется, если вы не сохранили её сами. Бюджет вызовов завершает запуск штатно, и вызывающий код получает обычный результат.

У мягкого завершения есть цена. Последнее сообщение в третьем запуске добавил middleware: это AIMessage с текстом про исчерпанный лимит, по типу его от ответа модели не отличить. Поэтому различайте два состояния: агент закончил работу и агент упёрся в лимит. Если ваш код берёт последнее сообщение и шлёт его в чат, служебная строка про лимит попадёт к пользователю.

Потолок шагов при этом не выбрасывайте, даже если бюджет вызовов уже подключён. Потолок встроен в движок графа и есть у каждого запуска. Бюджет работает, только пока middleware стоит в списке и лимит задан разумно.

Рамка настройки: шесть категорий

Харнесс из начала урока настраивается по категориям, и по ним построена вся вторая половина курса. Под каждую категорию есть готовые middleware, а в таблице указано, в каком уроке они разобраны.

Категория Что закрывает Где в курсе
Среда исполнения инструменты, файловая система, песочницы, запуск кода уроки 9-10, поиск по файлам в уроке 15
Управление контекстом суммаризация, память, редактирование контекста, кеш промпта уроки 12-14, кеш промпта в уроке 16
Планирование и делегирование список задач и субагенты для изолированной работы список задач в уроке 15
Отказоустойчивость повторы, запасная модель, лимиты вызовов уроки 4, 11 и 15
Ограждения персональные данные и контентные политики урок 17
Вмешательство человека подтверждение человеком перед важным действием урок 17

Middleware это единица настройки: одна задача, одно или несколько мест встраивания в цикл, и сочетать его можно с любыми другими. Часть таких middleware заранее собрана в пакете deepagents, отдельной сборке поверх create_agent: файловая система, планирование, субагенты. Курс ставит только langchain, а deepagents идёт третьим курсом.

Последний пример ни разу не вызывает модель. Он печатает схему графа у голого агента и у агента с middleware из пяти категорий, всех, кроме среды исполнения.

Пример 06_harness.py

"""Пример 6 урока 11: из чего собирается харнесс.

Два агента на одних и тех же инструментах. Первый голый: модель, инструменты,
системный промпт. Второму добавлены middleware из пяти категорий настройки,
всех, кроме среды исполнения.

Модель здесь не вызывается ни разу, печатается только схема графа: видно, во что
превращается каждый middleware и в каком порядке узлы стоят вокруг цикла.
"""

from langchain.agents import create_agent
from langchain.agents.middleware import (
    HumanInTheLoopMiddleware,
    ModelCallLimitMiddleware,
    PIIMiddleware,
    SummarizationMiddleware,
    TodoListMiddleware,
    ToolRetryMiddleware,
)
from langgraph.checkpoint.memory import InMemorySaver

from course_model import build_model


def find_order(number: str) -> str:
    """Находит заказ по номеру и возвращает товар и город доставки."""
    return f"Заказ {number}: наушники, город доставки Казань."


def refund(number: str, reason: str) -> str:
    """Оформляет возврат денег по заказу. Действие необратимое."""
    return f"Возврат по заказу {number} оформлен, причина: {reason}."


SYSTEM_PROMPT = "Вы оператор службы доставки. Отвечайте по-русски и коротко."

model = build_model(temperature=0, max_tokens=512)
tools = [find_order, refund]

plain = create_agent(model=model, tools=tools, system_prompt=SYSTEM_PROMPT)

configured = create_agent(
    model=model,
    tools=tools,
    system_prompt=SYSTEM_PROMPT,
    middleware=[
        # Управление контекстом: сжать историю, пока она не переполнила окно.
        SummarizationMiddleware(
            model=model, trigger=("tokens", 4000), keep=("messages", 20)
        ),
        # Планирование: список задач, который модель обновляет инструментом write_todos.
        TodoListMiddleware(),
        # Отказоустойчивость: бюджет вызовов модели и повтор упавшего инструмента.
        ModelCallLimitMiddleware(run_limit=8, exit_behavior="end"),
        ToolRetryMiddleware(max_retries=2),
        # Ограждения: почтовый адрес не уходит в модель как есть.
        PIIMiddleware("email", strategy="redact", apply_to_input=True),
        # Вмешательство человека: возврат денег без человека не делается.
        HumanInTheLoopMiddleware(interrupt_on={"refund": True}),
    ],
    checkpointer=InMemorySaver(),
)

print("ГОЛЫЙ АГЕНТ")
print(plain.get_graph().draw_mermaid())
print()
print("АГЕНТ С MIDDLEWARE")
print(configured.get_graph().draw_mermaid())

# Вывод:
# ГОЛЫЙ АГЕНТ
# ---
# config:
#   flowchart:
#     curve: linear
# ---
# graph TD;
#   __start__([<p>__start__</p>]):::first
#   model(model)
#   tools(tools)
#   __end__([<p>__end__</p>]):::last
#   __start__ --> model;
#   model -.-> __end__;
#   model -.-> tools;
#   tools -.-> model;
#   classDef default fill:#f2f0ff,line-height:1.2
#   classDef first fill-opacity:0
#   classDef last fill:#bfb6fc
#
# АГЕНТ С MIDDLEWARE
# ---
# config:
#   flowchart:
#     curve: linear
# ---
# graph TD;
#   __start__([<p>__start__</p>]):::first
#   model(model)
#   tools(tools)
#   SummarizationMiddleware\2ebefore_model(SummarizationMiddleware.before_model)
#   TodoListMiddleware\2eafter_model(TodoListMiddleware.after_model)
#   ModelCallLimitMiddleware\2ebefore_model(ModelCallLimitMiddleware.before_model)
#   ModelCallLimitMiddleware\2eafter_model(ModelCallLimitMiddleware.after_model)
#   PIIMiddleware\5bemail\5d\2ebefore_model(PIIMiddleware[email].before_model)
#   PIIMiddleware\5bemail\5d\2eafter_model(PIIMiddleware[email].after_model)
#   HumanInTheLoopMiddleware\2eafter_model(HumanInTheLoopMiddleware.after_model)
#   __end__([<p>__end__</p>]):::last
#   HumanInTheLoopMiddleware\2eafter_model --> PIIMiddleware\5bemail\5d\2eafter_model;
#   ModelCallLimitMiddleware\2eafter_model --> TodoListMiddleware\2eafter_model;
#   ModelCallLimitMiddleware\2ebefore_model -.-> PIIMiddleware\5bemail\5d\2ebefore_model;
#   ModelCallLimitMiddleware\2ebefore_model -.-> __end__;
#   PIIMiddleware\5bemail\5d\2eafter_model --> ModelCallLimitMiddleware\2eafter_model;
#   PIIMiddleware\5bemail\5d\2ebefore_model -.-> __end__;
#   PIIMiddleware\5bemail\5d\2ebefore_model -.-> model;
#   SummarizationMiddleware\2ebefore_model --> ModelCallLimitMiddleware\2ebefore_model;
#   TodoListMiddleware\2eafter_model -.-> SummarizationMiddleware\2ebefore_model;
#   TodoListMiddleware\2eafter_model -.-> __end__;
#   TodoListMiddleware\2eafter_model -.-> tools;
#   __start__ --> SummarizationMiddleware\2ebefore_model;
#   model --> HumanInTheLoopMiddleware\2eafter_model;
#   tools -.-> SummarizationMiddleware\2ebefore_model;
#   classDef default fill:#f2f0ff,line-height:1.2
#   classDef first fill-opacity:0
#   classDef last fill:#bfb6fc

Схема строится по графу, который на самом деле собран, поэтому по ней удобно проверять, совпадает ли агент с замыслом. На ней видно то, что в списке middleware не читается.

Middleware, который работает узлом, встаёт в граф отдельной вершиной. Имя вершины составлено из имени middleware и хука, например ModelCallLimitMiddleware.before_model, а у PIIMiddleware к имени добавлен тип данных: PIIMiddleware[email]. Порядок вершин задаёт список middleware, и по рёбрам видно, как именно. Хуки before_model идут по списку сверху вниз, а after_model в обратном порядке, поэтому первый middleware в списке обёртывает все остальные.

При этом не всякий middleware виден в графе. Повтор инструмента ToolRetryMiddleware работает обёрткой вокруг вызова и узла не добавляет, поэтому на схеме его нет. Если middleware не нашёлся на схеме, это ещё не ошибка: возможно, он работает обёрткой. Оба типа хуков разобраны в уроке 16.

Чего в этом уроке нет

Каждый вызов invoke в этом уроке начинался с пустой истории. Без checkpointer прошлый запуск нигде не хранится: история существует внутри одного запуска и после возврата пропадает. Ключ thread_id и сохранение истории между запусками разобраны в уроке 12.

Конфигурация запуска в параметрах context и context_schema разобрана в уроке 10. Структурированный ответ в параметре response_format разобран в уроке 6, поток и события в уроках 7 и 8. Параметр name у агента понадобится там, где агент встраивается в другой граф как подграф, и это уже тема мультиагентных сборок.

Распространённые ошибки

Фрагменты ниже продолжают примеры урока: StockState и last_wins взяты из примера 4, agent это агент из любого примера, а question это словарь входа вида {"messages": [...]}.

Ошибка 1: поле состояния без редьюсера, а модель вызывает инструмент дважды в одном ответе

# Неправильно: редьюсера нет, и за один шаг графа поле примет только одну правку
class StockState(AgentState):
    last_sku: str

Что происходит: пока модель вызывает инструмент по одному разу, всё работает. Когда она вызовет его дважды в одном ответе, запуск упадёт с InvalidUpdateError. Текст ошибки такой: "At key 'last_sku': Can receive only one value per step. Use an Annotated key to handle multiple values".

Почему так: оба вызова из одного ответа модели выполняются в одном шаге графа, и правки приходят одновременно. Поле без редьюсера принимает за шаг одно значение, а правила, как свести две правки в одну, у движка нет.

# Правильно: редьюсер задан явно и сводит две правки в одно значение
def last_wins(_current: str, update: str) -> str:
    return update


class StockState(AgentState):
    checked: Annotated[list[str], operator.add]   # копить
    last_sku: Annotated[str, last_wins]           # заменять

Ошибка 2: recursion_limit положили внутрь configurable

# Неправильно: ключ спрятан внутрь configurable
agent.invoke(question, config={"configurable": {"recursion_limit": 2}})

Что происходит: ограничение не действует, ошибки при этом нет. Агент крутится до потолка по умолчанию, у create_agent это 9999 шагов, и заметно это становится только по счёту от провайдера.

Почему так: recursion_limit это самостоятельный ключ конфигурации, а в configurable лежат пользовательские настройки запуска. Значение оттуда движок графа не читает и предупреждения не выдаёт.

# Правильно: ключ стоит на верхнем уровне config, рядом с configurable
agent.invoke(question, config={"recursion_limit": 25})

Ошибка 3: ждать, что второй вызов invoke помнит первый

# Неправильно: ждать, что второй вызов помнит первый
agent.invoke({"messages": [{"role": "user", "content": "Мой заказ 4412."}]})
agent.invoke({"messages": [{"role": "user", "content": "А когда он приедет?"}]})

Что происходит: второй ответ приходит без связи с первым. В запрос к модели уходит только вторая реплика, а номера заказа в ней нет.

Почему так: без checkpointer состояние между запусками нигде не хранится. Внутри одного запуска история полная, между запусками её нет.

# Правильно на этом этапе курса: историю передаёт вызывающий код
history = [{"role": "user", "content": "Мой заказ 4412."}]
result = agent.invoke({"messages": history})

history = result["messages"] + [{"role": "user", "content": "А когда он приедет?"}]
result = agent.invoke({"messages": history})

Практическое задание

Соберите агента travel_desk.py, который отвечает на вопросы о командировках и при этом тратит не больше заданного числа вызовов модели.

Требования:

1) два инструмента: find_trip(code) отдаёт город и даты поездки по коду вида TR-19, hotel_price(city) отдаёт стоимость ночи в городе. Данные держите словарями в файле

2) своя схема состояния с полями employee (приходит со входа, читается инструментами) и looked_up с редьюсером operator.add, куда каждый инструмент дописывает своё имя

3) бюджет вызовов модели: ModelCallLimitMiddleware с run_limit=4 и exit_behavior="end", плюс recursion_limit в конфигурации вызова. У этого middleware два хука встают в граф узлами, и вызов модели занимает три шага графа. При упоре в бюджет запуск проходит 18 шагов: двенадцать занимают четыре вызова модели, четыре занимают инструменты, ещё два приходятся на вход в граф и на проверку бюджета перед пятым вызовом. Потолок ставьте с запасом, например 25

4) запуск потоком в режиме updates: печатайте имя узла на каждом шаге

5) после запуска печатайте три строки: ответ пользователю, содержимое looked_up и признак того, упёрся агент в бюджет или закончил сам

6) признак упора определяйте по числу сообщений AIMessage в истории, без разбора текста. Служебное сообщение middleware тоже AIMessage, поэтому при упоре таких сообщений на одно больше, чем run_limit

Как проверить результат:

1) при вопросе "Сколько стоит ночь в поездке TR-19?" в looked_up оказываются оба имени инструментов, а признак показывает, что агент закончил сам

2) при run_limit=1 тот же вопрос заканчивается без исключения, в истории появляется служебное сообщение middleware про исчерпанный лимит, и ваш признак это ловит

3) при recursion_limit=2 запуск падает с GraphRecursionError, и ваш код печатает понятную строку вместо трассировки

4) уберите редьюсер у поля looked_up и задайте вопрос, на который модель может вызвать оба инструмента в одном ответе. Например: "Какие даты у поездки TR-19 и сколько стоит ночь в Казани?", город возьмите из своего словаря. Если оба вызова пришли одним шагом, запуск падает с InvalidUpdateError. Если модель вызвала их по очереди, ошибки не будет, это видно по выводу потока. Верните редьюсер на место

Подсказка: сообщения AIMessage считаются так же, как в примере 3, перебором истории.

Итоги урока

Агент складывается из модели и харнесса, а собирает его функция create_agent. Для рабочей сборки хватает трёх параметров: модели, списка инструментов и системного промпта. Агент возвращает словарь состояния, и ответ пользователю вы достаёте из него последним сообщением.

Внутри лежит граф из двух рабочих узлов. Узел model вызывает модель со всей историей и системным промптом, узел tools выполняет запрошенные вызовы и дописывает результаты. Запуск заканчивается сам, когда модель ответила без tool_calls, а раньше его завершают return_direct и предохранители.

Разницу со сценарием можно измерить: агент тратит больше вызовов модели и токенов, зато путь к ответу не нужно прописывать в коде. Пока путь известен заранее, дешевле сценарий. Когда путей много и они разные, дешевле агент.

Свои поля состояния описываются в подклассе AgentState, который передаётся параметром state_schema. Что происходит с полем при записи, задаёт редьюсер. Без него правка заменяет старое значение, operator.add копит правки, а своя функция сводит их по вашему правилу. Поля вашей схемы попадают и во вход, и в выход агента.

Границу ставите вы. Потолок шагов графа recursion_limit роняет запуск исключением. Бюджет вызовов ModelCallLimitMiddleware завершает его мягко и оставляет в истории служебное сообщение, которое нельзя показывать пользователю.

Настройка всего остального разложена на шесть категорий, и схема графа показывает, что из этого собрано на самом деле. Дальше курс идёт по этой рамке.

Каждый запуск агента в этом уроке начинался с пустой истории: два запроса подряд связаны только тогда, когда историю передаёт вызывающий код, как в ошибке 3.

В уроке 12, "Память сессии", разберу checkpointer и thread_id: как диалог сохраняется между вызовами и что такое checkpoint. Разберу и то, чем платят за долговечную запись и что делать, когда история перестала помещаться в контекстное окно.

Код урока

Примеры этого урока лежат в репозитории курса, папка lesson_11. Закреплённые версии, на которых получен вывод в тексте, лежат в requirements.txt в корне репозитория.


Предыдущий урок: ToolRuntime и Runtime: окружение вызова внутри инструмента

Следующий урок: Память сессии


Подписывайтесь на мой Telegram канал

Если вам нужен ментор и вы хотите научиться разрабатывать AI агентов, пишите, обсудим условия

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы