CodeGym /課程 /ChatGPT Apps /在步驟之間保存與還原情境(Context)

在步驟之間保存與還原情境(Context)

ChatGPT Apps
等級 11 , 課堂 2
開放

1. 什麼是 workflow 的情境(context),為什麼需要它

在一般的 Web 應用中,你很清楚狀態住在哪裡:資料庫、快取,加上一些前端的東西(像 Redux 或本地 React state)。在 ChatGPT App 裡更有趣:狀態同時分散在三個世界——模型內(對話歷史)、Widget 內(UI state)以及你的伺服器/MCP(商務資料)。

所謂的 workflow 情境,是指用來回答「我們走到哪個步驟」與「目前已知什麼」的整組資料。如果以我們的教學範例 GiftGenius 來說,情境包括:

  • 收禮者的個人資料:年齡、性別、興趣;
  • 預算與(可能的)貨幣;
  • 已產生的點子清單,以及哪些被使用者按讚或隱藏;
  • 技術項:工作階段或 workflow 的識別碼、狀態(「profile_collected」、「ideas_shown」、「checkout_started」)。

這個情境不只對你(後端工程師)有用。模型也需要它,才能知道哪些問題已問過、哪些工具已呼叫,以及目前到底在談什麼。對使用者來說也很重要,這樣回到聊天時就不必從零開始。

使用者直覺會以為「ChatGPT 什麼都會記住」。事實上模型只會記住對話的文字,而且限於上下文視窗的容量。像 order_idcart_id 或「被按讚的點子清單」這類結構化資訊應該保存在你的伺服器上,否則你會得到一台非常自信、但結論常常不正確的機器。

2. 三個狀態層級:UI、LLM、商務

最容易理解情境保存的方法,是用三層狀態模型,也就是「State Triad」。

層級表

先看一張小表:

層級 所在位置 生命週期 職責 GiftGenius 範例
UI State Widget(React,widgetState 只要聊天/含有 Widget 的訊息開著時 視覺狀態、本地輸入 哪些卡片被高亮、表單狀態
LLM Context OpenAI 的聊天歷史 只要訊息仍「放得進」上下文 對話理解與推理 「幫媽媽找禮物,預算 $50」
Business State MCP/你的後端(DB/Redis) 依你所需(可持久) 真實來源:經驗證的資料與狀態 { step: "ideas", budget: 50, liked: [42, 51] }

UI 層很快也很靈敏,但非常脆弱:當你在歷史紀錄中往上捲動時,ChatGPT 可能會「卸載」含有 Widget 的 iframe,之後再重新掛載。這正是 widgetState 存在的原因——它的生命週期比 React 元件再長一點,並且與 ChatGPT 的 host‑client 同步。

LLM 層讓模型有持續對話的感覺,但它只保存文字與 tool 呼叫。你可以把你的購物車 JSON 放進去,但本質上只是把 JSON 塞進文字裡——模型不會把那視為資料庫。

商務層是你作為工程師能掌控的部分:這裡放著已驗證的資料、索引、訂單狀態。只要你的情境變得嚴肅(送禮、訂位、教學),這個層級就應該成為關於狀態的單一真實來源(source of truth)。

最大的工程挑戰——就是別讓這三層各走各的。使用者在 Widget 裡改了預算,模型還在想舊的,而資料庫又是第三個數值——這就是怪異行為的經典配方。

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;       // 用於 TTL/清理的 timestamp
}

這不是最終的 schema,但重點元素已就位。包括:

  • workflow 識別碼,用它來查找這份情境;
  • 目前步驟,幫助 Widget 與模型知道我們走到哪;
  • 會在各個步驟填入的一組欄位;
  • 像更新時間之類的服務欄位。

特別談談識別碼。在本講中,workflowId 指的是你在後端/MCP 裡的某個具體工作流程的識別碼。它可能與 ChatGPT 對話的工作階段識別碼(sessionId)相同,但我們不依賴此假設。userId 是來自你自家認證系統的使用者識別碼(如果有的話);一個使用者可以同時擁有多個 workflows。id 欄位裝的正是這個 workflowId,我們會以它來查找與更新情境。

在接下來的章節,我們會拆解三件事:在哪裡保存這些物件、如何寫入,以及如何把它們取回——同時提供給 Widget 與模型。

4. 狀態存放在哪:選項與取捨

討論狀態保存時,最好從兩個面向思考:放在哪裡以及能活多久。本節先聚焦存放位置,生命週期會在後面的檢查清單與常見錯誤再談。

先來看存放位置。

放在對話內(prompt 中)

有時會想:「不如每次都把當前狀態的 JSON 丟給模型,讓它自己處理吧。」這在非常簡單、步驟很少的情境裡可行,但很快會碰到兩個問題:上下文長度限制,以及完全沒有資料一致性的保證。

此外,MCP 協定本質上是 stateless(無狀態):就像 HTTP,預設不會在請求之間保存任何狀態。要把 tool 的呼叫綁到特定工作階段,你必須顯式傳遞識別碼——workflow 或 session id——要嘛放在工具參數,要嘛透過 metadata/標頭傳遞。

因此把商務狀態只放在對話裡,比較像教學實驗,而不是架構設計。

在 Widget 中:UI + widgetState

在 UI 層我們使用一般的 React state(useStateuseReducer 等),但如前所述,元件可能被卸載。Apps SDK 為此提供了 widgetState 機制,它活在 React 之外,並與 ChatGPT host 同步。如果在掛載 Widget 時從那裡取出保存的值,變更時再放回去,你就得到一個本地、但相當順手的儲存層。

這個儲存層非常適合純視覺的狀態:哪些卡片折疊了、你在第幾個分頁、使用者在按「下一步」前在表單裡填了什麼。但它不能取代伺服器:當使用者換台裝置或隔了好幾天再打開聊天時,widgetState 可能幫不上忙。用它承載商務邏輯也相當有爭議。

在伺服器/MCP:Map、Redis、資料庫

最後是生產環境的主力選項:我們把 GiftWorkflowContext 儲存在 MCP 伺服器或後端服務端。由於 MCP 用戶端與伺服器的協定是 stateless,我們必須在每次工具呼叫中傳遞 workflowId(或 state_token),才能知道要更新哪個情境。

實作上有幾種方式:

  • Node.js 內的 in‑memory Map——適合 demo 與開發環境:很快,但重啟就消失;
  • Redis 或其他具 TTL 的 in‑memory 快取——很適合短流程的精靈(wizard):存活一兩個小時,之後就能清掉;
  • 一般的 SQL/NoSQL 資料庫——用在「隔了一週回來」或「草稿與購物車」這類情境是必要的。

本講不會深入特定資料庫,重點放在介面與該放哪些資料。

5. MCP 伺服器中的最簡儲存:以 workflowId 為鍵的 Map

先從接地氣的做起:在 MCP 伺服器用一個 in‑memory Map,key 是 workflowId。在教學 demo 裡可以把它等同於對話的 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 呼叫綁定到正確的情境。用戶端(Widget 或 agent)必須把它保存並一路往下傳。

6. 與 Apps SDK 串接:workflowId 與 sessionId 從哪來

在 ChatGPT Apps 中,「workflowId 從哪來」有點哲學。取決於你是否用到認證、是否直接用 MCP 或 Agents SDK。概括來說有兩種:在第一次 tool 呼叫時由伺服器產生,或在 Widget 端產生再往下傳。

在教學範例裡,我們假設第一步是呼叫一個會建立 workflow 的 MCP 工具,而 Widget 之後只要接住它的 id。

最簡作法:

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

export const startWorkflowTool = {
  name: "gift_start_workflow",
  description: "建立新的禮物挑選工作流程",
  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 傳給 Widget,讓 Widget 把它存進 widgetState,並在後續工具呼叫中一併帶上。

Widget 端的程式碼大致如下。

7. 在 Widget 中保存 workflowId 與本地 UI 狀態

假設我們有一個點子清單的 Widget,需要知道自己顯示的是哪個 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 工具或 Next.js 的 API endpoint),讓商務層也知道使用者選了什麼。

重點是不要嘗試用 widgetState 搭出整個 workflow。它應是伺服器端商務情境的輔助層。

8. 恢復流程:使用者回來了

接著來看更有意思的情境:使用者關了 ChatGPT,隔了幾小時或幾天又回來打開同一個聊天。此時應該發生什麼?

理想的 UX 是:模型與 App 看出使用者已有未完成的 workflow,拉回它的情境,並說類似:「你已填好個人資料與預算,讓我們從點子選擇繼續吧」。

架構上可以這樣做:

  1. 在你的伺服器上保存著 GiftWorkflowContext,它綁定某個 userId,或至少綁定內部的 workflowId
  2. 當有新請求(或本次對話中的第一次 tool 呼叫)時,App 向伺服器查詢:「這個使用者有活躍的 workflow 嗎?」
  3. 若有,伺服器回傳它,並可能帶上一個 resume 旗標,模型可在回覆中使用。

在簡單的單體式 demo 中,可以讓 MCP 伺服器與 Next.js 應用住在同一個 repo(甚至同一個 process),因此我們會在 MCP 與 API route 兩邊共用同一個 workflowStore

在 Next.js 中可以是個簡單的 API route:

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

Widget(或 MCP 工具)可以在需要更新狀態時呼叫這個 endpoint:例如首次掛載或切換步驟時。在教學設定裡,workflowId + Map 儲存就足夠;在實際產品中你會加上授權與使用者歸屬檢查。

如果你使用 Agents SDK 或更複雜的協調機制,可以把這個想法擴展為「檢查點」——在大型步驟的結尾保存狀態,代理在重新啟動時能從那裡接續。但這將是下一個模組的主題。

9. 前進/後退與步驟歷史

不可避免地會有人問:「能回到上一步嗎?」對使用者來說這很自然:改預算、調整興趣、把多餘的商品拿掉。

技術上意味著兩件事:

  • 不只要保存當前步驟,還要保存已做決策的歷史;
  • 在回退後要謹慎地重算衍生資料。

其中一個做法——在情境中加入 history 欄位,保存各步驟的快照。例如:

export interface StepSnapshot {
  step: GiftWorkflowStep;
  payload: any;          // 該步驟的具體資料
  createdAt: number;
}

export interface GiftWorkflowContext {
  // ...前述欄位
  history: StepSnapshot[];
}

當使用者填寫個人資料時,你在歷史中加入 step 的快照:"profile"。變更預算時再加一個快照。當回退到個人資料時,你會:

  • 更新 currentStep = "profile"
  • (可選)把歷史切到對應索引;
  • 重算衍生值(例如若點子與按讚依賴預算,就清空它們)。

在模型層要特別同步:若使用者在 Widget 點了「返回」,就需要送出一個 tool 呼叫來更新商務情境,並在回應裡回傳明確的新狀態。否則就會出現典型的失同步:UI 顯示第 2 步,模型卻以為你在第 3 步。

在 Widget 端,回退可以像是一個簡單的按鈕:

async function goBackToProfile() {
  await fetch("/api/gift/workflow/back", {
    method: "POST",
    body: JSON.stringify({ workflowId, targetStep: "profile" })
  });
  // 更新 UI,清理本地 state
}

伺服器會決定要清理情境中的哪些部分,並透過工具回應把該訊息送回給模型。

10. 如何把一切連到模型:為推理提供情境

我們對狀態做的所有事,最後不只要給使用者看,也要給 LLM。模型必須理解:

  • 目前已知什麼(例如收禮者檔案與預算);
  • 哪些步驟已完成;
  • 是否有未完成的流程。

把這些資訊送進模型的方式取決於 App 架構:你可以注入到 system prompt、在 ToolOutput 裡以結構化資料回傳,或使用 SDK 支援的特殊欄位 _meta/annotations。

典型模式如下:

  1. MCP 工具在 structuredContent 中回傳精簡的情境快照:當前步驟、關鍵欄位,可能還有 workflowId
  2. Apps SDK 把它轉成 Widget,或文字加上隱藏資料。
  3. 模型看到 structuredContent 後,知道流程已延續下去,並據此規劃下一步行動。

在某些情況下,如果模型「忘記」了重要參數或開始產生幻覺,你可以強制刷新情境:呼叫一個專用工具回傳最新狀態,模型就能「重新進入情境」。

重要的是不要把整個 GiftWorkflowContext 的每個欄位都塞給模型。關鍵資訊就夠了:要幫誰找禮物、預算多少、已顯示了多少點子、是否有未完成的結帳。

11. 設計 WorkflowContext 的迷你檢查清單

在進入常見錯誤之前,先列一份短清單,設計 workflow 情境時你應該回答這些問題(可以直接寫在介面旁邊):

  • 流程有哪些步驟,每一步最少需要哪些資料?
    這能避免為了「以防萬一」而長成巨大 JSON 怪物。
  • 哪些只需要在單一聊天裡記住,哪些要跨工作階段與裝置記住?
    前者可以留在 widgetState 與 prompt,後者一定要寫進伺服器端資料庫。
  • 情境的識別碼長什麼樣?
    可以是 userId + scenario 的組合、獨立的 workflowId,或兩者兼具。重點是你能在資料庫中唯一找到它。
  • 你將如何清理舊的 workflows?
    Demo 可以「永遠不清」,但在生產你會需要 TTL 或背景工作來刪除舊 workflows。
  • 使用者是否需要回退,如何實作?
    你會保存分支樹,還是線性步驟列表並提供回退即可。

最後:試著在腦中走一次「使用者隔了一週、在另一個聊天回來」的情境。如果你講不清楚 App 如何找出舊的 workflow 以及該顯示什麼,就該強化持久化儲存的部分。

12. 跨步驟情境處理的常見錯誤

錯誤 №1:只把一切放在對話歷史裡。
有時會想:「反正模型都能在文字裡看到,不如每次在 prompt 重新列一遍預算、商品、與使用者選擇吧。」這種作法很快就撞上上下文長度限制,而且完全沒有一致性的保證:模型可能「忘記」重要事實或把識別碼搞混。商務關鍵的東西(錢、訂位、訂單)應該住在你的後端/MCP 裡,作為真實來源。

錯誤 №2:試圖只用 widgetState 搭起整個 workflow。
Apps SDK 的 widgetState 解的是 UI 在卸載與重新掛載之間的生存問題,不是 workflow 的長期保存。如果嘗試用它存個人檔案、購物車、步驟歷史,在換裝置或長時間之後就會一片混亂,難以恢復。Widget 負責視覺與本地體驗,整個流程邏輯應該住在伺服器。

錯誤 №3:缺少明確的 workflowId 或其他 key。
有時開發者倚賴像 conversation_id 這種隱含的識別碼,卻沒有建立自己的 workflow 概念。結果導致無法區分不同情境、無法並行多個 workflows、也無法恢復到正確的那一個。只要在工具與 API endpoint 之間一致地傳遞一個簡單的 workflowId 字串,就能解掉 MCP 無狀態協定下的大量問題。

錯誤 №4:把 UI 狀態與商務邏輯混在一起。
典型情境:在 widgetState 裡不只放「打開的是哪個分頁」,還放「購物車裡有哪些商品」,然後想用這份 state 在伺服器做決策。結果只要稍微失同步(Widget 已經渲染但請求尚未送出,或反過來),模型看到一種現實、UI 看到另一種、資料庫又是第三種。責任邊界要清楚:伺服器保存並驗證商務資料,Widget 負責呈現並提供易用的修改方式。

錯誤 №5:沒有恢復與回退機制。
很容易畫出一條漂亮的「幸福路徑」:使用者完美依序操作、什麼都沒壞、ChatGPT 不會重啟、連線不會中斷。現實是每一步都可能失敗、使用者中途離開、隔一週才回來。如果你沒有設計 WorkflowContext 的結構、沒有想好如何找出「活躍」的 workflow、也沒有提供「返回」與「稍後繼續」,你的流程就會很脆弱,讓使用者挫折。穩健的情境設計是容錯性的基礎,這也是下一堂課的主題。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION