Один агент — много пользователей
Представь чат-бота в продакшене. В него одновременно пишут Иван, Мария и Олег. Если у бота одна общая «память», случится катастрофа: ответ Ивану подмешается в контекст Марии, бот перепутает, кого как зовут, и выдаст приватные данные одного пользователя другому. Каждому диалогу нужна своя изолированная история.
Наивное решение — поднимать отдельный граф на каждого пользователя — не работает: граф компилируется один раз и стоит дорого (помнишь урок про compile()?), а пользователей могут быть тысячи. Нужен один граф, который умеет держать много независимых сессий. Именно это и есть треды.
Слово «thread» здесь не про threading.Thread. В LangGraph тред — это логическая сессия: именованная цепочка чекпойнтов одного диалога. С потоками операционной системы это не связано (хотя обрабатывать разные треды можно и параллельно — об этом ниже).
Тред = изолированная сессия
Из урока про checkpointing мы знаем: checkpointer хранит чекпойнты, сгруппированные по thread_id. Теперь посмотрим на это под другим углом: один граф + один checkpointer обслуживают множество тредов, и thread_id в config — это переключатель между ними. Передал один id — работаешь с одной сессией, передал другой — с другой, полностью независимой.
Три пользователя, один граф, три изолированных истории. Граф не хранит состояние внутри себя — он каждый раз достаёт нужный тред из checkpointer'а по thread_id, отрабатывает шаг и кладёт обновлённый снимок обратно. Поэтому добавить четвёртого пользователя — это просто новый thread_id, без перекомпиляции и без риска утечки между сессиями.
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 систем.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")}}
LangGraph не ведёт «реестр тредов» и не подскажет, какие thread_id существуют. Связку «пользователь → его thread_id(ы)» хранишь ты — в своей БД, сессии или JWT. Checkpointer отвечает только за состояние внутри известного треда, а каким беседам какой id принадлежит — задача приложения.
Жизненный цикл треда
У треда нет явного «создания» — он возникает в момент первого invoke с новым thread_id. Дальше с ним работают через тот же id. Полный набор операций:
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будут писать в одну историю и могут затереть друг друга. Запросы одной сессии нужно сериализовать (обрабатывать по очереди).
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 и стримит ответ. Один процесс с одним скомпилированным графом обслуживает всех.
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 и прочитает чужую переписку. Привязывай тред к аутентифицированному пользователю на сервере.
Типичные ошибки
Захардкоженный "thread_id": "1" для всех пользователей — все беседы сливаются в одну, бот путает собеседников и протекают приватные данные. Каждой сессии — свой id.
Чтобы «изолировать» пользователей, новички компилируют граф заново на каждый запрос. Это лишнее и вредное: изоляцию даёт thread_id, а не отдельный граф. Компилируй один раз, переключай треды конфигом.
Несколько одновременных invoke с одним thread_id устраивают гонку за состояние. Сериализуй запросы одной сессии; параллель — только между разными тредами.
Доверять thread_id, присланному клиентом, — дыра в безопасности: можно подставить чужой и получить чужую историю. Формируй id на сервере из аутентифицированного пользователя.
Шпаргалка
# Один граф — много изолированных тредов
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 формируй на сервере из доверенного источника
Практическое задание
Поуправляй несколькими сессиями:
Задание: многопользовательский чат-бот
- Возьми чат-граф с
MemorySaver. Заведи трёх «пользователей» с разнымиthread_idи проведи каждому короткий диалог. Убедись, что бот не путает их между собой. - Сделай функцию
new_thread()наuuid4и эмулируй кнопку «начать заново»: переключи пользователя на свежий тред и проверь, что контекст обнулился, а старый тред всё ещё доступен по прежнему id. - Через
asyncio.gatherиainvokeобработай запросы трёх разных тредов одновременно. Убедись, что ответы корректны. - Намеренно запусти два
ainvokeс одинаковымthread_idпараллельно и понаблюдай за состоянием — почему так делать нельзя. - Удали один тред через
delete_threadи проверь, чтоget_stateпо нему больше не возвращает историю. - Со звёздочкой: заведи составной
thread_idвидаtenant:user:chatи напиши хелпер, который собирает config из этих трёх частей.