CodeGym /コース /ChatGPT Apps /ステップ間のコンテキストの保存と復元

ステップ間のコンテキストの保存と復元

ChatGPT Apps
レベル 11, レッスン 2
使用可能

1. Workflow コンテキストとは何か、なぜ必要か

一般的な Web アプリでは、状態がどこにあるかは比較的明確です。データベース、キャッシュ、そしてフロントでは Redux やローカルな React ステートのようなもの。しかし ChatGPT App は少し事情が異なります。状態が3つの世界に同時に分散します——モデル内部(会話履歴)、ウィジェット内部(UI ステート)、そしてサーバー/MCP(ビジネスデータ)です。

ここでいう workflow コンテキストとは、「今どのステップにいるのか」「すでに何が分かっているのか」に答えるために必要なデータ一式を指します。学習用の GiftGenius を例にすると、コンテキストには次のようなものが含まれます。

  • 贈る相手のプロフィール(年齢、性別、興味);
  • 予算と(あれば)通貨;
  • 生成されたアイデアの一覧、およびユーザーが高評価(Like)した・隠したもの;
  • 技術的情報:セッションまたはワークフローの識別子、ステータス(「profile_collected」「ideas_shown」「checkout_started」)など。

このコンテキストは、バックエンド開発者としてのあなたに必要なだけではありません。モデル自身にも必要です。すでにどんな質問をしたのか、どんなツールを呼び出したのか、そして今何の話をしているのかを理解させるためです。またユーザーにとっても、チャットへ戻ったときにゼロからやり直さなくて済むために必要です。

ユーザーは直感的に「ChatGPT は全部覚えている」と考えがちです。実際には、モデルが覚えているのは テキストの会話だけで、それがコンテキストウィンドウに収まっている間に限られます。order_id や cart_id、あるいは「高評価したアイデアの一覧」のような構造化データはサーバー側で管理する必要があります。そうしないと、「自信満々だが間違っている」回答を量産することになります。

2. 状態の3レイヤー: UI、LLM、ビジネス

コンテキスト保存は、3つの状態レイヤーというモデル(別名「State Triad」)で考えると分かりやすくなります。

レイヤーの表

小さな表で整理しましょう。

レイヤー どこに存在するか ライフサイクル 役割 GiftGenius の例
UI State ウィジェット(React、widgetState) チャット/ウィジェット付きメッセージが開いている間 見た目の状態、ローカル入力 どのカードがハイライトされているか、フォームの状態
LLM Context OpenAI のチャット履歴 メッセージがコンテキストに収まっている間 会話の理解と推論 「母へのプレゼントを探している。予算は $50」
Business State MCP / あなたのバックエンド(DB/Redis) 必要なだけ(永続) 真実のソース:検証済みデータ、ステータス { step: "ideas", budget: 50, liked: [42, 51] }

UI レイヤーは高速で反応も良い一方でとても壊れやすいものです。ChatGPT は履歴の上へスクロールしたときにウィジェットの iframe を「アンマウント」し、後で再マウントすることがあります。そこで widgetState が用意されており、React コンポーネントよりは長く生き、ChatGPT ホストクライアントと同期します。

LLM レイヤーはモデルに継続的な会話の感覚を与えますが、保持するのはテキストとツール呼び出しのみです。カートの JSON をそこへ入れることはできますが、実態は「テキストに JSON を貼る」だけであり、モデルはそれをデータベースのようには扱いません。

ビジネスレイヤーは、エンジニアとしてあなたが制御できる場所です。検証済みデータ、インデックス、注文ステータスが置かれます。ある程度本格的なシナリオ(ギフト、予約、学習など)になったら、このレイヤーを状態に関するプライマリの真実のソースにすべきです。

最大のエンジニアリング課題は、これら3つのレイヤーがバラバラにならないようにすることです。ユーザーがウィジェットで予算を変更したのに、モデルは古い予算だと思い込んだまま、データベースには第三の値が入っている——これは奇妙な挙動の典型的な原因です。

3. 何を保存するのか: WorkflowContext の構造

具体的にするため、GiftGenius のコンテキスト用 TypeScript インターフェースを記述してみましょう。ステップはすでに複数あるとします:プロフィール収集、予算の選択、アイデア生成、閲覧/Like です。

まずはシンプルな構造から始めます。

// 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;       // TTL/クリーンアップ用のタイムスタンプ
}

これは最終形ではありませんが、重要な要素は揃っています。具体的には:

  • workflow の識別子。これで該当コンテキストを検索します;
  • 現在のステップ。ウィジェットとモデルの双方が到達点を理解できます;
  • 各ステップで入力されるフィールド群;
  • 更新時刻などのメタ情報。

識別子について補足します。この講義での workflowId は、私たちの backend/MCP 内における特定のシナリオの識別子を意味します。ChatGPT の対話セッション ID(sessionId)と一致させることもできますが、それに依存しない設計にします。userId は認証システム(もしあれば)のユーザー識別子で、1 人のユーザーが複数のアクティブな workflow を持つ可能性があります。id フィールドにはこの workflowId が入り、これでコンテキストの検索・更新を行います。

次のセクションでは、これらのオブジェクトをどこに保存し、どのように書き込み、そしてどのように取り出してウィジェットとモデルの両方へ届けるかの3点を解説します。

4. 状態をどこに保存するか: 選択肢とトレードオフ

状態の保存は、どこに保存するかとどれだけ生かすかの2軸で考えると整理しやすいです。本節では保存場所に絞り、寿命については後のチェックリストと「よくある間違い」で触れます。

まずは保存場所から。

会話内(プロンプト内)

「毎回、モデルに現在の状態を JSON で渡して任せればいいのでは?」と言いたくなることがあります。これは非常に単純なシナリオや短い手順の連鎖では機能しますが、すぐに2つの問題に突き当たります。コンテキスト長の制限と、データ完全性に関する保証がないことです。

加えて、MCP プロトコルは本質的に stateless です。HTTP と同様、デフォルトではリクエスト間の状態は保持しません。特定のセッションにツール呼び出しをひも付けるには、明示的に識別子(workflow や session id)をツールの引数、あるいはメタデータ/ヘッダーで渡す必要があります。

したがって、ビジネス状態を会話だけに保存するのは、どちらかと言えば学習用の実験であり、アーキテクチャとは言えません。

ウィジェット内: UI + widgetState

UI レベルでは通常の React ステート(useState、useReducer など)を使いますが、前述のとおりコンポーネントはアンマウントされ得ます。Apps SDK にはそのための widgetState という仕組みがあり、React の外に生き、ChatGPT ホストと同期されます。ウィジェットのマウント時にそこから値を取り出し、変更時に戻しておけば、ローカルながら便利なストアとして使えます。

これは純粋に見た目の状態に適しています。どのカードを折りたたんでいるか、どのタブにいるか、ユーザーが「次へ」を押す前にフォームへ入力した内容などです。しかしサーバーの代わりにはなりません。ユーザーが別のデバイスでチャットを開いたり、1 週間後に戻ってきたりしたとき、widgetState では対応できないことがあります。ビジネスロジックをここに乗せるのもお勧めできません。

サーバー/MCP 上: Map、Redis、DB

最終的に本番運用の主力となる選択肢は、GiftWorkflowContext を MCP サーバーやバックエンドサービス側に保存することです。MCP クライアントとサーバーはプロトコル上 stateless なので、どのコンテキストを更新するか判断できるよう、各ツール呼び出しに workflowId(または state_token)を必ず渡します。

実装パターンはいくつかあります。

  • Node.js の in‑memory な Map —— デモや開発環境向け。高速だが再起動で消える;
  • TTL 付きの Redis など in‑memory キャッシュ —— 数ステップのウィザードのような短いシナリオに適する。1〜2 時間生かし、その後削除できる;
  • 通常の SQL/NoSQL データベース —— 「1 週間後に戻ってくる」「下書きやカートを保持」といったシナリオでは必須。

本講義では特定の DB には踏み込みません。何を入れるべきか、インターフェース設計の考え方に集中します。

5. MCP サーバーでの最小ストレージ: workflowId をキーにした Map

まずは足元から。MCP サーバーで in‑memory の Map を用意し、キーに workflowId を使います。学習用デモではこれを対話の sessionId と同じにしても構いませんが、本番ではシナリオの識別子として独立した workflowId を使うのがよいです。Map の値は GiftWorkflowContext です。実運用では Redis や DB に置き換えますが、API は同じままで構いません。

TypeScript 製の MCP サーバーがあると仮定して、初期化の近くに追加します。

// 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
      }
    };
  }
};

このツールはすでに2つの役割を果たしています。プロフィールを保存し、currentStep を次の段階へ進めます。実際のプロジェクトでは「データを保存する」と「ステップを進める」をツールとして分けたくなるかもしれませんが、概念理解のためにここではまとめています。

引数の workflowId に注目してください。ツール呼び出しを適切なコンテキストにひも付けるのはこのパラメータです。クライアント側(ウィジェットやエージェント)はどこかにこれを保持し、毎回渡す必要があります。

6. Apps SDK との連携: workflowId と sessionId はどこから来るか

ChatGPT Apps における「workflowId をどこから得るのか」という問題は、やや設計哲学的です。認証の有無、MCP の直接利用か、Agents SDK かによっても変わります。一般論としては、初回のツール呼び出しでサーバー側が生成するか、ウィジェット側で生成して下流へ渡すかのいずれかです。

学習用の例としては、最初のステップで workflow を作成する MCP ツールを呼び、その後ウィジェットがその 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 を表示しているかを把握し、アンマウントされてもローカルの Like 状態を保持したいとします。簡略化した実装は次のとおりです。

// 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 ツール "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 レイヤーとして使っています。どのアイデアがハイライトされているかを覚えるだけです。本来、Like はサーバーへも送るべきです(MCP ツールや Next.js の API エンドポイント経由)。そうすればビジネスレイヤーもユーザーの選択を把握できます。

widgetState だけで workflow 全体を構築しようとしないことが重要です。サーバー上のビジネスコンテキストを補完する追加レイヤーとして用いてください。

8. シナリオの復元: ユーザーが戻ってきた場合

次はより興味深いケースです。ユーザーが ChatGPT を閉じ、数時間または数日後に同じチャットを再び開いたとします。何が起こるべきでしょうか。

理想的な UX は次のとおりです。モデルと App が、当該ユーザーに未完了の workflow があると理解し、そのコンテキストを取得し、「プロフィールと予算は入力済みです。アイデアの選択から再開しましょう。」のように提示します。

アーキテクチャ的には以下のようになります。

  1. サーバーに GiftWorkflowContext が保存されており、userId または少なくとも内部の workflowId にひも付いている。
  2. 新しい要求(または対話内の最初のツール呼び出し)で、App がサーバーに「このユーザーにアクティブな workflow はあるか」を問い合わせる。
  3. あれば、サーバーはそれを返し、必要に応じて resume というフラグを返す。モデルはそのフラグを使って発話を調整する。

単純なモノリシックなデモでは、MCP サーバーと Next.js アプリが同一のリポジトリ(あるいは同一プロセス)で動いていることもあるので、MCP の workflowStore をそのまま 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 ツール)は、状態を更新したいとき(初回マウント時やステップ切り替え時など)にこのエンドポイントを呼び出せます。学習用構成では workflowId と Map ベースのストレージの組み合わせで十分ですが、実運用では認可やユーザー所有チェックを追加してください。

Agents SDK やより高度なオーケストレーションを使う場合は、これを「チェックポイント」へと拡張できます。大きなステップの終端で状態を保存しておき、エージェントが再起動時にそこから再開できるようにする考え方です。これは次のモジュールの話題です。

9. 前進・後退とステップ履歴

「前のステップへ戻れますか?」という要望は必ず出てきます。ユーザーにとっては至極当然です。予算を変更したい、興味を修正したい、余計なアイテムを外したい、など。

技術的には次の 2 点を意味します。

  • 現在のステップだけでなく、意思決定の履歴も保持する;
  • ロールバック後に派生データを丁寧に再計算する。

1 つの方法は、コンテキストに history フィールドを追加し、ステップのスナップショットを保持することです。例は次のとおりです。

export interface StepSnapshot {
  step: GiftWorkflowStep;
  payload: any;          // ステップ固有のデータ
  createdAt: number;
}

export interface GiftWorkflowContext {
  // ...前述のフィールド
  history: StepSnapshot[];
}

ユーザーがプロフィールを入力したら、step が "profile" のスナップショットを履歴に追加します。予算を変更したら、もう 1 つ追加。プロフィールへ戻すときは次のようにします。

  • currentStep を "profile" に更新;
  • 必要に応じて履歴を所定のインデックスまでトリミング;
  • 派生値を再計算(例:予算依存ならアイデアと Like をクリア)。

モデル側でも同期が重要です。ユーザーがウィジェットで「戻る」を押したら、ビジネスコンテキストを更新し、ツール応答で新しい状態の明確な記述を返すツール呼び出しを行います。そうしないと、UI はステップ 2 を表示しているのに、モデルはステップ 3 だと思い込んでいる、という古典的な不整合が発生します。

ウィジェット側のロールバックは、次のような単純なボタンで実装できます。

async function goBackToProfile() {
  await fetch("/api/gift/workflow/back", {
    method: "POST",
    body: JSON.stringify({ workflowId, targetStep: "profile" })
  });
  // UI を更新し、ローカルステートをクリアする
}

あとはサーバー側で、コンテキストのどこをクリアするか、ツール応答でモデルへどんなメッセージを返すかを決めればよいでしょう。

10. これをモデルにどう結び付けるか: 推論用のコンテキスト

状態管理は、最終的にはユーザーだけでなく LLM にも必要なものです。モデルは次のことを理解している必要があります。

  • すでに分かっていること(例:受取人のプロフィールと予算);
  • どのステップを通過済みか;
  • 未完了のプロセスがあるか。

この情報をモデルへ届ける方法は App のアーキテクチャに依存します。system‑prompt へインジェクトする、ToolOutput の構造化データで返す、SDK が対応していれば _meta/annotations のような専用フィールドを使う、などが考えられます。

典型的なパターンは次のとおりです。

  1. MCP ツールが structuredContent で、コンテキストの簡潔なスナップショット(現在のステップ、主要フィールド、必要なら workflowId)を返す。
  2. Apps SDK がそれをウィジェットやテキスト + 隠しデータへ変換する。
  3. モデルは structuredContent を見てシナリオが継続していることを理解し、それに基づいて次のアクションを構築する。

モデルが重要なパラメータを「忘れた」り、幻覚(ハルシネーション)を始めたりした場合、コンテキストを強制的に更新することもできます。最新状態を返す専用ツールを呼び出し、モデルを「文脈に戻す」わけです。

GiftWorkflowContext の全フィールドを丸ごとモデルへ押し込む必要はありません。重要な点だけで十分です。誰のためのギフトか、予算はいくらか、すでに何件のアイデアを見せたか、未完了のチェックアウトがあるか、などです。

11. WorkflowContext 設計のミニ・チェックリスト

「よくある間違い」に進む前に、workflow コンテキストを設計する際に自問すべきポイントを簡単にまとめておきます(インターフェースのそばにメモを書いておくと便利です)。

  • シナリオにはどんなステップがあり、各ステップで最低限必要なデータは何か?
    「念のため」の巨大な JSON モンスターを避けられます。
  • 単一チャット内だけで覚えておけばよいものと、セッションやデバイスをまたいで覚えるべきものは何か?
    前者は widgetState やプロンプトに留め、後者は必ずサーバーの DB に送ります。
  • コンテキストの識別子はどう設計するか?
    userId + scenario の組み合わせ、独立した workflowId、あるいはその両方。重要なのは、DB から一意に見つけられることです。
  • 古い workflow をどうクリーンアップするか?
    デモでは「削除しない」でも構いませんが、本番では TTL か、古い workflow を削除するバッチ/バックグラウンドジョブが必要になります。
  • ユーザーにとっての「戻る」は必要か? どう実装するか?
    分岐木を保存するのか、直線的なステップ列に戻し機能を付けるだけで十分か、方針を決めます。

最後に、「ユーザーが 1 週間後に別のデバイスから戻ってくる」シナリオを頭の中で再生してみてください。App がどうやって古い workflow を見つけ、何を表示するのかを説明できないなら、永続ストレージの設計を強化する必要があります。

12. ステップ間のコンテキストでよくある間違い

間違い №1: すべてを会話履歴だけに保存する。
「モデルはテキストで全部見えているのだから、プロンプトで毎回、予算・商品・ユーザーの選択を列挙すればよい」と考えがちです。このやり方はすぐにコンテキスト長の制限にぶつかり、完全性の保証も皆無です。モデルは重要な事実を「忘れ」たり、識別子を取り違えたり平気でします。お金・予約・注文のようなビジネスクリティカルな事項は、あなたの backend/MCP に真実のソースとして保存すべきです。

間違い №2: すべての workflow を widgetState だけで構築しようとする。
widgetState は Apps SDK において、ウィジェットのアンマウントと再マウントの間で UI 状態を生かすための仕組みであり、長期的な workflow 保存のためのものではありません。ここにプロフィールやカート、ステップ履歴まで入れようとすると、デバイスが変わったときに破綻し、時間が空いた後に復元できなくなります。ウィジェットは見た目とローカルな快適さの担当。シナリオのロジックはサーバー側に置いてください。

間違い №3: 明示的な workflowId などのキーがない。
conversation_id のような暗黙の識別子に頼り、独自の workflow の概念を導入しないことがあります。その結果、シナリオを区別できず、複数の並行 workflow を分けられず、必要なものを復元できなくなります。ツールや API エンドポイントのどこにでも単純な文字列 workflowId を通すだけで、多くの問題を解決できます。特にプロトコル上 stateless な MCP では重要です。

間違い №4: UI 状態とビジネスロジックの混同。
典型例として、widgetState に「どのタブが開いているか」だけでなく「カート内商品」まで入れ、それを基にサーバー側で意思決定しようとするケースがあります。結果として、ほんの少しの非同期ズレ(ウィジェットは描画済みだがリクエストは未到達、あるいはその逆)で、モデル・UI・DB がそれぞれ別の現実を見ることになります。責務の境界は明確に。サーバーはビジネスデータを保存・検証し、ウィジェットはそれを表示し、ユーザーが変更しやすい UI を提供します。

間違い №5: 復元とロールバックのシナリオがない。
「理想的なハッピーパス」を描くのは簡単です。ユーザーは完璧にステップを進み、何も壊れず、ChatGPT は再起動されず、接続は切れない。しかし現実には、どのステップでも失敗し得ます。ユーザーは途中で離脱し、1 週間後に復帰するかもしれません。WorkflowContext の構造を用意せず、「アクティブな」 workflow の探索方法も考えておらず、「戻る」「後で続ける」ボタンもないなら、そのシナリオは脆く、ユーザーを苛立たせます。きちんと設計されたコンテキストこそが耐障害性の土台であり、その話は次回の講義で扱います。

1
タスク
ChatGPT Apps, レベル 11, レッスン 2
ロック未解除
ウィジェットのアンマウントをまたいで保持される「step」を備えたミニウィザード
ウィジェットのアンマウントをまたいで保持される「step」を備えたミニウィザード
1
タスク
ChatGPT Apps, レベル 11, レッスン 2
ロック未解除
MCP におけるワークフローのビジネスコンテキスト(workflowId ごとの Map) + "get_context"
MCP におけるワークフローのビジネスコンテキスト(workflowId ごとの Map) + "get_context"
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION