Разработка ИИ-агентов · Модуль 2 · Урок 2.8
Управление сессией и многоходовым диалогом
Три разные вещи: запрос, история сообщений и сессия
Их легко спутать, но это разные уровни:
- Запрос (request) — один вызов API. Он без состояния (stateless): модель не помнит ничего, кроме того, что вы прислали в этом запросе (урок 1.2).
- История сообщений (messages) — список реплик, который вы пересобираете и шлёте на каждый запрос, чтобы модель «помнила» диалог. Это состояние одного хода/задачи.
- Сессия (session) — это уже ваша сущность: непрерывное взаимодействие конкретного пользователя с агентом во времени (часто — через много запросов, дней и устройств). У сессии есть идентификатор, владелец, время жизни и хранилище.
Главная мысль: модель состояние не хранит — сессией управляете вы. История сообщений живёт ровно столько, сколько вы её храните и пересылаете.
Что хранить между ходами и где
Между запросами агенту нужно где-то держать состояние сессии. Что именно:
- историю сообщений (или её сжатую версию — см. суммаризацию из урока 2.2);
- идентификатор пользователя/сессии и метаданные (когда создана, когда последняя активность);
- рабочее состояние задачи, если агент ведёт многошаговый процесс.
Где хранить. В памяти процесса (map[sessionID]Session) — только для прототипа: при рестарте всё теряется, и это не переживёт горизонтального масштабирования (несколько реплик сервиса). В проде состояние выносят во внешнее хранилище (например, Redis для горячих сессий с TTL, БД — для долгого), а сам обработчик делают stateless (подробно — в продвинутом курсе). Базовый принцип уже сейчас: сервис не помнит сессию в переменной — он читает её по sessionID в начале запроса и записывает в конце.
Жизненный цикл сессии
У сессии есть этапы, и каждый нужно обработать явно:
- Создание. Новый пользователь/диалог → новый
sessionID, пустая история, отметка времени. - Продолжение. На каждый ход: загрузить сессию по id, добавить новое сообщение, прогнать агентный цикл, сохранить обновлённую историю.
- Рост и сжатие. История не должна расти бесконечно (окно и стоимость, урок 2.2): по триггеру (число сообщений/токенов) — суммаризировать старую часть или обрезать, сохраняя системный промпт и последние реплики.
- Истечение и сброс. Неактивная сессия закрывается по TTL (тайм-аут бездействия). Пользователь может явно начать заново («новый диалог») — это новый
sessionID, а не дозапись в старый.
Частая ошибка — вечная сессия, которая копит всё подряд: растут стоимость, латентность и шум, а качество падает (context rot). Сброс и сжатие — это фичи, а не потеря данных.
Изоляция: не смешивайте пользователей
Сессия всегда принадлежит конкретному пользователю/тенанту. Критическое правило безопасности: история одного пользователя не должна попасть в контекст другого. Ключ хранилища включает идентификатор владельца, а доступ к сессии проверяется по аутентифицированному пользователю запроса, а не по sessionID из ввода (иначе подменой id можно прочитать чужой диалог).
Это тот же принцип, что и scoping инструментов (урок 2.5): доступ определяет код по личности пользователя, а не «доверие» к параметру. Смешение сессий — это утечка персональных данных.
flowchart TB
REQ["Запрос пользователя (sessionID)"] --> LOAD["Загрузить сессию из хранилища"]
LOAD --> CHK{Истекла по TTL?}
CHK -->|да| NEW["Новая сессия"]
CHK -->|нет| CONT["Добавить сообщение"]
NEW --> CONT
CONT --> LOOP["Агентный цикл (урок 1.4)"]
LOOP --> GROW{"История слишком большая?"}
GROW -->|да| SUM["Суммаризировать/обрезать"]
GROW -->|нет| SAVE
SUM --> SAVE["Сохранить сессию в хранилище"]
SAVE --> RESP["Ответ пользователю"]// Session — состояние диалога одного пользователя. Хранится во внешнем сторе,
// не в памяти процесса: так переживает рестарт и масштабирование.
type Session struct {
ID string
OwnerID string // владелец; доступ проверяем по нему, не по ID из ввода
Messages []Message // история (возможно, уже сжатая)
LastActive time.Time
}
type SessionStore interface {
Load(ctx context.Context, id string) (*Session, error)
Save(ctx context.Context, s *Session) error
}
const sessionTTL = 30 * time.Minute
// handleTurn — один ход диалога. Обработчик НЕ хранит сессию в себе: читает по
// id в начале, пишет в конце. user — аутентифицированный пользователь запроса.
func handleTurn(ctx context.Context, store SessionStore, now time.Time, user, sessionID, text string) (string, error) {
s, err := store.Load(ctx, sessionID)
if err != nil || s == nil || now.Sub(s.LastActive) > sessionTTL {
s = &Session{ID: newID(), OwnerID: user} // истекла/нет — начинаем заново
}
if s.OwnerID != user { // изоляция: чужую сессию не отдаём
return "", fmt.Errorf("доступ к сессии запрещён")
}
s.Messages = append(s.Messages, Message{Role: "user", Content: text})
s.Messages = compactIfNeeded(s.Messages) // сжать/обрезать (урок 2.2)
answer, err := runAgent(s.Messages, tools) // цикл из урока 1.4
if err != nil {
return "", err
}
s.Messages = append(s.Messages, Message{Role: "assistant", Content: answer})
s.LastActive = now
return answer, store.Save(ctx, s)
}Anti-patterns
| Анти-паттерн | Почему плохо | Как правильно |
|---|---|---|
| Хранить сессии в памяти процесса | Теряются при рестарте, не переживают масштабирование | Внешнее хранилище (Redis/БД), stateless-обработчик |
| Вечная сессия без TTL и сжатия | Рост стоимости/латентности, context rot | TTL по бездействию + суммаризация/обрезка по триггеру |
| Доступ к сессии по sessionID из ввода | Подмена id → чтение чужого диалога | Проверять владельца по аутентифицированному пользователю |
| Путать сессию с историей сообщений | Нет управления жизненным циклом и владельцем | Сессия — ваша сущность: id, владелец, TTL, хранилище |
| «Новый диалог» дозаписью в старую сессию | Старый контекст шумит и путает модель | Новый диалог = новый sessionID |
Практическое задание
- Опишите тип Session (id, владелец, messages, lastActive) и интерфейс SessionStore (Load/Save); сделайте in-memory реализацию для теста.
- Сделайте обработчик хода stateless: загрузка сессии по id в начале, сохранение в конце; убедитесь, что между запросами состояние не живёт в переменной.
- Добавьте TTL: неактивная дольше N минут сессия начинается заново; проверьте сценарий «вернулся через час».
- Добавьте сжатие истории по триггеру (число сообщений/токенов), сохраняя системный промпт и последние реплики.
- Реализуйте изоляцию: доступ к сессии — по аутентифицированному пользователю; проверьте, что подмена sessionID не отдаёт чужой диалог.
Проверка знаний
Чем сессия отличается от истории сообщений?
Верный ответ: B
B верно. API без состояния: модель ничего не помнит между запросами (C неверно). История — то, что вы пересобираете и шлёте; сессия — управляемая вами сущность поверх неё. A — это разные уровни; D — выбор хранилища не определяет понятие.
Прототип агента хранит сессии в map в памяти процесса. После деплоя на 3 реплики пользователи стали жаловаться, что бот «забывает» диалог через раз.
В чём причина и как исправить?
Верный ответ: B
B верно. При нескольких репликах память не разделяется: балансировщик отправляет следующий ход на другую реплику, где сессии нет. Решение — вынести сессии во внешний стор (Redis/БД) и читать/писать их по id. A/C/D не касаются корня — распределённого состояния.
Почему доступ к сессии нельзя авторизовывать просто по sessionID, пришедшему в запросе?
Верный ответ: B
B верно. Доступ должен определять код по личности пользователя, а не по доверию к параметру ввода — иначе это утечка чужих персональных данных. Тот же принцип, что и scoping инструментов (урок 2.5). A/C/D — технические мелочи, не относящиеся к сути.