前言
学 Streamlit 的人常常在同一个地方卡住:为什么我在按钮回调里改了变量,页面上却还是旧值?为什么我明明只动了滑块,整个页面的数据又重新加载了一遍?为什么别人写的例子里到处是st.session_state和@st.cache_data,看着像在解决什么问题,我却不知道问题在哪?
这些疑惑的答案只有一个,而且只要抓住它,Streamlit 的其余设计都变得顺理成章:Streamlit 的脚本是「每次交互都从头重跑一遍」的。你拖动滑块、在输入框敲字、点一下按钮,Streamlit 不是去修改页面的某个局部,而是把整个脚本重新执行一次,然后拿新结果去更新页面。
本文就围绕这一条心智模型展开。先讲清「重跑」到底意味着什么,再由它推出两个必然结果:普通变量会丢,所以要st.session_state;重复计算会白做,所以要@st.cache_data。最后给一个能跑的完整例子。
Streamlit 是第三方库,需要安装,版本与参数以官方文档为准。
一、理解 Streamlit 的钥匙:每次交互都从头重跑
写一个最简单的 Streamlit 应用:
# 适用于 Python 3.8+;需先安装 streamlit
import streamlit as st
st.title("重跑演示")
name = st.text_input("你的名字")
st.write("你好," + (name or "陌生人"))运行方式是命令行执行streamlit run 你的文件名.py。打开页面后,你在输入框里打字,会看到输出实时跟着变。
关键问题来了:这段代码在页面生命周期里执行了不止一次。页面第一次加载执行一次;你每敲一个字符,Streamlit 就把整个脚本再执行一次。也就是说,st.text_input这一行被反复调用,st.write也被反复调用。你看到的页面内容,永远是「最后一次完整执行」的结果。
理解了这一点,很多怪现象就说得通了:
- 页面上看到的所有元素,都是当前这次执行按顺序输出的。
- 你在脚本里定义的普通变量,每次执行都重新诞生一次。
- 如果你的脚本里有耗时操作(读数据库、拉接口),它会在每一次交互时都被执行。
也正因为如此,Streamlit 里没有「回调触发后修改页面」这种思路——没有页面可以修改,页面只是脚本输出的一帧。
二、重跑带来的第一个问题:变量会丢
先看看哪些东西会丢、哪些不会,这个区分很重要。
| 对象 | 重跑后会怎样 | 原因 |
|---|
| 普通局部变量 | 重新初始化 | 脚本重跑,赋值语句又执行了一遍 |
| 控件的值 | 自动保留 | Streamlit 按控件标识记住了用户的选择 |
st.session_state里的值 | 保留 | 它就存在「会话」这一层,跨重跑存在 |
@st.cache_data缓存的结果 | 保留 | 缓存存在于会话之上,可跨用户跨重跑 |
也就是说,你不用为控件的值操心:st.text_input("你的名字")重跑之后依然会返回上一次输入的内容。但你自己算出来的中间量,重跑后就没影了。
最典型的场景是计数器。下面这段是错的:
# 适用于 Python 3.8+;需先安装 streamlit(❌ 反例,仅示意问题)
import streamlit as st
count = 0
if st.button("加一"):
count += 1
st.write("当前次数:", count)每次点按钮,脚本重跑,count = 0先把它清成 0,再加 1,结果永远是 1。正确做法是把它放进st.session_state:
# 适用于 Python 3.8+;需先安装 streamlit
import streamlit as st
if "count" not in st.session_state:
st.session_state["count"] = 0
if st.button("加一"):
st.session_state["count"] += 1
st.write("当前次数:", st.session_state["count"])st.session_state是一个在重跑之间存活的字典,官方文档的说法是「一种在重跑之间共享变量的方式,针对每个用户会话」。注意st.write(st.session_state)还能直接把里面的内容全部显示出来,调试时很好用。
它一个重要特性是:每个用户有自己的一份。两个同事同时打开这个页面,各自点各自按钮,计数互不干扰——这正是「会话状态」的含义。
三、重跑带来的第二个问题:重复计算
既然每次交互都重跑,那么「读一个大 CSV」「调用一次慢接口」这类操作就会被反复执行。你拖动一次滑块,接口就被调一次,这显然不能接受。
Streamlit 的解法是缓存:给函数加上@st.cache_data装饰器,Streamlit 会记住「同样的参数调用过」,下次直接返回缓存结果,不再执行函数体。
# 适用于 Python 3.8+;需先安装 streamlit
import streamlit as st
@st.cache_data
def load_sales(year, region):
# 这里放真实场景中耗时的读取或计算
return {"year": year, "region": region, "total": year * 10}
data = load_sales(2026, "华东")
st.write(data)理解缓存有几个要点:
- 缓存以参数为键。参数一样就用缓存,参数变了就重新算。所以要求参数是可哈希的;官方文档说明,若对象的哈希方法不适用,回退方案还要求它能被
pickle序列化。 - 缓存的返回值是副本。官方文档明确指出,
st.cache_data返回的是拷贝;如果你要缓存一个需要就地修改的共享资源(比如数据库连接、模型对象),应该用st.cache_resource而不是它。 st.cache_data取代了早期的@st.cache。看到老教程里的@st.cache,请按官方文档迁移到st.cache_data(返回值数据)或st.cache_resource(共享资源)。- 它还有
ttl(过期时间)、max_entries等参数,具体签名以官方文档为准。
一句话:重跑保证了「页面上看到的永远是最新状态」,缓存保证了「最新状态不必从零算起」。两者配合才是完整的一套。
四、一个完整的小应用与常用控件
下面这个例子把两件工具都用上了:一个带缓存的「数据加载」,加上一个用会话状态维护的「浏览历史」。
# 适用于 Python 3.8+;需先安装 streamlit
import streamlit as st
st.title("销售简报")
# 缓存:参数相同就不重新计算
@st.cache_data
def load_months(year):
return [{"month": m, "amount": (m * 37 + year) % 500} for m in range(1, 13)]
# 会话状态:跨重跑保留的浏览历史
if "history" not in st.session_state:
st.session_state["history"] = []
year = st.selectbox("年份", [2024, 2025, 2026])
rows = load_months(year)
threshold = st.slider("只看金额大于", 0, 500, 200)
shown = [r for r in rows if r["amount"] > threshold]
st.write(f"共 {len(shown)} 个月份超过阈值")
st.dataframe(shown)
if st.button("记录这次筛选"):
st.session_state["history"].append({"year": year, "threshold": threshold})
st.write("筛选历史:", st.session_state["history"])这段代码的行为值得顺着「重跑」推一遍:每次你动年份,脚本重跑,但load_months因为参数没变(或者变了之后重新算一次并缓存)不会做重复劳动;每次你点「记录」,脚本重跑,history因为存在会话状态里,追加的记录被保留了下来。
常用控件与布局速览
| 控件 | 大致用途 | 返回值 |
|---|
st.text_input | 单行文本 | 字符串 |
st.text_area | 多行文本 | 字符串 |
st.selectbox | 下拉单选 | 选中项 |
st.multiselect | 下拉多选 | 列表 |
st.slider | 数值区间拖动 | 数值或区间 |
st.checkbox | 勾选框 | 布尔值 |
st.button | 触发一次动作 | 布尔值,仅本次重跑为真 |
st.file_uploader | 上传文件 | 类文件对象 |
关于st.button有一个必须记住的细节:它返回True只在这一帧。也就是说,脚本重跑一次,按钮为真的判断只在「刚刚被点击后的那次重跑」成立,下一次重跑(比如你又动了滑块)它就回到False了。所以「点击后要做的事」必须写在if st.button(...):这一块里,不能指望它一直为真。
布局方面,st.columns可以把页面横向切成几列,st.sidebar把控件放到侧边栏,st.tabs做标签页。这些都是「输出到哪里」的容器,不影响重跑模型。
常见坑点
1. 用普通变量跨重跑保存状态
❌count = 0写在脚本顶层,指望按钮点一次加一次。 ✅ 放进if "count" not in st.session_state: st.session_state["count"] = 0,之后只读改它。
2. 把耗时调用放在缓存之外
❌ 模块顶层直接data = requests.get(...),每次交互都重新请求。 ✅ 包成一个函数并加@st.cache_data,让相同参数命中缓存。
3. 把@st.cache_data用在共享资源上
❌ 用@st.cache_data缓存数据库连接、模型对象。 ✅ 这类需要共享、需要就地使用的资源用@st.cache_resource;st.cache_data的返回值是副本。
4. 以为st.button会一直为真
❌ 在按钮判断之外还用同一个布尔值控制后续逻辑。 ✅ 点击相关的逻辑全部写在if st.button(...):块内,因为它的真值只维持这一帧。
5. 忘了给缓存函数参数考虑可哈希性
❌ 传一个不可哈希、也无法pickle的对象进被缓存的函数,运行时缓存直接失效或报错。 ✅ 只传简单、可序列化的参数;复杂对象在函数内部按标识自行处理。
6. 在循环里创建同名控件
❌ 循环中反复st.button("确定"),多个控件同名,行为互相干扰。 ✅ 每个控件给出唯一的key参数,把它和循环变量绑定。
7. 用老教程里的@st.cache
❌ 照抄早期资料的@st.cache,语义不清。 ✅ 按官方文档换成@st.cache_data(数据)或@st.cache_resource(资源)。
8. 拿st.write当万能调试器却看不到想看的东西
❌ 只st.write变量名,不看会话状态里到底有什么。 ✅ 直接st.write(st.session_state)把整个会话状态打出来,一眼看清哪些键在、值是什么。
总结
| 现象 | 真正原因 | 对策 |
|---|
| 改动一处,整页重建 | 每次交互都重跑整个脚本 | 接受它,把所有逻辑写成可重复执行的形式 |
| 变量改了又变回去 | 普通变量每次重跑重新初始化 | 放入st.session_state |
| 慢操作被反复执行 | 重跑时那段代码又被走了一遍 | 用@st.cache_data缓存结果 |
| 共享对象被复制 | st.cache_data返回副本 | 共享资源改用@st.cache_resource |
| 按钮判断莫名其妙失效 | 按钮真值只维持一帧 | 逻辑写在if st.button(...):内 |
Streamlit 不难,难的是换脑子:不要再想「我改这个页面元素」,而要想「这次执行会输出什么」。把这条记住,st.session_state和@st.cache_data就不再是需要死记的 API,而是「重跑」这个模型的必然产物。