1. 为什么代理需要单独的内存
这一部分承接模块 12 的前置课程(关于代理):我们已经讨论了基础架构、run 循环与工具; 这里重点讲内存与状态。
如果类比常见的 Web 应用,这里的 LLM 就像一颗非常聪明的 CPU,能执行复杂的“文本程序”。 而代理的状态就像 RAM 与 SSD 的组合:短期的会话数据与长期存储。
在经典的 ChatGPT 聊天(没有你的代码)里,“内存”只是当前请求中模型可见的消息列表 system/user/assistant/tool。 对于一个代理而言,这远远不够,因为:
- 它需要记住复杂流程的进度:workflow 已完成到哪一步、哪些礼物候选已被过滤、用户确认了什么;
- 它需要了解用户的长期事实:偏好、收货地址、历史订单;
- 它需要具备容错性:如果服务器在挑选礼物中途宕机,用户不该被迫全部重来。
如果只把这些都塞进 prompt 上下文,你会很快碰到上下文窗口的上限,并为重复事实不断支付 token 成本。 同时还会有安全风险:太多无关数据会被频繁发送给模型。 因此,在代理系统中总会有显式状态——由你管理的、存在于消息历史之外的对象。
2. 代理状态的分层:上下文、session、persistent
先按层次拆分。一个代理通常至少有三层“内存”:
- 消息历史(dialogue context)。
- 会话状态(session state)。
- 长期状态(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_profile、get_past_orders。 不要在代理内部藏全局变量;一切都应是显式调用。
对比表
| 层 | 驻留位置 | 生命周期 | 数据示例 |
|---|---|---|---|
| Messages | Session / SDK / OpenAI | 单次 run / 对话 | system/user/tool 消息 |
| Session state | KV / SessionService / Redis | 会话存活期间 | 工作流步骤、临时缓存 |
| Persistent | 数据库(Postgres/NoSQL/ACP 后端) | 跨会话与对话 | 个人资料、订单、已保存清单 |
3. Session state:是什么以及如何存放
想象代理 GiftGenius 在执行一个多步骤流程:
- 收集收礼人的个人档案。
- 生成候选清单。
- 按预算、配送、地区进行过滤。
- 准备最终推荐。
过程中它会不断与用户对话并调用工具。 凡是“本次礼物挑选会话的进度”,都适合放在 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 循环中,通常有这样一段短流程:
- 根据 sessionId 取回 session state。
- 必要时通过工具从数据库加载相关的 persistent 数据。
- 为模型构建上下文(messages + 结构化状态)。
- 模型决策:直接回复文本或调用工具。
- 工具要么更新 session state,要么更新 persistent(经由数据库)。
- 保存新的 session 状态,并在需要时创建 checkpoint(下一节)。
- 向用户返回答复。
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 包含:
- 标识符:runId、userId、workflowId、stepId;
- 当时的会话状态;
- 关键 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,
});
在恢复时我们会:
- 根据 runId 或 userId 找到最新的 checkpoint;
- 用 checkpoint.sessionState 恢复 session.state;
- 必要时根据 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 尽量记录标识符(如 userId、recipientProfileId),不要记录原始字符串;
- 如果必须在多个步骤间传递 PII——将其存入 persistent 存储中的受保护字段,在 state 中仅传递键。
业务数据与对话日志的分离
一个良好模式是把 state 视为“干净内存”,而把 messages 视为“脏内存”。
也就是说:
- 业务实体(档案、订单、购物车)始终存于数据库;
- state/检查点仅包含恢复流程所必需的最小集合;
- 日志/聊天历史单独存放(例如向量库),用于分析,而不是混入每次模型请求。
10. 迷你练习:你会保存哪些内容?
为了巩固内存分层的差异,我们从理论里抬起头,想一想具体案例。 不必写代码——只需在纸上或脑中勾勒结构即可。
设想你的 GiftGenius 与用户有如下对话:
- 用户:“要给同事开发者准备礼物,预算到 50$,他喜欢桌游和咖啡因。”
- 代理:提出一些澄清问题。
- 用户:“他讨厌马克杯,而且已经有一堆笔记本了。”
- 代理:生成 10 个点子,用户选中其中一个,但说:“我回头再来完成下单。”
思考:
- 你会把什么放入会话可能在 30 分钟后过期的 session state?
- 你会把什么放入 persistent 存储,以便用户一周后回来还能继续?
- 在“已选定点子但尚未下单”这一刻,checkpoint 长什么样?
尝试勾勒相应的 TypeScript 类型与函数 saveSessionState、 savePersistentState、 createGiftIdeaCheckpoint ,参考本讲的示例。 如果愿意,可以直接在编辑器里照着本讲的样例先写一版——这会是进入下一讲前很好的“小 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 抽象与一组工具,至于数据具体在哪儿,由工具去知道。
GO TO FULL VERSION