JavaRush /Курсы /ChatGPT Apps /Сохранение и восстановление контекста между шагами

Сохранение и восстановление контекста между шагами

ChatGPT Apps
11 уровень , 2 лекция
Открыта

1. Что такое контекст workflow и зачем он вообще нужен

В обычном веб‑приложении у вас есть довольно ясное представление, где живёт состояние: база данных, кэш, плюс что‑то на фронте вроде Redux или локального React‑стейта. В ChatGPT App всё веселее: состояние размазано сразу по трём мирам — внутри модели (история диалога), внутри виджета (UI‑стейт) и на вашем сервере/MCP (бизнес‑данные).

Под контекстом workflow мы будем понимать весь набор данных, который нужен, чтобы ответить на вопросы «на каком мы шаге» и «что уже известно». Если говорить про наш учебный GiftGenius, в контекст входят:

  • профиль получателя подарка: возраст, пол, интересы;
  • бюджет и, возможно, валюта;
  • список сгенерированных идей и то, какие из них пользователь лайкнул или скрыл;
  • технические штуки: идентификатор сессии или workflow, статус («profile_collected», «ideas_shown», «checkout_started»).

Этот контекст нужен не только вам как бэкенд‑разработчику. Он нужен самой модели, чтобы она понимала, какие вопросы уже были заданы, какие инструменты уже вызывались и о чём сейчас вообще речь. И он нужен пользователю, чтобы при возвращении в чат не начинать всё с нуля.

Пользователь интуитивно думает, что «ChatGPT всё помнит». На деле модель помнит только текст диалога, и пока он помещается в контекстное окно. Структурированные вещи вроде order_id, cart_id или «список лайкнутых идей» нужно хранить у вас на сервере, иначе вы получите идеальную машину по генерации уверенных, но неправильных утверждений.

2. Три уровня состояния: UI, LLM, бизнес

Удобнее всего понимать сохранение контекста через модель трёх слоёв состояния. Она же «State Triad».

Таблица уровней

Используем небольшую таблицу:

Уровень Где живёт Жизненный цикл За что отвечает Пример в GiftGenius
UI State Виджет (React, widgetState) Пока открыт чат / сообщение с виджетом Визуальное состояние, локальный ввод Какие карточки подсвечены, состояние формы
LLM Context История чата в OpenAI Пока сообщение «помещается» в контекст Понимание диалога и рассуждения «Ищем подарок маме, бюджет $50»
Business State MCP / ваш backend (БД/Redis) Сколько вы захотите (персистентно) Истина: проверенные данные, статусы { step: "ideas", budget: 50, liked: [42, 51] }

UI‑слой быстрый и отзывчивый, но очень хрупкий: ChatGPT может «размонтировать» iframe с виджетом, когда вы уехали вверх по истории, а потом смонтировать заново. Именно для этого есть widgetState, который живёт чуть дольше, чем React‑компонент, и синхронизируется с хост‑клиентом ChatGPT.

LLM‑слой даёт модели ощущение непрерывного диалога, но хранит только текст и tool‑вызовы. Положить туда JSON с вашей корзиной можно, но это по сути просто вставить JSON в текст — модель не будет относиться к этому как к базе данных.

Бизнес‑слой — это то, что вы как инженер можете контролировать: там лежат валидированные данные, индексы, статусы заказов. Как только у вас появился серьёзный сценарий (подарки, бронирование, обучение), именно этот слой должен стать основным источником истины о состоянии.

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

3. Что именно мы сохраняем: структура WorkflowContext

Чтобы говорить предметно, давайте опишем в TypeScript интерфейс контекста для GiftGenius. Пускай у нас уже есть несколько шагов: сбор профиля, выбор бюджета, генерация идей и просмотр/лайки.

Начнём с простой структуры:

// backend/types/workflow.ts
export type GiftWorkflowStep =
  | "profile"
  | "budget"
  | "ideas"
  | "checkout";

export interface GiftWorkflowContext {
  id: string;              // workflowId — идентификатор сценария
  userId?: string;         // если уже настроена аутентификация
  currentStep: GiftWorkflowStep;
  profile?: {
    age?: number;
    gender?: string;
    interests?: string[];
  };
  budget?: {
    min?: number;
    max?: number;
    currency: string;
  };
  ideas?: {
    id: string;
    title: string;
  }[];
  likedIdeaIds: string[];
  hiddenIdeaIds: string[];
  updatedAt: number;       // timestamp для TTL/очистки
}

Это не финальная схема, но важные элементы уже на месте. Есть:

  • идентификатор workflow, по которому мы будем этот контекст искать;
  • текущий шаг, который поможет и виджету, и модели понять, куда мы дошли;
  • набор полей, которые будут заполняться на отдельных шагах;
  • служебные поля вроде времени обновления.

Отдельно про идентификаторы. В этой лекции под workflowId мы понимаем идентификатор конкретного сценария внутри нашего backend/MCP. Он может совпадать с идентификатором сессии диалога ChatGPT (sessionId), но мы на это не полагаемся. userId — это идентификатор пользователя из вашей системы аутентификации (если она есть); один пользователь может иметь несколько активных workflows. В поле id как раз лежит этот workflowId, по которому мы ищем и обновляем контекст.

В следующих разделах разберём три вещи: где хранить такие объекты, как их туда записывать и как доставать их обратно — и в виджет, и в модель.

4. Где хранить состояние: варианты и компромиссы

Про сохранение состояния удобно думать в двух плоскостях: где оно хранится и сколько живёт. В этом разделе сосредоточимся на месте хранения, а к срокам жизни ещё вернёмся в чек‑листе и блоке про типичные ошибки.

Сначала разберёмся с местом хранения.

Внутри диалога (в промпте)

Иногда хочется сказать: «Да давайте просто каждый раз возвращать модели JSON с текущим состоянием, и пусть она сама разбирается». Это работает для очень простых сценариев и коротких цепочек шагов, но быстро утыкается в две проблемы: ограничение длины контекста и отсутствие каких‑либо гарантий целостности данных.

Кроме того, MCP‑протокол по своей природе stateless: как HTTP, он не хранит никакого состояния между запросами по умолчанию. Чтобы привязать tool‑вызов к конкретной сессии, вам нужно явно передавать идентификатор — workflow или session id — либо в аргументах инструмента, либо через метаданные/заголовки.

Поэтому хранить бизнес‑состояние только в диалоге — это скорее учебный эксперимент, чем архитектура.

В виджете: UI + widgetState

На уровне UI мы используем обычный React‑стейт (useState, useReducer и так далее), но, как уже говорилось, компонент может размонтироваться. В Apps SDK для этого есть механизм widgetState, который живёт вне React и синхронизируется с ChatGPT‑хостом. Если при монтировании виджета вы вытаскиваете оттуда сохранённое значение, а при изменениях — кладёте обратно, вы получаете локальное, но довольно удобное хранилище.

Это хранилище отлично подходит для чисто визуального стейта: какие карточки сейчас свернуты, в каком табе вы находитесь, что пользователь ввёл в форму до того, как нажал «Далее». Но оно не заменяет сервер: как только пользователь откроет чат на другом устройстве или через неделю, widgetState может уже не помочь. Да и делать на нём бизнес‑логику — спорное решение.

На сервере/MCP: Map, Redis, БД

Наконец, основной рабочий вариант для продакшена: мы храним GiftWorkflowContext на стороне MCP‑сервера или backend‑сервиса. Поскольку MCP‑клиент и сервер по протоколу stateless, мы должны пробрасывать workflowId (или state_token) в каждый вызов инструмента, чтобы понять, какой именно контекст обновлять.

Вариантов реализации несколько:

  • in‑memory Map в Node.js — подходит для демо и dev‑окружения: всё быстро, но исчезает при рестарте;
  • Redis или другой in‑memory кэш с TTL — хорошо для коротких wizard‑сценариев (мастеров из нескольких шагов): час‑два живёт, потом можно удалить;
  • обычная SQL/NoSQL‑база — обязательна для сценариев типа «вернулся через неделю» или «черновики и корзины».

В этой лекции мы не будем углубляться в конкретную БД, сосредоточимся на интерфейсе и понимании, что именно туда должно попадать.

5. Простейший storage в MCP‑сервере: Map по workflowId

Начнём с чего-то приземлённого: обычная in-memory Map в MCP-сервере, где ключ — workflowId. В учебном демо его можно просто приравнять к sessionId диалога, но в проде лучше держать workflowId как отдельный идентификатор сценария. Значением в этой Map будет GiftWorkflowContext. В реальном проде вы замените это на Redis или БД, но API останется тем же.

Предположим, у нас MCP‑сервер на TypeScript. Добавим где‑то рядом с инициализацией:

// mcp/workflowStore.ts
import { GiftWorkflowContext } from "../backend/types/workflow";

const workflows = new Map<string, GiftWorkflowContext>();

export function getWorkflow(id: string): GiftWorkflowContext | undefined {
  return workflows.get(id);
}

export function saveWorkflow(ctx: GiftWorkflowContext): void {
  workflows.set(ctx.id, { ...ctx, updatedAt: Date.now() });
}

Дальше — инструмент, который сохраняет профиль получателя. Важно, что он принимает workflowId и данные профиля, а внутри обновляет/создаёт соответствующий контекст:

// mcp/tools/setProfile.ts
import { jsonSchema } from "@modelcontextprotocol/sdk"; // псевдоним
import { getWorkflow, saveWorkflow } from "../workflowStore";

export const setProfileTool = {
  name: "gift_set_profile",
  description: "Сохраняет профиль получателя подарка",
  inputSchema: jsonSchema.object({
    workflowId: jsonSchema.string(),
    age: jsonSchema.number().optional(),
    gender: jsonSchema.string().optional(),
    interests: jsonSchema.array(jsonSchema.string()).optional()
  }),
  async run(input: any) {
    const existing = getWorkflow(input.workflowId);
    const ctx = existing ?? {
      id: input.workflowId,
      currentStep: "profile",
      likedIdeaIds: [],
      hiddenIdeaIds: []
    };
    ctx.profile = {
      age: input.age,
      gender: input.gender,
      interests: input.interests ?? []
    };
    ctx.currentStep = "budget";
    saveWorkflow(ctx);
    return {
      structuredContent: {
        type: "profileSaved",
        workflowId: ctx.id,
        profile: ctx.profile,
        nextStep: ctx.currentStep
      }
    };
  }
};

Этот инструмент уже решает две задачи: сохраняет профиль и продвигает currentStep на следующий шаг. В реальном проекте вы, возможно, захотите разделить инструменты «сохранить данные» и «перейти к шагу», но для понимания концепции такой вариант годится.

Обратите внимание на workflowId в аргументах: именно этот параметр привязывает tool‑вызов к нужному контексту. Клиентская часть (виджет или агент) должна его где‑то хранить и пробрасывать.

6. Связка с Apps SDK: где взять workflowId и sessionId

Вопрос «откуда взять workflowId» в ChatGPT Apps немного философский. Возможности зависят от того, используете ли вы аутентификацию, MCP напрямую или Agents SDK. В общих чертах варианты такие: генерация на стороне сервера при первом tool‑вызове или генерация в виджете и передача вниз.

Для учебного примера допустим, что первый шаг — это вызов MCP‑инструмента, который создаёт workflow, а виджет потом только подхватывает его id.

Простейший вариант:

// mcp/tools/startWorkflow.ts
import { randomUUID } from "crypto";
import { saveWorkflow } from "../workflowStore";

export const startWorkflowTool = {
  name: "gift_start_workflow",
  description: "Создаёт новый workflow подбора подарка",
  inputSchema: { type: "object", properties: {} },
  async run() {
    const id = randomUUID();
    saveWorkflow({
      id,
      currentStep: "profile",
      likedIdeaIds: [],
      hiddenIdeaIds: [],
      updatedAt: Date.now()
    });
    return {
      structuredContent: {
        type: "workflowStarted",
        workflowId: id,
        currentStep: "profile"
      }
    };
  }
};

Дальше модель, получив workflowId в ответе инструмента, может:

  • проговорить его в скрытом виде в контексте;
  • передать его в виджет через structuredContent, чтобы виджет сохранил это значение в widgetState и стал подставлять при следующих вызовах инструментов.

На стороне виджета код будет примерно такой.

7. Хранение workflowId и локального UI‑состояния в виджете

Предположим, у нас есть виджет списка идей, который хочет знать, какой workflow он отображает, и помнить локальные лайки, даже если компонент размонтируется. В упрощённом виде:

// app/widgets/GiftIdeasWidget.tsx
import { useEffect, useState } from "react";

interface Idea {
  id: string;
  title: string;
}

interface WidgetProps {
  widgetId: string;
  workflowId: string;   // пришёл из structuredContent
  ideas: Idea[];
}

interface UiState {
  liked: string[];
}

export function GiftIdeasWidget(props: WidgetProps) {
  const [uiState, setUiState] = useState<UiState>({ liked: [] });

  useEffect(() => {
    window.openai.getWidgetState<UiState>(props.widgetId).then(saved => {
      if (saved) setUiState(saved);
    });
  }, [props.widgetId]);

  function toggleLike(id: string) {
    const exists = uiState.liked.includes(id);
    const next: UiState = {
      liked: exists
        ? uiState.liked.filter(x => x !== id)
        : [...uiState.liked, id]
    };
    setUiState(next);
    window.openai.setWidgetState(props.widgetId, next);
    // здесь же можно дернуть MCP-tool "gift_like_idea"
  }

  return (
    <ul>
      {props.ideas.map(idea => (
        <li key={idea.id}>
          {idea.title}
          <button onClick={() => toggleLike(idea.id)}>
            {uiState.liked.includes(idea.id) ? "★" : "☆"}
          </button>
        </li>
      ))}
    </ul>
  );
}

Здесь widgetState используется именно как UI‑слой: мы помним, какие идеи подсвечены. По‑хорошему лайки нужно ещё и отправить на сервер (через MCP‑tool или API‑эндпоинт в Next.js), чтобы бизнес‑слой тоже знал, что пользователь выбрал.

Важно не пытаться на widgetState построить весь workflow. Он должен быть дополнительным слоем к бизнес‑контексту на сервере.

8. Восстановление сценария: пользователь вернулся

Теперь перейдём к более интересному кейсу: пользователь закрыл ChatGPT, вернулся через пару часов или дней и снова открыл тот же чат. Что должно произойти?

Идеальный UX такой: модель и App понимают, что у пользователя уже есть незавершённый workflow, подтягивают его контекст и говорят что‑то вроде: «Вы уже указали профиль и бюджет, давайте продолжим с выбора идей».

Архитектурно это выглядит так:

  1. У вас на сервере хранится GiftWorkflowContext, привязанный к какому‑то userId или хотя бы к внутреннему workflowId.
  2. При новом запросе (или первом tool‑вызове в рамках диалога) App обращается к серверу, спрашивает: «Есть ли для этого пользователя активный workflow?».
  3. Если есть, сервер возвращает его и, возможно, специальный флаг resume, который модель использует в своей реплике.

В простом монолитном демо можно считать, что MCP‑сервер и Next.js‑приложение живут в одном репозитории (или даже процессе), поэтому мы просто переиспользуем тот же workflowStore из MCP и в API‑роутах.

В Next.js это может быть простой API‑роут:

// app/api/gift/workflow/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getWorkflow } from "@/mcp/workflowStore"; // в этом демо MCP и Next.js делят одно хранилище

export async function GET(req: NextRequest) {
  const id = req.nextUrl.searchParams.get("workflowId");
  if (!id) return NextResponse.json({ error: "Missing workflowId" }, { status: 400 });

  const ctx = getWorkflow(id);
  if (!ctx) return NextResponse.json({ exists: false });

  return NextResponse.json({
    exists: true,
    context: ctx
  });
}

Виджет (или MCP‑tool) может вызвать этот эндпоинт, когда ему нужно обновить состояние: например, при первом монтировании или при переключении шага. В учебной конфигурации достаточно связки workflowId + storage в Map; в реальном проде вы добавите туда авторизацию и проверку принадлежности к пользователю.

Если вы используете Agents SDK или более сложную оркестрацию, идею можно расширить до «чекпоинтов» — сохранения состояния на концах крупных шагов, откуда агент может продолжить при перезапуске. Но это уже тема следующего модуля.

9. Движение вперёд-назад и история шагов

Неминуемо возникает вопрос: «А можно ли вернуться на шаг назад?» Для пользователя это очень естественное желание: изменить бюджет, поправить интересы, убрать лишний товар из подборки.

Технически это означает две вещи:

  • нужно хранить не только текущий шаг, но и историю принятых решений;
  • нужно аккуратно пересчитывать производные данные после отката.

Один из вариантов — добавить в контекст поле history, которое будет содержать снимки шагов. Например:

export interface StepSnapshot {
  step: GiftWorkflowStep;
  payload: any;          // конкретные данные шага
  createdAt: number;
}

export interface GiftWorkflowContext {
  // ...предыдущие поля
  history: StepSnapshot[];
}

Когда пользователь заполняет профиль, вы добавляете в историю снимок step: "profile". Когда меняет бюджет — ещё один снимок. При откате к профилю вы:

  • обновляете currentStep = "profile";
  • опционально обрезаете историю до нужного индекса;
  • пересчитываете производные значения (например, очищаете идеи и лайки, если они зависят от бюджета).

На уровне модели важно синхронизироваться: если пользователь нажал в виджете кнопку «Назад», нужно отправить tool‑вызов, который обновит бизнес‑контекст и вернёт в ответе явное описание нового состояния. Иначе вы получите классическую проблему рассинхрона: UI показывает шаг 2, а модель уверена, что вы на шаге 3.

На уровне виджета откат может выглядеть как простая кнопка:

async function goBackToProfile() {
  await fetch("/api/gift/workflow/back", {
    method: "POST",
    body: JSON.stringify({ workflowId, targetStep: "profile" })
  });
  // обновляем UI, чистим local state
}

А уже сервер решит, что именно почистить в контексте и какое сообщение послать модели через tool‑ответ.

10. Как связать всё это с моделью: контекст для reasoning

Всё, что мы делаем со стейтом, в итоге нужно не только пользователю, но и LLM. Модель должна понимать:

  • что уже известно (например, профиль получателя и бюджет);
  • какие шаги уже пройдены;
  • есть ли незавершённые процессы.

Способ доставки этой информации в модель зависит от архитектуры App: вы можете инжектить её в system‑prompt, возвращать в ToolOutput в структурированном виде или использовать специальные поля _meta/annotations, если их поддерживает SDK.

Типичный паттерн такой:

  1. MCP‑tool возвращает в structuredContent краткий снимок контекста: текущий шаг, ключевые поля и, возможно, workflowId.
  2. Apps SDK превращает это в виджет или текст + hidden данные.
  3. Модель, видя structuredContent, понимает, что сценарий продолжился, и строит следующее действие исходя из этого.

В некоторых случаях, если модель «забыла» важные параметры или начала галлюцинировать, вы можете принудительно обновить контекст: вызвать спец‑tool, который вернёт актуальное состояние, и модель «снова войдёт в контекст».

Важно не пытаться засунуть в модель весь GiftWorkflowContext до последнего поля. Достаточно ключевых штук: кому ищем подарок, какой бюджет, сколько идей уже показали, есть ли незавершённый checkout.

11. Мини‑чек‑лист при проектировании WorkflowContext

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

  • Какие шаги есть у сценария и какой минимальный набор данных нужен на каждом?
    Это защитит от гигантских JSON‑монстров «на всякий случай».
  • Что нужно помнить только в рамках одного чата, а что — между сессиями и устройствами?
    Первое можно оставить в widgetState и промптах, второе обязательно отправлять в серверную БД.
  • Как будет выглядеть идентификатор контекста?
    Это может быть связка userId + scenario, отдельный workflowId или и то и другое. Главное — чтобы вы могли однозначно найти контекст в базе.
  • Как вы будете чистить старые workflows?
    Для демо допустимо «никогда не чистить», но в проде вам понадобятся либо TTL, либо фоновые джобы, которые удаляют старые workflows.
  • Нужен ли пользователю откат назад и как вы его реализуете?
    Будете ли вы хранить дерево веток или достаточно линейного списка шагов с возможностью отката.

И последнее: попробуйте прогнать в голове сценарий «пользователь вернулся через неделю в другой чат». Если вы не можете объяснить, как App узнает о старом workflow и что ему показать, нужно усилить часть с персистентным хранением.

12. Типичные ошибки при работе с контекстом между шагами

Ошибка №1: хранить всё только в истории диалога.
Иногда возникает соблазн: «Ну модель же всё видит в тексте, давайте просто каждый раз перечислять в промпте, какой бюджет, какие товары и что пользователь выбрал». Такой подход быстро бьётся о лимиты контекста и совершенно не даёт гарантий целостности: модель может спокойно «забыть» важный факт или перепутать идентификаторы. Бизнес‑критичные вещи (деньги, бронирования, заказы) должны жить в вашем backend/MCP как в источнике истины.

Ошибка №2: попытка построить весь workflow только на widgetState.
widgetState в Apps SDK решает задачу выживания UI‑состояния между размонтированием виджета и обратным монтированием, а не долгосрочного хранения workflow. Если попытаться через него хранить профиль, корзину и историю шагов, вы получите хаос при смене устройства и невозможность восстановиться через длительное время. Виджет отвечает за визуальные мелочи и локальный комфорт. Вся логика сценария должна жить на сервере.

Ошибка №3: отсутствие явного workflowId или другого ключа.
Бывает, что разработчик полагается на неявные идентификаторы вроде conversation_id, но не вводит собственное понятие workflow. В результате становится невозможно отличить один сценарий от другого, разделить несколько параллельных workflows или восстановить именно тот, который нужен. Простая строка workflowId везде, где есть инструменты и API‑эндпоинты, решает массу проблем, особенно в MCP, который по протоколу stateless.

Ошибка №4: смешивание UI‑состояния и бизнес‑логики.
Классическая ситуация: в widgetState складывают не только «какая вкладка открыта», но и «какие товары в корзине», а потом пытаются на основании этого стейта принимать решения на сервере. В итоге при малейшем рассинхроне (виджет отрисовался, но запрос ещё не дошёл, или наоборот) модель видит одну реальность, UI другую, а база — третью. Граница ответственности должна быть чёткой: сервер хранит и валидирует бизнес‑данные, виджет показывает их и даёт пользователю удобный способ их менять.

Ошибка №5: отсутствие сценария восстановления и отката.
Очень легко нарисовать красивый «счастливый путь», где пользователь идеально идёт по шагам, ничего не ломается, ChatGPT не перезагружается, а туннель не рвётся. В реальности каждый шаг может упасть, пользователь может уйти в середине, а через неделю вернуться. Если вы не заложили структуру WorkflowContext, не придумали, как искать «активный» workflow, и не предусмотрели кнопки «Назад» и «Продолжить позже», ваш сценарий будет хрупким и раздражающим для пользователей. Продуманный контекст — это основа для отказоустойчивости, о которой пойдёт речь в следующей лекции.

1
Задача
ChatGPT Apps, 11 уровень, 2 лекция
Недоступна
Мини‑мастер со “step”, который переживает размонтирование виджета
Мини‑мастер со “step”, который переживает размонтирование виджета
1
Задача
ChatGPT Apps, 11 уровень, 2 лекция
Недоступна
Business‑контекст workflow в MCP (Map по workflowId) + “get_context”
Business‑контекст workflow в MCP (Map по workflowId) + “get_context”
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ