JavaRush /Курси /ChatGPT Apps /Памʼять і стан агента: session vs persistent, checkpoints...

Памʼять і стан агента: session vs persistent, checkpoints

ChatGPT Apps
Рівень 12 , Лекція 2
Відкрита

1. Навіщо агенту взагалі потрібна окрема памʼять

Цей розділ спирається на попередні уроки модуля 12 про агентів: там ми вже обговорили базову архітектуру, run-цикл та інструменти. Тут зосередимося на памʼяті й стані.

Якщо провести аналогію зі звичним вебзастосунком, LLM тут — це дуже «розумний CPU», який може виконувати складні «текстові програми». А стан агента — це поєднання RAM і SSD: короткоживучі дані сесії та довгострокове сховище.

У класичному ChatGPT-чаті без вашого коду «памʼять» — це просто список повідомлень system/user/assistant/tool, який модель бачить у поточному запиті. Для агента цього замало, адже:

  • йому потрібно памʼятати прогрес складних процесів: який крок workflow уже пройдено, які варіанти подарунків уже відфільтровано, що користувач підтвердив;
  • йому потрібно знати довготривалі факти про користувача: вподобання, адресу доставки, історію минулих замовлень;
  • йому потрібно вміти «переживати» збої: якщо сервер упав посеред підбору подарунка, користувач не має вводити все заново.

Якщо намагатися зберігати все це лише в prompt-контексті, ви швидко впираєтеся в ліміт вікна контексту й платите токенами за одні й ті самі факти. Паралельно зростають і ризики для безпеки: надто багато зайвих даних регулярно «відлітає» до моделі. Тому в агентних системах завжди є явний стан — обʼєкт (або кілька обʼєктів), які живуть поза історією повідомлень і якими керуєте вже ви.

2. Шари стану агента: контекст, session, persistent

Почнемо з поділу на шари. В агента зазвичай є щонайменше три рівні «памʼяті»:

  1. Історія повідомлень (dialogue context).
  2. Сесійний стан (session state).
  3. Довготривалий стан (persistent state).

Важливо не змішувати ці поняття в одну купу.

Історія повідомлень: «брудна памʼять»

Історія повідомлень — це те, що бачить LLM на кожному кроці: system-інструкції, запити користувача, відповіді агента, результати інструментів.

Перевага в тому, що вам не потрібно вести це вручну: Agents SDK і сама платформа беруть це на себе через сутність Session/Conversation.

Недолік у тому, що це «брудна» памʼять: у ній багато зайвих слів, повторів і випадкових користувацьких даних. Такі дані «дорогі» в токенах і погано структуровані. Ви навряд чи хочете, щоб список уже відфільтрованих подарунків на 200 позицій щоразу зчитувався моделлю як звичайний текст.

Session state: робоча короткострокова памʼять

Session-стан — це структурований обʼєкт, що живе в межах однієї сесії/розмови агента. Для фронтенд-розробника добра аналогія — useState або Redux-store, який існує, доки відкрита вкладка.

Тут зазвичай зберігаються речі на кшталт:

  • поточного кроку процесу (наприклад, "collecting_profile" або "filtering_candidates");
  • тимчасового кешу результатів інструментів;
  • параметрів сесії: локалі, обраного каналу, прапорців на кшталт «користувач підтвердив умови».

Цей стан може зберігатися поруч з агентом — у Redis, у in-memory KV-сховищі або через вбудований SessionService конкретного SDK. Головне — не намагатися запхнути все це в system-prompt.

Persistent state: довгострокові дані

Persistent-стан живе довго: між сесіями, оформленнями замовлення, пристроями. Це профіль користувача, його замовлення, збережені списки бажань, налаштування.

Ключова ідея така: агент не «памʼятає» persistent-дані магічно — він їх «читає» через інструменти. Наприклад, get_user_profile, get_past_orders. Жодних прихованих глобальних змінних усередині агента — лише явний виклик.

Порівняльна таблиця

Шар Де живе Життєвий цикл Приклади даних
Messages Session / SDK / OpenAI Один run / діалог повідомлення system/user/tool
Session state KV / SessionService / Redis Поки жива сесія крок workflow, тимчасові кеші
Persistent БД (Postgres/NoSQL/ACP-бекенд) Між сесіями та діалогами профіль, замовлення, збережені списки

3. Session state: що це і як його зберігати

Уявімо, що агент GiftGenius веде багатокроковий процес:

  1. Збирає профіль отримувача подарунка.
  2. Генерує список кандидатів.
  3. Фільтрує їх за бюджетом, доставкою, регіоном.
  4. Готує фінальну добірку.

У процесі він постійно спілкується з користувачем і викликає інструменти. Усе, що стосується «прогресу конкретної сесії підбору подарунка», логічно тримати в session-state.

Приклад структури сесійного стану GiftGenius

Опишемо тип сесійного стану на TypeScript:

// Стан у межах одного "підбору подарунка"
export type GiftSessionState = {
  step:
    | "collecting_profile"
    | "generating_candidates"
    | "filtering"
    | "finalizing";

  // чернетка профілю отримувача
  profileDraft?: {
    recipientType?: string;
    ageRange?: string;
    interests?: string[];
    dislikes?: string[];
  };

  // id товарів-кандидатів, отриманих від backend
  candidateIds?: string[];

  // подарунок, обраний користувачем
  selectedGiftId?: string;

  // технічні прапорці
  locale?: string;
};

Тут ми свідомо не кладемо цілі обʼєкти товарів — лише їхні ID. Повні дані нехай живуть у БД; коли потрібно, агент викликає інструмент get_gift_details(gift_id).

Session в Agents SDK (концептуально)

У багатьох SDK для агентів є абстракція сесії: вона сама бере на себе збереження історії повідомлень і дає змогу додатково тримати структурований стан. У псевдокоді це може виглядати приблизно так:

import { createRunner, OpenAIConversationsSession } from "@openai/agents";
// тип GiftSessionState із прикладу вище

const session = new OpenAIConversationsSession<GiftSessionState>({
  sessionId: "chatgpt-thread-id-or-random",
});

const runner = createRunner({ agent });

const result = await runner.run({
  session,
  input: "Хочу подарунок колезі до 50$",
});

SDK «під капотом»:

  • завантажить історію повідомлень для цієї сесії;
  • додасть нове повідомлення користувача;
  • передасть моделі й інструментарій;
  • оновлений стан (зокрема session.state) збереже назад.

Ви ж працюєте з session.state як зі звичайним обʼєктом.

Оновлення session-state з інструментів

Типовий патерн: інструмент щось обчислює й водночас оновлює сесійний стан. Наприклад, інструмент, що збирає профіль отримувача з відповідей користувача:

export async function updateProfileDraft(
  session: GiftSessionState,
  answers: { questionId: string; value: string }
): Promise<GiftSessionState> {
  const next: GiftSessionState = { ...session };

  if (!next.profileDraft) {
    next.profileDraft = {};
  }

  if (answers.questionId === "interests") {
    next.profileDraft.interests = answers.value.split(",").map((s) => s.trim());
  }

  // ...інші поля

  next.step = "generating_candidates";
  return next;
}

Тут в інструмент ми передаємо не всю Session із SDK, а лише її state (тип GiftSessionState). У реальному коді є сенс називати такий аргумент, наприклад, currentState, щоб не плутати його з обʼєктом Session.

Агент викликає цей інструмент, отримує новий обʼєкт стану та зберігає його назад у session.state.

4. Persistent state: довгострокова памʼять агента

Згадаємо, що GiftGenius працює не лише в одному чаті. Користувач може повернутися за тиждень, з іншого пристрою, і сказати: «Підбери подарунок тому самому другові, що й минулого разу, але бюджет збільшився».

Ця інформація має жити не в session-state, а в persistent-сховищі: у базі, commerce-/ACP-бекенді (commerce-шар, про який буде окремий модуль) тощо.

Приклад persistent-моделі

Опишемо модель профілю отримувача в БД (спрощено, як TypeScript-тип):

// Те, що зберігається в БД
export type RecipientProfile = {
  id: string;
  userId: string;
  label: string; // "колега з маркетингу"
  recipientType: string;
  ageRange?: string;
  interests: string[];
  dislikes: string[];
  lastUsedAt: string; // ISO-дата
};

І репозиторій (нехай поки буде проста Map — у реальному проєкті ви б зробили ORM/SQL-шар):

const profiles = new Map<string, RecipientProfile>();

export const RecipientRepo = {
  async findByUser(userId: string): Promise<RecipientProfile[]> {
    return [...profiles.values()].filter((p) => p.userId === userId);
  },

  async save(profile: RecipientProfile): Promise<void> {
    profiles.set(profile.id, profile);
  },
};

Агент звертається до persistent через інструменти

Важливо, щоб агент не звертався до БД напряму, а працював через tools. Тоді він лишається «чистою» сутністю: в одному місці — LLM і логіка планування, в іншому — реалізація інтеграцій.

Наприклад, інструмент get_recipient_profiles:

export async function getRecipientProfilesTool(input: {
  userId: string;
}): Promise<{ profiles: RecipientProfile[] }> {
  const profiles = await RecipientRepo.findByUser(input.userId);

  return {
    profiles,
  };
}

Агент в описі інструмента читає: «використовуйте цей tool, щоб отримати збережені профілі отримувачів для поточного користувача». Він сам вирішує, коли саме його викликати.

Підсумок: session-state — це про прогрес конкретної розмови й тимчасові кеші, які можна безболісно втратити. Persistent-дані — це те, що має переживати сесії та пристрої: профілі, замовлення, списки бажань. Агент завжди читає їх через інструменти, а не «магічно памʼятає».

5. Як session і persistent працюють разом у run-циклі

Тепер зберемо все в загальну схему. На кожному кроці агентного run-циклу маємо коротку послідовність:

  1. Завантажуємо session-state за sessionId.
  2. За потреби підтягуємо релевантні persistent-дані з БД через інструменти.
  3. Формуємо контекст для моделі (messages + структурований стан).
  4. Модель вирішує: відповісти текстом чи викликати інструменти.
  5. Інструменти оновлюють або session-state, або persistent-дані (через БД).
  6. Зберігаємо новий session-стан і, якщо потрібно, створюємо checkpoint (про це далі).
  7. Віддаємо користувачеві відповідь.

Схема в mermaid:

flowchart TD
    A[Отримати введення користувача] --> B["Завантажити Session (state + messages)"]
    B --> C{Потрібні persistent-дані?}
    C -- Так --> D[Викликати tools: get_user_profile, get_recipient_profiles]
    C -- Ні --> E[Сформувати контекст для LLM]
    D --> E
    E --> F["Викликати модель (LLM)"]
    F --> G{Модель хоче викликати tool?}
    G -- Так --> H[Виконати tool, оновити session/persistent]
    G -- Ні --> I[Підготувати фінальну відповідь]
    H --> J[Створити checkpoint і зберегти Session]
    I --> J
    J --> K[Відповідь користувачу]

Такий цикл робить поведінку агента відтворюваною: на кожному кроці ми явно знаємо, який був стан до виклику моделі та що змінилося після.

6. Checkpoints: знімки стану агента

Checkpoints — це збережені «знімки стану» агента на важливому кроці процесу. Це не просто «поточний session-state», а зафіксований у зовнішньому сховищі факт: на кроці N у нас був такий-то state, такі-то результати інструментів, такий-то користувацький ввід.

Навіщо вони потрібні:

  • відновлення після помилок і падінь;
  • можливість для користувача «продовжити пізніше»;
  • налагодження: відтворюваність проблемного запуску;
  • аудит: що саме агент зробив перед тим, як, наприклад, створити замовлення.

Що зазвичай входить до checkpoint

Типовий checkpoint містить:

  • ідентифікатори: runId, userId, workflowId, stepId;
  • сесійний стан на цей момент;
  • ключові ідентифікатори persistent-сутностей (наприклад, id чернетки замовлення);
  • метадані: час створення, версія агента.

Важливо не тягнути туди весь текст діалогу. Нижче, у розділі про гігієну памʼяті, ми ще повернемося до того, що саме варто зберігати, а що — ні.

Натомість краще зберігати посилання на Session або короткий підсумок кроків.

7. Проєктуємо чекпоінти для GiftGenius

Візьмемо наш процес підбору подарунка й вирішимо, де саме хочемо чекпоінти. Наприклад:

  • після того, як зібрали профіль отримувача;
  • після генерації та первинної фільтрації кандидатів;
  • перед тим, як запропонувати користувачеві фінальний вибір.

Типи для checkpoint і workflow-стану

Опишемо стан workflow (дуже схожий на GiftSessionState, але це вже «зліпок» для чекпоінтів):

export type GiftWorkflowStep =
  | "profile_collected"
  | "candidates_generated"
  | "filtered"
  | "final_choice_made";

export type GiftCheckpoint = {
  id: string;
  runId: string;
  userId: string;

  step: GiftWorkflowStep;

  // частина session-стану,
  // яка нам потрібна для відновлення
  sessionState: GiftSessionState;

  // які id кандидатів були згенеровані
  candidateIds: string[];

  createdAt: string; // ISO
  agentVersion: string;
};

Сховище чекпоінтів (спрощено)

Зробимо, як і раніше, просту Map замість справжньої БД:

const checkpoints = new Map<string, GiftCheckpoint>();

export const GiftCheckpointRepo = {
  async save(cp: GiftCheckpoint) {
    checkpoints.set(cp.id, cp);
  },

  async findByRun(runId: string): Promise<GiftCheckpoint[]> {
    return [...checkpoints.values()].filter((c) => c.runId === runId);
  },

  async findLastByUser(userId: string): Promise<GiftCheckpoint | undefined> {
    return [...checkpoints.values()]
      .filter((c) => c.userId === userId)
      .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
  },
};

Створення чекпоінта з коду агента

Уявімо helper, який ми викликаємо після важливого кроку:

import { randomUUID } from "crypto";

export async function createCheckpoint(params: {
  runId: string;
  userId: string;
  step: GiftWorkflowStep;
  sessionState: GiftSessionState;
  candidateIds: string[];
}) {
  const checkpoint: GiftCheckpoint = {
    id: randomUUID(),
    runId: params.runId,
    userId: params.userId,
    step: params.step,
    sessionState: params.sessionState,
    candidateIds: params.candidateIds,
    createdAt: new Date().toISOString(),
    agentVersion: "v1.3.0",
  };

  await GiftCheckpointRepo.save(checkpoint);
}

Агент у потрібний момент може викликати:

await createCheckpoint({
  runId,
  userId,
  step: "filtered",
  sessionState,
  candidateIds,
});

Під час відновлення ми:

  1. Знаходимо останній чекпоінт за runId або userId.
  2. Відновлюємо session.state із checkpoint.sessionState.
  3. За потреби підтягуємо з БД свіжі дані за candidateIds.

8. Де зберігати session, persistent і checkpoints технічно

На рівні інфраструктури у вас зазвичай є три різні класи сховищ:

  • In-memory — для dev/демо: швидка, але тимчасова памʼять.
  • Redis (або інший KV-store) — для session-стану.
  • Реляційна/NoSQL БД — для persistent-даних і чекпоінтів.

In-memory store для локальної розробки

Для локального dev-режиму цілком достатньо простого in-memory store. Наприклад, мінісховище з TTL для сесій:

type StoredSession<T> = {
  state: T;
  expiresAt: number;
};

const sessions = new Map<string, StoredSession<GiftSessionState>>();

export function saveSession(sessionId: string, state: GiftSessionState) {
  sessions.set(sessionId, {
    state,
    expiresAt: Date.now() + 30 * 60 * 1000, // 30 хвилин
  });
}

export function loadSession(sessionId: string): GiftSessionState | undefined {
  const stored = sessions.get(sessionId);
  if (!stored) return undefined;
  if (stored.expiresAt < Date.now()) {
    sessions.delete(sessionId);
    return undefined;
  }
  return stored.state;
}

Така штука чудово підходить для локальної розробки, але в продакшені, за горизонтального масштабування (кілька екземплярів сервісу), уже не працюватиме.

Redis для session-state

У продакшені session-стан зручно зберігати в Redis:

  • швидкий запис і читання;
  • TTL «із коробки»;
  • доступність для всіх екземплярів сервісу.

Псевдоприклад (спрощено):

// Обгортка навколо клієнта Redis
export async function saveSessionToRedis(
  sessionId: string,
  state: GiftSessionState
) {
  const json = JSON.stringify(state);
  await redis.set(`session:${sessionId}`, json, "EX", 60 * 30); // 30 хвилин
}

export async function loadSessionFromRedis(
  sessionId: string
): Promise<GiftSessionState | undefined> {
  const json = await redis.get(`session:${sessionId}`);
  return json ? (JSON.parse(json) as GiftSessionState) : undefined;
}

Postgres/інша БД для persistent і чекпоінтів

Persistent-стан і чекпоінти — це вже «серйозні» сутності, для яких важливі транзакції, міграції, індекси та інші радощі життя. Їх розміщують у Postgres, MySQL, Firestore тощо.

Архітектурний патерн тут простий:

  • session у Redis з TTL;
  • persistent і checkpoints у БД без TTL (або з бізнес-залежною політикою зберігання).

9. Гігієна памʼяті: розміри, приватність, розділення відповідальності

Памʼять агента — це не просто «кудись покладемо обʼєкт і поїхали». Є кілька важливих правил, які економлять гроші й бережуть вам сон.

Не класти все до messages

Історія повідомлень — дорогий ресурс:

  • її довжина сильно впливає на вартість запиту до моделі;
  • у ній, як правило, багато «шуму».

Тому:

  • намагайтеся витягати з історії факти в структурований стан якомога раніше;
  • використовуйте узагальнення (summarization) для старих частин історії;
  • якщо зберігаєте в чекпоінтах текстову історію, робіть це окремо від того, що надсилається моделі.

Конфіденційність і PII

Особливо для commerce-сценаріїв важливо не зберігати чутливі дані в місцях, куди вони не мають потрапляти. Документи з архітектури памʼяті прямо підкреслюють, що PII-дані не варто тримати в messages або чекпоінтах без очищення.

Практичні правила:

  • не кладіть електронну пошту/телефон/адресу напряму в session-state, якщо це не потрібно для роботи агента;
  • у логи та чекпоінти намагайтеся записувати ідентифікатори (userId, recipientProfileId) замість «сирих» рядків;
  • якщо потрібно протягнути PII через кілька кроків — використовуйте окремі захищені поля в persistent-сховищі, а в state передавайте лише ключ.

Розділення бізнес-даних і логу діалогу

Гарний патерн — вважати state «чистою» памʼяттю, а messages — «брудною».

Тобто:

  • бізнес-сутності (профілі, замовлення, кошики) увесь час живуть у БД;
  • state/чекпоінти містять мінімум, потрібний для відновлення процесу;
  • логи/історія чату зберігаються окремо (наприклад, у векторному сховищі) та використовуються для аналітики, але не підмішуються до кожного запиту моделі.

10. Мініпрактика: що б ви зберегли?

Щоб закріпити різницю між шарами памʼяті, давайте на мить відірвемося від теорії й подумаємо про конкретний кейс. Не обовʼязково писати код — достатньо на папері або в голові прикинути структури.

Уявіть, що ваш агент GiftGenius вів із користувачем такий діалог:

  • Користувач: «Потрібен подарунок колезі-розробнику, бюджет до 50 $, він любить настільні ігри та кофеїн».
  • Агент: ставить кілька уточнювальних запитань.
  • Користувач: «Він ненавидить горнята і вже завалений блокнотами».
  • Агент: генерує список із 10 ідей, користувач обирає одну, але каже: «Я потім повернуся все оформити».

Подумайте:

  1. Що ви покладете в session-state (який може «померти» через 30 хвилин)?
  2. Що піде в persistent-сховище, щоб користувач міг повернутися за тиждень?
  3. Як би виглядав checkpoint після вибору ідеї, але до оформлення замовлення?

Спробуйте накидати відповідні TypeScript-типи та функції saveSessionState, savePersistentState, createGiftIdeaCheckpoint за аналогією з прикладами в цій лекції. За бажання можете прямо накидати ці типи та функції в редакторі — це буде хороший мінічекпоінт перед наступною лекцією.

11. Типові помилки під час роботи з памʼяттю агента

Помилка № 1: намагатися зберігати все лише в історії повідомлень.
Розробник радіє: «Ну модель же і так бачить весь діалог, навіщо вигадувати ще якийсь state?». У результаті за кілька десятків повідомлень вікно контексту забивається сміттям, токени коштують як новий MacBook, а поведінка агента стає нестабільною — він просто не бачить важливих старих фактів. Цю проблему потрібно розвʼязувати явним виокремленням session-state і persistent-сховища, а не збільшенням лімітів.

Помилка № 2: змішувати session і persistent в один обʼєкт.
Іноді спокусливо завести одну «велику» сутність AgentState, покласти туди все підряд і зберігати «як є» в базу. Тоді розмивається межа між тимчасовими даними конкретної розмови та довгостроковими даними користувача. Починаються історії на кшталт «після розгортання всі сесії таємниче відновилися з торішніх даних» або «сесія одного користувача випадково підхопила чужий persistent-профіль». Розділяйте рівні свідомо.

Помилка № 3: зберігати в чекпоінтах занадто багато.
Поширена помилка — записувати в checkpoint увесь JSON відповіді інструментів, усю історію діалогу, «сирі» дані інтеграцій тощо. Після кількох тижнів експлуатації база чекпоінтів роздувається до величезних розмірів, резервні копії тривають по годині, а запити до БД починають гальмувати. У чекпоінті мають бути лише ті факти, які справді потрібні, щоб продовжити процес, плюс мінімум метаданих.

Помилка № 4: забувати про TTL і очищення session-state.
Якщо session-стани не мають строку життя, то будь-який випадковий експеримент користувача в dev-режимі лишається в Redis назавжди. За кілька місяців ви дивитеся на моніторинг і бачите гору «забутих» сесій, що відʼїдають памʼять. Session-рівень потрібно проєктувати з явним TTL, а persistent-рівень — із продуманою політикою зберігання (retention).

Помилка № 5: зберігати PII у state і чекпоінтах без потреби.
Особливо небезпечно, коли в session-state бездумно кладуть електронну пошту, адресу, номер картки, а потім цей обʼєкт серіалізується в логи, «повзе» в аналітику й у чекпоінти. Це створює серйозні ризики з погляду регуляторики та безпеки. Краще зберігати безпечні ідентифікатори й за потреби резолвити їх у реальні дані через окремі захищені інструменти.

Помилка № 6: відсутність стратегії відновлення з чекпоінтів.
Деякі команди чесно записують чекпоінти, але водночас не продумують, як саме агент має з них відновлюватися. Урешті, коли «щось пішло не так», розробники дивляться в таблицю з красивими JSONʼами, але не мають коду, який уміє за ними реконструювати запуск. Чекпоінтинг без сценарію відновлення — це просто дорогий лог, а не інструмент надійності.

Помилка № 7: жорстка привʼязка агента до конкретної реалізації сховища.
Якщо код агента напряму звертається до Redis/Postgres, його складніше переносити, тестувати й розвивати. Під час зміни архітектури (наприклад, появи MCP-ресурсів або окремого state-сервісу) доведеться серйозно переробляти агентну логіку. Набагато краще, коли агент бачить лише абстракції Session і набір tools, а вже інструменти знають, де саме лежать дані.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ