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, подтягивают его контекст и говорят что‑то вроде: «Вы уже указали профиль и бюджет, давайте продолжим с выбора идей».
Архитектурно это выглядит так:
- У вас на сервере хранится GiftWorkflowContext, привязанный к какому‑то userId или хотя бы к внутреннему workflowId.
- При новом запросе (или первом tool‑вызове в рамках диалога) App обращается к серверу, спрашивает: «Есть ли для этого пользователя активный workflow?».
- Если есть, сервер возвращает его и, возможно, специальный флаг 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.
Типичный паттерн такой:
- MCP‑tool возвращает в structuredContent краткий снимок контекста: текущий шаг, ключевые поля и, возможно, workflowId.
- Apps SDK превращает это в виджет или текст + hidden данные.
- Модель, видя 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, и не предусмотрели кнопки «Назад» и «Продолжить позже», ваш сценарий будет хрупким и раздражающим для пользователей. Продуманный контекст — это основа для отказоустойчивости, о которой пойдёт речь в следующей лекции.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ