Один агент — много пользователей

Представь чат-бота в продакшене. В него одновременно пишут Иван, Мария и Олег. Если у бота одна общая «память», случится катастрофа: ответ Ивану подмешается в контекст Марии, бот перепутает, кого как зовут, и выдаст приватные данные одного пользователя другому. Каждому диалогу нужна своя изолированная история.

Наивное решение — поднимать отдельный граф на каждого пользователя — не работает: граф компилируется один раз и стоит дорого (помнишь урок про compile()?), а пользователей могут быть тысячи. Нужен один граф, который умеет держать много независимых сессий. Именно это и есть треды.

ℹ️ Тред ≠ поток ОС

Слово «thread» здесь не про threading.Thread. В LangGraph тред — это логическая сессия: именованная цепочка чекпойнтов одного диалога. С потоками операционной системы это не связано (хотя обрабатывать разные треды можно и параллельно — об этом ниже).

Тред = изолированная сессия

Из урока про checkpointing мы знаем: checkpointer хранит чекпойнты, сгруппированные по thread_id. Теперь посмотрим на это под другим углом: один граф + один checkpointer обслуживают множество тредов, и thread_id в config — это переключатель между ними. Передал один id — работаешь с одной сессией, передал другой — с другой, полностью независимой.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Иван thread_id: a-1 Мария thread_id: b-2 Олег thread_id: c-3 Граф один compiled экземпляр Checkpointer · изолированные треды a-1: история Ивана ["Иван", ...] b-2: история Марии ["Мария", ...] c-3: история Олега ["Олег", ...] thread_id выбирает, с каким тредом работает граф

Три пользователя, один граф, три изолированных истории. Граф не хранит состояние внутри себя — он каждый раз достаёт нужный тред из checkpointer'а по thread_id, отрабатывает шаг и кладёт обновлённый снимок обратно. Поэтому добавить четвёртого пользователя — это просто новый thread_id, без перекомпиляции и без риска утечки между сессиями.

Две сессии на одном графе не пересекаются
python
graph = builder.compile(checkpointer=MemorySaver())

ivan  = {"configurable": {"thread_id": "a-1"}}
maria = {"configurable": {"thread_id": "b-2"}}

graph.invoke({"messages": [{"role": "user", "content": "Меня зовут Иван"}]},  ivan)
graph.invoke({"messages": [{"role": "user", "content": "Меня зовут Мария"}]}, maria)

graph.invoke({"messages": [{"role": "user", "content": "Как меня зовут?"}]}, ivan)
# → "Вас зовут Иван"    ← тред a-1 знает только про Ивана

graph.invoke({"messages": [{"role": "user", "content": "Как меня зовут?"}]}, maria)
# → "Вас зовут Мария"   ← тред b-2 полностью изолирован

Как проектировать thread_id

thread_id — это просто строка, но от того, как ты её формируешь, зависит вся логика сессий. Главный вопрос: что считается одной беседой?

одна беседа = один тред
Генерируй uuid4() на каждый новый диалог. У пользователя может быть много бесед — как чаты в мессенджере.
один пользователь = один тред
Используй user_id как thread_id. Вся переписка пользователя — одна непрерывная память.
составной ключ
Кодируй несколько измерений: f"{tenant}:{user}:{chat}" — для multi-tenant систем.
Генерация и хранение thread_id
python
import uuid

# Новая беседа — новый идентификатор
def new_thread() -> str:
    return str(uuid.uuid4())          # "f47ac10b-58cc-..."

# Составной ключ для multi-tenant приложения
def thread_for(tenant: str, user: str, chat: str) -> str:
    return f"{tenant}:{user}:{chat}"

config = {"configurable": {"thread_id": thread_for("acme", "ivan", "chat-7")}}
⚠️ thread_id живёт в твоём приложении

LangGraph не ведёт «реестр тредов» и не подскажет, какие thread_id существуют. Связку «пользователь → его thread_id(ы)» хранишь ты — в своей БД, сессии или JWT. Checkpointer отвечает только за состояние внутри известного треда, а каким беседам какой id принадлежит — задача приложения.

Жизненный цикл треда

У треда нет явного «создания» — он возникает в момент первого invoke с новым thread_id. Дальше с ним работают через тот же id. Полный набор операций:

Создать, продолжить, посмотреть, удалить
python
cfg = {"configurable": {"thread_id": "chat-7"}}

# 1. СОЗДАТЬ — просто первый запуск с новым id (отдельного вызова нет)
graph.invoke({"messages": [msg]}, cfg)

# 2. ПРОДОЛЖИТЬ — тот же id, шлём только новое сообщение
graph.invoke({"messages": [next_msg]}, cfg)

# 3. ПОСМОТРЕТЬ — текущее состояние и история снимков треда
state = graph.get_state(cfg)                # .values, .next
history = list(graph.get_state_history(cfg))

# 4. УДАЛИТЬ — стереть весь тред из хранилища
#    (метод checkpointer'а; в async-версии — adelete_thread)
checkpointer.delete_thread("chat-7")
ℹ️ «Сбросить диалог» = новый тред

Кнопка «начать заново» в чате — это не очистка состояния, а просто переход на свежий thread_id. Старый тред можно оставить в истории или удалить через delete_thread. Так пользователь получает чистый контекст, а ты — возможность вернуться к прошлой беседе.

Конкурентность: что значит «одновременные»

Раз треды изолированы, их можно обрабатывать параллельно — это и есть «несколько одновременных сессий». Важно понимать модель:

  • Разные треды независимы → запускать их одновременно безопасно. Каждый invoke читает и пишет только свой checkpoint, конфликтовать нечему.
  • Внутри одного треда — строгая последовательность. Два параллельных invoke с одним thread_id будут писать в одну историю и могут затереть друг друга. Запросы одной сессии нужно сериализовать (обрабатывать по очереди).
Параллельная обработка разных тредов через asyncio
python
import asyncio

async def handle(thread_id: str, text: str) -> str:
    cfg = {"configurable": {"thread_id": thread_id}}
    result = await graph.ainvoke({"messages": [{"role": "user", "content": text}]}, cfg)
    return result["messages"][-1].content

async def main():
    # Три РАЗНЫХ треда — обрабатываем одновременно, полностью безопасно
    answers = await asyncio.gather(
        handle("a-1", "Привет от Ивана"),
        handle("b-2", "Привет от Марии"),
        handle("c-3", "Привет от Олега"),
    )
    print(answers)

asyncio.run(main())
Не запускай один тред параллельно сам с собой

Два одновременных ainvoke с одинаковым thread_id — гонка: оба стартуют от одного снимка и один перезапишет результат другого. В чат-приложении ставь сообщения одного пользователя в очередь (например, блокировка по thread_id), а параллель раздавай между разными тредами.

Multi-tenant и продакшен-паттерны

В реальном веб-приложении схема обычно такая: HTTP-эндпоинт получает запрос, достаёт thread_id из сессии/токена пользователя, прокидывает его в config и стримит ответ. Один процесс с одним скомпилированным графом обслуживает всех.

Эндпоинт чата: thread_id из контекста пользователя
python
from fastapi import FastAPI

app = FastAPI()
graph = build_graph()      # компилируем ОДИН раз при старте (с Postgres-saver на проде)

@app.post("/chat")
async def chat(user_id: str, chat_id: str, message: str):
    # thread_id из контекста — изоляция гарантирована
    cfg = {"configurable": {"thread_id": f"{user_id}:{chat_id}"}}
    result = await graph.ainvoke(
        {"messages": [{"role": "user", "content": message}]}, cfg
    )
    return {"reply": result["messages"][-1].content}
Изоляция — это безопасность, а не только удобство

Правильный thread_id из доверенного источника (серверная сессия, проверенный JWT) — это граница безопасности между пользователями. Никогда не бери thread_id напрямую из тела запроса без проверки: иначе злоумышленник подставит чужой id и прочитает чужую переписку. Привязывай тред к аутентифицированному пользователю на сервере.

Типичные ошибки

Ошибка 1: общий thread_id на всех

Захардкоженный "thread_id": "1" для всех пользователей — все беседы сливаются в одну, бот путает собеседников и протекают приватные данные. Каждой сессии — свой id.

Ошибка 2: компиляция графа на каждый запрос

Чтобы «изолировать» пользователей, новички компилируют граф заново на каждый запрос. Это лишнее и вредное: изоляцию даёт thread_id, а не отдельный граф. Компилируй один раз, переключай треды конфигом.

Ошибка 3: параллельные запросы в один тред

Несколько одновременных invoke с одним thread_id устраивают гонку за состояние. Сериализуй запросы одной сессии; параллель — только между разными тредами.

Ошибка 4: thread_id из тела запроса без проверки

Доверять thread_id, присланному клиентом, — дыра в безопасности: можно подставить чужой и получить чужую историю. Формируй id на сервере из аутентифицированного пользователя.

Шпаргалка

Thread management — всё в одном месте
python
# Один граф — много изолированных тредов
graph = builder.compile(checkpointer=saver)     # компилируем ОДИН раз

# thread_id = сессия; переключает, с какой историей работаем
cfg_a = {"configurable": {"thread_id": "a-1"}}
cfg_b = {"configurable": {"thread_id": "b-2"}}

# Жизненный цикл треда
graph.invoke({"messages": [m]}, cfg_a)   # создать (первый запуск с новым id)
graph.invoke({"messages": [m2]}, cfg_a)  # продолжить (тот же id, новое сообщение)
graph.get_state(cfg_a)                   # посмотреть текущее состояние
graph.get_state_history(cfg_a)           # история снимков треда
checkpointer.delete_thread("a-1")        # удалить тред

# Проектирование id
str(uuid.uuid4())                # одна беседа = один тред
user_id                          # один пользователь = один тред
f"{tenant}:{user}:{chat}"        # составной ключ (multi-tenant)

# Конкурентность
#  • разные треды → параллелить БЕЗОПАСНО (asyncio.gather)
#  • один тред → строго последовательно (иначе гонка)
#  • thread_id формируй на сервере из доверенного источника

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

Поуправляй несколькими сессиями:

Задание: многопользовательский чат-бот

  1. Возьми чат-граф с MemorySaver. Заведи трёх «пользователей» с разными thread_id и проведи каждому короткий диалог. Убедись, что бот не путает их между собой.
  2. Сделай функцию new_thread() на uuid4 и эмулируй кнопку «начать заново»: переключи пользователя на свежий тред и проверь, что контекст обнулился, а старый тред всё ещё доступен по прежнему id.
  3. Через asyncio.gather и ainvoke обработай запросы трёх разных тредов одновременно. Убедись, что ответы корректны.
  4. Намеренно запусти два ainvoke с одинаковым thread_id параллельно и понаблюдай за состоянием — почему так делать нельзя.
  5. Удали один тред через delete_thread и проверь, что get_state по нему больше не возвращает историю.
  6. Со звёздочкой: заведи составной thread_id вида tenant:user:chat и напиши хелпер, который собирает config из этих трёх частей.

Что дальше