從第 1 期開始,這個系統的 checkpointer 一直是 MemorySaver。
它做得很好——Streamlit 每次互動都從第 1 行重跑整份 script,
靠它才能把對話、stuck_count、提示階梯帶過每一次 rerun。
第 1 期那個「@st.cache_resource 忘記包就全毀」的坑,講的也是它。
但它有個名字就寫著的限制:Memory。
Ctrl+C 一按、改一行程式碼觸發 reload、機器重開——
學生跟導師講了十輪的過程,連同他卡到 level 2 的那個階梯,全部歸零。
下次進來,導師從「你覺得這段程式碼想做什麼呢」重新開始問。
今天把它換成 SQLite。
程式碼的改動只有一行:
checkpointer=SqliteSaver(_checkpoint_conn())
真正花時間的是測試。因為現有那條 checkpointer 測試長這樣:
graph.invoke(_base_state(), config)
out = graph.invoke({}, config) # 第二次不重傳整份 state
assert out["stuck_count"] == 2
這條測試換成 SQLite 照樣會過,換回 MemorySaver 也會過。
它驗的是「同一個 graph 物件記得上一次」,而那正是 MemorySaver 本來就會的事。
要驗「跨行程」,就得在測試裡模擬一次重啟:
graph, conn = _sqlite_graph(db_path)
graph.invoke(_base_state(), config)
conn.close()
del graph # graph 物件沒了
graph, conn = _sqlite_graph(db_path) # 「重啟」:新連線、新 graph
out = graph.invoke({}, config)
assert out["stuck_count"] == 2 # 不是 1
只有那個檔案路徑是共用的,其餘全部重建。
寫完一次過,而「一次過」本身就該懷疑。所以我把 saver 臨時換回 MemorySaver
跑了一次——紅了。確認過會紅的測試,才知道它在守東西。
(這是 Day 23 那個教訓的另一面:綠燈要能解釋。)
另外補了兩條:不同 thread_id 在同一個檔案裡不會互相污染、
新學生第一次進來拿到的是乾淨狀態而不是別人留下的。
從前 MemorySaver 時代這些隔離「顯然成立」,
現在所有人的狀態裝在同一個檔案裡,它才開始有代價。
check_same_thread=False。 SQLite 的 Python 綁定預設禁止跨 thread 使用
同一條連線,而 Streamlit 的 script 不保證跑在同一個 thread。不關掉的話,
第二次互動就直接拋例外。
連線不關。 它由 cache_resource 持有到行程結束——生命週期就是 app 的
生命週期,沒有「用完」的那一刻。
那段註解要改。 原本寫著:
MemorySaver尤其重要——每次 rerun 都新建一個的話,checkpointer 等於沒用
現在不成立了。state 在檔案裡,不快取的後果從「正確性壞掉」降級成
「每次 rerun 重開一條連線」,是效能問題。註解要跟著改,不然它會變成一句
曾經為真、現在騙人的話——而這種註解比沒有註解更糟,因為讀的人會信。
Streamlit Community Cloud 的磁碟是暫時的,重新部署就沒了。
所以這個改動解決的是本機重啟,不是雲端持久化。README 上我寫死了這句,
沒有含糊成「支援持久化」。要雲端也保住得換 Postgres saver 或把 state 寫進
Firestore,那是另一件事,今天沒做。
這跟前幾天的「不可以謊稱跑過測試」是同一條紀律,只是對象從學生換成了未來的我。
Day 24 加了一條規則:判定裡沒有 graded_code 欄位就當它過期。
當時那是假想情況——MemorySaver 一重啟就什麼都沒了,哪來的舊 checkpoint。
今天換成 SQLite 之後,舊格式的 state 真的會躺在檔案裡被讀回來。
昨天寫的時候我只是照原則辦事,今天它變成了一個真的會走到的分支。
這件事值得記下來:「防禦未來」的程式碼,有時候一天之後就不再是防禦了。
測試 324 → 327 支,全綠。另外真的把 app 跑起來確認開得起來、DB 檔生得出來。