LangChain

Долговременная память: хранилище между сессиями | Курс LangChain урок 13

Долговременная память: хранилище между сессиями | Курс LangChain урок 13
Михаил Омельченко
Автор
Михаил Омельченко
Опубликовано 10.10.2026
5,0
Views 5

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

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

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

2) урок 5: системный промпт агента и middleware как способ поменять его перед вызовом модели

3) урок 6: схема на Pydantic и Literal как закрытый набор значений

4) урок 10: ToolRuntime, поле runtime.store, конфигурация запуска в аргументе context и схеме context_schema

5) урок 12: чекпойнтер, thread_id и то, что состояние привязано к thread

6) Python на уровне джуниора: кортежи, словари, Literal, Pydantic на уровне описания модели

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

1) хранилище, в терминах фреймворка store, это документы JSON, разложенные по пространству имён и ключу

2) чекпойнтер хранит историю одного thread, хранилище хранит данные о человеке, и это разные сущности

3) пространство имён, это кортеж строк, и поиск по нему идёт по префиксу

4) аргумент limit обрезает выдачу, и признака "было больше" в ответе нет

5) достать записи можно в middleware перед вызовом модели или инструментом, вызов которого модель ставит в свой ответ

6) три вида долговременной памяти: факты, опыт, инструкции

7) профиль против коллекции: один документ, который правят, против списка, который растёт

8) отбор при записи и удаление по сроку, это то, что держит память в рабочем размере


Чего не помнит память сессии

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

Дальше идут случаи, где одного thread мало. Человек открыл новый диалог, и агент снова спрашивает его город. Человек написал с телефона, и это другой thread. Прошла неделя, и старый диалог закрыт. Каждый раз человеку приходится повторять то, что он уже говорил.

Граница между памятью сессии и долговременной памятью проходит по области действия. Память сессии привязана к thread, долговременная память общая для всех threads и доступна в любой момент из любого диалога. Для неё в LangGraph есть отдельное хранилище (Store): данные в нём лежат документами JSON, разложенными по пространству имён и ключу.

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

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

Пространство имён, ключ и значение

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

Поиск по хранилищу идёт по префиксу пространства имён: запрос по ("u-101",) вернёт и факты, и пожелания, и всё остальное, что лежит уровнями ниже. Если разложить записи по уровням, можно достать отдельно записи одного вида или сразу всё про пользователя. В одном плоском пространстве записи одного вида придётся отбирать фильтром по полю значения, и это поле надо класть в каждую запись.

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

Пример 01_layout.py

"""Пример 1 урока 13: раскладка хранилища по пространствам имён.

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

Отдельно показан потолок выдачи: аргумент limit обрезает список, и в ответе
нет признака того, что записей было больше.
"""

from langgraph.store.memory import InMemoryStore

store = InMemoryStore()

# Пространство имён, это кортеж строк. Здесь первый уровень, владелец записи,
# второй, вид памяти. Уровней может быть сколько угодно.
FACTS = [
    ("city", {"text": "Работает в Омске", "source": "диалог"}),
    ("role", {"text": "Ведёт склад запчастей", "source": "анкета"}),
    ("shift", {"text": "Смена с 8 до 17", "source": "анкета"}),
    ("team", {"text": "В подчинении четыре человека", "source": "диалог"}),
]

PREFERENCES = [
    ("answer_style", {"text": "Отвечать без списков", "source": "диалог"}),
]

for key, value in FACTS:
    store.put(("u-101", "facts"), key, value)

for key, value in PREFERENCES:
    store.put(("u-101", "preferences"), key, value)

# Второй пользователь, чтобы было видно, что записи не перемешиваются.
store.put(("u-202", "facts"), "city", {"text": "Работает в Казани", "source": "анкета"})


def show(title, items):
    """Печатает заголовок и найденные записи одной строкой на запись."""
    print(title)
    for item in items:
        print(f"  {'/'.join(item.namespace)} :: {item.key} :: {item.value['text']}")
    print()


show("ВСЁ ПРО ОДНОГО ЧЕЛОВЕКА, поиск по префиксу ('u-101',):", store.search(("u-101",)))

show("ТОЛЬКО ФАКТЫ, полное пространство имён:", store.search(("u-101", "facts")))

show(
    "ОТБОР ПО СОДЕРЖИМОМУ, filter по полю source:",
    store.search(("u-101", "facts"), filter={"source": "анкета"}),
)

show(
    "ПОТОЛОК ВЫДАЧИ, limit=2 при четырёх записях:",
    store.search(("u-101", "facts"), limit=2),
)

print("ПРОСТРАНСТВА ИМЁН ОДНОГО ЧЕЛОВЕКА:")
for namespace in store.list_namespaces(prefix=("u-101",), max_depth=2):
    print(" ", namespace)
print()

print("ВСЕ ПРОСТРАНСТВА ИМЁН ХРАНИЛИЩА:")
for namespace in store.list_namespaces():
    print(" ", namespace)
print()

item = store.get(("u-101", "facts"), "city")
print("ОДНА ЗАПИСЬ ЦЕЛИКОМ:")
print(item.dict())

# Вывод:
# ВСЁ ПРО ОДНОГО ЧЕЛОВЕКА, поиск по префиксу ('u-101',):
#   u-101/facts :: city :: Работает в Омске
#   u-101/facts :: role :: Ведёт склад запчастей
#   u-101/facts :: shift :: Смена с 8 до 17
#   u-101/facts :: team :: В подчинении четыре человека
#   u-101/preferences :: answer_style :: Отвечать без списков
#
# ТОЛЬКО ФАКТЫ, полное пространство имён:
#   u-101/facts :: city :: Работает в Омске
#   u-101/facts :: role :: Ведёт склад запчастей
#   u-101/facts :: shift :: Смена с 8 до 17
#   u-101/facts :: team :: В подчинении четыре человека
#
# ОТБОР ПО СОДЕРЖИМОМУ, filter по полю source:
#   u-101/facts :: role :: Ведёт склад запчастей
#   u-101/facts :: shift :: Смена с 8 до 17
#
# ПОТОЛОК ВЫДАЧИ, limit=2 при четырёх записях:
#   u-101/facts :: city :: Работает в Омске
#   u-101/facts :: role :: Ведёт склад запчастей
#
# ПРОСТРАНСТВА ИМЁН ОДНОГО ЧЕЛОВЕКА:
#   ('u-101', 'facts')
#   ('u-101', 'preferences')
#
# ВСЕ ПРОСТРАНСТВА ИМЁН ХРАНИЛИЩА:
#   ('u-101', 'facts')
#   ('u-101', 'preferences')
#   ('u-202', 'facts')
#
# ОДНА ЗАПИСЬ ЦЕЛИКОМ:
# {'namespace': ['u-101', 'facts'], 'key': 'city', 'value': {'text': 'Работает в Омске',
# 'source': 'диалог'}, 'created_at': '2026-09-19T12:50:23.858270+00:00', 'updated_at':
# '2026-09-19T12:50:23.858270+00:00'}

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

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

1) префикс сравнивается с началом пространства имён, поэтому в выдачу попадают и все вложенные уровни. Каждый элемент префикса должен совпасть целиком: ("u-1",) не найдёт записи ("u-101", "facts")

2) записи сверх limit отрезаются. По умолчанию limit равен 10, поэтому обрезка срабатывает и тогда, когда аргумент не передан. Признака того, что записей было больше, в ответе нет: либо ставьте потолок заведомо выше ожидаемого, либо обходите выдачу постранично аргументом offset

3) порядок выдачи зависит от реализации хранилища. InMemoryStore отдаёт записи в порядке вставки, PostgresStore и SqliteStore при поиске без query сортируют по updated_at от свежих к старым. SqliteStore хранит время записи и правки с точностью до секунды, и порядок записей одной секунды не определён. Полагаться на порядок нельзя: держите в значении своё поле времени и сортируйте по нему

Последняя строка вывода показывает запись целиком. Кроме значения у неё есть служебные поля: пространство имён, ключ, время создания и время правки. В InMemoryStore и SqliteStore при перезаписи ключа время создания тоже обновляется, это разобрано после примера 4.

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

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

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

Тот же модуль, что course_model.py уроков 4-12, без изменений: у build_model есть
необязательный первый аргумент с именем модели, а рядом лежит gateway_kwargs()
для примеров, которые собирают модель сами.

Файл .env берётся тот же, что в уроке 0. Положите его рядом с этой папкой или
выше по дереву: load_dotenv() ищет файл начиная с папки этого модуля и поднимается
вверх.
"""

import os

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model

load_dotenv()


def gateway_kwargs():
    """Возвращает аргументы доступа к провайдеру: имя, адрес, ключ.

    Нужны примерам, которые вызывают init_chat_model сами. У настраиваемой модели
    имени модели при создании нет, поэтому build_model ей не подходит.
    """
    base_url = os.getenv("MODEL_BASE_URL")

    if base_url:
        return {
            "model_provider": "openai",
            "base_url": base_url,
            "api_key": os.environ["OPENAI_API_KEY"],
        }

    return {}


def build_model(model_name=None, **kwargs):
    """Собирает модель курса.

    model_name без значения означает модель из переменной MODEL_NAME. Явное имя
    нужно примерам, где моделей в приложении больше одной.

    Все именованные аргументы уходят в init_chat_model как есть: temperature,
    max_tokens, timeout, max_retries, rate_limiter, profile и прочее из раздела
    Parameters.
    """
    model_name = model_name or os.environ["MODEL_NAME"]
    access = gateway_kwargs()

    if access:
        # Путь для любого адреса, совместимого с OpenAI Chat Completions API.
        return init_chat_model(model=model_name, **access, **kwargs)

    # Путь напрямую к провайдеру.
    return init_chat_model(model_name, **kwargs)

Вспоминать до вызова модели

Записи из хранилища надо ещё достать и передать модели. Сделать это можно инструментом или в middleware.

1) инструмент. Чтение памяти идёт вызовом инструмента, и этот вызов модель ставит в свой ответ. Так устроен пример 5 урока 10. Плюс: до вызова в контексте лежит только описание инструмента, записи приходят по вызову. Минус: чтение стоит лишнего вызова модели. Если вызова в ответе нет, ответ строится без записей, даже когда они нужны. Результат вызова остаётся в истории thread и дальше уходит в модель, как любое сообщение

2) middleware. Записи достаются кодом перед каждым вызовом модели и дописываются в системный промпт. Плюс: записи попадают в контекст сразу, и лишнего вызова модели нет. Минус: они занимают место в контексте всегда, даже когда диалог про другое

Инструмент для чтения памяти вы уже видели в уроке 10, поэтому пример 2 сделан на middleware. Устроен он так же, как динамический промпт из урока 5. Хук wrap_model_call получает ModelRequest, системное сообщение лежит в его поле system_message. Метод request.override(...) собирает новый запрос с другим системным сообщением. В уроке 5 middleware читал request.runtime.context, здесь из того же объекта берётся ещё и runtime.store.

Агент собран с двумя видами памяти и вызывается дважды подряд, в двух разных threads.

Пример 02_recall.py

"""Пример 2 урока 13: знания о человеке дописываются в системный промпт.

Агент собран с двумя видами памяти: чекпойнтер сохраняет переписку одного
thread, в хранилище лежит то, что известно о человеке. Middleware перед каждым
вызовом модели достаёт записи из хранилища и дописывает их в системный промпт,
поэтому отдельного вызова модели для чтения памяти не нужно.

Два вызова агента идут в разных threads. История первого thread во второй не
попадает, а записи о человеке доступны обоим.
"""

from collections.abc import Callable
from dataclasses import dataclass

from langchain.agents import create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call
from langchain.messages import SystemMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore

from course_model import build_model


@dataclass
class Session:
    """Конфигурация запуска: чьи записи доставать из хранилища."""

    user_id: str


store = InMemoryStore()

# Записи уже лежат в хранилище: их положил прошлый диалог, которого в этом
# запуске нет. Как они туда попадают, разбирается в примере 5.
store.put(("u-101", "facts"), "city", {"text": "Работает в Омске"})
store.put(("u-101", "facts"), "role", {"text": "Ведёт склад запчастей"})
store.put(("u-101", "preferences"), "answer_style", {"text": "Отвечать без списков"})


@wrap_model_call
def recall_memory(
    request: ModelRequest,
    handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
    """Дописывает в системный промпт записи о пользователе из хранилища."""
    store = request.runtime.store
    if store is None:
        # Агент собран без хранилища: запрос уходит к модели без изменений.
        return handler(request)

    user_id = request.runtime.context.user_id
    items = store.search((user_id,), limit=10)
    if not items:
        return handler(request)

    known = "\n".join(f"- {item.value['text']}" for item in items)
    blocks = list(request.system_message.content_blocks) + [
        {"type": "text", "text": f"Что известно о пользователе:\n{known}"}
    ]
    return handler(request.override(system_message=SystemMessage(content=blocks)))


agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[],
    system_prompt="Вы рабочий помощник. Отвечайте по-русски и коротко.",
    middleware=[recall_memory],
    context_schema=Session,
    checkpointer=InMemorySaver(),
    store=store,
)

session = Session(user_id="u-101")

first = agent.invoke(
    {"messages": [{"role": "user", "content": "Посчитайте, сколько будет 17 умножить на 3."}]},
    config={"configurable": {"thread_id": "thread-1"}},
    context=session,
)
print("THREAD 1:", first["messages"][-1].text.replace("\n", " "))
print("  сообщений в thread:", len(first["messages"]))
print()

second = agent.invoke(
    {"messages": [{"role": "user", "content": "Что вы считали минуту назад и в каком городе я работаю?"}]},
    config={"configurable": {"thread_id": "thread-2"}},
    context=session,
)
print("THREAD 2:", second["messages"][-1].text.replace("\n", " "))
print("  сообщений в thread:", len(second["messages"]))

# Вывод:
# THREAD 1: 51
#   сообщений в thread: 2
#
# THREAD 2: Минуту назад я не проводил подсчетов, так как не веду хронологию действий.
# Вы работаете в Омске.
#   сообщений в thread: 2

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

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

Проверка if store is None нужна агенту, собранному без хранилища: поле runtime.store у него равно None, и без проверки такой агент упадёт при первом же вызове модели.

Что имеет смысл помнить

Что хранить, решается по видам человеческой памяти. От вида зависит и то, где держать запись.

Вид памяти Что хранится Пример у агента Где держать
Семантическая Факты Город, роль, пожелания пользователя Хранилище, профиль или коллекция
Эпизодическая Опыт Прошлые действия агента, удачные примеры решения Хранилище, коллекция примеров
Процедурная Инструкции Системный промпт самого агента Код, промпт, иногда хранилище

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

2) эпизодическая это примеры в промпте, собранные из прошлых удачных случаев. Механику вы видели в уроке 5, а долговременная память даёт место, где эти примеры хранить

3) процедурная это инструкции. Её составляют веса модели, код агента и системный промпт. Веса и код меняют редко, промпт чаще. Чтобы поправить промпт, ваш код отдаёт модели текущую инструкцию вместе с недавними диалогами или отзывами пользователя. Уточнённую инструкцию из ответа код кладёт в хранилище. У create_agent системный промпт задаётся при сборке агента, поэтому инструкцию из хранилища перед вызовом модели достаёт middleware, как записи в примере 2

Второй вопрос, когда писать в память.

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

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

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

Как память превращается в свалку

После первого удачного примера хочется записывать в память всё подряд: вдруг пригодится.

Пример ниже так и делает: каждая реплика пользователя уходит в хранилище отдельной записью с новым ключом. Три сессии в разные дни, по четыре реплики в каждой.

Пример 03_dump.py

"""Пример 3 урока 13: память, в которую пишут всё подряд.

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

После трёх сессий пример печатает число записей и знаков в них, пять записей,
которые уйдут в промпт при limit=5, и записи про город и про кофе.
"""

import uuid

from langgraph.store.memory import InMemoryStore

store = InMemoryStore()
NAMESPACE = ("u-101", "memories")

SESSIONS = {
    "понедельник": [
        "Работаю в Казани",
        "Кофе пью без сахара",
        "Сегодня болит голова, отвечайте покороче",
        "Веду склад запчастей",
    ],
    "среда": [
        "Кофе пью без сахара",
        "В офисе холодно",
        "Завтра встреча с поставщиком в десять",
        "Смена с 8 до 17",
    ],
    "пятница": [
        "Переехал в Омск",
        "Кофе теперь пью с молоком",
        "Голова прошла",
        "Встреча с поставщиком прошла нормально",
    ],
}

for day, turns in SESSIONS.items():
    for text in turns:
        # Ключ новый на каждую реплику, поэтому ничего не перезаписывается.
        store.put(NAMESPACE, str(uuid.uuid4()), {"text": text, "session": day})

everything = store.search(NAMESPACE, limit=100)
print("ЗАПИСЕЙ В ПАМЯТИ:", len(everything))
print("ЗНАКОВ В ТЕКСТАХ:", sum(len(item.value["text"]) for item in everything))
print()

print("ЧТО УЙДЁТ В ПРОМПТ ПРИ search(..., limit=5):")
for item in store.search(NAMESPACE, limit=5):
    print(f"  [{item.value['session']}] {item.value['text']}")
print()

city_records = [item for item in everything if "Казани" in item.value["text"] or "Омск" in item.value["text"]]
print("ЗАПИСИ ПРО ГОРОД:")
for item in city_records:
    print(f"  [{item.value['session']}] {item.value['text']}")
print()

coffee_records = [item for item in everything if "Кофе" in item.value["text"]]
print("ЗАПИСИ ПРО КОФЕ:", len(coffee_records))
for item in coffee_records:
    print(f"  [{item.value['session']}] {item.value['text']}")

# Вывод:
# ЗАПИСЕЙ В ПАМЯТИ: 12
# ЗНАКОВ В ТЕКСТАХ: 272
#
# ЧТО УЙДЁТ В ПРОМПТ ПРИ search(..., limit=5):
#   [понедельник] Работаю в Казани
#   [понедельник] Кофе пью без сахара
#   [понедельник] Сегодня болит голова, отвечайте покороче
#   [понедельник] Веду склад запчастей
#   [среда] Кофе пью без сахара
#
# ЗАПИСИ ПРО ГОРОД:
#   [понедельник] Работаю в Казани
#   [пятница] Переехал в Омск
#
# ЗАПИСИ ПРО КОФЕ: 3
#   [понедельник] Кофе пью без сахара
#   [среда] Кофе пью без сахара
#   [пятница] Кофе теперь пью с молоком

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

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

В промпт уходят не все записи: limit ставится по бюджету контекста. Какие записи попадут в выдачу, зависит от порядка хранилища, важность записи на него не влияет. InMemoryStore отдаёт записи в порядке вставки, поэтому при limit=5 в промпт попали понедельник и начало среды, а переезд в Омск не попал. SqliteStore и PostgresStore первыми отдали бы свежие записи, но и там отбор идёт по времени правки.

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

Профиль против коллекции

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

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

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

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

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

Пример 04_profile.py

"""Пример 4 урока 13: те же сессии, но запись идёт в профиль.

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

Список PROPOSALS, это поля и значения, которые модель могла бы предложить к
записи по репликам трёх сессий. Здесь он задан данными, чтобы вывод не
менялся от запуска к запуску.
"""

from pydantic import BaseModel, ConfigDict, ValidationError

from langgraph.store.memory import InMemoryStore

NAMESPACE = ("u-101", "profile")
KEY = "main"


class UserProfile(BaseModel):
    """Закрытый набор того, что агенту разрешено помнить о человеке."""

    model_config = ConfigDict(extra="forbid")

    city: str | None = None
    role: str | None = None
    shift: str | None = None
    drink: str | None = None
    answer_style: str | None = None


PROPOSALS = [
    ("понедельник", "city", "Казань"),
    ("понедельник", "drink", "кофе без сахара"),
    ("понедельник", "mood", "болит голова"),
    ("понедельник", "role", "склад запчастей"),
    ("среда", "drink", "кофе без сахара"),
    ("среда", "office_temperature", "холодно"),
    ("среда", "next_meeting", "завтра в десять"),
    ("среда", "shift", "с 8 до 17"),
    ("пятница", "city", "Омск"),
    ("пятница", "drink", "кофе с молоком"),
    ("пятница", "mood", "голова прошла"),
]

store = InMemoryStore()


def save_field(field, value):
    """Кладёт одно поле в профиль. Возвращает пару: принято ли и почему."""
    item = store.get(NAMESPACE, KEY)
    current = dict(item.value) if item else {}
    candidate = {**current, field: value}

    try:
        profile = UserProfile(**candidate)
    except ValidationError:
        return False, "поля нет в схеме"

    if current.get(field) == value:
        return False, "значение не изменилось"

    store.put(NAMESPACE, KEY, profile.model_dump(exclude_none=True))
    return True, "записано"


for day, field, value in PROPOSALS:
    accepted, reason = save_field(field, value)
    mark = "+" if accepted else "-"
    print(f"{mark} [{day}] {field} = {value} :: {reason}")

print()
item = store.get(NAMESPACE, KEY)
print("ЗАПИСЕЙ В ПРОСТРАНСТВЕ ИМЁН:", len(store.search(NAMESPACE)))
print("ПРОФИЛЬ:")
for field, value in item.value.items():
    print(f"  {field}: {value}")
print()
print("СОЗДАН:  ", item.created_at.isoformat())
print("ИЗМЕНЁН: ", item.updated_at.isoformat())

# Вывод:
# + [понедельник] city = Казань :: записано
# + [понедельник] drink = кофе без сахара :: записано
# - [понедельник] mood = болит голова :: поля нет в схеме
# + [понедельник] role = склад запчастей :: записано
# - [среда] drink = кофе без сахара :: значение не изменилось
# - [среда] office_temperature = холодно :: поля нет в схеме
# - [среда] next_meeting = завтра в десять :: поля нет в схеме
# + [среда] shift = с 8 до 17 :: записано
# + [пятница] city = Омск :: записано
# + [пятница] drink = кофе с молоком :: записано
# - [пятница] mood = голова прошла :: поля нет в схеме
#
# ЗАПИСЕЙ В ПРОСТРАНСТВЕ ИМЁН: 1
# ПРОФИЛЬ:
#   city: Омск
#   role: склад запчастей
#   shift: с 8 до 17
#   drink: кофе с молоком
#
# СОЗДАН:   2026-09-19T12:50:30.848115+00:00
# ИЗМЕНЁН:  2026-09-19T12:50:30.848115+00:00

extra="forbid" у Pydantic превращает неизвестное поле в ошибку проверки, и такая запись до хранилища не доходит. В уроке 6 Pydantic проверял готовый ответ модели, здесь он проверяет данные перед записью в память.

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

В последних двух строках вывода время создания и время правки совпадают, хотя профиль записывался шесть раз. В InMemoryStore и SqliteStore вызов put по существующему ключу создаёт запись заново и ставит оба времени в момент записи. Поэтому они совпадают или расходятся на микросекунды. Нужна дата, когда факт появился впервые, держите её внутри значения своим полем.

Отбор при записи в агенте

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

1) системный промпт перечисляет, что считается устойчивым фактом. Так отбирать дешевле всего, но модель может промпт не выполнить

2) схема инструмента сужает поле до перечисления с помощью Literal. Допустимые значения попадают в описание инструмента, которое получает модель, а неподходящий вызов отсекается проверкой аргументов

3) код инструмента ещё раз сверяет имя поля со списком. Пока в схеме стоит Literal, чужое поле до этой проверки не доходит. Она страхует память на случай, если схему поменяют или ослабят

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

Пример 05_write_policy.py

"""Пример 5 урока 13: агент пишет в память по правилу.

Системный промпт перечисляет, что считается устойчивым фактом. Схема
инструмента сужает поле до перечисления, и вызов с чужим полем отсекается
проверкой аргументов до запуска функции. Проверка ALLOWED_FIELDS внутри
инструмента повторяет схему и страхует от её правки.

Ключ записи, это имя поля, поэтому повторный факт перезаписывает прежний и
хранилище не растёт от диалога к диалогу.
"""

from dataclasses import dataclass
from typing import Literal

from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from langgraph.store.memory import InMemoryStore

from course_model import build_model

ALLOWED_FIELDS = ("city", "role", "shift", "answer_style")


@dataclass
class Session:
    """Конфигурация запуска: чьи записи править."""

    user_id: str


@tool
def remember(
    field: Literal["city", "role", "shift", "answer_style"],
    value: str,
    runtime: ToolRuntime[Session],
) -> str:
    """Запомнить устойчивый факт о пользователе.

    Поля: city это город работы; role это чем человек занимается; shift это
    рабочие часы; answer_style это как человеку отвечать. Разовые состояния
    (самочувствие, погода, планы на завтра) этим инструментом не сохраняются.
    """
    if runtime.store is None:
        return "Хранилище не подключено, запомнить нечем."

    if field not in ALLOWED_FIELDS:
        return f"Поле {field} не сохраняется, допустимы: {', '.join(ALLOWED_FIELDS)}."

    namespace = (runtime.context.user_id, "profile")
    runtime.store.put(namespace, field, {"value": value})
    return f"Запомнил {field}."


store = InMemoryStore()

agent = create_agent(
    model=build_model(temperature=0, max_tokens=512),
    tools=[remember],
    system_prompt=(
        "Вы рабочий помощник. В память записывайте только то, что останется "
        "верным через месяц: город работы, занятие, рабочие часы, пожелание "
        "о форме ответа. Самочувствие, погоду и планы на ближайшие дни не "
        "записывайте. Отвечайте по-русски и коротко."
    ),
    context_schema=Session,
    store=store,
)

MESSAGE = (
    "Я переехал в Омск и теперь веду склад запчастей. Смена с 8 до 17. "
    "Сегодня болит голова, а завтра в десять встреча с поставщиком."
)

result = agent.invoke(
    {"messages": [{"role": "user", "content": MESSAGE}]},
    context=Session(user_id="u-101"),
)

print("ОТВЕТ:", result["messages"][-1].text.replace("\n", " "))
print()

print("ВЫЗОВЫ ИНСТРУМЕНТА:")
for message in result["messages"]:
    for call in getattr(message, "tool_calls", []) or []:
        print(f"  {call['name']}({call['args']})")
print()

print("ЧТО ЛЕГЛО В ПАМЯТЬ:")
for item in store.search(("u-101", "profile")):
    print(f"  {item.key}: {item.value['value']}")

# Вывод:
# ОТВЕТ: Запомнил: Омск, склад запчастей, смена 8–17.   Головная боль и завтрашняя
# встреча — это разовое, не записываю. Отлежитесь вечером и к завтрашнему сбору к 10
# будете в порядке. Удачи с поставщиком!
#
# ВЫЗОВЫ ИНСТРУМЕНТА:
#   remember({'field': 'city', 'value': 'Омск'})
#   remember({'field': 'role', 'value': 'веду склад запчастей'})
#   remember({'field': 'shift', 'value': '8:00–17:00'})
#
# ЧТО ЛЕГЛО В ПАМЯТЬ:
#   city: Омск
#   role: веду склад запчастей
#   shift: 8:00–17:00

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

На разовое агент отвечает в реплике, но в память его не записывает. Ответить и запомнить, это два разных решения, и принимаются они по разным правилам.

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

Память на диске и срок годности

InMemoryStore держит записи в памяти процесса, и после завершения программы они пропадают. Для примеров этого хватает. В продакшне берут хранилище на постоянном носителе: PostgresStore, MongoDBStore, RedisStore и UpstashStore.

Есть ещё SqliteStore. Он входит в пакет langgraph-checkpoint-sqlite, который вы поставили в уроке 12 ради SqliteSaver. SqliteStore не требует внешних служб и кладёт всё в один файл, поэтому дальше в уроке долговременная память хранится в нём. Для продакшна SQLite не рекомендуется.

Срок записи задаётся аргументом ttl у метода put, а настройку срока для всего хранилища, TTLConfig, передают при его создании. На страницах документации про хранилища их нет. Срок записей описан только в документации сервера LangSmith: настройка в файле langgraph.json и аргумент ttl у put для отдельной записи. ttl задаётся в минутах. Метод sweep_ttl у SqliteStore удаляет записи, чей срок вышел, и возвращает их количество. До этого вызова истёкшая запись по-прежнему приходит в get и search. В TTLConfig есть и настройка omit_expired. По описанию в коде пакета она прячет истёкшие записи до чистки, если хранилище её поддерживает. Из хранилищ курса её не читает ни одно. InMemoryStore срока не поддерживает, и put с ttl у него падает, это ошибка 2 ниже. SqliteStore настройку omit_expired пропускает.

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

Пример 06_disk_and_expiry.py

"""Пример 6 урока 13: память на диске, срок годности и ручная чистка.

Хранилище лежит файлом SQLite, поэтому записанное одним соединением достаётся
другим, как достанется завтрашним запуском программы.

Разовая заметка пишется с аргументом ttl, а метод sweep_ttl удаляет всё, чему
срок вышел. Профиль пишется без ttl и остаётся. Устаревшее руками убирается
методом delete.

Запуск занимает около пяти секунд: пример ждёт, пока истечёт срок заметки.
"""

import time
from pathlib import Path

from langgraph.store.sqlite import SqliteStore

DB_PATH = Path(__file__).with_name("memory.db")

# Пример начинается с чистого файла, иначе его вывод зависел бы от прошлых запусков.
DB_PATH.unlink(missing_ok=True)


def show(title, items):
    """Печатает заголовок и записи одной строкой на запись."""
    print(title)
    for item in items:
        print(f"  {'/'.join(item.namespace)} :: {item.key} :: {item.value['value']}")
    print()


with SqliteStore.from_conn_string(str(DB_PATH)) as store:
    # setup() создаёт таблицы. Если его не вызвать, SqliteStore выполнит его сам
    # при первом запросе, поэтому второе соединение ниже обходится без него.
    store.setup()

    store.put(("u-101", "profile"), "city", {"value": "Омск"})
    store.put(("u-101", "profile"), "answer_style", {"value": "без списков"})

    # ttl задаётся в минутах, 0.05 это три секунды. В продакшне здесь стоят
    # недели: это срок, после которого заметка перестаёт быть правдой.
    store.put(
        ("u-101", "notes"),
        "promo-september",
        {"value": "Просил напомнить про акцию поставщика"},
        ttl=0.05,
    )
    print("ФАЙЛ ЗАПИСАН:", DB_PATH.name)
    print()

# Соединение закрыто. Дальше с тем же файлом работает другое соединение, так
# же к нему подключится завтрашний запуск программы.
with SqliteStore.from_conn_string(str(DB_PATH)) as store:
    show("ПОСЛЕ ПЕРЕОТКРЫТИЯ ФАЙЛА:", store.search(("u-101",)))

    time.sleep(5)

    print("УДАЛЕНО ПО СРОКУ ГОДНОСТИ:", store.sweep_ttl())
    print()
    show("ОСТАЛОСЬ:", store.search(("u-101",)))

    store.delete(("u-101", "profile"), "answer_style")
    show("ПОСЛЕ РУЧНОГО УДАЛЕНИЯ ОДНОЙ ЗАПИСИ:", store.search(("u-101",)))

    print("ПРОСТРАНСТВА ИМЁН, КОТОРЫЕ ОСТАЛИСЬ:", store.list_namespaces(prefix=("u-101",)))

# Пример удаляет файл, чтобы повторный запуск начинался с того же места.
DB_PATH.unlink(missing_ok=True)

# Вывод:
# ФАЙЛ ЗАПИСАН: memory.db
#
# ПОСЛЕ ПЕРЕОТКРЫТИЯ ФАЙЛА:
#   u-101/notes :: promo-september :: Просил напомнить про акцию поставщика
#   u-101/profile :: city :: Омск
#   u-101/profile :: answer_style :: без списков
#
# УДАЛЕНО ПО СРОКУ ГОДНОСТИ: 1
#
# ОСТАЛОСЬ:
#   u-101/profile :: city :: Омск
#   u-101/profile :: answer_style :: без списков
#
# ПОСЛЕ РУЧНОГО УДАЛЕНИЯ ОДНОЙ ЗАПИСИ:
#   u-101/profile :: city :: Омск
#
# ПРОСТРАНСТВА ИМЁН, КОТОРЫЕ ОСТАЛИСЬ: [('u-101', 'profile')]

Записи в блоке "ПОСЛЕ ПЕРЕОТКРЫТИЯ ФАЙЛА" достало второе соединение с файлом, первое к этому моменту уже закрыто. Так же к вашей памяти подключится завтрашний запуск программы или соседний процесс веб-сервера. Порядок строк в этом блоке не совпадает с порядком записи. В этом запуске все три записи попали в одну секунду, а время правки SqliteStore хранит только до секунды. У вас порядок может выйти другим.

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

sweep_ttl надо вызвать, сам по себе он не запускается. Фоновую чистку включает start_ttl_sweeper, но только у хранилища, созданного с TTLConfig, например SqliteStore.from_conn_string(путь, ttl={"sweep_interval_minutes": 60}). Без этой настройки метод ничего не запускает. Запущенную чистку останавливайте методом stop_ttl_sweeper до выхода из блока with: соединение там закрывается, а поток чистки остаётся и раз в интервал пишет в журнал ошибку. От TTLConfig зависит и продление срока при чтении. В описании TTLConfig в коде пакета сказано, что get и search продлевают срок по умолчанию. В SqliteStore продление работает, только если в TTLConfig явно стоит "refresh_on_read": True. В примере 6 настройки нет, поэтому поиск срок заметки не продлил.

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

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

Семантический поиск по памяти. Для него хранилищу при создании передают index с моделью эмбеддингов, размерностью и списком полей для индексации. Тогда аргумент query у search ищет по смыслу. Без index он не действует: InMemoryStore возвращает те же записи, что и без query, в пределах limit. Отдельные записи можно исключить из индекса, передав index=False в put. Для примера нужна вторая модель, модель эмбеддингов, поэтому поиск по смыслу целиком относится к отдельному курсу про RAG.

Хранилища для продакшна. PostgresStore ставится пакетом langgraph-checkpoint-postgres вместе с драйвером psycopg[binary] и требует вызова store.setup() при первом использовании. При переходе с InMemoryStore меняется только создание хранилища, вызовы put, get, search, delete и list_namespaces остаются прежними. Порядок выдачи при этом другой, свежие записи идут первыми.

Своё хранилище. Оно нужно тем, у кого база данных не из списка поддержанных. В документации обязательными названы пять асинхронных методов: aput, aget, adelete, asearch, alist_namespaces. В коде пакета у BaseStore два абстрактных метода, batch и abatch. Остальные, включая пять из документации, собраны поверх них. Без batch и abatch создание экземпляра подкласса падает с TypeError. Свою реализацию проверяйте сверкой с InMemoryStore.

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

Разборы ниже даны фрагментами, отдельных файлов и импортов у них нет. recall_memory, Session и store в ошибке 1 взяты из примера 2, model там же означает build_model(temperature=0, max_tokens=512). В ошибке 3 store это хранилище, где у пользователя лежат пять пожеланий в ("u-101", "preferences"), а рядом факты и заметки в соседних пространствах имён.

Ошибка 1: хранилище есть, а чекпойнтера нет

# Неправильно: агент собран с хранилищем, но без чекпойнтера
agent = create_agent(
    model=model,
    tools=[],
    system_prompt="Вы рабочий помощник. Отвечайте по-русски и коротко.",
    middleware=[recall_memory],
    context_schema=Session,
    store=store,
)
session = Session(user_id="u-101")
agent.invoke({"messages": [{"role": "user", "content": "Посчитайте, сколько будет 17 умножить на 3."}]}, context=session)
agent.invoke({"messages": [{"role": "user", "content": "Повторите, что вы сейчас сказали."}]}, context=session)

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

Почему так: хранилище даёт долговременную память, чекпойнтер даёт память сессии, и одно другое не заменяет. system_prompt в обоих блоках обязателен: recall_memory дописывает записи к request.system_message, а без системного промпта это поле равно None, и вызов падает с AttributeError.

# Правильно: обе части на месте
agent = create_agent(
    model=model,
    tools=[],
    system_prompt="Вы рабочий помощник. Отвечайте по-русски и коротко.",
    middleware=[recall_memory],
    context_schema=Session,
    checkpointer=InMemorySaver(),
    store=store,
)
config = {"configurable": {"thread_id": "thread-1"}}
session = Session(user_id="u-101")
agent.invoke({"messages": [{"role": "user", "content": "Посчитайте, сколько будет 17 умножить на 3."}]}, config=config, context=session)
agent.invoke({"messages": [{"role": "user", "content": "Повторите, что вы сейчас сказали."}]}, config=config, context=session)

Ошибка 2: ttl у хранилища, которое его не поддерживает

# Неправильно: InMemoryStore не поддерживает срок годности
store = InMemoryStore()
store.put(("u-101", "notes"), "promo", {"value": "напомнить про акцию"}, ttl=60)

Что происходит: вызов падает с NotImplementedError и текстом "TTL is not supported by InMemoryStore. Use a store implementation that supports TTL or set ttl=None".

Почему так: срок годности поддерживает не каждая реализация хранилища. У базового класса за это отвечает признак supports_ttl, по умолчанию он False. У InMemoryStore он не включён, у SqliteStore включён.

# Правильно: срок годности задаётся там, где он есть, и считается в минутах
with SqliteStore.from_conn_string("memory.db") as store:
    store.setup()
    store.put(("u-101", "notes"), "promo", {"value": "напомнить про акцию"}, ttl=60 * 24 * 7)

Ошибка 3: поиск по префиксу вместо точного пространства имён

# Неправильно: хотели пожелания, получили всё
items = store.search(("u-101",), limit=5)

Что происходит: в выдачу попадают записи из всех вложенных пространств имён. Факты и заметки пользователя могут занять места в пятёрке и вытеснить нужные пожелания.

Почему так: первый аргумент поиска, это префикс пространства имён, поэтому выдача собирается со всех уровней ниже него.

# Правильно: полное пространство имён. Если нужен общий срез, отбирайте записи по item.namespace сами
items = store.search(("u-101", "preferences"), limit=5)

everything = store.search(("u-101",), limit=100)
preferences = [item for item in everything if item.namespace[-1] == "preferences"]

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

Соберите в файле support_memory.py агента, который ведёт профиль клиента поддержки так, чтобы память не разрасталась.

Требования:

1) хранилище на SQLite в файле рядом со скриптом, пространства имён (user_id, "profile") и (user_id, "notes")

2) инструмент remember_fact(field, value) с полем из перечисления city, product, contact_time и записью по ключу, равному имени поля

3) инструмент add_note(text, days) для разовых заметок: пишет в notes со случайным ключом и сроком годности days в днях, переведённых в минуты. Тип days задайте float: с int дробный срок не пройдёт проверку аргументов

4) middleware на wrap_model_call, который перед каждым вызовом модели дописывает в системный промпт профиль и заметки

5) агент собран с чекпойнтером. Два вызова идут подряд в разных threads. В первом пользователь сообщает город, продукт и удобное время связи и просит напомнить про заявку со сроком 0.001 дня. Это около полутора минут, поэтому истечение срока видно в том же запуске. Во втором он называет новый город и спрашивает, что записано в его профиле

6) после каждого вызова печатайте ответ модели и реплики пользователя из результата: во втором thread реплика одна, вопрос второго вызова

7) печатайте содержимое обоих пространств имён и число записей в каждом трижды: до вызовов агента, после них и после sweep_ttl(). Перед sweep_ttl() подождите две минуты. У search по умолчанию limit=10, задайте его явно

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

1) факты из первого thread доступны во втором, а реплики первого thread во второй не попадают

2) город из второго thread заменяет прежний, второй записи с ключом city в profile не появляется

3) заметка со сроком days=0.001 (около полутора минут) исчезает после sweep_ttl(), вызванного по истечении срока, а профиль остаётся на месте

4) при втором запуске скрипта первая печать, до вызовов агента, уже показывает профиль первого запуска, потому что файл хранилища остался на диске

Подсказка: дни в минуты переводите в самом инструменте. Тогда в описании инструмента срок задаётся в днях, а единицы хранилища модели знать не нужно.

Итоги урока

Долговременная память, это отдельное хранилище (Store), общее для всех threads. Оно подключается к агенту параметром store. Внутри лежат документы JSON, адресованные парой из пространства имён и ключа, а пространство имён, это кортеж строк, по которому ищут с совпадением по префиксу.

Поиск по префиксу захватывает вложенные уровни. limit по умолчанию равен 10 и обрезает выдачу, признака обрезки в ответе нет. Порядок записей зависит от реализации хранилища.

С инструментом память читается, только когда вызов инструмента есть в ответе модели, и это стоит лишнего вызова модели. Middleware на wrap_model_call дописывает память в системный промпт перед каждым вызовом модели: лишнего вызова нет, но место в контексте занято всегда.

Что писать, зависит от вида памяти. Факты о человеке ложатся в профиль или в коллекцию, опыт превращается в примеры для промпта. Инструкции лежат в коде и в промпте, а промпт можно держать и в хранилище. Профиль с закрытым набором полей не растёт от диалога к диалогу и снимает противоречия в момент записи. Коллекция даёт полноту, но обновлять и удалять старые записи приходится модели.

Свалка получается, если писать в память всё подряд. Её уменьшает отбор при записи: промпт, схема инструмента, проверка в коде. Гарантии отбор не даёт, поэтому лишнее удаляют методом delete по ключу или задают записи срок годности, если хранилище его поддерживает. У SqliteStore срок годности есть, и отдельного сервера базы данных он не требует. При переходе на PostgresStore меняется только создание хранилища.

Агент теперь помнит и диалог, и человека, но всё найденное уходит в контекст при каждом вызове модели. Что именно туда попадёт, сколько это стоит и что делать, когда контекст перестаёт помещаться, в этом уроке не разобрано.

В уроке 14, "Context engineering", разберу это как отдельную инженерную задачу: три типа контекста и три источника данных. Там же будут отбор инструментов, редактирование контекста во время работы агента и то, почему агенты ломаются именно на контексте.

Код урока

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


Предыдущий урок: Память сессии: threads, checkpoints и цена истории

Следующий урок: Context engineering


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

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

5,0

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

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

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

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

Пишите info@aisferaic.ru

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