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-шар дає моделі відчуття безперервного діалогу, але зберігає лише текст і виклики інструментів. Можна покласти туди 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 — це ідентифікатор користувача з вашої системи автентифікації (якщо вона є). Один користувач може мати кілька активних workflow. У полі id якраз і лежить цей workflowId, за яким ми шукаємо й оновлюємо контекст.
У наступних розділах розберемо три речі: де зберігати такі обʼєкти, як їх туди записувати і як діставати їх назад — і у віджет, і в модель.
4. Де зберігати стан: варіанти та компроміси
Про збереження стану зручно думати у двох площинах: де він зберігається і скільки живе. У цьому розділі зосередимося на місці зберігання. До строків життя ще повернемося в чек-листі та блоці про типові помилки.
Спочатку розберімося з місцем зберігання.
Усередині діалогу (у промпті)
Іноді хочеться сказати: «Та давайте просто щоразу повертати моделі JSON з поточним станом — і хай вона сама розбирається». Це працює для дуже простих сценаріїв і коротких ланцюжків кроків. Але досить швидко ви впираєтеся в дві проблеми: обмеження довжини контексту й відсутність будь-яких гарантій цілісності даних.
До того ж MCP-протокол за своєю природою stateless: як HTTP, він не зберігає жодного стану між запитами за замовчуванням. Щоб привʼязати виклик інструмента до конкретної сесії, вам потрібно явно передавати ідентифікатор — workflow або sessionId — або в аргументах інструмента, або через метадані/заголовки.
Тому зберігати бізнес-стан лише в діалозі — радше навчальний експеримент, ніж архітектурне рішення.
У віджеті: UI + widgetState
На рівні UI ми використовуємо звичайний стан React (useState, useReducer тощо). Але, як уже згадувалося, компонент може розмонтуватися. В Apps SDK для цього є механізм widgetState, який живе поза React і синхронізується з ChatGPT-хостом. Якщо під час монтування віджета ви дістаєте звідти збережене значення, а під час змін — записуєте назад, ви отримуєте локальне, але доволі зручне сховище.
Це сховище чудово підходить для суто візуального стану: які картки зараз згорнуті, на якій вкладці ви перебуваєте, що користувач увів у форму до того, як натиснув «Далі». Але воно не замінює сервер. Щойно користувач відкриє чат на іншому пристрої або повернеться за тиждень, widgetState може вже не допомогти. Та й будувати на ньому бізнес-логіку — рішення спірне.
На сервері/MCP: Map, Redis, БД
І нарешті основний робочий варіант для промислового середовища: ми зберігаємо GiftWorkflowContext на боці MCP-сервера або бекенд-сервісу. Оскільки MCP-клієнт і сервер за протоколом stateless, ми маємо передавати workflowId (або state_token) у кожен виклик інструмента, щоб розуміти, який саме контекст оновлювати.
Варіантів реалізації кілька:
- in-memory Map у Node.js — підходить для демо й dev-оточення: усе швидко, але зникає після перезапуску;
- Redis або інший in-memory кеш із TTL — добре для коротких wizard-сценаріїв (майстрів із кількох кроків): годину-дві живе, потім можна видалити;
- звичайна SQL/NoSQL-база — обовʼязкова для сценаріїв на кшталт «повернувся за тиждень» або «чернетки й кошики».
У цій лекції ми не заглиблюватимемося в конкретну БД. Натомість зосередимося на інтерфейсі та на розумінні того, що саме має туди потрапляти.
5. Найпростіше сховище в 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 в аргументах: саме цей параметр привʼязує виклик інструмента до потрібного контексту. Клієнтська частина (віджет або агент) має десь зберігати його й передавати під час наступних викликів.
6. Звʼязка з Apps SDK: де взяти workflowId і sessionId
Питання «звідки взяти workflowId» у ChatGPT Apps трохи філософське. Можливості залежать від того, чи використовуєте ви автентифікацію, MCP напряму або Agents SDK. Загалом варіанти такі: згенерувати на боці сервера під час першого виклику інструмента або згенерувати у віджеті й передати вниз.
Для навчального прикладу припустімо, що перший крок — це виклик 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-інструмент або API-ендпойнт у Next.js), щоб бізнес-шар також знав, що обрав користувач.
Важливо не намагатися будувати на widgetState увесь workflow. Він має бути додатковим шаром поверх бізнес-контексту на сервері.
8. Відновлення сценарію: користувач повернувся
Тепер перейдемо до цікавішого кейсу: користувач закрив ChatGPT, повернувся за кілька годин або днів і знову відкрив той самий чат. Що має відбутися?
Ідеальний UX такий: модель і App розуміють, що в користувача вже є незавершений workflow, підтягують його контекст і кажуть щось на кшталт: «Ви вже вказали профіль і бюджет — давайте продовжимо з вибору ідей».
Архітектурно це виглядає так:
- У вас на сервері зберігається GiftWorkflowContext, привʼязаний до якогось userId або принаймні до внутрішнього workflowId.
- Під час нового запиту (або першого виклику інструмента в межах діалогу) 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: "Не передано workflowId" }, { status: 400 });
const ctx = getWorkflow(id);
if (!ctx) return NextResponse.json({ exists: false });
return NextResponse.json({
exists: true,
context: ctx
});
}
Віджет (або MCP-інструмент) може викликати цю кінцеву точку, коли потрібно оновити стан: наприклад, під час першого монтування або під час перемикання кроку. У навчальній конфігурації достатньо звʼязки workflowId + сховище в Map. У реальному проєкті ви додасте сюди авторизацію та перевірку належності користувачеві.
Якщо ви використовуєте Agents SDK або складнішу оркестрацію, ідею можна розширити до «чекпойнтів» — збереження стану наприкінці великих кроків, звідки агент зможе продовжити після перезапуску. Але це вже тема наступного модуля.
9. Рух уперед-назад і історія кроків
Неминуче виникає запитання: «А чи можна повернутися на крок назад?». Для користувача це дуже природне бажання: змінити бюджет, підправити інтереси, прибрати зайвий товар із підбірки.
Технічно це означає дві речі:
- треба зберігати не лише поточний крок, а й історію ухвалених рішень;
- потрібно акуратно перераховувати похідні дані після відкату.
Один із варіантів — додати в контекст поле history, яке міститиме знімки кроків. Наприклад:
export interface StepSnapshot {
step: GiftWorkflowStep;
payload: any; // конкретні дані кроку
createdAt: number;
}
export interface GiftWorkflowContext {
// ...попередні поля
history: StepSnapshot[];
}
Коли користувач заповнює профіль, ви додаєте в історію знімок step: "profile". Коли змінює бюджет — ще один знімок. Під час відкату до профілю ви:
- оновлюєте currentStep = "profile";
- за потреби обрізаєте історію до потрібного індексу;
- перераховуєте похідні значення (наприклад, очищуєте ідеї та вподобання, якщо вони залежать від бюджету).
На рівні моделі важливо синхронізуватися. Якщо користувач натиснув у віджеті кнопку «Назад», потрібно надіслати виклик інструмента, який оновить бізнес-контекст і поверне у відповіді явний опис нового стану. Інакше ви отримаєте класичну проблему розсинхрону: UI показує крок 2, а модель упевнена, що ви на кроці 3.
На рівні віджета відкат може виглядати як проста кнопка:
async function goBackToProfile() {
await fetch("/api/gift/workflow/back", {
method: "POST",
body: JSON.stringify({ workflowId, targetStep: "profile" })
});
// оновлюємо UI, чистимо local state
}
А вже сервер вирішить, що саме почистити в контексті й яке повідомлення надіслати моделі через відповідь інструмента.
10. Як повʼязати все це з моделлю: контекст для reasoning
Усе, що ми робимо зі станом, зрештою потрібно не лише користувачеві, а й LLM. Модель має розуміти:
- що вже відомо (наприклад, профіль одержувача й бюджет);
- які кроки вже пройдено;
- чи є незавершені процеси.
Спосіб доставки цієї інформації в модель залежить від архітектури App. Ви можете інжектити її в system-prompt, повертати в ToolOutput у структурованому вигляді або використовувати спеціальні поля _meta/annotations, якщо їх підтримує SDK.
Типовий патерн такий:
- MCP-інструмент повертає в structuredContent короткий знімок контексту: поточний крок, ключові поля і, можливо, workflowId.
- Apps SDK перетворює це у віджет або в текст + hidden-дані.
- Модель, бачачи structuredContent, розуміє, що сценарій продовжився, і будує подальші дії, виходячи з цього.
У деяких випадках, якщо модель «забула» важливі параметри або почала галюцинувати, ви можете примусово оновити контекст. Для цього викликають спец-інструмент, який повертає актуальний стан, і модель «знову входить у контекст».
Важливо не намагатися «запхати» в модель увесь GiftWorkflowContext до останнього поля. Достатньо ключових речей: кому шукаємо подарунок, який бюджет, скільки ідей уже показали, чи є незавершений checkout.
11. Міні-чек-лист під час проєктування WorkflowContext
Перш ніж переходити до типових помилок, корисно сформулювати невеликий набір запитань, на які ви маєте собі відповісти, коли проєктуєте контекст workflow (можна буквально записати це поруч з інтерфейсом):
- Які кроки є в сценарії та який мінімальний набір даних потрібен на кожному?
Це захистить від гігантських JSON-монстрів «про всяк випадок». - Що потрібно памʼятати лише в межах одного чату, а що — між сесіями та пристроями?
Перше можна залишити в widgetState і промптах, друге — обовʼязково надсилати в серверну БД. - Як виглядатиме ідентифікатор контексту?
Це може бути звʼязка userId + scenario, окремий workflowId або і те, і інше. Головне — щоб ви могли однозначно знайти контекст у базі. - Як ви прибиратимете старі workflow?
Для демо допустимо «ніколи не чистити», але в реальному проєкті вам знадобляться або TTL, або фонові джоби, що видаляють старі workflow. - Чи потрібен користувачеві відкат назад і як ви його реалізуєте?
Чи зберігатимете ви дерево гілок, чи достатньо лінійного списку кроків із можливістю відкату.
І насамкінець: спробуйте програти в голові сценарій «користувач повернувся за тиждень в інший чат». Якщо ви не можете пояснити, як App дізнається про старий workflow і що саме йому показати, варто посилити частину з персистентним зберіганням.
12. Типові помилки під час роботи з контекстом між кроками
Помилка № 1: зберігати все лише в історії діалогу.
Іноді виникає спокуса: «Ну модель же все бачить у тексті — давайте просто щоразу перелічувати в промпті, який бюджет, які товари й що користувач обрав». Такий підхід швидко впирається в ліміти контексту й узагалі не дає гарантій цілісності: модель може спокійно «забути» важливий факт або переплутати ідентифікатори. Бізнес-критичні речі (гроші, бронювання, замовлення) мають жити у вашому backend/MCP як у джерелі істини.
Помилка № 2: спроба побудувати весь workflow лише на widgetState.
widgetState в Apps SDK розвʼязує задачу «виживання» UI-стану між розмонтуванням віджета й повторним монтуванням, а не довгострокового зберігання workflow. Якщо намагатися зберігати через нього профіль, кошик та історію кроків, ви отримаєте хаос під час зміни пристрою й неможливість відновитися через тривалий час. Віджет відповідає за візуальні дрібниці та локальний комфорт. Уся логіка сценарію має жити на сервері.
Помилка № 3: відсутність явного workflowId або іншого ключа.
Буває, що розробник покладається на неявні ідентифікатори на кшталт conversation_id, але не вводить власного поняття workflow. У результаті стає неможливо відрізнити один сценарій від іншого, розділити кілька паралельних workflow або відновити саме той, що потрібен. Проста строка workflowId всюди, де є інструменти й API-ендпойнти, розвʼязує безліч проблем, особливо в MCP, який за протоколом stateless.
Помилка № 4: змішування UI-стану та бізнес-логіки.
Класична ситуація: у widgetState складають не тільки «яка вкладка відкрита», а й «які товари в кошику», а потім на підставі цього стану намагаються ухвалювати рішення на сервері. У підсумку за найменшого розсинхрону (віджет уже перемалювався, але запит ще не дійшов, або навпаки) модель бачить одну реальність, UI — іншу, а база — третю. Межа відповідальності має бути чіткою: сервер зберігає й валідовує бізнес-дані, а віджет показує їх і дає користувачеві зручний спосіб їх змінювати.
Помилка № 5: відсутність сценарію відновлення та відкату.
Дуже легко намалювати красивий «щасливий шлях», де користувач іде по кроках ідеально, нічого не ламається, ChatGPT не перезавантажується, а «тунель» не рветься. У реальності кожен крок може впасти, користувач може піти посередині, а за тиждень — повернутися. Якщо ви не заклали структуру WorkflowContext, не продумали, як шукати «активний» workflow, і не передбачили кнопки «Назад» та «Продовжити пізніше», ваш сценарій буде крихким і дратівливим для користувачів. Продуманий контекст — це основа відмовостійкості, про яку йтиметься в наступній лекції.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ