CodeGym /课程 /ChatGPT Apps /在各步骤之间保存与恢复上下文

在各步骤之间保存与恢复上下文

ChatGPT Apps
第 11 级 , 课程 2
可用

1. 什么是 workflow 上下文,它为何重要

在普通的 Web 应用中,你通常很清楚状态存在哪里:数据库、缓存,以及前端的 Redux 或本地 React 状态。到了 ChatGPT App 就更有意思了:状态同时分布在三个世界里——模型内部(对话历史)、小部件内部(UI 状态)以及你的服务器/MCP(业务数据)。

这里所说的workflow 上下文,指的是回答“我们处于哪一步”和“目前已知信息有哪些”所需的全部数据。以我们的教学示例 GiftGenius 而言,上下文包括:

  • 收礼人的资料:年龄、性别、兴趣;
  • 预算以及可能的货币;
  • 生成的创意清单,以及用户点赞或隐藏了哪些;
  • 技术性信息:会话或 workflow 的标识、状态(“profile_collected”、“ideas_shown”、“checkout_started”)。

这个上下文不仅对后端开发者重要,对模型同样重要,它需要知道哪些问题已经问过、哪些工具已经调用、当前在讨论什么。对用户也重要,这样当他回到聊天时不必从零开始。

用户直觉上会以为“ChatGPT 会记住一切”。实际上模型只“记住”对话的文本,而且仅限于上下文窗口能容纳的部分。类似 order_idcart_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 状态(useStateuseReducer 等),但如前所述,组件可能会被卸载。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,拉取其上下文,然后说类似:“你已经填写了资料和预算,我们从挑选创意继续吧。”

架构上可这样处理:

  1. 你的服务器保存着与某个 userId 或至少某个 workflowId 绑定的 GiftWorkflowContext
  2. 在新请求(或对话中的首次工具调用)时,App 去服务器询问:“这个用户是否有活跃的 workflow?”;
  3. 如果有,服务器返回该上下文,并可能带一个 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 等专用字段。

常见模式如下:

  1. MCP 工具在 structuredContent 中返回上下文的简要快照:当前步骤、关键字段以及可能的 workflowId
  2. Apps SDK 将其转换为小部件或文本 + 隐藏数据;
  3. 模型看到 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、没有提供“后退”和“稍后继续”,你的场景就会脆弱并令用户沮丧。经过周密设计的上下文是实现高可用的基础,这也是下一讲的主题。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION