補寫於 2026 年 8 月 24 日,9 月才上線(8 月 21 日的排程沒跑起來,稿子隔三天補上;部落格的發佈額度也在 8 月中用完了)。文中的版本號與「目前」都指 8 月 24 日的官方文件狀態,Agent SDK 更新很快,你讀到時可能已經又改過了。

一個用 Agent SDK 寫的 agent,包成 function 丟上雲端跑。第一次呼叫很順,回了一個 session id。使用者接著問第二句,這次進來的是另一個容器,你把剛剛那個 session id 塞進 resume,找不到。

原因不在你的程式碼。Agent SDK 預設把 session 的 transcript 寫成本機的 JSONL 檔,位置在 ~/.claude/projects/。開第一句話那台機器已經被回收了,檔案跟著一起蒸發。

autoscale 的 worker 是同一題,只是更頻繁:同一個使用者的兩句話打到兩台不同的 pod,第二句就變成一場全新的對話。

同一個 session,換一台機器就等於沒發生過。

兩條土法煉鋼的路

第一個念頭通常是:我自己存啊。把使用者說什麼、模型回什麼寫進自己的資料庫,下次呼叫再把歷史整包塞回 prompt。這條路能動,代價是你手上多了一套對話狀態,而 SDK 內部還有它自己那一套。兩邊記的內容不完全一樣,維護的人遲早要回答「到底哪一份才對」這個問題。

第二個念頭是:那我把檔案搬走。跑完把 ~/.claude/projects/ 底下的東西 tar 起來丟物件儲存,下次開工再拉下來鋪回去;或者更粗暴一點,掛個網路磁碟讓所有 worker 共用同一個目錄。這條我沒實際試過,只能講判斷:它把整個 agent 的可用性綁在檔案系統的一致性上,而 serverless 環境裡的檔案系統本來就是租來的、隨時會還。

先把底講清楚:這篇是我讀官方那篇 Persist sessions to external storage 整理出來的,我沒有把這套裝起來跑過任何一行程式碼。下面每個行為都指得出文件的哪一段,但沒有一個是我的實測結果。

介面小到有點過分

SessionStore 是 SDK 匯出的一個型別,你實作它、把實例塞進 options,SDK 就會把 transcript 轉發給你。TypeScript 端長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 由 @anthropic-ai/claude-agent-sdk 匯出:
// SessionStore, SessionKey, SessionStoreEntry, SessionSummaryEntry
type SessionKey = {
projectKey: string; // 工作目錄的穩定、檔名安全編碼
sessionId: string; // session UUID
subpath?: string; // 有值=subagent transcript 或 sidecar 檔
};

type SessionStore = {
// 必要
append(key: SessionKey, entries: SessionStoreEntry[]): Promise<void>;
load(key: SessionKey): Promise<SessionStoreEntry[] | null>;
// 選用
listSessions?(projectKey: string): Promise<Array<{ sessionId: string; mtime: number }>>;
listSessionSummaries?(projectKey: string): Promise<SessionSummaryEntry[]>;
delete?(key: SessionKey): Promise<void>;
listSubkeys?(key: { projectKey: string; sessionId: string }): Promise<string[]>;
};

必要的只有兩個,剩下四個全部可選。

Python 端是 claude_agent_sdk 匯出的 SessionStore Protocol,欄位改 snake_case(project_key / session_id / subpath),方法名對應 append / load / list_sessions / list_session_summaries / delete / list_subkeys

先用記憶體版把接線跑通

要驗證你的 resume 接法有沒有對,不需要先開 S3 bucket。SDK 內建一個 InMemorySessionStore,官方的 Quick start 就用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { query, InMemorySessionStore } from "@anthropic-ai/claude-agent-sdk";

const store = new InMemorySessionStore();
let sessionId: string | undefined;

for await (const message of query({
prompt: "List the TypeScript files under src/",
options: { sessionStore: store },
})) {
if (message.type === "result") sessionId = message.session_id;
}

// 從 store 續跑,第二次呼叫帶同一個 store 實例 + resume
for await (const message of query({
prompt: "Summarize what those files do",
options: { sessionStore: store, resume: sessionId },
})) {
if (message.type === "result" && message.subtype === "success") console.log(message.result);
}

整段的重點就在這兩個參數,換成 Redis 或 Postgres 也不會多長。Python 對應的寫法是 ClaudeAgentOptions(session_store=store),續跑那次是 options=ClaudeAgentOptions(session_store=store, resume=session_id)

記憶體版當然一關掉行程就沒了。先把型別、session_id 從哪裡拿、resume 要帶什麼這幾件事搞定。這幾件事搞錯,換成再貴的後端也一樣接不回來。

它寫的是第二聯,不是正本

這一段最好在你動手寫 append() 之前就搞懂。

Claude Code 子行程永遠先寫本機磁碟,SDK 再把同一批 entry 轉發給你的 append()。文件把這個叫 dual-write 架構。你的 store 拿到的是副本,像早年餐廳點單那種複寫紙:廚房那一聯一定會印出來,你手上這聯是壓在下面複寫上去的。

那副本有什麼用?用在下一次啟動。下一次啟動的時候,哪一份會活下來,答案取決於這次是怎麼開起來的:

如果是新 session,或是 resume 但你的 store 裡根本沒東西,那本機那份會留著,你的 store 收到一份副本。兩份都在。

如果是從 store resume,執行結束時本機那份副本會被刪掉,你的 store 變成唯一的持久副本。機制是 SDK 把 transcript 寫進一個臨時的 config 目錄、把 CLAUDE_CONFIG_DIR 指過去,跑完就把目錄清掉。

code review 的時候值得多問一句:這次 session 是怎麼開起來的?

S3、Redis、Postgres 的三份參考實作

官方在 TypeScript SDK 的 repo 放了三個 adapter,位置是 examples/session-stores,目錄下有 s3/redis/postgres/shared/ 跟一份 README。

Adapter 後端 client 儲存模型
S3SessionStore @aws-sdk/client-s3 每次 append() 一個 JSONL part 檔;load() 列出、排序、串接
RedisSessionStore ioredis 每份 transcript 一個 list(RPUSH/LRANGE),另加 sorted-set 當 session 索引
PostgresSessionStore pg 一筆 entry 一列 jsonb,用 BIGSERIAL 排序

三份的取捨看得很清楚。S3 那份把「append 只能是新增」這件事玩得最徹底,代價是 load() 得先列出來、排序,才串得回一份完整的 transcript。Redis 那份直接吃 list 的原生語意,Postgres 那份把排序交給自增主鍵。

接上去的樣子不複雜:

1
2
3
4
5
const store = new S3SessionStore({
bucket: "my-claude-sessions",
prefix: "transcripts",
client: new S3Client({ region: "us-east-1" }),
});

有個地雷要先踩明白:這三份沒有發佈到 npm。你不能 npm install 一個 @anthropic-ai/session-store-s3,得自己把 src/ 底下的檔案複製進專案,再裝對應的後端 client。README 也自己講了這些是參考實作、不當生產程式碼維護。複製進去那天開始,那份程式碼的維護責任是你的。

13 個 contract 幫你驗自己寫的那份

複製進來要改、或乾脆自己寫一個接內部儲存服務的版本,怎麼知道有沒有寫壞?官方給了一致性測試。README 說三個 adapter 都通過 shared/conformance.ts 定義的 13 個 contract,TypeScript 端就是把那個檔複製進你的測試;Python 端更省事,測試套件內建,裝好 pytest 就能跑:

1
2
3
4
5
from claude_agent_sdk.testing import run_session_store_conformance

@pytest.mark.anyio
async def test_my_store_conformance():
await run_session_store_conformance(MyRedisStore)

有一個要求文件寫得很明白:你傳進去的工廠每次都要回傳一個「儲存空間是空的」新 store,因為各個 contract 會重用同一組 session key。工廠回傳共用實例的話,測試會互相污染。

會咬人的地方

寫入是 best-effort,這是整套設計最需要你自己補的地方。

append() reject 的時候 SDK 最多試 3 次(第一次算在裡面),中間隔一小段 backoff。timeout 不重試,理由是原本那次呼叫可能還是會落地。三次都失敗就記 log、往 iterator 塞一則 { type: "system", subtype: "mirror_error" }、丟掉這批、繼續跑下去。

兩個可以直接寫進 TODO 的結論。第一,你的 append() 要用 entry.uuid 去重,因為重試會把已經落地的 entry 再送一次。第二,想知道有沒有掉資料,就得監聽 mirror_error;在「從 store resume」那條路上,本機不留副本,掉一批就真的沒了。

projectKey 綁工作目錄,所以 resume 必須在跟原始執行相符的工作目錄底下跑。TypeScript 可以用 CLAUDE_CODE_PROJECT_DIR_NAME 改用自訂名稱當 key(配 CLAUDE_CONFIG_DIR 放在 query 的 env 裡),需要 Agent SDK v0.3.234 以上。要注意 listSessions / deleteSession 這種 standalone helper 不吃 env,它們讀的是行程的環境變數,所以主行程也要設同一組。

有兩個選項跟 store 是互斥的,同時給就在啟動時 throw:TypeScript 的 persistSession: false(鏡射的來源被你關掉了),還有 file checkpointing(enableFileCheckpointing / enable_file_checkpointing,它的檔案備份直接寫本機,SDK 不鏡射)。

getSessionMessages 回的不是原始全量。文件舉的例子是 store 裡有 503 筆原始 entry,這個函式可能只回 18 則 message,因為 auto-compaction 把早期回合換成摘要了。要原始那 503 筆得直接呼叫 store.load(key)

看起來像壞掉,其實是設計。

forkSession 也不能在 adapter 層抄捷徑。它會重寫每個 sessionId 欄位、重映射 message UUID,所以你不能用 S3 的 CopyObject 或自己寫個 copy 了事,抄出來會是一份還指著舊 session ID 的 transcript。

保留策略完全是你的責任。

SDK 永遠不會主動從你的 store 刪東西,TTL、S3 lifecycle、定期清理都要自己做。本機那份走 cleanupPeriodDays 的掃描,但「從 store resume」那條路不留本機檔,等於你的保留策略就是唯一的保留策略。

Python 使用者還有一條要看。從 store resume 的時候,TypeScript 會複製憑證、.claude.json、user 的 settings.json(並剝掉 enabledPluginsextraKnownMarketplaces 與其 additionalMarketplaces 別名、以及 env 區塊裡的 CLAUDE_CONFIG_DIR)。Python 只複製憑證與 .claude.json。靠 user settings.jsonapiKeyHelper 認證的 Python app 從 store resume 會拿到 Not logged in,得把它放在 managed 或 project settings 才行。版本上,TS 剝掉別名是 v0.3.232 起。v0.3.222 之前 TS 也只複製憑證與 .claude.json

還有兩個缺角:listSubkeys 沒實作的話,resume 只還原主 transcript,subagent 的 transcript 不會回來;文件也註明 Python 端沒有 startup() 的對應函式。

回到那台被回收的容器

同一個場景再走一次。使用者問第二句、負載平衡把他丟到另一台機器、你的程式碼呼叫 resume

換機器接不回對話這件事會變成一次 store 的讀取。介面只有兩個必要方法,門檻低到讓人有點想笑,代價就是那些門檻全被搬進你的營運責任裡。