CodeGym /课程 /ChatGPT Apps /代理的内存与状态:session 与 persistent,checkpoints

代理的内存与状态:session 与 persistent,checkpoints

ChatGPT Apps
第 12 级 , 课程 2
可用

1. 为什么代理需要单独的内存

这一部分承接模块 12 的前置课程(关于代理):我们已经讨论了基础架构、run 循环与工具; 这里重点讲内存与状态。

如果类比常见的 Web 应用,这里的 LLM 就像一颗非常聪明的 CPU,能执行复杂的“文本程序”。 而代理的状态就像 RAM 与 SSD 的组合:短期的会话数据与长期存储。

在经典的 ChatGPT 聊天(没有你的代码)里,“内存”只是当前请求中模型可见的消息列表 system/user/assistant/tool。 对于一个代理而言,这远远不够,因为:

  • 它需要记住复杂流程的进度:workflow 已完成到哪一步、哪些礼物候选已被过滤、用户确认了什么;
  • 它需要了解用户的长期事实:偏好、收货地址、历史订单;
  • 它需要具备容错性:如果服务器在挑选礼物中途宕机,用户不该被迫全部重来。

如果只把这些都塞进 prompt 上下文,你会很快碰到上下文窗口的上限,并为重复事实不断支付 token 成本。 同时还会有安全风险:太多无关数据会被频繁发送给模型。 因此,在代理系统中总会有显式状态——由你管理的、存在于消息历史之外的对象。

2. 代理状态的分层:上下文、session、persistent

先按层次拆分。一个代理通常至少有三层“内存”:

  1. 消息历史(dialogue context)。
  2. 会话状态(session state)。
  3. 长期状态(persistent state)。

不要把这些概念混为一谈。

消息历史:“脏内存”

消息历史是 LLM 在每一步能看到的东西: system 指令、用户请求、代理回答、工具结果。

好处是你无需手动维护——Agents SDK 和平台本身会通过 Session/Conversation 来管理。

缺点是它是“脏”的:包含大量冗词、重复、用户的偶发信息。 这些数据在 token 上很昂贵且结构化程度差。 你不会希望一个 200 项的“已过滤礼物”清单每次都以纯文本被读给模型。

Session state:工作的短期内存

Session 状态是一个结构化对象,作用域限定在一次代理会话/对话中。 前端开发者可以将其类比为 useState 或 Redux store,只在浏览器标签打开期间存在。

它通常包含:

  • 当前流程步骤(例如 "collecting_profile""filtering_candidates");
  • 工具结果的临时缓存;
  • 会话参数:本地化、选定渠道,以及诸如“用户已同意条款”的标志位。

这种状态可以放在靠近代理的一侧——比如 Redis、内存 KV 存储,或使用特定 SDK 自带的 SessionService。 关键点是:不要试图把它们都塞进 system prompt。

Persistent state:长期数据

Persistent 状态是长寿命的:跨会话、跨结账、跨设备。包括用户档案、订单、已保存的心愿单、设置等。

核心思想:代理不会“魔法地记住” persistent 数据,而是通过工具去“读取”——例如 get_user_profileget_past_orders。 不要在代理内部藏全局变量;一切都应是显式调用。

对比表

驻留位置 生命周期 数据示例
Messages Session / SDK / OpenAI 单次 run / 对话 system/user/tool 消息
Session state KV / SessionService / Redis 会话存活期间 工作流步骤、临时缓存
Persistent 数据库(Postgres/NoSQL/ACP 后端) 跨会话与对话 个人资料、订单、已保存清单

3. Session state:是什么以及如何存放

想象代理 GiftGenius 在执行一个多步骤流程:

  1. 收集收礼人的个人档案。
  2. 生成候选清单。
  3. 按预算、配送、地区进行过滤。
  4. 准备最终推荐。

过程中它会不断与用户对话并调用工具。 凡是“本次礼物挑选会话的进度”,都适合放在 session state 中。

GiftGenius 的会话状态结构示例

用 TypeScript 描述会话状态类型:

// 单次“选礼物”会话中的状态
export type GiftSessionState = {
  step:
    | "collecting_profile"
    | "generating_candidates"
    | "filtering"
    | "finalizing";

  // 收礼人档案草稿
  profileDraft?: {
    recipientType?: string;
    ageRange?: string;
    interests?: string[];
    dislikes?: string[];
  };

  // 后端返回的候选商品 id
  candidateIds?: string[];

  // 用户已选礼物
  selectedGiftId?: string;

  // 技术标志
  locale?: string;
};

这里我们刻意不存放完整的商品对象——只存 ID。 完整数据放在数据库里;当需要时,让代理调用工具 get_gift_details(gift_id)

Agents SDK 中的 Session(概念)

许多代理 SDK 提供了会话抽象,自动管理消息历史,并允许你额外存储 structured-state。 伪代码大致如下:

import { createRunner, OpenAIConversationsSession } from "@openai/agents";
// 上文示例中的 GiftSessionState 类型

const session = new OpenAIConversationsSession<GiftSessionState>({
  sessionId: "chatgpt-thread-id-or-random",
});

const runner = createRunner({ agent });

const result = await runner.run({
  session,
  input: "想给同事挑一份不超过 50$ 的礼物",
});

SDK 在幕后会:

  • 取出该会话的消息历史;
  • 追加新的用户消息;
  • 把上下文交给模型与工具;
  • 把更新后的状态(包括 session.state)保存回去。

你把 session.state 当作普通对象来用即可。

从工具更新 session state

典型模式:执行计算的工具同时更新会话状态。 例如,一个根据用户回答汇总收礼人档案的工具:

export async function updateProfileDraft(
  session: GiftSessionState,
  answers: { questionId: string; value: string }
): Promise<GiftSessionState> {
  const next: GiftSessionState = { ...session };

  if (!next.profileDraft) {
    next.profileDraft = {};
  }

  if (answers.questionId === "interests") {
    next.profileDraft.interests = answers.value.split(",").map((s) => s.trim());
  }

  // ...其他字段

  next.step = "generating_candidates";
  return next;
}

这里我们传给工具的不是整个 SDK 的 Session,而只是它的 state(类型为 GiftSessionState)。 在真实代码里,给这个参数取名为 currentState 之类会更清晰,避免与 Session 对象混淆。

代理调用该工具,得到新的状态对象,然后把它保存回 session.state

4. Persistent state:代理的长期内存

现在回到 GiftGenius 不止在一个聊天中工作这个事实。用户可能一周后、在另一台设备上回来并说: “给上次那个朋友再挑礼物,但预算提高了。”

这些信息不该放在 session state,而应存放在 persistent 存储中:数据库、commerce/ACP 后端(关于 commerce 层会有单独模块)等。

Persistent 模型示例

用 TypeScript(简化)描述数据库中的收礼人档案模型:

// 存在数据库里的内容
export type RecipientProfile = {
  id: string;
  userId: string;
  label: string; // "市场部的同事"
  recipientType: string;
  ageRange?: string;
  interests: string[];
  dislikes: string[];
  lastUsedAt: string; // ISO 日期
};

以及一个仓库(先用简单的 Map;实际项目你会用 ORM/SQL 层):

const profiles = new Map<string, RecipientProfile>();

export const RecipientRepo = {
  async findByUser(userId: string): Promise<RecipientProfile[]> {
    return [...profiles.values()].filter((p) => p.userId === userId);
  },

  async save(profile: RecipientProfile): Promise<void> {
    profiles.set(profile.id, profile);
  },
};

代理通过工具访问 persistent

关键是让代理不要直接访问数据库,而是通过 tools 工作。 这样它保持为“纯”实体:一侧是 LLM 与规划逻辑,另一侧是集成实现。

例如工具 get_recipient_profiles

export async function getRecipientProfilesTool(input: {
  userId: string;
}): Promise<{ profiles: RecipientProfile[] }> {
  const profiles = await RecipientRepo.findByUser(input.userId);

  return {
    profiles,
  };
}

在工具描述中写明:“使用此 tool 获取当前用户保存的收礼人档案”。 至于何时调用,由代理自行决定。

小结:session state 负责本次对话的进度与可丢弃的临时缓存; persistent 数据负责跨会话与设备存活的事实:档案、订单、心愿单。 代理总是通过工具读取它们,而不是“魔法记忆”。

5. session 与 persistent 在 run 循环中的配合

把以上合在一起。在代理的每一步 run 循环中,通常有这样一段短流程:

  1. 根据 sessionId 取回 session state。
  2. 必要时通过工具从数据库加载相关的 persistent 数据。
  3. 为模型构建上下文(messages + 结构化状态)。
  4. 模型决策:直接回复文本或调用工具。
  5. 工具要么更新 session state,要么更新 persistent(经由数据库)。
  6. 保存新的 session 状态,并在需要时创建 checkpoint(下一节)。
  7. 向用户返回答复。

Mermaid 图示:

flowchart TD
    A[获取用户输入] --> B["加载 Session(state + messages)"]
    B --> C{是否需要 persistent 数据?}
    C -- 是 --> D[调用 tools:get_user_profile, get_recipient_profiles]
    C -- 否 --> E[为 LLM 构建上下文]
    D --> E
    E --> F["调用模型(LLM)"]
    F --> G{模型想调用 tool 吗?}
    G -- 是 --> H[执行 tool,更新 session/persistent]
    G -- 否 --> I[准备最终回复]
    H --> J[创建 checkpoint 并保存 Session]
    I --> J
    J --> K[向用户回复]

这样的循环让代理行为可复现:每一步我们都能明确知道调用模型前的状态是什么,以及之后发生了什么变化。

6. Checkpoints:代理状态的快照

Checkpoints 是在流程关键步骤保存下来的“状态快照”。 它不只是“当前的 session state”,而是写入外部存储的事实: 在第 N 步时,我们的状态是什么、工具结果是什么、用户输入是什么。

它们的用途:

  • 错误与崩溃后的恢复;
  • 支持用户“稍后继续”;
  • 调试:复现问题 run;
  • 审计:例如下单前代理究竟做了什么。

Checkpoint 通常包含什么

典型 checkpoint 包含:

  • 标识符:runIduserIdworkflowIdstepId
  • 当时的会话状态;
  • 关键 persistent 实体的标识(例如订单草稿 id);
  • 元数据:创建时间、代理版本。

重要的是不要把整段对话文本都塞进去。稍后在“内存卫生”里我们会再讨论哪些该存、哪些不该存。

更好的做法是存 Session 的引用或步骤的简要摘要。

7. 为 GiftGenius 设计 checkpoints

以我们的选礼流程为例,决定哪些地方需要 checkpoint,例如:

  • 在收集完收礼人档案之后;
  • 在生成并完成初步过滤候选之后;
  • 在向用户展示最终选择之前。

Checkpoint 与 workflow 状态的类型

描述 workflow 状态(很像 GiftSessionState,但它是用于 checkpoint 的“快照”):

export type GiftWorkflowStep =
  | "profile_collected"
  | "candidates_generated"
  | "filtered"
  | "final_choice_made";

export type GiftCheckpoint = {
  id: string;
  runId: string;
  userId: string;

  step: GiftWorkflowStep;

  // 用于恢复所需的 session 状态子集
  sessionState: GiftSessionState;

  // 已生成的候选 id
  candidateIds: string[];

  createdAt: string; // ISO
  agentVersion: string;
};

Checkpoint 存储(简化)

像之前一样,先用简单的 Map 代替真实数据库:

const checkpoints = new Map<string, GiftCheckpoint>();

export const GiftCheckpointRepo = {
  async save(cp: GiftCheckpoint) {
    checkpoints.set(cp.id, cp);
  },

  async findByRun(runId: string): Promise<GiftCheckpoint[]> {
    return [...checkpoints.values()].filter((c) => c.runId === runId);
  },

  async findLastByUser(userId: string): Promise<GiftCheckpoint | undefined> {
    return [...checkpoints.values()]
      .filter((c) => c.userId === userId)
      .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
  },
};

在代理代码中创建 checkpoint

假设有一个 helper,我们在关键步骤后调用:

import { randomUUID } from "crypto";

export async function createCheckpoint(params: {
  runId: string;
  userId: string;
  step: GiftWorkflowStep;
  sessionState: GiftSessionState;
  candidateIds: string[];
}) {
  const checkpoint: GiftCheckpoint = {
    id: randomUUID(),
    runId: params.runId,
    userId: params.userId,
    step: params.step,
    sessionState: params.sessionState,
    candidateIds: params.candidateIds,
    createdAt: new Date().toISOString(),
    agentVersion: "v1.3.0",
  };

  await GiftCheckpointRepo.save(checkpoint);
}

代理在合适的时机可以调用:

await createCheckpoint({
  runId,
  userId,
  step: "filtered",
  sessionState,
  candidateIds,
});

在恢复时我们会:

  1. 根据 runIduserId 找到最新的 checkpoint;
  2. checkpoint.sessionState 恢复 session.state
  3. 必要时根据 candidateIds 从数据库拉取最新数据。

8. 从技术上存放 session、persistent 和 checkpoints 的位置

在基础设施层面,你通常会使用三类不同的存储:

  • 内存(In‑memory)——用于开发/演示,速度快但易失。
  • Redis(或其他 KV 存储)——用于 session 状态。
  • 关系型/NoSQL 数据库——用于 persistent 数据与 checkpoints。

用于本地开发的内存存储

对于本地开发场景,一个简单的内存存储足够。例如,一个带 TTL 的会话迷你存储:

type StoredSession<T> = {
  state: T;
  expiresAt: number;
};

const sessions = new Map<string, StoredSession<GiftSessionState>>();

export function saveSession(sessionId: string, state: GiftSessionState) {
  sessions.set(sessionId, {
    state,
    expiresAt: Date.now() + 30 * 60 * 1000, // 30 分钟
  });
}

export function loadSession(sessionId: string): GiftSessionState | undefined {
  const stored = sessions.get(sessionId);
  if (!stored) return undefined;
  if (stored.expiresAt < Date.now()) {
    sessions.delete(sessionId);
    return undefined;
  }
  return stored.state;
}

这种方式非常适合本地开发,但在生产的水平扩展场景(多个实例)下就不适用了。

用于 session state 的 Redis

在生产中,用 Redis 存放 session 状态很方便:

  • 读写速度快;
  • 自带 TTL;
  • 对所有服务实例可用。

伪示例(简化):

// 对 Redis 客户端的包装
export async function saveSessionToRedis(
  sessionId: string,
  state: GiftSessionState
) {
  const json = JSON.stringify(state);
  await redis.set(`session:${sessionId}`, json, "EX", 60 * 30); // 30 分钟
}

export async function loadSessionFromRedis(
  sessionId: string
): Promise<GiftSessionState | undefined> {
  const json = await redis.get(`session:${sessionId}`);
  return json ? (JSON.parse(json) as GiftSessionState) : undefined;
}

用于 persistent 与 checkpoints 的 Postgres/其他数据库

Persistent 状态与 checkpoints 都是“严肃”的实体,需要事务、迁移、索引等能力。 通常放在 Postgres、MySQL、Firestore 等数据库中。

架构上的通用模式:

  • session 存在 Redis,带 TTL;
  • persistent 与 checkpoints 存在数据库中,无 TTL(或采用与业务相关的保留策略)。

9. 内存卫生:大小、隐私、职责分离

代理的内存并不是“随便放个对象就开干”。有几条重要规则能省钱并保你安睡。

不要把一切都塞进 messages

消息历史是昂贵资源:

  • 长度会显著影响模型调用的成本;
  • 通常含有大量“噪声”。

因此:

  • 尽早把历史中的事实抽取到结构化状态;
  • 对较旧的历史使用摘要(summarization);
  • 如果要在 checkpoint 中保存文本历史,请与发给模型的上下文分开存放。

隐私与 PII

尤其在 commerce 场景,不要把敏感数据放到不该出现的地方。 许多内存架构文档都强调,不要把 PII 放在 messages 或 checkpoint 中而未经清理。

实践建议:

  • 除非确有必要,不要把 email/电话/地址直接放入 session state;
  • 日志与 checkpoint 尽量记录标识符(如 userIdrecipientProfileId),不要记录原始字符串;
  • 如果必须在多个步骤间传递 PII——将其存入 persistent 存储中的受保护字段,在 state 中仅传递键。

业务数据与对话日志的分离

一个良好模式是把 state 视为“干净内存”,而把 messages 视为“脏内存”。

也就是说:

  • 业务实体(档案、订单、购物车)始终存于数据库;
  • state/检查点仅包含恢复流程所必需的最小集合;
  • 日志/聊天历史单独存放(例如向量库),用于分析,而不是混入每次模型请求。

10. 迷你练习:你会保存哪些内容?

为了巩固内存分层的差异,我们从理论里抬起头,想一想具体案例。 不必写代码——只需在纸上或脑中勾勒结构即可。

设想你的 GiftGenius 与用户有如下对话:

  • 用户:“要给同事开发者准备礼物,预算到 50$,他喜欢桌游和咖啡因。”
  • 代理:提出一些澄清问题。
  • 用户:“他讨厌马克杯,而且已经有一堆笔记本了。”
  • 代理:生成 10 个点子,用户选中其中一个,但说:“我回头再来完成下单。”

思考:

  1. 你会把什么放入会话可能在 30 分钟后过期的 session state?
  2. 你会把什么放入 persistent 存储,以便用户一周后回来还能继续?
  3. 在“已选定点子但尚未下单”这一刻,checkpoint 长什么样?

尝试勾勒相应的 TypeScript 类型与函数 saveSessionStatesavePersistentStatecreateGiftIdeaCheckpoint ,参考本讲的示例。 如果愿意,可以直接在编辑器里照着本讲的样例先写一版——这会是进入下一讲前很好的“小 checkpoint”。

11. 使用代理内存时的常见错误

错误 1:试图只把一切都存进消息历史。
开发者会说:“模型已经能看到全部对话了,为什么还要整什么 state?” 结果是几十条消息后上下文窗口被垃圾填满,token 花得堪比新 MacBook,代理行为也变得不稳定——它看不到一些重要的旧事实。 这个问题应该通过显式的 session state 与 persistent 存储来解决,而不是一味增加上下文上限。

错误 2:把 session 与 persistent 混成一个对象。
有时会忍不住搞一个“大一统”的 AgentState,把所有东西都丢进去,然后原样存数据库。 这样会模糊“某次对话的临时数据”与“用户的长期数据”的边界。 接着就会出现“部署后所有会话神秘地从去年的数据恢复”或“某个用户的会话意外加载了别人的 persistent 档案”之类的问题。 请有意识地分离层级。

错误 3:在 checkpoint 里存得太多。
常见误区是把工具响应的整段 JSON、整段对话历史、集成的原始数据等全部写进 checkpoint。 几周后 checkpoint 库会膨胀到离谱,备份要跑一个小时,数据库查询也会变慢。 Checkpoint 中只应保留继续流程真正需要的事实,再加上少量元数据。

错误 4:忘记为 session state 设计 TTL 与清理。
如果 session 状态没有寿命,那么任何一次用户在 Dev 模式下的试验都会永远留在 Redis 中。 几个月后你看监控会发现一堆“被遗忘”的会话占着内存。 Session 层需要显式 TTL,而 persistent 层需要清晰的保留策略。

错误 5:不必要地在 state 与 checkpoint 中存放 PII。
尤其危险的是把 email、地址、卡号随手放进 session state,随后该对象被序列化进日志、流到分析系统与 checkpoint。 这会带来合规与安全上的严重风险。 更好的做法是只存安全的标识符,需要时通过受保护的工具把标识符解析成真实数据。

错误 6:没有从 checkpoint 恢复的策略。
有的团队认真地记录了 checkpoint,但没有设计代理如何从中恢复。 于是当“出了问题”时,开发者面对一张张漂亮的 JSON 表,却没有能按它们重建 run 的代码。 没有恢复方案的 checkpointing 只是昂贵的日志,而不是可靠性的工具。

错误 7:把代理与具体存储实现强耦合。
如果代理代码直接访问 Redis/Postgres,就更难迁移、测试与演进。 当架构发生变化(比如出现 MCP 资源或独立的 state 服务)时,不得不大幅改动代理逻辑。 更好的做法是让代理只看到 Session 抽象与一组工具,至于数据具体在哪儿,由工具去知道。

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