Community寫作與編輯github.com

Streamlit 核心 (st-core)

Streamlit 1.58.x 的執行模型、狀態管理與畫面更新機制 — 從底層理解單一腳本重執行的運作原理,避免經典錯誤。

Streamlit 核心 (st-core) 是什麼?

這個技能詳細解說 Streamlit 的核心執行模型:每次使用者互動(觸發任何 widget)都會從頭到尾重新執行整個腳本。它沒有事件處理器、沒有持續的區域變數、也沒有主迴圈;一次執行就是 `st.session_state` 加上目前 widget 值的純函數。技能提供了具體的硬性規則(例如不要在腳本層級使用 `while True:`、不要無條件呼叫 `st.rerun()`、不要用 `time.sleep` 搭配 `st.rerun()` 做自動重新整理),並說明 `st.fragment` 的正確用法來進行範圍有限的即時更新,以及如何透過 callback 避免手動 rerun。同時,它也深入探討 `st.session_state` 的寫入時機與限制、`st.button` 的瞬間行為(輸出在下次互動後消失)、表單 (`st.form`) 對減少重複執行的幫助、以及快取 (`st.cache_data`/`st.cache_resource`) 對效能與「重新整理」問題的影響。支援 Streamlit 1.55+ 以上版本。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add 4k7yvjv7w4-ops/streamlit-agent-skills

Installed? Explore more 寫作與編輯 skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

Streamlit 核心 (1.58.x) — 執行模型、狀態、重新整理

唯一的心智模型: 每次使用者互動(觸發任何 widget),Streamlit 都會從頭到尾重新執行整個腳本。沒有事件處理器、沒有持續的區域變數、也沒有主迴圈。一次執行是 st.session_state 加上目前 widget 值的純函數。以下所有內容都源自於此。

每個說法的可執行證明:此技能資料夾中的 st_core_lab.py(執行 python -m streamlit run ~/.roo/skills/st-core/st_core_lab.py)。已在 Streamlit 1.58 上驗證,API 在 1.55 上檢查過:此技能中的所有內容在這兩個版本上都存在(st.fragment(run_every=) 需要 ≥1.37)。

硬性規則(違反 = 典型的壞掉應用程式)

  1. 永遠不要在腳本層級使用 while True:。腳本必須在每次執行時結束。若要有一個即時更新的區段,請使用 @st.fragment(run_every="2s")(見下方)。只有在一次執行內進行有限的工作(進度條、st.write_stream)時,才可以在迴圈內部使用迴圈。
  2. 永遠不要無條件呼叫 st.rerun()(或在下次執行仍然成立的條件下)—— Streamlit 沒有迴圈保護;它會無止盡地旋轉。每個 st.rerun() 都必須放在一個你會先翻轉的狀態守衛之後:
    if st.session_state.get("needs_refresh"):
        st.session_state.needs_refresh = False   # 在 rerun 之前翻轉
        refresh_data()
        st.rerun()
    
  3. 永遠不要使用 time.sleep(n); st.rerun() 做自動重新整理。頁面永遠不會穩定:widget 會閃爍,點擊會在 sleep 期間被吃掉。請使用 @st.fragment(run_every=...),或者如果「重新整理」只是為了取得更新的資料,使用 st.cache_data(ttl=...)。沒有原生的 st.autorefresh(已驗證——dir(st) 中沒有此項目)。streamlit-autorefresh 這個 PyPI 套件是第三方的,它會在每次 tick 重新執行整個腳本(重置所有 widget,搶走焦點);建議使用原生的限定範圍 @st.fragment(run_every=...)。
  4. st.experimental_rerun() 不存在(已在 1.27+ 中移除)。現在是 st.rerun()。同樣地,st.experimental_memo/singleton → st.cache_data / st.cache_resource;st.experimental_fragment → st.fragment;@st.cache 也已經移除。
  5. 大多數的 st.rerun() 呼叫都是不必要的。Widget 互動已經會觸發 rerun,而且 on_click/on_change callback 會在該次 rerun之前執行,因此它們的狀態寫入在沒有要求另一次 rerun 的情況下就可見。合理的使用情況:關閉 st.dialog、應用在渲染位置下方所做的程式化狀態更改(規則 2 的模式)。

st.button 是瞬間的——排名第一的輸出消失錯誤

st.button() 只會在一次執行中回傳 True(點擊的那一次),然後又回傳 False。所以 if st.button("run"): st.write(result) 會顯示結果,直到使用者碰到任何其他東西,然後結果就會消失。而且巢狀按鈕永遠不可能同時為真:點擊內層按鈕會讓外層再次變成 False(已驗證)。

# 錯誤的寫法                              # 正確的寫法——透過狀態持久化
if st.button("compute"):               if st.button("compute"):
    r = model()                            st.session_state.r = model()
    st.write(r)   # 下次執行就消失       if "r" in st.session_state:
                                            st.write(st.session_state.r)

多步驟流程:在 session_state 中使用一個 step 鍵 + 按照規則 2 使用 st.rerun(),永遠不要按鈕裡面再放按鈕。

session_state 規則

  • 初始化一次:st.session_state.setdefault("k", default)(或 if "k" not in st.session_state: 的形式)。在頂端、widget 之前執行。
  • 在本次執行中,如果在建立 key="k" 的 widget之後才寫入 st.session_state.k,會拋出 StreamlitAPIException: cannot be modified。要以程式方式設定 widget,請在 widget 那一行之前寫入該鍵(通常做法:設定一個旗標 + st.rerun(),然後在下次執行的頂端套用該寫入)——或者在 callback 中執行,這永遠是安全的。
  • 如果 key 已經存在於 session_state 中,value= 會被忽略——狀態優先;唯一的痕跡是在伺服器日誌中的一個警告(「created with a default value but also had its value set via the Session State API」),UI 中沒有任何顯示。不要同時傳入兩者並期望預設值會被套用。
  • 某次執行中沒有被執行的 widget 那一行,會遺失它的狀態(條件隱藏的頁籤、折疊的分支——已驗證:設定為 8 的滑桿在回來時變成 0)。為了在隱藏時存活下來,請鏡像到你自己的鍵: st.slider(..., key="_w", on_change=lambda: st.session_state.update(w=st.session_state._w)) 並透過「寫入在 widget 之前」的模式將它餵回來。(st.tabs 是安全的——所有頁籤的程式碼每次都會執行,無論是否隱藏;這也是為什麼重的頁籤需要快取的原因。)
  • 兩個具有相同類型和參數的 widget 會拋出 StreamlitDuplicateElementId——請傳入不同的 key=。在迴圈中的 widget:key=f"row-{i}"。

Callback 勝過手動 rerun

在渲染點下方改變的狀態,會在一次執行中讀取到過時的值(已驗證:if st.button(...) 分支是在它上方的計數器被繪製之後才執行的)。Callback 解決了順序問題——它們在腳本重新執行之前執行:

st.session_state.setdefault("count", 0)
def inc(): st.session_state.count += 1
st.button("+1", on_click=inc)                 # count 在同一次執行中就是新的
st.metric("count", st.session_state.count)

on_change 在輸入/選取元件上也是同樣運作。Callback 可以寫入任何狀態鍵(甚至是 widget 的,因為它的那一行尚未執行)。不需要 st.rerun()。

  • 在 callback 內部呼叫 st.rerun() 是無效操作(已驗證):它會顯示一個使用者可見的警告 Calling st.rerun() within a callback is a no-op. 並且中止其餘的 callback 主體——沒有額外的 rerun,也沒有例外。點擊已經安排了 rerun;在 callback 中只需修改 session_state 然後回傳。在那裡加上 st.rerun()「以套用更改」會默默地截斷你的 callback。

表單 — 停止每次按鍵都 rerun

每次 widget 互動都會重新執行腳本;N 個輸入代表使用者在填寫它們時會產生 N 次 rerun(每次都重新打你的資料層)。將它們批次處理:

with st.form("params"):
    a = st.slider("a", 0, 10)
    b = st.selectbox("b", opts)
    go = st.form_submit_button("套用")
if go: run_query(a, b)

在提交之前不會有任何 rerun(已驗證:執行計數器總共 +1)。在表單內部:不能有 st.button,除了提交按鈕之外不能有每個 widget 的 callback。

「我打字時它一直在 rerun」幾乎從來都不是因為按鍵。 Widget 在不同事件上提交(Streamlit 前端行為):text_input/text_area/number_input 在按下 Enter 或失去焦點時觸發,slider/select_slider 在放開時觸發——不是每次按鍵、不是拖曳到一半;checkbox/radio/selectbox/toggle 會立即觸發。一次提交的更改 = 一次 rerun,而且當值不變時前端不會發送任何東西。所以打字時持續 rerun 意味著一個同時進行的全應用自動重新整理(計時器 / st_autorefresh / sleep+rerun)搶走了焦點——將該重新整理限定在一個不包含該輸入的 @st.fragment 中,或者將輸入包在 st.form 中。不要對按鍵做防抖;Streamlit 不需要。

快取 — 大多數「糟糕的重新整理」其實是這個

  • @st.cache_data — DataFrame/可序列化的結果。每次呼叫回傳一個新的副本(修改安全,已驗證 is 為 False)。ttl="15m" 用於應該要重新整理的資料;在 ↻ 按鈕上使用 Foo.clear() 或 st.cache_data.clear()。
  • @st.cache_resource — 連線、模型、執行緒。同一個物件回傳給所有工作階段(已驗證)——永遠不要對每個使用者修改它,如果需要則加入鎖。
  • 引數必須是可雜湊的,並構成快取鍵;將無法雜湊的引數前綴加上 _ 以排除它們(def load(_conn, day):)。
  • 沒有快取,每次 widget tick 都會重新執行腳本中的每個載入——應用程式「感覺卡住了」,因為每次點擊都要花費數秒。先快取,再除錯 rerun。

正確的即時更新 — st.fragment

@st.fragment(run_every="2s")        # 整數秒 / timedelta / "2s" 字串
def ticker():
    st.metric("last", get_price())  # 只有這個函式會重新執行
ticker()
  • 頁面的其餘部分不會重新執行(已驗證:外層執行計數器在 fragment 滴答時凍結)。Fragment 內部的 widget 只會重新執行 fragment 範圍;st.rerun(scope="fragment") 只重新啟動它。
  • 陷阱(已驗證):如果 fragment 主體是作為全應用執行的一部分(首次繪製)在執行,scope="fragment" 會拋出例外——請保護它,或者翻轉狀態並讓 run_every 接手。
  • Fragment 可以自由地讀寫 session_state;若要將更改推出到整個頁面,請寫入狀態然後 st.rerun()(scope 為 "app",預設值)。
  • 從外部腳本停止或重新設定 run_every fragment 的計時——內部的 widget 無法做到。run_every 只會在全應用執行時重新讀取,所以在 fragment 內部的間隔滑桿或停止按鈕(只重新執行 fragment)永遠不會重新設定計時器(已驗證)。從外部控制 fragment 的呼叫:
    secs = st.slider("間隔", 1, 10, 2)          # 在 fragment 外部
    run  = st.session_state.get("run", True)
    tick = st.fragment(run_every=secs if run else None)(_body)
    tick()
    if st.button("停止"): st.session_state.run = False; st.rerun()
    
    在全應用重新執行時,run_every=None(或不呼叫它)不會發出自動重新整理訊息,計時器就會停止。
  • Fragment 的回傳值只在全應用執行時才會到達呼叫者(已驗證)。在 run_every 滴答或內部的 fragment widget 更改時,外部腳本不會被重新執行,所以 data = ticker() 會保留過時的值,下游圖表永遠不會更新。將即時資料透過在 fragment 內部寫入 st.session_state 來推出(st.session_state.data = fetch()),在外部讀取——或者直接在 fragment 內部繪製圖表。不要把 fragment 當作一個普通的函式,並期望能在不同 tick 之間賦值使用。
  • Fragment 是在應用程式的單一執行緒上執行——它不是背景執行緒。 在它內部使用 time.sleep 會在那段時間內凍結整個頁面;讓 run_every 來決定節奏,永遠不要 sleep。巢狀 fragment 是允許的(已驗證),但每個都是一個獨立的 rerun 範圍——子 tick 不會重新執行它的父層,所以將 run_every 放在擁有資料的 fragment 上;不要為了同樣的資料巢狀計時器。對於真正的背景工作(提交一個慢速任務、繼續瀏覽、等待結果)→ [st-jobs]。
  • 不要在 fragment 內部和外部建立相同鍵的 widget。
  • Fragment 在滴答,但值從不改變?你的即時讀取被快取了。 一個沒有 ttl 的 @st.cache_data 載入器只會執行其主體一次(已驗證),並在每次 tick 回傳那個凍結的值——run_every 會重新執行函式,但快取短路了它。這是「我的 fragment 不會重新整理」的頭號原因。修正:不要快取即時讀取,或者給它一個 ≤ 重新整理頻率的 ttl(@st.cache_data(ttl="2s")),或者每次 tick 變化一個引數。

Fragment 作為一個限定範圍的工作盒 — 現在擷取,稍後使用(無全應用 rerun)

沒有 run_every 的 fragment 是一個頁面內的盒子,其內部的 widget 只會重新執行該盒子——這是一種模式,用於「在這裡查詢參考資料,在後續動作中使用它」,而不需要在每次查詢時重新執行整個頁面:

@st.fragment
def fetch_box():                              # 這裡的點擊只會重新執行該盒子
    with st.form("lookup"):                   # fragment 內部的表單:合法(已驗證)
        code = st.selectbox("實體", codes, key="box_code")
        go = st.form_submit_button("擷取詳細資料")
    if go:
        st.session_state.details = get_details(code)   # 透過 STATE 交接
        st.success(f"已擷取 {code}")
fetch_box()

if st.button("執行請求"):                  # 外部 → 它的點擊就是全應用 rerun,
    d = st.session_state.get("details")       # 會讀取暫存的詳細資料
    if d is None: st.warning("請先擷取")
    else:        run_request(d)
  • 消費者不需要 st.rerun():它自己的點擊就是一次完整的執行,會讀取最新的狀態(已驗證)。只有當外部頁面必須立即顯示已擷取的資料(在下次點擊之前)時,你才會在寫入狀態後在 fragment 內部執行 st.rerun() —— 代價是一次全應用執行。
  • 透過 session_state 交接,永遠不要使用 fragment 的回傳值(在僅限盒子的 rerun 之間會過時——見上方子彈)。使用 @st.cache_data 來快取查詢,這樣重新擷取同一個實體就是免費的。

工作中的狀態(在一次執行內——允許迴圈)

ph = st.empty()                       # 佔位符,在原地覆寫
for i, step in enumerate(steps):
    ph.text(f"{i+1}/{len(steps)} {step.name}")
    step.run()
ph.empty()

還有 st.progress、st.status("...", expanded=True)、st.spinner、st.toast。這些會在一次執行內動畫——不涉及 rerun,沒有迴圈風險。

除錯檢查清單 — 「卡在迴圈 / 持續重新整理」

  1. 搜尋 st.rerun——每個是否都在一個「先翻轉」的狀態守衛之後(規則 2)?
  2. 搜尋腳本層級的 while True / time.sleep → 改用 fragment(規則 1/3)。
  3. 一個 widget 寫入一個狀態鍵,而另一行又寫回該鍵 → 形成乒乓 rerun;每個鍵只保留一個寫入者。
  4. key= 衝突或每次執行都變化的 f-string 鍵(例如包含 time.time())——每次執行一個新鍵 = widget 每次執行都重置。
  5. 未快取的慢速載入讓正常的 rerun看起來像是迴圈——加上 st.cache_data 和一個執行計數器(st.session_state.runs += 1)來看看實際發生了什麼。
  6. 即時區段在自己的計時器上重新整理,但顯示過時的值 → 一個沒有 ttl 的 @st.cache_data 函式提供了它;fragment 重新執行,但快取回傳了舊值。加上 ttl 或者不要快取即時讀取。

相關技能