Community写作与编辑github.com

Streamlit 核心 (1.58.x) — 执行模型、状态与刷新

深入理解 Streamlit 的脚本执行模型、状态管理与组件刷新机制,助力开发者避免常见陷阱,构建可靠的交互式数据应用。

Streamlit 核心 (1.58.x) — 执行模型、状态与刷新 是什么?

该技能全面解析 Streamlit 1.58.x 版本的核心执行模型:每次用户交互(如操作组件)都会导致整个脚本从上到下重新执行。它详细阐述了五个关键规则以避免应用崩溃:杜绝脚本级无限循环、条件性 `st.rerun()` 必须配合状态守卫、禁止用 `time.sleep` 加 `st.rerun()` 实现自动刷新(应使用 `@st.fragment(run_every=...)`)、旧版 API 的迁移(如 `st.experimental_rerun` 已被移除)、以及多数 `st.rerun()` 实际不必要的场景。技能深入讲解了 `st.button` 的瞬时性导致的“输出消失”问题及其解决方案、`st.session_state` 的读写规则(包括“写入后置”异常与隐藏组件状态丢失的应对)、回调函数相比手动 rerun 的优势,并通过 Forms、缓存机制(`@st.cache_data` / `@st.cache_resource`)和 Fragment 等技术提供高效的页面组织与局部刷新方案。此外还包含了详细的调试清单,帮助开发者快速定位“死循环”或“持续刷新”的根源。

兼容平台~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) — 执行模型、状态与刷新 是做什么的?

核心理念: 每当用户与任何组件交互时,Streamlit 都会从上到下重新执行整个脚本。没有事件处理器,没有持久化的局部变量,也没有主循环。每次运行等价于 st.session_state 加上当前组件值的纯函数。以下所有内容均源于此。

每个论断的可运行证明:本技能文件夹中的 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() 实现自动刷新。 页面永远不会稳定:组件闪烁,点击事件可能被睡眠期间吞噬。应使用 @st.fragment(run_every=...),或者如果“刷新”仅意味着获取新数据,可使用 st.cache_data(ttl=...)。Streamlit 没有原生的 st.autorefresh(已验证 — dir(st) 中没有此函数)。PyPI 上的 streamlit-autorefresh 包是第三方的,它会在每次计时时重新运行整个脚本(重置所有组件,窃取焦点);推荐使用原生且作用域限定的 @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() 调用是不必要的。 组件交互已经会触发重新运行,而 on_click/on_change 回调会在那次重新运行之前执行,因此它们的状态写入无需请求另一次重新运行即可生效。合法用途:关闭 st.dialog,应用在其渲染位置下方的程序性状态更改(规则 2 模式)。

st.button 是瞬时的 — #1 输出消失错误

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: 形式)。在脚本顶部,组件之前执行。
  • 如果在本次运行创建了 key="k" 的组件之后写入 st.session_state.k,会引发 StreamlitAPIException: cannot be modified。要以编程方式设置组件值,应在组件行之前写入该键(通常:设置一个标志 + st.rerun(),在下次运行开始时应用写入)— 或者在回调中写入,这样总是安全的。
  • 如果 key 已存在于 session_state 中,则 value= 参数会被忽略 — 状态优先;唯一的痕迹是服务器日志中的一条警告(“使用默认值创建,但也通过 Session State API 设置了其值”),UI 中没有任何提示。不要同时传递两者并期望默认值生效。
  • 在本次运行中未执行其代码行的组件会丢失其状态(例如通过条件隐藏的标签页、折叠的分支 — 已验证:一个值为 8 的滑块在隐藏后显示为 0)。为了在隐藏时保留状态,请镜像到您自己的键: st.slider(..., key="_w", on_change=lambda: st.session_state.update(w=st.session_state._w)) 并通过“写入在组件之前”的模式将其反馈回去。(st.tabs 是安全的 — 所有标签页的代码每次都会执行,无论是否隐藏;这也是为什么重标签页需要缓存的原因。)
  • 两个类型和参数完全相同的组件会引发 StreamlitDuplicateElementId — 请传递不同的 key=。在循环中的组件:使用 key=f"row-{i}"。

回调优于手动 rerun

在渲染点之下变更的状态会在一次运行中读取到旧值(已验证:if st.button(...) 分支在其上方计数器绘制之后执行)。回调修正了执行顺序 — 它们在脚本重新运行之前执行:

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 在输入/选择组件上同样工作。回调可以写入任何状态键(甚至包括组件的键,因为其行尚未运行)。无需 st.rerun()。

  • 回调内部的 st.rerun() 是空操作(已验证):它会在用户界面显示一条警告 Calling st.rerun() within a callback is a no-op. 并中止回调体的其余部分 — 不进行额外的重新运行,也不抛出异常。点击操作已经安排了重新运行;在回调中只需修改 session_state 然后返回即可。在其中添加 st.rerun() “以应用更改”会静默地截断您的回调。

Forms — 阻止每次按键都重新运行

每次组件交互都会重新运行脚本;N 个输入意味着用户在填写时经历 N 次重新运行(每次都会再次命中你的数据层)。将它们批量处理:

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

提交之前不会发生任何重新运行(已验证:运行计数器总共 +1)。在表单内部:不能使用 st.button,除了提交按钮外没有基于每个组件的回调。

“在我打字时页面不断重新运行”几乎从来不是按键引起的。 组件在不同事件上提交(Streamlit 前端行为):text_input/text_area/number_input 在 回车或失焦 时触发,slider/select_slider 在 释放时 触发 — 不是每次按键,也不是拖动过程中;checkbox/radio/selectbox/toggle 会立即触发。一次提交的更改 = 一次重新运行,并且当前端的值未更改时不会发送任何内容。因此,打字时的持续重新运行意味着存在一个并发的整个应用自动刷新(定时器 / 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):)。
  • 没有缓存时,每次组件交互都会重新运行脚本中的所有数据加载操作 — 应用“感觉卡顿”,因为每次点击都需要几秒钟。先缓存,再调试重新运行。

正确实现实时刷新 — st.fragment

@st.fragment(run_every="2s")        # 整数秒 / timedelta / "2s" 字符串
 def ticker():
     st.metric("last", get_price())  # 仅此函数重新执行
 ticker()
  • 页面其余部分不会重新运行(已验证:外部运行计数器在片段滴答时冻结)。片段内部的组件仅重新运行片段本身;st.rerun(scope="fragment") 仅重启该片段。
  • 陷阱(已验证):scope="fragment" 如果片段体是作为完整应用运行的一部分(首次绘制)执行时,会引发异常 — 请对其进行守卫,或者翻转状态并让 run_every 接管。
  • 片段可以自由读写 session_state;要将更改推送到整个页面,请写入状态然后调用 st.rerun()(作用域为 "app",这是默认值)。
  • 从外部脚本停止或重新设定 run_every 片段的计时 — 片段内部的组件无法做到。 run_every 仅在完整的应用运行中才会被重新读取,因此片段内部的间隔滑块或停止按钮(它们仅重新运行片段本身)永远无法重新设定定时器(已验证)。请从外部对片段调用进行门控:
    secs = st.slider("interval", 1, 10, 2)          # 在片段外部
    run  = st.session_state.get("run", True)
    tick = st.fragment(run_every=secs if run else None)(_body)
    tick()
    if st.button("stop"): st.session_state.run = False; st.rerun()
    
    在完整的重新运行时,run_every=None(或不调用它)不会发送自动刷新消息,定时器停止。
  • 片段的返回值仅在完整运行时到达调用者(已验证)。在 run_every 滴答或片段内部的组件变更时,外部脚本并未重新执行,因此 data = ticker() 保留旧值,下游图表永远不会更新。通过在片段内部写入 st.session_state 来推送实时数据(st.session_state.data = fetch()),在外部读取 — 或者在片段本身内部绘制图表。不要将片段视为可以在不同滴答之间赋值的普通函数。
  • 片段在应用的单线程上运行 — 它不是一个后台线程。 内部的 time.sleep 会冻结整个页面在该持续时间;让 run_every 成为节奏,永远不要睡眠。允许嵌套片段(已验证),但每个都是独立的重新运行作用域 — 子片段的滴答不会重新运行其父片段,因此请将 run_every 放在拥有数据的片段上;不要为相同数据嵌套定时器。对于真正的后台工作(提交一个慢速作业,继续导航,结果到达)→ [st-jobs]。
  • 不要同时在片段内外创建相同键的组件。
  • 片段在滴答但值从未改变?你的实时读操作被缓存了。 一个没有 ttl 的 @st.cache_data 加载器会执行其体一次(已验证),并在每次滴答时返回那个冻结的值 — run_every 重新执行了函数,但缓存短路了它。这是“我的片段不刷新”的头号原因。修复方法:不缓存实时读取,或者给它一个 ttl ≤ 刷新周期(@st.cache_data(ttl="2s")),或者每次滴答时改变一个参数。

将片段用作作用域限定的工作盒 — 现在获取,稍后使用(无需完全重新运行)

没有 run_every 的片段是一个页面内的盒子,其组件仅重新运行该盒子本身 — 这是“在此处查找参考数据,在后续操作中消费”的模式,而无需每次查找都重新运行整个页面:

@st.fragment
 def fetch_box():                              # 此处的点击仅重新运行该盒子
     with st.form("lookup"):                   # 片段内的表单:合法(已验证)
         code = st.selectbox("entity", codes, key="box_code")
         go = st.form_submit_button("fetch details")
     if go:
         st.session_state.details = get_details(code)   # 通过状态传递
         st.success(f"fetched {code}")
 fetch_box()

 if st.button("run request"):                  # 外部 → 其点击即是完整重新运行,
     d = st.session_state.get("details")       # 读取了暂存的 details
     if d is None: st.warning("fetch first")
     else:        run_request(d)
  • 消费者不需要 st.rerun():其自身的点击是一次读取新状态的完整运行(已验证)。仅当外部页面必须立即显示获取的数据时(在任何下一次点击之前),才在写入状态后的片段内部使用 st.rerun() — 这会造成一次完整运行的开销。
  • 通过 session_state 传递,绝不能使用片段的返回值(在仅重新运行盒子时是过时的 — 上文已阐述)。对查找操作使用 @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。这些在一次运行内动画化 — 不涉及重新运行,没有循环风险。

调试清单:“卡在循环中 / 持续刷新”

  1. 搜索 st.rerun — 每个是否都处于一个先翻转状态的状态守卫之后(规则 2)?
  2. 搜索脚本级别的 while True / time.sleep → 应替换为 fragment(规则 1/3)。
  3. 一个组件写入一个状态键,而另一个行又写回该键 → 导致乒乓式重新运行;每个键只保留一个写操作。
  4. key= 冲突或每次运行都会改变的 f-string 键(例如包含 time.time())— 每次运行一个新键 = 每次运行组件都会重置。
  5. 未缓存的慢速加载会使正常的重新运行看起来像循环 — 添加 st.cache_data 和一个运行计数器(st.session_state.runs += 1)以观察实际发生了什么。
  6. 实时区域在其定时器上刷新但显示旧值 → 一个不带 ttl 的 @st.cache_data 函数为其提供数据;片段重新运行但缓存返回旧值。添加 ttl 或不缓存该实时读取。

相关技能