CodeGym /課程 /ChatGPT Apps /代理的記憶與狀態:session vs persistent,checkpoints

代理的記憶與狀態:session vs persistent,checkpoints

ChatGPT Apps
等級 12 , 課堂 2
開放

1. 為什麼代理需要獨立的記憶

本節承接模組 12 關於代理的前面課程:我們已經討論了基礎架構、run 迴圈與工具; 這裡聚焦於記憶與狀態。

若與常見的網頁應用相比,LLM 在此就像非常聰明的 CPU,能執行複雜的「文字程式」。 而代理的狀態則像是 RAM 與 SSD 的組合:短期的工作階段資料與長期的儲存。

在經典的 ChatGPT 聊天中(沒有你的程式碼時),「記憶」只是訊息列表 system/user/assistant/tool, 模型在當前請求會看見這些內容。對代理來說,這還不夠,因為:

  • 需要記住複雜流程的進度:workflow 的哪一步已完成、哪些禮物候選已被篩掉、使用者確認了什麼;
  • 需要了解使用者的長期事實:偏好、配送地址、過往訂單歷史;
  • 需要能夠應對故障:如果伺服器在挑選禮物中途宕機,使用者不該被迫重新輸入一切。

若嘗試只把這些都放在 prompt 上下文,很快就會碰到上下文視窗限制,且為同樣的事實反覆付出權杖成本。 同時也有安全風險:過多不必要的資料會被定期送進模型。 因此在代理系統中,總會有顯式的狀態——位於訊息歷史之外、由你來管理的物件(們)。

2. 代理狀態的層次:context、session、persistent

先依層次拆解。代理通常至少有三個「記憶」層級:

  1. 訊息歷史(dialogue context)。
  2. 工作階段狀態(session state)。
  3. 長期狀態(persistent state)。

重要:不要把這些概念混為一談。

訊息歷史:「髒記憶」

訊息歷史是 LLM 在每一步會看見的內容: system 指令、使用者請求、代理回覆、工具結果。

好處是你不必手動維護——Agents SDK 與平台會透過 Session/Conversation 這個實體處理。

壞處是這是「髒」記憶:充滿多餘字詞、重複與使用者的零碎資料。 這些資料以權杖計價昂貴,且結構鬆散。 你不會希望把已篩掉的 200 筆禮物清單,每次都用純文字念給模型聽。

Session state:工作用的短期記憶

Session 狀態是住在單一代理工作階段/對話之內的結構化物件。 對前端開發者的好比喻是 useState 或 Redux store,只要分頁還開著就會存在。

裡面會放:

  • 流程的當前步驟(例如 "collecting_profile""filtering_candidates");
  • 工具結果的暫時快取;
  • 工作階段的參數:在地化、選定的通道、像是「使用者已同意條款」這類旗標。

此狀態可以放在靠近代理的地方——例如 Redis、in-memory 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 工作階段存續期間 workflow 步驟、暫時快取
Persistent 資料庫 (Postgres/NoSQL/ACP backend) 跨工作階段與對話之間 個人檔案、訂單、已儲存清單

3. Session state:是什麼以及怎麼存

想像代理 GiftGenius 正在執行多步驟流程:

  1. 收集收禮者的個人檔案。
  2. 產生候選清單。
  3. 依預算、配送、地區篩選。
  4. 準備最終精選。

過程中它會持續與使用者互動並呼叫工具。 凡是屬於「這次挑禮物工作階段的進度」的內容,合適放在 session state。

GiftGenius 的工作階段狀態結構範例

用 TypeScript 描述 session 狀態的型別:

// 單一「挑選禮物」流程中的狀態
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 具備工作階段抽象,會自動維護訊息歷史,並允許你另外保存結構化狀態。 以偽代碼來看,大致如下:

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 步時,我們有某個 state、某些工具結果、以及某次使用者輸入。

用途:

  • 錯誤與崩潰後的復原;
  • 提供使用者「稍後繼續」的能力;
  • 偵錯:重現問題 run;
  • 稽核:例如在建立訂單前,代理做了什麼。

checkpoint 通常包含什麼

典型的 checkpoint 會有:

  • 識別碼:runIduserIdworkflowIdstepId
  • 當下的工作階段狀態;
  • persistent 實體的關鍵識別碼(例如訂單草稿的 id);
  • 中繼資料:建立時間、代理版本。

重點是不要把整段對話文字都塞進去。稍後在「記憶衛生」章節,我們會再談哪些該存、哪些不該存。

更好的做法是保存對 Session 的引用或是步驟的簡要摘要。

7. 為 GiftGenius 設計檢查點

以我們的挑禮物流程來看,決定在哪些步驟建立檢查點,例如:

  • 收集完收禮者檔案之後;
  • 產生並初步篩選候選清單之後;
  • 在向使用者提出最終選擇之前。

checkpoint 與 workflow 狀態的型別

描述 workflow 狀態(與 GiftSessionState 很像,但這是用於檢查點的「定影」):

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;
};

檢查點儲存(簡化版)

同樣先用簡單的 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];
  },
};

在代理程式碼中建立檢查點

想像一個在重要步驟後會呼叫的 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 找到最後一個檢查點。
  2. checkpoint.sessionState 還原到 session.state
  3. 必要時,根據 candidateIds 從資料庫補上最新資料。

8. 從技術面來看,session、persistent 與 checkpoints 應放哪裡

在基礎設施層面,你通常會用到三種不同類別的儲存:

  • In‑memory——適合開發/展示,快速但暫時。
  • Redis(或其他 KV store)——用於 session 狀態。
  • 關聯式/NoSQL 資料庫——用於 persistent 資料與檢查點。

本機開發用的 in‑memory store

在本機開發模式,簡單的 in‑memory store 就夠用了。例如,一個具有 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;
}

這樣的做法很適合本機開發,但在生產環境做水平擴充(多個執行個體)時就不適用了。

用 Redis 儲存 session state

在生產環境,用 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;
}

用 Postgres/其他資料庫存 persistent 與檢查點

Persistent 狀態與檢查點是「嚴肅」的實體,需要交易、遷移、索引等能力。 通常會放在 Postgres、MySQL、Firestore 等。

架構上的簡單準則:

  • session 放在有 TTL 的 Redis;
  • persistent 與 checkpoints 放在資料庫,沒有 TTL(或依商務需求設定保留策略)。

9. 記憶衛生:大小、隱私、責任分離

代理的記憶不是「隨便丟個物件就好」。有幾個重要原則能省錢、也讓你睡得更安穩。

不要把一切都塞進 messages

訊息歷史是昂貴資源:

  • 其長度強烈影響模型請求的成本;
  • 通常含有大量「雜訊」。

因此:

  • 儘早把歷史中的事實抽取到結構化狀態;
  • 對舊的歷史使用摘要(summarization);
  • 若需要在檢查點保存文本歷史,請將其與送進模型的內容分離。

隱私與 PII

特別是在 commerce 情境,請勿把敏感資料存放在不該出現的地方。 關於記憶架構的文件明確強調,未清理的 PII 不該放在 messages 或檢查點裡。

實務守則:

  • 若非代理工作必需,別把 email/電話/地址直接放進 session state;
  • 在日誌與檢查點盡量記錄識別碼(userIdrecipientProfileId)而非原始字串;
  • 若需要跨步驟傳遞 PII——請用 persistent 儲存中的受保護欄位,而在 state 中只傳遞鍵值。

分離商業資料與對話日誌

一個好模式是把 state 視為「乾淨」記憶,而 messages 視為「髒」記憶。

也就是說:

  • 商業實體(個人檔案、訂單、購物車)長期存放在資料庫;
  • state/檢查點只包含復原流程所需的最小集;
  • 日誌/聊天歷史分開保存(例如放在向量庫),用於分析,而不是在每次模型請求中攪和進去。

10. 迷你實作:你會保存哪些?

為了鞏固不同記憶層的概念,先離開理論,想一個具體情境。 不必寫程式——在紙上或腦中先設計一下結構即可。

假設你的代理 GiftGenius 與使用者有以下對話:

  • 使用者:「需要一份給同事(開發者)的禮物,預算到 50$,他喜歡桌遊和咖啡因。」
  • 代理:提出幾個追問。
  • 使用者:「他討厭馬克杯,而且筆記本已經堆滿。」
  • 代理:產生 10 個點子,使用者選了一個,但說:「我之後再回來完成訂單。」

請思考:

  1. 你會把什麼放進可能在 30 分鐘內失效的 session state?
  2. 你會把什麼放進 persistent 儲存,讓使用者一週後能回來銜接?
  3. 在選定點子但尚未下單時,checkpoint 會長什麼樣?

嘗試草擬相應的 TypeScript 型別與函式 saveSessionStatesavePersistentStatecreateGiftIdeaCheckpoint ,依照本講座中的範例來設計。 如果願意,直接在編輯器裡照著上面的範例動手寫——這會是下一堂課前很好的小檢查點。

11. 使用代理記憶時的常見錯誤

錯誤 1:嘗試把一切都放在訊息歷史。
開發者很開心地想:「模型反正會看到整段對話,為什麼還要再搞一個 state?」。 結果是幾十則訊息後,上下文視窗被雜訊塞滿,權杖成本堪比一台新 MacBook,代理行為也變得不穩定——它根本看不到早先的重要事實。 這個問題應該用明確的 session state 與 persistent 儲存來解,而不是一味拉高限制。

錯誤 2:把 session 與 persistent 混成一個物件。
有時會想偷懶做一個巨大的 AgentState,把所有東西丟進去,然後「原封不動」存到資料庫。 這會模糊「特定對話的暫時資料」與「使用者的長期資料」之間的界線。 於是就會出現「部署後,所有工作階段神祕地從去年的資料復原」或「某位使用者的工作階段誤抓到別人的 persistent 檔案」之類情況。 請有意識地分離層級。

錯誤 3:在檢查點保存過多資料。
常見錯誤是把工具回應的整份 JSON、完整對話歷史、整合的原始資料等全部塞進 checkpoint。 運行幾週後,檢查點資料庫會肥大到離譜、備份要跑一小時、查詢也變慢。 checkpoint 應只保存真正續辦所需的事實,加上一點必要中繼資料。

錯誤 4:忘了為 session state 設定 TTL 與清理。
若 session 狀態沒有壽命,任何使用者在 Dev Mode 的隨手實驗都會永遠留在 Redis。 幾個月後你看監控會發現一堆「被遺忘」的工作階段在吃記憶體。 Session 層應該明確設計 TTL,而 persistent 層則需要經過深思熟慮的保留策略。

錯誤 5:不必要地把 PII 放在 state 與檢查點。
尤其危險的是,若把 email、地址、卡號等毫無節制地放進 session state,接著這個物件被序列化到日誌、流入分析系統與 checkpoint。 這會造成法規與資安的重大風險。 更好的做法是保存安全的識別碼,必要時再透過受保護的工具把它們解析為真實資料。

錯誤 6:沒有從檢查點復原的策略。
有的團隊會老實記錄檢查點,但沒有設計代理應該如何從中復原。 於是當「出了問題」時,開發者看著資料表裡漂亮的 JSON,卻沒有能依此重建 run 的程式。 沒有復原劇本的 checkpointing,只是昂貴的日誌,而不是可靠性的工具。

錯誤 7:把代理硬綁到特定的儲存實作。
如果代理的程式碼直接去存取 Redis/Postgres,遷移、測試與演進都更困難。 一旦架構改變(例如出現 MCP 資源或獨立的 state 服務),就得大幅改動代理邏輯。 更好的設計是讓代理只看見 Session 抽象與一組 tools,而工具本身知道資料實際存在哪裡。

留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION