Разработка ИИ-агентов · Модуль 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): доступ определяет код по личности пользователя, а не «доверие» к параметру. Смешение сессий — это утечка персональных данных.

Жизненный цикл сессии: stateless-обработчик читает и пишет состояние по sessionID
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["Ответ пользователю"]
Stateless-обработник: сессия читается по id и пишется обратно; изоляция по владельцу
// 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 rotTTL по бездействию + суммаризация/обрезка по триггеру
Доступ к сессии по sessionID из вводаПодмена id → чтение чужого диалогаПроверять владельца по аутентифицированному пользователю
Путать сессию с историей сообщенийНет управления жизненным циклом и владельцемСессия — ваша сущность: id, владелец, TTL, хранилище
«Новый диалог» дозаписью в старую сессиюСтарый контекст шумит и путает модельНовый диалог = новый sessionID

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

  • Опишите тип Session (id, владелец, messages, lastActive) и интерфейс SessionStore (Load/Save); сделайте in-memory реализацию для теста.
  • Сделайте обработчик хода stateless: загрузка сессии по id в начале, сохранение в конце; убедитесь, что между запросами состояние не живёт в переменной.
  • Добавьте TTL: неактивная дольше N минут сессия начинается заново; проверьте сценарий «вернулся через час».
  • Добавьте сжатие истории по триггеру (число сообщений/токенов), сохраняя системный промпт и последние реплики.
  • Реализуйте изоляцию: доступ к сессии — по аутентифицированному пользователю; проверьте, что подмена sessionID не отдаёт чужой диалог.

Проверка знаний

Чем сессия отличается от истории сообщений?

  • A Это синонимы
  • B История сообщений — состояние одной задачи, которое вы шлёте в запрос; сессия — ваша сущность с id, владельцем, TTL и хранилищем, живущая через много запросов
  • C Сессию хранит сама модель между вызовами
  • D История сообщений всегда хранится в Redis, а сессия — в памяти

Прототип агента хранит сессии в map в памяти процесса. После деплоя на 3 реплики пользователи стали жаловаться, что бот «забывает» диалог через раз.

В чём причина и как исправить?

  • A Мало памяти — увеличить лимиты контейнера
  • B Состояние в памяти процесса не общее для реплик: запрос попадает на другую реплику без сессии; нужно внешнее хранилище и stateless-обработчик
  • C Нужно увеличить размер окна контекста
  • D Проблема в модели — сменить на более крупную

Почему доступ к сессии нельзя авторизовывать просто по sessionID, пришедшему в запросе?

  • A sessionID слишком длинный для индекса
  • B Подменив чужой sessionID, пользователь прочитает чужой диалог; владельца надо проверять по аутентифицированному пользователю (изоляция)
  • C Это замедляет загрузку сессии
  • D sessionID нельзя хранить в Redis