換一台容器就接不回對話?Agent SDK 的 SessionStore 把 transcript 搬去 S3
補寫於 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 | // 由 @anthropic-ai/claude-agent-sdk 匯出: |
必要的只有兩個,剩下四個全部可選。
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 | import { query, InMemorySessionStore } from "@anthropic-ai/claude-agent-sdk"; |
整段的重點就在這兩個參數,換成 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 | const store = new S3SessionStore({ |
有個地雷要先踩明白:這三份沒有發佈到 npm。你不能 npm install 一個 @anthropic-ai/session-store-s3,得自己把 src/ 底下的檔案複製進專案,再裝對應的後端 client。README 也自己講了這些是參考實作、不當生產程式碼維護。複製進去那天開始,那份程式碼的維護責任是你的。
13 個 contract 幫你驗自己寫的那份
複製進來要改、或乾脆自己寫一個接內部儲存服務的版本,怎麼知道有沒有寫壞?官方給了一致性測試。README 說三個 adapter 都通過 shared/conformance.ts 定義的 13 個 contract,TypeScript 端就是把那個檔複製進你的測試;Python 端更省事,測試套件內建,裝好 pytest 就能跑:
1 | from claude_agent_sdk.testing import run_session_store_conformance |
有一個要求文件寫得很明白:你傳進去的工廠每次都要回傳一個「儲存空間是空的」新 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(並剝掉 enabledPlugins、extraKnownMarketplaces 與其 additionalMarketplaces 別名、以及 env 區塊裡的 CLAUDE_CONFIG_DIR)。Python 只複製憑證與 .claude.json。靠 user settings.json 的 apiKeyHelper 認證的 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 的讀取。介面只有兩個必要方法,門檻低到讓人有點想笑,代價就是那些門檻全被搬進你的營運責任裡。



























































































































































































