Долговременная память: хранилище между сессиями | Курс LangChain урок 13
Цель урока: подключить агенту хранилище, записи которого переживают и смену 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 агентов, пишите, обсудим условия
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru