1. 為什麼代理需要獨立的記憶
本節承接模組 12 關於代理的前面課程:我們已經討論了基礎架構、run 迴圈與工具; 這裡聚焦於記憶與狀態。
若與常見的網頁應用相比,LLM 在此就像非常聰明的 CPU,能執行複雜的「文字程式」。 而代理的狀態則像是 RAM 與 SSD 的組合:短期的工作階段資料與長期的儲存。
在經典的 ChatGPT 聊天中(沒有你的程式碼時),「記憶」只是訊息列表 system/user/assistant/tool, 模型在當前請求會看見這些內容。對代理來說,這還不夠,因為:
- 需要記住複雜流程的進度:workflow 的哪一步已完成、哪些禮物候選已被篩掉、使用者確認了什麼;
- 需要了解使用者的長期事實:偏好、配送地址、過往訂單歷史;
- 需要能夠應對故障:如果伺服器在挑選禮物中途宕機,使用者不該被迫重新輸入一切。
若嘗試只把這些都放在 prompt 上下文,很快就會碰到上下文視窗限制,且為同樣的事實反覆付出權杖成本。 同時也有安全風險:過多不必要的資料會被定期送進模型。 因此在代理系統中,總會有顯式的狀態——位於訊息歷史之外、由你來管理的物件(們)。
2. 代理狀態的層次:context、session、persistent
先依層次拆解。代理通常至少有三個「記憶」層級:
- 訊息歷史(dialogue context)。
- 工作階段狀態(session state)。
- 長期狀態(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_profile、get_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 正在執行多步驟流程:
- 收集收禮者的個人檔案。
- 產生候選清單。
- 依預算、配送、地區篩選。
- 準備最終精選。
過程中它會持續與使用者互動並呼叫工具。 凡是屬於「這次挑禮物工作階段的進度」的內容,合適放在 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 迴圈,通常有一個簡短序列:
- 依 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 步時,我們有某個 state、某些工具結果、以及某次使用者輸入。
用途:
- 錯誤與崩潰後的復原;
- 提供使用者「稍後繼續」的能力;
- 偵錯:重現問題 run;
- 稽核:例如在建立訂單前,代理做了什麼。
checkpoint 通常包含什麼
典型的 checkpoint 會有:
- 識別碼:runId、userId、workflowId、stepId;
- 當下的工作階段狀態;
- 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,
});
在復原時,我們會:
- 依 runId 或 userId 找到最後一個檢查點。
- 把 checkpoint.sessionState 還原到 session.state。
- 必要時,根據 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;
- 在日誌與檢查點盡量記錄識別碼(userId、recipientProfileId)而非原始字串;
- 若需要跨步驟傳遞 PII——請用 persistent 儲存中的受保護欄位,而在 state 中只傳遞鍵值。
分離商業資料與對話日誌
一個好模式是把 state 視為「乾淨」記憶,而 messages 視為「髒」記憶。
也就是說:
- 商業實體(個人檔案、訂單、購物車)長期存放在資料庫;
- state/檢查點只包含復原流程所需的最小集;
- 日誌/聊天歷史分開保存(例如放在向量庫),用於分析,而不是在每次模型請求中攪和進去。
10. 迷你實作:你會保存哪些?
為了鞏固不同記憶層的概念,先離開理論,想一個具體情境。 不必寫程式——在紙上或腦中先設計一下結構即可。
假設你的代理 GiftGenius 與使用者有以下對話:
- 使用者:「需要一份給同事(開發者)的禮物,預算到 50$,他喜歡桌遊和咖啡因。」
- 代理:提出幾個追問。
- 使用者:「他討厭馬克杯,而且筆記本已經堆滿。」
- 代理:產生 10 個點子,使用者選了一個,但說:「我之後再回來完成訂單。」
請思考:
- 你會把什麼放進可能在 30 分鐘內失效的 session state?
- 你會把什麼放進 persistent 儲存,讓使用者一週後能回來銜接?
- 在選定點子但尚未下單時,checkpoint 會長什麼樣?
嘗試草擬相應的 TypeScript 型別與函式 saveSessionState、 savePersistentState、 createGiftIdeaCheckpoint ,依照本講座中的範例來設計。 如果願意,直接在編輯器裡照著上面的範例動手寫——這會是下一堂課前很好的小檢查點。
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,而工具本身知道資料實際存在哪裡。
GO TO FULL VERSION