1. 什麼是 workflow 的情境(context),為什麼需要它
在一般的 Web 應用中,你很清楚狀態住在哪裡:資料庫、快取,加上一些前端的東西(像 Redux 或本地 React state)。在 ChatGPT App 裡更有趣:狀態同時分散在三個世界——模型內(對話歷史)、Widget 內(UI state)以及你的伺服器/MCP(商務資料)。
所謂的 workflow 情境,是指用來回答「我們走到哪個步驟」與「目前已知什麼」的整組資料。如果以我們的教學範例 GiftGenius 來說,情境包括:
- 收禮者的個人資料:年齡、性別、興趣;
- 預算與(可能的)貨幣;
- 已產生的點子清單,以及哪些被使用者按讚或隱藏;
- 技術項:工作階段或 workflow 的識別碼、狀態(「profile_collected」、「ideas_shown」、「checkout_started」)。
這個情境不只對你(後端工程師)有用。模型也需要它,才能知道哪些問題已問過、哪些工具已呼叫,以及目前到底在談什麼。對使用者來說也很重要,這樣回到聊天時就不必從零開始。
使用者直覺會以為「ChatGPT 什麼都會記住」。事實上模型只會記住對話的文字,而且限於上下文視窗的容量。像 order_id、cart_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(useState、useReducer 等),但如前所述,元件可能被卸載。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,拉回它的情境,並說類似:「你已填好個人資料與預算,讓我們從點子選擇繼續吧」。
架構上可以這樣做:
- 在你的伺服器上保存著 GiftWorkflowContext,它綁定某個 userId,或至少綁定內部的 workflowId。
- 當有新請求(或本次對話中的第一次 tool 呼叫)時,App 向伺服器查詢:「這個使用者有活躍的 workflow 嗎?」
- 若有,伺服器回傳它,並可能帶上一個 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。
典型模式如下:
- MCP 工具在 structuredContent 中回傳精簡的情境快照:當前步驟、關鍵欄位,可能還有 workflowId。
- Apps SDK 把它轉成 Widget,或文字加上隱藏資料。
- 模型看到 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、也沒有提供「返回」與「稍後繼續」,你的流程就會很脆弱,讓使用者挫折。穩健的情境設計是容錯性的基礎,這也是下一堂課的主題。
GO TO FULL VERSION