1. 什么是 workflow 上下文,它为何重要
在普通的 Web 应用中,你通常很清楚状态存在哪里:数据库、缓存,以及前端的 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/你的后端(数据库/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; // 用于 TTL/清理的时间戳
}
这不是最终的 schema,但关键元素已就位,包括:
- workflow 标识符,用它来查找该上下文;
- 当前步骤,帮助小部件和模型理解我们已经走到哪里;
- 在各个步骤中会被填充的一组字段;
- 诸如更新时间之类的服务字段。
再说下标识符。本文中 workflowId 指的是你后端/MCP 中某个具体场景的标识。它可以与 ChatGPT 对话会话的标识(sessionId)相同,但我们不会依赖于此。userId 是你认证系统中的用户标识(如果有的话);一个用户可以有多个活跃的 workflow。id 字段就是这个 workflowId,我们据此查找并更新上下文。
接下来我们会讨论三件事:这些对象应该存在哪里、应该如何写入,以及如何把它们取回来——既要给小部件,也要给模型。
4. 状态存哪里:选项与权衡
讨论状态保存可以从两个维度入手:它存在哪里,以及能活多久。本节先聚焦存储位置,生命周期会在清单与常见错误部分再谈。
先看存储位置。
放在对话内(prompt 中)
有时会想:“干脆每次都把当前状态的 JSON 返回给模型,让它自己判断吧。”这在非常简单、步骤很短的场景下还能工作,但很快就会碰到两个问题:上下文长度限制,以及完全没有数据一致性的保障。
此外,MCP 协议本质上是无状态(stateless)的:就像 HTTP,默认不会在请求之间保存任何状态。为了把工具调用绑定到具体会话,你需要显式传递一个标识——workflow 或 session id——要么作为工具的参数,要么通过元数据/请求头传递。
因此,只把业务状态放在对话里更像是教学实验,而不是可用的架构。
在小部件中:UI + widgetState
在 UI 层,我们会使用常规的 React 状态(useState、useReducer 等),但如前所述,组件可能会被卸载。Apps SDK 提供了 widgetState 机制,它独立于 React 存活,并与 ChatGPT 宿主同步。小部件挂载时从中取回保存的值,发生变化时再写回去,你就得到一个本地但好用的存储。
它很适合存放纯视觉状态:哪些卡片折叠了、当前在哪个标签页、在点击“下一步”之前用户在表单里输入了什么。但它不能替代服务器:当用户在另一台设备上或一周后打开聊天时,widgetState 可能就不顶用了。将业务逻辑全压在它上面也并不稳妥。
在服务器/MCP:Map、Redis、数据库
最后是面向生产的主力方案:在 MCP 服务器或后端服务端保存 GiftWorkflowContext。由于 MCP 客户端与服务器按协议是无状态的,我们必须在每次工具调用里传递 workflowId(或 state_token),以便知道要更新哪个上下文。
实现方式有几种:
- Node.js 中的内存 Map——适合演示与开发环境:很快,但重启即丢失;
- Redis 或其它带 TTL 的内存缓存——适合短流程的向导式场景:活个一两个小时,然后就可以清掉;
- 常规 SQL/NoSQL 数据库——对于“一周后回来”或“草稿与购物车”这样的场景是必须的。
本文不展开具体数据库实现,而是聚焦接口与需要入库的内容。
5. MCP 服务器中的最简存储:按 workflowId 的 Map
从接地气的做法开始:在 MCP 服务器中用内存 Map,键为 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 从哪里来
在 ChatGPT Apps 中,“workflowId 从哪里来”略带点哲学意味。具体取决于你是否使用认证、是否直连 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 工具 "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 端点),从而让业务层也知道用户选了什么。
不要尝试把整个 workflow 都建在 widgetState 上。它应该是服务器上业务上下文的补充层。
8. 恢复场景:用户回来了
现在看一个更有意思的场景:用户关闭了 ChatGPT,几个小时或几天后又回到同一个聊天。此时应该发生什么?
理想的交互是:模型和 App 识别到用户已有未完成的 workflow,拉取其上下文,然后说类似:“你已经填写了资料和预算,我们从挑选创意继续吧。”
架构上可这样处理:
- 你的服务器保存着与某个 userId 或至少某个 workflowId 绑定的 GiftWorkflowContext;
- 在新请求(或对话中的首次工具调用)时,App 去服务器询问:“这个用户是否有活跃的 workflow?”;
- 如果有,服务器返回该上下文,并可能带一个 resume 标志,模型据此组织回复。
在简单的单体演示里,可以让 MCP 服务器与 Next.js 应用共用一个代码库(甚至一个进程),于是我们在 API 路由里直接复用 MCP 的 workflowStore。
在 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. 前进/后退与步骤历史
不可避免地会被问到:“能不能回到上一步?”对用户而言这很自然:修改预算、调整兴趣、从候选里去掉多余商品。
技术上意味着两件事:
- 不仅要保存当前步骤,还要保存已做决定的历史;
- 在回滚后要小心地重算派生数据。
一种方案是在上下文中加入 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,清理本地状态
}
服务器负责决定具体要清理上下文中的哪些字段,并通过工具响应向模型发送怎样的消息。
10. 如何把这些与模型关联:为推理准备上下文
我们对状态所做的一切,最终不仅是为了用户,也是为了 LLM。模型需要理解:
- 目前已知了什么(例如收礼人资料与预算);
- 哪些步骤已经完成;
- 是否有未完成的流程。
将这些信息送达模型的方式取决于 App 的架构:你可以把它们注入 system prompt,在 ToolOutput 中以结构化形式返回,或使用 SDK 支持的 _meta/annotations 等专用字段。
常见模式如下:
- MCP 工具在 structuredContent 中返回上下文的简要快照:当前步骤、关键字段以及可能的 workflowId;
- Apps SDK 将其转换为小部件或文本 + 隐藏数据;
- 模型看到 structuredContent,理解场景已继续,并据此规划下一步动作。
在某些情况下,如果模型“忘记”了关键参数或出现了幻觉,你可以强制刷新上下文:调用一个专用工具返回当前状态,让模型“重新进入上下文”。
不要把整个 GiftWorkflowContext 毫无保留地塞给模型。关键字段就够了:为谁找礼物、预算是多少、已经展示了多少创意、是否有未完成的结账等。
11. 设计 WorkflowContext 的迷你清单
在进入常见错误之前,先列一份你在设计 workflow 上下文时应该回答的小清单(甚至可以直接写在接口旁边):
- 场景有哪些步骤?每步所需的最小数据集是什么?
这能避免“以防万一”的巨型 JSON 怪物。 - 哪些只需要在一次聊天内记住,哪些需要跨会话与设备记住?
前者可以留在 widgetState 与 prompt 中,后者必须写入服务器数据库。 - 上下文的标识符长什么样?
可以是 userId + scenario 的组合、单独的 workflowId,或者两者兼有。关键是能在数据库中唯一定位上下文。 - 你将如何清理旧的 workflows?
演示环境可以“永不清理”,但生产中需要 TTL 或后台任务来删除旧的 workflows。 - 用户是否需要后退?你将如何实现?
你会保存分支树,还是线性步骤列表并允许回退即可。
最后,试着在脑海中过一遍“用户一周后在另一个聊天中返回”的场景。如果你无法说清 App 将如何发现旧的 workflow 并展示什么内容,就需要加强持久化存储这一块。
12. 在步骤之间处理上下文的常见错误
错误 1:把一切都存进对话历史。
有时会产生诱惑:“反正模型能看到文本,我们每次在 prompt 里把预算、商品与用户选择逐条列一下就好了。”这种做法很快会撞上上下文长度限制,而且完全没有一致性保障:模型可能“忘记”某个关键事实或混淆标识符。业务关键内容(资金、预订、订单)必须放在你的后端/MCP 中作为权威来源。
错误 2:试图只用 widgetState 来承载整个 workflow。
Apps SDK 中的 widgetState 解决的是小部件卸载与重挂载之间的 UI 状态生存问题,而不是 workflow 的长期保存。如果用它来存放资料、购物车与步骤历史,换设备或长时间后重开时就会一团糟。小部件负责视觉与本地体验。场景逻辑必须在服务器端。
错误 3:缺少显式的 workflowId 或其它主键。
有时开发者依赖于 conversation_id 一类的隐式标识,却没有自有的 workflow 概念。结果无法区分不同场景、无法并行多个 workflow,或无法恢复到所需的那个。将简单的 workflowId 放进所有工具与 API 端点,能解决 MCP 这种无状态协议下的很多问题。
错误 4:混淆 UI 状态与业务逻辑。
经典情形:在 widgetState 中不仅保存“当前打开的是哪个标签”,还保存“购物车有哪些商品”,然后试图据此在服务器端做决策。结果一旦出现不同步(小部件已渲染但请求尚未到达,或反之),模型看到的是一种现实,UI 是另一种,数据库又是第三种。职责边界必须清晰:服务器保存并校验业务数据,小部件负责展示并让用户便捷地修改它们。
错误 5:缺少恢复与回退的方案。
画“幸福路径”很容易:用户完美地按步骤进行、没有任何异常、ChatGPT 不重载、网络不掉线。现实中每一步都有可能失败,用户可能中途离开,一周后再回来。如果你没有设计 WorkflowContext 的结构、没有想好如何查找“活跃的” workflow、没有提供“后退”和“稍后继续”,你的场景就会脆弱并令用户沮丧。经过周密设计的上下文是实现高可用的基础,这也是下一讲的主题。
GO TO FULL VERSION