Streamlit-实战-全-
Streamlit 实战(全)
原文:
zh.annas-archive.org/md5/628501c44d4af25fb31b493d413e4349译者:飞龙
第一章:1 Streamlit 简介
本章节涵盖了
-
为什么你想构建 Web 应用
-
Streamlit 究竟是什么,以及为什么它如此受欢迎
-
Streamlit 的易用性、对 LLM 的友好性以及其他因素使其变得流行
-
Streamlit 与其他类似技术的不同之处
-
你可以用(和不能用)Streamlit 构建什么
欢迎来到激动人心的 Streamlit 领域!通过拿起这本书,你已经加入了在过去几年中发现 Streamlit 的数千名开发者的行列。这些开发者已经着迷于 Streamlit 所能实现的事情:只需几分钟就能用 Python 编写的完整 Web 应用!
请花点时间思考一下你为什么会被这本书吸引。也许你脑海中有一个想法,这个应用将为你同事节省数小时枯燥的、容易自动化的任务,你希望最快地将它变为现实。也许你正在瞄准科技行业的工作,并希望通过添加前端开发来填补你的技能缺口。也许你是一位数据分析师或科学家,希望以交互式仪表板的形式向高层展示你的发现。或者你可能是一位需要快速原型化应用程序的软件工程师。也许你只是听说了 Streamlit 和 AI 的热潮,感到好奇。
无论你的故事如何,Streamlit 都能快速轻松地将你的想法变为现实。这本书将是你向导,带你一步步通过创建强大、交互式 Web 应用程序的过程。你将学习如何利用 Python 的简洁和优雅来构建和部署能够震撼你的观众、解决实际问题并推动你职业发展的应用程序。
经验是最好的老师,所以你将通过实际项目来学习 Streamlit。到这本书结束时,你将构建一个多功能的投资组合,从交互式抵押贷款计算器到由生成式 AI 驱动的聊天机器人,所有这些都在创纪录的时间内完成!
虽然每一章都会让你更深入地了解 Streamlit 的功能,但你也会学习到开发应用程序的整体流程,包括如何考虑 UI 设计以及如何组织代码以实现可维护性。
无论你是经验丰富的行业老手还是完全的初学者,我都确信你会发现这本书的价值。
你渴望深入探索吗?那么,让我们从基础知识开始。
1.1 构建 Web 应用
在我们深入探讨 Streamlit 本身之前,让我们先谈谈 Web 应用以及为什么你想构建一个,这正是 Streamlit 的目的所在。
一个图形应用是一个具有图形用户界面(GUI)的应用程序,你可以通过点击鼠标或触摸屏幕控件与之交互。这与命令行应用形成对比,后者有一个命令行用户界面,你在终端中输入文本输入并从同一终端获得结果。你很可能已经使用 Python 编写了一些这样的应用程序。
Web 应用(或Web 应用)是一种通过网页浏览器访问的图形化应用,例如您使用 Google Chrome 浏览器访问 Gmail 或 Netflix 时,与桌面应用相对,桌面应用是直接在您的计算机上运行(例如,Photoshop 或 Notepad 在您的 PC 上运行),或者原生移动应用,它直接在您的手机上运行(例如,Uber 或通过 iPhone 的 Gmail 应用访问 Gmail)。
Streamlit 帮助您构建 Web 应用,而不是命令行、桌面或原生移动应用。但为什么您想要这样做呢?
1.1.1 最初为什么要构建图形化应用?
如果您已经学习了 Python,那么您可能对创建命令行程序感到很自在。这些是最容易创建的应用类型,Python 为它们提供了原生支持。
问题在于,除了技术人员之外,没有人喜欢使用命令行程序进行任何重要的事情。图形化应用不那么令人畏惧,并为用户提供更直观的体验。
这通常是真的,但在工作场所也是如此。如果您想为非技术受众自动化工作场所中的某些事情,并期望人们真正使用您所构建的应用,您将需要为它创建一个图形界面。您可能已经为解决一个真实问题创造了完美的解决方案,这减少了完成某项任务所需的时间从数小时到数分钟,但当你告诉人们他们需要打开终端并输入命令时,您就会失去他们。
1.1.2 为什么要为 Web 构建应用?
图形化应用提供了明显的易用性优势,但为什么构建 Web 应用而不是桌面应用或原生移动应用呢?
在过去的二十年里,由于各种原因,Web 应用变得越来越受欢迎。这里只列举几个原因:
-
Web 应用几乎可以在任何拥有网页浏览器的设备上运行。这意味着您只需编写一次代码,用户就可以在他们的电脑、平板或手机上运行您的程序,无需额外努力。
-
用户无需手动安装或更新 Web 应用;通过访问正确的网址,他们始终使用最新版本。
Web 应用在公司中特别受欢迎。如今,大多数公司都有一个内部网络,这是一个仅对员工开放的网页内部网络。由于所有员工都习惯于访问内部网络,因此对于公司来说,将他们的内部程序托管在这个内部网络上作为 Web 应用,而不是让人们费心安装桌面或移动应用,通常是有很多道理的。
当然,构建桌面或移动应用肯定有合理的理由,例如当性能或离线访问至关重要时。然而,对于许多用例来说,Web 应用的优点使它们成为开发者和用户的首选选择。
1.1.3 构建 Web 应用需要什么?
希望您已经足够认同创建 Web 应用的想法,以至于转向下一个问题:如何?
一般而言(并且稍微简化一下),一个 Web 应用有两个主要部分:一个前端和一个后端。前端包含人们与之交互的屏幕上的元素,例如按钮、文本框、菜单等等。后端包含实现应用目的的实际逻辑,例如处理数字或在数据库中查找信息。
创建后端所需的技术和语言与前端所需的技术和语言相当不同。你可以用 Python(本书假设你熟悉)编写你的后端逻辑,主要关注使你的业务逻辑工作。
相反,编写前端需要关注用户体验,其中“正确”的答案较少。而且重要的是,它传统上需要熟悉不同的语言集。学习这些语言至少需要与从头开始学习 Python 一样多的努力,甚至可能更多。
这个问题困扰了许多忙碌的 Python 开发者,他们没有时间投入学习全新的技能集,从而阻止了他们构建完整的 Web 应用。幸运的是,今天我们有一个解决方案:Streamlit。
1.2 什么是 Streamlit?
Streamlit 是一个纯 Python 前端开发库,它允许你快速轻松地创建名为Streamlit 应用的 Web 应用。
正如我们所见,Python 开发中的一个传统挑战是,你需要使用命令行来执行你的脚本,或者如果你是为 Web 创建的,需要编写非 Python 代码来创建可视化界面。Streamlit 通过允许你用 Python 编写基于 Web 的 UI 来颠覆了这一点。
实际上,我向那些对 Streamlit 不熟悉的人描述 Streamlit 应用的最喜欢的方式是:“把它们想象成你可以点击按钮和执行操作的 Python 脚本。”
Streamlit 最初于 2019 年推出,在过去的几年里,其流行度和使用量急剧增长,这得益于——我们将在稍后探讨的其他因素——其易于理解的语法、在数据科学中的价值以及其对基于 LLM 的聊天机器人的支持。如此之甚,以至于在 2022 年,它被 Snowflake Inc.以 8 亿美元的价格收购。
为了让你对 Streamlit 的兴起有一个概念,图 1.1 显示了 Google Trends 图表:

图 1.1 一个显示 Streamlit 随时间流行趋势的 Google Trends 图表(注意:2023 年底的下降是在圣诞节和新年之间的那一周,当时我假设在美国相对较少的人在工作)
1.3 Streamlit 为何如此受欢迎的 10 个原因
如图 1.1 所示,Streamlit 的流行度稳步上升,特别是在 2022 年开始。除了其充满活力的个人用户社区外,使用 Streamlit 作为内部工具的公司名单中还包括 Netflix、Airbnb、Stripe 和 Square 等知名公司。
Streamlit 之所以受到如此多的接受,有很多原因,其中最重要的是其纯 Python 本质,以及它在数据科学和 AI 应用中的易用性。在本节中,我们将探讨这十个原因之一。
1.3.1 Streamlit 是纯 Python
你用 Streamlit 编写的任何代码都是 Python 代码。
传统上,创建基于网络的界面需要开发者编写 HTML、CSS 和 JavaScript,这是网络的三种支柱语言。超文本标记语言(HTML)用于页面结构,层叠样式表(CSS)用于外观和布局,而 JavaScript 用于功能。
问题在于,如果你想要创建相对复杂的东西,这些语言(尤其是 CSS 和 JavaScript)可能很难掌握。在这些语言之上构建的框架可以帮助,但往往也有学习曲线。无论如何,你仍然需要了解 HTML、CSS 和 JavaScript 才能有效地使用它们。
由于其易用性和丰富的数据处理和分析库生态系统,Python 在数据科学家、爱好者甚至那些为了帮助日常工作(以及因为其乐趣)而学习它的人中很受欢迎。这些群体在图 1.2 中展示。他们的技能可能不涉及三种网络语言,或者他们可能只是对它们有肤浅的了解——通常不足以创建复杂的应用程序。

图 1.2 Streamlit 为任何了解 Python 的人解锁了网络应用程序开发,并帮助全栈开发者更快地进行原型设计和构建
对于这些人来说,Streamlit 是一大福音,因为它降低了他们的一大障碍;它让他们能够在不首先投入时间学习另一套语言的情况下创建丰富的网络应用程序。
1.3.2 Streamlit 让你几分钟内从想法到应用程序
当你开始使用 Streamlit 时,我可以保证你会对创建一个工作应用程序所需的时间之短感到印象深刻。
由于其直观性和它为大多数事情提供合理的默认值(这意味着你不需要手动自定义或配置它们以看起来和工作得很好),在 Streamlit 中创建的应用程序开发速度非常快。
事实上,从想法到完全工作的应用程序所需的时间往往可以以分钟来衡量,而不是小时或天。
1.3.3 Streamlit 制作美观的应用程序
即使你确实知道我之前提到的某些基于网络的编程语言,了解它们与熟练使用它们来创建外观良好的网页是非常不同的。
在 Streamlit 中,你创建的应用程序和页面默认就是美观的。这是因为 Streamlit 为你创建的元素(应用程序的各个部分,如按钮、复选框或标签页)已经预先设计得很好看。你所要做的就是将它们组合起来。
任何手动使用 CSS 为网页添加样式(或者至少尝试过)的人都会告诉你,要在按钮的边缘和文本之间得到你想要的间距,或者实现你梦想中的独特阴影效果,可能会非常困难。而且,即使你通过意志力实现了你心中的想法,也不一定保证它会看起来漂亮,因为 UI 设计既是艺术也是科学。
Streamlit 并不一定为你解决所有这些问题,但它确实使得你必须费尽周折才能创建出看起来不美观的东西。
1.3.4 Streamlit 让你专注于你的应用,而不是 UI 细节
给你预设计的元素,然后你可以将它们组合起来创建应用,这还有一个优点;它释放了你的时间,让你可以专注于你最擅长的部分:你的应用逻辑。
Streamlit 有意限制了你可以做出的 UI 选择,因为它为你做出了选择。
例如,以列表 1.1 中显示的代码片段为例
列表 1.1 在 Streamlit 中使用标签
import streamlit as st
tab1, tab2, tab3 = st.tabs(["Mission", "About us", "Careers"])
with tab1:
st.header("Our Mission")
st.write("Our mission is to teach people to make web apps in Python.")
with tab2:
st.header("About Us")
st.write("We are a group of Python enthusiasts.")
with tab3:
st.header("Careers")
st.write("We are hiring! Apply today!")
你现在不需要知道它是如何工作的,但它会产生如图 1.3 所示的带标签的页面。

图 1.3 Streamlit 中的标签,展示了 Streamlit 如何为你做出 UI 选择
将你的注意力转向顶部的标签栏。注意我们当前所在的标签下的橙色线条以及我们悬停的标签被突出显示的方式。尽管在截图上看不到,但从一个标签切换到另一个标签时,会有一个小动画,橙色线条会移动到新的标签下。
生成标签栏的行如下:
tab1, tab2, tab3 = st.tabs(["Mission", "About us", "Careers"])
注意,这一行并没有提到我们刚才讨论的任何关于样式的内容。我们本质上只是说了“标签”,而 Streamlit 为我们处理了细节。
这是因为 Streamlit 正确地认识到,大多数开发者不想设计这些与 UI 相关的细节,他们更愿意花时间实现他们的业务逻辑。
结果是,Streamlit 应用开发者非常高效,能够快速生成合理的界面,这些界面补充而不是削弱了它们的功能。
话虽如此,这里在无障碍 UI 开发和细粒度控制之间有一个权衡。如果你关心前者,Streamlit 对你来说是个不错的选择,但如果你想要对界面的细微方面有更多的控制,Streamlit 可能不是适合你的工具。
例如,截至写作时,如果你想在一个当前标签周围放置一个阴影框而不是下方的线条,你无法轻松地做到这一点,除非你了解 HTML 和 CSS。
1.3.5 Streamlit 的语法简单、简洁且直观
我一直很欣赏 Streamlit 的一个特点就是它的语法可读性。就像 Python 本身一样,Streamlit 的代码是自文档化的,你经常会发现它的功能很明显。
例如,假设你想模拟掷骰子的过程,并在图表中展示结果。考虑列表 1.2 中展示的四个代码片段,它就是用来实现这一功能的。
列表 1.2 Streamlit 中的骰子模拟器
import streamlit as st
import random
st.title("Die Roll Simulator")
num_rolls = st.slider('Number of die rolls', min_value=10, max_value=100)
if st.button('Plot Graph'):
die_rolls = [random.randint(1, 6) for _ in range(num_rolls)]
st.line_chart(data=die_rolls)
你可以在图 1.4 中看到输出。

图 1.4 Streamlit 中骰子模拟器的输出
注意,你很可能在阅读列表 1.2 中的代码时就能理解它的含义,即使你之前从未见过任何一行 Streamlit 代码:我们展示了一个标题,显示一个滑块供用户选择要绘制的骰子投掷次数(在 10 到 100 之间),显示一个“绘制图表”按钮,当点击该按钮时,我们生成骰子投掷(1 到 6 之间的随机数)并在折线图中绘制它们。
与其他语言或库(如定义一个处理程序来监听按钮点击事件或定义一系列滑块的属性)相比,这里没有“设置”代码。它既简短又简单。
1.3.6 Streamlit 非常适合 LLMs
无论好坏,2022 年,随着 OpenAI 的生成式 AI 聊天机器人 ChatGPT 的推出及其随后的反响,技术经历了一个分水岭时刻。
要了解生成式 AI 的兴起如何导致 Streamlit 的流行爆炸,请考虑以下内容:
-
AI 已经吸引了全世界的想象力。
-
Python 是 AI 开发中最流行的语言,因为它在行业中的广泛应用以及其丰富的 AI 相关库生态系统,如 TensorFlow、PyTorch、scikit-learn 和 LangChain。
-
Streamlit 是编写 Python 中视觉应用最快的方式。
这些事实的交汇意味着各种类型的开发者都纷纷涌向 Streamlit 来开发 AI 应用。
Streamlit 本身迅速利用了大型语言模型(LLMs)如 GPT 的突然流行。
例如,Streamlit 通过引入聊天元素,使得编写对话式聊天机器人变得轻而易举。
列表 1.3 展示了使用 Streamlit 聊天元素在不到 30 行代码内构建的完整工作 AI 聊天机器人:
列表 1.3 在不到 30 行代码内实现的 AI 聊天机器人
import os
import streamlit as st
from openai import OpenAI
os.environ["OPENAI_API_KEY"] = "sk-..." # Replace with your own API key
openai = OpenAI()
human_message = lambda m: {"role": "user", "content": m}
ai_message = lambda m: {"role": "assistant", "content": m}
def talk_to_ai(question, history):
return openai.chat.completions.create(
model="gpt-3.5-turbo",
messages=history + [human_message(question)],
).choices[0].message.content
st.session_state.history = st.session_state.get("history", [])
history = st.session_state.history
for message in history:
st.chat_message(message["role"]).markdown(message["content"])
if prompt := st.chat_input("Chat with me!"):
st.chat_message("human").markdown(prompt)
response = talk_to_ai(prompt, history)
history.extend([human_message(prompt), ai_message(response)])
st.chat_message("ai").markdown(response)
注意
要使这生效,你需要创建一个 OpenAI 账户,生成一个 API 密钥并将其插入到os.environ["OPENAI_API_KEY"] = "sk-..."这一行。重要的是,上述代码仅用于演示目的。在实际操作中,你永远不应该在任何你分发或共享的代码中包含 API 密钥。本书后面我们将探讨如何解决这个问题。
图 1.5 展示了我们代码的输出。你目前不需要理解代码,但希望你能看到 Streamlit 为我们创建了一个完整的聊天界面,包括可爱的小机器人和使用者头像。

图 1.5 Streamlit 中的完整 AI 聊天机器人
时间将证明围绕生成式 AI 的炒作是否是合理的。与此同时,如果你正在考虑编写 AI 应用,Streamlit 为你提供了支持。
1.3.7 你可以在创纪录的时间内免费分享你的 Streamlit 应用程序
建立一个网络应用程序是一回事,但使其对人们可用则是另一回事。对于一个不使用 Streamlit 的面向公众的网络应用程序,通常这涉及到找到一种托管方式(例如使用云服务提供商如 AWS 或甚至获取和管理你自己的服务器)。
所有这些对于时间紧迫的数据科学家或业余爱好者来说可能看起来令人畏惧(更不用说昂贵的了),尤其是如果他们试图分发的应用程序相对简单,不需要迎合成千上万的用户。
进入 Streamlit 社区云,这是一种完全免费且快速的方式,可以部署无限数量的面向公众的 Streamlit 应用程序,其设计理念与 Streamlit 本身一样简单。使用它就像将你的 GitHub 仓库(一种分享代码和管理不同版本的方式)链接到它一样简单。
事实上,本书中使用的许多项目和示例,除了在 GitHub 上可用外,还已在 Streamlit 社区云上发布,你可以访问并与之互动。
社区云确实有限制,所以它可能不是适合每个人,但它是一种方便的、无烦恼的方式,可以分享你的创作。
我们在第五章中详细讨论了部署到社区云,如果社区云不能满足你的特定需求,第十二章中还有其他选项。
1.3.8 Streamlit 拥有一个庞大而友好的社区
Streamlit 的用户基础每天都在增长,而且增长得越多,人们提出的问题就越多。幸运的是,Streamlit 的论坛很友好,成员(包括 Streamlit 背后的团队)反应相当迅速。
例如,在研究这本书时,我需要了解更多关于 Streamlit 内部工作原理的信息。一位 Streamlit 工程师在论坛上的评论帮助我确定了在源代码中检查的正确位置。
如果你已经用尽了你的 Google 技巧,但仍有一个让你困惑的问题,那么帮助就在手边。
1.3.9 Streamlit 对数据科学和可视化有出色的支持
Streamlit 的原始设计优先考虑数据科学家,因此你可能会惊讶地了解到它对各种数据可视化有着强大的支持,这得益于 Python 已经丰富的可视化库集。
这意味着你可以使用你喜欢的可视化库来创建图表(Matplotlib、Plotly、Altair 等)、图形(GraphViz)或 3D 渲染(PyDeck),并在 Streamlit 中显示它们。
图 1.6 显示了使用 Matplotlib 库在 Streamlit 中渲染的直方图。

图 1.6 使用流行的 Matplotlib 库在 Streamlit 中创建的直方图
Streamlit 还与 Pandas 非常兼容,Pandas 是一个非常受欢迎的库,它通过其中心概念数据框使处理表格数据变得容易。
数据框是一种基于表格的数据结构,使开发者能够以各种方式摄取、整理和分析数据。如果你是数据科学家,那么你很可能在工作中经常使用它。
Streamlit 对数据框提供了一级支持,能够直接显示它们,甚至可以直观地编辑它们。
图 1.7 展示了这一示例,其中我们允许用户在 Streamlit 应用程序中实时编辑 Pandas 数据框。

图 1.7 在 Streamlit 中显示的可编辑的 Pandas 数据框
我们将在后面的章节中更详细地探讨 Pandas 和数据框。
1.3.10 您可以使用第三方组件扩展 Streamlit 或构建自己的组件
如我们之前所见,Streamlit 通过提供预构建的 UI 元素并限制您对这些元素可进行的自定义程度来节省您的时间。
大多数时候,这实际上是一件好事,因为它给您留出了空间来专注于您的逻辑。有时,它可能会感觉有些限制,因为您心中所想的特定体验可能难以使用 Streamlit 的构建块直接构建。
在这种情况下,Streamlit 提供了 Streamlit 组件的形式作为出路,这些组件是第三方开发者可以创建以扩展其功能的模块。Streamlit 组件的范围可以从填补 Streamlit 原生可用元素中感知到的空白的东西,如带有自动完成的搜索框,到可以嵌入到您的应用程序中的完整迷你应用程序(例如音频录音机)。
Streamlit 在其网站上发布了一个组件库,您可以在其中查看它们的外观和工作方式。安装组件就像安装任何其他 Python 库一样简单。
如果您有一些前端开发经验,您甚至可以创建自己的组件。这确实需要我们之前提到的网络语言(HTML、CSS 和 JavaScript)的知识,但可以使您能够微调为您的用户创建的体验。我们将在第十五章中探讨如何创建我们自己的 Streamlit 组件。
1.4 您可以用 Streamlit 构建什么?
Streamlit 提供了一个灵活的平台,用于创建各种交互式应用。在本节中,我们将探讨使用 Streamlit 可以构建的多种项目,展示其适应性和实用性。
1.4.1 数据应用
Streamlit 最初是为数据科学家设计的,它在创建数据应用方面的实用性仍然是其最大的卖点之一。
您可以用 Streamlit 创建的数据相关应用包括但不限于:
-
显示公司决策者关心的指标的仪表板
-
数据探索应用,使您能够深入了解并感受数据集
-
用户可以与之交互以更好地理解数据的可视化
-
允许用户上传输入以获取预测的机器学习模型部署
1.4.2 工作场所内部工具
数据应用可能是 Streamlit 最知名的使用案例,但在我看来,它同样适合于开发您公司员工的内部工具。
这可能包括以下应用程序:
-
项目管理仪表板
-
时间跟踪应用
-
轮班调度工具
-
库存管理系统
-
文件转换工具等。
几个因素使这些工具成为 Streamlit 应用程序的理想候选者:
-
它们通常只需要服务于有限数量的并发用户
-
它们本质上是灵活的,可能需要在短时间内完成
-
大多数公司没有足够的预算来雇佣全职工程师来构建它们
Streamlit 足够简单,即使是那些对 Python 只有基本了解的半技术人士也能非常有效地使用它来构建他们所需的内容。
1.4.3 使用类似 LLM 的生成式 AI 的应用程序
编写生成式 AI 应用程序通常涉及在调用生成式 AI 服务(如 OpenAI 的 GPT 或 Anthropic 的 Claude)的 API 上添加一层薄薄的业务逻辑。
Streamlit 对于这些工具来说工作得相当不错,它允许你通过内置对常见 AI 形式(如聊天机器人)的支持,在短时间内推出带有 UI 的 AI 功能。
此外,Python 用于与生成式 AI 交互的库(例如 LangChain)无与伦比,Streamlit 完美地处于利用这些库的位置。

图 1.8 Streamlit 应用程序库中的 AI 面试聊天机器人(aiinterviewer.streamlit.app/)
1.4.4 大型应用程序的原型
即使是负责构建大型、雄心勃勃应用程序的专用软件工程团队,Streamlit 也可能很有用。这类项目在开发时间和精力上往往成本高昂,工作可能持续数月或数年。即使采用迭代开发方法,通常也需要很长时间才能看到任何结果,到那时,许多错误且难以修复的假设可能已经形成。
早期设计草图有助于解决这个问题,但原型甚至更好。使用 Streamlit,你可以快速生成轻量级的原型,模仿大型应用程序的功能。这有助于利益相关者了解预期,并在流程早期验证关于功能的基本假设。
如果你是一名软件工程师,进行这项练习不仅可以从长远角度为公司节省时间和金钱;它还能在合作伙伴中激起兴奋,并建立对你工作的支持,因为人们更欣赏那些他们可以亲身体验的东西,而不是设计文档和草图。
1.4.5 你能想到的其他任何东西
在本章早期,我把 Streamlit 应用程序描述为“你可以点击按钮的 Python 脚本”。这归结为你的 Streamlit 应用程序几乎可以做 Python 能做的任何事情。
你不必以人们熟悉的方式使用 Streamlit。你可以找到新的、富有创意的事情来编写成应用程序。以下是一些不寻常的应用程序想法以供参考:
-
一个个人 AI 习惯培养伙伴,让你记录你的活动并提供建议和鼓励
-
一个生成有趣谜题的迷宫生成器(见图 1.9)
-
一个追踪衣物何时需要洗涤的洗衣追踪器
重点是,你应该自由地尝试!通常,学习一项技术会激发关于其新潜在应用的灵感,这些灵感可能只有你自己能想到!

图 1.9 地牢,一个使用 Streamlit 创建的游戏 (dungeon.streamlit.app/)
1.5 不要使用 Streamlit 的场景
就像任何其他技术一样,Streamlit 也有其权衡之处。让我们通过查看一些你不能(或可能不应该)使用 Streamlit 来开发的事情来解决这个问题。
1.5.1 复杂、大规模应用
Streamlit 应用的理想用户规模可能以数百或数千用户计算,而不是数百万。
当涉及到并发用户(同时访问你的应用的用户)时,根据你创建的应用类型及其为单个用户提供服务所需的资源需求,一旦超过阈值,你的应用可能难以扩展。
正如我们将在后面的章节中看到的,Streamlit 通过每次应用屏幕上需要更改时从头到尾运行整个 Python 脚本来实现工作。这可能会对性能产生影响,特别是如果你的脚本执行了大量的计算。Streamlit 的缓存功能通常可以缓解这个问题,但你可能会偶尔遇到无法使用缓存的情况。
虽然 Streamlit 可能不适合大型生产级应用,但你通常可以通过使用第三方组件来克服这些限制。例如,Streamlit 没有内置的认证功能,但其他人创建的 Streamlit 组件可以添加这一功能(例如,streamlit-authenticator)。
随着你的应用复杂性的增加,你可能会发现自己越来越偏离常规路径。虽然 Streamlit 论坛对于识别 Streamlit 缺少的功能的解决方案非常有价值,但一旦你的应用达到一定程度的复杂性,迁移到不同、更灵活的框架(如 Flask、Django 或我们之前探索的其他选项)并增强其功能可能是合理的。
1.5.2 需要高度 UI 定制的应用
Streamlit 旨在简化将大多数常见 UI 元素添加到你的应用中,但有时你可能会遇到想要精细控制界面某部分工作方式的情况。Streamlit 有限的定制性可能会在这里带来挑战。
尽管 Streamlit 确实提供了一些缓解措施,例如主题化以进行颜色定制或编写自己的组件(或包含他人共享的组件)以增强其原生功能,但更大的问题仍然存在。
如果你需要精确控制应用程序的外观或如果你正在尝试实现特定的视觉效果,Streamlit 可能不适合你。考虑其他替代方案,如 React,这是一个以其灵活性和广泛的定制能力而闻名的框架(将在下一节中探讨),或者甚至使用传统的 HTML 和 CSS 进行手动页面设计。
1.5.3 原生桌面或移动应用程序
Streamlit 是一个网络框架,这意味着它产生的应用程序是在网络浏览器中运行的。如果你试图在浏览器外开发桌面或移动应用程序,请选择原生应用程序框架,如 PyQt 或 React Native。
值得注意的是,你仍然可以在移动设备上访问 Streamlit 应用程序;这里的重点是 Streamlit 不会产生独立于浏览器的独立运行的 Android 或 iOS 应用程序。
1.6 Streamlit 与其他技术有何不同?
如果你过去考虑过或研究过创建交互式应用程序的方法,那么你很可能之前遇到过或可能使用过与 Streamlit 类似或相关的技术。本节旨在通过比较 Streamlit 与这些技术来澄清你的理解。
1.6.1 Jupyter 笔记本
Jupyter 笔记本是一个用于数据探索和与代码及可视化一起工作的交互式环境。概念相当简单:你在“单元格”中编写 Python 代码(或者甚至只是文本或 Markdown),执行它,并直接在下面看到输出(可以是文本、某种可视化甚至交互式内容)。每个单元格的输出都会保留,这样你就可以看到它之前的内容。
对于解释你的思维过程、玩弄数据以及与同事分享你的工作等用途来说,Jupyter 笔记本已经成为数据科学社区的一个主流工具。
在几个方面,Jupyter 与 Streamlit 相似:它们都是基于 Python 的,都支持表格和图形数据,都集成了像 pandas 这样的流行库,并且在数据科学领域都很受欢迎。
然而,也存在一些重要的差异:
-
Jupyter 用于创建交互式文档,而不是实际的应用程序;它最好用于与协作者共享你的代码和解释,而不是让实际最终用户执行该代码。正如我们所见,Streamlit 用于创建面向最终用户的应用程序。
-
Jupyter 对版本控制等工程实践的支持比较薄弱;这没关系,因为它是为了探索性分析而设计的,而不是用于发布生产仪表板。另一方面,Streamlit 很好地融入了常规的工程工作流程。
-
Jupyter 笔记本的目标受众是技术人员,而不是普通大众。Streamlit 不要求你的应用程序用户能够阅读或理解代码。
总的来说,Jupyter 笔记本是实验代码、数据和图表的绝佳初始工具。Streamlit 是一个惊人的工具,用于构建和共享你希望用户拥有的最终按钮化的交互式体验,通常使用你在 Jupyter 笔记本中精炼的代码、数据和图表。
1.6.2 HTML、CSS 和 JavaScript
如我们之前讨论的,HTML、CSS 和 JavaScript 是网络三大互补语言。
HTML,或超文本标记语言,用于提供网页的结构和内容,并通过“标签”树定义元素,如标题、列表、分区和链接,这些标签可以包含其他标签。
CSS,或称层叠样式表,允许网络开发者在一个中心位置管理许多不同网页的外观、布局和格式。CSS 提供了设置颜色、间距、字体、边框等选项。
JavaScript 是一种主要用于定义网页动态行为的编程语言。它可以用来创建动画、验证表单、连接到其他网页,以及几乎任何你希望网站完成的任务。
一起,这些强大的语言使你能够构建任何你想要的网络体验。虽然它们本身并不难学,但有效地使用它们来创建复杂的应用程序是一个费力的过程,以至于“前端”开发在软件工程中成为了一个独立的学科。
Streamlit 将使用这些语言的原始形式的复杂性抽象化,并允许你使用更简单、更简洁的语法来编写网页,在后台生成浏览器可以理解的等效代码。
1.6.3 React
React 是一个流行的 JavaScript 框架,它可以帮助你创建快速、响应式的网页。和 Streamlit 一样,它是一个使用原始 HTML、JavaScript 和 CSS 创建网络应用的替代方案。
React 采用可重用组件化设计,你使用较小的部分构建网页的各个部分,然后将这些部分组合起来构建更大的部分,一直构建到完整的应用程序。你创建的每个这样的部分称为组件,可以在你的应用程序中使用,甚至可以与其他人共享,以便他们在自己的应用程序中使用它。
React 采用声明式编程方法,你描述你想要的 UI,React 负责更新 DOM(位于你网页下的 HTML 元素树),以匹配你的描述。
虽然 React 非常强大,被前端开发者用来创建复杂的 UI,但其架构和方法可能难以理解。它也不是基于 Python 的。
尽管 Streamlit 实际上在底层使用 React,但从程序员的视角来看,Streamlit 以更简单的语义、预构建元素(具有一些可定制性)和使用 Python 编写网页的能力为代价,牺牲了一些 React 的强大和灵活性。
1.6.4 Flask 和 Django
Flask 和 Django 都是 Python 基础的网络框架。
Flask 轻量级且简约。它旨在易于使用,并且相当灵活,允许开发者选择他们偏好的各种库和工具,而不是强制性的。Flask 提供了创建 Web 应用所需的基本功能,例如处理路由和 HTTP 请求的能力,而将大部分设计选择留给了开发者。它深受希望采用模块化方法或拥有高度控制的 Web 开发者的喜爱。
Django 另一方面,功能丰富且复杂。它比 Flask 更具意见性,遵循特定的设计模式,如 MVT(“模型-视图-模板”)架构。它还包含许多内置模块,用于常见任务,例如一个管理面板来管理您的数据模型。Django 是运行强大企业级应用的流行选择。
虽然 Flask 和 Django 与 Streamlit 相似,因为它们都可以用来构建 Web 应用,但一个关键的区别是 Flask 和 Django 主要是后端框架,用于与你的前端一起使用,其中前端仍然包括你必须编写或嵌入到 Python 代码中的 HTML/CSS/JavaScript。Streamlit 则不同,它允许你使用纯 Python 编写前端代码。
1.6.5 Tkinter 和 PyQt
Tkinter 和 PyQt(发音为“pie-cute”)是用于创建图形用户界面(GUI)的 Python 库。
Tkinter 与 Python 捆绑在一起,提供了一套你可以用来创建桌面应用的控件。它深受初学者喜爱,适合创建简单的应用。
PyQt 与之类似,但比 Tkinter 更强大、更成熟(也更复杂)。它实际上是一个围绕 C++ Qt 应用框架的包装器。PyQt 让你能够创建相当复杂的 GUI,并具有广泛的功能。
如你所猜,Tkinter/PyQt 与 Streamlit 之间的关键区别在于后者用于创建 Web 应用,而不是桌面应用。在过去的几十年里,用户越来越习惯于无需安装或更新的基于 Web 的软件,这使得 Streamlit 在大多数用例中可能比其他两个更有用。
1.7 通过项目学习
既然我们已经知道了 Streamlit 能做什么和不能做什么,那么让我们谈谈在阅读本书的剩余部分时,你将面临什么。我坚信,学习一项技术的最佳方式是通过使用它来获得经验。
本书旨在通过构建需要你思考实际问题和解决方法的真实项目来给你这种体验。实际上,通过这些项目,我们将不仅学习 Streamlit 本身,还会学习:
-
大型语言模型(LLMs)如 OpenAI 的 GPT,以及如何创建由 LLM 驱动的应用
-
关于用户体验(UX)和用户界面(UI)设计的最佳实践
-
关于代码组织和结构的最佳实践
这里是一些我们将一起构建的内容的预览:
1.7.1 交互式指标仪表板
在这个项目中,我们将研究一位 CEO 对公司做出重要决策的需求,并设计一个他们每天都会查看的交互式仪表板,以了解业务状况。
在旅途中,我们将探索 Streamlit 的图表和可视化能力,以及如何与 Pandas 数据框一起工作。
1.7.2 租房与购房计算器
在我们的第二个真实世界应用中,我们将评估拥有房屋与租房的利弊,并设计一个帮助人们做出决定的计算器。
在这里,我们将尝试 Streamlit 的布局选项和用户提供输入的不同方式。我们还将深入了解 Streamlit 如何维护状态。
1.7.3 CRUD 应用
我们的下一步是一个创建-读取-更新-删除(CRUD)应用,您需要超越孤立的应用,并建立与数据库的连接以实现持久存储。
我们将看到这如何解锁构建各种类型事物时的强大可能性。我们还将探索数据表和主题。
1.7.4 人工智能知识问答应用
在掌握三个探索 Streamlit 关键特性的真实应用后,现在是时候将注意力转向令人兴奋的生成式 AI 世界了。为了体验这个领域您可以做什么,我们将设计一个可以使用 OpenAI 的 GPT 回答知识问答的应用。
1.7.5 聊天机器人
在这个项目中,您将使用 LangChain 库扩展您的 LLM 能力,并构建您自己的定制聊天机器人。我们将学习 Streamlit 的聊天元素如何使这变得简单易行。
1.7.6 自定义知识库
想要一个第二大脑?在我们的第三个 LLM 项目中,这正是我们正在构建的。我们将学习如何将各种信息来源整合到一个可查询的知识库中,并在 Streamlit 应用中展示它。
1.7.7 您自己的 Streamlit 组件
这一项相当高级,所以请系好安全带!我们首先将确定一些我们想要的功能,这些功能是 Streamlit 预构建元素无法直接提供的。然后我们将使用 React 框架创建这些功能,并将其集成到应用中。
1.8 摘要
-
Streamlit 是一个框架,允许您使用纯 Python 构建 Web 应用,无需 HTML、CSS 或 JavaScript。
-
由于其简单性、开发速度、对 LLMs 的支持、强大的可视化以及与数据科学库的集成等功能,Streamlit 的受欢迎程度正在迅速增长。
-
您可以使用 Streamlit 创建许多类型的应用程序:数据应用、工作场所的内部工具、LLM 应用、大型应用的原型,等等。
-
您不应使用 Streamlit 构建面向数百万用户的规模化应用,或需要高度 UI 定制的应用。
第二章:2 开始使用 Streamlit
本章涵盖
-
设置你的开发环境
-
Streamlit 的开发工作流程
-
构建和运行你的第一个 Streamlit 应用程序
欢迎来到第二章。这里是真正的挑战!在本章结束时,你将能够与你的第一个 Streamlit 应用程序进行交互!
本书不仅关于教你 Streamlit;还确保你能够高效地使用 Streamlit,并且为在现实世界中开发应用程序做好准备。因此,在我们开始编写代码之前,我们将花一些时间来设置你的开发环境。具体来说,我们将讨论你在设置过程中需要考虑的三件事:使用 Git 进行版本控制、代码编辑工具和虚拟环境。
然后,我们将检查你在使用 Streamlit 编码时将遵循的工作流程,这样你就可以在本书构建应用程序时知道期待什么。解决了这个问题之后,我将一步一步地引导你实际创建你的第一个应用程序,一个密码检查器。
激动了吗?让我们深入探讨吧!
2.1 将 Streamlit 设置为运行状态
首先!在你能够构建任何应用程序之前,你需要安装并准备好 Streamlit。这涉及两个步骤:
-
安装正确的 Python 版本(3.8 及以上),以及
pip(一个与 Python 一起提供的工具,可以轻松安装 Python 包) -
使用
pip安装 Streamlit(剧透:输入pip install streamlit)
对于详细的安装指南,请参阅本书附录 A。
2.2 设置你的开发环境
你使用的工具以及你设置的应用程序编码环境的方式在很大程度上是个人偏好的问题。然而,随着时间的推移,这些问题可能会对你的开发效率产生不成比例的影响,因此花时间考虑这些问题是值得的。
在本节中,我将简要讨论你开发环境的一些重要方面,特别是版本控制、你的编辑工具和虚拟环境。
如果你是一位经验丰富的 Python 开发者并且已经有一个让你感到舒适的设置,那么请直接跳转到安装 Streamlit 的部分。
2.2.1 使用 Git 进行版本控制
如果你从未在专业环境中编写过代码,那么你之前可能没有使用过像 Git 这样的版本控制系统,或者可能并不完全理解它是用来做什么的。
版本控制 是一种结构化的方式,用于跟踪、管理、记录和实验性地对程序进行更改。把它想象成你代码的时间机器。
Git 是目前最受欢迎的版本控制系统,因此在本书中,我们将互换使用 Git 和 版本控制 这两个术语。
在开发应用程序的过程中,你可能会改变主意,决定以不同的方式设计某些内容。在这些情况下,Git 允许你“回到过去”,查看你的代码 曾经 是什么样子,并从那个点应用更改。
你还可以在测试多个设计选项的同时,对代码的不同版本进行实验,并使用 Git 轻松地在它们之间切换。也许 Git 最让我喜欢的一点是,它允许你记录代码的更改,为你提供了一个解释你为什么决定以某种方式做事的地方。相信我——六个月后,当你阅读自己的代码并试图理解它时,你会感谢自己的。
当你在处理大型项目或与他人协作时,所有这些情况都非常常见。即使目前这对你来说不适用,我也强烈建议你学习和将 Git 纳入你的开发工作流程,因为其提供的益处太多,不容忽视。
如果我已经向你展示了版本控制的益处,请查看第五章中名为“Git 快速入门”的部分,快速了解基础知识。它被设计为一个独立的章节,所以如果你愿意,你现在可以前往那里,完成后再回来。如果你(可以理解地)急于开始使用 Streamlit,请继续阅读。
2.2.2 代码编辑器
Streamlit 应用程序,就像所有 Python 脚本一样,只是文本文件,所以你实际上只需要一个简单的文本编辑器(比如 Windows 中的记事本)来编写代码。
然而,使用高级代码编辑器或 集成开发环境(IDE)可以使你更加高效——提供诸如语法高亮、易于调试的工具和代码导航等功能,因此很难不推荐使用。
有许多工具可以满足这一需求,但我想在这里提及两个最受欢迎的工具。
PyCharm
PyCharm 是由 JetBrains 公司开发的一个专为 Python 定制的多功能 IDE。它提供了开箱即用的全面支持,例如代码补全、错误检测、快速修复建议等。
如果你寻求在安装时直接获得高级功能,而不必过多地调整,PyCharm 是一个很好的选择。专业版需要付费,但 PyCharm 有一个免费的社区版,你可以在 www.jetbrains.com/pycharm/download 获取(确保你滚动到“Community Edition”部分)。

图 2.1 PyCharm Community Edition 的项目编辑窗口
Visual Studio Code
Visual Studio Code(简称 VS Code)是由微软维护的一个非常流行的代码编辑器。它支持多种语言。
VS Code 提供了一些基本功能,如语法高亮,但其真正的优势在于其插件生态系统,它可以扩展其功能。确实,有了正确的插件组合,你可以让 VS Code 做到 PyCharm 专业版(付费版本)几乎能做的一切。
由于 VS Code 完全免费,对于不介意花时间设置一切的学习者来说,这是一个有吸引力的提议。
注意:由于这两个(以及许多其他,如 Sublime Text 和 Notepad++)都是可行的代码编辑工具,我不会假设你使用的是任何特定的一个。只要你有可以输入命令的终端和一个可以编辑文本文件的程序,根据本书的要求,你就已经准备好了。
2.2.3 虚拟环境
你在工作的绝大多数实际 Python 项目中都将需要依赖各种库。这些库会不断更新,发布新版本,增加或删除功能,或修改现有功能。
随着你经验的积累和更复杂应用的创建,你经常会发现更新一个项目的库会导致另一个项目崩溃或导致不可预测的错误和冲突。
虚拟环境是解决这种困境的一种方法。虚拟环境由一个独立的 Python 实例和一组库及依赖项组成。你开始每个项目时都应该有一个自己的虚拟环境。这样,你可以修改任何项目的依赖项,而不会影响其他项目。
即使你刚开始学习并且还没有遇到依赖管理问题,熟悉虚拟环境也是一个很好的主意。目前有几种不同复杂程度的虚拟环境相关库和工具可供选择:你可能会遇到 venv、pipenv、pyenv、poetry 以及其他。
我们将在第十三章中更详细地讨论虚拟环境,同时探讨如何分发你的代码。
2.3 首次运行 Streamlit
这就是乐趣的开始!在本节中,我们将首次运行 Streamlit 应用程序,并亲自看看你可以用 Streamlit 做出什么。
如果你还没有安装,请转到附录 A 并确保你已经安装了 Streamlit。
如果你已经有了,请打开一个终端窗口,让我们开始吧!
我们将要运行的应用程序是 Streamlit 预先构建的 hello 应用程序。要在终端中查看其运行效果,请输入 streamlit hello。
这应该在终端上显示一些输出,你现在可以忽略它,几秒钟后,它会打开你的网络浏览器以显示实际的应用程序。
hello 应用通过演示各种功能展示了 Streamlit 的能力:动画、图形绘图、地图和表格。
图 2.2 展示了一个这样的示例:由数学可视化构建的动画。你可以使用左侧的侧边栏导航到其他示例。

图 2.2 来自 streamlit hello 的动画示例
图 2.3 展示了一个与 Pandas 数据框相关的更实用的示例。我们将在本书的后续章节中深入了解这些内容。

图 2.3 来自 streamlit hello 的 DataFrame 示例
希望查看这些示例能激发你的灵感!
侧边栏还有一个“显示代码”复选框,正如你可能想象的那样,它会在应用程序本身下方显示每个应用程序的源代码。在每种情况下,你会发现代码并不特别冗长。
显然,您现在不必理解所有这些是如何完成的,但希望您能对 Streamlit 能实现的功能有所欣赏。
2.4 Streamlit 开发工作流程
编写 Streamlit 应用程序——或者任何类型的编程——是一个迭代的过程,您编写一些代码,测试它是否工作,然后重复。如果您以前从未编写过图形应用程序,您可能会好奇这个过程看起来是什么样子。
图 2.4 描述了您很快就会习惯的开发工作流程。

图 2.4 Streamlit 开发工作流程
让我们一步步来:
-
编写您的 Python 代码: 在您的代码编辑器中的 Python 文件中创建应用程序的第一个草稿。这可以非常基础,也可以非常详细,根据您的喜好而定。有些人喜欢一开始就运行应用程序,以便在塑造它并可视化每一步的变化时,他们的第一个草稿可能只是一个带有标题的空应用程序。
-
我个人觉得这很分散注意力,因为如果我一开始就看到它,我可能会倾向于花时间调整应用程序的外观。相反,我更喜欢在第一次运行之前先编写一个大致完整的应用程序版本。但这完全是个人的偏好。
-
保存您的文件: 这应该是不言而喻的。像通常一样保存您的 Python 文件,使用
.py扩展名。 -
使用
streamlit run <filename>运行您的应用程序: 在您的终端中运行此命令,并用您文件的路径替换<filename>。 -
这将打开一个浏览器窗口,显示您的应用程序,正如您在运行hello应用程序时所看到的那样。
-
在浏览器中测试您的应用程序: 与您的应用程序进行交互。通过输入值、点击按钮等方式进行操作,并查看输出是否符合您的预期。
-
根据需要修改代码: 根据您的测试,回到您的 Python 代码并对其进行编辑。
-
保存您的更改: 再次,不言而喻。
-
切换回您的浏览器窗口:请记住,您不需要重新运行
streamlit run <filename>来查看应用程序中的更改。 -
您在这里有两个选择: 如果您希望 Streamlit 自动重新运行您的应用程序以应用更改,您可以在应用程序中点击“始终重新运行”。否则,您仍然可以每次都点击“重新运行”。
-
重复步骤 4-7
您将在本书的整个过程中多次经历这些步骤,很快就会变得习以为常。但理论就到这里吧,让我们将其付诸实践!
2.5 构建您的第一个应用程序
在本节中,我们将构建我们的第一个应用程序!为此,我首先会介绍应用程序背后的概念,并概述逻辑流程。
接下来,我将逐步向您展示完整应用程序的代码,解释其每个部分。最后,我们将运行该应用程序并对其进行修改。
在我们深入之前,有一句话要提醒你:如果你在过程中积极参与,不断尝试和实验,你的学习体验将会更加丰富。保持好奇心,不要害怕在文本之外做出改变!你经常会发现,那些你记忆最深刻的部分,是你主动探索和理解的部分。
说到这里,是时候让我们动手实践了!
2.5.1 密码检查器
你可能访问过那些要求密码满足大量条件的网站,比如至少包含一个小写字母、一个特殊字符、偶数个下划线、最多两个来自《白雪公主》的矮人名字等等。
我们将要创建的应用程序允许用户输入他们正在考虑的密码,并显示哪些条件通过,哪些条件失败。
2.5.2 逻辑流程
图 2.5 展示了我们的应用程序逻辑的流程。
它从用户在输入文本框中输入密码并点击按钮开始检查过程。
在内部,应用维护一个要检查的条件列表(在代码中实际上是字典)以存储在内存中。我们遍历这个列表,根据输入的密码是否满足条件,将每个条件分类为“通过”或“失败”,并生成一个新的结果列表(或字典)。
接下来,我们遍历结果,在屏幕上显示结果。每个条件都由一个框表示,如果通过则显示绿色,如果失败则显示红色。

图 2.5 我们密码检查器应用的逻辑流程
2.5.3 遍历代码
到现在为止,你 hopefully 应该已经清楚地理解了我们想要实现的逻辑,所以让我们直接跳进去开始构建吧!
要开始,请访问这本书的 GitHub 页面,将列表 2.1 中的代码复制到一个新文件中,并保存它。
列表 2.1 我们密码检查器应用的代码
import streamlit as st
# Add your own conditions if you like
conditions = {
'More than 8 characters': lambda pw: len(pw) > 8,
'At least one uppercase letter':
lambda pw: any(char.isupper() for char in pw),
'At least one lowercase letter':
lambda pw: any(char.islower() for char in pw),
'At least one special character':
lambda pw: any(char in ",.!?@#$%^&*()-_+=|\/:;<>~" for char in pw),
}
def get_password_properties(password):
return {cond: check(password) for cond, check in conditions.items()}
st.title("Password Checker")
password_input = st.text_input("Enter your password", type="password")
if st.button("Check password!"):
if password_input:
properties = get_password_properties(password_input)
# Loop through password conditions and show the status for each
for condition, passes in properties.items():
if passes:
st.success(f'✔ Pass: {condition}')
else:
st.error(f'✖ Fail: {condition}')
else:
st.write("Please enter a password.")
让我们逐部分分析:
我们的第一行导入 Streamlit 本身(你需要在每个应用程序中这样做),并注明我们稍后会将它称为 st:
import streamlit as st
你实际上可以使用任何你喜欢的,但 st 的约定非常广泛,所以你经常会听到人们将 Streamlit 元素称为 st.< whatever>。
我们使用以下块来定义我们想要检查的条件:
# Add your own conditions if you like
conditions = {
'More than 8 characters': lambda pw: len(pw) > 8,
'At least one uppercase letter':
lambda pw: any(char.isupper() for char in pw),
'At least one lowercase letter':
lambda pw: any(char.islower() for char in pw),
'At least one special character':
lambda pw: any(char in ",.!?@#$%^&*()-_+=|\/:;<>~" for char in pw),
}
我们这样做是通过创建一个 Python 字典,其中键是条件本身,值是我们将用来检查该条件的相应函数。
在这种情况下,函数是 lambdas 或匿名内联函数。Lambda 是一种在 Python 中定义短的单行函数的好方法,而不必费心给它命名。每个函数接受一个参数——密码,pw——如果满足相应的条件,则返回 true 的布尔值,否则返回 false。
第一个 lambda 函数简单地检查密码长度是否超过 8 个字符。在其他的每个 lambda 函数中,我们遍历 pw 中的字符并对其应用测试(例如,char.isupper(),char.islower() 等)。
要评估上述定义的所有密码条件,我们将运行以下函数:
def get_password_properties(password):
return {cond: check(password) for cond, check in conditions.items()}
它返回一个新的 Python 字典,其中键是条件,值是对应于我们上面定义的每个条件的 lambda 函数的运行结果。
我们在这里使用的语法在 Python 中称为 字典推导,它是从一些输入创建新字典的简写。
接下来是我们第一个实际的 Streamlit 元素。它是一个简单的 title 元素:
st.title("Password Checker")
它会做你预期的事情,即以大号字体显示传递的文本作为标题。
我们使用以下行向用户显示密码输入框:
password_input = st.text_input("Enter your password", type="password")
Streamlit 的实现还提供了一个切换选项来显示/隐藏输入的文本。当用户输入一些文本时,它被保存在 password_input 变量中。
st.button 是 Streamlit 中最简单的元素之一,你将经常使用它。不出所料,在这里它显示了一个带有文本“检查密码”的按钮:
if st.button("Check password"):
注意,这位于一个 if 子句中。当点击按钮时,会评估 if 内部嵌套的任何代码(见下文)。这个例子中有些有趣的细微差别,我们将在第四章中深入探讨。
一旦点击按钮,我们的应用程序会检查 password_input 是否有非空值,即用户是否实际上输入了密码。如果他们已经输入,它将调用 get_password_properties 来评估我们定义的条件:
if password_input:
properties = get_password_properties(password_input)
# Loop through password conditions and show the status for each
for condition, passes in properties.items():
if passes:
st.success(f'✔ Pass: {condition}')
else:
st.error(f'✖ Fail: {condition}')
然后,它遍历返回的字典,其中每个键是一个条件,相应的值是一个布尔值,指示条件是否通过。
如果条件通过,我们使用另一个 Streamlit 元素 st.success 来指示这一点,如果没有通过,我们使用 st.error 来显示它失败了。
st.success 和 st.error 都是带有一些语义样式的文本容器(即,st.success 主要是一个绿色的框,而 st.error 是红色的)。
这个 else 与之前的 password_input 相对应:
else:
st.write("Please enter a password.")
这里,我们使用另一个 Streamlit 元素,即通用的 st.write,来要求用户输入密码。
2.5.4 运行应用程序
要运行应用程序,在你的终端中,导航到你保存文件的目录,并输入以下内容:
streamlit run <filename>
例如,如果你将你的代码保存在名为 password_checker.py 的文件中,你将输入以下内容:
streamlit run password_checker.py
正如你在之前运行 streamlit hello 时所看到的,这将在你的终端窗口中显示一些输出,并打开你的网络浏览器,在那里你可以看到应用程序。
随意输入各种密码并在这个应用程序中玩玩!如果一切按计划进行,当你点击“检查密码”时,你应该会看到类似于图 2.6 的内容。

图 2.6 我们的密码检查应用程序
不要关闭这个页面,因为我们很快就会看到当你更改代码时会发生什么。
在此之前,让我们将注意力转向终端输出:
You can now view your Streamlit app in your browser.
Local URL: http://localhost:8503
Network URL: http://192.168.50.68:8503
注意它说“本地 URL: http://localhost:8503”。
这是 Streamlit 告诉你,在你的电脑上有一个 Streamlit 服务器正在运行(这就是localhost的意思)在端口 8503 上。你看到的输出中的端口可能不同,比如 8501;这是完全可以接受的。
我们将在第三章中更详细地探讨这是如何工作的,但就目前而言,你需要记住的是,你也可以通过访问输出中指定的“本地 URL”来访问你的应用。
如果你的网络上有其他电脑,网络 URL是那些其他机器可以使用来访问你的应用。
不要关闭终端窗口或取消它!
注意
在某些情况下,你可能发现当你输入 streamlit run 时,浏览器窗口没有自动打开,或者浏览器窗口打开了但页面是空的。如果你遇到这种情况,这里有一些你可以尝试的方法:
-
在你的浏览器中,手动输入终端输出中“本地 URL”下列出的地址,例如,
http://localhost:8503 -
确保你正在运行浏览器最新版本。
-
在另一个浏览器中导航到
http://localhost:8503。我发现 Google Chrome 通常问题最少。
2.5.5 修改应用
现在我们知道我们的应用可以工作,让我们尝试对其进行修改。例如,假设我们想要添加一个检查,以确保输入的密码至少包含一个数字。
你可以通过向我们的 conditions 字典中添加一个新的条件来实现这一点:
列表 2.2 添加新条件
# Add your own conditions if you like
conditions = {
'More than 8 characters': lambda pw: len(pw) > 8,
'At least one uppercase letter':
lambda pw: any(char.isupper() for char in pw),
'At least one lowercase letter':
lambda pw: any(char.islower() for char in pw),
'At least one special character':
lambda pw: any(char in ",.!?@#$%^&*()-_+=|\/:;<>~" for char in pw),
'At least one numeral':
lambda pw: any(char.isdigit() for char in pw),
}
我们的按钮听起来不够兴奋,所以让我们也在文本中添加一个感叹号:
if st.button("Check password!"):
保存你的文件后,回到你打开应用的浏览器窗口。在右上角你应该会看到一个消息通知你源文件已更改(见图 1.5),以及“重新运行”或“始终重新运行”的选项。

图 2.7 Streamlit 在源代码更改时在你的应用上显示消息
这是因为 Streamlit 会监控你的应用文件,以查看是否有对其所做的任何更改。点击“重新运行”以使用最新代码重新运行你的应用。
你会看到我们的按钮现在说“检查密码!”带有感叹号,如果你输入一个密码并点击它,你会看到我们添加的新数字测试。这个更新后的视图如图 2.8 所示。

图 2.8 带有我们新添加更改的密码检查器应用
你也可以选择“始终重新运行”,这意味着每次你更改代码时,Streamlit 都会自动更新你的应用。这使得开发体验更加流畅,你可以在编辑代码后立即切换到浏览器,查看它产生了什么效果。
如果你想要禁用这种行为(有时当它开启时,在页面上很难发现变化),你可以点击右上角的汉堡菜单,进入“设置”并取消勾选“保存时运行”。
2.5.6 杀死和重启服务器
在所有这些过程中,请记住你一直保持终端窗口(你在这里输入streamlit run …的地方)开启。这个窗口是运行“服务”你的应用的 Streamlit 服务器的地方。
一旦你玩够了应用,你可能想要关闭服务器,以免它消耗资源。
要做到这一点,现在就去终端窗口,要么关闭它,要么按“Ctrl+C”。
如果你现在回到你的打开的浏览器窗口,你会发现你不能再与你的应用交互了。按钮被禁用,右上角显示“连接中”,并且如图 2.9 所示,出现连接错误。

图 2.9 当你关闭 Streamlit 服务器时,前端应用无法运行
你可以通过再次运行streamlit run <filename>来使你的应用复活,这将重启你的服务器并重新建立前端与重新启动的服务器之间的连接。
这将打开一个新的浏览器窗口,其中包含你应用的不同实例。你会发现旧窗口现在也活跃起来,因为它能够重新建立与服务器的连接。
2.6 总结
-
Streamlit 需要 Python 3.8 或更高版本才能运行。
-
Git 是一种版本控制工具,它帮助你跟踪和管理代码的变更。
-
高级代码编辑器如 VS Code 和 IDE 如 PyCharm 通过语法高亮、调试工具、代码导航、自动完成等功能,使你更加高效。
-
虚拟环境允许你隔离你工作的每个项目的库和依赖。
-
你可以通过
streamlit run <file_name>来运行 Streamlit 应用,这将打开一个包含你的应用的网页浏览器窗口。 -
你可以设置 Streamlit 在源代码变更时始终重新运行你的应用,以获得无缝的开发体验。
第三章:3 从概念到代码的应用
本章涵盖
-
定义应用程序的范围
-
设计用户界面
-
组织应用程序的代码
-
Streamlit 的工作马输入组件
在我作为软件工程师的初期,我经常惊讶于我花在编写代码以外的活动上的时间有多少。我会花上好几天甚至几周的时间仅仅理解我试图解决的问题,在设计上花费的时间更多,所有这些都是在敲打第一行代码之前。
当时,我感到焦虑,因为我感觉不 productive。潦草的会议笔记和设计文档并没有做任何事情。随着时间的推移,我意识到那些日子和星期实际上并没有浪费;由于我对我要做的事情进行了深入的思考,这些日子和星期让最终产品变得更好。
同样,这本书不仅仅是为了教你编写 Streamlit 代码。它是关于帮助你学习在现实世界中开发应用程序。规划和设计是这个过程中的不可避免的部分。
尽管我们没有时间来深入探讨这些主题,但本章将给你一个开发应用的端到端体验的尝鲜。
我们将从应用的概念开始,将其转化为一系列需求。然后,我们将设计满足这些需求的设计方案,从用户体验出发,逆向工作,同时也要考虑代码的组织结构。
最后,我们将审查我们的代码和逻辑,在介绍过程中介绍一些 Streamlit 最常用的组件。有很多事情要做,所以让我们开始吧!
3.1 从概念到代码:六个步骤的过程
一旦你超越了最初的灵感火花,编写软件可能是一项令人压倒性的任务。有很多事情需要考虑!你从哪里开始?你将开发哪些功能以及需要多长时间?用户将如何与你的应用互动?你应该立即开始编码并在过程中弄清楚吗?
引用德斯蒙德·图图的话:“吃大象的唯一方法:一口一口吃。”
在创建 Streamlit 应用时,就像在消费大型陆地哺乳动物一样,最佳的方法是将它分解成更小的块。图 3.1 显示了一个简单的、逻辑的六个步骤的过程,你可以在开发应用时遵循,或者实际上在开发任何软件时都可以遵循:

图 3.1 六阶段应用开发流程
-
阐述概念:为了解决问题——或者向他人描述它——你首先需要能够以高度概括的方式简洁地表述它。
-
定义需求:这是你细化概念并将其分解为冷冰冰的需求的地方。这包括定义你的应用将做什么——也许更重要的是——不会做什么。
-
可视化用户体验:绘制你设想的使用者在使用你的应用时的体验的图表和原型。
-
头脑风暴实现:你的解决方案将包含哪些组件,它们将如何相互集成,你将面临哪些权衡?
-
编写代码:实际实现你的应用。
-
迭代:检查你的输出并根据需要调整步骤 1 至 5。
这可能听起来很多,你可能会想:“这一切真的有必要吗?我并不是在大型团队中构建企业级软件,我只是在为少数用户制作一个相当小的应用程序。”
以上步骤的美丽之处在于,你可以根据项目的需求调整它们。如果你在处理一个大型项目,上述每个步骤可能都需要很长时间,因为可能有很多需要就整体方法达成一致的人。
但如果你在构建一个小型项目,你可以将每个步骤缩小到更合理的大小。例如,你可以快速列出需求,可能只需要五分钟就能完成,你的用户体验可视化可以是一个简单的草图。
在本章的剩余部分,我们将以一个示例应用程序的上下文来逐一介绍这些步骤。
注意
在本章的大部分内容中,我们的注意力将集中在端到端的应用程序开发流程上;我们将在讨论前端实现时才介绍各种 Streamlit 元素。这是有意为之,以反映现实世界,在开发图形应用程序时,你的主要关注点将是应用程序本身,而不是 Streamlit。这也解释了 Streamlit 的成功:它不会妨碍你,这样你就可以开发应用程序而无需过多担心如何实现 UI。
所以尽管在本书的早期部分你不会看到很多关于 Streamlit 本身的讨论,但请耐心等待!我们会自然而然地找到最合适的位置来介绍它!
3.2 提出我们的概念:一个单位转换器
不论是在学习物理学、根据食谱烹饪一顿饭,还是在国际旅行中,我相信你一定遇到过需要在不同测量单位之间转换的情况,比如将杯转换为盎司,码转换为米等等。你可能不得不在网上查找转换系数,然后使用计算器进行实际转换。
在本章中,我们将要工作的应用程序将使这项任务更容易、更流畅。
正如我们在上一节中讨论的,开发过程的第一步是“提出概念”,即简洁地表达我们要解决的问题,最好是单句或一行。
这是我们的概念:
概念
一个 Streamlit 应用程序,允许用户轻松地在不同的测量单位之间转换,如距离、质量和时间。
提出概念揭示了你要做的核心内容,并集中你的思考,防止你偏离数十个方向。
在开发应用程序的过程中,你可能会遇到各种新的可能性以及要整合的潜在功能。提出的概念作为一个参考点,确保你考虑的任何变化仍然满足其中阐述的核心思想。
例如,你可能会想,“也许我应该制作一个单位概述页面,解释单位的历史以及它是以什么命名的。”如果你将它与我们的单行概念进行比较,你会发现这样的页面实际上并不帮助我们实现让用户轻松转换单位的目标,所以可能明智地将其删除。
当然,这并不意味着你阐述的概念是固定不变的。如果你遇到一个可以显著丰富你为用户提供体验的想法,但它与概念不太吻合,你可以自由地重新阐述概念。
最后,阐述概念的好处是给你提供一个可以与他人分享的一行描述,以帮助他们理解你的工作。这在你获取用户或寻找合作伙伴时特别有价值。
3.3 定义需求
你的概念作为你应用的一种使命宣言效果很好,但它对细节的描述有些模糊。接下来的步骤是将概念分解成具体的需求,你可以朝着这些需求去构建。
将这些视为你的利益相关者(你的用户、你合作的团队等)希望应用执行的具体事项列表。
需求应:
-
表达应用应具备的能力
-
避免使用“实现”语言,例如,不应提及任何技术
对于你在现实生活中构建的应用程序,制定需求列表可能需要通过采访人们来了解他们的需求。
为了我们的示例,以下是我们要围绕应用中心化的需求列表:
需求:
-
用户应能够输入一个数值数量及其单位(“源单位”)。
-
用户应能够选择要转换到的单位(“目标单位”),该单位必须测量与“源单位”相同类型的事物。例如,不能从磅转换为码。
-
应用应显示转换后的输出值。
-
应用应能够处理英制或公制系统内的转换(例如,英尺到英寸或米到厘米),以及跨公制系统的转换(例如,英尺到米)。
-
向应用中添加新的单位或数量应该是直接的。
注意到上述要点比我们最初的构想要具体得多。同时,请注意需求不仅仅来自用户;它们也可以来自其他人。
为了详细说明,上述列出的前四个需求是用户想要的东西,而最后一个是为了让开发者或维护者生活更轻松,因为你希望尽量减少在响应常见请求(如向应用添加另一个测量单位)上花费的时间。
在组织环境中,你也可以想象来自其他利益相关者的需求,例如分析团队(“我们应该能够监控和跟踪应用的用量”)或货币化团队(“应用应允许用户以月费订阅”)。
你可能已经意识到,需求没有提到实现它们的技术。例如,它们没有说“应用应该显示一个用户可以点击以执行转换的 Streamlit 按钮”。
这是故意的;需求是关于利益相关者实际需要的内容。如何实现它们取决于你,开发者。
3.3.1 定义应用不会做什么
虽然列出需求是一个重要的步骤,它为完成目标提供了清晰的指导,但有时可能会感觉你只是在重复显而易见的事情。在实践中,即使如此,做这件事也是有价值的,因为对你来说显而易见的事情可能对你的用户或合作伙伴来说并不明显。人们常常会在脑海中形成一个关于你所承诺的内容的图像,这与现实毫不相干。
因此,定义不在应用范围内的内容同样至关重要——可能更有启发性。
对于本章中的单位转换应用,我们希望尽可能保持简单,因为我们不希望在这上面花费太多时间;我们主要构建它是为了熟悉 Streamlit。
任何与单位转换目标无直接关系的辅助功能都应立即排除。我们不希望构建任何使用跟踪或复杂的可视化。
我们还应该尝试保持我们的转换逻辑简单。从英镑转换成千克很容易,因为你可以将转换因子嵌入到你的应用中,但比如说货币呢?
从美元转换成欧元很难,因为汇率每天甚至每小时都在变化。为了包含这一点,你需要从某处读取汇率,比如在线 API。我们将在本书后面的项目中从 API 读取,但就目前而言,最好避免额外的复杂性。所以货币被排除在外。
正式来说:
范围之外的内容
-
使用跟踪、日志记录、可视化等,这些与单位转换无直接关系
-
之间没有简单、恒定的转换因子,如货币之间的转换
这可以是一个有用的工具,用于优先级排序和分阶段实施。即使你现在将某些内容排除在范围之外,你也可能希望在以后构建它们。
明确需求和范围让你可以表达你认为优先考虑的功能,以及你可能希望推迟到未来版本的功能。
如果你还有其他利益相关者,将这些内容写下来是征求反馈和开始关于优先级对话的好方法。
3.4 可视化用户体验
到目前为止,我们已经花了不少时间思考我们想要解决的问题;我们已经概念化了我们将要构建的内容,细化了我们对我们应用需要做什么的理解,甚至明确了我们不会做什么。
我们还没有开始设计解决方案。这正是我们接下来要做的。
当你开始实际开发应用程序的过程时,你可能发现自己想知道先做什么。你是简单地从上到下编写你的 Python 应用程序代码,边做边设计吗?或者你试图弄清楚应用程序的基本组件以及它们如何组合在一起?或者你可能专注于你认为将是问题中最困难的部分,以便将其排除在外?
这些都是有效的方法,并且可以为每种方法提出论据。我一直认为一种方法非常有价值,我们将在本章中采用这种方法,那就是从用户体验开始倒推。
从用户体验开始是一个确保最终产品质量的好方法,因为它将用户置于首位,直接满足他们的需求和偏好。它还有助于在早期过程中识别和解决潜在的可用性问题。
3.4.1 创建模拟
那么,我们希望我们的应用程序用户有什么样的体验?为了回答这个问题,让我们创建一个模拟或草图,看看 UI 可能看起来像什么。
回顾我们在 3.3 节中列出的需求,其核心是,应用程序需要提供一种方式,让用户输入数值量以及要转换的单位和目标单位。
图 3.2 显示了一个快速的初步草图。

图 3.2 单位转换应用程序的初步模拟
图 3.2 显示了 UI 设计的第一次尝试。它相当简单:有一个框,你可以输入要转换的值,你可以从选择框中选择“从”单位和“到”单位。完成之后,你点击“转换”按钮,答案就会出现在下面。
你可以用笔和纸、记号笔和白板,或者你喜欢的任何图形程序制作这样的图表。它实际上不需要太复杂,甚至不需要特别整洁。重要的是它应该显示你试图创建的最终结果,迫使你从用户的角度思考你的应用程序。
3.4.2 确保用户体验正确
让我们更仔细地看看我们的模拟。这里有一些选择框来选择一个“从”单位和“到”单位,但如果这些单位不兼容怎么办?如果用户选择“磅”(质量单位)作为“从”单位,而选择“英尺”(测量长度)作为“到”单位,并尝试在它们之间进行转换(见图 3.3)?

图 3.3 我们最初的 UI 允许用户选择不兼容的单位,如“磅”到“英尺”
当然,你可以显示一个错误消息。这会起作用,但这对用户来说可能不是一次很好的体验。用户通常希望他们的体验尽可能无摩擦,他们讨厌收到错误。更好的 UI 将使最初犯错误变得不可能。
例如,当用户选择“磅”时,我们可以将“到”单位的下拉菜单中的选项缩小到仅与磅兼容的单位(见图 3.4)。

图 3.4 我们可以将目标单位下拉菜单更新为只显示与所选源单位兼容的单位
这样就解决了不兼容单位的问题,但可能会引入另一个问题。如果用户想要首先选择目标单位怎么办?我们可以潜在地应用反向逻辑,并更新源单位列表,使其只包含与所选目标单位兼容的单位。本质上,无论先选择哪个单位,另一个下拉菜单都限制为与该单位兼容的单位,如图 3.5 所示。

图 3.5 双向限制:当选择一个单位时,另一个下拉菜单更新为只显示兼容的单位
但如果用户现在想要在完全不同的量单位之间进行转换,比如从英尺到英寸的长度转换呢?
当他们尝试在源单位列表中选择“英尺”时,他们会发现它不再列表中,因为源单位被目标单位下拉菜单中的选择人为地限制为只有质量单位,该下拉菜单显示“千克”(见图 3.6)。

图 3.6 前面的选择会创建一个令人困惑的体验,因为用户现在无法从其他量中选择单位
有多种方法可以解决这个问题。
我们可以强制执行一条规则,即用户必须首先选择源单位。但这感觉有点人为的限制,并不是业务问题的固有属性。
我们可以引入一个重置按钮,用户可以在想要开始新的转换时点击,重置两个下拉菜单。这可以工作,而且并不算太糟糕,但似乎对用户来说工作量很大。
处理这个问题最直观的方法是简单地为用户想要转换的量类型添加另一个输入。例如,如果用户在这个新的下拉菜单中选择“质量”,那么源单位和目标单位将只显示质量单位,如图 3.7 所示。因此,我们不是根据下拉菜单之间的相互关系来限制它们,而是根据第三个外部选择来限制它们。

图 3.7 添加量类型选择器使应用程序更直观
这以一种非常优雅的方式解决了问题,同时用户界面也非常直观。用户需要做什么一目了然:选择一个量,输入一个值,选择源单位和目标单位,然后点击“转换”。
注意,虽然图 3.7 显示了量类型的选择按钮,我们也可以使用下拉菜单。选择按钮稍微改变了事情,并且比下拉菜单少一个点击,但使用下拉菜单的体验并不明显更差。无论如何,当您实际使用 Streamlit 进行实现时,您可能会发现如何呈现输入的新可能性。
我希望这能让你了解如何开始设计 UI 以及其中的一些权衡。当然,我们总会关心更多因素——例如,用户是否关心英制和公制系统单位,我们的 UI 是否应该以某种方式区分它们?——但这已经是一个我们可以感到舒适的坚实基础。
我们已经对我们的 UI 进行了相当多的细化,但你仍然不应该觉得它一定是最终的。随着我们深入实施,你可能会发现更多你想做的优化。实际上,我们将在接下来的章节中遇到一些这些优化,并对我们的设计进行迭代。
注意
由于你将使用 Streamlit 来实现你的 UI 设计,在可视化用户体验时可能会出现的一个自然问题是,你是否应该考虑 Streamlit 中可用的各种元素,这样你就不会设计出无法实际构建的东西。
在我看来,这是本末倒置。在理想的世界里,你想要弄清楚用户最佳体验,然后使用 Streamlit实现它。你不希望让 Streamlit 限制你对理想用户体验的想法。
当然,这也存在一些风险;在罕见的情况下,你可能会发现 Streamlit 没有你设计中所期望的精确元素,需要调整你的实现。但实际上,这会通过使 Streamlit 的不足更加明显来丰富你的学习之旅。它还将确保用户体验不会受到你对 Streamlit 功能集预设的约束,而 Streamlit 的功能集一直在不断扩展。
3.5 实施方案构思
我们现在大致知道了我们应用中最终用户的体验将是什么样的。让我们把注意力转向让这种体验成为现实。
在这个过程的这一步,我们将列举我们解决方案的各个部分,讨论它们如何配合,并绘制逻辑流程图。
几乎任何应用程序都有两个主要部分:前端和后端。前端处理用户如何与你的应用交互,即我们如何收集输入并显示输出。后端是应用的“大脑”,它从前端接收输入,处理它们,并将输出交给前端以显示给用户。
根据上一节,我们已经对应用的前端有了相当的了解;我们只需要将 UI 翻译成 Streamlit 中相应的元素,我们将在代码讲解中完成这一步。
因此,我们将把注意力转向后端,从实际的单位转换开始。
3.5.1 执行实际的单位转换
假设我们想要将 5 磅(lbs)转换为盎司(oz)。通过在线搜索(我是在公制系统中长大的),1 磅等于 16 盎司。
因此,要将 5 磅转换为盎司,我们需要将 16 乘以 5 得到 80 盎司。
我们将把数字 16 称为磅到盎司转换系数。
每一对单位都有一个类似的转换系数。例如,1 码等于 3 英尺,因此码到英尺的转换系数是 3。以英制到公制的例子来说,1 英里等于 1.609344 公里,因此英里到公里的转换系数是 1.609344。
为了一般性地说明,对于任何一对单位 X 和 Y,要将用 X 表示的给定值转换为 Y,
单位 Y 中的值 = 单位 X 中的值 * X 到 Y 的转换系数
要进行反向转换(例如,盎司到磅),你可以使用计算为原始转换系数倒数的转换系数。
所以,
1 码 = 3 英尺;1 英尺 = ⅓码;英尺到码的转换系数 = ⅓1 英里 = 1.609344 公里;1 公里 = 1/1.609344 = 0.621372 英里;公里到英里的转换系数 = 0.621372
根据这些信息,似乎我们只需要收集一个数量类型中所有可能单位对之间的转换系数,并应用上述公式。
但这里有一个问题:我们可能会遇到很多需要跟踪的转换系数。
假设我们想让用户能够在 3 个单位之间进行转换:磅、千克和盎司
有 3 x (3-1) = 3 x 2 = 6 种可能的转换类型可以进行:磅到千克,千克到磅,磅到盎司,盎司到磅,盎司到千克,以及千克到盎司。
这意味着我们需要跟踪 6 个转换系数。从我们上面的讨论中,我们已经看到,如果我们知道 X 到 Y 的转换系数,那么 Y 到 X 的转换系数就是 X 到 Y 转换系数的倒数。利用这一点,我们可以将需要跟踪的转换系数数量从 6 个减少到 6 / 2 = 3 个。
通常情况下,如果你有 n 个单位,为了能够在任意一对单位之间进行转换,我们需要 n (n-1) / 2 个转换系数。
我们上面的例子看起来并不太糟糕。但如果我们有超过 3 个单位的特定数量类型怎么办?如果我们有 20 个呢?
在这种情况下,我们需要跟踪 20 * (20-1) / 2 = 20 * 19 / 2 = 190 个转换系数。哇!这需要跟踪的数字太多了。
但对于单一数量类型的 20 个单位来说,这有点极端吗?其实并不。以距离或长度为例。如果你计算表示量级的公制系统前缀,以及在天文学或导航中使用的单位,你会得到以下列表:
千米、米、分米、厘米、毫米、微米、纳米、埃、英寸、英尺、码、英里、英里、天文单位、秒差距、光年、海里、英寻、里、肘、皮米、分米、百米…你懂的。
显然,跟踪每一对单位的转换系数是不可持续的。
相反,我们想要做的是为每个数量指定一个单位作为该数量的标准单位,并且只跟踪从所有其他单位到该单位的转换系数。
例如,如果我们将米作为距离的标准单位,我们只需跟踪每个单位到米的转换系数,即“在这个单位中 1 的值是多少米?”
然后,要从单位 X 转换为单位 Y,我们可以先从单位 X 转换为米,然后再从米转换为单位 Y。
所以,如果我们想将 5 码转换为厘米:
-
码到米转换系数 = 0.9144
-
厘米到米转换系数 = 0.01
然后,我们可以遵循两步转换过程(见图 3.8):
-
5 码 = 5 x 0.9144 米 = 4.572 米
-
4.572 米 = 4.572 x 1 / 0.01 厘米 = 457.2 厘米

图 3.8 使用米作为标准中间单位将码转换为厘米
注意,由于我们总是存储单位到米的转换系数,在步骤 2 中,我们必须除以系数以获得反向转换系数,即米到厘米。
更普遍地说,给定一对单位 X 和 Y 以及一个标准单位 S,要将用 X 表示的值转换为 Y,
单位 S 中的值 = 单位 X 中的值 * X 到 S 转换系数单位 Y 中的值 = 单位 S 中的值 * 1 / Y 到 S 转换系数
我们现在拥有一个更易于管理的转换系数数量:因为我们只关心给定单位有多少标准单位,所以我们只需为每个单位存储一个系数。
3.5.2 跟踪单位和转换系数
回想一下,我们最初的要求之一是向应用程序添加新单位必须简单明了。
当我们提到“添加新单位”时,我们是指我们更新应用程序以包括更多的转换单位进行转换,而不是用户能够动态地添加单位。
这是一个非用户需求的例子。我们的用户可能并不关心我们添加新单位有多容易;他们关心的是单位是否可供他们进行转换。那么,谁会关心呢?开发者会。我们会。
作为一名开发者,一旦你发布了软件,你通常永远不会完成它。如果你的应用程序被足够多的人使用,你可以期待持续不断的反馈、报告的 bug 和请求的功能源源不断地到来,并消耗你原本计划用于其他项目的时光。或者更糟糕的是,有人其他人将负责维护你的代码,而他们将不知道它的工作原理。
在这种情况下,如果你没有为应用程序的实现设计易于维护,那么有人将不得不花费大量时间挖掘你的代码,以找出正确的更改位置并确保没有副作用。即使你对其他开发者并不特别慷慨,也总是有可能那个人就是你。从个人经验来说,你会惊讶于你甚至一个月前写的代码你记得有多少。
回到我们的单位转换器,你可能需要执行的最常见的维护任务之一是添加新单位,我们希望使其尽可能简单。理想情况下,代码中应该只有一个地方需要添加单位及其转换系数,并且它应该是最明显的地方。
实现这一点的有效方法是将所有量、单位和转换系数的列表保存在一个单独的配置文件中。该文件应具有明显的格式,添加新单位应像在文件中追加几行一样简单。
关键的是,这个配置文件应该是唯一引用任何特定量的文件。这意味着应用中的其他地方不应引用特定的量或单位,如米或磅。这是因为如果它们这样做,添加新量或单位将需要更新代码的这一部分,这违反了我们要求代码易于更新的要求。
这有一个有趣的含义:我们任何时候都不能在我们的 UI 代码中硬编码量或单位。与特定量或单位相关的所有内容都需要从配置文件中提取。我们的其余代码需要与它们独立。
我们将在本章后面的代码演练中看到如何做到这一点。
3.5.3 逻辑流程映射
我们现在对应用的所有单个部分都有了很好的理解:前端、转换逻辑和配置。
在我们编写代码之前,有一个如何将它们组合在一起的心理模型是有用的。图 3.9 展示了我们应用的整体设计。

图 3.9 我们单位转换应用的整体设计和逻辑流程
我们在上一节中讨论的配置文件为从我们之前可视化的 UI(图 3.10)中的量选择单选按钮提供动力。

图 3.10 来自我们模拟的量选择单选按钮
一旦用户选择了量,从单位和到单位下拉列表将根据所选量更新(同样由配置文件提供动力)。
当用户选择了从单位和到单位并点击“转换”按钮后,所有条目都会发送到后端,在那里使用配置中的转换系数执行第 3.5.1 节中概述的两步转换。
转换后的值随后返回到前端,用户可以查看它。
3.5.4 后端 API
在软件开发中有一个关键概念叫做关注点分离。本质上,这意味着软件中的每个构建块都应该关注整体系统功能的一个方面,并且应该独立于其他构建块。当一个块与另一个块交互时,它应该按照由契约定义的严格受控的方式进行交互。
为了更好地理解这一点,考虑一下一家快餐店的运作方式。前面有一个人在接顾客的订单,厨房根据菜单准备食物。接单的人不在乎厨房使用什么食材,只要它能做出菜单上的菜肴即可,厨房也不在乎接单的人对顾客说什么语言,只要订单上的菜肴来自菜单即可。
如果厨房想要雇佣新厨师或使用不同的食材,它可以这样做,只要准备的菜肴与菜单相符。如果我们想用电话接单的人替换现场接单的人,我们也可以这样做,而不会影响厨房。
使这一切成为可能的是菜单,这是一个共同的共享合同,通过这个合同,接单人和厨房进行交互。
在开发应用程序时,将组件分离并让它们通过称为应用程序编程接口(简称 API)的合同进行独家交互是一个好主意。在我们的应用程序的上下文中,我们应该在前后端之间保持这种分离。
这意味着我们的前端应该只与后端交互,请求它执行一系列特定的操作。
这些操作究竟是什么呢?让我们回顾一下图 3.11 中的逻辑流程图。前端和后端之间有四条箭头:
-
这是一个前端从后端拉取数量以向用户展示的场景,
-
这是一个它拉入用户选择的数量对应的单位的情况,
-
这是一个前端提供要转换的值的情况,
-
这是一个后端返回转换值的情况

图 3.11 展示了我们的应用程序的流程图,其中 API 操作被突出显示
这些可以归结为前端与后端之间的三种交互(最后两条箭头实际上是同一交互的两个部分):
-
列出数量:前端请求后端提供它支持的数量的列表。
-
列出单位:前端给后端一个数量,并请求它列出它知道如何在该数量之间转换的单位。
-
转换值:前端给后端提供要转换的值和用户选择的单位,并请求它进行转换。
上述操作构成了我们后端的菜单或 API。正如在我们的快餐示例中,只要后端能够履行这些职责,它就可以自由地以任何方式实现它们。也许在某个时候,我们会找到一种更有效的方法来执行转换。或者可能想要将后端连接到外部服务以执行转换。在两种情况下,我们都可以在不触及前端代码的情况下更改后端实现。
或者,也许在某个时候,我们希望在我们的图形用户界面之外,为我们的转换应用程序启用命令行界面。在这种情况下,我们可以简单地添加它,而无需对现有的前端代码进行任何更改。
如您所见,将前端和后端之间的关注点分离给我们带来了很多灵活性。我们的应用程序足够简单,其好处可能并不明显,但这是一个好习惯,因为它将在您开发更复杂的应用程序并需要轻松更换组件时在现实世界中帮助您。
我们很快就会看到如何实际实现这种 API 方法。
3.6 编写代码
到目前为止,我们已经花了很多时间思考我们应用程序的设计,现在我们准备好编写一些代码了。
在本节中,我们将遍历我们的应用程序代码,从我们的配置文件开始,它在我们的设计中扮演着核心角色。我们将定义前端和后端之间的契约或API,然后让后端履行该契约。
最后,我们将编写我们的前端,在这样做的同时了解使我们的用户界面成为可能的 Streamlit 元素,并通过我们定义的契约对后端进行调用,以完成循环。
3.6.1 创建配置文件
我们的配置文件是我们应用程序理解的单位和数量的信息存储处。
从我们的设计来看,我们知道应用程序需要查找给定数量的单位。此外,给定一个单位,它需要能够查找相对于该数量的标准单位的转换系数。
Python 字典听起来像是完成这项任务的理想选择。这个字典中的每个键都是一个数量,相关值是一个单位列表,或者更好的是,另一个字典,其中键是单位,值是对应的转换系数。
写作这种代码的一种可能方式可能是以下内容:
unit_config = {
"Mass": {
"Kilograms": 1, # Standard unit
"Grams": 0.001,
"Pounds": 0.453592,
# ...
},
"Length": {
"Meters": 1, # Standard unit
"Centimeters": 0.01,
# ...
},
}
这当然有效,但如果我们也能显示单位的缩写(例如,'oz'代表盎司或'kg'代表千克),并且我们可能还应该将数量的标准单位存储起来,那就更好了。
我们已经到达了一个阶段,单位与数量被明确定义为具有自身属性的“事物”(一个单位有一个缩写和一个转换系数,一个数量有一系列单位以及一个被指定为标准单位的一个单位)。我们可以通过扩展我们的字典以拥有更多的“层”来表示这种复杂性,但定义类来表示单位和数量会是一个更好的实践。
因此,启动您的代码编辑器并创建一个名为unit.py的文件(见列表 3.1),以定义单位类。在这里,我们将使用一个数据类,这是 Python 中的一种特殊类型类,通过dataclasses模块启用,它是 Python 标准库的一部分。
列表 3.1 unit.py
from dataclasses import dataclass
@dataclass
class Unit:
abbrev: str
value_in_std_units: float
数据类是创建具有一些标准基本功能类的简单方法。例如,使用数据类,你不需要像使用普通类那样指定__init__方法,一旦你有一个数据类的对象,你可以使用点符号访问其属性,就像这样:
gram = Unit(abbrev="g", value_in_std_units=0.001)
print(gram.abbrev) # Prints 'g'
要使用普通类实现相同的功能,你将不得不编写:
class Unit:
def __init__(self, abbrev, value_in_std_units):
self.abbrev: str = abbrev
self.value_in_std_units: float = value_in_std_units
使用数据类,你可以通过在类定义上方包含@dataclass装饰器来使用列表 3.1 中的更简洁的语法。
转到类的内容,你可以看到它们相当简单;有一个abbrev字符串字段,用于存储单位的缩写,还有一个value_in_std_units,它是一个表示我们在 3.5.1 节中讨论的转换系数的浮点数。
列表 3.1 中的: str和: float被称为类型注解。它们用于指定字段的类型。类型注解不是强制性的,但这是一个好习惯,因为(在其他方面)它们使你的代码更容易理解,并允许你的代码编辑器或 IDE 在早期捕获并突出显示错误。
你可能已经注意到,我们没有在Unit类中包含一个name字段。当我们到达配置文件时,我们将讨论为什么。
让我们也在一个新文件quantity.py中定义一个Quantity类。
列表 3.2 quantity.py
from dataclasses import dataclass
from typing import Dict
from unit import Unit
@dataclass
class Quantity:
std_unit: str
units: Dict[str, Unit]
列表 3.2 显示了Quantity类。它包含两个字段:units和std_unit。
std_unit在这里是该量的标准单位名称。
注意我们为units字段使用的更复杂的类型注解。Dict[str, Unit]表示units是一个字典,其中每个键是一个字符串(单位的名称),相应的值是Unit类的对象。对于一些更高级的类型,需要从typing模块导入注解。
在完成这些之后,我们现在可以创建我们的最终配置文件(见列表 3.3)。
列表 3.3 unit_config.py
from typing import Dict
from quantity import Quantity
from unit import Unit
unit_config: Dict[str, Quantity] = {
"Mass": Quantity(
std_unit="Kilograms",
units={
"Kilograms": Unit(abbrev="kg", value_in_std_units=1),
"Grams": Unit(abbrev="g", value_in_std_units=0.001),
"Pounds": Unit(abbrev="lb", value_in_std_units=0.453592),
"Ounces": Unit(abbrev="oz", value_in_std_units=0.0283495),
# Add more units here
}
),
"Length": Quantity(
std_unit="Meters",
units={
"Meters": Unit(abbrev="m", value_in_std_units=1),
"Centimeters": Unit(abbrev="cm", value_in_std_units=0.01),
"Inches": Unit(abbrev="in", value_in_std_units=0.0254),
"Feet": Unit(abbrev="ft", value_in_std_units=0.3048),
}
),
"Time": Quantity(
std_unit="Seconds",
units={
"Seconds": Unit(abbrev="s", value_in_std_units=1),
"Minutes": Unit(abbrev="min", value_in_std_units=60),
"Hours": Unit(abbrev="hr", value_in_std_units=3600),
"Days": Unit(abbrev="d", value_in_std_units=86400),
}
),
# Add more quantities here
}
我们配置仍然是字典的形式,但现在每个字典值现在是一个Quantity类的对象。我们的文件包括三个量:质量、长度和时间。
让我们检查其中一个:
"Mass": Quantity(
std_unit="Kilograms",
units={
"Kilograms": Unit(abbrev="kg", value_in_std_units=1),
"Grams": Unit(abbrev="g", value_in_std_units=0.001),
"Pounds": Unit(abbrev="lb", value_in_std_units=0.453592),
"Ounces": Unit(abbrev="oz", value_in_std_units=0.0283495),
# Add more units here
}
)
如你所见,这是相当易读的。我们正在配置一个名为“质量”的量,作为一个具有标准单位“千克”的Quantity对象。units字典有四个条目,每个单位一个,其中值是一个具有缩写和转换系数的Unit对象。
由于“千克”是标准单位,其value_in_std_units值为 1。
你现在可能也意识到了为什么Unit和Quantity类都没有name字段。因为单位和数量名称已经包含在unit_config.py中的字典键中,所以在类中再包含它是不必要的,这会使配置文件更长且更难以阅读。
添加更多单位很容易;你只需在Quantity中的units字典中添加新的条目。同样,添加新的量只需在unit_config字典中追加,遵循前一条目的格式。
3.6.2 实现 API
在本章的早期(3.5.4 节),我们列举了后端能够执行的操作:
-
列表-量
-
列表-单位
-
转换值
现在让我们实际实现这些。
我们希望上述每个动作都是后端的一个函数。对于每个动作,我们首先将给出函数签名,然后实现它。
列表-量非常简单;它要求后端列出它所知道的所有量。不需要任何参数,输出可能是一个字符串列表。因此:
def list_quantities() -> List[str]
我们如何实现这个?嗯,我们需要的是unit_config.py(列表 3.3)中配置对象的键。因此,我们可以简单地写出:
def list_quantities() -> List[str]:
return list(unit_config.keys())
列表-单位确实需要一个参数(要列出单位的量),它将再次返回一个字符串列表。因此我们得到:
def list_units(quantity_name) -> List[str]
实现这一点也很直接。我们需要列表是由Quantity对象中unit_config的quantity_name键对应的units字典的键组成的。
def list_units(quantity_name) -> List[str]:
return list(unit_config[quantity_name].units.keys())
注意
你可能会意识到我们没有处理提供的quantity_name在unit_config中不存在的情况。在现实世界中,我们绝对应该这样做,但在这个章节中,我排除了错误处理以保持代码相对简洁。我们将在未来的章节中处理错误处理。
转换值需要四个参数:量名、源单位和目标单位,以及要转换的值。我们在这里包含量名是因为不同的量可能有相同的单位名称(尽管我们的示例配置没有这种情况)。
至于返回类型,我们可以简单地返回转换后的值,这将是一个浮点数,不再需要其他内容。
然而,记住我们的配置也为每个单位提供了缩写。如果在转换时,我们也能给前端提供适当的缩写,以便它可以显示类似“15 ft = 5 yd”的内容,那就太好了。
另一方面,我们不想对前端如何实际显示结果做出规定;这是前端的事务——记得关注点分离?如果前端只想显示转换后的数字而不带缩写,那也是完全可以的。
一种方法是将前端可能需要的任何元数据包装在一个专门的Result类中,并让前端决定如何处理它。
让我们在一个名为result.py的新文件中定义Result类(列表 3.4)。
列表 3.4 result.py
from dataclasses import dataclass
from unit import Unit
@dataclass
class Result:
from_unit: Unit
to_unit: Unit
from_value: float
to_value: float
注意,我们不是只将每个单位的缩写放在结果中,而是包括整个Unit。如果我们决定稍后修改Unit类并添加更多属性,这段代码就不需要更改。
有了这些,我们就准备好定义Convert-value的签名:
def convert_value(
quantity_name: str,
from_unit_name: str,
to_unit_name: str,
value: float) -> Result
我们已经讨论了如何实现转换,但这里以代码形式呈现:
def convert_value(
quantity_name: str,
from_unit_name: str,
to_unit_name: str,
value: float) -> Result:
quantity = unit_config[quantity_name]
from_unit = quantity.units[from_unit_name]
to_unit = quantity.units[to_unit_name]
# Two-step conversion: from-unit to standard unit, then to to-unit
value_in_to_units = (value *
from_unit.value_in_std_units /
to_unit.value_in_std_units)
return Result(from_unit, to_unit, value, value_in_to_units)
注意我们之前讨论的两个步骤转换。value * from_unit.value_in_std_units给出了标准单位中的值,而/ to_unit.value_in_std_units将其转换为目标单位。
列表 3.5 将所有这些内容整合到一个单独的backend.py文件中。
列表 3.5 backend.py
from unit_config import unit_config
from result import Result
from typing import List
def list_quantities() -> List[str]:
return list(unit_config.keys())
def list_units(quantity_name) -> List[str]:
return list(unit_config[quantity_name].units.keys())
def convert_value(
quantity_name: str,
from_unit_name: str,
to_unit_name: str,
value: float) -> Result:
quantity = unit_config[quantity_name]
from_unit = quantity.units[from_unit_name]
to_unit = quantity.units[to_unit_name]
# Two-step conversion: from-unit to standard unit, then to to-unit
value_in_to_units = (value *
from_unit.value_in_std_units /
to_unit.value_in_std_units)
return Result(from_unit, to_unit, value, value_in_to_units)
3.6.3 实现前端
我们终于到了本章的这部分,你将实际使用 Streamlit!
首先,我们将探索我们将要使用的 Streamlit 的每个功能,参考我们之前的 UI 设计(如图 3.12 所示),然后我们将使用它们来创建我们的前端。

图 3.12 我们单位转换应用程序的 UI 可视化,供参考
我们将逐步构建我们的用户界面。首先,创建一个名为frontend.py的新 Python 文件,并添加我们需要的导入(即 Streamlit 本身和定义的后端 API 函数)。
import streamlit as st
from backend import convert_value, list_quantities, list_units
保存你的文件,并在终端中运行以下命令:
streamlit run frontend.py
或者如果你的工作目录不包含frontend.py,可以使用streamlit run <path to frontend.py>。
这将打开一个浏览器窗口,显示你的应用程序(目前只是一个空白屏幕)。每次你进行更改时,切换回浏览器窗口并点击“重新运行”或“始终重新运行”以查看结果。
st.radio
我们将关注的 UI 的第一个组件是数量选择器,它是一组设置在左侧面板中的单选按钮。
单选按钮是一个 UI 元素,允许用户从给定列表中选择一个项目。
例如,如果你将以下内容添加到刚刚创建的frontend.py文件中:
quantity = st.radio("Select a quantity", ["Mass", "Force", "Pressure"])
Streamlit 将显示问题“选择一个量”以及一个带有“质量”、“力”和“压力”选项的单选按钮列表。一旦用户选择了一个选项,变量quantity将包含用户选择的选项(即字符串“质量”、“力”或“压力”)。
在我们的情况下,我们的选项列表将来自后端,回想一下我们有一个名为list_quantities(列表 3.5)的函数。因此,我们将会写:
quantity = st.radio("Select a quantity", list_quantities())
这给我们展示了图 3.13 中的输出。现在我们得到了“质量”、“长度”和“时间”作为选项,因为list_quantities获取unit_config(来自我们的配置文件)的键,并将它们作为列表返回,这个列表是st.radio的第二个参数。

图 3.13 st.radio 的一个示例输出
quantity变量将包含用户当前选择的项(例如,图 3.8 中的“质量”)。
Streamlit 为st.radio提供了大量的自定义选项。你可以设置水平选项而不是垂直选项,为每个选项添加标题,完全禁用它们,等等。
您可以在 Streamlit 文档的docs.streamlit.io/develop/api-reference/widgets/st.radio中找到完整的自定义选项列表。
st.sidebar
我们已经创建了单选按钮,但现在我们需要将它们放在如图 3.12 所示的左侧面板中。
在 Streamlit 术语中,这种面板被称为侧边栏。当您想要创建一组链接,这些链接可以引导到应用中的不同页面,提供一些关于您应用的元信息等时,侧边栏非常有用。
要使用 st.sidebar,您需要在其中放置一些内容。有两种方法可以做到这一点:
您可以使用上下文管理器(即 Python with 语句)如下所示:
with st.sidebar:
quantity = st.radio("Select a quantity", list_quantities())
您可以将任何 Streamlit 元素放置在 with 语句内部,它将在侧边栏中显示。
您还可以使用点符号并引用您想要放置在侧边栏中的元素,将其作为侧边栏的成员,如下所示:
quantity = st.sidebar.radio("Select a quantity", list_quantities())
将上述任何一个添加到 frontend.py 中都会产生如图 3.14 所示的输出。

图 3.14 st.sidebar 在使用一组单选按钮时的效果
侧边栏显示一个“X”图标,用于折叠它,或者一个“>”图标,用于展开折叠的侧边栏。
st.title
在设置好侧边栏后,让我们将注意力转向应用的主要区域。用户需要了解应用是什么以及它做什么,因此我们将添加一个标题:
st.title("Unit Converter")
这应该相当直观,但为了明确说明,st.title 将传递给它的任何字符串作为标题写入,即以大号粗体字体。
st.text_input
接下来,我们需要用户输入他们想要转换的值。让我们为此使用文本输入框。
st.text_input 是 Streamlit 允许用户输入单行值的方式。我们可以这样写:
input_num = st.text_input("Value to convert", value="0")
显示一个带有标题“要转换的值”和初始值“0.”的文本输入框。请注意,st.text_input 返回一个字符串,因此 input_num 有字符串“0.”。
我们希望将其保持为数字,因此我们还将值转换为浮点数:
input_num = float(st.text_input("Value to convert", value="0"))
在添加标题和文本输入框后,我们的应用应该看起来像图 3.15。

图 3.15 添加标题和文本输入框后的单位转换器应用
st.text_input 有许多自定义选项,例如在未输入值时显示的占位文本,可选的工具提示,以及输入密码的能力(我们在第二章的密码检查器应用中遇到过这种情况)。
再次强调,docs.streamlit.io 上有更多关于可用功能的详细信息。
st.selectbox
我们需要下拉菜单,让用户选择从和到单位,因此让我们创建这些下拉菜单。
st.selectbox 是我们在这里要找的。它显示一个带有标签和一组选项的基本选择小部件。
参数类似于您传递给 st.radio 的参数。例如,我们可以这样写:
country = st.selectbox("Pick a country", ["United States", "Canada", "India"])
显示一个包含变量 country 的国家下拉菜单,其中包含所选选项。当下拉菜单首次渲染时,默认选中第一个选项(在本例中为“美国”)。
对于我们的用例,我们首先需要收集要显示的选项列表。我们可以从后端调用list_units来获取用户所选数量的可用单位:
units = list_units(quantity)
我们可以使用这个列表来填充我们的“从”和“到”单位下拉菜单中的选项:
from_unit = st.selectbox("From", units)
to_unit = st.selectbox("To", units, index=1)
我们在第二个下拉菜单中包含的index参数用于设置默认选中的选项。值为'1'表示在第一次渲染时将选中第二个选项。
我们这样做是因为我们不希望“从”和“到”下拉菜单有相同的默认选中值,因为这几乎永远不会有用(用户不会想从千克转换成千克)。
此时,我们的应用看起来像图 3.16。

添加“从”和“到”单位下拉菜单后的 3.16 单位转换器应用
这是好的,但我们的设计是将“从”和“到”下拉菜单并排放置,这似乎更自然。
st.columns
默认情况下,Streamlit 按代码中遇到的顺序从上到下显示 UI 元素。
自然地,你并不总是想要这样;有时你想要东西并排排列。我们看到了如何使用st.sidebar来做这件事,但一个应用只能有一个侧边栏,它出现在整体 UI 的左侧;它不能是内联的。
st.columns就是答案。要使用它,你首先需要创建一个列的列表,指定你想要的列数:
from_unit_col, to_unit_col = st.columns(2)
在这里,调用st.columns(2)返回一个包含两个列的列表。我们在这里使用的语法称为列表解包,它将列表的各个项分配给不同的变量。正如你可能想象的那样,在这里from_unit_col将包含第一个列,而to_unit_col将包含第二个。
就像在st.sidebar的情况下,有两种方法可以将某物放入列中:使用with上下文管理器或点符号表示法。因此,我们可以写:
with from_unit_col:
from_unit = st.selectbox("From", units)
with to_unit_col:
to_unit = st.selectbox("To", units, index=1)
或者更简洁地,
from_unit = from_unit_col.selectbox("From", units)
to_unit = to_unit_col.selectbox("To", units, index=1)
一般而言,当你需要在容器(无论是侧边栏、列还是其他东西)内显示多个元素时,with上下文管理器更有意义,而当您只有一个项目或想要显示顺序不规则的元素时,点符号表示法效果更好。在整个书中,我们将看到许多这些情况的示例。
图 3.17 展示了此时我们的应用:

图 3.17 带有并排“从”和“到”单位下拉菜单的单位转换器应用
st.button
在捕获所有输入后,我们准备添加我们的“转换”按钮。
我希望你能记得第二章中的st.button,Streamlit 的老忠实元素,用于让某事发生。
要添加我们的按钮,我们会写:
if st.button("Convert"):
# Statements to execute
这应该很容易理解。它说,“显示一个写着'转换'的按钮,如果/当用户点击它时,执行一些语句。”
一旦我们写出了我们想要按钮执行的操作,这将渲染一个带有我们定义的功能的裸骨 Streamlit 红白按钮。
那么,我们实际上希望我们的按钮做什么呢?我们的后端有一个convert_value函数,它执行单位转换,所以让我们先调用它:
if st.button("Convert"):
result = convert_value(quantity, from_unit, to_unit, input_num)
在这里,我们正在将用户选择的数量、源单位和要转换的值传递给convert_value。回想一下,convert_value返回一个Result类的对象(在result.py中定义)
因此,变量result包含了我们的转换结果,包括源值、目标值和缩写。剩下要做的就是将其显示在屏幕上。进入…
st.metric
我们本可以将结果显示为普通的段落文本,但这是我们应用的大结局,是我们谈论的重点。我们想要一些更有力的东西。
st.metric是一个在仪表板上常用的小部件,用于显示重要的数字——比如公司的收入——以及它们与前期相比的趋势。
一个单独的st.metric元素旨在表示用户感兴趣的度量,它由三个部分组成:一个文本标签,以大字体显示的数字本身,以及一个“增量指示器”,它显示数字与前期相比增加了多少或减少了多少。
我们将使用st.metric来显示源值和目标值以及单位的缩写,所以让我们首先准备这些:
from_display = f"{result.from_value} {result.from_unit.abbrev}"
to_display = f"{result.to_value} {result.to_unit.abbrev}"
由于结果是一个Result对象,我们通过连接其from_value或to_value字段并从from_unit或to_unit获取缩写来形成显示文本,from_unit或to_unit本身是一个Unit实例。
要实际使用st.metric,我们编写:
st.metric("From", from_display, delta=None)
st.metric("To", to_display, delta=None)
st.metric中的增量指示器对我们来说没有意义,所以我们将其设置为None以隐藏它。
上述内容将垂直显示源值和目标值,但我们需要它们并排显示,所以让我们再次使用st.columns:
from_value_col, to_value_col = st.columns(2)
from_value_col.metric("From", from_display, delta=None)
to_value_col.metric("To", to_display, delta=None)
图 3.18 显示了我们的完成的应用程序。

图 3.18 我们的完成的单位转换应用程序
列表 3.6 显示了如果你一直跟随着,你应该结束的frontend.py文件。
列表 3.6 frontend.py
import streamlit as st
from backend import convert_value, list_quantities, list_units
quantity = st.sidebar.radio("Select a quantity", list_quantities())
st.title("Unit Converter")
input_num = float(st.text_input("Value to convert", value="0"))
units = list_units(quantity)
from_unit_col, to_unit_col = st.columns(2)
from_unit = from_unit_col.selectbox("From", units)
to_unit = to_unit_col.selectbox("To", units, index=1)
if st.button("Convert"):
result = convert_value(quantity, from_unit, to_unit, input_num)
from_display = f"{result.from_value} {result.from_unit.abbrev}"
to_display = f"{result.to_value} {result.to_unit.abbrev}"
from_value_col, to_value_col = st.columns(2)
from_value_col.metric("From", from_display, delta=None)
to_value_col.metric("To", to_display, delta=None)
3.7 对我们的应用程序进行迭代
呼呼!我们做到了!我们现在手里有一个完全功能的应用程序。在现实世界中,这仅仅是你的旅程的开始,你现在将启动你的应用程序并向用户展示。
用户经常会对你为他们构建的体验提出非常主观的反馈,这可以帮助你发现你在测试中可能没有遇到的可用性问题盲点。
在本节中,我们将通过使用应用程序本身来模拟此过程,并确定我们可以做出的潜在改进。
3.7.1 四舍五入我们的转换结果
让我们试驾我们的完成的应用程序。图 3.18 显示了将千克转换为克的示例结果。
这看起来大部分都很好,但现在让我们尝试一个公制到英制的转换。比如说,我们想把 4000 千克转换成磅。我们启动我们的应用程序,选择“质量”,输入我们的输入并点击“转换”来查看图 3.19。

图 3.19 一个公制到英制的转换,展示了为什么我们应该进行四舍五入
我们得到的答案似乎是正确的,但十进制位数却非常多。大多数人可能不需要这种程度的精度;事实上,它可能减少了他们的体验,因为他们需要一秒钟的时间来弄清楚为什么显示的数字这么长。
如果用户可以选择将结果四舍五入到他们真正需要的精度,那将很理想。而且,如果我们考虑到更大的数字,理想情况下应该用逗号分隔千位。
为了实现这一点,让我们首先创建一个名为 format_value 的函数,该函数将以我们想要的方式格式化数字,即用逗号分隔千位,并且可选地四舍五入到一定的小数位数。
format_value 函数接受三个参数:要格式化的值、缩写,以及一个可选的 decimal_places 数量,用于将值四舍五入到。如果我们不传递后者(即 is_rounded 为假),则函数不会进行任何四舍五入。
def format_value(
value: float,
unit_abbrev: str,
decimal_places: int = None) -> str:
is_rounded = decimal_places is not None
rounded = round(value, decimal_places) if is_rounded else value
formatted = format(rounded, ",")
return f"{formatted} {unit_abbrev}"
如果我们需要,我们可以使用 round 函数进行实际的四舍五入,并使用 Python 内置的 format 函数添加逗号,使用 "," 作为 格式说明符。
我们可以直接硬编码要四舍五入到的十进制位数,但理想情况下我们应该让用户来决定,这意味着添加一个新的输入小部件。
st.number_input
st.number_input 是 Streamlit 的数字输入小部件。它与 st.text_input 非常相似,但它有一些额外的功能,例如可以指定最小和最大值,以及一个步进按钮,允许您通过点击来增加或减少输入的值。
我们可以通过在 frontend.py 中添加以下行来使用它来收集用户首选的十进制位数,就在从单位和到单位下拉菜单之后:
places = st.number_input("Decimal places to round to", value=2, min_value=0)
我们指定默认值为 2,最小值为 0,因为我们不能有负数的十进制位数(尝试输入更小的值会显示错误。places 将保留输入的数字。
这应该会给我们展示在图 3.20 中的小部件。

图 3.20 st.number_input
注意 '-' 和 '+' 按钮,它们允许您通过点击增加或减少值 1。您可以通过在 st.number_input 中指定 step 参数来调整步进间隔。
您可能想知道为什么我们费心使用 st.text_input 来收集要转换的值并将其转换为浮点数,当 st.number_input 可用时。这是因为在我们写作的时候,似乎没有简单的方法来去除 st.number_input 中的 '-' 和 '+' 按钮。当我们收集十进制位数时,这些按钮是有意义的,因为十进制位数是一个非常紧密范围内的整数,但要转换的值几乎是无限的,并且没有预定义的步进间隔是有意义的。
好的,现在我们已经收集了小数位数,我们准备在显示结果时应用我们的格式化。我们可以通过更改from_display和to_display变量,使它们使用我们之前定义的format_value函数来实现:
from_display = format_value(input_num, result.from_unit.abbrev)
to_display = format_value(result.to_value, result.to_unit.abbrev, places)
我们传递places(我们从用户那里收集)来四舍五入to_display变量。我们也可以为from_display做同样的事情,但用户可能已经输入了他们想要在源值中看到的精度,所以我们不想去修改它。
这给了我们图 3.21。

图 3.21 单位转换器应用,带有小数位数输入
注意
这不会在目标值后面填充额外的尾随零。例如,如果目标值是一个整数,比如说 600,它将显示为“600.0”,只有一个尾随零。
格式化后的结果看起来更美观,但我们还增加了一个额外的数字输入,这稍微增加了用户的认知负荷。也许我们只应该在用户要求时引入小数位数输入。
st.checkbox
st.checkbox是 Streamlit 复选框,即你可以勾选的框。请尽量控制你的惊讶,我们有一个可以发布的应用。
如同st.button,st.checkbox是一个条件元素;你可以使用 if 语句根据它是否被勾选来分支你的逻辑。
我们的用例是让用户决定他们是否想要四舍五入转换结果,我们可以通过修改获取places变量值的方式来实现:
places = None
if st.checkbox("Round result?", value=False):
places = st.number_input(
"Decimal places to round to", value=2, min_value=0)
复选框默认未勾选,因为我们向值参数传递了 False。
注意,我们在st.checkbox代码上方包含了places = None这一行。这是因为,在下面,我们正在引用places,它超出了if st.checkbox块的作用域,所以我们需要传递一个初始值给它,以防用户未勾选该框。
现在我们的应用应该看起来像图 3.22。

图 3.22 单位转换器应用,启用四舍五入
或者,如果用户未勾选“四舍五入结果?”,我们将得到图 3.23 中的全精度处理。

图 3.23 单位转换器应用,禁用四舍五入
3.7.2 移除按钮
我们的应用现在工作得很好;它显示四舍五入的输出,但只有在我们想要的时候。我们添加了一个点击和一个数字输入来启用这个功能,然而。
也许有办法简化这个体验?让我们把注意力转向“转换”按钮。我们真的需要它吗?
它所做的只是触发转换。但我们真的不需要一个明确的触发器。为什么不让应用始终根据输入显示结果呢?所以如果用户更改了值,他们会立即看到转换结果,而无需再次点击按钮。
这确实看起来是一个更直观的体验,让我们来实现它。
实际上,这很容易做到。只需删除if st.button("Convert"):这一行,并将该块中的所有内容移动到外面。
列表 3.7 展示了frontend.py的最终版本。
列表 3.7 frontend.py的最终版本
import streamlit as st
from backend import convert_value, list_quantities, list_units
def format_value(
value: float,
unit_abbrev: str,
decimal_places: int = None) -> str:
is_rounded = decimal_places is not None
rounded = round(value, decimal_places) if is_rounded else value
formatted = format(rounded, ",")
return f"{formatted} {unit_abbrev}"
quantity = st.sidebar.radio("Select a quantity", list_quantities())
st.title("Unit Converter")
input_num = float(st.text_input("Value", value="0"))
units = list_units(quantity)
from_unit_col, to_unit_col = st.columns(2)
from_unit = from_unit_col.selectbox("From", units)
to_unit = to_unit_col.selectbox("To", units, index=1)
places = None
if st.checkbox("Round result?", value=False):
places = st.number_input(
"Decimal places to round to", value=2, min_value=0)
result = convert_value(quantity, from_unit, to_unit, input_num)
from_display = format_value(input_num, result.from_unit.abbrev)
to_display = format_value(
result.to_value, result.to_unit.abbrev, places)
from_result_col, to_result_col = st.columns(2)
from_value_col, to_value_col = st.columns(2)
from_value_col.metric("From", from_display, delta=None)
to_value_col.metric("To", to_display, delta=None)
图 3.24 展示了我们应用程序的最终截图。

图 3.24 移除了“转换”按钮的单位转换器应用程序
你会发现移除按钮会使应用程序的流程变得更好。例如,如果你更改小数位数或值,你会立即看到结果。
我希望这一章对你来说非常有趣!我们从一个简单的概念开始,将其分解为具体的需求,设计了用户界面,深入思考了实施方法,编写代码将我们的想法转化为一个可工作的应用程序,并对其进行了优化以获得更好的用户体验。
本章的目的是让你对在现实世界中与真实利益相关者一起开发 Streamlit 应用程序的感觉有一个了解。正如你可能已经学到的,这个过程远不止编写代码;像确定需求以及设计(和优化)用户体验这样的事情同样重要。
3.8 摘要
-
从一个概念构建应用程序远不止编写代码。
-
在现实世界中创建 Streamlit 应用程序涉及以下六个步骤:阐述概念、定义需求、可视化用户体验、头脑风暴实施、编写代码和迭代。
-
需求可能来自你的用户或非用户利益相关者。
-
在开始时通过绘制原型来可视化用户体验是个好主意,这样你就有了一个努力的方向。
-
实施头脑风暴涉及分析权衡并绘制逻辑流程图。
-
将你的前端和后端代码分离,并为两者定义一个 API 以进行交互是组织应用程序的绝佳方式。
-
st.text_input和st.number_input允许用户输入文本和数值。 -
st.radio和st.selectbox允许用户从列表中选择一个值。 -
st.sidebar和st.columns是布局元素,允许你打破 Streamlit 渲染 UI 元素的自然从上到下的方式。 -
st.button和st.checkbox都是条件元素。
第四章:4 Streamlit 的执行模型
本章涵盖
-
创建需要在页面更新之间维护状态的应用程序
-
有效解决你的应用程序问题
-
非常重要的
st.session_state和st.rerun -
Streamlit 的执行模型
在上一章的最后两章中,你已经通过构建两个功能齐全的应用程序(密码检查器和单位转换器)开始了 Streamlit 的实践。你已经学习了 Streamlit 的语法基础以及如何创建交互元素。但是当你运行一个 Streamlit 应用时,幕后发生了什么?理解这一点对于构建更复杂的应用程序至关重要。
本章深入探讨了 Streamlit 的执行模型的核心,我们将探讨如何管理应用程序的状态。
本章也采取了与前几章略有不同的方法。虽然我们仍然会构建一个实用的应用程序——每日待办事项应用——但主要重点是让你具备解决问题的技能。我们将故意在应用程序中引入一些错误来模拟现实世界中的情况,在这些情况下事情可能不会按计划进行。通过跟随并修复这些问题,你将更深入地了解 Streamlit 的内部工作原理以及如何有效地调试自己的应用程序。
4.1 更复杂的应用:每日待办事项
是否曾经在工作时同时处理多个截止日期,同时在心理上为家人计划假期,同时也试图记得在回家的路上买面包?无论你的具体情况如何,现代生活的狂潮总有一种方式让你陷入其中,把你拉入无尽的活动中和需求中。希望这一章的 Streamlit 应用能帮助你管理混乱,即使它实际上不能把面包送到你家门口。
我们将制作一个待办事项应用,让用户跟踪他们在一天内需要完成的各项事务。
由于本章的主要目的是让你熟悉 Streamlit 的执行模型,我们不会像上一章那样详细地介绍整个六步开发过程。
相反,我们将快速浏览概念、要求和模拟设计,然后直接跳到实现阶段。
4.1.1 陈述概念
如你希望从上一章中记住的,概念是对我们的应用的一个简洁陈述。以下是我们的应用:
概念
一个 Streamlit 应用,让用户能够将任务添加到每日待办事项列表中并跟踪其状态。
这听起来非常清晰明了,那么让我们深入详细的要求。
4.1.2 定义要求
回顾第三章,虽然概念提供了你对应用的一般想法,但正是你的要求使其具体化,阐述了用户需要从它那里得到什么。
我们正在构建的待办事项应用的要求如下:
要求:
用户应该能够:
-
查看他们的每日待办事项列表,由任务组成
-
将任务添加到他们的待办事项列表中
-
从他们的列表中删除任务
-
在他们的列表中标记任务为完成
-
取消标记任务为完成
-
查看他们的整体任务完成状态,即他们的总任务数和已完成的任务数
同样重要,甚至可能更重要,的是明确我们的应用不会做什么,所以让我们也明确这一点:
范围之外的内容
-
当用户刷新或重新打开页面时检索待办事项列表
-
将待办事项列表导出到外部文件或格式
-
保存添加或完成的待办事项的历史记录
上面的两个列表应该让你对我们的本章要构建的内容有一个概念:这是一个相当基本的每日待办事项列表,完全存在于单个浏览器会话中。
实际上,我们期望用户与我们的应用交互的方式是在每天开始时在一个浏览器窗口中打开它,添加他们的任务,并在他们的一天进展中标记为完成/未完成,保持窗口打开直到一天结束,并且不要刷新它。第二天重复同样的步骤。
我们不会构建在浏览器会话之外持久化(或保存)任何任务的能力。如果你刷新页面,你会丢失你的数据。
*“这不会限制应用的一些有用性吗?”你可能会问。当然。只是我们不想现在就引入外部存储的复杂性。我们将在本书的后面部分探讨这一点,特别是在第二部分中。
然而,有人也可能认为,在每天开始时给用户提供一张白纸会使他们更有效率。所以你看,无法保存你的任务是功能,而不是错误!
这主要是关于复杂性的事情。尽管如此,你应该知道,将你产品的局限性转化为积极因素在行业中几乎是一种生存技能!我敢打赌你的其他前端技术手册也不会给你提供免费的生活建议。
4.1.3 可视化用户体验
现在我们相当精确地知道我们的应用需要能够做什么,因此考虑到这一点,并遵循我们在上一章中介绍的原则,即把用户体验放在首位,让我们将注意力转向图 4.1 所示的模拟 UI 设计。

图 4.1 我们每日待办事项应用的模拟 UI 设计
我们的设计有两个部分:一个侧边栏,您可以在其中输入新任务,以及一个“主要”区域,您可以在其中查看您添加的任务并更新它们的状态。
一旦你通过输入任务文本并点击左侧的按钮添加任务,它就会出现在右侧。每个任务都以复选框的形式呈现。你通过勾选框标记任务为“完成”,这也会满意地划掉任务。你可以通过点击右侧的按钮完全删除一个任务。
顶部还有一个跟踪器,告诉你你完成了多少任务,总共是多少。
4.1.4 实施方案头脑风暴
你可能已经意识到,我们的待办事项应用比第二章中构建的密码检查器或第三章中的单位转换应用要复杂得多。在这两种情况下,用户最终只能执行一个主要操作——在前一种情况下,评估输入的密码;在后一种情况下,执行转换。
我们的任务列表有四个不同的用户可以执行的操作:
-
添加任务
-
标记为完成
-
标记为未完成
-
删除它
让我们花点时间来头脑风暴一下我们如何实现这一点。
我们实现的核心是任务的概念,以及由此扩展的任务列表。在我们的应用中,一个任务是一个具有两个属性的实体:一个名称和一个状态,状态可以是“完成”或“未完成”。任务列表仅仅是任务的有序列表。
上文提到的四个用户操作只是修改任务列表的不同方式;添加任务会将项目添加到列表中,标记为完成/未完成会更新列表中项目的状态,删除任务会将其从列表中移除。
在每个点上,应用都应该向用户展示任务列表的最新状态。
因此,我们可以将我们的应用分为三个部分:
-
任务列表
-
动作,这些动作连接到按钮和复选框,并修改任务列表
-
显示逻辑,它在屏幕上渲染任务列表
每当执行一个操作时,任务列表都会被修改,并且显示逻辑会自动更新屏幕上显示的内容。
图 4.2 显示了添加新任务时会发生什么;任务被附加到任务列表中,显示逻辑再次遍历所有任务,并根据诸如“完成则划掉”等规则在屏幕上渲染它们。

图 4.2 添加任务会将项目附加到任务列表中,并且显示逻辑会再次遍历所有任务,并根据诸如“完成则划掉”等规则在屏幕上渲染它们
当任务被勾选时,会发生非常类似的情况,如图 4.3 所示。这次,任务的状态在任务列表中被更新。其他一切照旧;显示逻辑再次遍历每个任务。根据“如果完成则勾选”和“如果完成则划掉”的规则,给完成的“购买面包”任务赋予我们想要的样式。

图 4.3 标记任务会更新任务列表中项目的状态,并且相同的显示逻辑会重新渲染更新后的任务列表
删除任务或取消勾选操作基本上是以相同的方式进行;存储在内存中的任务列表被更新,并由显示逻辑重新渲染。
到目前为止,我们已经确定了如何在应用中表示关键实体,以及每个用户操作会产生什么效果。现在是时候实现我们的逻辑了。
4.2 实现和调试我们的应用
当我们在第三章构建单位转换应用时,我们走了一条风景优美的路线,详细地走过了应用开发过程的每一步。然而,在这个过程中,我们并没有真正深入探讨的一个部分是:当事情出错时会发生什么,以及如何调试问题。这是一段相当顺利的旅程。
这次,我们将走一条更崎岖的道路,你会发现这更符合现实世界。在我们实现待办事项应用的过程中,我们会遇到各种问题和错误。就像在现实世界中一样,这些错误会促使我们更深入地了解 Streamit。我们将利用我们的深入理解来克服并解决问题。
注意
由于本章的重点是让你在调试应用问题方面获得经验,我们将放弃在第三章中学到的一些最佳实践(例如,在前后端之间保持严格的分离,或定义清晰的 API),以便编写更简洁的代码。
首先,在你的代码编辑器中创建一个新的文件,并将其命名为todo_list.py。
4.2.1 显示任务列表
虽然我们在一开始就一次性完成了规划,但在编写实际代码时,我们将像第三章中那样迭代地构建我们的应用,一部分一部分地构建,并在 Streamlit 中查看结果。
那么,我们从哪里开始?第一次迭代是什么?
如前所述,任务列表的概念是我们应用实现的核心。我们应用显示逻辑组件始终需要显示任务列表的最新状态。
我们的第一步可以简单到只是创建任务列表的标题。
st.header
Streamlit 有几种不同的文本元素,它们以各种大小和格式简单地显示文本。我们之前已经使用st.title来渲染大标题。
st.header非常相似,但它显示的文本比st.title小一些。
通过将列表 4.1 中的代码放入todo_list.py来使用它。
列表 4.1 todo_list.py 仅包含标题
import streamlit as st
st.header("Today's to-dos:", divider="gray")
注意到我们包括了分隔线参数,它只是在你的标题下方显示一条灰色线条。不错吧?
和往常一样,为了看到你的工作效果,保存你的文件并运行streamlit run todo_list.py,或者如果你在另一个工作目录中,运行streamlit run <path to frontend.py>。
当你的浏览器窗口打开时,你应该会看到类似于图 4.4 的内容。

图 4.4 st.header 带分隔线
如果你在你的应用中恰好有多个标题,你甚至可以通过将divider设置为True而不是特定颜色来循环切换分隔线颜色。
创建任务列表
接下来,让我们转向任务的概念。如前所述,任务有一个名称和完成/未完成状态。
因此,我们可以使用数据类来表示具有恰好这两个字段的任务:一个字符串name和一个布尔值is_done来表示任务状态。列表 4.2 显示了Task类。请将其保存到一个名为task.py的新文件中,与todo_list.py位于同一目录下。
列表 4.2 task.py
from dataclasses import dataclass
@dataclass
class Task:
name: str
is_done: bool = False
注意到is_done: bool = False这一行。在这里,我们默认将is_done设置为False,以防在创建Task实例时未指定。这很快就会派上用场。
现在我们有了任务,我们的任务列表实际上是一个包含Task对象的 Python 列表。您可以在todo_list.py中使用几个虚拟任务来测试它,如下所示:
task_list = [Task("Buy milk"), Task("Walk the dog")]
由于我们已经为is_done指定了默认值False,因此不需要为Task的每个实例指定它。
不要忘记在文件顶部导入您的Task类:
from task import Task
任务复选框
开始时,我们的显示逻辑可以很简单:只需为每个任务显示一个复选框。我们知道如何使用字符串标签创建静态复选框;回想一下,我们在第三章中使用st.checkbox来创建一个将单位转换结果四舍五入的复选框。
但在这里,我们事先不知道每个复选框的标签。相反,我们必须从task_list中推断它们。我们如何做到这一点?
答案当然是循环。当一个 Streamlit 元素放置在循环中时,每次循环运行时都会渲染一个新的元素。我们实际上在第二章的初始密码检查器示例中已经遇到过这种情况,我们在循环中使用st.success和st.error来显示表示每个条件通过/失败状态的绿色和红色框。
我们可以从任务列表中创建复选框,如下所示:
for task in task_list:
st.checkbox(task.name, task.is_done)
回想一下,传递给st.checkbox的第一个参数是标签(在这种情况下是任务的名称),第二个是一个布尔值,表示复选框是否应该渲染为选中状态。我们希望如果任务已完成,则每个复选框都被选中,因此直接传递任务的is_done字段是有意义的。
列表 4.3 显示了此时todo_list.py应该看起来像什么。
列表 4.3 task.py 带有每个任务的复选框
import streamlit as st
from task import Task
task_list = [Task("Buy milk"), Task("Walk the dog")]
st.header("Today's to-dos:", divider="gray")
for task in task_list:
st.checkbox(task.name, task.is_done)
保存并运行以获取图 4.5 所示的输出

图 4.5 在循环中使用 st.checkbox 显示每个任务
我们的复选框目前实际上并没有做什么。我们稍后会解决这个问题,但首先让我们为每个任务添加一个“删除”按钮。
添加删除按钮
我们想要一个按钮来删除列表中的每个任务,位于其右侧。类似于我们为复选框所做的那样,我们将动态生成这些按钮,因此它们应该放入我们之前编写的循环中。
但如果我们简单地将按钮附加到循环中,Streamlit 会将它放在任务的下方,而不是右侧,因为 Streamlit 默认按垂直方式渲染元素,正如我们在第三章中看到的。
和之前一样,我们将使用st.columns来解决这个问题。在这里,我们将创建两列——一列用于复选框和任务文本,另一列用于按钮。将你现有的for task in task_list循环替换为以下内容:
for task in task_list:
task_col, delete_col = st.columns([0.8, 0.2])
task_col.checkbox(task.name, task.is_done)
if delete_col.button("Delete"):
pass
注意,我们调用st.columns的方式与上一章有所不同:st.columns([0.8, 0.2])。我们不是传递列数,而是传递一个数字列表。这个列表包含了每列的相对宽度。我们表示,带有任务的列应该占据 80%的水平空间,而带有按钮的列应该占据 20%。如果我们只是传递列数,即st.columns(2),Streamlit 会使得两列宽度相等,这没有意义,因为任务文本可以任意长,而按钮不能。
我们目前还没有让按钮做任何事情,所以我们只是写了pass,这是 Python 中的一个关键字,意味着“什么都不做”。
小部件键
让我们再次运行我们的应用,看看它的样子如何。图 4.6 展示了你可能会看到的内容。

图 4.6 当存在多个相同的小部件时,Streamlit 会抛出错误
在第一个任务右侧有一个按钮,但在第二个任务中没有。最重要的是,下面有一个大红色的错误信息框;Streamlit 在抱怨我们尝试创建多个具有相同键的st.button小部件。
键是 Streamlit 用来识别小部件的一段文本——本质上就是我们所说的 Streamlit 元素,如st.button、st.checkbox等。小部件键需要是唯一的,这样 Streamlit 才能区分任何两个小部件。
在大多数情况下,你不需要手动指定小部件的键,因为 Streamlit 会根据其特性内部指定一个。对于一个按钮,Streamlit 的内部键基于其文本。所以当你有两个按钮都写着“删除”时,它们的键是相同的,这违反了唯一性约束。
这个问题的解决方案,正如错误提示所建议的,是为我们创建的每个按钮手动指定一个唯一的键。
由于我们需要为列表中的每个任务创建一个唯一的删除按钮键,确保唯一键的一种方法是在键中包含任务的列表索引。例如,第一个任务的删除按钮键可以是delete_0,第二个的可以是delete_1,依此类推:
for idx, task in enumerate(task_list):
task_col, delete_col = st.columns([0.8, 0.2])
task_col.checkbox(task.name, task.is_done)
if delete_col.button("Delete", key=f"delete_{idx}"):
pass
由于我们需要任务索引和任务本身,我们已经将 for 循环的标题更改为for idx, task in enumerate(task_list)。
注意
enumerate,如你所知,是 Python 中的一个实用的小函数,它允许你以优雅的方式遍历列表,一次获得索引和元素。不那么优雅的替代方案是编写:
for idx in range(len(task_list)):
task = task_list[idx]
...
正如我们讨论的,我们使用每个按钮的索引来形成其唯一的键:key=f"delete_{idx}"。如果你现在运行你的代码,你应该会看到错误消失,如图 4.7 所示

图 4.7 向每个按钮传递一个唯一的键允许 Streamlit 区分其他方面相同的按钮
你现在可能想知道,“为什么我们当时不需要向复选框传递一个键?”
嗯,因为复选框已经有了唯一的内部键,因为它们的标签(任务名称)是不同的。如果我们尝试在我们的列表中放入两个相同的任务,我们实际上会面临相同的问题。例如,如果我们将我们的任务列表更改为 task_list = [Task("Buy milk"), Task("Buy milk")],我们将看到类似于我们之前为按钮看到的错误。
如果用户想要重复输入相同的任务,这可能是一个好主意,所以让我们通过向每个复选框传递一个唯一的键来解决这个问题:
task_col.checkbox(task.name, task.is_done, key=f"task_{idx}")
这样做可以让我们在不出现问题的前提下拥有两个具有相同名称的任务。
4.2.2 启用操作
到目前为止,我们已经设置了我们的应用,以大致的方式显示我们想要的任务,使用虚拟任务来测试它。我们实际上并没有提供用户与任务交互或修改任务的方法。
我们将在本节中这样做。我们首先定义更新我们的任务列表的函数,然后将它们连接到 Streamlit UI 元素。
添加任务
要将任务添加到我们的任务列表中,我们需要一个任务名称。一旦我们有了这个名称,添加它就像创建一个 Task 对象并将其追加到我们的列表中一样简单。
我们可以在 todo_list.py 中的简单 add_task 函数中写出这个功能:
def add_task(task_name: str):
task_list.append(Task(task_name))
标记任务完成或未完成
一个任务的完成状态由 Task 实例的 is_done 字段表示。因此,标记为完成或未完成涉及更新这个字段。让我们为此创建两个函数:
def mark_done(task: Task):
task.is_done = True
def mark_not_done(task: Task):
task.is_done = False
注意,这些函数的参数是 Task 实例本身,而不是任务名称字符串。
删除任务
删除任务也是直接的。对于这个函数,我们需要任务在我们列表中的索引,以便我们可以删除它。
def delete_task(idx: int):
del task_list[idx]
启用用户添加任务
由于我们现在有了 add_task 函数,我们不再需要用虚拟任务初始化我们的任务列表。让我们将行 task_list = [Task("Buy milk"), Task("Walk the dog")] 替换为空列表:
task_list = []
接下来,我们将添加 Streamlit 元素,以便用户可以调用我们的 add_task 函数。我们需要一个 st.text_input 让用户输入任务名称,以及一个 st.button 来触发添加操作。我们将这两个元素都包裹在 st.sidebar 中,这样它们就会出现在我们应用左侧的面板中。再次提醒,如果这些内容听起来不熟悉,你应该回顾第三章。
with st.sidebar:
task = st.text_input("Enter a task")
if st.button("Add task", type="primary"):
add_task(task)
注意 st.button 中的 type="primary"。type 参数允许你通过表示它链接到“主要操作”来给按钮添加强调(以不同颜色形式)。在 UI 设计中,让用户的注意力集中在他们通常会执行的操作上是一个好主意。在这里,添加任务是我们期望用户会经常执行的操作,所以使用主要按钮是有意义的。如果你没有指定这个参数(我们一直做到现在),它默认为“secondary”,在撰写本文时,这会导致按钮为白色。
注意,我们并没有给按钮添加一个 widget key,因为我们只有一个“添加任务”按钮,Streamlit 不需要任何额外的帮助来区分它和其他按钮。
在这个阶段,你的 todo_list.py 文件应该看起来像列表 4.4 中所示的那样。
列表 4.4 到目前为止的 todo_list.py
import streamlit as st
from task import Task
task_list = []
def add_task(task_name: str):
task_list.append(Task(task_name))
def delete_task(idx: int):
del task_list[idx]
def mark_done(task: Task):
task.is_done = True
def mark_not_done(task: Task):
task.is_done = False
with st.sidebar:
task = st.text_input("Enter a task")
if st.button("Add task", type="primary"):
add_task(task)
st.header("Today's to-dos:", divider="gray")
for idx, task in enumerate(task_list):
task_col, delete_col = st.columns([0.8, 0.2])
task_col.checkbox(task.name, task.is_done, key=f"task_{idx}")
if delete_col.button("Delete", key=f"delete_{idx}"):
pass
保存并运行你的代码。为了检查结果(见图 4.8),输入一个名为“清理车库”的新任务并点击“添加任务”。

图 4.8 添加了一个待办事项的待办事项应用
到目前为止一切顺利,但当我们尝试添加另一个任务,比如“完成项目提案”时,我们看到了图 4.9 中显示的问题输出。

图 4.9 当添加新任务时,旧的任务消失
我们可以看到我们的新任务,但旧的任务“清理车库”不见了。似乎有些不对劲,但我们没有看到像 widget key 问题那样的错误。
奇怪的是,点击“删除”会删除剩余的任务(见图 4.10),即使我们没有将其连接到任何东西;回想一下,我们使用了 pass 来使按钮不执行任何操作——这通常被称为 no-op。

图 4.10 点击“删除”删除了任务,尽管我们没有将其连接到任何东西。
如果你再次添加一个任务并点击复选框,也会发生同样的事情:任务就会消失。你也可以随意尝试一下。
发生了什么问题?
显然,我们的应用并没有按照我们的预期工作。Streamlit 不会显示错误,所以我们必须找出发生了什么。我们的显示逻辑是否只显示了最后添加的任务?或者任务列表本身有什么问题?
让我们找出问题所在。调试代码最重要的部分之一是在程序运行时检查变量的值。在一个正常的 Python 脚本中(即你会在命令行中运行的脚本,而不是使用 Streamlit),你可能会包含 print 语句来显示变量的值。你也可以使用你的 IDE 的调试器或 pdb 模块,但让我们保持简单。
print 语句不会出现在你的 Streamlit 应用程序的浏览器窗口中。相反,让我们使用一个合适的 Streamlit 元素。我们感兴趣的是 task_list 变量,所以请在 st.header("Today's to-dos:", divider="gray") 行的下面写下以下内容,在我们的显示逻辑循环之前。
st.info(f"task_list: {task_list}")
st.info 是一个显示一些文本的彩色框的元素。它是我们在第二章中已经看到过的元素家族的一部分:st.success、st.error 和 st.warning,这些元素也以彩色框显示文本。对于 st.info,框是蓝色的。
当你保存并运行(或刷新页面)时,你会看到一个带有文本 task_list: [] 的框,因为没有任务。添加一个任务,就像之前一样,你会在图 4.11 中看到输出。

图 4.11 task_list 包含一个 Task 实例
如你所见,task_list 现在包含一个 Task 实例的单个实例,对应于“清理车库”。当我们添加第二个任务时,我们的 task_list 变量只有新的任务。这表明问题不在于我们的显示逻辑有误;task_list 变量本身已经丢失了“清理车库”任务。
当我们尝试勾选任务旁边的复选框或按下“删除”按钮时,task_list 现在又变空了,这就是为什么没有显示任何任务的原因。
好的,所以这是我们所知道的:添加任务似乎正确地将任务添加到了 task_list 中,但无论之后你做什么,无论是添加另一个任务还是点击复选框或“删除”按钮,它都会从 task_list 中移除之前添加的任务。
在我们能够解决这个问题之前,我们需要了解为什么会发生这种情况。为此,让我们回顾一下 Streamlit 应用实际上是如何工作的。
4.3 Streamlit 如何执行应用程序
在前两章中,我们学习了如何使用 Streamlit,甚至用它开发了一些非平凡的程序。然而,我们主要关注的是语法和对应用程序工作原理的表面理解。
要成功编写更复杂的 Streamlit 应用程序,我们需要比这更深入地了解。为了更进一步,我们需要讨论 Streamlit 的一个相当基础的概念:它的执行模型。
4.3.1 前端和服务器
Streamlit 应用实际上有两个部分:一个后端 Streamlit 服务器 和一个 前端。
对于我们的目的来说,服务器是在你的电脑上运行的软件程序,等待接收发送给它的请求。从技术角度来说,我们说服务器是在一个 端口 上 监听。
端口是一个虚拟标识符,用于识别特定的通信通道,就像大型办公室中的分机号一样。正如分机可以帮助你联系公司内的特定人员,端口允许网络通信到达你电脑上运行的特定程序。
当你在终端中输入 streamlit run <filename.py> 时,你可能已经注意到了类似以下输出的内容:
Local URL: http://localhost:8502
实际上发生的情况是,一个 Streamlit 服务器启动并开始监听端口 8502(端口号可能因你而异)上的请求。
当你现在打开浏览器并导航到给定的地址(即 http://localhost:8502)或等待服务器自动为你完成此操作时,浏览器会向端口 8502 上的 Streamlit 服务器发送请求。
作为回应,Streamlit 服务器从顶部到底部执行您的 Python 脚本,并向浏览器发送一个消息,告诉它显示什么,即前端。
因此,前端是用户可以看到并与之交互的应用程序的前端部分,它运行在您的网页浏览器上。它由浏览器理解的 HTML、CSS 和 JavaScript 代码组成。
4.3.2 应用程序重新运行
现在,这里是重要的部分:Streamlit 服务器每次页面需要更改时都会运行您的 Python 脚本,这包括每次用户与您的应用程序中的小部件交互时。
例如,图 4.12 详细说明了当用户在您的应用程序中点击按钮时会发生什么。

图 4.12 每次用户与应用程序交互时,Python 脚本都会重新运行
一旦前端检测到按钮点击,它会向服务器发送一个消息,告知服务器点击信息。服务器通过重新运行 Python 代码并对按钮进行评估来响应此信息,将其设置为 True。
完成后,服务器向前端发送一个包含需要更改的显示的消息。前端随后进行这些更改,用户看到更新后的显示。
注意,这并不仅限于按钮点击;它适用于 任何 交互或 Streamlit 确定显示需要更改的 任何 时间。这意味着每次用户点击按钮、从下拉菜单中选择不同的项目或移动滑块时,循环都会重复,服务器会重新运行您的整个 Python 脚本。
4.3.3 将此应用于我们的应用程序
让我们看看我们能否利用应用程序重新运行的知识来了解我们的待办事项应用程序中发生了什么。
列表 4.5 显示了当前存在的代码。
列表 4.5 todo_list.py
import streamlit as st
from task import Task
task_list = []
def add_task(task_name: str):
task_list.append(Task(task_name))
def delete_task(idx: int):
del task_list[idx]
def mark_done(task: Task):
task.is_done = True
def mark_not_done(task: Task):
task.is_done = False
with st.sidebar:
task = st.text_input("Enter a task")
if st.button("Add task", type="primary"):
add_task(task)
st.header("Today's to-dos:", divider="gray")
st.info(f"task_list: {task_list}")
for idx, task in enumerate(task_list):
task_col, delete_col = st.columns([0.8, 0.2])
task_col.checkbox(task.name, task.is_done, key=f"task_{idx}")
if delete_col.button("Delete", key=f"delete_{idx}"):
pass
我们现在将逐步讲解这段代码在应用程序使用过程中的各个执行点。
首次运行
第一次运行我们的应用程序时,即当用户首次加载它时,task_list 被设置为空列表。
现在考虑 st.sidebar 管理器内的这一行:
if st.button("Add task", type="primary")
这是一个 if 语句,因此其下的行,即 add_task(task),只有在 st.button 表达式评估为 True 时才会执行。
到目前为止,按钮尚未被点击,因此它评估为 False,add_task 不会被调用。因此,task_list 仍然是一个空列表。
然后,代码继续执行到 st.info 框和显示逻辑,但由于没有任务,st.info 显示一个空列表,循环从未执行,因此没有复选框。
用户添加任务
现在假设用户已经输入了一个任务,“清理车库”,并点击了“添加任务”按钮。如前所述,这会触发整个 Python 代码的重新运行。
注意
技术上,重新运行可能已经在这个点发生,甚至在用户点击按钮之前。当用户完成输入“清洁车库”后,如果他们通过点击文本框外部将焦点移出文本框,这将被视为一个交互(因为文本框中的值已更改)并触发代码的重新运行。但这不会导致任何有趣的变化,所以我们现在忽略它。
再次从脚本顶部开始,task_list 被设置为空列表。由于行 task = st.text_input("Enter a task"),变量 task 现在持有字符串“清洁车库”,因为这是文本框中的内容。
由于按钮刚刚被点击,st.button 评估为 True,因此触发 if 语句并调用 add_task。
add_task 为“清洁车库”创建一个 Task 实例并将其追加到 task_list 中,使其不再为空。这就是 st.info 显示的内容。
因此,显示逻辑循环运行一次,并继续渲染复选框和删除按钮。这完成了重新运行,产生了图 4.13 中所示的结果。

图 4.13 当用户点击“添加任务”时,st.button 评估为 True,并且 task_list 有一个任务
用户点击任务复选框
到目前为止一切顺利。一切似乎都在正常工作。但当用户点击“清洁车库”的复选框时,会触发另一个重新运行。
再次从头开始,我们有行 task_list = [],它再次将其设置为空列表,丢弃了之前存在的“清洁车库”任务!
但让我们假设文本框尚未清除,仍然显示“清洁车库。”这意味着一旦执行了行 task = st.text_input("Enter a task"),变量 task 仍然包含字符串“清洁车库。”
当我们到达 st.button 行时会发生什么?按钮之前已经被点击,那么这意味着它会被评估为 True 吗?如果是这样,那么 add_task 将再次被触发,将“清洁车库”追加到 task_list 中,恢复其早期状态,一切都会正常。
但 st.button 的工作方式并非如此。实际上,st.button 只在点击后立即发生的重新运行中评估为 True。在所有后续的重新运行中,它将恢复到其原始的 False 值。在这种情况下,点击复选框触发了一个全新的重新运行,因此 st.button 现在评估为 False。
这意味着 add_task 从未被调用,task_list 也从未更新。它保持为空列表,因此循环从未执行,没有显示任何任务。
用户添加另一个任务而不是点击任务复选框
为了结束这次讨论,让我们考虑这样一个场景:用户没有点击任务复选框,而是尝试添加另一个任务(通过在任务输入文本框中输入“完成项目提案”并点击“添加任务”)。
在这种情况下,执行过程类似。st_task 在顶部被设置为空列表,因此我们失去了之前的“清理车库”任务。
由于我们输入了一个新任务,文本框现在包含“最终化项目提案”,因此这就是 task 变量所持有的内容。
这次,由于我们最新的按钮点击,我们的 st.button 通过了评估,并且 add_task 被调用,传递的参数值为“最终化项目提案”。这将在我们原本为空的列表中添加新的 Task。
到此为止,task_list 只包含一个元素:“最终化项目提案”,这正是 st.info 和我们的显示逻辑循环所显示的内容,如图 4.14 所示。

图 4.14 当用户添加不同的任务时,st.button 再次评估为 True,并将“最终化项目提案”添加到最初为空的 task_list 中。
我们终于可以解释我们看到的奇怪结果了。问题归结为,由于我们的脚本每次都会重新运行,task_list 不断被重置。
4.4 在多次运行间持久化变量
在上一节中,我们能够通过审查 Streamlit 的执行模型并在各个阶段逐步执行我们的应用程序来解释我们得到的不预期的输出。在本节中,我们将尝试确定一个实际解决问题的方法。
回顾一下,我们面临的困境是我们编写的应用程序表现得像金鱼:它对任何之前的运行中发生的事情都没有记忆。由于 Streamlit 每次都有机会重新运行我们的全部代码,我们的应用程序的内存就会反复被清除,重置我们用来保存用户任务的 task_list 变量。
4.4.1 st.session_state
事实上,Streamlit 有一个解决方案,那就是 st.session_state。简而言之,st.session_state 是一个容器,用于存储将在多次运行间持久化的变量。
这里的 Session 指的是应用程序会话,你可以将其大致理解为从打开应用程序到刷新页面或关闭应用程序之间的时间。
当你需要记住一个值时,你只需将其保存到 st.session_state,然后在下一次运行中检索该值,如图 4.15 所示。

图 4.15 使用 st.session_state 在多次运行间保存和检索值
因此,st.session_state 是变化海洋中的一块岩石。或者,如果你想有一个更技术的比喻,它是一个存储你想要在多次运行间持久化的变量的仓库。
那么,我们实际上如何利用它呢?嗯,st.session_state 在技术上虽然不是,但几乎完全像是一个 Python 字典。就像在字典的情况下,你可以向其中添加键值对,检查是否存在特定的键,查找其值,或者完全删除它。甚至大部分使用的语法都与字典相同。
例如,如果你想在st.session_state中存储一个值为 5 的变量x,你会写st.session_state["x"] = 5,然后使用st.session_state["x"]检索该值。
要检查x是否存在于会话状态中,你会写if "x" in st.session_state。你甚至可以使用for key, value in st.session_state.items()遍历st.session_state中的项,并使用del st.session_state删除一个键。
与字典不同,你还可以使用点符号来引用键x的值,如下所示:st.session_state.x.。
列表 4.6 显示了一个使用st.session_state的玩具 Streamlit 应用,其任务是简单地跟踪和增加一个数字:
列表 4.6 一个简单的数字增加应用
import streamlit as st
if "number" not in st.session_state:
st.session_state.number = 0
if st.button("Increment"):
st.session_state.number += 1
st.info(f"Number: {st.session_state.number}")
我们首先检查会话状态中是否存在键"number",如果不存在,则将其添加,值为零。
然后我们有一个按钮,每次点击都会增加“number”的值,还有一个st.info框来检索并显示 number 的值。
图 4.16 显示了按下“增加”按钮五次后的输出。

图 4.16 使用 st.session_state 跟踪和增加数字的玩具 Streamlit 应用
如果我们没有在这里使用st.session_state,而是简单地将number存储在其自己的变量中(或在一个常规字典中),那么它将不会工作,因为每次应用重新运行时,值(或字典本身)都会被重置。st.session_state是唯一能够在应用重新运行之间保留其状态的东西。
为什么我们需要在添加之前检查“number”是否已经存在于会话状态中?嗯,如果没有这个检查,我们会遇到之前的问题。每次应用运行时,它都会将st.session_state.number设置为零,覆盖之前运行中增加到的任何值,我们永远不会看到数字实际上发生变化。
通过检查"number"是否存在,我们确保只有一次执行st.session_state.number = 0这一行——在第一次运行时,"number"尚未添加。
4.5 完成我们的应用
现在我们知道如何给我们的应用一个“记忆。”当你开始为你自己的目的编写 Streamlit 应用时,你会很快意识到这个知识是绝对关键的——到了没有它你甚至无法编写除了最简单的应用之外的应用的程度。
借助强大的st.session_state,我们准备好再次尝试让我们的待办事项列表应用工作!
4.5.1 添加会话状态
在我们上次运行我们的应用时,我们面临的主要问题是task_list变量,它包含所有我们的任务,每次重新运行都会被重置。
让我们通过将task_list添加到st.session_state来解决这个问题。将你之前代码中的task_list = []行替换为以下内容:
if "task_list" not in st.session_state:
st.session_state.task_list = []
这与我们在上一节中讨论的玩具示例相似。唯一的区别是我们将task_list存储在st.session_state中,而不是一个单独的数字。
我们现在可以修改我们代码的其余部分,使其在当前引用 task_list 的所有地方都引用 st.session_state.task_list,但这似乎很繁琐,而且相当笨拙。相反,让我们只是将变量 task_list 指向 st.session_state 中的版本,如下所示:
task_list = st.session_state.task_list
现在其余的代码应该可以正常工作,因为它们都引用了 task_list。
列表 4.7 显示了我们的代码现在应该包含的内容。
列表 4.7 todo_list.py 与 st.session_state
import streamlit as st
from task import Task
if "task_list" not in st.session_state:
st.session_state.task_list = []
task_list = st.session_state.task_list
def add_task(task_name: str):
task_list.append(Task(task_name))
def delete_task(idx: int):
del task_list[idx]
def mark_done(task: Task):
task.is_done = True
def mark_not_done(task: Task):
task.is_done = False
with st.sidebar:
task = st.text_input("Enter a task")
if st.button("Add task", type="primary"):
add_task(task)
st.header("Today's to-dos:", divider="gray")
st.info(f"task_list: {task_list}")
for idx, task in enumerate(task_list):
task_col, delete_col = st.columns([0.8, 0.2])
task_col.checkbox(task.name, task.is_done, key=f"task_{idx}")
if delete_col.button("Delete", key=f"delete_{idx}"):
pass
保存、重新运行,并尝试添加多个任务。图 4.17 显示了你这样做会得到的结果。

图 4.17 使用 st.session_state,Streamlit 记住了我们的旧任务
哇!task_list 终于可以更新为包含多个任务了,我们的显示逻辑也显示了所有内容。
4.5.2 连接“删除”按钮
在此基础上,让我们让我们的“删除”按钮开始工作。回想一下,我们之前通过在显示循环中的按钮代码下写 pass 来设置它们什么也不做。
if delete_col.button("Delete", key=f"delete_{idx}"):
pass
从那时起,我们创建了一个 delete_task 函数,所以让我们在这里调用它:
if delete_col.button("Delete", key=f"delete_{idx}"):
delete_task(idx)
如果我们现在在保存和重新运行(如果你刷新了页面,请按顺序重新添加三个任务)后点击“删除”旁边的“买面包”,我们会看到...没有变化!但是,如果你第二次点击按钮,任务就会消失。但似乎仍然有问题。
我不会用截图详细说明所有内容,但如果你现在在这个阶段玩一下这个应用,你会注意到更多奇怪的行为。当你第一次点击列表中最后一个任务的“删除”按钮时,它没有任何反应。但是,如果你紧接着点击一个复选框(任何复选框),任务就会消失。
或者,如果你从列表中间删除一个任务,下一个任务会消失,而不是你删除的那个!但是,如果你然后做些其他事情,比如点击一个复选框或添加另一个任务,那个任务就会回来,而你实际删除的那个任务会正确地被移除,一切都会恢复正常。
总体来说,似乎在实际上点击“删除”按钮和任务被删除之间存在一定的延迟。你似乎需要在点击按钮后做些其他事情(任何其他事情,比如点击一个复选框,或者编辑任务输入框中的文本并点击外部),才能显示正确的结果。
4.5.3 背后发生的事情
为了理解发生了什么,我们需要再次深入挖掘我们的应用执行。让我们假设我们处于应用的一个阶段,用户已经按顺序输入了三个任务:“清理车库。”、“最终确定项目提案。”和“买面包。”
到目前为止,task_list 已经被填充了这三个任务。
步骤遍历应用执行
假设用户尝试删除第三个任务。图 4.18 以图解方式显示了应用中发生的情况。删除按钮由分配给它们的 Streamlit 小部件键标识,即 delete_0、delete_1 等。

图 4.18 步骤分解应用执行:在第二次运行中,删除按钮评估为 True,但任务和按钮在调用delete_task之前就已经显示
第一次运行是在点击按钮之前发生的。Streamlit 简单地遍历我们的任务列表,显示每个任务及其复选框和删除按钮。正如我们在本章前面讨论的那样,每个st.button都评估为False,因为它们还没有被按下。
当用户点击“买面包”的删除按钮时,它触发了应用的重新运行。一切保持不变,直到我们显示第三个任务的st.button。这次,由于它刚刚被点击,这个按钮评估为True。由于条件为真,应用进入嵌套在st.button下的代码,调用delete_task(idx)。由于在这个循环迭代中idx是 2,所以调用delete_task(2),从task_list中移除“买面包”。执行在此处停止。
看到问题了吗?所有三个按钮在执行delete_task之前就已经显示,更新了task_list。由于没有其他用户操作,没有更多的重新运行被触发。所以task_list确实被更新了,但显示逻辑已经在旧的task_list版本上执行了。这就是为什么我们点击“删除”后仍然看到三个任务。
但在这个时候,如果用户执行其他操作,比如点击复选框或者再次点击“删除”按钮,就会触发另一个重新运行。这次,显示逻辑运行在task_list的最新版本上,所以我们最终看到了第三个任务及其复选框和删除按钮被移除。
注意
以这种方式逐步执行也可以解释我们注意到的其他奇怪行为,例如当你点击列表中间任务的删除按钮时,下一个任务消失。这是因为当调用delete_task时,列表索引都会向上移动一个位置,下一个显示循环迭代最终跳过一个任务,因为它的索引已经改变。
4.5.4 自动触发重新运行
如我们所见,尽管我们的删除按钮最初没有正确工作,但 Streamlit 最终确实得到了正确的结果,前提是用户采取额外的行动,触发重新运行。
我们可以利用这个知识来发挥我们的优势。我们需要的只是一个通过代码触发应用重新运行的方法,而不是通过用户操作。Streamlit 通过st.rerun提供这个功能,并且可以在任何时候不带任何参数调用它,如下所示:
st.rerun()
当你调用st.rerun时,你实际上是在告诉 Streamlit,“退出当前运行并从头开始。”
在我们的情况下,一旦任务被删除,我们应该触发一次重新运行:
if delete_col.button("Delete", key=f"delete_{idx}"):
delete_task(idx)
st.rerun()
如果你做出这个更改,重新运行并像以前一样重新创建任务,然后再次尝试删除“买面包”,你将看到图 4.19 中的输出。

图 4.19 使用 st.rerun 时按下“删除”按钮按预期工作
它成功了!“买面包”不再存在,你也可以从截图中的st.info框中看到它已从task_list中消失。
4.5.5 连接复选框
让我们继续到我们应用的下一个部分:复选框。我们已经将它们添加到显示中,你可以勾选它们,但它们实际上并没有做任何事情。因此,我们的下一步是将它们连接到我们为更改任务状态定义的功能。
我们当前的复选框代码是一行:
task_col.checkbox(task.name, task.is_done, key=f"task_{idx}")
当用户勾选任务复选框时,我们想要实现两个目标:
-
标记任务为完成,并
-
删除线
我们还希望如果用户取消勾选框,则撤销上述更改。
要更改任务的状态,我们可以使用我们之前为该目的创建的函数,mark_done和mark_not_done。
我们如何实现删除线?为了这种格式化效果(以及其他几种效果),Streamlit 支持一种叫做markdown的语言。
Markdown 是一种特殊的基于文本的标记语言,用于添加各种类型的格式。它有显示文本加粗或斜体、创建链接、列表、标题等功能。我们将在后面的章节中遇到这些功能,但就目前而言,让我们专注于删除线效果。
在 markdown 中,要删除一段文本,你可以用两个波浪号将其包围,如下所示:
~~Text to be struck through~~
这通过标签参数连接到我们的复选框,该参数支持 markdown。我们将定义一个变量,如果任务未完成,则包含任务名称本身,如果已完成,则包含带有 markdown 删除线的名称:
label = f"~~{task.name}~~" if task.is_done else task.name
然后,我们可以将其输入到我们的复选框中:
task_col.checkbox(label, task.is_done, key=f"task_{idx}")
最后,让我们也将我们的复选框连接到我们的mark_*函数。如果我们勾选了复选框,则调用mark_done,否则调用mark_not_done。我们的整体代码现在应如列表 4.8 所示。
连接任务复选框后的 4.8 todo_list.py
import streamlit as st
from task import Task
if "task_list" not in st.session_state:
st.session_state.task_list = []
task_list = st.session_state.task_list
def add_task(task_name: str):
task_list.append(Task(task_name))
def delete_task(idx: int):
del task_list[idx]
def mark_done(task: Task):
task.is_done = True
def mark_not_done(task: Task):
task.is_done = False
with st.sidebar:
task = st.text_input("Enter a task")
if st.button("Add task", type="primary"):
add_task(task)
st.header("Today's to-dos:", divider="gray")
st.info(f"task_list: {task_list}")
for idx, task in enumerate(task_list):
task_col, delete_col = st.columns([0.8, 0.2])
label = f"~~{task.name}~~" if task.is_done else task.name #A
if task_col.checkbox(label, task.is_done, key=f"task_{idx}"):
mark_done(task) #B
else:
mark_not_done(task) #C
if delete_col.button("Delete", key=f"delete_{idx}"):
delete_task(idx)
st.rerun()
A 如果任务已完成,则在标签上添加删除线效果
B 如果复选框被勾选并因此评估为 True,则调用 mark_done
C 如果复选框未勾选,则调用 mark_not_done
保存,重新运行,并添加你的任务,然后勾选一项任务以获得类似于图 4.20 所示的结果。

图 4.20 勾选任务并不立即按预期工作
再次强调,我们没有得到预期的结果。“清理车库”仍然没有删除线,我们的信息框显示task_list没有变化。在你把电脑扔出窗外并致力于余生放牧羊群之前,试着勾选另一个任务。
你会看到现在我们勾选的原任务有了删除线,并且根据我们的st.info框,其is_done字段为True。
听起来熟悉吗?看起来在我们点击复选框和该动作的结果显示之间有一个用户动作的延迟。
这里发生的情况与我们在删除按钮的情况中看到的情况非常相似。点击复选框确实触发了我们的函数并将 is_done 设置为 True,但到那时,任务及其标签已经显示。只有在 下一次 重运行中,实际显示 才会更新,而这个重运行只有在用户采取进一步行动时才会触发。
解决方案与之前相同:我们可以在每次我们的 mark_* 函数运行时触发一个手动重运行:
if task_col.checkbox(label, task.is_done, key=f"task_{idx}"):
mark_done(task)
st.rerun()
else:
mark_not_done(task)
st.rerun()
保存输出,刷新页面,然后再次尝试。图 4.21 展示了输出。

图 4.21 我们的应用挂起并且永远不会停止加载
这里出了大问题。一旦我们添加了第一个任务,我们的应用似乎就完全停止响应了。屏幕变灰,顶部出现“正在运行…”的指示符。
4.5.6 无限重运行循环
你刚刚遇到了你的第一个 Streamlit 无限重运行循环。让我们通过再次逐步执行来尝试理解出了什么问题。图 4.22 以图表形式展示了这一点。

图 4.22 步骤分解应用执行:一系列的 st.rerun() 导致无限循环
一旦我们添加了“清理车库”任务,task_list 包含一个单独的 Task 实例,其 is_done 字段被设置为 False。
由于 task_list 不为空,我们进入了显示循环,并显示了“清理车库”的复选框。
现在我们的任务有了分支逻辑:
if task_col.checkbox(label, task.is_done, key=f"task_{idx}"):
mark_done(task)
st.rerun()
else:
mark_not_done(task)
st.rerun()
复选框评估结果为 False,因为它没有被勾选,这意味着应用进入了 else 子句。
调用了 mark_not_done,将 is_done 设置为 False(尽管它已经是 False),然后 st.rerun() 强制 Streamit 停止当前运行并从头开始。
再次,在第二次运行中,我们进入了循环。复选框仍然没有被勾选,所以 mark_not_done 再次被调用,然后调用 st.rerun(),这开始第三次运行,以此类推。
由于这永远不会停止,Streamlit 会卡住并停止响应。
4.5.7 防止无限重运行
这里的问题是 mark_done 即使在没有必要的情况下也会被调用。回顾我们刚才看到的执行步骤,你会注意到“清理车库”任务的 is_done 字段已经被设置为 False,因此实际上没有必要再次调用 mark_not_done。
我们目前的代码设置中,一旦我们进入显示循环,就无法退出。如果我们的复选框评估结果为 True,则在 mark_done 函数之后调用 st.rerun()。如果评估结果为 False,则在 mark_not_done 函数之后调用 st.rerun()。
我们需要确保这只有在绝对需要时才会发生。mark_done(以及相关的 st.rerun)只有在复选框被勾选且任务尚未标记为“完成”时才应被调用。同样,mark_not_done 和其 st.rerun 应只有在复选框未被勾选且任务当前标记为“完成”时才被调用。
我们可以通过像这样编辑我们的代码来实现这一点:
checked = task_col.checkbox(label, task.is_done, key=f"task_{idx}") #A
if checked and not task.is_done: #B
mark_done(task)
st.rerun()
elif not checked and task.is_done: #C
mark_not_done(task)
st.rerun()
A 将复选框的值保存在一个名为 checked 的新变量中,以提高可读性。
B 只有当复选框被勾选且任务尚未标记为完成时,才调用 mark_done。
C 只有当复选框未勾选且任务仍然标记为完成时,才调用 mark_not_done。
这样,当复选框被勾选时,任务的状态设置为 is_done,但在下一次重新运行中,if 和 elif 子句都评估为 False,并且 st.rerun 永远不会执行。
尝试一下。现在我们的复选框应该可以正常工作,如图 4.23 所示。

图 4.23 我们复选框现在按预期工作
4.5.8 添加完成进度指示器
我们几乎完成了我们的应用程序。从我们早期的模拟设计中剩下的唯一事情是为用户提供额外的成就感感,添加一个进度指示器。
这相当直接。我们希望指示器既大又漂亮,所以我们在第三章中首次遇到的 st.metric 看起来很理想。
我们需要展示两个东西:任务总数和已完成任务数,这两个都可以从 task_list 获取。我们的 st.metric 代码可能看起来像这样:
total_tasks = len(task_list)
completed_tasks = sum(1 for task in task_list if task.is_done)
metric_display = f"{completed_tasks}/{total_tasks} done"
st.metric("Task completion", metric_display, delta=None)
要获取 completed_tasks,我们使用列表推导(当它被函数如 sum 包裹时,可以省略方括号)以实现简洁。
哦,我们可能可以去掉我们的信息框(st.info),因为我们不再处于故障排除模式。
我们最终的代码应该看起来像列表 4.9 中所示的那样。
列表 4.9 todo_list.py 的最终版本
import streamlit as st
from task import Task
if "task_list" not in st.session_state:
st.session_state.task_list = []
task_list = st.session_state.task_list
def add_task(task_name: str):
task_list.append(Task(task_name))
def delete_task(idx: int):
del task_list[idx]
def mark_done(task: Task):
task.is_done = True
def mark_not_done(task: Task):
task.is_done = False
with st.sidebar:
task = st.text_input("Enter a task")
if st.button("Add task", type="primary"):
add_task(task)
total_tasks = len(task_list)
completed_tasks = sum(1 for task in task_list if task.is_done)
metric_display = f"{completed_tasks}/{total_tasks} done"
st.metric("Task completion", metric_display, delta=None)
st.header("Today's to-dos:", divider="gray")
for idx, task in enumerate(task_list):
task_col, delete_col = st.columns([0.8, 0.2])
label = f"~~{task.name}~~" if task.is_done else task.name
checked = task_col.checkbox(label, task.is_done, key=f"task_{idx}")
if checked and not task.is_done:
mark_done(task)
st.rerun()
elif not checked and task.is_done:
mark_not_done(task)
st.rerun()
if delete_col.button("Delete", key=f"delete_{idx}"):
delete_task(idx)
st.rerun()
图 4.24 提供了对我们应用程序的最终审视。

图 4.24 最终待办事项列表应用程序
这样,你就有了一个完整的应用程序在你的掌握之中,也许甚至是一个你可以每天使用的工具来保持高效!你现在已经处于可以使用 Streamlit 在自己的项目中开始使用的位置。在下一章中,我们将看到如何将它们发布供其他人使用。
4.6 总结
-
在现实世界中,开发过程并不顺利;你大部分的时间将花在解决那些不符合预期的事情上。
-
st.header用于以大字体显示标题。 -
Streamlit 使用基于其特性的唯一小部件键来识别 UI 小部件。
-
当两个小部件在各个方面都相同的时候,你必须手动指定小部件键,以便 Streamlit 能够区分它们。
-
在应用执行过程中跟踪变量值的一个好方法是使用
st.info在应用屏幕上显示它们。 -
每当页面需要更改时,Streamlit 服务器都会从头到尾重新运行你的 Python 代码。
-
重新运行将重置应用程序中所有常规变量。
-
st.session_state用于存储你希望 Streamlit 在重新运行之间记住的变量。 -
当你看到意外结果时,逐步执行应用程序的执行是一个好主意。
-
你可以使用
st.rerun触发应用程序的重新运行。 -
当使用
st.rerun时,如果你的脚本没有提供退出路径,你的应用可能会陷入无限重跑循环。
第五章:5 在全球范围内分享您的应用程序
本章涵盖了
-
可用于与用户分享应用程序的各种选项
-
免费将应用程序部署到 Streamlit 社区云
-
将应用程序连接到外部服务,如 API
-
在生产中保护您的 API 密钥和其他机密信息
-
管理您的应用程序依赖项
当您第一次成功运行您从头开始构建的应用程序时,这是一种神奇的时刻——这是所有花费在设计、开发和改进上的时间最终得到回报的时刻。您已经引导它经历了多次迭代,解决了错误,并微调了每个功能。
但接下来是什么?您是否将其隐藏在本地机器上?除非您只为自己的使用构建了某些东西,否则答案可能是否定的。要使您的应用程序真正有用,您需要将应用程序交给您的目标受众。
本章是关于从本地开发到全球部署的飞跃。我们将简要讨论您可用于分享应用程序的各种路径。然后我们将确定其中之一,并指导您将应用程序投入生产,让全世界体验。
在这个过程中,我们将讨论使您的应用程序公开时涉及的关键考虑因素,例如保护 API 密钥等机密信息以及管理您的代码依赖项。一如既往,我们将采取实用方法,让您直接亲身体验我们讨论的每一件事。
5.1 部署您的应用程序
自我们从 Streamlit 开始以来已经走了很长的路。在过去的三个章节中,您已经创建了三个完全功能性的——我甚至可以说,有用的——应用程序。然而,您一直将您的才华隐藏在俗语所说的灌木丛下;没有人体验过您的技艺。是时候改变这一点了!
5.1.1 什么是部署?
部署应用程序松散地意味着使其可供其他人使用。更具体地说,这意味着将您的应用程序托管在您的目标用户可以轻松访问的地方。
回想一下第四章,Streamlit 应用程序由一个后端服务器和一个在网页浏览器上运行的客户端组成。虽然前端向服务器发送请求并显示结果,但实际上是服务器在真正运行。
要建立前端和服务器之间的连接并加载应用程序,用户必须导航到服务器运行的 URL 和端口。您之前已经体验过这个过程;当您使用streamlit run命令启动应用程序时,这个命令最终实际上做的就是为您打开一个网页浏览器并导航到一个类似于"https://localhost:8501"的 URL。
您也可以手动完成这项工作。事实上,只要您的 Streamlit 服务器正在运行,在新的浏览器标签页或窗口中打开 URL 就会创建到服务器的新的连接和您应用程序的新实例。
因此,部署你的应用程序涉及启动并保持 Streamlit 服务器运行,以便接受新的连接。只是,而不是你通过 localhost URL 访问自己的应用程序,其他人将通过不同的 URL 访问它。
部署应用程序有几种方法。我们将在下一节中简要讨论这些方法。
5.1.2 部署选项
根据你的需求、你愿意花费的金额以及你愿意投入的努力,你可能需要考虑多种部署选项。让我们简要地考虑一些:
在本地网络中运行服务器
部署你的应用程序最简单的方法就是每次运行 Streamlit 应用程序时你已经做过的。回想一下,当你使用 streamlit run 命令这样做时,Streamlit 服务器会启动,你可以在终端窗口中看到类似以下输出:
You can now view your Streamlit app in your browser.
Local URL: http://localhost:8502
Network URL: http://192.168.50.68:8502
就像我们在这里多次看到的那样,这里的“本地 URL”允许你从运行应用程序的计算机上访问你的应用程序。
但如果你的机器连接到本地网络或甚至你的家庭 Wi-Fi,网络上的其他设备可以通过 网络 URL 访问它。
好吧,试试看!如果你在 Wi-Fi(或局域网)上,并且有另一台设备——比如智能手机或另一台电脑——连接到相同的 Wi-Fi / LAN,尝试运行你创建的一个应用程序,记下网络 URL,然后在第二台设备的网页浏览器中打开它。
例如,图 5.1 展示了我从连接到同一 Wi-Fi 网络的手机打开我的待办事项应用程序时看到的内容:

图 5.1 使用 Streamlit 应用程序的网络 URL 从同一网络中的另一台设备访问它
注意
使其工作取决于你的网络如何设置。例如,你的防火墙可能阻止来自其他设备的传入流量,从而阻止它到达你的 Streamlit 服务器,或者可能存在其他类似的规则。修复此类问题超出了本书的范围,但你应该能够通过一些 Google 搜索或从你的网络管理员那里获得帮助来解决这个问题。
这种部署方法的一个优点是,更改代码并让用户看到这些更改就像编辑你的代码一样简单;没有额外的步骤!
然而,有一些明显的局限性:
-
它仅在运行 Streamlit 服务器的计算机开启并连接到网络时才有效。
-
它只允许连接到你的本地网络的设备访问你的应用程序,而不是公众。
然而,它在很多地方都可能很有用。例如,你可以为你的家庭创建应用程序,并与家人分享链接。甚至根据你的网络和安全策略的宽松程度,你还可以在工作场所使用这种类型的部署来运行基本的非业务关键应用程序。
设置专用服务器
如果你希望让你的 Streamlit 应用程序对更广泛的受众可用,设置一个专用服务器可能是在本地部署之外的一个合理的步骤。这涉及到使用一个独立于你个人电脑的单独的物理或虚拟机器。通过这样做,你可以确保你的应用程序全天候可用,并且对本地网络之外的用户可访问。
在这种设置中,你首先选择一个合适的服务器——这可能是你拥有的一个重新利用的额外电脑,或者是一个专门为此目的设置的新机器。在选择服务器并在其上安装 Python 和 Streamlit 之后,你将启动你的应用程序的 Streamlit 服务器,并将正确的端口(例如,端口 8501)暴露给外部流量。你还需要处理网络配置以允许访问,例如,如果服务器位于防火墙后面,你需要在路由器上设置端口转发。
运营一个专用服务器可能是一项艰巨的任务,伴随着许多责任,尤其是与安全相关。你将负责配置防火墙,维护和更新服务器的操作系统和软件等。
选择这种方式的优点是你可以完全控制你的部署,但另一方面,它需要大量的技术知识,也许更重要的是,你将投入大量的时间。
如果你只是想让公众使用你开发的应用程序,我会推荐我们接下来要讨论的剩余选项之一。
部署到云端
为了实现更大的可扩展性、可靠性和易于访问,你可以使用基于云的平台来部署你的应用程序。这种方法利用了公共云服务提供商的基础设施——例如亚马逊网络服务(AWS)、微软 Azure 或谷歌云——允许你无需物理硬件即可托管你的应用程序。它提供了许多好处,包括自动扩展以处理不同水平的流量,强大的安全措施以保护你的数据,以及高可用性以确保你的应用程序始终可访问。
云部署的关键好处是云服务提供商管理了与维护应用程序相关的许多基础设施责任。这包括服务器维护和安全更新,让你可以专注于应用程序的开发和改进。
许多公司已经将或正在将他们的内部应用程序迁移到云端。如果你考虑让你的应用程序在组织内部或更广泛的受众中可用,云部署可以是一个高效且有效的解决方案。与你的云管理员或 IT 团队合作将有助于确保顺利的设置和集成过程。
然而,重要的是要注意,随着你的应用程序变得流行,使用云提供商可能会很昂贵,因为成本通常基于你的应用程序使用的资源,这随着访问它的用户数量的增加而增加。
第十三章将详细讨论如何将您的应用程序部署到公共云平台,如 AWS 和 Google Cloud。
Streamlit 社区云
这就留下了我们将在这本书的大部分内容中使用的选项——Streamlit 社区云,这是一种将您的应用程序发布给任何感兴趣的人的方式,完全免费。
Streamlit 社区云由 Streamlit 的所有者 Snowflake 公司运营。它优先考虑易用性,正如其名称所暗示的,是专门为运行 Streamlit 应用程序而构建的。
社区云确实有一些资源限制,例如您的应用程序可以使用多少计算能力、内存和存储空间。如果您超过了这些限制——比如说,如果您的应用程序因受欢迎而变得非常流行——您可能需要考虑其他选项,例如部署到付费云提供商(见第十三章)。
然而,鉴于我们正处于学习 Streamlit 的过程中,社区云非常适合我们的需求。在本章的其余部分,我们将介绍如何将应用程序部署到社区云。
5.2 将我们的待办事项应用程序部署到 Streamlit 社区云
如前所述,Streamlit 社区云完美地满足了我们的部署需求,因为它免费、专门为 Streamlit 定制,并且极其易于使用。
在本节中,我们将部署我们之前构建的一个应用程序——第四章中的待办事项应用程序——到社区云上,这样任何有互联网连接的人都可以使用它。
5.2.1 前提条件
除了 Python 和 Streamlit 本身之外,将应用程序部署到 Streamlit 社区云还需要以下内容:
-
git,我们在第二章中简要讨论的流行版本控制工具 -
GitHub 账户
-
Streamlit 社区云账户
-
将您的 GitHub 账户连接到社区云
如果您之前从未使用过git并且想了解它,请参阅附录 B,其中简要介绍了如何使用它。
创建和设置 GitHub 账户
您可能听说过 GitHub,这是一个用于版本控制和协作软件开发的基于网络的平台。它使用git,一个分布式版本控制系统,帮助开发者跟踪其代码的变化,在项目上进行协作,并管理其软件的版本。
注意
GitHub 和 git 不要混淆。git 是版本控制系统的名称,而 GitHub是使用 git 创建的仓库最流行的托管平台。您可以使用 git 与其他托管平台一起使用,例如 Bitbucket,尽管 Streamlit 社区云确实需要 GitHub。
GitHub 账户的链接现在是大多数开发者简历上的一个相当标准的固定项目。对我们来说,重要的是 Streamlit 社区云期望您的应用程序代码存储在 GitHub 仓库 中,该仓库包含一组文件和目录及其修订历史。
首先,请访问github.com并注册一个新账户。注册过程与其他网站上的预期非常相似——您需要输入您的电子邮件并验证它,创建用户名,并选择一个强大的密码。
一旦您创建了账户,您需要启用您的命令行以进行身份验证并将代码推送到您创建的任何仓库。有几种方法可以做到这一点,但我们将使用个人访问令牌(PATs),这是密码的替代品,用于通过命令行或 API 访问 GitHub。
在撰写本文时,要到达 PAT 创建屏幕,您可以按照以下步骤操作:
-
点击您的个人头像然后“设置”
-
在侧面板中找到并点击“开发者设置”
-
点击“个人访问令牌”>“令牌(经典)”
-
选择“生成新令牌”
图 5.2 以可视方式显示了此路径(尽管当然 GitHub 可能会更改其配置方式)。

图 5.2 如何在 GitHub 上到达个人访问令牌生成页面
在打开的屏幕上,请确保选择“repo”范围,这会为您提供对仓库的完全控制权。
您还需要输入一个描述令牌用途的备注(您可以输入类似“用于推送 Streamlit 代码”的内容)以及一个有效期。
一旦 PAT 过期,您将无法再使用它,需要创建一个新的,因此请相应选择。较短的过期时间更安全——因为它在泄露的情况下有效期限更短——但也意味着您需要更频繁地更改它。
图 5.3 显示了您可以做出的选择。

图 5.3 GitHub 上的 PAT 创建屏幕;请确保选择“repo”范围
点击“生成令牌”以实际创建它。GitHub 现在会向您展示您创建的令牌。请复制并安全存储它,因为您将再也看不到它了!
我们将在推送我们的代码时使用 PAT。
创建 Streamlit 社区云账户
要创建社区云账户,请访问 Streamlit 网站,https://streamlit.io/,点击“注册”,并按照指示操作。我建议使用您的 GitHub 账户注册。
完成后,它应该会带您到一个“仪表板”页面。
如果您没有使用 GitHub 账户注册,您会在左上角的“工作区”旁边看到一个感叹号。
如果是这样,您需要单独将您的 GitHub 账户连接到社区云。截至撰写本文时,您可以通过点击“工作区”然后“连接 GitHub 账户”来完成此操作。
如果您已经登录 GitHub,您不需要做任何事情。如果您还没有,您需要输入您的 GitHub 账户凭据。
5.2.2 部署步骤
现在您的账户已经设置好了,部署您的应用程序是一个三步过程:
-
创建 GitHub 仓库
-
将您的代码推送到 GitHub
-
告诉社区云在哪里查找它
如果您的应用需要连接到外部服务,或者如果它需要专用库,我们将在本章后面探索一些额外的步骤,但上述步骤将适用于我们在第四章中构建的现有待办事项应用。
如果您已经像我们在第二章中建议的那样,将 git 作为您作为开发者的常规工作流程的一部分,您可能已经创建了一个仓库并将代码推送到其中,因此可以跳过到“告诉社区云您的应用位置”这一部分。
如果还没有,按照下面的部分顺序阅读。
创建 GitHub 仓库
首先,登录您的 GitHub 账户。创建新仓库的按钮应该相当明显。如果您在此账户中之前从未创建过,您应该会看到一个“创建仓库”按钮,如图 5.4 左侧所示。如果您已经有了一些仓库,它将显示您的顶级仓库列表,您可以通过点击“新建”,如图 5.4 右侧所示来创建一个新的。

图 5.4 在 GitHub 上创建新仓库的按钮
这应该会带您到一个询问您新仓库详细信息的页面(见图 5.5)

图 5.5 GitHub 上的仓库创建界面
您可以忽略这里的大多数设置——只需确保您给仓库起一个容易记住的名字,并选择“公开”作为可见性。
完成后,点击“创建仓库”。您将被带到包含一些说明和重要的是,您的仓库 URL 的界面,如图 5.6 所示。

图 5.6 您的仓库 URL
记下这个 URL,因为您在下一节中需要它。如果需要,您可以在以后通过导航到您的仓库再次找到这个 URL。
您的仓库现在已准备好供您存放一些代码了!
将代码推送到 GitHub
到目前为止,您有一个远程 GitHub 仓库,但您的 Streamlit 应用代码是存储在本地,在您的计算机上。我们现在将使此代码在您的远程仓库中可用,这个过程称为推送您的代码。
打开一个终端窗口,导航到包含第四章中待办事项应用(由两个文件组成:todo_list.py 和 task.py)的目录。
输入以下命令以将此目录初始化为本地 Git 仓库(与 GitHub 上的远程仓库相对):
git init
git init 命令创建一个空的 Git 仓库,在名为 .git 的隐藏子目录中设置所有必需的文件。
接下来,输入:
git add .
这会将当前目录的内容添加到 Git 的暂存区,这是一个临时存放您对代码所做的更改的地方。您使用暂存区来准备您想要保存的代码的确切快照。
是时候提交您的更改了,所以输入:
git commit -m "Commit Streamlit to-do list app"
此命令捕获您代码当前状态的快照,并将其保存到本地仓库中,附有关于更改的描述性消息。
此处的最后一步是将您的代码复制到 GitHub。为此,我们首先需要让您的本地 Git 仓库知道您在 GitHub 上的远程仓库。
获取您在 5.1.1 节中创建的 PAT 以及您远程仓库的 URL,并将它们组合起来创建一个 PAT 嵌入的 GitHub URL:
https://<Your PAT>@<Repo URL without the https://>
例如,如果您的 PAT 是ghp_fLbbSwjMlw3gUs7fgRux8Ha3PIlG9w3ZY3zY(这不是一个真实的 PAT)并且您的仓库是https://github.com/omnom-code2/streamlit-todo-list-app.git,您的 PAT 嵌入 URL 将是:
https://ghp_fLbbSwjMlw3gUs7fgRux8Ha3PIlG9w3ZY3zY@github.com/omnom-code2/streamlit-todo-list-app.git
您现在可以通过输入以下命令将此 URL 添加为本地仓库的远程:
git remote add origin <PAT-embedded URL>
或者在我们的例子中:
git remote add origin https://ghp_fLbbSwjMlw3gUs7fgRux8Ha3PIlG9w3ZY3zY@github.com/omnom-code2/streamlit-todo-list-app.git
这告诉 Git 将一个带有别名“origin”的远程仓库添加到您的本地 Git 配置中,并将其与指定的 PAT 嵌入 URL 关联。这允许您在未来的 Git 命令中使用该别名与远程仓库交互,并自动使用 PAT 进行身份验证。
最后,运行以下命令以执行代码推送:
git push -u origin master
这做了两件事:
-
将您当前所在的本地分支(默认称为“master”)推送到您指定的“origin”远程仓库,从而使您的代码在远程仓库的“master”分支中可用。
-
将您的本地仓库的默认上游分支(
-u是-–set-upstream的缩写)设置为远程仓库上的“master”分支,这样在将来您只需使用git push即可推送代码,无需-u origin main。
注意
我们在这里假设 Git 在您的仓库中创建的默认分支名为“master”。一些 Git 版本使用“main”这个名字。如果您在“master”上使用时遇到错误,请尝试将其替换为“main”。您的命令将变为:“git push -u origin main”。或者,您可以通过输入“git branch”来确定您所在的分支名称(当前分支将被突出显示),并使用它。
如果您导航到您在 GitHub 上创建的仓库,您现在应该能够看到您的代码,如图 5.7 所示。

图 5.7 推送代码后您的 GitHub 仓库
Git 是一个相当复杂的工具,与它一起工作可能会有些复杂。我在本节中描述的只是您部署代码到 Streamlit 社区云所需了解的 Git 的最基本知识,但理想情况下,您应该将其作为常规开发工作流程的一部分来使用,每次完成有意义的工作后都提交代码。这要求您熟悉各种 Git 命令和选项。如果您想为使用 Git 建立一个良好的心理模型,请查看附录 B 中的教程。
告诉社区云在哪里找到您的代码
我们已经在 GitHub 上设置了 Streamlit 代码。我们现在需要告诉 Streamlit 社区云在哪里查找它。
登录到您的社区云账户streamlit.io,然后点击右上角的“创建应用”按钮。如果它询问您是否已有应用,请选择表示您已有的选项。
这应该会带您到如图 5.8 所示的“部署应用”页面。

图 5.8 Streamlit 社区云上的应用程序部署屏幕
填写你推送代码的详细信息,包括:
-
你创建的 GitHub 仓库
-
你推送代码到的分支(例如,从
git push -u origin master推送到的master) -
你应用程序的路径(这将是
todo_list.py,因为这是你在streamlit run命令中使用的文件,并且它位于你的仓库根目录中)
在应用程序 URL 字段中,你可以选择人们可以使用来访问你的应用程序的地址。Streamlit 会建议一个默认 URL,但你也可以将其覆盖为更有意义的内容。在图 5.8 中,我选择了stmlit-todo-list-app-1.streamlit.app。
一些高级设置将在本章的后面部分进行探讨,但你现在可以忽略它们。
就这些了!继续点击“部署!”按钮。
一分钟左右,你的应用程序应该准备好了!任何有互联网连接的人现在都可以访问你选择的地址(在这个例子中是https://stmlit-todo-list-app-1.streamlit.app/)来运行你的待办事项列表应用程序并整理自己的生活。你,改变生活的人!
5.3 部署使用外部服务的应用程序
正如我们在上一节中看到的,将一个简单的应用程序部署到 Streamlit 社区云遵循一个逻辑路径,并且相当直接。我们的待办事项列表应用程序在“简单”这个意义上是相当自包含的。
首先,除了 Streamlit 和 Python 本身之外,它没有其他库或软件依赖于它。其次,它不与任何外部服务或 API 交互。
这对于你创建的大多数实际应用程序来说并不成立。在现实世界中,你将在其他人构建的软件之上构建软件。因此,你的业务逻辑很可能需要第三方库,而这些库并不是 Python 预先安装的。
你还会发现,通常,你的应用程序需要连接到互联网上的服务以执行某些有用的操作。实际上,这是几乎不可避免的。在某个时候,你的应用程序将需要访问英语词典以检查用户输入的单词是否有效,或者货币汇率以显示不同货币的价格,或者来自全球的新闻头条。在这些情况下,你通常需要注册并连接到某种类型的应用程序编程接口(API),以提供你需要的特定服务。
在本节中,我们将向现有的待办事项列表应用程序添加一些复杂性,并查看如何正确部署更改。
5.3.1 一句每日名言来激励用户
想象一下你的待办事项应用程序的用户开始他们的一天。天空晴朗,鸟儿在鸣叫,他们面前有一张干净的画布。diem属于他们去carpe。因此,哼唱维瓦尔第的《四季》中的春之部分,他们开始添加他们的任务。
快进五分钟,他们又添加了第十八件他们刚刚想起今天必须做的事情。肩膀垂下,你的用户现在在脑海中模糊地哼着《星球大战》中的达斯·维达曲调,慢慢地意识到这一天将会是多么艰难的攀登。
好吧,我们不能让这种情况发生!如果我们的应用能在用户需要的时候提供一些安慰,一些激励他们的名言呢?我们难道不能找到一些值得分享的智慧来激励他们——比如像“成功的关键在于开始行动”这样的名言——来鼓励他们应对挑战吗?
当然,你们的一些更愤世嫉俗的用户可能会把咖啡杯扔向屏幕,但嘿,你不可能取悦每一个人。
不论如何,让我们考虑一下在我们的应用中添加每日名言将涉及什么。我们可以在代码中硬编码一些名言,但这似乎很浪费,而且不太可扩展。相反,我们将使用公共 API 来获取名言。
API 及其调用方法
API 实际上只是一个术语,指的是一组指令,允许不同的软件组件相互通信。你可能记得在第三章中,我们为我们的单位转换应用的后端定义了一个“API”,这是一个合约,定义了前端如何与之交互。
一般而言,“API”意味着几乎相同的意思,只不过我们不是指同一应用两个部分之间的合约,而是指定义任何软件如何与特定外部服务交互的合约。
API 可以按照其开发者的意愿进行结构化,而唯一完美理解如何使用它的方法就是阅读文档。话虽如此,你会在实践中遇到一些常见的传统API 模式。
一般来说,你可以通过向以下形式的 URL 发送 HTTP(或 Web)请求来调用此类 API:
https://<base address>/<endpoint>
基础地址是 API 请求中每个请求都包含的常见地址,而端点是针对你请求的类型特定的。例如,一个与天气相关的 API 可能有api.weathersite.com作为基础地址,forecast和history作为天气预测和过去天气数据的端点。
你通常会通过传递键值参数来定制你的 API 请求,这些参数可以是 URL 中的(在 GET 请求的情况下)或者作为请求负载(在 POST 请求中)。你可能还需要通过 HTTP 头传递其他信息,例如 API 密钥。请参阅侧边栏了解 HTTP 工作原理的更多信息。
在我们上面的天气示例中,我们可能需要将日期作为 URL 参数传递给 API,以获取该日期的预报,因此 URL 变为:
https://api.weathersite.com/forecast?date=2024-08-01
然后,API 会返回一个响应,通常是以 JSON(JavaScript 对象表示法)格式返回,你可以在代码中解析它以理解其含义。
在我们的例子中,响应可能如下所示:
{
"high_temp": 72,
"low_temp": 60,
"forecast_text": "Nice and sunny, a good day for the park"
}
侧边栏:HTTP 请求的工作原理
HTTP,即超文本传输协议,是一组定义如何在网络上发送消息的规则。把它想象成计算机在网络上通信时使用的语法结构。
要使用 HTTP 进行通信(例如,当网页浏览器想要与网页服务器通信以检索网页时),一个 HTTP 客户端——比如浏览器——会向网页服务器发送格式正确的 HTTP 请求。然后服务器返回一个 HTTP 响应,这可能包括一个 HTML 文件、一张图片、一些文本,或者几乎任何其他内容。
HTTP 请求包括:
-
一个请求行(在我们的用例中,我们可以将其视为一个 URL)
-
一些 HTTP 头部,它们是键值对,提供了关于如何处理请求的额外信息
-
一个可选的主体,其中包含客户端想要发送到服务器的数据
HTTP 请求有几种不同的类型,但最常见的是 GET 和 POST。
通俗地说,GET 请求是没有主体的“轻量级”请求。关于请求的所有信息都包含在 URL 中。需要发送到服务器的数据被编码为键值对,并附加到 URL 的末尾,如下所示:?param1=value1¶m2=value2...
当你在搜索引擎中输入查询时,这通常是一个 GET 请求。事实上,当你在你浏览器的地址栏中输入一个 URL 并按回车时,你正在向该 URL 发送一个 GET 请求。
POST 请求确实有一个包含额外信息的主体。这个主体通常被称为负载,可能包含诸如输入到网页表单中的信息或上传的文件。
API Ninjas 的报价 API
我们将要使用的 API 来自一个名为 API Ninjas 的网站(https://api-ninjas.com/)。API Ninjas 提供各种服务的免费 API,例如实时商品价格、汇率、URL 信息查找、图像中的面部检测等等。
他们通过他们 API 的付费版本赚钱,如果你每月发出超过 10,000 次调用,就必须使用这个版本。由于我们只是在学习,我们可以用得少一些,所以我们只会使用免费版本。具体来说,我们对其报价 API 感兴趣,根据他们的文档,“提供历史上著名人物的几乎无尽的引言。”
要访问它,你需要在他们那里创建一个账户。前往https://api-ninjas.com/并注册。像往常一样,你需要提供并验证你的电子邮件并创建一个密码。
一旦你登录,找到你的 API 密钥(你将有一个可以访问所有 API 的密钥),这是一个当你连接到 API 时用来识别你的字符集。当我写这篇文章时,在“我的账户”下有一个“显示 API 密钥”按钮。记下这个密钥。
查看在https://api-ninjas.com/api/quotes上的文档,以了解如何连接到报价 API。
这里的基础 URL 是api.api-ninjas.com,这是他们所有 API 中共享的一个常见 URL。Quotes API 的端点是/v1/quotes,它“返回一个(或多个)随机名言”。
文档还提到,你可以传递一个类别参数,以获取特定列表类别中的一句话。还有一个限制参数,允许你指定你想要多少句话,但这是一个付费功能,我们不会使用。默认情况下,API 将返回一句话,这是我们需要的所有内容。
假设我们想要“友谊”类别中的一句话。我们将向以下 URL 发送 GET 请求
https://api.api-ninjas.com/v1/quotes?category=friendship
作为实验,尝试在你的浏览器中访问这个 URL(正如 HTTP 请求侧边栏中提到的,这会发送一个 GET 请求)。如果一切按预期工作,你应该得到一个认证错误消息:
{"error": "Missing API Key."}
这是因为我们还需要提供之前记下的 API 密钥。文档提到,这需要以名为X-Api-Key的 HTTP 头的形式传递。
为了做到这一点,我们将使用一个名为requests的 Python 库。
5.3.2 使用requests库连接到 API
正如我们所见,我们将使用的生成励志名言的 API 是一个基于 Web 的 API,调用它需要我们的代码通过 HTTP 发送和接收消息。
我们可以使用纯 Python 来做这件事,但我们将使用requests,这是 Python 世界中用于 HTTP 通信的首选库。requests提供了大量处理 HTTPrequests的内置功能,并且比其他替代方案具有更友好的工具集。
首先,你需要使用以下命令安装requests:
pip install requests
完成后,使用pip show requests命令验证是否已安装,该命令应显示你拥有的requests版本以及其他一些信息。
使用requests模块发送 HTTP 请求相当简单:我们有简单的get和post方法可以使用。
为了尝试一下,打开 Python shell(在命令行中输入python或python3)并尝试以下操作:
>>> import requests
>>> response = requests.get("https://api.api-ninjas.com/v1/quotes?category=friendship")
>>> response
<Response [400]>
>>> response.text
'{"error": "Missing API Key."}'
在这里,我们只是使用requests.get方法发送我们之前通过浏览器发送的相同请求。此方法返回一个Response类的实例,它封装了 HTTP 服务器对我们请求的响应。
然后,我们访问response的text属性,它以字符串的形式获取响应的主体。正如你所见,字符串与我们之前在浏览器中看到的一模一样。
正如我们从文档中学到的,我们可以使用X-Api-Key头传递我们的 API 密钥。requests.get方法接受一个名为headers的参数,其形式为常规 Python 字典,所以让我们设置一下:
>>> headers={"X-Api-Key": "+4VJR..."} #A
>>> response = requests.get("https://api.api-ninjas.com/v1/quotes?category=friendship", headers=headers)
>>> response.text
'[{"quote": "People come in and out of our lives, and the true test of friendship is whether you can pick back up right where you left off the last time you saw each other.", "author": "Lisa See", "category": "friendship"}]'
A 将之前记下的 API 密钥“+4VJR…”替换
看起来现在它正在工作!当我们通过X-Api-Key头传递从 API Ninjas 获得的 API 密钥时,我们得到了 API 的实际名言!
requests 库的功能远不止这些。在这本书的剩余部分,我们将多次遇到这个模块,但如果你想了解更多,请查看 https://requests.readthedocs.io/ 上的文档。
5.3.3 在我们的应用中整合名言
现在我们已经拥有了添加每日名言功能所需的一切,到我们的待办事项应用中。由于这与管理待办事项的核心功能有很大不同,因此创建一个新的文件 quotes.py 可能是有意义的。
具体来说,我们需要一个函数,比如 generate_quote,它接受一个 API 密钥并从名言 API 中提取名言。
列表 5.1 展示了 quotes.py 可能的样子。
列表 5.1 quotes.py
import requests
API_URL = "https://api.api-ninjas.com/v1/quotes"
QUOTE_CATEGORY = "inspirational"
def generate_quote(api_key):
params = {"category": QUOTE_CATEGORY}
headers = {"X-Api-Key": api_key}
response = requests.get(API_URL, params=params, headers=headers)
quote_obj = response.json()[0]
if response.status_code == requests.codes.ok:
return f"{quote_obj['quote']} -- {quote_obj['author']}"
return f"Just do it! -- Shia LaBeouf"
我们将 API_URL 和 CATEGORY(设置为“inspirational”,这是 API Ninjas 文档告诉我们可以使用的一个类别)放置在文件顶部,作为易于配置的常量,以便如果以后需要更改它们,可以轻松地做到这一点,而无需修改函数本身。
在函数内部,你可能注意到我们没有直接使用 ?category= 符号将类别直接传递到 URL 中,而是做了不同的处理;我们创建了一个类似这样的字典:
params = {"category": QUOTE_CATEGORY}
并将这个字典传递给 requests.get 的 params 参数:
response = requests.get(API_URL, params=params, headers=headers)
另一种方法同样有效,但这种方法更易读。此外,如果我们需要发送 POST 请求(使用 requests.post),params 参数在那里也适用,而你不能使用 URL 方法。
接下来,我们不再使用之前在 Python shell 中使用的 response.text,而是使用 response.json()。这是因为,从我们在 shell 中的早期实验中,我们知道 API 的响应是 JSON 格式:
>>> response.text
'[{"quote": "People come in and out of our lives, and the true test of friendship is whether you can pick back up right where you left off the last time you saw each other.", "author": "Lisa See", "category": "friendship"}]'
JSON 是一种格式,允许你使用文本创建任意层次的数据结构。对于 Python 开发者来说,读取 JSON 数据块实际上相当容易,因为你在 JSON 中存储数据的方式几乎与你在 Python 字面量中编码相同的数据方式相同。
所以在 response.text 的上述值中,你可能已经看出数据被编码为一个列表(因为方括号),它包含一个元素:一个包含三个键:“quote”、“author”和“category”的字典。
然而,response.text 是一个字符串。为了能够在 Python 中轻松访问数据,我们需要将其解析成一个 Python 对象。这就是 response.json() 方法的作用:它将响应的文本主体解析成一个 Python 对象,假设文本是 JSON。
quote_obj = response.json()[0]
一旦我们有了 response.json() 的结果,我们可以将其视为一个正常的 Python 对象。在这种情况下,我们知道数据是一个单元素列表,因此我们使用 response.json()[0] 来获取列表的唯一元素,它是一个包含三个键的字典。
下一个部分检查请求是否顺利通过:
if response.status_code == requests.codes.ok:
return f"{quote_obj['quote']} -- {quote_obj['author']}"
每个 HTTP 响应都附带一个状态码,用于分类请求的结果。你可能之前见过这些。状态码 200 表示“OK”,代码 404 表示“未找到”等。
在上面的代码片段中,if 语句验证状态码对应于成功的结果,或者在这种情况下,返回了一个引用。requests.codes 提供了一种更易读的方式来引用特定的 HTTP 状态码。requests.codes.ok 仅表示代码 200。
如果一切正常,我们构建一个包含引用本身以及作者(用"--"分隔)的字符串。我们使用 quote_obj['quote'] 和 quote_obj['author'] 从我们解析 JSON 结果的 quote_obj 字典中提取这些信息。
return f"Just do it! -- Shia LaBeouf"
最后,如果 API 调用出现错误且状态码不是 200,我们将优雅地失败,并返回 Shia LaBeouf 的永恒励志名言,他是我们这个时代伟大的哲学家之一。
一旦 quotes.py 完成,使用它来创建在主应用程序中显示的引用非常简单。在 todo_list.py 中,在顶部导入 generate_quotes 函数:
from quotes import generate_quote
然后,要显示引用,请在显示任务完成度指标的正上方调用 generate_quote 函数并使用您的 API 密钥,并将结果放入一个 st.info 框中:
st.info(generate_quote("+4VJR..."))
和以前一样,别忘了将 generate_quote 的参数替换为您的实际 API 密钥。
如果您此时使用 streamlit run 运行应用程序,您应该会看到如图 5.9 所示的每日名言出现。

图 5.9 我们待办事项应用程序中的每日名言
太好了!这似乎表明我们的 API 调用完全按照我们的预期工作!我们可以放心,如果我们的用户在组织他们的一天中间想要放弃,他们会有一个每日励志名言来激励他们保持坚强。
但等等!回想一下,Streamlit 每次发生任何交互时都会重新运行您的 全部 代码。这包括通过 generate_quote 进行 API 调用。基本上,每次您的用户添加任务、标记某事为完成或基本上在应用程序中做任何事情时,都会进行一次 API 调用并生成一个新的引用。忘掉每日名言吧,我们在这里处理的是点击名言。
通常,您可能会认为这是“功能,而不是错误”。但请记住,我们每月只有 10,000 次免费的 API 调用。如果我们每次用户点击任何东西时都拉取一个引用,我们很快就会用完这个庞大的配额。
相反,让我们利用我们第四章的老朋友 st.session_state。就像我们保存任务列表以便每次运行时不会重置一样,我们也会保存引用,这样我们只调用 API 一次:
if "quote" not in st.session_state:
st.session_state.quote = generate_quote("+4VJR...") #A
A 再次,将 "+4VJR…" 替换为您之前记下的 API 密钥
我们现在可以用这个来替换我们之前的 st.info 行:
st.info(st.session_state.quote)
如果现在尝试该应用程序,您会看到当您在应用程序中添加任务或执行其他任何操作时,引用不会改变,只有当您刷新页面时才会改变。
那么,我们完成了吗?嗯,还不完全...
5.3.4 使用 st.secrets 安全地访问您的 API 密钥
我们的应用程序现在运行得很好,但它有一个明显的安全漏洞。我们只是直接将 API 密钥嵌入到代码中。
如果我们现在将此代码提交到公共 GitHub 仓库,任何人都可以看到它,因此可以找到我们的 API 密钥!在这种情况下,使用免费 API 的风险相对较低,但想象一下,如果你在付费 API 上——无论是用完了免费配额,还是因为你正在使用一个需要付费的 API!
任何访问你的 GitHub 仓库的人——可能是无意中或出于恶意意图——都可能对你的 API 账户造成破坏,可能花费你数百甚至数千美元。
注意
现在值得记住这个规则:永远不要在代码中存储任何秘密信息,无论是 API 密钥、密码还是任何其他类型的凭证——特别是如果代码将要公开访问。
那么,替代方案是什么?如果我们不能将其放入代码中,我们如何让我们的应用访问 API 密钥?一般来说,有几种方法可以做到这一点:你可以将你的密钥放入一个存储在服务器上的环境变量中,该服务器上运行着你的应用。另一个选择是使用像 AWS Secrets Manager 或 HashiCorp Vault 这样的秘密管理服务,这些服务允许你安全地检索机密信息。
幸运的是,Streamlit 提供了一个简单的方法来保护你的秘密:st.secrets。
与st.session_state类似,st.secrets是一个类似字典的结构,你可以用它来存储作为键值对的秘密信息。
要使一个值可用于st.secrets,你将其放入一个名为secrets.toml的特殊文件中。当你本地开发时,Streamlit 会从secrets.toml文件中提取值。当你推送代码时,请确保不要推送secrets.toml文件。
相反,你将直接在 Streamlit 社区云中配置这些信息,这样你的部署应用就可以在生产环境中访问它。
secrets.toml文件
在你本地仓库的根目录(你之前在这里运行了git init),创建一个名为.streamlit的空目录,并在其中创建一个名为secrets.toml的空文本文件。
接下来,按照列表 5.2 所示,在文件中输入你的 API 密钥。
列表 5.2 secrets.toml
[quotes_api]
api_key = "+4VJR..."
如前所述,在这里使用你的实际 API 密钥。
.toml格式可能对你来说很新。TOML 代表 Tom's Obvious Minimal Language。它是一种用于配置文件的格式,旨在简洁且易于人类阅读,最初由一个名叫——信不信由你——Tom 的人创建。
.toml文件主要由键值对组成,支持一系列简单和复杂的数据类型,包括数组和表格。它们可以通过方括号分隔的节来划分。
列表 5.2 中显示的文件有一个单独的节,quotes_api,其中有一个键值对(键为api_key)。
如果我们在 Python 中读取它(在 Streamlit 之外,你通常会使用toml模块来做这件事),它将映射到以下嵌套字典:
{'quotes_api': {'api_key': '+4VJR...'}}
在 Streamlit 中,st.secrets大致也会包含这些内容。
因此,我们现在可以将todo_list.py中调用generate_quote的部分替换为以下内容:
if "quote" not in st.session_state:
api_key = st.secrets["quotes_api"]["api_key"]
st.session_state.quote = generate_quote(api_key)
在运行时,Streamlit 将读取我们的secrets.toml文件,如上所示,填充st.secrets,我们可以使用st.secrets["quotes_api"]["api_key"]来引用 API 密钥。试一试——你会看到你的应用仍然能够从 API 中获取引言。
注意
技术上,.streamlit 目录应该位于你运行“streamlit run <file_path>”命令的目录中。例如,如果你目前在一个名为“apps”的目录中,你的实际代码位于一个名为“todo_list_app”的文件夹中,你用来运行应用的命令是“streamlit run todo_list_app/todo_list.py”,那么.streamlit 文件夹应该位于“apps”文件夹中,而不是“todo_list_app”文件夹中。当你将代码部署到 Streamlit 社区云时,“streamlit run”实际上会从你的仓库根目录运行,这就是为什么我建议将其放置在那里。在本地开发时,如果你的本地仓库根目录与您通常执行“streamlit run”的文件夹不同,你可能需要“cd”到仓库根目录才能使其工作。
在此之后,你的todo_list.py文件应该如列表 5.3 所示
列表 5.3 todo_list.py的最终状态
import streamlit as st
from task import Task
from quotes import generate_quote
if "task_list" not in st.session_state:
st.session_state.task_list = []
task_list = st.session_state.task_list
if "quote" not in st.session_state:
api_key = st.secrets["quotes_api"]["api_key"]
st.session_state.quote = generate_quote(api_key)
def add_task(task_name: str):
task_list.append(Task(task_name))
def delete_task(idx: int):
del task_list[idx]
def mark_done(task: Task):
task.is_done = True
def mark_not_done(task: Task):
task.is_done = False
with st.sidebar:
task = st.text_input("Enter a task")
if st.button("Add task", type="primary"):
add_task(task)
st.info(st.session_state.quote)
total_tasks = len(task_list)
completed_tasks = sum(1 for task in task_list if task.is_done)
metric_display = f"{completed_tasks}/{total_tasks} done"
st.metric("Task completion", metric_display, delta=None)
st.header("Today's to-dos:", divider="gray")
for idx, task in enumerate(task_list):
task_col, delete_col = st.columns([0.8, 0.2])
label = f"~~{task.name}~~" if task.is_done else task.name
checked = task_col.checkbox(label, task.is_done, key=f"task_{idx}")
if checked and not task.is_done:
mark_done(task)
st.rerun()
elif not checked and task.is_done:
mark_not_done(task)
st.rerun()
if delete_col.button("Delete", key=f"delete_{idx}"):
delete_task(idx)
st.rerun()
5.4 将我们的更改部署到 Streamlit 社区云
通常来说,对已经部署的 Streamlit 应用进行更改很简单。你所需要做的就是提交你的更改,并使用git push将它们推送到你的 GitHub 仓库,Streamlit 社区云将自动获取这些更改。
然而,鉴于我们对应用所做的最新更改,在我们的情况下,这个过程将稍微复杂一些。有三个复杂因素:
-
我们现在使用了一个额外的第三方库,requests,并且我们必须确保在生产环境中安装了正确的版本
-
我们需要确保永远不要将
secrets.toml推送到 GitHub,即使是不小心也是如此 -
由于我们不会将 API 密钥存储在 GitHub 仓库中,我们需要直接在社区云中配置它
5.4.1 使用requirements.txt文件管理 Python 依赖项
当你刚开始将你的应用发布到世界时,可能会让你感到困惑的一件事是,你现在需要管理两个环境:你的开发环境(通常简称为dev),它位于你的笔记本电脑或你用来编写应用的任何计算机上,以及你的生产环境(称为prod),它位于 Streamlit 社区云中。
为了确保你在开发环境中编写的应用在生产环境中按预期工作,你必须确保两个环境配置得完全相同,或者至少确保开发环境和生产环境之间的任何差异都不会影响你的应用。
显然,这意味着在生产环境中运行的 Python 代码应该与你在开发环境中运行的代码完全相同,但这还不够;你的 Python 代码可能依赖于你未编写的软件,例如在我们的待办事项应用程序中,requests 模块,或者实际上,Streamlit 本身——这意味着我们还需要确保这些 依赖项 在生产环境和开发环境中是相同的,或者至少它们在影响应用程序功能的方式上没有差异。
例如,如果你使用了 requests 库在 2.1.1 版本中引入的功能,但你在生产环境中安装的 requests 版本是 2.0.5,你的应用程序在生产环境中将会遇到错误。为了确保你的代码不会出错,你需要在生产环境中安装 requests 版本 2.1.1 或更高版本。技术上,为了确保你的代码不会出错,你需要安装确切的 2.1.1 版本,因为总有可能你使用的功能在未来的版本中被弃用或删除。
在 Python 世界中,我们传统上使用一个名为 requirements.txt 的文件来防止这类差异。requirements.txt 的前提很简单:它中的每一行代表一个 Python 模块和一个字符串,该字符串指定了该模块的版本或一系列版本,这些版本与你的代码兼容。
当将文件输入到 pip install 命令(如 pip install -r requirements.txt)时,pip 将会自动遍历该文件并安装其中每个模块的正确版本。
Streamlit 社区云知道如何处理 requirements.txt 文件,所以如果你在你的 GitHub 仓库中添加一个,它将自动安装使你的应用程序工作的库。
创建一个 requirements.txt 文件
我们的应用程序只有两个外部依赖项——streamlit 和 requests(你可以通过检查所有 .py 文件中的导入语句来确定这一点)。因此,我们的 requirements.txt 文件的内容可以非常简单,如列表 5.4 所示。
列表 5.4 一个简单的 requirements.txt 文件
requests
streamlit
虽然这是一个有效的 requirements.txt 文件,但它并没有说明我们想要安装的版本。
处理这个问题的一个安全方法就是确定我们在开发环境中安装的这些库的版本,并在 requirements.txt 中指定这些确切的版本。
有几种方法可以做到这一点:
-
你可以输入
pip show streamlit和pip show requests命令来查看(以及其他信息)你拥有的每个库的版本。 -
你也可以输入命令
pip freeze > requirements.txt以自动从你在开发环境中安装的模块创建requirements.txt文件——然而,这将会包括你安装的 所有 模块,无论你是否在代码中导入它们。如果你选择这种方式,可能需要删除文件中除了与streamlit或requests对应的行之外的所有行。
无论哪种方式,一旦你使用pip show确定了正确的版本,或者已经使用pip freeze创建了文件,你的requirements.txt文件应该看起来像列表 5.5。
列表 5.5 严格的 requirements.txt
requests==2.31.0
streamlit==1.34.0
我们不必对版本这么严格。我们也可以允许这些库的任何版本,只要它们与我们安装的版本相同或更高。这样,我们就可以让我们的应用程序从这些库可能在未来做出的任何底层改进中受益,同时仍然可以合理地确信它不会崩溃。在这种情况下,我们的文件可能看起来像列表 5.6。
列表 5.6 允许此版本或更高版本的 requirements.txt
requests>=2.31.0
streamlit>=1.34.0
一旦你的文件准备好了,将其保存到本地 Git 仓库的根目录下。
注意
你可能会注意到,即使你没有创建 requirements.txt 文件,你的应用程序在生产环境中也能正常工作。这是因为 Streamlit Community Cloud 预先安装了我们在这里使用的两个库,即 streamlit 和 requests。显然,streamlit 库对于任何应用程序的正常运行都是必需的。由于 streamlit 模块实际上依赖于 requests 模块,因此 requests 也被预先安装了!然而,你无疑会在未来遇到需要安装除这两个库之外的其他库的情况,那时你需要一个 requirements.txt 文件。即使在当前情况下,你也需要这个文件来限制在生产环境中安装的这些模块的确切版本。
5.4.2 使用.gitignore 保护 secrets.toml
你可能会想知道使用secrets.toml文件实际上是如何保护你的 API 密钥不被窥视的——密钥可能技术上不再在你的 Python 代码中,但它仍然存储在你实际代码旁边的文件中。好吧,关键是要在本地保留secrets.toml文件,但永远不要将其提交到我们的 Git 仓库中。
为了确保我们不会意外提交secrets.toml文件,我们将使用一个名为.gitignore的文件。
.gitignore是一个简单的文本文件,位于你的仓库根目录,它告诉 Git 忽略某些文件,永远不要提交它们。
如果文件还不存在,在仓库根目录下创建一个名为.gitignore的空文本文件。然后添加你的.streamlit文件夹的路径。由于.streamlit文件夹也应该在仓库根目录下,你只需将以下内容添加到.gitignore中:
.streamlit/
如果你现在执行git add或git commit,你的secrets.toml文件(以及实际上.streamlit目录中的任何文件)都不会被提交。
注意
Git 可能已经在你将其添加到.gitignore 之前跟踪了你的.streamlit文件夹。在这种情况下,为了将其从 Git 的索引中删除,你可能需要输入以下命令:
git rm -r --cached .streamlit/
到目前为止,你的目录结构应该看起来像以下这样:
Root of your Git repo
├── .streamlit (folder)
│ └── secrets.toml
├── .gitignore
├── requirements.txt
├── quotes.py
├── task.py
└── todo_list.py
将你的更改添加到 Git 中,用消息提交,然后将它们推送到 GitHub。你可以使用以下命令:
git add .
git commit -m "Add quote-of-the-day functionality"
git push
注意,你可以使用上面的简单 git push 命令,因为你之前已经使用 git push -u 将分支的上游设置为远程仓库。
你的更改现在应该反映在你的生产 Streamlit 应用程序中。或者,应该是这样吗?现在导航到你的应用程序的公共 URL,看看图 5.10 中显示的内容。

图 5.10 由于无法访问 API 密钥,我们的应用程序抛出了错误
看起来在我们应用程序正常工作之前,我们还需要进行一些最后的配置。
5.4.3 在社区云中配置秘密
我们已经尽力保护我们的 API 密钥不被泄露,但我们的应用程序在运行时仍然需要它。为了启用此功能,我们需要在部署应用程序的 Streamlit 社区云中进行配置。
我们可以在社区云的 应用设置 屏幕上完成这项操作。你可以通过两种方式访问此屏幕:
-
当你在已部署应用程序的公共 URL 上时,点击右下角的 "管理应用程序"(如图 5.11 所示),然后点击三个垂直点,再点击 "设置"。
-
当你在 streamlit.io 上的社区云登录时,点击应用程序列表中你应用程序旁边的三个垂直点,然后从那里点击 "设置"。
无论哪种方式,一旦你打开了应用程序设置页面,点击侧面板上的 "秘密",就可以进入图 5.11 中显示的屏幕。

图 5.11 Streamlit 社区云中的秘密配置屏幕
在 "秘密" 下的文本框中,只需复制并粘贴你在开发机器上的 secrets.toml 文件中的内容,然后点击 "保存"。
这样,你就完成了所有操作!再次访问你应用程序的公共 URL。现在它应该完全可用,如图 5.12 所示。

图 5.12 已部署到生产环境的完整功能应用程序
到现在为止,你应该已经准备好构建和与公众分享有用的 Streamlit 应用程序了。在下一章中,我们将探讨一个更高级的应用程序,该应用程序专注于数据处理。
5.5 总结
-
部署是将你的应用程序托管并设置起来,以便目标用户可以访问的过程。
-
部署应用程序有多种方式——你可以在本地网络中简单地运行一个服务器,设置一个专用服务器,或者使用云服务提供商。
-
你可以免费将无限数量的应用程序部署到 Streamlit 社区云,但受一些资源使用限制的约束。
-
在社区云中部署涉及创建 GitHub 账户,将本地代码推送到远程 GitHub 仓库,并告诉社区云你的代码位置。
-
你可以通过名为
requests的库使用 HTTP 协议连接到外部服务。 -
结合位于
.streamlit文件夹中的secrets.toml文件,可以使用st.secrets来保护你的凭证安全。 -
确保永远不要提交你的
secrets.toml文件——通过将其添加到.gitignore来防止意外,而是在 Streamlit 社区云的应用设置页面上保存你在本地secrets.toml中存储的信息。 -
您可以使用
requirements.txt文件来指定您的应用程序所依赖的各种 Python 库的版本。 -
在社区云上部署您对已部署应用程序所做的任何更改,就像将更改推送到您的远程 GitHub 仓库一样简单。
第六章:6 适合 CEO 的仪表板
本章涵盖
-
构建一个交互式指标仪表板
-
使用 Pandas 库处理数据
-
缓存函数结果以改进 Streamlit 应用程序性能
-
在仪表板中创建过滤器、面板和其他小部件
-
使用 Plotly 开发数据可视化和图表
你是否曾想过大公司的高管是如何保持对所经营业务的掌控?想象一下像亚马逊、3M 或谷歌这样的公司提供的成千上万的产品和服务复杂性。一个人如何理解这一切?他们如何知道他们的业务是否达到预期,以及哪些领域需要他们的关注?
在管理良好的公司中,答案——或者部分答案——是指标。高管们依赖精心挑选的一套指标,即那些能给他们提供公司业绩高级概述的数字。指标帮助领导者做出明智的决策,在问题成为大问题之前识别潜在问题,并确定公司可以改进或创新的领域。
但仅仅有指标是不够的——它们需要以清晰、易于理解的方式呈现。这就是仪表板发挥作用的地方。一个好的仪表板允许用户探索各种数据切片,将原始数据转化为故事,突出重点,并帮助领导者专注于大局。
在本章中,我们将开发一个为 CEO 设计的仪表板,用于监控其业务的绩效关键指标(KPIs)。到本章结束时,你不仅将知道如何使用 Streamlit 构建一个强大、交互式的指标仪表板,还将了解如何以赋予决策者关注真正重要事情的方式呈现数据。
6.1 一个指标仪表板
Note n' Nib Inc.,这个大家最喜欢的虚构在线办公用品购物网站,正在发生变革!创始人兼首席执行官已经退休,去伊维萨岛享受他的百万财富,而他的继任者,一个来自硅谷的数据狂热大佬,为公司制定了宏伟的计划。
在他的第一次员工会议上,他询问销售副总裁在哪里可以查看最新的销售数字,而副总裁从他的手提箱里掏出一个纸质报告,那是两个月前的。他的热情受到了一点打击。
“我们不是有一个仪表板来跟踪每日销售数据吗?”CEO 问道,担心着答案。他的担忧在副总裁低声提到前任 CEO 拥有一种神奇的直觉,他经常依赖这种直觉来做决策——毕竟,纸笔是商业的核心。
一小时后,销售副总裁召唤你,部门中的新星(也是他处理不想亲自处理的事情的首选),并要求你为他老板构建“那些带有折线图的精美指标页面”。
你知道工程部门正忙于对网站进行大规模改造,所以你不想把这件事推给他们。幸运的是,你一直在尝试这个酷炫的新 Python 框架 Streamlit,你迫不及待想在工作中使用它。
6.1.1 陈述概念
副总裁没有给你太多可以工作的内容,但你已经在公司待了足够长的时间,对老板想要什么有了一个相当好的了解。一如既往,让我们先明确这一点:
概念
一个仪表板,让高管能够查看销售数据的各种切割,并跟踪关键指标以做出决策。
6.1.2 定义需求
我们需要将概念转化为具体的需求。具体来说,需要扩展“销售数据”、“各种切割”和“关键指标”这些短语。
探索数据
你设法让工程部门从他们的系统中导出你认为与你的项目相关的关键数据字段到逗号分隔值(CSV)文件中。你可以从本书的 GitHub 仓库中下载此文件(文件名为sales_data.csv,位于chapter_6目录中)。
下载后,在 Microsoft Excel 或 macOS 上的 Numbers 等电子表格程序中打开它,并检查前几行(如图 6.1 所示)

图 6.1 我们将工作的 CSV 数据的头两行
数据代表了 Note n' Nib 销售的各种产品的销售额,按各种维度如细分市场和类别进行细分。它还包含了关于购买这些产品的顾客的人口统计信息——具体来说,包括他们的性别和年龄组,以及他们来自哪个州。该文件包含从 2019 年 1 月到 2024 年 8 月的数据,并包含度量,如销售额(Note n' Nib 创造了多少收入)、毛利率(销售额中有多少是利润,考虑到成本),以及每行覆盖的交易数量。
例如,这是我们对第一行的解释:2019 年 1 月 1 日,来自加利福尼亚州的 18-25 岁男性购买 Inkstream 钢笔(归类为“书写工具”)在 Note n' Nib 网站上进行了 8 笔交易,为公司带来了 134.91 美元的销售额,扣除成本后剩余 83.91 美元。
这里的主键(唯一标识一行的列集)是date、product_name、segment、category、gender、age_group和state的组合。
与首席执行官交谈后,你还确定他主要关心以下数字:总销售额、毛利率、利润率和平均交易价值。我们将在本章后面详细讨论这些数字的计算方法,但这些都是我们概念中提到的“关键指标”。
首席执行官希望能够看到这些数字在不同产品、类别、年龄组、性别和州之间的差异——即从概念中得出的“各种切割”,以及随时间的变化。
你也思考了数据应该如何表示,最终提出了一组初步的需求。
需求
用户应该能够:
-
汇总查看 Note n' Nib 上销售产品的总销售额、毛利率、利润率和平均交易价值
-
查看这些数字在不同年份、产品、类别、年龄组、性别和州之间的差异
-
通过这些维度筛选数据以探索数据的横截面
-
可视化指标随时间的变化趋势和分解,按每个维度分解
您希望快速交付仪表板的初始版本,而不被额外的功能请求所困扰,因此您也清楚地定义了范围之外的内容。
范围之外的内容
在首次发布时,仪表板将不支持:
-
深入数据以查看特定行
-
预测任何指标的未来的值
-
提供关于指标随时间变化原因的解释
其中一些可以是仪表板的未来扩展,但到目前为止,我们将尝试将其功能限制在观察数据,而不是积极分析或预测数据。
6.1.3 可视化用户体验
在第三章中我们探索的应用程序开发流程的下一步是可视化用户体验。从需求来看,四个关键指标至关重要,因此我们应该突出显示它们,理想情况下以这种方式吸引用户的注意力。需求还提到了基于我们讨论的维度和切割来筛选数据。似乎合理地包括一个用户可以执行此操作的面板。
一图胜千言,因此我们希望提供清晰的数据可视化;需求中指出的趋势和分解可以通过时间序列图表实现,这些图表显示了数字随时间的变化,以及饼图,它告诉您总量如何分解为其组成部分。
图 6.2 展示了基于上述内容设计的模拟界面。

图 6.2 仪表板的 UI 模拟
如您所见,此设计包含了我们讨论的所有内容:四个关键指标以大号字体展示,其值对应于由顶部筛选器和左侧日期范围选择器控制的数据切片。
有一个折线图显示了任何选定的指标随时间的变化,以及一个饼图,它根据选定的维度(如产品类别)分解总销售额或毛利润。
6.1.4 头脑风暴实现方案
在实际编写代码之前,让我们从高层次上绘制仪表板中逻辑和数据流的流程。图 6.3 显示了我们可能选择的一种结构方式。

图 6.3 仪表板中的逻辑和数据流
第一步是从我们的 CSV 文件中读取原始数据并将其加载到内存中。我们可能想要进行一些基本的数据“准备”或“清理”,例如为了方便而重命名列。
在图 6.2 中的模拟用户界面中,顶部的过滤器栏和侧边的日期范围选择器旨在是全局的,即它们影响仪表板中的所有小部件。因此,在将数据传递到仪表板的其它部分之前,我们应该先应用过滤器并选择所需的范围。
在仪表板中,我们将以某种形式显示数据的三个部分:包含总体指标值的指标栏、随时间变化的折线图和饼图。为了获取显示在每个部分中的内容,我们需要对过滤后的数据进行转换,这意味着我们需要对其进行分组、聚合以及其他转换。
线形图和饼图有额外的选项(你需要在两个图表中选择一个指标来显示,并在饼图中选择一个细分维度——例如产品类别或性别),这些选项将作为我们将要应用的转换的输入。
这应该为你提供了一个概述,我们将在本章的其余部分实现的设计。如果你对其中的一些部分还不是非常清楚,不要担心;我们即将更详细地探索每个部分。
6.2 加载数据
在任何展示或可视化数据的程序中,第一步涉及从某个来源获取数据。有时这个来源是用户输入的信息,但通常它是一个外部来源,如数据库或文件。我们将在后面的章节中讨论如何连接到数据库,但现在我们将使用我们之前审查过的 CSV 文件。
在本节中,我们将介绍如何从外部文件加载数据到您的应用程序中,将其保存在内存中,并在 Streamlit 中显示它。在这个过程中,我们将介绍 Pandas,这是数据操作的典范 Python 库。我们还将讨论当需要加载数据非常大时,如何通过缓存来提高应用程序的性能。
6.2.1 Pandas 库
在第一章中,我提到了 Pandas,将其描述为处理表格数据的流行库。实际上,Pandas 已经成为数据生态系统中不可或缺的一部分,很难想象在没有它(或类似的东西)的情况下用 Python 处理数据(无论如何)。
安装 Pandas
你可以像安装任何其他 Python 库一样安装 Pandas:使用pip。现在输入以下命令来设置它:
pip install pandas
一旦运行完成,通过运行pip show pandas来验证一切设置正确,它应该显示有关库的一些信息。
在 Pandas 中探索我们的销售数据
Pandas 围绕数据框的概念,这是一个类似于电子表格中表格的二维、表格式数据结构。它由行和列组成,其中每一列都包含特定类型的数据(例如整数、浮点数、字符串)。数据框允许高效的数据操作和分析,使它们成为处理结构化数据的通用工具。
要查看数据框的实际应用,让我们将我们的销售数据 CSV 文件加载到 Pandas 数据框中。
导航到您下载 sales_data.csv 的本地目录,打开 Python 命令行并输入以下命令:
>>> import pandas as pd
>>> df = pd.read_csv('sales_data.csv')
import pandas as pd 是一个常规的 Python 导入语句。习惯上,我们将 Pandas 模块称为 pd(就像我们使用 st 一样用于 Streamlit)。
>>> df = pd.read_csv('sales_data.csv')
这是我们实际加载 CSV 文件的地方。Pandas 使用 read_csv 方法使这一过程变得非常简单。read_csv 实际上有很多参数可以传递给它(例如,文件是否包含标题,要使用的列名和类型等),但由于所有这些都有合理的默认值,你也可以只传递文件的路径,而无需其他任何内容。
在这里,我们有一个变量 df,它包含一个 Pandas 数据框。
让我们使用 Python 内置的 type 函数来验证这一点,该函数返回特定变量所持有的对象的类型:
>>> type(df)
<class 'pandas.core.frame.DataFrame'>
我们还可以使用数据框的 info() 方法获取更多关于数据框的信息:
>>> df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1035000 entries, 0 to 1034999
Data columns (total 10 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 date 1035000 non-null object
1 product_name 1035000 non-null object
2 segment 1035000 non-null object
3 category 1035000 non-null object
4 gender 1035000 non-null object
5 age_group 1035000 non-null object
6 state 1035000 non-null object
7 sales 1035000 non-null float64
8 gross_margin 1035000 non-null float64
9 transactions 1035000 non-null float64
dtypes: float64(3), object(7)
memory usage: 79.0+ MB
这告诉我们很多信息。数据框有超过一百万行,编号从 0 到 1034999。它有十个列,前七个是 object 类型(基本上是字符串)和最后三个是 float64(或浮点数)。
注意
这些名称可能对你来说很困惑,因为它们不是你可能习惯的常规 Python 类型(如 str、float 等)。这是因为 Pandas 使用其自己的数据类型(或 dtypes),这些类型是从一个名为 numpy 的相关 Python 库派生出来的,用于高效计算。
你还可以选择让 Pandas 使用 Apache Arrow 格式,这对于大型数据集可能更高效。为此,在读取 CSV 时,添加一个 dtype_backend 参数,如下所示:
pd.read_csv('sales_data.csv', dtype_backend='pyarrow')
如果你这样做,你会注意到 .info() 显示的数据类型是 string[pyarrow] 和 double[pyarrow],而不是 object 和 float64。
让我们来看看数据框中的一些内容。由于我们在这页上没有足够的空间打印所有列,我们首先选择其中的一部分:
>>> only_some_cols = df[['date', 'product_name', 'segment', 'state']]
你可以通过对另一个数据框执行某些操作来创建一个新的 Pandas 数据框。这里发生的事情本质上就是这样。当我们使用 Pandas 的用户友好方括号表示法(df[<列名列表>])将列列表传递给数据框时,我们得到一个新的只包含我们传递的列的数据框,然后我们可以将其分配给另一个变量(在这种情况下为 only_some_cols)。
最后,为了查看前几行,我们在较小的数据框上使用 .head() 方法,这显示了前五行中的值。
>>> only_some_cols.head()
date product_name segment state
0 2019-01-01 InkStream Fountain pens CA
1 2019-01-01 InkStream Fountain pens TX
2 2019-01-01 InkStream Fountain pens FL
3 2019-01-01 InkStream Fountain pens UT
4 2019-01-01 InkStream Fountain pens RI
我们将在学习过程中了解更多 Pandas 的知识,所以现在我们先停下来,回到构建我们的仪表板。
6.2.2 读取和显示数据框
我们的仪表板应用程序将比我们之前编写的一些应用程序涉及更多的代码,所以将其分散到多个模块或 .py 文件中是个好主意。
从正确的文件路径加载数据
我们将从一个专门的 Python 脚本文件开始,用于从我们的 CSV 中读取数据。将 sales_data.csv 复制到你打算存放代码文件的文件夹中,然后创建 data_loader.py 并包含列表 6.1 中的内容。
列表 6.1 data_loader.py
import pandas as pd
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
SALES_DATA_PATH = BASE_DIR / "sales_data.csv"
def load_data():
return pd.read_csv(SALES_DATA_PATH)
load_data 函数简单地使用 Pandas 读取 CSV,就像我们在上一节中看到的那样,但这里似乎还有更多的事情发生。我们是如何填充 SALES_DATA_PATH 变量的?既然 data_loader.py 与 sales_data.csv 在同一目录中,为什么我们不能直接将其设置为 "sales_data.csv" 呢?
这里的问题是,Python 中的文件路径被认为是相对于你执行脚本的当前工作目录的,而不是当前执行行的文件所在的目录。
例如,如果你目前位于 '/Users/alice/',你的 .py 文件和 CSV 文件在 '/Users/alice/streamlit_project/' 文件夹中,而你编写 pd.read_csv('sales_data.csv'),Python 将寻找 '/Users/alice/sales_data.csv' 的路径,这个路径是不存在的。
你可以将 CSV 的绝对路径硬编码为 '/Users/alice/streamlit_project/sales_data.csv' 并传递它,但显然这会在你的应用程序部署到不同的计算机上时产生问题,因为文件不会在那个确切的路径中。
不,我们需要的是一个可以引用当前 .py 文件并相对于该文件的路径构造路径的方法。
这就是顶部附近的两行所做的事情:
BASE_DIR = Path(__file__).resolve().parent
SALES_DATA_PATH = BASE_DIR / "sales_data.csv"
__file__ 是 Python 中一个特殊的变量,它包含当前正在执行的文件的路径。
Path 是 pathlib 模块(Python 内置)中的一个类,它允许你以用户友好的、面向对象的方式处理文件路径。Path(__file__) 创建了一个与当前 Python 脚本 data_loader.py 对应的 Path 对象,.resolve() 动态生成 data_loader.py 的绝对路径(无论它是在你的本地机器上还是在生产部署中)。然后 .parent 指的是 data_loader.py 所在的目录。
最后,我们使用 '/' 操作符与 Path 对象一起工作(在这个上下文中不是数学上的除法操作符)来生成我们 CSV 文件的最终路径,存储在 SALES_DATA_PATH 中。
值得注意的是,SALES_DATA_PATH 不是一个字符串,它仍然是一个 Path 对象。幸运的是,Pandas 的 read_csv 知道如何处理这些,所以我们可以直接将其传递给该函数。
使用 st.write 显示数据框
现在我们来使用我们在 Streamlit 应用中刚刚创建的 load_data 函数。创建一个名为 dashboard.py 的应用程序入口点(我们将使用 streamlit run 命令的文件),如列表 6.2 所示。
列表 6.2 dashboard.py
import streamlit as st
from data_loader import load_data
data = load_data()
st.write(data.head(5))
这部分相当直接。data 是一个 Pandas 数据框,包含来自我们 CSV 的数据(因为这就是 load_data 返回的内容)。
最后这一行,st.write(data.head(5))很有趣。我们之前在第二章中简要接触过st.write。Streamlit 的文档将st.write描述为“Streamlit 命令的瑞士军刀”,这相当准确。
你基本上可以将任何类型的对象传递给st.write,并且它会以优雅和合理的方式显示传递的对象。这意味着你可以传递一个字符串,它会在屏幕上写入该字符串,但你也可以传递一个字典,并以良好的格式打印出字典的内容(试试看!)你甚至可以传递内部 Python 对象,如类或函数,并且它会显示有关它们的信息。
st.write在我们的用途中工作得很好,因为我们可以向它传递一个 Pandas 数据框,它会在屏幕上显示数据。data.head(5)返回一个只包含数据前 5 行的数据框,使用st.write来检查数据是否正确加载,如图 6.4 所示——这是执行streamlit run dashboard.py时你会得到的结果。

图 6.4 Streamlit 可以原生显示 Pandas 数据框
你可能会注意到应用程序加载需要几秒钟。这是因为我们的 CSV 文件相当大(超过 90 兆字节),读取它需要一段时间。乍一看,这可能不算什么大问题,但请再次记住,Streamlit 每次屏幕上需要改变时都会重新运行你的整个脚本。
这意味着每次用户更改选择或从文本框中点击出来时,你的应用程序都会重新读取 CSV 文件,这会减慢整个应用程序的速度。除了非常浪费之外,这还会降低仪表板的用户体验,所以让我们接下来解决这个问题。
6.2.3 缓存数据
Streamlit 执行模型的一个影响是,如果没有干预,像读取文件或执行复杂计算这样的昂贵操作会反复执行以获得相同的结果。这对于我们正在构建的数据应用程序来说可能是个问题,因为它们经常依赖于这样的操作。
在前面的章节中,我们看到了一种处理问题的方法:我们可以将数据保存到st.session_state中。这样,数据就只需要在每个用户会话中读取一次,应用程序在用户交互期间就不会变慢。
这是一种不错的解决方案,但它没有解决几个问题:
-
数据仍然需要在每次网页刷新时加载。如果用户在多个标签页中打开仪表板——考虑到平均每个人在任何时候打开的浏览器标签页数量,他们很可能这么做——每次加载都会花费一些时间。
-
即使数据完全相同,仪表板也必须为每个用户重新读取数据。
Streamlit 提供了一种更好的方法来处理这种情况,即st.cache_data。
st.cache_data
st.cache_data 是 Streamlit 缓存或存储慢速函数调用结果的方式,以便下次以相同的参数调用该函数时,它可以直接查找上次调用的存储结果,而不是再次执行该函数。
对于我们的用例,我们可以简单地缓存我们之前编写的 load_data 函数的结果。Streamlit 将存储它返回的 Pandas 数据框,并且随后的应用程序重新运行或甚至页面刷新都不会再次执行该函数。
st.cache_data 使用了与迄今为止我们所见的 Streamlit 元素不同的 Python 构造:它是一个 装饰器,你可以将其视为一种可以接受一个函数或类并为其添加一些新功能,而无需重新编写函数或类的东西。
要在 load_data 函数(在 data_loader.py 中)上应用 st.cache_data,只需在其上方写 @st.cache_data,如下所示:
@st.cache_data
def load_data():
return pd.read_csv(SALES_DATA_PATH)
在这里,st.cache_data 正在 装饰 load_data 函数,通过缓存功能将其转换,以便再次调用时,它将返回之前的缓存结果,而不是再次执行其逻辑。
由于我们现在在 data_loader.py 中引用了 Streamlit 元素,因此我们还需要在顶部包含 Streamlit 的导入:
import streamlit as st
如果你现在运行应用程序,你将短暂地看到一个带有文本 "正在运行 load_data()." 的旋转图标(见图 6.5),然后你的数据框才会显示出来,但如果你重新加载页面,你的数据现在应该可以立即加载。

图 6.5 默认情况下,st.cache_data 在实际运行函数时显示一个带有函数名的旋转图标
看起来缓存正在工作!不过这里有一个问题:当数据发生变化时会发生什么?CEO 想要一个包含 最新 销售数据的仪表板,因此我们可以假设源数据会定期变化——至少每天,甚至更频繁。在我们的例子中,让我们假设工程部门每天都会用包含新数据的版本覆盖现有的 CSV 文件。
如果数据以我们设置的方式缓存,Streamlit 不会在 CSV 更改时拉取更新的 CSV。它只会看到 load_data 的返回值是从第一次运行(可能是几天前)缓存的,并使用那个结果。
我们需要一种方法来设置缓存的 过期日期,本质上告诉它,如果缓存的数据超过某个阈值,则函数需要实际执行并重新缓存结果。
我们使用 st.cache_data 的 ttl 参数来实现这一点。ttl 代表 "存活时间",它设置了缓存数据在 Streamlit 重新执行函数之前有效的时长。
如果我们假设我们的 CSV 数据每天都会变化,我们可以将 ttl 设置为 1 天,如下所示:
@st.cache_data(ttl="1d")
这样,当用户每天运行应用程序时,加载的数据将每天被拉取一次。这次运行可能需要一段时间,但接下来的 24 小时内所有后续的运行都将使用新缓存的数据库,因此运行速度快。
图 6.6 说明了这是如何工作的。

图 6.6 st.cache_data 的工作原理
我们可能还想改变与旋转图标一起显示的消息。名称load_data是我们代码内部的,我们不希望它暴露给用户。我们可以使用st.cache_data的show_spinner参数来更改此消息,使我们的代码变为:
@st.cache_data(show_spinner="Reading sales data...", ttl="1d")
def load_data():
return pd.read_csv(SALES_DATA_PATH)
再次运行应用程序,你会注意到加载指示器已更改为图 6.7。

图 6.7 在 st.cache_data 中设置 show_spinner 参数,当数据正在加载时显示一个用户友好的消息
有价值的是意识到,当用户运行应用程序并且数据被缓存时,缓存值对所有应用程序的用户都可用,而不仅仅是当前用户。有时这可能会导致意外结果(我们可能在后面的章节中遇到一些),但在这个案例中,因为我们想向所有用户显示相同的数据,所以这是一种理想的行为。
在数据加载并可在我们的应用中使用后,让我们继续构建仪表板本身。
6.3 准备和过滤数据
我们仪表板需要满足的一个关键要求是能够检查数据的各种切片,而不是其整体。这是非常合理的;我们的源数据跨越了 5 年以上。今天的用户可能对最近一年的数据更感兴趣,而不是旧年的数据。同样,用户可能只对与“纸制品”类别相关的销售感兴趣。
过滤数据表是只考虑我们关心的数据行,并排除其他所有内容的行为。我们设想中的仪表板有两个区域可以实现这一点:一个过滤器面板,用户可以选择他们想要考虑的每个字段的值,以及一个日期范围选择器。
在本节中,我们将可视化构建这两个组件,并执行使它们功能化的 Pandas 数据处理。
6.3.1 创建过滤器面板
让我们重新审视我们的 UI 原型,重点关注顶部显示的过滤器面板,如图 6.8 所示。

图 6.8 我们 UI 原型中的过滤器面板
该面板基本上是每个我们想要筛选的字段的下拉菜单的集合。
每个菜单可能包含对应于每个字段的唯一值作为选项,用户可以选择多个选项(例如,在图 6.8 中,“州”过滤器选择了 CA、TX 和 FL)。此外,用户可以选择不选择任何选项(例如,图 6.8 中的“性别”),我们可能将此视为该字段没有筛选。
图 6.8 中显示的选择应将数据缩减到对应于 CA、TX 或 FL 的 18-25 岁和 26-35 岁年龄段的客户行。由于没有对“产品名称”进行筛选,数据应包括 Note n' Nib 销售的所有产品。
获取字段唯一值的列表
显然,我们需要一种方法在创建下拉菜单时填充选项。一个可能的方法可能是为每个字段硬编码可能的值列表,但这会带来一些明显的问题:每次有新产品或类别,或者我们分组年龄的方式改变,或者有其他许多原因时,我们都需要更改我们的代码。
一个更好的方法是动态地从数据 本身 获取选项列表。开始一个新的 Python 文件,命名为 data_wrangling.py,并添加一个函数来完成此操作,如列表 6.3 所示。
列表 6.3 data_wrangling.py
def get_unique_values(df, column):
return list(df[column].unique())
get_unique_values 函数接受一个 Pandas 数据框 df 和一个列名 column。它返回数据框中该列的唯一值列表。
当你在 Pandas 数据框中传递一个列名(称为 列选择 操作)并用方括号括起来时,你会得到一个 Pandas 序列,这是一个类似于一维数组的对象(你可以将其视为单列数据框)。
例如,df['age_group'] 会返回一个与 df 中行数相同数量的元素序列,每个元素对应于一行中的 age_group。
在它上面调用 .unique() 会去重元素,并给你一个新的序列,其中只包含数据中的五个或六个不同的年龄组。我们最终使用 list 函数将其转换为常规 Python 列表。
使用 st.multiselect 添加下拉菜单
正如我们所见,用户应该能够选择每个过滤器中任何选项的组合。Streamlit 通过 st.multiselect 提供此功能,这与我们之前遇到的 st.selectbox 非常相似。与 st.selectbox 的情况一样,传递给 st.multiselect 的前两个参数(唯一必需的参数)是标签和选项列表。
例如,你可以编写 st.multiselect('Color', ['blue', 'green', 'red']) 来显示一个标记为 "Color" 的下拉菜单,你可以从中选择一个或多个颜色。
按照我们将代码分散到几个模块的方法,我们将创建一个新的模块用于过滤器面板,并将其命名为 filter_panel.py。
列表 6.4 显示了 filter_panel.py 的起始草稿。
列表 6.4 dashboard.py
import streamlit as st
from data_wrangling import get_unique_values
filter_dims = ["age_group", "gender", "category", "segment",
"product_name", "state"]
def filter_panel(df):
with st.expander("Filters"):
filter_cols = st.columns(len(filter_dims))
for idx, dim in enumerate(filter_dims):
with filter_cols[idx]:
unique_vals = get_unique_values(df, dim)
st.multiselect(dim, unique_vals)
filter_dims 包含我们想要过滤的数据框中的字段列表。
filter_panel 函数实际上是显示下拉菜单的部分。它接受一个数据框作为输入,并使用每个字段的唯一值来渲染下拉菜单。
到目前为止,一些代码应该看起来很熟悉。我们希望并排显示下拉菜单,因此我们使用 st.columns(len(filter_dims)) 来创建我们想要过滤的字段数那么多显示列。
对于每个字段,我们使用来自 data_wrangling.py 的 get_unique_values 函数获取唯一值,并使用它们来填充下拉菜单:
with filter_cols[idx]:
unique_vals = get_unique_values(df, dim)
st.multiselect(dim, unique_vals)
列表 6.4 还展示了使用一个新的 Streamlit 小部件 st.expander,它是一个可折叠的框,用户可以根据需要展开或收缩。这很有意义,因为用户可能不想总是看到筛选器。隐藏它们的选项是个好主意,这样他们就可以专注于实际显示的数据。
让我们在 dashboard.py 中编辑主仪表板,包括 filter_panel:
...
from filter_panel import filter_panel
data = load_data()
filter_panel(data)
st.write(data.head(5))
这应该会产生图 6.9 中的输出。

图 6.9 默认情况下,我们的 Streamlit 应用布局是居中的,如果水平显示的内容很多,这可能会成问题
我们可以立即看到的一个问题是,有六个筛选字段时,每个单独的筛选器似乎都挤压在其邻居旁边。
此外,当前筛选字段的名称是来自 CSV 的原始列名,包括下划线。一个更精致的设计会展示为用户友好的标签,例如“年龄组”,而不是像“age_group”这样的技术标识符。
解决宽度问题
注意图 6.9 中筛选面板两侧有很多未使用的空白空间。默认情况下,Streamlit 应用有一个“居中”布局,其中应用的主体在窗口中心水平渲染,两侧是空白。
这通常没问题,但鉴于我们正在构建的 UI 如此密集,屏幕空间变得非常宝贵。幸运的是,Streamlit 允许我们改变这一点,并使用更多的屏幕空间。我们可以通过在 dashboard.py 中使用 st.set_page_config 方法来实现这一点。
st.set_page_config(layout='wide')
重要的是,这只有在它是应用程序中执行的第一个 Streamlit 命令时才有效,所以请确保它在 dashboard.py 的顶部,紧随导入之后。
显示字段的用户友好标签
为了将原始字段名称替换为标签,我们可以在字典中保留原始名称及其相关标签之间的映射,并在需要显示字段名称时查找标签。但这听起来相当繁琐,所以让我们直接将数据框中的字段重命名为用户友好的名称。
很可能需要对数据进行一系列类似的“清理”修改。我们将把这些修改打包成一个名为 prep_data 的函数,放在 data_wrangling.py 文件中:
...
from data_loader import load_data #A
...
def clean_column_names(df):
df.columns = df.columns.str.replace('_', ' ').str.capitalize()
return df
@st.cache_data(show_spinner="Reading sales data...", ttl="1d") #B
def prep_data():
return clean_column_names(load_data())
A 不要忘记从其他模块导入我们需要的函数
B 将 st.cache_data 装饰器移动到 prep_data 而不是 load_data 上
这里我们定义了一个名为 clean_column_names 的函数,该函数将数据框中每个列名中的下划线替换为空格,并将其大写。df.columns 返回数据框的列名,.str.replace() 和 .str.capitalize() 是相应的字符串操作。我们使用 .str 将替换和大小写操作一次性应用于 df.columns 中的每个元素,这在 Pandas 中会经常看到。
目前,prep_data函数内部调用load_data,然后简单地返回对返回的数据帧应用clean_column_names的结果,但我们将稍后添加更多逻辑。
我们还将st.cache_data装饰器移动到prep_data函数中(从data_loader.py中的load_data中移除),因为prep_data应该包含我们对数据进行的基本操作。在此之后,Streamlit 将仅在准备数据后缓存结果,而不是在加载数据后立即缓存。
为了闭合循环,在dashboard.py中,将data = load_data()行替换为data = prep_data()。
最后,在filter_panel.py中,修改filter_dims以使用新抛光的字段名称:
filter_dims = ["Age group", "Gender", "Category", "Segment",
"Product name", "State"]
保存并重新运行以查看图 6.10 中所示的结果

图 6.10 带有用户友好字段名称和宽布局的过滤器面板
仪表板现在使用屏幕的全宽,我们正在使用标签作为过滤器。
注意
由于本章代码量较大,且打印页面上可用的空间有限,因此在这里提供代码片段时,我们将主要关注特定文件中代码的变化,而不是像前几章那样重新生成整个文件。然而,在任何时候,如果您遇到困难并希望将您的代码与该点应具有的代码进行比较,您可以在本书的 GitHub 仓库(github.com/aneevdavis/streamlit-in-action)中查看代码的完整进度快照,在 chapter_6 文件夹下。例如,您应该比较的代码文件目前在 in_progress_4 目录中。
应用过滤器到数据
到目前为止,我们已经创建了与过滤器对应的 UI 元素,但实际上还没有将它们应用到数据上。
为了做到这一点,让我们首先考虑我们应该如何表示从过滤器面板获得的输出。为了能够应用过滤器,我们需要查找用户在每个维度上选择的过滤器值。这是一个使用字典的好用例,其中每个字段名称作为键,所选选项列表作为键的值。
我们当前的filter_panel函数(来自filter_panel.py)只是简单地显示过滤器栏,但我们希望修改它,使其实际上返回一个我们可以用于进一步处理的字典。
def filter_panel(df):
filters = {}
with st.expander("Filters"):
filter_cols = st.columns(len(filter_dims))
for idx, dim in enumerate(filter_dims):
with filter_cols[idx]:
unique_vals = get_unique_values(df, dim)
filters[dim] = st.multiselect(dim, unique_vals)
return filters
虽然我们之前没有利用这一点,但st.multiselec实际上返回了用户在 UI 中选择的选项列表。正如突出显示的行所示,我们现在使用dim(字段名称)作为键,将此返回值存储在filters字典中,并在最后返回过滤器。
接下来,我们需要使用字典来生成用户想要的数据切片。由于这涉及到数据操作操作,让我们将此功能放在data_wrangling.py中,在名为apply_filters的函数中。
def apply_filters(df, filters):
for col, values in filters.items():
if values:
df = df[df[col].isin(values)]
return df
apply_filters 接受 df(一个 Pandas 数据框)和 filter_panel 返回的过滤器字典。它遍历过滤器中的每个键值对,并通过使用语句迭代修改数据框:
df = df[df[col].isin(values)]
这值得稍微分解一下。在 Pandas 中,方括号非常灵活。当你将列名传递给数据框变量后面的方括号(如 df['Age group'])时,它返回一个 Pandas 系列如我们所见。
如果相反,你传递一个 Pandas 布尔系列(每个项目都是布尔值的系列),它将匹配系列编号元素与数据框的有序行,只返回对应布尔值为 True 的行。这被称为 布尔索引。
你可以在上面的行中看到这两种用法:
df[col] 进行列选择,在 df 中选择要过滤的列 col。
.isin(values) 对列执行逐元素操作,检查列中的每个值是否存在于用户选择的下拉选项列表 values 中。这返回另一个包含 True/False 值的系列,对应于 df[col] 中的每个项目。这是一个 向量化 计算的例子,这是 Pandas 性能的主要原因。
最后,我们使用前一步获得的布尔系列进行布尔索引,生成一个新的过滤后的数据框并将其分配给 df。
实际上,每次执行时,行 df = df[df[col].isin(values)] 都会过滤数据框,只包含当前查看的列包含用户选择的值的行。
if values 部分确保了如果用户没有为某个字段选择任何值,我们不会进行过滤,从而正确地实现了我们的要求。
要看到这个动作,请对 dashboard.py 进行必要的修改:
...
from data_wrangling import apply_filters, prep_data
...
data = prep_data()
filters = filter_panel(data) #A
main_df = apply_filters(data, filters)
st.write(main_df.head(5))
A 我们现在将 filter_panel(data) 返回的字典放入一个变量中
我们现在正在捕获过滤器字典,使用 apply_filters 创建一个新的数据框 main_df,并显示该数据框而不是数据。这应该会给我们带来图 6.11 的结果。

图 6.11 过滤面板中的选择正确地过滤了显示的行(有关完整代码的快照,请参阅 GitHub 上的 chapter_6/in_progress_5)。
如你所见,显示的数据框只显示与过滤面板中用户选择对应的行(18-25, M, Staples, CA)。
还有另一种类型的过滤器需要应用:日期范围!让我们接下来处理这个问题。
6.3.2 创建日期范围选择器
与往常一样,Streamlit 提供了一种简单的方法来允许用户选择日期范围。按照其典型的直观命名方式,我们想要的部件被称为 st.date_input。
st.date_input 接受一个标签、默认值、最小值和最大值等。用户可以选择单个日期或日期范围。
例如,为了允许用户选择一个默认为今天的单日日期:
date = st.date_input("Select a date", value=datetime.date.today())
为了在默认的开始和结束日期之间启用日期范围选择:
range = st.date_input("Select a date range", value=(datetime.date(2023, 1, 1),
datetime.date(2023, 12, 31)))
我们将日期范围选择器放在一个名为 date_range_panel.py 的新文件中,如列表 6.5 所示。
列表 6.5 date_range_panel.py
import streamlit as st
from datetime import date, timedelta
# Hardcode this to the last date in dataset to ensure reproducibility
LATEST_DATE = date.fromisoformat("2024-08-31")
THIRTY_DAYS_AGO = LATEST_DATE - timedelta(days=30)
def date_range_panel():
start = st.date_input("Start date", value=THIRTY_DAYS_AGO)
end = st.date_input("End date", value=LATEST_DATE)
return start, end
date_range_panel 函数显示两个日期选择器小部件——一个用于范围的开始,一个用于结束——并返回用户选择的日期。我们选择在这里使用两个单独的单日输入而不是单个范围输入,以防止在尚未选择范围的结束日期时出现临时错误。
对于默认日期范围值,我们显示了一个月前的开始日期和今天的结束日期,使用变量 THIRTY_DAYS_AGO 和 LATEST_DATE。由于我们处理的是一个静态数据集,我们将 LATEST_DATE 硬编码为 CSV 中可用的特定日期。如果我们处理的是实时或定期更新的数据,我们将用 LATEST_DATE = date.today() 替换它。THIRTY_DAYS_AGO 是通过从 LATEST_DATE 中减去 30 天,使用 datetime 模块中的 timedelta 类获得的。
LATEST_DATE 和 THIRTY_DAYS_AGO 的值是来自内置 datetime 模块的 date 类型。st.date_input 理解这种类型,甚至返回它。因此,date_range_panel 返回的 start 和 end 变量也都是 date 类型。
实际上,让我们现在利用这些值通过编辑 data_wrangling.py 来使用它们:
import pandas as pd
import streamlit as st
...
@st.cache_data(show_spinner="Reading sales data...", ttl="1d")
def prep_data() -> pd.DataFrame:
df = clean_column_names(load_data())
df['Day'] = pd.to_datetime(df['Date'])
return df
def get_data_within_date_range(df, start, end):
if start is not None and end is not None:
dt_start, dt_end = pd.to_datetime(start), pd.to_datetime(end)
return df[(df['Day'] >= dt_start) & (df['Day'] <= dt_end)]
return df
def get_filtered_data_within_date_range(df, start, end, filters):
df_within_range = get_data_within_date_range(df.copy(), start, end)
return apply_filters(df_within_range, filters)
我们在这里做了一些更改:
除了清理列名之外,prep_data 还添加了一个名为 'Day' 的新列,它是将 pd.to_datetime() 应用到现有的 'Date' 列的结果。回想一下,'Date' 列目前是一个不透明的 "object" 类型。pd.to_datetime() 将其转换为 datetime[ns] 类型,这可以通过 Pandas 进行优化。
我们在 get_filtered_data_within_date_range 函数中包装了对 apply_filters 的调用,该函数首先接受日期范围的开始和结束日期,并使用它们来调用 get_data_within_date_range。
get_data_within_date_range 是实际在我们的 dataframe 上应用日期范围过滤器的函数。该函数的第一行执行日期格式转换:
dt_start, dt_end = pd.to_datetime(start), pd.to_datetime(end)
这是因为 Pandas 使用 datetime[ns] 类型来表示日期。这与 start 和 end 所在的 Python 的 date 类型不同,并且在 dataframe 操作中更有效。
return df[(df['Day'] >= dt_start) & (df['Day'] <= dt_end)]
这又是布尔索引,类似于我们在 apply_filters 函数中遇到的,但我们使用两个条件(df['Day'] >= dt_start and df['Day'] <= dt_end)的组合来过滤 dataframe,并用 & 连接它们,这是 Pandas 的元素级逻辑 AND 运算符。
我们还需要再次更新 dashboard.py:
import streamlit as st
from data_wrangling import get_filtered_data_within_date_range, prep_data
from filter_panel import filter_panel
from date_range_panel import date_range_panel
st.set_page_config(layout='wide')
with st.sidebar:
start, end = date_range_panel()
data = prep_data()
filters = filter_panel(data)
main_df = get_filtered_data_within_date_range(data, start, end, filters)
st.write(main_df.head(5))
我们将日期范围面板放置在侧边栏中(你现在应该非常熟悉了)。
由于 apply_data 现在包含在 get_filtered_data_within_date_range 中,我们将这个包装函数的结果赋值给 main_df。
图 6.12 显示了我们的新日期范围面板。

图 6.12 显示在侧边栏中的日期范围输入(在 GitHub 仓库的 chapter_6/in_progress_6 中查看完整的代码快照)
我们的仪表板正在慢慢成形!然而,目前对 CEO 来说并不太有用,因为它没有显示任何总结信息或指标。接下来就是这些内容!
6.4 计算和显示指标
想象你正在经营一家企业。你怎么知道它是否繁荣或挣扎,它是否即将飞涨或崩溃?
一个明显的答案是你应该查看公司赚了多少钱以及它赚取的利润总额。你可能还想知道你的收入增长的速度。如果你是《鲨鱼坦克》中 Mr. Wonderful 的粉丝,你可能还会关注一些更专业的数字,比如获取一个客户需要花费你多少钱。
所有这些数字都被称为指标。指标是有用的,因为它们可以帮助你将一个庞大业务(或任何项目,实际上)的所有复杂性简化为几个数字。如果一个积极的指标(如利润)正在上升,或者一个消极的指标(如成本)正在下降,这意味着事情进展顺利。如果情况相反,可能需要做出一些改变。
在本节中,我们将计算并显示 Note n' Nib 的 CEO 关心的指标。为此,我们首先了解这些指标的含义以及如何计算它们。然后,我们将设置一种可扩展的方式来定义仪表板使用的新指标,并在仪表板中突出显示它们。
6.4.1 计算指标
回到我们的需求,我们有四个我们关心的指标:总销售额、毛利率、利润率和平均交易价值。
让我们尝试通过我们的数据来理解这些。回想一下,我们的源 CSV 文件为每个日期、产品(及其相关的细分和类别)、性别、年龄组和州组合创建一行。对于每一行,它给我们三个数值字段:销售额、毛利率和交易。
交易是指购买一定数量商品的单一购买,而“交易”这个数字指的是一行中代表它们的数量。销售额很容易理解:它是 Note n' Nib 从这些交易中收集到的美元金额。毛利率是指利润,即销售额减去公司为获取所售商品而支付的成本。
基于此,让我们弄清楚如何计算我们 CSV 文件中任何给定部分的指标:
-
总销售额仅仅是销售额列的总和。
-
同样,毛利率是毛利率列的总和。
-
利润率是将毛利率表示为总销售额的百分比,因此它被计算为总销售额除以毛利率乘以 100。
-
平均交易价值是指 Note n' Nib 每次交易赚取的金额,因此我们可以将其计算为总销售额除以交易列的总和。
让我们考虑一个快速示例来阐明这一点。假设您有以下行:
| Date | Product | State | Sales | Gross Margin | Transactions |
|------------|--------------|-------|--------|--------------|--------------|
| 2024-01-01 | Fountain Pen | CA | 500 | 200 | 10 |
| 2024-01-01 | Notebook | TX | 300 | 120 | 5 |
| 2024-01-01 | Pencil | NY | 200 | 80 | 8 |
-
总销售额 是 $500 + $300 + $200 = $1000
-
毛利润 是 $200 + $120 + $80 = $400
-
利润率百分比 是 $400 / $1000 = 0.4 = 40%
-
平均交易价值 是 $1000 / (10 + 5 + 8) = $1000 / 23 = $43.48
6.4.2 设置指标配置
在软件开发中,您会遇到的一个反复出现的主题是,保持设计合理地 通用 是一个好主意。通过这种方式,我的意思是,如果您在以非常具体的方式(例如硬编码值列表)和以灵活的方式(从配置文件中填充列表)编写代码之间有选择,那么通常更好的做法是后者。
更通用的设计使您的代码更容易适应未来的变化,并且更容易维护。我们在第三章开发单位转换应用程序时看到了这一点,在那里我们选择使用配置文件而不是直接在我们的代码中放置转换系数。
这正是我们将要在我们的仪表板上设置指标的方式——定义一个配置文件,该文件定义了如何计算它们。
正如我们在第三章中所做的那样,我们将首先创建一个数据类——Metric——来保存我们想要配置的对象。让我们将其放入一个名为 metric.py 的新文件中(参见列表 6.6)。
列表 6.6 metric.py
from dataclasses import dataclass
@dataclass
class Metric:
title: str
func: callable
type: str
Metric 类包含一个标题,这是我们将在界面上显示的标签,以及一个类型,它指示应该如何格式化(例如,类型为 "dollars" 将指示我们的应用程序在数字前加上一个 '$' 符号)。
它还有一个名为 func 的成员,显然是一个 可调用对象。可调用对象本质上只是函数,而 func 的意图是作为一个函数,它接受一个 Pandas 数据框对象并计算指标值。
为了真正理解这一点,让我们看看 Metric 类的对象是如何在我们的配置文件 metric_config.py 中定义的,如列表 6.7 所示。
列表 6.7 metric_config.py
from metric import Metric
def margin_percent(df):
total_sales = df["Sales"].sum()
return df["Gross margin"].sum() / total_sales if total_sales > 0 else 0
def average_transaction_value(df):
total_sales = df["Sales"].sum()
return total_sales / df["Transactions"].sum() if total_sales > 0 else 0
metrics = {
"Total sales": Metric(
title="Total sales",
func=lambda df: df["Sales"].sum(),
type="dollars"
),
"Gross margin": Metric(
title="Gross margin",
func=lambda df: df["Gross margin"].sum(),
type="dollars"
),
"Margin %": Metric(
title="Margin %",
func=margin_percent,
type="percent"
),
"ATV": Metric(
title="Average transaction value",
func=average_transaction_value,
type="dollars"
)
}
display_metrics = ["Total sales", "Gross margin", "Margin %", "ATV"]
将您的注意力转向变量 metrics,它是一个字典,以我们每个指标的名称作为键,以及相应的 Metric 对象作为值。再次强调,这与第三章中的 unit_config.py 类似,在那里我们做了几乎同样的事情。
让我们检查字典中的第一个条目:
metrics = {
"Total sales": Metric(
title="Total sales",
func=lambda df: df["Sales"].sum(),
type="dollars"
),
...
这是 "总销售额" 指标,它有一个合理的显示标签作为其 title,以及 "dollars" 作为其 type。这里的 func 是一个 lambda 表达式(您可能还记得,在第二章中,它是一个匿名的一行函数)。它接受一个参数——df,一个 Pandas 数据框,并使用表达式计算总销售额:
df["Sales"].sum()
df["Sales"],正如我们之前讨论的,从 df 中选择 "Sales" 列,而 .sum() 将其中的所有值加起来以获得指标的最终值。
字典中定义的其他指标相当相似,它们的计算应该在上一节的讨论之后有意义。"Margin %" 和 "ATV" 指标不使用 lambda 表达式作为它们的 func。
相反,在这些情况中,func 指向上面定义的常规函数(margin_percent 和 average_transaction_value)。由于这些指标都是比率,我们需要处理分母为零的可能性,以防止除以零错误:
def margin_percent(df):
total_sales = df["Sales"].sum()
return df["Gross margin"].sum() / total_sales if total_sales > 0 else 0
我们配置文件的底部有如下一行:
display_metrics = ["Total sales", "Gross margin", "Margin %", "ATV"]
变量旨在存储我们将在指标栏中实际显示的指标。这看起来可能有点无意义,因为我们只是在 metrics 字典中列出所有键。然而,如果我们决定包含更多指标而不想显示所有指标,或者想按特定顺序显示它们,这可能会很有用。我们稍后会看到 display_metrics 的实际用法。
6.4.3 格式化指标
在 Metric 类中捕获的信息之一是指标的“类型”,主要用于格式化。
在我们的配置文件中,我们的三个指标是“美元”类型,而“利润百分比”是“百分比”类型。
现在我们将定义格式化逻辑,并在其自己的模块 formatting.py 中实现(参见列表 6.8)。为此,我们将使用一个名为 humanize 的第三方库(按照常规方式安装,即运行 pip install humanize),它提供了一些不错的格式化功能。
列表 6.8 formatting.py
import humanize
def format_metric(value, metric_type):
if metric_type == "dollars":
return f'${humanize.metric(value)}'
elif metric_type == "percent":
return f'{round(value * 100, 1)}%'
return f'{value}'
format_metric 函数接受一个数值和一个指标类型,并返回一个格式化的显示字符串。
我们刚刚安装的 humanize 库在格式化美元类型指标时发挥作用。由于 Note n' Nib 是如此受欢迎的零售商,其销售额以百万计。如果我们被要求显示的原始收入数字是 $25,125,367,我们宁愿不向首席执行官展示精确值。像 "$25.1m" 这样的缩写版本将起到作用,同时减少用户的认知负担。
这正是 humanize.metric 方法所做的事情。给定一个原始数字,它将返回一个经过四舍五入的数字,具有合理的低精度和后缀(“k”代表千,“m”代表百万等)。我们手动添加“\(”符号以获得 f-string `f'\){humanize.metric(value)}'`。
对于百分比指标,我们将实际值乘以 100,将其从分数转换为百分比,并在添加“%”符号之前将其四舍五入到一个小数点。
对于其他任何内容,我们不做任何格式化,只是按原样打印值。
6.4.4 显示指标
在所有构建块就绪后,我们现在可以继续在 Streamlit 中创建指标栏。为此,创建另一个名为 metric_bar.py 的 Python 模块,其中包含列表 6.9 中的代码。
列表 6.9 metric_bar.py
import streamlit as st
from metric_config import metrics, display_metrics
from formatting import format_metric
def get_metric(df, metric):
return metric.func(df)
def metric_bar(main_df):
with st.container(border=True):
metric_cols = st.columns(len(display_metrics))
for idx, metric_name in enumerate(display_metrics):
metric = metrics[metric_name]
with metric_cols[idx]:
value = get_metric(main_df, metric)
formatted_value = format_metric(value, metric.type)
c1, c2, c3 = st.columns([1, 3, 1])
with c2:
st.metric(metric.title, formatted_value)
metric_bar 函数遍历我们标记用于显示的指标列表(来自 metric_config.py 的 display_metrics),为每个指标在显示列中添加一个 st.metric 元素。
这就是它的核心内容,但这里有几个有趣的部分。
第一点是使用st.container,这是一个新的 Streamlit 元素。在这里,我们只是用它将指标条形图放入一个带有边框的框中:
with st.container(border=True):
然而,st.container还有更多用途。其中一个用例是我们想要显示“顺序外”的元素,或者以不同于代码中编写的顺序显示。我们将在本书的后面遇到这种情况。
我们使用get_metric函数来提取一个指标值,给定一个数据框和一个指标对象。由于每个指标对象已经有一个func成员来定义如何进行这一操作,因此get_metric的主体非常简单,只需调用它即可:
def get_metric(df, metric):
return metric.func(df)
最后有趣的部分是这里:
c1, c2, c3 = st.columns([1, 3, 1])
with c2:
st.metric(metric.title, formatted_value)
你可能会觉得这相当奇怪。我们已经为指标定义了一个列(metric_cols[idx]),但看起来我们似乎将那个列分成了三个子列,然后将st.metric小部件只放在第二个中!这有什么意义呢?
嗯,这实际上是一个布局技巧。不幸的是,截至写作时,Streamlit 没有提供一种在不使用 HTML 的情况下在列内水平居中项的出色方法。因此,在这里,我们在主列内创建了三个列,其中第一和第三个是等宽的空白列,第二个则包含实际内容。整体效果是,第二个子列的内容在主列中看起来是居中的。
因此,我们只需要快速更新dashboard.py,然后我们就可以继续前进:
...
from metric_bar import metric_bar
...
main_df = get_filtered_data_within_date_range(data, start, end, filters)
if main_df.empty:
st.warning("No data to display")
else:
metric_bar(main_df)
我们已经从显示中移除了样本数据框的行,并用指标条形图替换了它们。重新运行仪表板以找到图 6.13 中的输出。

图 6.13 指标条形图显示了关键指标的汇总(完整的代码请参阅 GitHub 仓库中的 chapter_6/in_progress_7)
接下来,让我们解决仪表板的一些可视化组件。
6.5 构建可视化
人类天生具有直观的视觉能力。当你试图通过数据传达信息时,使用图表而不是数字表格通常更容易记住,并且点击速度更快。这对于忙碌的执行者尤为重要,他们可能需要处理众多事务,并需要快速了解业务以便做出决策。
在本节中,我们将以折线图的形式将可视化添加到仪表板中,以显示我们跟踪的指标随时间的变化,以及以饼图的形式显示这些指标在所选维度上的分解。在每种情况下,我们将使用 Pandas 将数据整理成易于可视化的形式,使用名为 Plotly 的库生成实际图像,并使用 Streamlit 显示它们。
6.5.1 创建时间序列图
时间序列简单来说就是一系列在固定时间间隔记录的数据点,展示了特定指标或变量随时间的变化情况。这对于发现趋势、季节性和异常值至关重要,这些信息可以指导决策。
你可以将简单的时间序列视为具有两个变量的数据序列——一个代表日期或时间,另一个代表我们正在跟踪的度量。
例如,这是一个时间序列:
+------------+-------+
| Date | Sales |
+------------+-------+
| 2024-01-01 | 120 |
| 2024-01-02 | 135 |
| 2024-01-03 | 142 |
| 2024-01-04 | 130 |
| 2024-01-05 | 155 |
+------------+-------+
从我们的数据框中获取时间序列
回想一下,我们正在处理的数据有许多不同的字段——日期、产品名称、性别、销售额等等。在我们将数据传递给我们将要构建的可视化之前,我们必须将其转换成时间序列的特定形状。
在我们的数据中有一个 "Day" 字段,但数据框中特定的一行代表特定组合的性别、年龄组、产品名称等的值。我们需要的是,给定我们完整数据框的任何特定部分(这就是我们的过滤器给出的),能够将数据聚合到 "Day" 级别。
例如,一个用户可能应用了 "State = CA" 的过滤器,给我们以下数据片段(为了清晰起见简化,排除了其他字段):
+------------+-------+--------+-----------+------------+
| Day | State | Gender | Product | Sales |
+------------+-------+--------+-----------+------------+
| 2024-08-01 | CA | M | RoyalQuill| 1500 |
| 2024-08-01 | CA | M | GripLink | 1300 |
| 2024-08-02 | CA | M | RoyalQuill| 1600 |
| 2024-08-02 | CA | M | GripLink | 1200 |
+------------+-------+--------+-----------+------------+
我们的时间序列应仅包含 "Day" 和 "Total sales",因此我们需要将每个日期在州、性别和产品上的销售额加起来:
+------------+--------+
| Day | Sales |
+------------+--------+
| 2024-08-01 | 2800 |
| 2024-08-02 | 2800 |
+------------+--------+
在 Pandas 中做这个,假设我们的数据框名为 df,我们可以编写以下代码:
grouped = df.groupby('Day')
data = grouped.apply(lambda df: df['Sales'].sum(), include_groups=False).reset_index()
让我们分解一下。df.groupby('Day') 会给我们一个分组后的数据框,你可以想象它内部表示如下:
+------------+------------------------------------------------------+
| Day | Grouped Rows |
+------------+------------------------------------------------------+
| 2024-08-01 | (2024-08-01, CA, M, RoyalQuill, 1500) |
| | (2024-08-01, CA, M, GripLink, 1300) |
| 2024-08-02 | (2024-08-02, CA, M, RoyalQuill, 1600) |
| | (2024-08-02, CA, M, GripLink, 1200) |
+------------+------------------------------------------------------+
接下来,考虑以下行 grouped.apply(lambda df: df['Sales'].sum(), include_groups=False)
apply 方法是 Pandas 中一个非常强大的结构,它允许你将函数应用于数据框的每一行或每一列,或者应用于序列的值。当用于如上所述的分组数据框时,它允许你分别对每个组执行操作。
在这种情况下,lambda 函数 lambda df: df['Sales'].sum() 对每个组(对应于特定的某一天)进行操作,通过求和 Sales 值来计算该天的总销售额。在上面的例子中,2024-08-01 这一天的总 Sales 值为 2800,这是 RoyalQuill 和 GripLink 行销售额的总和。
include_groups = False 这一部分表示我们不希望函数同时对组标签(Day 的值)进行操作。在这里有点多余,因为我们的 lambda 函数专门引用了 "Sales" 列,但如果你不包括这个,Pandas 会对此抱怨。
最后,reset_index() 方法将结果转换回标准的 DataFrame 格式。索引是 Pandas 的一个概念,指的是每行的唯一标识符列,它使得数据检索和校准变得高效;在求和后,Day 列成为索引。通过调用 reset_index(),我们将 Day 恢复为常规列,并创建一个新的索引,范围从 0 到 n-1,其中 n 是唯一天数。
为了我们的仪表板,我们需要一个稍微更通用的上述代码版本,因为我们的折线图可能需要显示我们四个关键指标中的任何一个,而不仅仅是销售额(参见我们 UI 模拟的图 6.14)。

图 6.14 我们 UI 模拟的时间序列图表
因此,我们不会将 df["Sales"].sum() 作为要应用的功能传递,而是从我们在 metric.py 中定义并在 metric_config.py 中实例化的 Metric 类的 func 属性中获取这个功能。
Let's add such a function to the bottom of data_wrangling.py:
def get_metric_time_series(df, metric):
grouped = df.groupby('Day')
data = grouped.apply(metric.func, include_groups=False).reset_index()
data.columns = ['Day', 'Value']
return data
我们的功能接受一个数据框 df 和 metric,它是来自 metric_config.py 的 Metric 对象之一。正如你所看到的,我们通过将 metric.func 传递给 grouped.apply 来使其通用。
data.columns = ['Day', 'Value'] 这行代码将结果数据框的列名重置为新的名称。
现在我们有了我们的时间序列,让我们构建我们的折线图。
使用 Plotly 构建我们的时间序列折线图
正如我们在第一章中简要提到的,Streamlit 支持许多不同的数据可视化库。Plotly 是其中之一,它通常很容易使用,并提供了一系列引人入胜的交互式可视化。
要使用它,你首先需要使用 pip install plotly 安装它。
我们将使用的 Plotly 版本被称为 Plotly 图形对象(简称 GO)。GO 允许你以高程度控制和定制构建图表,使其非常适合交互式图表,你可以定义每个细节。
创建一个名为 time_series_chart.py 的新 Python 模块,并在列表 6.10 中编写代码。
列表 6.10 time_series_chart.py
import plotly.graph_objs as go
from data_wrangling import get_metric_time_series
def get_time_series_chart(df, metric):
data = get_metric_time_series(df, metric)
fig = go.Figure()
fig.add_trace(
go.Scatter(x=data['Day'], y=data['Value'], mode='lines+markers')
)
fig.update_layout(
title=f"{metric.title}",
xaxis_title='Day',
yaxis_title=metric.title
)
return fig
顶部的导入语句使 Plotly 图形对象可用;缩写 go 是传统上用来指代它的。
get_time_series_chart 指标接受我们的数据框以及一个类型为 Metric 的对象,并返回我们可以稍后传递给 Streamlit 小部件进行显示的折线图(一个 Plotly Figure 对象)。
我们首先通过调用我们之前定义的 get_metric_time_series 函数来获取我们的时间序列数据。
Plotly 图表是逐步构建的。我们从一个空图表开始,逐步添加所需的组件。在这里,fig = go.Figure() 初始化图表并将其分配给 fig。
下一个部分将实际的线条添加到图表中:
fig.add_trace(
go.Scatter(x=data['Day'], y=data['Value'], mode='lines+markers')
)
在这里,我们创建了一个 go.Scatter 对象,它代表一个散点图。散点图简单地在二维坐标轴(x 轴和 y 轴)上绘制点。每个点都有一个坐标对。在这种情况下,x 坐标由 data['Day'] 提供,即我们想在图表中显示的日期,而 y 坐标在 data['Value'] 中,这将是我们关键指标之一(取决于变量 metric 包含的是哪一个)。
我们还传递 mode='lines+markers',这使得 Plotly 不仅绘制点(使用“标记”),还在它们之间画线。结果是,我们数据框中的每个 (Day, Value) 对都有一个标记,所有标记都由线连接,形成了我们需要的折线图。
然后,我们将我们刚刚创建的图表添加到我们的 Figure 对象中,通过将其传递给 fig.add_trace()。
fig.update_layout(
title=f"{metric.title}",
xaxis_title='Day',
yaxis_title=metric.title
)
上面的最后部分,只是在我们图表中添加了一些文本,例如标题(我们从Metric对象的title属性中获取,该属性在metric_config.py中定义),以及 x 轴和 y 轴的标题。
我们现在知道如何创建所需的图表。但我们还需要实际显示它,所以继续更新time_series_chart.py,在底部添加一个time_series_chart函数,并在顶部添加相应的导入:
import plotly.graph_objs as go
import streamlit as st
from data_wrangling import get_metric_time_series
from metric_config import metrics, display_metrics
...
def time_series_chart(df):
with st.container(border=True):
chart_tabs = st.tabs(display_metrics)
for idx, met in enumerate(display_metrics):
with chart_tabs[idx]:
chart = get_time_series_chart(df, metrics[met])
st.plotly_chart(chart, use_container_width=True)
再次使用st.container(border=True)来制作一个盒子,将我们的折线图放入其中。
下一个部分,chart_tabs = st.tabs(display_metrics),引入了一个新的 Streamlit UI 小部件:st.tabs。
如其名所示,st.tabs在应用中创建了一个标签页区域,允许用户通过点击顶部的标签在内容之间切换。st.tabs的参数是要使用的标签标题列表。
例如,st.tabs(["Home", "About us", "Careers"])将创建三个带有标题“Home”、“About us”和“Careers”的标签页。
我们试图为每个指标创建一个标签页,并带有相应的折线图,因此我们可以传递来自metric_config.py的变量display_metrics——你可能还记得,这是一个我们关心的指标列表,我们可以将其用作标签页标题。
我们定义标签页内的内容的方式与定义st.column的方式相同:使用 with 上下文管理器。由于chart_tabs现在包含标签列表(由st.tabs返回),我们可以遍历display_metrics中的每个列表索引/指标名称对(idx, met),并使用chart_tabs[idx]来引用相应的标签页,调用get_time_series_chart来为该指标创建 Plotly 折线图。
最后,我们将创建的图表传递给st.plotly_chart元素以在屏幕上渲染:
st.plotly_chart(chart, use_container_width=True)
use_container_width=True确保折线图扩展以填充包含它的盒子的宽度。这防止了图表比容器大或在其周围留下大量空白等奇怪布局问题。
现在我们将折线图包含到我们的主应用中,通过更新dashboard.py:
...
from time_series_chart import time_series_chart
...
if main_df.empty:
st.warning("No data to display")
else:
metric_bar(main_df)
time_series_chart(main_df)
如果你现在保存并重新运行应用,你会看到你的第一个 Streamlit 可视化(见图 6.15)

图 6.15 使用 Plotly 创建的时间序列图表(GitHub 仓库中的 chapter_6/in_progress_8 部分有完整的代码)
真的很漂亮,不是吗?你可以看到标签页,并通过切换它们来查看每个指标在给定数据范围内的变化情况。使用st.plotly_chart创建的视觉图表免费提供许多有用的功能,例如能够放大图表中的特定点,当鼠标悬停在特定数据点上时显示工具提示,全屏模式,以及下载按钮以保存图像。
注意
我们本可以使用单选按钮作为我们的指标选择小部件,正如我们的 UI 原型所示,但我不想错过介绍 st.tabs 的机会。此外,通过 st.radio 和通过 st.tabs 进行选择的方式有一个重要区别:在选项之间切换不会触发应用程序重新运行,而切换单选按钮选项则会。这使得使用标签页更快、更高效,但代价是需要一次性在初始加载时创建所有指标的图表。
6.5.2 创建饼图
我们刚刚创建的时间序列图表让我们能够识别时间趋势,但我们还需要能够分解特定的数据点并查看其贡献因素。例如,如果我们知道“钢笔”细分市场的总销售额为 50 万美元,那么了解其中 70%是由 RoyalQuill 品牌推动的,而 Inkstream 只占 30%,或者 45%的订书机销售额来自 56 岁以上的年龄组,将是有帮助的。
饼图,它展示了整体按其组成部分的百分比分解,是快速形成数据图像的好方法。
图 6.16 再次显示了我们的 UI 原型的饼图。

图 6.16 我们 UI 原型的饼图
在这里,您可以做出两个选择:为饼图显示的指标和细分维度。指标只能是“总销售额”或“毛利润”,不能是“利润率%”或 ATV。这是因为后两个指标是比率,相应的按维度比率的总和不会达到 100%——因此饼图不适用。
例如,假设钢笔的平均交易价值(销售额除以交易次数)为 50 美元。我们无法按性别细分,并说其中 60%(30 美元)来自男性,40%(20 美元)来自女性。要获取男性的 ATV,我们必须通过将男性的总销售额除以相应的交易次数来计算比率。
让我们在 metric_config.py 的底部记录这个适用于饼图的较小指标列表的新变量:
pie_chart_display_metrics = ["Total sales", "Gross margin"]
将数据整理成正确的形状
正如我们将数据整理成日期/值时间序列以供时间序列图表使用一样,我们还需要为饼图准备我们的数据。饼图需要知道与每个维度值对应的指标值;它将自行进行转换为百分比。
例如,考虑我们之前处理成时间序列的早期样本数据:
+------------+-------+--------+-----------+------------+
| Day | State | Gender | Product | Sales |
+------------+-------+--------+-----------+------------+
| 2024-08-01 | CA | M | RoyalQuill| 1500 |
| 2024-08-01 | CA | M | GripLink | 1300 |
| 2024-08-02 | CA | M | RoyalQuill| 1600 |
| 2024-08-02 | CA | M | GripLink | 1200 |
+------------+-------+--------+-----------+------------+
如果我们想按产品展示总销售额的细分,您将按产品分组并汇总销售额:
+-----------+-------------+
| Product | Sales |
+-----------+-------------+
| RoyalQuill| 3100 |
| GripLink | 2500 |
+-----------+-------------+
这与我们之前所做的工作非常相似;唯一的区别是,我们不是按日期字段分组,而是按特定维度(“产品”)分组。
因此,我们将包含在data_wrangling.py底部的函数也与get_metric_time_series非常相似:
...
def get_metric_time_series(df, metric):
grouped = df.groupby('Day')
data = grouped.apply(metric.func, include_groups=False).reset_index()
data.columns = ['Day', 'Value']
return data
def get_metric_grouped_by_dimension(df, metric, dimension):
grouped = df.groupby(dimension)
data = grouped.apply(metric.func, include_groups=False).reset_index()
data.columns = [dimension, 'Value']
return data
新增的 get_metric_grouped_by_dimension 和 get_metric_time_series 之间的唯一区别在于,在前者中,我们接受维度作为输入,并按该维度进行分组,而不是按 Day。
一个 Plotly 饼图
可变通用的 Plotly 图形对象也可以用来创建我们想要的饼图。实际上,你将在 pie_chart.py(如列表 6.11 所示的新文件)中放入的代码与 time_series_chart.py 中的代码密切相关:
列表 6.11 pie_chart.py
import plotly.graph_objects as go
from data_wrangling import get_metric_grouped_by_dimension
def get_pie_chart(df, metric, dimension):
data = get_metric_grouped_by_dimension(df, metric, dimension)
fig = go.Figure()
fig.add_trace(
go.Pie(labels=data[dimension], values=data['Value'], hole=0.4)
)
return fig
差异应该是相当明显的:我们用 get_metric_grouped_by_dimension 替换了 get_metric_time_series,用 go.Pie 替换了 go.Scatter。
go.Pie 接受 labels,这些标签将在颜色图例中显示,values,以及 hole,它表示饼图中的“甜甜圈洞”应该有多大。
我们在这里不使用 fig.update_layout() 来设置图表中的任何文本,因为标题将简单地是标签页标题(我们将在下一部分讨论),并且没有 x 或 y 轴。
如我们之前所做的那样,我们还需要在 pie_chart.py 中编写另一个函数来渲染图像:
import plotly.graph_objects as go
import streamlit as st
from data_wrangling import get_metric_grouped_by_dimension
from metric_config import metrics, pie_chart_display_metrics
...
def pie_chart(df):
with st.container(border=True):
split_dimension = st.selectbox(
"Group by",
["Age group", "Gender", "State", "Category",
"Segment", "Product name"]
)
metric_tabs = st.tabs(pie_chart_display_metrics)
for idx, met in enumerate(pie_chart_display_metrics):
with metric_tabs[idx]:
chart = get_pie_chart(df, metrics[met], split_dimension)
st.plotly_chart(chart, use_container_width=True)
pie_chart 函数与来自 time_series.chart.py 的对应函数——time_series_chart——类似。
关键区别是添加了 split_dimension 变量(用于分解指标的维度),我们需要使用 st.selectbox 从用户那里收集。
其他内容保持大致相似;我们在 pie_chart_display_metrics(我们在 metric_config.py 中定义的)中为每个指标创建一个标签,遍历这些指标,使用 get_pie_chart 创建 Plotly 对象,并使用 st.plotly_chart 显示它。
在 dashboard.py 中,我们希望显示线形图和饼图并排,因此我们使用 st.columns:
...
from pie_chart import pie_chart
...
if main_df.empty:
st.warning("No data to display")
else:
metric_bar(main_df)
time_series_col, pie_chart_col = st.columns(2)
with time_series_col:
time_series_chart(main_df)
with pie_chart_col:
pie_chart(main_df)
因此,我们的仪表板的 UI 就完成了!重新运行以查看图 6.17。

图 6.17 我们完成的程序,底部右边的饼图是新添加的(GitHub 仓库中的 chapter_6/in_progress_9 有完整的代码)
在本章中,我们覆盖了很多内容,我们准备启动我们的仪表板。尽管如此,我们还没有完成 Note n' Nib。在下一章中,我们将看到几种改进我们应用程序的方法——包括可用性调整,以及从静态 CSV 文件切换到数据仓库。
6.6 摘要
-
指标仪表板是高管们的重要决策工具。
-
Pandas 是一个流行的 Python 库,用于在数据框中操作表格数据。Streamlit 可以原生地显示 Pandas 数据框。
-
使用 Pandas 的
read_csv函数从逗号分隔值(CSV)文件加载数据。 -
st.cache_data装饰器可用于缓存函数的结果以改进性能。其ttl参数设置缓存结果有效的时长。 -
在 Pandas 中,数据框的方括号表示法非常灵活;你可以使用它们来选择列、过滤行等等。
-
st.container可以用来包含其他 Streamlit 小部件,以无序或带有边框的方式显示它们。 -
st.multiselect创建一个下拉菜单,你可以从中选择多个选项。 -
st.set_page_config可以在 Streamlit 中设置应用程序配置,包括从居中布局切换到最大化布局。 -
st.date_input可以用来在你的应用程序中显示日期选择器。 -
humanize库对于以用户友好的方式格式化数字非常有用。 -
时间序列是一系列数据点,每个数据点都有一个日期和一个值。
-
Pandas 数据框上的
groupby方法可以在多个维度上聚合数据。 -
Plotly 图形对象(简称
go)是一个 Python 库,用于创建可以直接由 Streamlit 显示的可视化。 -
go.Scatter可以用来创建散点图和折线图,而go.Pie可以用来制作饼图。 -
数据仓库是一个专门设计的系统,用于存储和检索大量数据。
-
Google BigQuery(GCP 的一部分)是一个数据仓库的例子。为了使应用程序能够连接到它,你需要创建一个带有密钥的服务帐户,并将凭据记录在
st.secrets中。
第七章:7 CEO 反击:提升我们的仪表板
本章涵盖
-
评估应用并解决用户反馈
-
为 Streamlit 可视化添加灵活性
-
通过使常用功能易于访问来提高可用性
-
在 Streamlit 中创建模态对话框
-
使用查询参数在 Streamlit 应用中启用深链接
当 Python 首次发布时,它缺少了我们今天依赖的许多功能。我们熟悉并喜爱的 Python 已经经过多年的精心打磨——这个过程仍在继续。这个演变的一个很大部分来自于积极使用该语言的开发者的反馈。
事实上,没有任何软件在发布时是完美的。相反,它需要随着时间的推移不断改进,这里修复一个错误,那里添加一个新功能。本书中构建的项目也不例外。
在上一章中,我们为一家名为 Note n' Nib 的公司创建了一个指标仪表板。在这一章中,我们将跳过一段时间,看看用户是如何反应的。我们将使用他们的意见和评论,以批判性的眼光重新审视我们的应用程序,并对其进行改进。在这个过程中,我们将了解更多关于 Streamlit 可视化的知识,介绍模态对话框和查询参数,并了解如何编写高级、灵活的仪表板。
如果第六章是关于根据用户需求发布仪表板,那么第七章是关于着陆它,解决用户的问题,并对应用程序进行迭代以确保质量和满意度。
7.1 仪表板的反馈
你在上一章中构建的仪表板在 Note n' Nib 公司引起了轰动。第一次,公司的管理层可以查找更新的销售数据,比较产品之间的性能,并自行分析趋势,而无需寻求工程部门的帮助。
CEO 要求所有员工会议都以回顾关键销售指标开始,这意味着高层现在对您的仪表板非常熟悉。当然,有了这样的关注,自然会带来更多的审查,所以在发布后的几周的一个周一下午,你在收件箱里收到 CEO 的电子邮件,你并不完全感到惊讶。
这封电子邮件包含了高层对您的仪表板反馈的汇总——基本上是一个您要实现的新功能愿望清单。
嗯,你的这个星期就这样过去了。尽管如此,你对这项工作还是很兴奋的,因为你又能多玩一会儿 Streamlit!在本章的整个过程中,我们将检查并解决每一项反馈。
7.2 时间序列图表的粒度
电子邮件中的第一点写道:“时间图表在回顾几天数据时很有用,但对于更长时间的数据则难以理解”。
记得我们的仪表板有一个折线图,显示了选定指标随时间的变化。对于较短的日期范围(大约一个月左右),它工作得相当不错(见图 7.1 的左侧),但对于较长的日期范围(比如说一年或更长时间),它看起来就像图 7.1 的右侧。

图 7.1 现有时间图表中的日粒度适用于像一个月这样的小时间框架(左),但不适用于更长的时间框架(右)。
所有数据都在那里,但数据量如此之大以至于令人不知所措。当考虑 多年 的数据时,我们不需要在日期范围内的每一天都绘制一个点。
你可以看到这样做是如何增加图表中单个标记的数量;如果我们观察的是两年的日期范围,那么就是 365 x 2 = 730 个点——太多以至于难以解释。
那么,我们如何解决这个问题呢?在较长的时间范围内,单日粒度太多,但如果我们的日期范围较短,比如一周或一个月,这是合理的。对于更长的范围,我们可能希望有一个表示一周、一个月甚至一年的标记。
最简单的解决方案是允许用户选择他们想要的粒度。现在让我们来解决这个问题。
7.2.1 启用不同的时间粒度
要启用周、月和年粒度,我们首先必须确保我们的数据 具有 这些字段,目前它还没有。一旦我们做到了,我们就能将每个指标聚合到正确的粒度。
回想一下,我们应用中的数据流始于 data_loader.py 中的 load_data 函数,该函数从外部源获取数据,目前是一个 CSV 文件。
这在 data_wrangling.py 中的 prep_data 后续进行,我们重命名列并添加 Day 字段。这也是我们需要进行更改以包含我们想要的其它粒度的地方。
现在就去编辑 prep_data,使其看起来像这样:
@st.cache_data(show_spinner="Reading sales data...", ttl="1d")
def prep_data() -> pd.DataFrame:
df = clean_column_names(load_data())
df['Day'] = pd.to_datetime(df['Date'])
df['Week'] = df['Day'].dt.to_period('W').dt.to_timestamp()
df['Month'] = df['Day'].dt.to_period('M').dt.to_timestamp()
df['Year'] = df['Day'].dt.to_period('Y').dt.to_timestamp()
return df
我们正在向 Pandas 数据框中添加三个新列:Week、Month 和 Year。为了获取每个字段,我们首先从 Day 列 (df['Day']) 开始,将其转换为周期,然后将结果转换为时间戳。考虑以下这样的语句:
df['Month'] = df['Day'].dt.to_period('M').dt.to_timestamp()
在这里,.dt 用于以元素方式访问列的日期/时间相关属性。to_period('M') 将 Day 列转换为 Pandas 内部的月度“周期”类型,代表整个月而不是特定的时间点。
然后,我们使用第二个 .dt 访问器来获取转换列的日期/时间属性,最后使用 .to_timestamp() 将每个月度周期转换为表示月份开始的日期。
例如,如果我们正在操作 df['Day'] 中的某个元素,而这个元素是日期 2024-07-12,我们最终得到的是日期 2024-07-01,即对应月份的开始。
创建 Week 和 Year 列的其他两个语句是类似的,分别添加表示周开始和年开始的日期。
在代码的其他地方——特别是在 data_wrangling.py 和 time_series_chart.py 中的(其他)函数内——我们一直将 Day 视为一个硬编码的列名。一旦我们有了这些其他列,我们只需要在后台引入一个变量来表示粒度即可。
因此,data_wrangling.py 中的 get_metric_time_series 函数现在看起来是这样的:
def get_metric_time_series(df, metric, grain):
grouped = df.groupby(grain)
data = grouped.apply(metric.func, include_groups=False).reset_index()
data.columns = [grain, 'Value']
return data
并且 get_time_series_chart(在 time_series_chart.py 中)变为:
def get_time_series_chart(df, metric, grain):
data = get_metric_time_series(df, metric, grain)
fig = go.Figure()
fig.add_trace(
go.Scatter(x=data[grain], y=data['Value'], mode='lines+markers')
)
fig.update_layout(
title=f"{metric.title}",
xaxis_title=grain,
yaxis_title=metric.title
)
return fig
在这两种情况下,我们都在做同样的更改:在函数中添加 grain 作为新的参数,并将 'Day' 替换为 grain。
7.2.2 创建时间粒度选择器
现在我们已经将生成时间序列图的函数连接起来,以处理作为变量的 grain,我们需要提供一个让用户选择他们想要的时间粒度的方式。
st.select_slider
让我们使用一个新的 Streamlit 小部件来完成这个任务:st.select_slider,另一个选择元素。st.select_slider 是 st.selectbox 和 st.slider 的结合,st.selectbox 允许你从下拉列表中选择单个值,而 st.slider 允许你选择数值。
当你有用户可以选择的文本选项列表,但还想对它们施加一些顺序时,你会使用它。例如,当你创建调查选项时,“强烈同意”、“同意”、“中立”、“不同意”和“强烈不同意”是字符串,但它们有特定的顺序——从最同意到最不同意。
在我们的案例中,我们希望用户看到的时间粒度选项——“日”、“周”、“月”和“年”——也有一个顺序,从最小的时间单位到最大的时间单位。
为了我们的目的,我们可以在 time_series_chart.py 中的 time_series_chart 函数内这样使用 st.select_slider:
def time_series_chart(df):
with st.container(border=True):
grain_options = ["Day", "Week", "Month", "Year"]
grain = st.select_slider("Time grain", grain_options)
chart_tabs = st.tabs(display_metrics)
for idx, met in enumerate(display_metrics):
with chart_tabs[idx]:
chart = get_time_series_chart(df, metrics[met], grain)
st.plotly_chart(chart, use_container_width=True)
grain_options 这里持有有序的选项列表,并将其提供给 st.select_slider 的第二个参数,第一个是显示的标签。你会发现这些参数与 st.selectbox 和 st.radio 的参数非常相似。st.select_slider 返回用户选择的选项,我们将其存储在 grain 中,并将其作为新添加到 get_time_series_chart 中的参数传递。
使用 streamlit run <path to dashboard.py> 命令保存并运行你的应用程序以获取图 7.2

图 7.2 线形图现在有了一个时间粒度选择器(有关完整代码,请参阅 GitHub 仓库中的 chapter_7/in_progress_01)
尝试使用时间粒度选择器。使用月粒度在查看跨越多年多个日期范围的长时间范围内会使图表更加易于接受。
7.3 依赖性过滤器
“如果我已经选择了‘书写工具’类别,为什么它仍然问我是否想查看订书机和日历?”邮件中的一条直接引语问道,据报道这是来自首席财务官,他是仪表板更热情的用户之一。
你承认这是一个有效的问题。她指的是图 7.3 中的过滤器栏,在显示过滤器下拉列表中的选项时没有考虑现有的选择,导致出现诸如“书写工具”与“回形针”这样的荒谬组合。

图 7.3 过滤器栏选项可能存在不匹配的组合
过滤栏并不是“智能”的。在产品类别上过滤不会更新段和产品名称过滤器中用户可用的选项,即使那些段和产品不属于所选类别。
如果用户过滤“写作工具”的数据,仍然在段下拉列表中看到所有其他产品线(如“回形针”)会有些恼人。我们一直在使用的示例数据总共只有十个产品,所以这本身并不是一个决定性的问题,但考虑一下有数百个产品的情况。到那时,使用产品层次结构中的较高级别(如“类别”)来从其他下拉列表中过滤掉不相关的产品成为一个必要的功能,而不仅仅是一个锦上添花的功能。
让我们考虑如何解决这个问题。
一种可能性是记录维度之间的相互依赖关系,然后在获取每个字段的唯一值时查找和解决这些依赖关系。例如,由于“产品名称”应该依赖于“类别”和“段”的选择,我们可能会在某个地方记录这种依赖关系。
这需要我们维护一个新的配置,逻辑可能会相当复杂。
有一个更简单的方法:而不是首先获取所有唯一的过滤值,然后过滤数据框(图 7.4),我们可以先获取第一个过滤器的唯一值,应用过滤器以获取新的数据框,然后获取第二个过滤器的唯一值,再应用它,依此类推。

图 7.4 旧的过滤方法:首先获取所有过滤器的唯一值,然后根据选择进行过滤
在新的方法(图 7.5)中,由于我们在获取下一个过滤器的唯一值之前过滤数据框,我们保证只显示可用的值。

图 7.5 新的过滤方法:获取每个过滤器的唯一值,根据选择应用过滤器,然后对其他过滤器重复此操作
因此,当用户选择“写作工具”类别时,数据框会被过滤,只包含那些行,并且段过滤器的唯一值从这个新集合中抽取,这个新集合不会包括订书机之类的物品。
要实现这一点,修改filter_panel.py:
import streamlit as st
from data_wrangling import get_unique_values, apply_filters
filter_dims = ["Age group", "Gender", "Category", "Segment",
"Product name", "State"]
def filter_panel(df):
filters = {}
with st.expander("Filters"):
filter_cols = st.columns(len(filter_dims))
effective_df = df
for idx, dim in enumerate(filter_dims):
with filter_cols[idx]:
effective_df = apply_filters(effective_df, filters)
unique_vals = get_unique_values(effective_df, dim)
filters[dim] = st.multiselect(dim, unique_vals)
return filters
变化并不复杂。在遍历过滤字段时,我们不是直接将df传递给get_unique_values以获取要显示的下拉选项集,而是引入一个名为effective_df的变量并将它传递。
按照我们对方法的解释,effective_df在每个循环迭代中通过应用我们迄今为止的过滤器被重新计算(我们在此目的上在顶部导入apply_filters)。
放手去重新运行你的应用!图 7.6 展示了当你仅选择“写作工具”作为你感兴趣的唯一类别时会发生什么。

图 7.6 过滤栏仅显示有效的选项组合(有关完整代码,请参阅 GitHub 仓库中的 chapter_7/in_progress_02)
如预期的那样,Segment过滤器现在只显示书写工具。
注意
过滤器的顺序现在是有意义的。如果类别过滤器被放置在分段之后,选择一个类别对分段没有影响,因为分段的唯一值已经在评估所选类别值之前已经计算出来了。
7.4 日期范围比较
来自产品线主管之一的反馈也很有帮助,她发布了一张截图(图 7.7)来说明她的观点:“我可以看到 RoyalQuill7 月份的销售额为 132 万美元。但这好还是不好?去年我们做得怎么样?”

图 7.7 RoyalQuill 的销售额为 132 万美元,但没有迹象表明这是否是好的,或者之前可比期间的销售额是多少
分析数据时,困难的部分往往不是获取或转换数据,而是情境化数据。一个指标本身并没有多少意义。为了使其有用,你必须能够比较它。如果我们知道某个产品的年销售额为 100 万美元,那么如果我们知道去年的销售额是 1000 万美元,我们做出的决策将与我们知道去年的销售额仅为 10 万美元时做出的决策大不相同。
我们的控制板目前没有提供一种简单的方式来做出这种比较。
理想情况下,当我们看到某个特定时间段的指标时,我们也应该能够知道它与过去相比是如何变化的。在本节中,我们将更深入地探讨这一要求,并将其纳入我们的控制板。
7.4.1 添加另一个日期范围选择器
将指标与其过去值进行比较究竟意味着什么?我们使用什么开始日期和结束日期来表示“过去”?让我们考虑一些用户可能感兴趣的常用比较。
例如,如果用户正在查看 2024 年 8 月 1 日至 8 月 15 日的总销售额,那么他们很可能想看看这与上个月相同日期(即 2024 年 7 月 1 日至 7 月 15 日)相比如何。这被称为“月环比”比较,通常缩写为“MoM”。
用户可能想要进行的其他类似比较包括 QoQ(“季度环比”)和 YoY(“年度环比”),它们都与 MoM 相似。QoQ 意味着与上一个季度相同日期的比较。例如,8 月 1 日至 8 月 15 日代表第三季度第二个月的头 15 天,因此 QoQ 将比较这些日期与第二季度第二个月的头 15 天,即正好三个月前:5 月 1 日至 5 月 15 日。
YoY 应该是显而易见的——这是正好一年前的日期范围,所以以我们的例子来说,就是 2023 年 8 月 1 日至 8 月 15 日。
高管可能想要进行的另一种比较是对立即之前的 X 天进行比较,其中 X 是当前选定主要日期范围的天数。
因此,如果主要范围是 8 月 1 日至 8 月 15 日,则“上一个期间”将是 8 月 1 日之前的 15 天,即 7 月 17 日至 7 月 31 日。
现在我们来实现这些常用的比较功能。我们首先更新 date_range_panel.py 中的 date_range_panel 函数,以便包含一个比较选择器,并向调用者(稍后我们将编辑的 dashboard.py)返回两个额外的日期:
...
def date_range_panel():
start = st.date_input("Start date", value=THIRTY_DAYS_AGO)
end = st.date_input("End date", value=LATEST_DATE)
comparison = st.selectbox(
"Compare to", ["MoM", "QoQ", "YoY", "Previous period"])
compare_start, compare_end = get_compare_range(start, end, comparison)
st.info(f"Comparing with: \n{compare_start} - {compare_end}")
return start, end, compare_start, compare_end
由于比较选项是离散值,我们使用 st.selectbox 为用户提供选择,并调用尚未定义的函数 get_compare_range 来获取比较范围的起始和结束日期。
我们还通过 st.info 框向用户公开这些比较日期,这样用户就不必自己进行日历计算来获取这些信息。
让我们定义上面提到的 get_compare_range 函数(在同一文件 date_range_panel.py 中):
def get_compare_range(start, end, comparison):
offsets = {
"MoM": pd.DateOffset(months=1),
"QoQ": pd.DateOffset(months=3),
"YoY": pd.DateOffset(years=1),
"Previous period": pd.DateOffset((end - start).days + 1)
}
offset = offsets[comparison]
return (start - offset).date(), (end - offset).date()
此函数接受三个参数:主日期范围的起始和结束日期,以及 comparison,一个字符串,包含我们想要执行的比较类型——如上所述,这可以是 MoM、QoQ、YoY 或 Previous period。
计算比较日期范围归结为从起始和结束日期中减去正确的 偏移量。例如,对于 MoM 比较,我们需要从两个日期中减去一个月。对于 QoQ,我们减去 3 个月,对于 YoY,我们减去一年。
对于 Previous period 比较来说,我们首先使用 (end - start).days + 1 计算主日期范围内的天数,并将其用作偏移量。
我们将这些偏移量存储在一个字典中(如上述代码中所示,称为 offsets),比较名称作为键,Pandas DateOffset 对象作为值。然后我们可以通过从每个日期中减去偏移量来获得新的起始和结束日期:
return (start - offset).date(), (end - offset).date()
注意
为什么这里需要 .date()?好吧,如果您特别关注,您可能会意识到 start 和 end 是 datetime.date 对象,而不是 Pandas 时间戳对象。Pandas 确保与 datetime.date 兼容的 pd.DateOffset,并且前者可以从后者中减去,但结果是 Pandas 时间戳对象。由于我们一直在尝试将日期范围保持为 datetime.date 对象,我们使用 Pandas 时间戳类的 .date() 方法将 start - offset 和 end - offset 转换为 datetime.dates,从而确保一致性。
由于 date_range_panel 函数现在返回四个值(start、end、compare_start 和 compare_end),而不是两个,我们需要更新调用它的代码以反映这一点。
这段代码恰好位于 dashboard.py 文件中的侧边栏部分。将其修改为:
with st.sidebar:
start, end = date_range_panel()
to:
with st.sidebar:
start, end, compare_start, compare_end = date_range_panel()
您的应用程序侧边栏现在应该看起来像图 7.8 所示。

图 7.8 展示了主日期范围的起始/结束日期选择器以及“比较到”输入(请参阅 GitHub 仓库中的 chapter_7/in_progress_03 以获取完整代码)
虽然我们实际上并没有对比较日期范围做任何事情,但您可以看到新的选择器。
7.4.2 在指标栏中显示比较
现在我们已经收集了要与主要日期范围比较的日期范围,我们如何使用它来解决我们收到的反馈?
让我们通过一个例子来考虑这个问题。假设我们有两个日期范围:2024 年 8 月 1 日至 8 月 31 日(“主要”日期范围)和 2024 年 7 月 1 日至 7 月 31 日(比较日期范围)。如果我们比较这两个日期范围之间的总销售额,我们需要分别计算这两个日期范围的变化,然后显示它们之间的 delta(差异)。
如果八月份的销售额为 500 万美元,而七月份的销售额为 400 万美元,我们将显示 100 万美元的变化。一般来说,将差异表示为过去数值的百分比更有用,因此变化为 20%(400 万美元 / 500 万美元 x 100)。我们将显示这个数字与八月份的销售额一起,以提供完整的画面:八月份的销售额为 500 万美元,比上一期增长 20%。
这种方法要求我们做两件事:
-
分别为比较日期范围计算指标,同时保持其他一切(主要是过滤器值)不变。
-
计算百分比变化并将其与主要指标一起显示。
对于第一部分,让我们修改 dashboard.py:
import streamlit as st
...
with st.sidebar:
start, end, compare_start, compare_end = date_range_panel()
...
main_df = get_filtered_data_within_date_range(data, start, end, filters)
if main_df.empty:
st.warning("No data to display")
else:
compare_df = get_filtered_data_within_date_range(
data, compare_start, compare_end, filters)
metric_bar(main_df, compare_df)
...
在这里,我们以类似获取 main_df 的方式获取一个新的 Pandas 数据框 compare_df——通过将原始准备好的数据传递给 get_filtered_data_within_date_range,并带有适当的起始和结束日期以及过滤器。
过滤器与创建 main_df 时使用的过滤器相同。这很重要,因为如果用户过滤了特定的类别和/或性别,他们想要的比较就是与相同的类别和/或性别,只是不同的日期范围。
我们还将 compare_df 作为 metric_bar 的第二个参数传递,它目前还不支持,但完成时将支持。
要计算和显示百分比变化,我们需要在 metric_bar.py 中进行以下更改。
让我们先修改 metric_bar 以接受我们传递的额外参数:
def metric_bar(main_df, compare_df):
with st.container(border=True):
metric_cols = st.columns(len(display_metrics))
for idx, metric_name in enumerate(display_metrics):
metric = metrics[metric_name]
with metric_cols[idx]:
value = get_metric(main_df, metric)
formatted_value = format_metric(value, metric.type)
formatted_delta = get_formatted_delta(value, compare_df, metric)
c1, c2, c3 = st.columns([1, 3, 1])
with c2:
st.metric(
metric.title, formatted_value, formatted_delta, "normal")
在此之前,对于每个需要显示的指标,我们会使用 format_metric 获取格式化的值,并将其与标题一起传递给 st.metric 以进行显示,如下所示:
st.metric(metric.title, formatted_value)
然而,st.metric 也支持显示变化,通过其第三个和第四个参数(内部命名为 delta 和 delta_color)。
第三个参数是要显示的变化(在这种情况下,百分比差异)的格式化数字,而第四个参数 delta_color 指示显示变化时使用的颜色方案。
delta_color 可以取 "normal"、"inverse" 或 "off" 的值。如果设置为 "normal",则正变化以绿色显示,负变化以红色显示。如果设置为 "inverse",则相反:增加以红色显示,减少以绿色显示(这对于值越低越好的指标来说很合适,比如成本)。如果设置为 "off",Streamlit 只会以灰色显示所有内容。
在这种情况下,我们调用 st.metric 的方式如下:
st.metric(metric.title, formatted_value, formatted_delta, "normal")
“正常”适用于我们所有的度量,因为更高的值对它们来说都更好(你希望有更高的销售额、更高的毛利率、更高的利润百分比和更高的平均交易价值)。对于第三个参数,我们传递formatted_delta,这是我们通过调用尚未定义的函数获得的:
formatted_delta = get_formatted_delta(value, compare_df, metric)
让我们继续创建get_formatted_delta以及任何相关的函数:
def get_delta(value, compare_df, metric):
delta = None
if compare_df is not None:
compare_value = get_metric(compare_df, metric)
if compare_value != 0:
delta = (value - compare_value) / compare_value
return delta
def get_formatted_delta(value, compare_df, metric):
delta = get_delta(value, compare_df, metric)
formatted_delta = None
if delta is not None:
formatted_delta = format_metric(delta, "percent")
return formatted_delta
我们定义了两个函数:get_delta计算实际增量,而get_formatted_delta调用它并格式化结果。
get_delta接受主要度量的值,compare_df——我们在dashboard.py中计算的比较数据框——以及metric,它是一个Metric对象,代表我们试图展示其变化的度量。
get_delta的主体并不复杂。我们使用compare_df上的get_metric函数来计算比较日期范围的度量,并按以下方式获取百分比增量:
delta = (value - compare_value) / compare_value
在任何时刻,如果我们意识到一个增量无法显示(要么是因为compare_df没有数据,要么是因为尝试计算它会导致除以零错误,因为比较值是零),我们将返回None。
在get_formatted_delta中,我们获取这个返回值,并通过调用format_metric来获取其格式化版本:
formatted_delta = format_metric(delta, "percent")
回想一下第六章的内容,format_metric(在formatting.py文件中定义)将数值转换为用户友好的字符串,具体取决于其类型。在这种情况下,度量类型是“百分比”,因此format_metric将在末尾添加一个“%”符号。
如果没有增量需要格式化(当get_delta返回None时发生),get_formatted_delta也会返回None。
当这个值最终传递给st.metric时,Streamlit 会正确地处理None值,根本不显示任何内容。
你现在可以重新运行仪表板来查看更新的度量条(记得选择我们有数据的比较日期范围),如图 7.9 所示。

图 7.9 展示了每个度量如何从比较日期范围变化的度量条(GitHub 仓库中的 chapter_7/in_progress_04 章节有完整代码)
如您所见,度量条现在显示了每个度量与比较日期范围中的值相比的变化情况。
嘣!我们解决了另一个关键反馈点,并且正在朝着仪表板的 2.0 版本稳步前进!让我们看看邮件中还有什么要说的。
7.5 深入查看
注意,N' Nib 的 CEO 以自己是“细节控”而自豪,所以当他看到仪表板上的数字时,他想知道为什么。例如,如果他发现圆珠笔的平均交易价值低于钢笔,他希望深入挖掘数据,了解是否有一些特定的群体在推动 ATV 下降。
我们的仪表板没有暴露比度量栏、折线图和饼图更多的数据,但显然人们希望有一个更灵活和详细的视图,也许甚至可以显示源数据中的单个行。
到目前为止,在我们的仪表板设计中,我们尽可能地屏蔽了复杂性。我们依靠可视化使数据易于理解,并使用清晰、友好的度量栏来显示关键汇总数字。抽象复杂性的质量通常是值得赞扬的,对于大多数用户来说也是如此。然而,有时你可能会遇到一个想要深入了解并更高级地与你的软件交互的强大用户。
在我们的案例中,Note n' Nib 的 CEO 符合这一描述——他对数据很熟悉,并表达了对无法钻取以获取更详细见解的挫败感。解决这一反馈可能是本章最复杂的任务,因为我们需要创建一个全新的视图,而不仅仅是改进现有功能。
7.5.1 插入模态对话框
在考虑钻取视图可能包含的内容之前,让我们思考一下将此功能放在哪里。由于我们将其归类为“高级”功能,我们可能不应该将其放在仪表板的主窗口中。普通用户应该能够忽略这个新的、更详细视图,而高级用户应该能够轻松找到它。
让我们利用这个机会讨论一个新的用户界面结构:模态对话框。模态对话框本质上是在主内容之上显示的覆盖层,在它被取消之前暂时阻止与底层界面的交互。这个覆盖层将专注于特定任务,这使得它非常适合展示像钻取这样的高级功能。
st.dialog
Streamlit 通过st.dialog提供了开箱即用的模态对话框。现在让我们看看它是如何工作的。
在我们对钻取视图的第一轮迭代中,为了保持简单,当用户想要钻取数据时,我们只需向他们展示整个 Pandas 数据框。当然,由于用户可能还希望深入了解我们最近添加的比较日期范围,我们需要展示主数据和比较数据框。
列表 7.1 展示了新文件drilldown.py,该文件已设置以实现这一点。
列表 7.1 drilldown.py
import streamlit as st
@st.dialog("Drilldown", width="large")
def drilldown(main_df, compare_df):
main_tab, compare_tab = st.tabs(["Main", "Compare"])
with main_tab:
st.dataframe(main_df, use_container_width=True)
with compare_tab:
st.dataframe(compare_df, use_container_width=True)
了解到st.dialog的结构与st.columns、st.tabs或st.container不同,即它不是一个包含其他小部件的小部件,可能会让你感到惊讶。
相反,它与第六章中的st.cache_data类似,它是一个装饰器。用st.dialog装饰的函数运行时,其内容将在弹出对话框中渲染。
@st.dialog("Drilldown", width="large")
宽度参数简单地设置了对话框的大小,可以是"small"(500 像素宽)或"large"(750 像素宽)。
被装饰的函数称为 drilldown,它接受来自 dashboard.py 的 main_df 和 compare_df 作为参数。该函数渲染两个标签页,分别命名为“主”和“比较”,并使用一个新的小部件 st.dataframe 在各自的标签页中显示传递的 Pandas 数据框。
使用 st.dataframe 如此简单地在屏幕上显示数据框,就像第六章中 st.write 所做的那样。我们稍后也会遇到它。
要看到对话框,我们需要 触发 它,所以让我们专注于这一点。
使用 st.container 来显示 UI 元素顺序
如前所述,钻取视图应该对普通用户不显眼,但对高级用户来说相当明显。实现这一目标的一种方法是在侧边栏中添加一个标签为“钻取”的按钮,并在点击时触发对话框。
让我们检查 dashboard.py 中的现有代码:
...
with st.sidebar:
start, end, compare_start, compare_end = date_range_panel()
...
main_df = get_filtered_data_within_date_range(data, start, end, filters)
if main_df.empty:
st.warning("No data to display")
else:
compare_df = get_filtered_data_within_date_range(
data, compare_start, compare_end, filters)
...
侧边栏已经包含了日期范围面板,其中包含四个小部件(两个用于主要范围的日期输入、一个比较选择框和一个显示比较范围的说明框),所有这些都垂直排列。由于我们希望钻取触发器易于可见,我们可能不希望它位于日期范围面板 下方。好,所以我们把它放在面板上方,对吧?
除了这一点,这里还有一点排序问题。为了触发钻取视图,我们需要调用我们刚刚用 st.dialog 装饰的 drilldown 函数。这个函数的参数是 main_df 和 compare_df。
如果你再次检查 dashboard.py 代码,你会意识到获取 main_df 和 compare_df 需要我们已经有 start、end、compare_start 和 compare_end 的值,这样我们才能像这样(对于 main_df)传递它们:
main_df = get_filtered_data_within_date_range(data, start, end, filters)
但这些值从何而来?当然是在侧边栏中!
with st.sidebar:
start, end, compare_start, compare_end = date_range_panel()
你看到我们的困境了吗?为了将钻取按钮放置在日期范围面板上方,我们需要在这一点之前编写其代码,但该代码需要只有在这之后才能获得的值!
这正是我在第六章中顺便提到的事情的完美阐述:显示元素顺序的能力。我们需要一种方法来区分 Streamlit 在屏幕上渲染小部件的顺序和这些小部件计算的顺序。
我们将使用 st.container 来实现这一点。在第六章中,我们使用它来显示指标栏和可视化内容的边框。这次,我们将利用不同的属性——st.container 可以在屏幕上放置一个 占位符 小部件,当我们能够填充其他小部件时。
对于我们的用例,占位符将位于日期范围面板上方——在侧边栏内——并且我们只有在代码中稍后获得 main_df 和 compare_df 后才会填充实际的钻取按钮。
让我们在 dashboard.py 中安排这个布局:
...
from drilldown import drilldown
...
with st.sidebar:
dd_button_container = st.container() #A
start, end, compare_start, compare_end = date_range_panel()
...
main_df = get_filtered_data_within_date_range(data, start, end, filters)
if main_df.empty:
st.warning("No data to display")
else:
compare_df = get_filtered_data_within_date_range(
data, compare_start, compare_end, filters)
if dd_button_container.button("Drilldown", use_container_width=True):
drilldown(main_df, compare_df)
...
A 这是在 st.sidebar 中定义的占位符
正如承诺的那样,我们在日期范围面板上方使用 st.container 放置一个占位符,并通过名称 dd_button_container 来引用它。
然后,一旦我们有了 main_df 和 compare_df,我们创建了一个按钮,当点击时调用钻取函数。注意,我们使用的是 dd_button_container.button 语法,而不是 with/st.button 结构,就像我们可以在列或选项卡中使用的那样。
现在是时候看到我们的对话框生动起来!重新运行 dashboard.py 并点击钻取按钮以获取图 7.10。

图 7.10 使用 st.dialog 在对话框中渲染的基本原始数据框视图(完整代码请见 GitHub 仓库中的 chapter_7/in_progress_05)
你可能已经注意到,在点击之前,钻取按钮出现需要一秒钟。正如你可能猜到的,那是因为我们推迟了它的渲染,直到处理完其他一堆东西。
7.5.2 设计钻取内容
把你的注意力转到图 7.10 中的对话框上。我们目前只是以原样显示 main_df 和 compare_df。它相当难看,水平 和 垂直滚动条表明我们只看到了数据的一小部分。更重要的是,我们无法轻松使用这种视图来查找特定的数据点或查看特定数据子集。如果老板必须使用这个,他不会高兴的。
不,我们需要仔细思考才能获得正确的体验。
用户需要从钻取视图中得到什么?
很明显,钻取页面的内容需要改变,但如何改变?我们的用户需要从这个视图中得到什么?好吧,还有什么比与用户交谈更好的方式来理解呢?
因此,你在 CEO 的日程上预订了一个时间段——他愿意接受,这表明他对仪表板的热情。在你与他面试时,他阐述了他提供关于钻取视图反馈的原始动机。
注意,Note n' Nib 在其畅销钢笔系列中有两个独立的产品:InkStream 和 RoyalQuill。InkStream 被视为对钢笔的现代时尚诠释,而 RoyalQuill,让人联想到经典复古笔的优雅,针对的是老年客户。
最近,公司为 RoyalQuill 运行了一项广告活动,特别针对 46-55 岁和 56 岁以上的女性。CEO 希望了解这次活动的假设和结果数据。具体来说,他想知道按年龄 和 性别划分的 InkStream 和 RoyalQuill 的销售额如何。
我们当前的仪表板向用户展示了按年龄 或 性别划分的指标细分,但不是两者兼而有之,因此他无法轻松访问这些信息。
你可能会看到这种反馈可以推广到 任何 数据维度的组合,而不仅仅是年龄组/性别。此外,在连贯且可重复的可视化中呈现这类细节可能会很棘手。
那么,我们需要的是一个高度灵活的表格形式来显示数据,类似于你可能从像 Microsoft Excel 这样的电子表格程序中熟悉的交叉表。这个表格应该:
-
使我们能够查看我们选择的任何维度的组合的数字
-
只让我们关注我们关心的字段,隐藏无关的行和/或列
-
显示汇总数字,以便我们可以看到完整的分解
根据这些要求创建的模拟用户界面如图 7.11 所示。

图 7.11 钻取视图的模拟用户界面
模拟显示了一个相当灵活的表格,相当类似于交叉表。钻取字段框是一个多选框,允许我们选择我们关心的维度。下面是一个只显示我们已选择的维度的表格。它在这些维度上聚合数据,显示所选维度的每个组合的指标。还有一个总行,将所有内容加起来。
我们还保留了主/比较标签,以便用户可以在它们之间切换,查看指标的过去/现在视图。这种格式非常灵活,符合我们的要求,因此是时候构建它了!
7.5.3 实现钻取视图
图 7.11 中的钻取视图相当复杂,所以我们将逐步组装它,从钻取字段选择器开始,以一些格式化和样式结束。
按几个选定的维度聚合
构建维度选择器(图 7.11 顶部的控件)只是将可能的维度选项传递给st.multiselect的问题。向drilldown.py添加一个新函数来完成此操作,并返回用户的选定列表:
def drilldown_dimensions():
return st.multiselect(
"Drilldown fields",
["Age group", "Gender", "Category", "Segment", "Product name", "State"]
)
如图 7.11 中的模拟所示,我们希望同时显示所有关键指标。让我们在drilldown.py中添加另一个函数(也在此文件中),它接受一个数据框(或其切片),通过聚合计算所有指标,并返回结果。记得导入我们需要的所有模块!
import pandas as pd
import streamlit as st
from metric_config import metrics
...
def get_metric_cols(df):
metrics_dict = {met: metric.func(df) for met, metric in metrics.items()}
return pd.Series(metrics_dict)
...
表达式 {met: metric.func(df) for met, metric in metrics.items()} 是一种 字典推导式,它通过迭代某个东西来创建字典的简写形式。这里的意思是“迭代metrics字典(来自metric_config.py),并返回一个新的字典,其中每个键是指标的名称,对应的值是应用指标函数metric.func在df上的结果,即该指标的价值”。
我们在这里使用 Pandas 系列,因为,正如您很快就会看到的,它是一种多功能的数据类型,可以无缝集成到各种数据框操作中。
为了准备给定数据框和维度列表的聚合表,我们在同一文件中引入了一个名为get_aggregate_metrics的新函数:
def get_aggregate_metrics(df, dimensions):
if dimensions:
grouped = df.groupby(dimensions)
return grouped.apply(get_metric_cols).reset_index()
metric_cols = get_metric_cols(df)
return pd.DataFrame(metric_cols).T
如果dimensions不是一个空列表,即如果用户确实选择了某些钻取维度,get_aggregate_metrics将df按这些字段分组,并对每个组应用get_metric_cols(使用您在第六章中应该熟悉的grouped.apply),从而为每个组获得指标值。
如果没有选择维度,那么我们直接在 df 上调用 get_metric_cols 来获取一个包含整个数据框聚合度量的 Pandas 系列对象。最后,我们将这个系列转换为数据框并返回其 转置:
return pd.DataFrame(metric_cols).T
数据框的转置(使用 Pandas 数据框的 .T 属性引用)是另一个数据框,其行和列已互换。在这种情况下,metric_cols 是一个 pd.Series 对象,对其调用 pd.Dataframe 将返回一个单列数据框,其中每个度量值都是一个行。
.T 是必需的,以便将其转换为只有一个-行的数据框,其中每个度量值都是一个列,这种格式更方便。
接下来,我们编写一个函数来返回我们的完整钻取表格。目前它相当单薄,因为我们只进行了一些聚合:
def get_drilldown_table(df, dimensions):
aggregated = get_aggregate_metrics(df, dimensions)
return aggregated
我们将在稍后向 get_drilldown_table 添加更多逻辑。
总结来说,我们还需要一个函数来显示钻取表格(目前是一个 Pandas 数据框):
def display_drilldown_table(df):
if df is None:
st.warning("No data available for selected filters and date range")
else:
st.dataframe(df, use_container_width=True, hide_index=True)
这应该很简单;如果没有数据,我们显示警告,或者使用 st.dataframe 来显示聚合的表格。注意使用 hide_index=True。默认情况下,Streamlit 会显示索引字段(如您可能从第六章中回忆起,它是行的唯一标识符,默认为简单的序列号)并排显示在每个行旁边。您可以在图 7.10 中看到这一点(它们是数据框中极左边的数字)。我们不希望向用户显示索引,所以将其隐藏。
在做出这些更改后,我们还可以更新 drilldown 函数:
@st.dialog("Drilldown", width="large")
def drilldown(main_df, compare_df):
dimensions = drilldown_dimensions()
main_data = get_drilldown_table(main_df, dimensions)
compare_data = get_drilldown_table(compare_df, dimensions)
main_tab, compare_tab = st.tabs(["Main", "Compare"])
with main_tab:
display_drilldown_table(main_data)
with compare_tab:
display_drilldown_table(compare_data)
操作顺序是逻辑的:首先我们从用户那里获取钻取维度(dimensions = drilldown_dimensions()),然后使用 get_drilldown_table 计算聚合的数据框(对于 main_df 和 compare_df),最后使用 display_drilldown_table 在单独的标签页中显示它们。
如果您现在重新运行仪表板,您应该会看到一个更加令人满意的钻取视图版本,如图 7.12 所示。

图 7.12 带有维度选择器和按选定维度聚合的钻取视图(请参阅 GitHub 仓库中的 chapter_7/in_progress_06 以获取完整代码)
用户现在可以挑选他们想要的任何维度,并查看这些维度的每个组合的度量值。这实际上让用户能够钻取到所需的数据细节级别,但也许添加一个汇总“总计”行来理解这里正在分解的整体是有益的。
添加“总计”行
在钻取表格中添加一个汇总行相对复杂,原因有几个:
-
Pandas 数据框没有原生的方式来指定一行作为其他所有行的汇总。当我们需要总和时,我们必须使用各种操作将它们整理在一起。
-
维度值在总计行中是没有意义的,应该留空。
以一个例子来说明,假设我们有以下钻取数据框(在过滤和聚合之后):
+--------+----------------+--------------+-------------+--------------+----------+-------+
| Gender | Segment | Product name | Total sales | Gross margin | Margin % | ATV |
+--------+----------------+--------------+-------------+--------------+----------+-------+
| M | Fountain pens | InkStream | $100,000 | $60,000 | 60% | $10 |
| M | Fountain pens | RoyalQuill | $200,000 | $150,000 | 75% | $40 |
+--------+----------------+--------------+-------------+--------------+----------+-------+
在顶部添加总计行后,我们将得到一个看起来像这样的 dataframe:
+--------+----------------+--------------+-------------+--------------+----------+-------+
| Gender | Segment | Product name | Total sales | Gross margin | Margin % | ATV |
+--------+----------------+--------------+-------------+--------------+----------+-------+
| Total | | | $300,000 | $210,000 | 70% | $20 |
| M | Fountain pens | InkStream | $100,000 | $60,000 | 60% | $10 |
| M | Fountain pens | RoyalQuill | $200,000 | $150,000 | 75% | $40 |
+--------+----------------+--------------+-------------+--------------+----------+-------+
让我们在drilldown.py中使用一个新的函数add_total_row来实现这一点:
def add_total_row(df, all_df, dimensions):
total_metrics = get_metric_cols(all_df)
if dimensions:
dim_vals = {dim: '' for dim in dimensions}
dim_vals[dimensions[0]] = 'Total'
total_row = pd.DataFrame({**dim_vals, **total_metrics}, index=[0])
return pd.concat([total_row, df], ignore_index=True)
total_row = pd.DataFrame({'': 'Total', **total_metrics}, index=[0])
return total_row
add_total_rows函数接受三个参数:df、all_df和dimensions(我们一直在传递的相同维度名称列表)。df是我们到目前为止的钻取 dataframe(例如上面的第一个表格),而all_df是在聚合之前的原始细粒度列的 dataframe。
为什么这里需要同时使用df和all_df?回想一下,我们有一个get_metric_cols函数,它可以计算给定 dataframe 所需的所有指标——换句话说,就是我们要构建的“总计”行的数值。get_metric_cols期望一个原始的非聚合 dataframe,而不是聚合版本。这意味着我们需要传递all_df而不是df。
那确实是函数中的第一个语句所做的事情,将结果存储在total_metrics中。
下一部分在维度列表非空(if dimensions:)的情况下构建总计行,即如果用户选择了某些钻取维度。
以下两行与填充维度值相关:
dim_vals = {dim: '' for dim in dimensions}
dim_vals[dimensions[0]] = 'Total'
第一行是另一个字典推导式,为dimensions中的每个维度键都有一个空值。然后我们将第一个维度的值设置为"Total"。这实际上创建了我们上面示例中看到的总计行的文本显示值——第一个字段是"Total",其余所有字段都是空白。
我们在dim_vals(一个字典)中有总计行的维度值,在total_metrics(一个pd.Series)中有指标值。我们只需要将它们放在一起!这就是下一行所做的事情:
total_row = pd.DataFrame({**dim_vals, **total_metrics}, index=[0])
这里有一些有趣的语法,让我们来分解一下。
这里的字符序列**被称为字典解包运算符。它将字典中的项解包,以便它们可以与其他项结合形成新的字典,甚至可以作为函数参数传递。
前者是这里发生的情况。例如,如果dim_vals类似于{'Gender': 'Total', 'Segment': '', ...},而total_metrics是{'Total sales': 300000, …},则{**dim_vals, **total_metrics}会给你一个合并的字典{'Gender': 'Total', 'Segment': '', ..., 'Total sales': 300000, ...}。index=[0]将这个单行 dataframe 的唯一行的索引设置为 0。
你可能会注意到这个问题:我们刚才不是说过total_metrics是一个pd.Series而不是字典吗?好吧,虽然这是真的,但 Pandas 系列实际上具有许多常规 Python 字典的特性——其中包括对**运算符的支持。
下一行将这个总计行连接到其他钻取 dataframe 的剩余部分,并返回它:
return pd.concat([total_row, df], ignore_index=True)
现在,如果用户没有选择任何维度并且 dimensions 为空,获取带有总行的数据框变得更容易;我们只需向 total_metrics 添加一个表示 'Total' 的空白列,就没有必要连接行了——数据框只包含总行:
total_row = pd.DataFrame({'': 'Total', **total_metrics}, index=[0])
我们现在可以将获取总行的操作添加到 get_drilldown_table 中的转换,如下所示:
def get_drilldown_table(df, dimensions):
aggregated = get_aggregate_metrics(df, dimensions)
with_total = add_total_row(aggregated, df, dimensions)
return with_total
重新运行仪表板以查看总行看起来如何(见图 7.13):

图 7.13 显示了带有总行的钻取数据框视图(在 GitHub 仓库的 chapter_7/in_progress_07 中查看完整代码)
这几乎是完美的,但如果总行被突出显示或着色,以区别于其他行,那岂不是更好?
格式化和样式钻取表格
虽然我们已经准备好了我们的钻取表格内容,但展示方式还有一些不足之处:
-
如图 7.17 所示,表中的数字是用户不友好的原始数字,几乎没有格式化。理想情况下,我们希望它们以与指标栏相同的方式显示(例如,“$1.2m”而不是“1200000”)。
-
没有阴影区分总行和其他表格行。
让我们先解决前者。在表格中格式化数字应该是相当直接的,因为我们已经在第六章的 formatting.py 文件中定义了实际的格式化规则。
我们需要的只是一个函数,可以将格式应用到整个 Pandas 数据框,而不是指标栏中显示的单独数字。
在 formatting.py 中为这个新功能启动一个新的函数:
import humanize
def format_metric(value, metric_type):
...
def format_dataframe(df, metrics):
cols = df.columns
for col in cols:
if col in metrics:
df[col] = df[col].apply(format_metric, metric_type=metrics[col].type)
return df
format_dataframe 函数应该很容易理解。在接收两个参数(df,要格式化的数据框,以及来自 metrics.py 的 metrics 字典)后,我们只需遍历 df 中的列,并将 format_metric(我们在第六章中编写的函数)逐元素应用到每一列。
注意我们是如何将 metric_type 传递给 .apply() 函数作为 另一个 参数的!
实质上以下表达式:
df[col] = df[col].apply(format_metric, metric_type=metrics[col].type)
表示的是:为 df[col] 中的每个元素发出函数调用 format_metric(element, metric_type=metrics[col].type),并保存结果,这应该是我们的格式化数据框。
让我们接下来解决着色问题:假设我们想要给总行一个灰色背景,使其从表格的其他部分中脱颖而出。
这的关键在于 Pandas 数据框的 style 属性,它使我们能够应用 条件格式化(即基于某些规则的格式化)到数据框。
为了实现这一点,我们将使用 style 属性的 .apply 方法,以及一个自定义函数,该函数定义了要应用的条件样式。
让我们在 drilldown.py 中创建一个新的函数来实现这个逻辑:
def style_total_row(df):
def get_style(row):
first_col = row.index[0]
return [
'background-color: #d9d9d9' if row[first_col] == 'Total' else ''
for _ in row
]
return df.style.apply(get_style, axis=1)
style_total_row函数接受钻取数据框df,并应用所需的阴影。为了实现这一点,它在其体内定义了另一个名为get_style的函数!
在 Python 中,定义在另一个函数中的函数称为嵌套函数或内部函数。Python 认为嵌套函数是封装函数作用域内的局部变量。换句话说,style_total_row之外的所有代码都不能调用get_style。
接下来,我们来看get_style函数的逻辑,它操作 Pandas 数据框的单行,因此将行作为参数传入。
然后,它使用first_col = row.index[0]确定数据框的第一列名称。数据框行的index属性是一个类似列表的对象,包含其列的名称,因此index[0]给出第一列的名称。
下一行定义(并返回)了我们想要应用的真正条件样式:
return [
'background-color: lightgray' if row[first_col] == 'Total' else ''
for _ in row
]
我们返回的表达式是一个列表推导式,通过迭代某个东西(类似于我们之前看到的如何构建新的字典)来构建一个新的列表。
在这种情况下,我们正在通过for _ in row遍历数据框行的字段。实际上我们不需要引用字段本身,这就是为什么我们在这里使用_——一个完全有效的 Python 标识符——作为循环索引。
对于每个字段,如果传入的行是总行(我们通过检查第一列的值是否为"Total"来验证),我们在我们构建的列表中添加一个特殊的字符串,'background-color: lightgray'。
这种表示法来自 CSS,这是用于样式化网页的语言。我知道我承诺过你不需要学习 CSS 就能阅读这本书,但这个特定的部分应该足够明显:我们正在告诉 Pandas 给总行中的每个字段一个浅灰色背景。
我们现在已经定义了我们想要应用的条件样式,但我们仍然需要应用它。style_total_row中的最后一行执行此操作:
return df.style.apply(get_style, axis=1)
.apply在这里期望一个接受数据框行的函数,因此它可以在每一行上调用它(正如我们之前看到的)。
要完成我们的钻取视图,我们还需要添加get_drilldown_table的格式化和样式:
...
from formatting import format_dataframe
...
def get_drilldown_table(df, dimensions):
aggregated = get_aggregate_metrics(df, dimensions)
with_total = add_total_row(aggregated, df, dimensions)
formatted = format_dataframe(with_total, metrics)
styled = style_total_row(formatted)
return styled
就这样!我们的钻取视图现在已经完全形成。在图 7.14 中查看。

图 7.14 完成的钻取视图,带有阴影的总行和格式化值(完整代码请见 GitHub 仓库中的 chapter_7/in_progress_08)
呼吁!这真是做了很多工作!然而,在我们完成之前,我们还有更多功能请求要处理。
st.dialog 的片段式行为
如果你特别关注了第四章中我们学习的 Streamlit 执行模型,那么我们实现钻取视图的一个方面可能会让你感到困惑。
要显示对话框,我们将其嵌套在一个按钮下面,如下所示(在dashboard.py中):
if dd_button_container.button("Drilldown", use_container_width=True):
drilldown(main_df, compare_df)
drilldown是一个用st.dialog装饰的函数。在钻入中,我们可以执行许多交互,例如选择一个维度或设置一个过滤器。
但在之前的项目中,我们发现st.button的“点击”状态只持续一次重运行,而且每次我们与按钮下嵌套的任何东西交互时,应用都会再次运行,并且按钮点击会被清除。实际上,我们不得不在第四章跳过一系列的障碍,使用st.session_state来获得我们想要的行为。
但在这种情况下,我们不需要做任何这些。交互钻入不应该导致按钮点击重置并且钻入消失吗?
这并不是因为st.dialog表现出某种特殊行为。当用户与st.dialog装饰的函数内的小部件交互时,只有装饰的函数会重新运行,而不是整个应用!
在上述情况下,当有人选择一个钻入维度时,只有钻入功能会重新运行,按钮保持在“点击”状态。st.dialog从更通用的装饰器st.fragment中获取这种行为。
7.6 启用 deeplinks
下一个反馈列表上的问题是关于无法与其他人共享仪表板视图的投诉。自然地,CEO 经常在应用了各种过滤器和选择后,通过电子邮件向他的报告发送他在仪表板上看到的数据。
当收到这样的反馈时,下属会花很多时间尝试重新创建他的老板在仪表板上看到的内容,有时会通过试错来获取正确的过滤器和日期范围。“这,”CEO 写道,“相当于一种协作税。”
通过数据做出决策不是一项孤立的活动,或者在任何企业中都不应该是。你通常至少希望有其他几对人眼来验证你打算做出的决策。这对我们仪表板的当前用户来说是一个真正的问题。
使用仪表板,他们可能识别出对公司正在评估的决策至关重要的趋势或数据点。然而,如果他们要与别人分享,他们有两个选择:要么截图应用,要么给分享者提供如何重新创建视图的说明。
这两种方法都不是理想的。截图阻止了其他人进一步与应用交互,而另一种方法显然是低技术的(想象一下用户告诉某人,“你做错了。你需要应用去年的日期范围,过滤 18-25 岁的年龄组,并选择月度粒度!”)。
如果用户可以直接将他们正在查看的 URL 复制粘贴到聊天中,而接收者可以访问该 URL 并看到第一个用户看到的确切内容,那岂不是很好?毕竟,这对许多其他网站都有效。例如,当你使用像 Google 或 DuckDuckGo 这样的搜索引擎时,你可以通过发送你的搜索 URL 直接将某人带到搜索结果页面。
这种功能被称为深度链接,即在您的网站上链接某人“深入”。
深度链接是如何工作的?让我们以搜索引擎 DuckDuckGo 的一个例子来说明。如果您在 duckduckgo.com 上搜索"streamlit",搜索结果页面的 URL 将类似于:
https://duckduckgo.com/?t=h_&q=streamlit&ia=web
将此 URL 复制并粘贴到您的浏览器中,它将直接带您到查询“streamlit”的搜索结果页面。URL 中显示 q=streamlit 的部分使得这一点成为可能。该 URL 包含了第一个用户输入的信息,DuckDuckGo 使用这些信息将第二个用户引导到正确的页面。
如果我们将这种逻辑应用到我们的应用中,实现深度链接需要两个东西:
-
一种在应用 URL 中嵌入用户输入的方法
-
给定这样的 URL,一种在应用中自动重新填充这些输入的方法
7.6.1 使用 st.query_params
URL 中包含实际地址之后“额外”信息的那部分被称为查询字符串。它通过问号(?)字符与 URL 的其余部分分开。查询字符串由几个称为查询参数的键值对组成,这些键值对依次通过 ampersand(&)字符分隔。
例如,在上文中我们讨论的 URL 中,即 https://duckduckgo.com/?t=h_&q=streamlit&ia=web:
-
查询字符串是
t=h_&q=streamlit&ia=web -
查询参数是:
t=h_(键t和值h_),q=streamlit(键q和值streamlit),以及ia=web(键ia和值web)。
如果我们的应用有查询参数,它们会是什么样子?嗯,由于查询字符串需要捕获用户输入的信息,它可能看起来像以下这样:
start_date=2024-08-01&end_date=2024-08-31&product_name=RoyalQuill
实质上,用户的选项需要成为查询字符串的一部分(因此,也是 URL 的一部分)。
Streamlit 允许您通过 st.query_params 管理查询参数,这是一个类似于 st.session_state 的字典样式的对象。
在任何时刻,st.query_params 包含您应用 URL 查询字符串中的所有键值对。您也可以通过修改 st.query_params 来修改浏览器地址栏中的查询字符串。
在 st.query_params 中获取和设置参数的语法与使用字典相同。例如,代码 st.query_params["pie_chart_dimension"] = "Gender" 会设置 pie_chart_dimension 参数,更新 URL 以包含 pie_chart_dimension=Gender。
您也可以这样读取参数的值:
dimension = st.query_params["pie_chart_dimension"]
因此,我们的解决方案将涉及类似于图 7.15 的内容。

图 7.15 实现深度链接的方法
当某人第一次导航到我们的应用时,如果 URL 中有查询参数,我们应该从 URL 中提取它们。
然后,我们根据这些参数设置小部件的值。例如,如果我们有一个 start_date=2024-08-01 作为查询参数之一,我们将在日期范围选择器小部件中将开始日期设置为 2024-08-01。如果没有查询参数,或者没有为特定小部件指定值,我们不会设置小部件的值;相反,我们让默认行为接管。
然后,当有人在小部件中更改选择时,我们会 更新 查询参数以反映该更改,从而也更改地址栏中的 URL。这样,如果用户复制 URL,它总是包含他们在应用程序中做出的最新选择!
7.6.2 通过 st.session_state 设置小部件默认值
我们方案中有一个部分在之前的任何章节中都没有涉及。我们如何通过编程设置输入小部件的值?
回想第四章,Streamlit 中的每个小部件(或 UI 元素)都有一个唯一的标识符,称为小部件键。键通常由 Streamlit 自动创建,但如果您有两个相同的小部件,您必须手动为每个小部件提供一个键,以便 Streamlit 可以区分它们。我之前没有提到的一个巧妙之处是,每次您为小部件提供一个键时,其值就会在 st.session_state 中变得可访问。
因此,如果您有一个如下编码的下拉输入:
st.selectbox("Pick a field", ["Gender", "Product name"], key="select_dim")
您可以使用 st.session_state["select_dim"] 来访问其值
对我们来说非常重要的一点是,我们还可以 设置 其值,模拟用户选择,如下所示:
st.session_state["select_dim"] = "Product name"
一个需要注意的问题是,我们只能在 运行小部件代码之前 做这件事。换句话说,我们可以在 st.session_state 中设置小部件键的值 首先,然后在小部件渲染时采用该值,但您不能先渲染带有键的小部件,然后通过在 st.session_state 中设置键的值来 覆盖 其值。
7.6.3 实现 deeplinks
我们现在拥有了构建 deeplink 功能所需的所有信息。创建一个名为 query_params.py 的新文件,其内容如列表 7.2 所示。
列表 7.2 query_params.py
import streamlit as st
def get_param(key):
return st.query_params.get(key, None)
def set_widget_defaults():
for key in st.query_params:
if key.startswith('w:') and key not in st.session_state:
st.session_state[key] = get_param(key)
def set_params():
query_params_dict = {}
for key in st.session_state:
if key.startswith('w:'):
value = st.session_state[key]
query_params_dict[key] = value
st.query_params.from_dict(query_params_dict)
get_param 函数通过使用 st.query_params 的 .get() 方法获取特定查询参数的值。它这样做的方式与常规字典相同,如果键不存在,则返回默认值 None。
set_widget_defaults 通过迭代它们并设置 st.session_state 中每个小部件键的值,从查询参数中填充应用程序中各种小部件的值。
为什么我们有以下条件?:
if key.startswith('w:') and key not in st.session_state:
我们不一定希望 st.session_state 中存储的 所有 键都出现在 URL 中,只是那些代表小部件的键。为了确保这一点,稍后我们将为查询参数中想要包含的每个小部件键添加前缀 'w:'。这使我们能够在需要时使用 st.session_state 来实现其他目的,同时仍然能够使用它来自动填充小部件的值。
我们也不希望 Streamlit 在每次重新运行应用程序时尝试从 st.query_params 中设置小部件值,因为这样用户就无法更改值了。相反,我们只想在应用程序第一次从参数嵌入的 URL 加载时设置每个小部件值一次。这就是为什么我们有子条件 key not in st.session_state。
set_widget_defaults 完成了我们需要的深链接的第一部分——从 URL 中填充小部件输入的能力。然而,我们仍然需要在用户做出选择时更改查询参数。
这就是 set_params 函数所做的事情。它遍历 st.session_state,获取每个小部件键的值并将它们存储在一个字典中,即 query_params_dict。然后,它使用 from_dict 方法直接从这个字典中填充所有的 st.query_params。正如我之前所展示的,我们也可以在 st.query_params 中设置每个值,但我还想展示这种方法。
当然,为了使这可行,我们想要包含在查询参数中的所有小部件都必须有键定义,以 'w:' 开头。因此,我们需要遍历我们所有的代码,并为每个小部件添加小部件键。这恐怕不是一件很有趣的事情,但这是必须完成的。
如果你正在跟随,以下是我们需要进行的修改:
对 date_range_panel.py 的修改
在 date_range_panel.py 文件中,我们向用户展示了三个日期选择小部件。我们需要为它们每个都添加键。这些键将变成:
start = st.date_input("Start date", value=THIRTY_DAYS_AGO, key="w:start")
end = st.date_input("End date", value=LATEST_DATE, key="w:end")
comparison = st.selectbox(
"Compare to", ["MoM", "QoQ", "YoY", "Previous period"], key="w:compare")
只要键以 w: 开头,键的确切名称并不重要。不过这里有一个额外的复杂性。
注意,我们目前通过 st.date_input 中的 value 参数将默认值 THIRTY_DAYS_AGO 和 TODAY 分别分配给开始和结束日期选择器。
当我们使用 st.session_state 来设置小部件值——就像我们在之前创建的 set_widget_defaults 函数中所做的那样——如果我们也尝试使用 value 参数来设置值,Streamlit 将会抛出一个错误。我们不能同时使用这两种方法,我们必须选择其中一种。
if 'w:start' not in st.session_state:
st.session_state['w:start'] = THIRTY_DAYS_AGO
if 'w:end' not in st.session_state:
st.session_state['w:end'] = LATEST_DATE
start = st.date_input("Start date", key="w:start")
end = st.date_input("End date", key="w:end")
在这里,我们已经从两个小部件中移除了 value 参数,并在定义小部件之前添加了一些逻辑来使用 st.session_state 设置相同的值。这些行必须在定义小部件之前。
作为参考,整体的 date_range_panel 函数现在如下所示:
...
def date_range_panel():
if 'w:start' not in st.session_state:
st.session_state['w:start'] = THIRTY_DAYS_AGO
if 'w:end' not in st.session_state:
st.session_state['w:end'] = LATEST_DATE
start = st.date_input("Start date", key="w:start")
end = st.date_input("End date", key="w:end")
comparison = st.selectbox(
"Compare to", ["MoM", "QoQ", "YoY", "Previous period"], key="w:compare")
compare_start, compare_end = get_compare_range(start, end, comparison)
st.info(f"Comparing with: \n{compare_start} - {compare_end}")
return start, end, compare_start, compare_end
对 filter_panel.py 的修改
filter_panel.py 中有我们需要添加键的多选框:
...
def filter_panel(df):
...
with st.expander("Filters"):
...
for idx, dim in enumerate(filter_dims):
with filter_cols[idx]:
...
filters[dim] = st.multiselect(
dim, unique_vals, key=f'w:filter|{dim}')
return filters
在这种情况下,由于多个小部件通过循环填充,我们使用 f-string f'w:filter|{dim}' 作为键,使用维度名称 dim 来区分键。
对 pie_chart.py 和 time_series_chart.py 的修改
在 pie_chart.py 中,向分配给 split_dimension 的 st.selectbox 添加一个键:
...
split_dimension = st.selectbox(
"Group by", pie_chart_dims, key="w:pie_split")
类似地,在 time_series_chart.py 中,向 time_series_chart 函数中的 grain 和 split_dimension 添加键:
...
def time_series_chart(df):
with st.container(border=True):
...
grain = grain_col.select_slider(
"Time grain", grain_options, key="w:ts_grain")
split_dimension = split_col.selectbox(
"Group by", ["None"] + time_chart_dims, key="w:ts_split")
………
在小部件键就位后,我们现在可以从 dashboard.py 中调用之前在 query_params.py 中定义的相关功能:
...
from query_params import set_widget_defaults, set_params
st.set_page_config(layout='wide')
set_widget_defaults()
...
set_params()
注意我们放置 set_widget_defaults 和 set_params 调用的确切位置。如前所述,我们只能在创建任何小部件之前使用 st.session_state 来设置小部件键值,因此 set_widget_defaults() 调用需要放在最上面(紧随 st.set_page_config(layout='wide') 之后,这需要是第一条命令)。
另一方面,查询参数需要捕获用户更改的任何小部件的变化,因此 set_params 调用必须放在 dashboard.py 的末尾,在所有小部件创建之后。
让我们测试一下我们的 deeplinks!保存一切并重新运行应用程序。然后在应用程序中尝试进行以下选择:
-
将 "开始日期" 设置为 2024/07/01,并将 "比较" 设置为 YoY。
-
将折线图中的 "时间粒度" 滑块设置为周。
如果你现在检查浏览器地址栏中的 URL,它应该看起来像:
http://localhost:8501/?w%3Ats_grain=Week&w%3Acompare=YoY&w%3Aend=2024-08-31&w%3Astart=2024-07-01&w%3Apie_split=Age+group
当一个网页 URL 包含某些特殊字符,例如冒号(:)或空格时,它会被转换为百分编码字符以确保浏览器和 Web 服务器能够正确解释它们。每个特殊字符通常会被一个 % 符号后跟两个十六进制数字代码所替换,该代码代表 ASCII 标准中的原始字符。一个例外是空格字符,当它在 URL 的查询参数部分出现时,会被编码为 + 符号。
在我们的情况下,以下替换已经发生:
-
冒号字符已变为
%3A,因此w:ts_grain变为w%3Ats_grain -
空格字符已变为
+,因此Age group变为Age+group
反向替换这些值,URL 变为:
这基本上是我们预期的——我们做出的选择反映在 URL 中(以及饼图维度选择框的值,它默认获得一个非空值——Age group,这会自动捕获到 URL 中)。
注意
由于我们目前正在本地开发,URL 以 http://localhost: 开头。当我们部署我们的应用程序时,localhost 部分将被应用程序的地址所替换。例如,如果我们部署到 Streamlit Community Cloud 下的 ceo-dashboard.streamlit.app,我们的带有查询参数的 URL 将类似于 https://ceo-dashboard.streamlit.app?query_param1=value1&query_param2=...
接下来,将您复制的原始 URL 粘贴到另一个浏览器标签中并导航到它。不幸的是,应用程序抛出了一个错误(请参阅图 7.16)。

图 7.16 当我们无意中向 st.date_input 传递字符串时,我们会得到一个错误(有关完整代码,请参阅 GitHub 仓库中的 chapter_7/in_progress_09)。
错误信息声称我们试图向一个 "DateInput" 传递了错误类型的值,可能是 "开始日期" 和/或 "结束日期" 小部件。
这里的问题是,在上面的(解析后的)URL 中,分配给开始日期小部件(键为 w:start)的值是字符串 "2024-07-01":
w:start=2024-07-01
当 Streamlit 试图将此值分配给最终定义的日期输入小部件时,它会导致错误,因为st.date_input期望一个日期对象,而不是字符串。
我们过滤器输入也存在类似的问题:这些小部件期望列表(因为您可以选择多个值),但我们传递的是字符串。
当要设置的值不是字符串而是日期或列表时,我们需要一些特殊的处理逻辑。
首先,当在st.query_params中设置此类小部件的值时,让我们添加一个前缀来表示我们放置的值是一个列表或日期——比如说,对于列表使用L#,对于日期使用D#。以下是经过修改的query_params.py中的set_params函数:
import streamlit as st
from datetime import date
...
def set_params():
query_params_dict = {}
for key in st.session_state:
if key.startswith('w:'):
value = st.session_state[key]
if value:
if isinstance(value, list):
value = f'L#{','.join(value)}'
elif isinstance(value, date):
value = f'D#{value.isoformat()}'
query_params_dict[key] = value
st.query_params.from_dict(query_params_dict)
在这里,在将值添加到query_params_dict之前,我们使用isinstance检查其类型。如果是列表,我们将其转换为特定格式的字符串(例如,['M', 'F']变为L#M,F)。如果是日期,我们将其转换为不同的格式(例如,2024-08-01变为D#2024-08-01)。
我们还需要反向逻辑来解码这些字符串格式,并将它们转换回原始值。这部分代码位于get_param函数中,我们将完全重写它:
def get_param(key):
if key not in st.query_params:
return None
value = st.query_params[key]
if value.startswith('L#'):
return value[2:].split(',')
if value.startswith('D#'):
return date.fromisoformat(value[2:])
return value
如您所见,对于特定的键,如果值是一个以我们的特殊前缀之一开头的字符串——无论是L#还是D#——我们进行反向转换,将字符串转换为原始列表或日期。
当我们在set_widget_defaults中使用这个返回值时,它将因此处于预期的类型,与用户最初设置的值相同,从而消除了错误。
您可以通过重试前面的步骤亲自查看。现在您应该可以看到图 7.17,展示了您现在可以复制并粘贴仪表板的当前 URL,以向他人展示您所看到的确切内容。

图 7.17 仪表板中的小部件是根据 URL 值填充的(有关完整代码,请参阅 GitHub 仓库中的 chapter_7/in_progress_10)
再探索一下 deeplinks。注意,Nib 的执行人员现在可以花更少的时间摆弄仪表板,有更多时间做出对公司有利的决策!
7.7 从数据仓库中获取数据
我们已经解决了所有关于仪表板的用户反馈,但还有一个明显的实际问题我们尚未讨论:仪表板中显示的数据来自静态 CSV 文件。
我选择这种方法是因为我想让我们主要关注我们一旦拥有数据后如何处理数据。在我们的应用中,读取静态 CSV 文件可能是摄入数据的最简单方式。然而,这种方法存在多个问题:
-
当处理少量数据时,CSV 文件是可管理的,但当处理大量数据集时,它很快就会变得低效。
-
我们无法在源处灵活查询数据,而必须将其加载到内存中执行过滤、聚合和连接等操作。
在现实世界中,我们通常会把这个数据存储在 数据仓库 中,这是一个专门为管理大量结构化数据而设计的系统。在本节中,我们将用数据仓库中的表替换我们的 CSV 文件——具体来说就是 Google BigQuery。
7.7.1 将数据导入 BigQuery
Google BigQuery 是 Google Cloud Platform (GCP) 的一部分,是一个基于云的数据仓库服务。它允许您使用名为 结构化查询语言 (SQL) 的语言高效地存储和分析大量数据集,无需管理基础设施或担心扩展问题。
要开始,您需要设置一个 GCP 账户,您可以在 cloud.google.com 上完成此操作。您可能需要输入支付方式详情,例如信用卡,但由于我们只是要使用此练习中的免费资源,您不会收到任何费用。
当您创建新账户时,Google 也会为您创建一个 GCP 项目。在 GCP 术语中,项目是组织和管理您的 Google Cloud 资源的一个容器。您需要有一个项目来使用 BigQuery;您可以自由使用为您创建的默认项目或创建一个新的项目。项目有一个唯一的 ID;您可以在创建项目时选择这个 ID,但默认 ID 是随机生成的字符串。例如,我的默认项目 ID 是 dauntless-brace-436702-q0。
接下来,让我们转到 BigQuery 本身。Google Cloud 非常庞大,提供的产品和服务众多,其 UI 可能会让初学者感到 intimidating。最可靠的方法可能是将搜索字符串 "bigquery" 输入顶部的搜索框中(如图 7.18)。

图 7.18 在 GCP 中找到某物的最可靠方法是使用搜索栏。
一旦进入 BigQuery 页面,您应该能够在左侧的探索器侧面板中看到您的 BigQuery 资源,这些资源按您的 GCP 项目分类。"资源" 这里指的是 "查询"、"笔记本"、"工作流" 等等,所有这些都可能被忽略。
我们试图通过上传我们的 CSV 文件来创建一个 BigQuery 表。在我们能够这样做之前,我们需要创建一个数据集。BigQuery 数据集只是您在项目内组织表的一种方式。
通过在探索器面板中点击项目 ID 旁边的三个点,然后选择 "创建数据集"(如图 7.19),来创建您的第一个数据集。

图 7.19 BigQuery 中的探索器面板
这将打开一个屏幕,您可以配置数据集。您需要输入的只是名称(我选择了 sia_ceo_dashboard);其余选项可以使用默认设置。
创建后,您的数据集应该会出现在探索器面板中。点击它旁边的三个点,然后选择 "创建表",以进入创建表的屏幕,在那里我们可以上传我们的文件。
在“从创建表”选项下选择“上传”,并将文件格式选为“CSV”。然后您可以从本地磁盘选择sales_data.csv文件。您需要为表格选择一个名称(sales_data是一个不错的选择)。其余选项应该是直截了当的,并且很可能会自动填充:您的项目 ID 和您刚刚创建的数据集名称。图 7.20 显示了屏幕的外观。

图 7.20 BigQuery 中的表创建屏幕
在“模式”下勾选“自动检测”框,这样您就不必手动输入。然后点击底部的按钮来实际创建您的表格。
到这一步,您数据已经在 BigQuery 中,表格应该出现在资源管理器中您的数据集下。如果您愿意,可以点击进入并转到“预览”标签来查看数据。
7.7.2 设置 Python-BigQuery 连接
我们现在可以通过 BigQuery 界面访问我们的数据,但我们还需要能够从我们的 Python 代码中连接到它。
启用 BigQuery 和 BigQuery 存储 API
首先,我们需要在我们的 GCP 项目中启用一些与 BigQuery 相关的 API:BigQuery 和 BigQuery 存储 API。BigQuery API 使我们能够首先连接到 BigQuery,而存储 API 使得将数据快速导入 Pandas 数据框变得更快。
在每种情况下,相应的结果都应引导您到一个页面,您可以在该页面启用 API。
创建服务账户
由于我们的应用将通过编程方式连接到 BigQuery,我们需要一个GCP 服务账户来处理身份验证。服务账户是一种特殊类型的账户,属于您的应用程序而不是个人用户。它使您的应用能够进行身份验证并与 Google Cloud 服务(包括 BigQuery)交互。
要创建服务账户,首先在 Google Cloud 导航菜单中找到“IAM & Admin”,然后找到“服务账户”(或者更好的是,搜索“服务账户”并点击第一个结果)。
在“服务账户”页面中,点击创建账户的选项。此屏幕将要求您输入服务账户名称(我使用了sia_service_account)和描述。一旦创建账户,您还需要在同一屏幕上授予它对您项目的访问权限。创建时选择“查看者”角色。
您的服务账户现在应该显示在服务账户页面中。
创建服务账户密钥
我们有一个可以访问我们的 BigQuery 资源的服务账户,但我们仍然需要获取让我们的 Streamlit 应用能够以服务账户身份操作的凭证。为此,我们需要一个服务账户密钥。
在服务账户页面中找到您刚刚创建的账户,点击它旁边“操作”下的三个点,然后点击“管理密钥”。
点击 "添加密钥" > "创建新密钥" 并选择 "JSON" 作为密钥类型。当你点击 "创建" 时,你的计算机应该会自动下载一个 JSON 文件。使用文本编辑器检查此文件。它应该包含你从应用程序访问 BigQuery 所需的凭证,以及一些额外的详细信息,如你的项目 ID。
生成 secrets.toml
我们刚刚获得的凭证必须保密,因为它们允许任何拥有它们的人读取你的 BigQuery 数据。回想一下第五章,在 Streamlit 中维护机密信息的最佳方式是使用 secrets.toml 文件结合 st.secrets。
不幸的是,我们拥有的凭证文件是 JSON 格式的,因此我们需要将其转换为 TOML。你可以手动完成此操作,但让我们使用 Python 来完成。
首先创建一个 .streamlit 文件夹来存放你的 secrets.toml 文件。
将你的 JSON 文件重命名为 sia-service-account.json,然后从同一文件夹打开 Python 命令行,并输入以下命令:
>>> import json
>>> import toml
>>> with open('sia-service-account.json') as json_file:
... config = json.load(json_file)
...
>>> obj_to_write = {'bigquery': config}
>>> with open('.streamlit/secrets.toml', 'a') as toml_file:
... toml.dump(obj_to_write, toml_file)
...
'[bigquery]\ntype = "service_account"\nproject_id = "dauntless-...'
<Rest excluded for brevity>
注意
你可能首先需要运行 "pip install toml" 来使这生效。
我们在这里所做的只是打开我们从 GCP 获得的 JSON 文件,将其读取到一个 Python 字典中,并将其写回到 secrets.toml 中的 "bigquery" 键下。如果你现在打开 secrets.toml,你应该能够看到以 TOML 格式显示的凭证。
[bigquery]
type = "service_account"
project_id = "dauntless-brace-436702-q0"
private_key_id = ...
...
7.7.3 更新仪表板以从 BigQuery 加载数据
是时候更新我们的代码,从 BigQuery 而不是静态 CSV 文件中获取数据源了。我们需要安装三个新的 Python 模块来实现这一点,所以请继续在终端窗口中输入以下命令:
pip install google-cloud-bigquery
pip install google-cloud-bigquery-storage
pip install db-dtypes
前两个是访问 BigQuery 和 BigQuery Storage API 所必需的。db-dtypes 是必需的,以便将 BigQuery 返回的数据转换为 Pandas 数据框。
由于我们以模块化的方式编写了代码,我们唯一需要更改的是 data_loader.py 中 load_data 函数的实现,而我们的应用程序的其他部分应该像以前一样工作。这是我们在第三章中讨论的“关注点分离”原则的优势。
列表 7.3 显示了新的 data_loader.py,其中 load_data 已重新实现以使用 BigQuery。
列表 7.3 重新实现了 data_loader.py 以使用 BigQuery
import streamlit as st
from google.cloud import bigquery, bigquery_storage
DATASET = "sia_ceo_dashboard"
TABLE = "sales_data"
def load_data():
service_account_info = st.secrets["bigquery"]
client = bigquery.Client.from_service_account_info(service_account_info)
creds = client._credentials
storage_client = bigquery_storage.BigQueryReadClient(credentials=creds)
project_id = service_account_info["project_id"]
query = f"SELECT * from `{project_id}.{DATASET}.{TABLE}`"
query_job = client.query(query)
result = query_job.result()
return result.to_dataframe(bqstorage_client=storage_client)
我们在顶部保留了一些常量(DATASET 和 TABLE),以保存我们在 BigQuery 中创建的数据集和表的名称。
在 load_data 中,我们首先将 st.secrets 中 bigquery 键的凭证保存到 service_account_info。然后我们将这些凭证传递进去创建一个 BigQuery 客户端(本质上是一个包含与 BigQuery 交互所需的方法和抽象的对象):
client = bigquery.Client.from_service_account_info(service_account_info)
我们希望在 BigQuery Storage API 客户端中也使用相同的凭证,因此我们从 BigQuery 客户端提取凭证,并使用它们来初始化 storage_client:
creds = client._credentials
storage_client = bigquery_storage.BigQueryReadClient(credentials=creds)
我们现在已建立连接。
在 BigQuery 中,表是通过项目 ID、数据集名称和表名称的点分隔组合来引用的。例如,我创建的表将被引用为:
dauntless-brace-436702-q0.sia_ceo_dashboard.sales_data
我们从凭证中获取项目 ID (project_id = service_account_info["project_id"]),以及从上面创建的常量中获取数据集和表名。
我们使用表引用来构建这样的 SQL 查询:
query = f"SELECT * from `{project_id}.{DATASET}.{TABLE}`"
在第八章中,我们将遇到更多的 SQL,但就目前而言,你需要理解的是,“SELECT * from <table>”意味着“从 <table> 获取所有列”。本质上,我们是在告诉 BigQuery 返回表中的所有数据。
虽然我们在这里没有这样做,但我们本可以使用不同的 SQL 查询来获取数据的某些 子集;如果我们还在使用 CSV 文件,我们就无法这样做。
接下来的两行执行查询本身,等待其完成,并将结果保存到 result:
query_job = client.query(query)
result = query_job.result()
最后,我们使用 BigQuery 存储客户端来优化性能,将结果转换为 Pandas 数据框,并返回数据框。
如果你再次执行 streamlit run dashboard.py(由于我们使用了 st.cache_data,你无法在浏览器中重新运行应用程序,因为这会返回之前缓存的版本),应用程序现在将从 BigQuery 中拉取数据!
7.7.4 Streamlit 社区云部署注意事项
在第五章中,我们探讨了如何将我们的应用程序部署到 Streamlit 社区云。为此所做的过程对我们指标仪表板来说仍然相同,但我想要强调几点。
第一部分与数据存储位置有关。在部署时,如果你使用静态 CSV 方法来获取数据,你需要将 CSV 文件提交到 git 中,本质上是在你的 GitHub 仓库中存储它。
如果你使用的是 BigQuery,显然不需要 CSV,你也不必将其检入你的仓库。然而,你确实需要在 Streamlit 社区云中使用我们在第五章中使用的过程来配置你的 GCP 凭证。
你还需要创建一个包含我们使用和需要的所有模块的 requirements.txt 文件,并让社区云进行安装。如第五章所述,你可以使用 pip freeze 命令来识别我们使用的库的具体版本。
列表 7.4 提供了仪表板的示例 requirements.txt。
列表 7.4 requirements.txt
humanize>=4.10.0
streamlit>=1.38.0
plotly>=5.23.0
google-cloud-bigquery==3.25.0
google-cloud-bigquery-storage==2.26.0
db-dtypes==1.3.0
我们终于准备好发布仪表板的 2.0 版本了!毫无疑问,之后会有更多的反馈,每一次迭代都会进一步改进我们的仪表板。
然而,现在是我们告别 Note n' Nib 及其数据需求的时候了。在下一章中,我们将从数据洞察转向交互式工具,因为我们深入到一个用于创建、存储和共享俳句的 Web 应用程序。
7.8 概述
-
启动 应用程序只是使其成功的第一步。你还需要 着陆 它,确保它满足用户的需求。为此,直接听取用户的反馈至关重要。
-
st.select_slider是st.selectbox和st.slider的结合,用于当你想要在选项之间施加逻辑顺序时使用。 -
st.metric可以显示与指标相关的增量,即一个值随时间的变化情况。 -
st.dialog是一个装饰器,允许你创建一个模态对话框,一个覆盖层,它会阻止与应用程序其余部分的交互。 -
你可以使用
st.container在你的应用程序中创建占位符,只有当内容可用时才会渲染你想要显示的内容。 -
Pandas 数据帧有一个样式属性,可以用来设置条件规则,以修改它们在屏幕上的显示方式。
-
st.query_params是一个类似于字典的对象,允许你读取和更新 URL 查询参数——这可以用来在应用程序中启用深链接。 -
数据仓库是一个专门设计的系统,用于存储和检索大量数据。
-
Google BigQuery——GCP 的一部分——是数据仓库的一个例子。要使应用程序能够连接到它,你需要创建一个带有密钥的服务帐户,并将凭据记录在
st.secrets中。
第八章:8 使用 Streamlit 构建 CRUD 应用程序
本章涵盖
-
为持久存储设置关系型数据库
-
使用 SQL 执行 CRUD 操作
-
开发多页 Streamlit 应用程序
-
在 Streamlit 应用程序中创建共享数据库连接
-
用户身份验证
1957 年,一位科幻小说作家 Theodore Sturgeon 著名地说,“百分之九十的一切都是 CRUD”。虽然这最初是对科幻小说体裁的一种愤世嫉俗的辩护——其观点是,在这方面它与任何其他事物没有区别——但这个谚语后来获得了不同的含义,成为软件工程中最不为人知的秘密:百分之九十的一切都是 CRUD。
通过 CRUD,我指的是创建、读取、更新和删除,这四种平凡操作几乎出现在任何著名的软件中。
想想看。像 Facebook 这样的社交媒体平台围绕着创建帖子、阅读动态、更新个人资料和删除内容。电子商务网站通过类似操作管理产品、订单、客户账户和评论。即使是 Windows 上的简单记事本也围绕着创建、读取、更新和删除文本文件。
精通 CRUD 操作对于在软件设计方面打下坚实基础至关重要,因为它们的实现通常涉及解决非平凡挑战。在本章中,我们将使用 Streamlit 创建一个 CRUD 应用程序,从头开始实现这些操作,同时涵盖相关主题,如用户身份验证。
8.1 Haiku Haven:Streamlit 中的 CRUD 应用程序
对于我们的 CRUD 探险,我们将选择日本艺术中的俳句——具体来说,我们将创建一个网站,让用户能够编写和分享他们自己的俳句。
对于不太熟悉日本文学的人来说,俳句是一种遵循某些规则的短三行诗:第一行和第三行必须包含五个音节,而第二行必须包含七个音节。例如,这是我关于 Streamlit 写的一首俳句:
这么多网络应用!
用 Python 我能做吗?
然后我尝试了 Streamlit。
我知道,对吧?我有时也怀疑自己是否错过了自己的使命。不管怎样,你会发现这首诗遵循了我上面提到的 5-7-5 音节规则,因此是一个有效的俳句。
Haiku Haven 将是一个初学者诗人可以创作、精炼和管理俳句的地方。它将使用户能够从头开始创建俳句,阅读他们所创作的俳句,更新他们已经创建的俳句,如果他们决定它不符合标准,则删除它。
8.1.1 陈述概念和需求
如往常一样,我们将首先简洁地陈述我们应用程序的概念:
概念
Haiku Haven,一个允许用户创建、编辑和管理俳句的网站
这个概念以及我们之前关于 CRUD 的讨论应该给你一个基本的概念,但让我们明确具体的需求,以便我们对 Haiku Haven 的愿景达成共识。
需求
Haiku Haven 的用户应该能够:
-
使用用户名和密码创建和登录账户
-
在他们的用户名下创建 haiku
-
查看他们创建的 haiku
-
更新 haiku
-
删除他们的 haiku
希望你能意识到你经常遇到这类应用。如果你在需求中将“haiku”一词替换为“image”,你将得到 Instagram 的一个基本版本。用“task”替换可以得到像 Asana 这样的生产力工具,用“post”替换可以得到 Twitter 或 WordPress。
重点在于,这些需求不仅仅是关于 haiku 的——它们代表了软件设计中的一个通用模式。几乎每个应用程序都围绕管理某种数据展开,这些数据基于 CRUD 操作。
通过构建 Haiku Haven,你不仅为诗歌爱好者创建了一个有趣的应用程序;你还在学习如何构建现代软件的基本工作流程。你将处理用户身份验证、数据存储和检索——这些技能适用于你未来可能创建的几乎任何应用程序或系统。
超出范围的内容
我们可以将很多功能集成到 Haiku Haven 中(想想你可以在 Twitter 上做的一切),但我们只有一个章节,所以我们将专注于绝对核心的内容。这意味着我们 不会 关注:
-
使 haiku 对其他用户可见并可搜索——haiku 对作者来说是私有的
-
“社交”功能,如点赞、评论和分享
-
辅助功能,如分页
-
高级安全功能(尽管我们将确保基础正确)
8.1.2 可视化用户体验
Haiku Haven 将是我们的第一个多页面应用。我们需要制作多个体验或流程——账户创建流程、登录和登出,以及实际的 CRUD 部分(创建、读取、更新和删除 haiku)。
图 8.1 尝试勾勒出我们应用的不同部分可能的样子。

图 8.1 我们希望在 Haiku Haven 中出现的页面的粗略草图
由于这些流程在各种常见应用中的普遍性,我不会在这里占用太多空间来详细解释它们,但这里有一些亮点:
-
登录页面使用密码进行身份验证,你可以在注册页面设置
-
一个“我的 haiku”页面代表了应用内的登录体验,让用户可以创建、查看、编辑或删除他们的 haiku。
-
此外,还有一个 haiku 编辑页面,在这里进行 haiku 的创作。
8.1.3 实施构思
由于 Haiku Haven 代表 CRUD 网络应用——因此,根据斯特金定律,90% 的 所有 网络应用——它的实现应该涉及一些非常常见的模式。
事实上,我们将使用的设计由三个在大多数在线应用中都能看到的组件组成:前端、后端和 数据库。图 8.2 展示了这种方法。

图 8.2 显示前端、后端和数据库的应用设计
前端,正如我们在先前的应用程序中看到的,由用户与之交互的小部件组成。每个主要操作,如创建账户或更新俳句,都会在后台调用相应的函数。
我们将把后端可用的功能分为两组:一组包括与用户相关的操作,如创建账户或验证用户,另一组包括与俳句相关的操作——创建、读取、更新或删除俳句。
这里有趣的部分——对我们来说是新的——是数据库,它用于在表格中永久存储与用户和俳句相关的信息。正如我们很快就会看到的,数据库也使得检索我们存储的信息变得容易。
虽然我们的应用程序故意很简单,但它捕捉了大多数 Web 应用程序的核心要素:用于用户交互的前端、用于处理请求的后端以及用于存储和检索数据的数据库。这三个支柱无缝协作,形成了无数应用程序的基础,无论是简单还是复杂。
8.2 设置持久化存储
我们迄今为止创建的应用程序中存在的一个最重要的缺陷是缺乏持久化存储。本质上,在我们迄今为止的所有应用程序中,如果用户关闭浏览器窗口,他们就会丢失他们的数据和进度。这对俳句天堂来说完全不行;我们需要用户能够在将来某个时候注销并再次登录后保存和访问他们的俳句——我们需要将数据存储在应用程序本身之外。
解决数据存储问题有几种不同的方法,但我们将使用一种相当常见的技术:关系型数据库。
8.2.1 关系型数据库概念
关系型数据库是一种数据存储系统,它将数据组织成结构化的表格,其中每个表格由行(也称为记录)和列(也称为字段)组成。一行代表某种类型的条目或实体,一列是实体的属性。模式定义了这些表格的结构,包括每列的数据类型以及不同表格之间的关系。
关系型数据库依赖于一种称为结构化查询语言(SQL)的语言来创建、管理和查询表。
如果这些内容让你感到熟悉,那很可能是因为我们已经在处理这类事情一段时间了。在第六章和第七章中,我们与 Pandas 数据框一起工作,这些数据框也处理表格数据——除了数据框在程序运行时存储在内存中,而数据库用于持久存储,即即使在程序运行完成后仍然存在的存储。
我们在第七章中也简要介绍了 SQL,当时我们使用它来检索我们存储在 Google BigQuery 中的销售数据的行。确实,BigQuery 通常被认为是一种关系型数据库,尽管它与我们本章将使用的关系型数据库不同。
8.2.2 俳句天堂的数据模型
为了更好地理解这一切,让我们尝试弄清楚我们如何在一个关系型数据库中建模Haiku Haven 的数据。
广义而言,为应用建模数据包括以下步骤:
-
确定应用中涉及的实体
-
定义这些实体之间的关系
-
列出每个实体的属性
-
将实体、属性和关系转换为关系数据库模式
确定实体
通常来说,识别应用中涉及的实体的一种好方法是列出所有代表应用核心概念的名词。例如,如果你考虑 Twitter,以下所有这些都可能被认为是实体:用户、推文、转发、直接消息、提及、关注者、标签等。
Haiku Haven 当然要简单得多。我们可以相当容易地确定我们的应用需要处理的两个关键实体:俳句和用户。
定义实体之间的关系
任何两个实体之间的关系应该根据它们之间可能交互的性质和基数来定义。用英语来说,这意味着你应该说明一个实体是如何与另一个实体相关的,以及每种实体在这一关系中每一边可以有多少个。
例如,俳句和用户是相关的,因为一个用户可以写俳句(我们上面提到的“性质”)。同时,一个用户可以写很多俳句,而一个特定的俳句只能由一个用户写。因此,用户和俳句之间的关系是一对一或 1:n(“基数”)。
列出每个实体的属性
实体的属性是描述它的字段。在我们的例子中:
-
一个用户有一个用户名和密码。在现实生活中,我们可能还想捕捉用户的姓名或用户账户创建的时间,但让我们保持简单。
-
俳句有其文本和作者(碰巧是用户)。我们还应该给每个俳句一个数字 ID以便于引用。俳句的创建时间在应用中显示可能很重要,所以我们也考虑这一点。
所有这些都可以用一个实体-关系图(或 ER 图)表示,如图 8.3 所示。

图 8.3 实体-关系(ER)图,展示了用户和俳句实体
当然,现实世界中的应用程序的 ER 图通常比这复杂得多,但我希望这有助于说明这个概念。
将所有内容转换为数据库模式
绘制 ER 图来构思数据模型是有帮助的,但我们真正想要的最终结果是一个可以在我们的关系型数据库中使用的模式。
将实体、属性和关系转换为数据库中的表没有固定的规则,但一般来说:实体成为表,属性成为列,一对一关系成为外键(稍后将有更多介绍),多对多关系成为它们自己的表。

图 8.4 用户和俳句表之间的外键关系数据库模式
在我们的案例中,如图 8.4 所示,我们将有两个表,users和haikus,它们具有我们之前讨论的属性作为列。users表中的每一行代表一个单个用户,而haikus表中的每一行代表一个单个俳句。此外,每个表都有一个主键,这是一个可以用来唯一标识表中任何行的字段。对于users,主键是username字段(这很有道理,因为每个用户都有一个用户名,且没有两个用户可以有相同的用户名)。对于haikus,它是haiku_id。
用户和俳句之间的关系反映在俳句表中的author列中,该列包含必须出现在用户表中的用户名。这样的列(author)被称为外键,因为它指向另一个(“外部”)表的主键(username)。
8.2.3 PostgreSQL:一个真正的关系数据库
足够的理论了!现在让我们动手操作一个真实的数据库。在本章中,我们将使用 PostgreSQL(发音为“post-gress-cue-ell”),这是行业中最古老、最稳健、最受欢迎的数据库之一。
注意
在第七章中我们遇到的 Google BigQuery 也可以被认为是一个关系数据库(尽管它更像是基于云的数据仓库)。虽然 BigQuery 优化用于分析用例(如查询大量数据以生成报告或发现趋势),但 PostgreSQL 更适合事务用例,例如处理对单个记录的频繁小更新,并在并发操作中维护数据一致性。
安装 PostgreSQL
在本章的末尾,当我们部署俳句天堂到生产环境时,我们将使用免费云服务来设置 PostgreSQL。然而,对于本地开发,我们首先需要一个本地安装。
要安装 PostgreSQL,从https://www.postgresql.org/download/下载适用于您操作系统的安装程序,并按照屏幕上的说明运行。对于大多数选项,您可以接受默认设置。在安装过程中,您将被提示为数据库超级用户设置密码。请确保记住这个密码——您将在下一节需要它。
安装完成后,你应该可以访问 PostgreSQL 命令行工具,称为 psql,它位于安装文件夹的bin/目录中。如果你保留了默认选项,这通常会是:
-
macOS 上的
/Library/PostgreSQL/17/bin/psql -
Windows 上的
C:\Program Files\PostgreSQL\17\bin\psql
为了简化访问,您应该配置系统,以便您可以直接从终端运行 psql 命令。这需要将 bin/ 目录添加到系统环境变量中。您可能已经为 streamlit 命令做了类似的事情,详细步骤可以在附录 A(第 A.4 节)中找到。
如果由于任何原因无法将路径添加到环境变量中,您仍然可以通过输入其完整路径而不是仅命令来使用 psql。例如:
-
macOS:
/Library/PostgreSQL/17/bin/psql -
Windows:
"C:\Program Files\PostgreSQL\17\bin\psql"(包括引号)
在本章的其余部分,如果需要,当您看到 psql 时,请替换为完整路径。
为 Haiku Haven 创建数据库
一旦您设置了 psql 命令,通过运行以下命令登录到您的本地 PostgreSQL 实例:
psql -U postgres -d postgres
当提示输入密码时,请输入您在安装过程中配置的密码。此命令以默认用户(-U)postgres 登录 PostgreSQL——这是一个可以随意做任何事的行政用户。-d 指定您想要连接到默认数据库,而这个默认数据库——如果按照我的理解来说——也被称为 postgres。现在您应该能看到类似以下的 psql 提示符:
postgres=#
当您的应用程序与 PostgreSQL 通信时,您不希望它以广泛的行政权限运行——那将是一场安全噩梦。相反,让我们创建一个范围更窄的用户。在 psql 提示符中输入以下内容,并用您选择的密码替换引号内的字符串:
CREATE USER haiku_lord WITH PASSWORD '<Pick a password you like>';
显然,您可以使用您喜欢的任何用户名,但我会假设我们使用 haiku_lord。如果这成功了(别忘了结尾的分号!),您应该会得到一条只说 CREATE ROLE 的输出行。
在您可以在 PostgreSQL 中创建表之前,您首先需要创建一个数据库(在这里您可以将其视为表的容器)。您刚刚创建的 haiku_lord 用户还不能做这件事,所以输入以下命令来允许它:
ALTER USER haiku_lord CREATEDB;
现在我们有一个具有适当权限的 Haiku Haven 特定用户,我们就完成了默认的 postgres 用户,所以通过输入 exit 退出 psql 壳,然后按如下方式重新运行它:
psql -U haiku_lord -d postgres
假设您在提示时输入了为 haiku_db 选定的密码,您再次连接到了 postgres 数据库,但现在您是以 haiku_lord 的身份操作的(如果您愿意,可以通过输入 SELECT current_user; 来验证这一点)。
要创建一个名为 haikudb 的数据库来存储 Haiku Haven 的表,请输入:
CREATE DATABASE haikudb;
您可以通过输入 \l 来列出您本地 PostgreSQL 实例中可用的所有数据库。现在您应该能够看到其中包含 haikudb、postgres 以及几个其他数据库。
要开始使用我们的数据库,我们需要“连接”到它。为此,请输入:
\c haikudb;
一个确认消息——“您现在已连接到数据库 "haikudb" 作为用户 "haiku_lord"”——应该让您知道这成功了。
保持这个终端窗口中的psql shell 打开。我们将在本章的整个过程中不断回到它。如果您最终关闭了它,您可以通过输入以下命令恢复到这个状态:
psql -U haiku_lord -d haikudb
8.2.4 SQL 入门课程
PostgreSQL 中的 SQL 代表结构化查询语言。它被不同地发音为"sequel"和"ess-cue-ell",SQL 是数据库的语言,用于创建和更新表,最重要的是,用于查询以获取我们确切需要的数据。如果您是开发者或在任何形式的数据领域工作,SQL 也是您应该知道的最受欢迎和最有用的语言之一。
如果您不知道 SQL,今天真是您的幸运日,因为我们将在这个部分快速浏览基础知识。
创建表
在本章前面,我们提出了 Haiku Haven 的数据库模式,其中包含两个表:users和haikus。
我们现在将在 PostgreSQL 中实际创建这些表。回到您连接到haikudb数据库的psql shell(或如之前所示重新运行psql),并输入以下 SQL 命令:
CREATE TABLE users (
username VARCHAR(100) PRIMARY KEY,
password_hash VARCHAR(128)
);
CREATE TABLE命令创建了一个具有特定模式的表。在上面的例子中,表名为users,它有两个列:username和password_hash——如果您还记得,这是我们之前确定的重要用户属性字段。
为什么它说password_hash而不是仅仅password?请稍等,我将在本章稍后解释这一点。现在,我们只需将其视为用户的密码。
将您的注意力转向我们定义列的行:
username VARCHAR(100) PRIMARY KEY,
password_hash VARCHAR(128)
这些序列,由逗号分隔,是列指定,允许您配置每一列——包括指定数据类型。
VARCHAR是 PostgreSQL 中的一种数据类型;它是一种可以具有可变字符数的字符串类型。VARCHAR(100)表示该列可以包含最多 100 个字符。如果您尝试存储超过 100 个字符,PostgreSQL 将抛出一个错误。
您还会注意到username列旁边的PRIMARY KEY。这表示username列将被用来唯一标识用户表中的一行。这还意味着users表中的每一行都必须有一个username,并且只有一个用户可以有一个特定的username。
要检查是否成功,您可以使用psql命令\dt,它列出当前数据库中的所有表。现在这样做应该会给出:
List of relations
Schema | Name | Type | Owner
--------+-------+-------+------------
public | users | table | haiku_lord
(1 row)
接下来,让我们使用另一个CREATE TABLE命令创建我们的第二个表haikus:
CREATE TABLE haikus (
haiku_id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
author VARCHAR(100),
text TEXT,
FOREIGN KEY (author) REFERENCES users(username)
);
这里有一些新内容:
-
haiku_id,主键,数据类型为SERIAL。这意味着 PostgreSQL 将自动为该列提供一个自增整数值。第一行插入将具有1作为其haiku_id,第二行将具有2,依此类推。 -
created_at的数据类型为TIMESTAMPTZ,这是一个带有时区信息的日期时间戳。DEFAULT关键字指定了在向表中插入行时如果没有明确提供值,则放入此列的值。在这种情况下,我们希望提供当前的时间戳 (CURRENT_TIMESTAMP) 作为此默认值。 -
text的数据类型为TEXT,类似于VARCHAR,但没有最大字符长度限制。这对于俳句的实际内容来说是有意义的。 -
最后一行,
FOREIGN KEY (author) REFERENCES users(username)表示作者列必须有一个值,该值存在于用户表中的某些行的username列中。这被称为 外键约束。
向表中插入行
接下来,让我们使用 SQL 的 INSERT INTO 语句向这些表中插入行。
我们首先通过向 users 表中添加一行来创建一个新用户:
INSERT INTO users (username, password_hash) VALUES ('alice', 'Pass_word&34');
INSERT INTO 命令通过指定列值将行插入到表中。表名 users 后的 (username, password_hash) 表示我们即将指定的列值对应于 username 和 password_hash 列,顺序如下。
VALUES 后的部分给出了要输入的实际值。这里的效果是 users 现在有一行——username 为 alice,password_hash 设置为 Pass_word&34。
INSERT INTO haikus (author, text) VALUES
('alice', E'Two foxes leap high\nOne lands safely on the earth\nWhere is the other?');
注意,尽管 haikus 有四个列,但我们只指定了其中两个的值——author 和 text。PostgreSQL 会自动提供 haiku_id 以及 created_at 的默认值。
还要注意俳句开头引号前的 E。这标志着值是一个 转义字符串,正确地将 \n 转义序列转换为换行符。
在我们查询这些表之前,为了以防万一,让我们在 alice 下再添加一首令人不安的俳句:
INSERT INTO haikus (author, text) VALUES
('alice', E'Five frogs are jumping\nFour come down as expected\nBut one goes missing.');
查询表
SQL 的真正优势在于我们可以在表被行填充后以多种灵活的方式 查询 表(从它们中读取数据)。
要检索数据,我们使用 SELECT 语句。此命令允许我们指定我们想要看到的列,并根据某些标准过滤行。让我们从一个简单的查询开始,以检索 users 表中的所有行和列:
SELECT * FROM users;
星号 * 表示我们想要检索表中的所有列。因此,我们将看到所有用户及其相关的 user_id 和 password_hash 列表。我们目前只有一个用户,所以我们得到:
username | password_hash
----------+---------------
alice | Pass_word&34
(1 row)
注意
如果你看到密码如此容易被查询而感到惊恐,请不要担心!正如我们很快就会看到的,我们实际上并不会以这种方式存储密码。
我们也可以选择只从表中检索某些列。例如,如果我们只想从 haikus 表中检索 haiku_id、created_at 和 author 字段,我们可以这样写:
SELECT haiku_id, created_at, author FROM haikus;
这将给我们:
haiku_id | created_at | author
----------+-------------------------------+--------
1 | 2024-12-10 16:12:11.71654-08 | alice
2 | 2024-12-10 16:12:16.364669-08 | alice
(2 rows)
我们还可以使用 WHERE 子句根据条件(或条件集)过滤我们想要看到的行。例如,如果我们只想看到由 alice 编写的且包含 "fox" 的俳句的 haiku_id 和 text,我们可以使用以下内容:
SELECT haiku_id, text FROM haikus WHERE author = 'alice' AND text LIKE '%fox%';
以获得:
haiku_id | text
----------+-------------------------------
1 | Two foxes leap high +
| One lands safely on the earth+
| Where is the other?
(1 row)
我们在这里使用两个由关键字 AND 分隔的条件过滤了 haikus 表:author = 'alice' 和 text LIKE '%fox%'。
LIKE 关键字用于执行文本匹配。% 符号表示“零个或多个字符”,使 %fox% 成为一个匹配包含单词 'fox' 的任何文本的模式。
SELECT 语句可以做得比这更多,例如计算汇总统计信息、聚合行、通过连接从多个表中获取数据等等,但这些内容超出了本章的范围。
更新表
在插入行后,您可以使用 UPDATE 语句更新表中的行。例如,假设您想将字符串 "[By Alice] " 添加到 haiku_id 为 1 的俳句的开头:
UPDATE haikus SET text = '[By Alice] ' || text WHERE haiku_id = 1;
在这里,我们正在设置 haikus 表中 haiku_id 为 1 的行的 text 列。SQL 中使用 || 运算符来连接字符串,因此 SET text = '[By Alice] ' || text 将 "[By Alice] " 添加到开头。如果我们现在通过以下方式 SELECT 该俳句的文本:
SELECT text FROM haikus WHERE haiku_id = 1;
我们将得到:
text
--------------------------------
[By Alice] Two foxes leap high+
One lands safely on the earth +
Where is the other?
(1 row)
虽然我们在这里只更新了一行,但 UPDATE 将更新所有与 WHERE 子句匹配的行。如果我们省略了 WHERE 子句,则 UPDATE 将应用于表中的每一行。
删除行(和表)
要从表中删除行,我们使用 DELETE FROM 命令。与 UPDATE 的情况一样,它使用 WHERE 条件来确定要删除的行。
因此,我们可以通过以下命令删除 ID 为 2 的俳句:
DELETE FROM haikus WHERE haiku_id = 2;
小心使用此命令!如果您省略了 WHERE 子句,DELETE FROM 将删除 每个 单独的行!
最后,我们可以通过使用 DROP TABLE 命令本身删除一个表(而不是仅删除表中的行)。
例如,如果我们想删除 haikus 表(目前我们不需要这样做),我们可以输入以下内容:
DROP TABLE haikus;
当然,SQL 的内容远不止我们所学的这些——实际上,整个职业生涯都可以通过对其有足够的了解来塑造——但这正是我们本章所需的所有内容。
8.2.5 将 PostgreSQL 连接到我们的应用程序
我们现在已经为“俳句天堂”数据库设置了数据库。我们还学会了手动插入行和查询数据库 手动。接下来要做的就是使我们的应用程序能够通过 Python 程序化地查询和修改数据库。
为了做到这一点,我们将使用一个名为 psycopg2 的第三方 Python 模块,它为我们提供了一种在应用程序中与 PostgreSQL 通信的方式。
现在就安装它,通过在新终端窗口中运行 pip install psycopg2 来完成。
数据库连接是如何工作的
在使用 psycopg2 之前,让我们了解 PostgreSQL 查询在 Python 中的工作方式。
在前面的章节中,我们了解到 Streamlit 应用程序由一个 Streamlit 服务器进程提供服务,该进程监听机器上的特定端口(通常是 8501 或附近某个端口)。同样,PostgreSQL 数据库在 PostgreSQL 服务器上运行,该服务器监听不同的端口——默认为 5432,除非你在安装时设置了不同的端口号。
为了执行 SQL 命令,应用程序必须与 PostgreSQL 服务器建立连接。单个连接一次只能处理一个命令。
但如果多个用户同时尝试创建或更新一个俳句,怎么办?我们可以在单个连接上排队 SQL 命令,但这可能会导致延迟。使用多个连接更好,但设置每个连接可能会消耗资源——尤其是在生产环境中,因为它涉及到网络初始化和身份验证。
psycopg2的解决方案是一个连接池,它高效地管理多个可重用的数据库连接,如图 8.5 所示。

图 8.5 具有 1 个最小连接和 5 个最大连接的数据库连接池
连接池维护一组可以被应用程序的不同部分或访问应用程序的不同用户快速重用的连接。当应用程序的一部分需要运行数据库查询时,它可以:
-
从池中请求一个连接
-
使用连接来执行查询
-
将连接返回到池中以供重用
使用psycopg2连接到我们的数据库
考虑到所有这些,让我们尝试设置一个实时数据库连接并运行一个查询!在终端中输入python(或python3)以打开 Python shell。
一旦你连接上了,从模块中导入我们需要的内容:
>>> from psycopg2.pool import ThreadedConnectionPool
ThreadedConnectionPool是我们将要使用的连接池类。为了实例化它,我们需要知道我们的 PostgreSQL 服务器运行的地址、端口号、我们创建的 PostgreSQL 用户的用户名和密码,以及我们数据库的名称。
我们可以将所有这些组合成一个连接字符串,其形式如下:
postgresql://<PostgreSQL username>:<Password>@<Address of server>:<Port number>/<Database name>
在我们的情况下,我们将输入:
>>> connection_string = 'postgresql://haiku_lord:<password>@localhost:5432/haikudb'
显然,你需要将<password>替换为你为haiku_lord创建的实际密码。由于我们现在在本地运行 PostgreSQL,服务器地址简单地是localhost,但在本章后面切换到托管 PostgreSQL 服务时,这将会改变。
我们现在可以创建我们的连接池:
>>> connection_pool = ThreadedConnectionPool(1, 5, connection_string)
这里的 1 和 5 是池中的最小和最大连接数。这意味着即使在我们收到任何请求之前,也始终保留一个连接可用,并且可以维护最多 5 个并发连接。
让我们使用getconn方法从池中获取一个连接:
>>> connection = connection_pool.getconn()
为了执行查询,我们需要一个游标,它是一个指针,允许我们以灵活的方式执行 SQL 命令并从数据库中检索结果。我们通过在connection对象上调用cursor()方法来创建一个:
>>> cursor = connection.cursor()
Let's now write the query itself. We'll try to fetch the haiku_id and author fields for a given author:
>>> query = 'SELECT haiku_id, author FROM haikus WHERE author = %s'
注意这里的 %s。这使得作者的用户名成为查询的参数。通过插入不同的用户名,我们可以获取不同作者的俳句。现在,我们想要 alice 的俳句,所以我们将创建一个包含查询的元组来传递。
>>> params = ('alice',)
('alice',) 是包含字符串 'alice' 的单元素元组的字面表示。
>>> cursor.execute(query, params)
一旦执行完成,让我们获取所有结果。
>>> cursor.fetchall()
[(2, 'alice'), (1, 'alice')]
结果以元组列表的形式出现,其中每个元组代表数据库中的一行。由于我们的 SELECT 子句说了 haiku_id, author,每个结果元组的第一个元素是 haiku_id,而第二个是 author 字段。
由于我们已经完成了查询,让我们将连接返回到连接池,以便应用程序的其他部分可以使用它:
>>> connection_pool.putconn(connection)
这就结束了我们对 psycopg2 的说明。作为最后一步,让我们清理连接池:
>>> connection_pool.closeall()
这将关闭所有连接,并且无法再从连接池中请求更多。
创建数据库类
现在我们知道了如何在 Python 中运行 SQL 查询,我们准备开始编写应用程序的代码。
在本章中,我们将把我们的代码组织在两个文件夹中:backend 和 frontend,以及一个主入口脚本(我们将使用 streamlit run),它位于这两个文件夹之外。
我们刚才提到的数据库连接内容相当技术性,我们宁愿不在应用程序代码的其余部分处理它。如果我们可以有一个数据库对象,我们可以直接要求执行所需的查询,而不必担心连接池和游标的细节。每当我们要运行特定的查询时,我们应该能够编写 database.execute_query(query, params),传递我们想要执行的查询和想要提供的参数。
为了设置这个环境,让我们创建一个 Database 类。
在后端文件夹中创建一个新的 Python 文件,命名为 database.py,并将列表 8.1 中的代码复制进去。
列表 8.1 后端/database.py
from psycopg2.pool import ThreadedConnectionPool
MIN_CONNECTIONS = 1
MAX_CONNECTIONS = 10
class Database:
def __init__(self, connection_string):
self.connection_pool = ThreadedConnectionPool(
MIN_CONNECTIONS, MAX_CONNECTIONS, connection_string
)
def connect(self):
return self.connection_pool.getconn()
def close(self, connection):
self.connection_pool.putconn(connection)
def close_all(self):
print("Closing all connections...")
self.connection_pool.closeall()
def execute_query(self, query, params=()):
connection = self.connect()
try:
cursor = connection.cursor()
cursor.execute(query, params)
results = cursor.fetchall()
connection.commit()
return results
except Exception:
connection.rollback()
raise
finally:
self.close(connection)
到目前为止,你应该已经习惯了我们在前几章中使用过的简单数据类。数据类简化了在 Python 中创建类所使用的语法,但对于更复杂的使用场景,我们需要剥去这一层,并编写传统的类定义。
类本质上是一个可以被转换成具体的 Python 对象的蓝图,它可以拥有属性——这些是对象的属性或与对象关联的数据——以及方法——或定义对象行为并能与其属性交互的函数。
让我们研究 Database 类的定义,如列表 8.1 所示。我们将从 __init__ 方法开始:
def __init__(self, connection_string):
self.connection_pool = ThreadedConnectionPool(
MIN_CONNECTIONS, MAX_CONNECTIONS, connection_string
)
__init__(发音为“dunder init”)是 Python 中的一个特殊方法。当一个对象首次从一个类创建时,__init__ 方法会自动执行。我们的 __init__ 方法接受的两个参数是 self 和 connection_string。
在 Python 类定义中,你会非常频繁地看到 self。类内部方法的第一参数是一个特殊的参数,在调用它时不会明确传递(我们稍后会看到)。它始终指向被调用方法的对象。按照惯例,这个参数被命名为 self——尽管技术上你可以称它为任何你喜欢的名字。
connection_string 的目的是存储我们之前形成的字符串类型,其中包含数据库配置。
这行 self.connection_pool = ThreadedConnectionPool(MIN_CONNECTIONS, MAX_CONNECTIONS, connection_string) 创建了一个 psycopg2 的 ThreadedConnectionPool,就像我们在前面的部分中所演示的那样,并将其分配给 self.connection_pool,使其成为从类创建的对象的属性。
MIN_CONNECTIONS 和 MAX_CONNECTIONS 在文件顶部定义,可以轻松配置或稍后更改。
我们实际上是在创建数据库对象时立即初始化连接池。
我们还定义了除 __init__ 之外的方法,例如 connect,它从连接池中获取连接,close,它将给定的连接返回到连接池,以及 close_all,它关闭连接池本身(并在终端窗口中打印一条消息)。这些代码应该很熟悉,因为我们之前在尝试 psycopg2 时讨论过。
然而,我们通常不会直接调用这些方法。回想一下,我们真正想要的是一个简单的 execute_query 方法,它会处理所有底层的数据库连接逻辑。让我们现在将注意力转向该方法:
def execute_query(self, query, params=()):
connection = self.connect()
try:
cursor = connection.cursor()
cursor.execute(query, params)
results = cursor.fetchall()
connection.commit()
return results
except Exception:
connection.rollback()
raise
finally:
self.close(connection)
execute_query 自然接受我们想要执行的 SQL 查询以及我们想要提供给查询的任何参数(默认设置为空元组 ())。
方法的主体首先从连接池中获取一个连接。然后我们看到一个 try-finally 块。
在 Python 中,try-except-finally 是一个用于错误处理的构造 that's。其思路是将你的常规代码写在 try 块中。如果在运行 try 代码时发生异常,Python 会停止执行并跳转到 except 块,该块会 "捕获" 异常,允许你记录一个合理的错误信息,例如,或者使用其他类型的处理逻辑。
无论是否有异常,finally 块中的代码总是会执行。
try 块中的代码只是获取一个游标,执行查询并返回结果,就像我们在探索 psycopg2 时所看到的那样。你可能注意到的唯一新事物是 connection.commit() 这一行。
这一行存在是因为我们不仅仅会使用 execute_query 来运行 SELECT 查询。我们还会运行 INSERT INTO、UPDATE 和 DELETE FROM 命令——所有这些都会 修改 表,而不仅仅是 读取 它们。
在 PostgreSQL 中,当你修改数据时,直到你 提交 它们之前,这些更改不会被永久保存。
如果在 Python 尝试执行此代码时发生某种错误(例如,给定的查询可能包含不正确的 SQL 语法),该怎么办?我们进入由except Exception标记的except块,其中包含connection.rollback()这一行。这将撤销所做的任何临时更改,以便数据库保持原始状态。
我们还打印出我们遇到的错误,然后重新抛出异常,让常规的异常流程接管——例如,将异常消息打印到屏幕上。我们通过try-except结构实现的是,在发生异常时将自己注入流程中,并确保任何部分更改都被回滚。
那么finally块呢?嗯,它调用文件中更高处的close方法,将连接返回到池中。无论try块中发生什么,连接总是会被释放。如果我们没有这段代码,连接在函数退出后仍然会被分配,最终池将耗尽新的连接。将此代码放在finally块中确保即使在发生错误的情况下,连接也会被返回。
在我们的应用中使用数据库类
在设置了Database类之后,要使我们的应用实现持久存储和检索,剩下的就是实际上在我们的 Streamlit 应用中使用这个类。
创建数据库类的实例需要提供一个连接字符串。连接字符串是敏感的,因为它们包含 PostgreSQL 凭据,所以我们不希望将字符串放入我们的代码中。
你可能知道接下来会发生什么——我们需要像过去一样使用st.secrets!无需多言,立即在你的应用根目录(即创建backend目录的父文件夹)中创建一个.streamlit文件夹,并在其中创建一个secrets.toml文件,其内容类似于列表 8.2 中所示。
列表 8.2 .streamlit/secrets.toml
[config]
connection_string = "postgresql://haiku_lord:password@localhost:5432/haikudb"
不要忘记将password替换为你的实际密码。此外,正如我们之前所学的,你不应该将此文件提交到 Git。
接下来,在应用的根目录中创建我们的应用入口文件——比如说,main.py——,其中的代码如列表 8.3 所示。
列表 8.3 main.py
import streamlit as st
from backend.database import Database
st.title('Haiku Haven')
connection_string = st.secrets['config']['connection_string']
database = Database(connection_string)
query_results = database.execute_query('SELECT * FROM haikus')
st.write(query_results)
这就是一切汇聚在一起的地方!首先,我们使用以下方式导入我们刚刚创建的Database类:
from backend.database import Database
这行代码的意思是“从backend/database.py导入数据库类”。注意,在模块导入路径中,路径分隔符变成了点。
为了使这一行代码能够正常工作,Python 需要识别backend的父文件夹作为起点,从该起点它可以查找模块。我们可以通过将backend父文件夹的路径添加到sys.path(Python 中确定此内容的路径列表)来实现这一点。
幸运的是,当你运行streamlit run <script.py>命令时,<script.py>的父文件夹会自动添加到sys.path中。在这种情况下,由于main.py的父文件夹也是backend目录的父文件夹,我们不需要做任何额外的事情。
接下来,在显示标题之后,我们像之前章节中做的那样,从st.secrets中提取连接字符串:
connection_string = st.secrets['config']['connection_string']
我们使用这个来创建Database类的实例:
database = Database(connection_string)
你可能在我们之前使用 dataclasses 时已经认识到了这种语法,但这是本书中我们第一次实例化我们编写的传统类,因此需要更深入的说明。
这里发生的事情是,通过将connection_string传递给类Database,就像传递给一个函数一样,我们实际上是在将其传递给Database的__init__方法,该方法具有签名行def __init__(self, connection_string)。正如我之前提到的,self会自动设置为正在创建的对象,所以我们不需要显式地传递它。
相反,我们只传递connection_string,这样__init__就会为我们设置连接池,并将得到的Database实例存储在database变量中(注意,即使我们没有写一个显式的return语句,__init__也会返回实例)。
一旦我们有了database实例,我们就用它来执行一个简单的SELECT查询:
query_results = database.execute_query('SELECT * FROM haikus')
再次强调,尽管execute_query将self作为其第一个参数,我们不需要显式地传递它。相反,database本身被传递给self。
这个查询没有参数,所以我们通过不指定它,让params参数保持默认值(一个空元组)。
最后,我们使用st.write来显示查询的结果:
st.write(query_results)
通过输入streamlit run main.py来查看页面(确保你首先在main.py的包含文件夹中,这样 Streamlit 才能找到.streamlit文件夹)。你应该会看到类似于图 8.6 的内容。

图 8.6 使用 Database 类执行 SELECT 查询并使用 st.write 显示的俳句表内容(完整代码请见 GitHub 仓库中的 chapter_8/in_progress_01)
这里有趣的一点是st.write如何以易于阅读的格式格式化query_results中的元组列表。
无论如何,我们的应用现在已经连接到数据库了!接下来,让我们使用这个来允许用户创建账户!
8.3 创建用户账户
由于我们的用户可以创建他们自己的俳句,因此他们需要有一种方式来创建 Haiku Haven 账户来保存他们的俳句。在本节中,我们将连接我们的应用以启用此功能,并注意安全地存储密码。
在我们到达那里之前,让我们花一分钟来谈谈代码组织。
8.3.1 将我们的应用拆分为服务
在第三章中,我们讨论了关注点分离的原则——即我们的应用程序的每个组件都应该专注于特定的事情,并且与其他组件独立,仅以合同或 API 指定的方式与之交互。
我们在这里将做类似的事情,就像在第三章中那样,将前端和后端分开。由于我们这次使用类和面向对象编程,我们可以定义一个后端类——让我们称它为Hub——它可以作为前端代码调用后端代码的单一点。任何前端可以调用的函数都应该在Hub类中是一个方法。这与第三章中的backend.py类似,其中每个从前端代码调用的后端函数都在backend.py中定义。
我们可能期望Hub类拥有满足前端用户可能想要执行的操作的方法,例如create_user、create_haiku、update_haiku等。然而,随着时间的推移,随着我们的应用程序变得越来越复杂,Hub类的方法数量会不断增加,慢慢地使其变得难以驾驭和管理。
而不是采取这种单体方法,将Hub提供的操作分成单独的服务类可能更好,每个类都针对特定类型的操作,并使用Hub类仅作为协调者。例如,我们可以有一个UserService类,它提供与用户相关的操作,如create_user,以及一个HaikuService类,它提供与俳句相关的操作,如create_haiku和update_haiku。
这将使我们的应用程序更加模块化,更容易扩展和维护。我们可以独立于HaikuService中的俳句相关操作,向UserService添加更多与用户相关的功能,如果我们想在以后添加打油诗到我们的应用程序中,我们可以引入一个LimerickService而不需要触及任何东西。
考虑到我们的整体代码组织策略,让我们将注意力转向构建组件之一——用户服务。
8.3.2 创建用户服务
正如在先前的章节中一样,我们将使用 dataclass 来表示我们关心的基本对象。在第 3、4 和 6 章中,我们有Unit、Task和Metric类。在这里,我们将从User类开始。
在backend文件夹中创建一个新的 Python 文件,命名为user.py,其文本如列表 8.4 所示。
列表 8.4 backend/user.py
from dataclasses import dataclass
@dataclass
class User:
username: str
password_hash: str
你会看到这直接反映了我们数据库中的users表,其中包含username和password_hash字段。
现在我们来谈谈后一个字段,以及为什么我们称之为password_hash而不是password。
安全存储密码
密码自然是软件开发者需要处理的最敏感的信息之一,而网络安全领域的很大一部分都集中在保持它们的秘密性。
我们知道我们不应该在我们的代码中存储密码,而是求助于st.secrets构造来避免这种情况。但是,将它们存储在数据库中又如何呢?
显然,我们的应用程序需要能够将用户输入的密码与实际关联的用户密码进行比较,因此密码需要以某种形式存储。然而,直接将它们以纯文本形式存储在数据库中会引入安全漏洞。
这是因为任何通过某种安全漏洞获得我们数据库访问权限的人——将能够看到以原始形式存储的密码。但我们如何避免这种情况呢?
答案是:使用单向加密哈希函数,或者说,一点复杂的数学。事实证明,有一些数学运算在正常情况下很容易执行,但在反向执行时却极其困难。作为一个简单的例子,考虑将两个质数 a 和 b 相乘得到 c。a 和 b 相乘得到 c 很容易,但如果你只得到 c,识别 a 和 b 就困难了,尤其是当 c 非常大时(想想有几百位长)。
同样,你可以将加密哈希函数视为对密码执行的操作,这种操作很难逆转。
假设某个人的密码是SomePassword123。如果你对它应用一个哈希函数H,你可能会得到一个看起来像随机字符序列的密码哈希:
而不是直接将字符串SomePassword123存储在数据库中,我们存储g53jkdlgfee09ded8d33rr45t5y5y43f2eff。然后当有人在我们的应用程序中输入密码时,我们将哈希函数应用于那个密码,并将结果与g53jkdlgfee09ded8d33rr45t5y5y43f2eff进行比较。如果两者相同,则用户被验证。
这如何帮助提高安全性?好吧,如果黑客现在设法进入我们的数据库,他们只能得到密码的哈希值,而不是实际的密码。正如所述,从密码哈希中获取密码非常困难。
对于黑客来说,密码哈希本身是无用的,因为没有理由在应用程序中输入它——如果你这样做,应用程序会对其应用哈希函数,并得出一个与真实哈希值完全不同的哈希值。
我们如何在我们的应用程序中实现这一点?幸运的是,我们不必从头开始。有一些第三方库为我们做了这件事。我们将使用bcrypt,你现在应该使用pip install bcrypt来安装它。
让我们在User类中添加两个更多的方法,使其现在看起来像这样:
from dataclasses import dataclass
import bcrypt
@dataclass
class User:
username: str
password_hash: str
@staticmethod
def hash_password(password):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def authenticate(self, password):
return bcrypt.checkpw(password.encode(), self.password_hash.encode())
我们用@staticmethod装饰了hash_password方法。这使得它属于类本身,而不是属于类的任何特定实例。我们通常使用@staticmethod来表示与类逻辑相关但不需要访问特定实例的实用函数。
hash_password 函数非常适合这个用途,因为它不需要访问实例的任何属性或方法(注意没有self参数)。相反,它只是接受用户输入的密码,使用bcrypt将其转换为哈希值,并返回它。
我不会深入讲解其工作原理的细节,但总的来说,我们在对密码进行散列之前,通过添加一个随机的“盐”(bcrypt.gensalt())来采取额外的安全措施。这种盐有助于保护密码免受黑客通过从巨大的预计算散列表(称为彩虹表)中查找与密码散列关联的密码的攻击。
我们还有一个authenticate方法,当用户输入密码时我们将使用它。bcrypt.checkpw比较输入的密码(password.encode())和存储在User对象中的密码散列(self.password_hash.encode()),如果它们匹配则返回True。
UserService 类
现在,我们准备创建UserService类,这是我们确定将具有用户相关操作方法的类。在backend/目录下创建一个名为user_service.py的文件,其内容如列表 8.5 所示。
列表 8.5 backend/user_service.py
from backend.user import User
class UserService:
def __init__(self, database):
self.database = database
def get_user(self, username):
query = "SELECT username, password_hash FROM users WHERE username = %s"
params = (username,)
results = self.database.execute_query(query, params)
return User(*results[0]) if results else None
def create_user(self, username, password):
existing_user = self.get_user(username)
if not existing_user:
query = '''
INSERT INTO users (username, password_hash)
VALUES (%s, %s)
RETURNING username, password_hash
'''
password_hash = User.hash_password(password)
params = (username, password_hash)
results = self.database.execute_query(query, params)
return User(*results[0]) if results else None
return None
UserService类有一个__init__方法,它接受我们之前创建的Database类的一个实例,并将其分配给对象的database属性(self.database)。
当用户输入用户名和密码以创建用户时,我们首先需要检查是否存在具有该用户名的用户,因此我们有一个get_user方法,如果存在用户,则返回用户,如果不存在,则返回None。
get_user方法在数据库上执行一个参数化的 SQL 查询(SELECT username, password_hash FROM users WHERE username = %s),传递给定的用户名作为唯一参数((username, ))。
如我们之前所见,这返回了一个元组的列表。由于SELECT username, password,这些将具有形式(<username>, <password_hash>)。考虑get_users中的最后一行:
return User(*results[0]) if results else None
如果没有给定用户名的用户,结果将是一个空列表,因此它将评估为False,导致get_user返回None。
如果确实存在这样的用户,results[0]将是一个形式为(<username>, <password_hash>)的元组。在 Python 中,当*运算符应用于元组(或列表)时,它会解构它以用于函数调用等。
因此User(*results[0])等价于User(<username>, <password_hash>),这创建了一个新的User数据类实例(您会记得它有两个相应的成员:username和password_hash)。
create_user方法首先使用self.get_user(username)来查看是否存在具有给定用户名的用户。如果存在,它将简单地返回None。
如果它不存在,它将向数据库发出以下查询:
INSERT INTO users (username, password_hash)
VALUES (%s, %s)
RETURNING username, password_hash
这是一个INSERT查询,我们之前已经见过。这里唯一的新内容是行RETURNING username, password_hash。通常,INSERT查询不需要返回任何结果,因为它是一个修改操作,而不是读取操作。
添加RETURNING子句使其以与SELECT查询相同的方式返回指定的字段。在这种情况下,返回新创建行的username和password_hash。
再次强调,create_user使用与get_user(User(*results[0]))相同的方法来创建并返回一个User对象,如果一切顺利的话。
Hub类
当我们之前提到代码组织时,我们提到了Hub类,它将是我们的前端代码访问的单一点。现在让我们编写这个类。创建backend/hub.py,并使用列表 8.6 中的代码。
列表 8.6 backend/hub.py
from backend.database import Database
from backend.user_service import UserService
class Hub:
def __init__(self, config):
database = Database(config['connection_string'])
self.user_service = UserService(database)
Hub类的__init__非常简单:它接受一个config对象(一个配置选项的字典,例如我们之前解析的secrets.toml文件中获得的),创建一个Database对象,并将其传递给UserService以创建该类的实例。
Hub没有其他方法。这很有道理,因为我们之前强调过,Hub只是一个协调器类,我们的前端可以使用它来访问各种服务类对象(其中user_service是一个UserService的实例,这是我们迄今为止创建的唯一一个)。
注册页面
从下往上,我们创建了一个User类,一个访问User类的UserService类,以及一个访问UserService类的Hub类——目前它只有一个create_user方法。
访问create_user的我们的 Streamlit 应用程序部分将是注册页面,我们暂时在main.py中定义它。
我们之前的main.py直接初始化了Database对象并执行了一个示例查询。由于数据库现在在Hub类中初始化,我们将完全重写main.py,如列表 8.7 所示。
列表 8.7 main.py 修订版
import streamlit as st
from backend.hub import Hub
hub = Hub(st.secrets['config'])
with st.container(border=True):
st.title("Sign up")
username = st.text_input("Username")
password = st.text_input("Password", type="password")
confirm_password = st.text_input("Confirm password", type="password")
if st.button("Create account", type="primary"):
if password != confirm_password:
st.error("Passwords do not match")
else:
user = hub.user_service.create_user(username, password)
if user:
st.success("Account created successfully")
else:
st.error("Username already exists")
到这本书的这一部分,你应该能够比较容易地阅读列表 8.7 中的代码。
它首先通过使用st.secrets的'config'条目来创建一个Hub实例(注意这里,Hub接受整个config对象,而不仅仅是连接字符串,以防需要考虑其他配置)。
接下来,它显示了通常的用户名-密码-确认密码的输入组合,我相信你之前在各种网站上已经见过。点击“创建账户”按钮时,如果两个输入框中的密码不匹配,会显示错误。
如果它们匹配,我们调用在UserService类中定义的create_user方法来在数据库中创建用户:
user = hub.user_service.create_user(username, password)
然后我们根据返回值显示成功或错误消息。
在这个阶段,如果你重新运行应用程序,你应该能够看到图 8.7。

图 8.7 Haiku Haven 的注册页面(完整代码请见 GitHub 仓库中的 chapter_8/in_progress_02)
尝试使用用户名bob创建一个账户。为了验证它是否成功并且确实创建了一个用户,你可以在你的psql提示符(希望你保持打开状态)中发出查询SELECT * from users where username = 'bob'。这应该会给你类似以下的结果:
username | password_hash
----------+--------------------------------------------------------------
bob | $2b$12$hVzjZJN7QMTM94H7ZL.ZJe3PFfgyAPgDOH1F2b38IovcuvKrNAu3G
(1 row)
如您所见,由于散列,bob 的密码不再直接可见。但 bob 能登录吗?除非我们完成了下一部分!
8.4 设置多页登录流程
我们之前在绘制用户体验草图时看到——Haiku Haven 意味着它应该是一个多页应用,具有注册、登录和俳句相关功能的不同页面,这是我们之前没有遇到过的。
8.4.1 Streamlit 中的多页应用
Streamlit 内置了对多页应用的支持。在这个方案中,你单独定义你的各个页面,让你的入口文件(你用 streamlit run 运行的文件)充当一个“路由器”,它识别要加载的页面并运行它。
入口文件在这里非常关键;它通常在每次重新运行时加载,并且是选择要加载的“当前页面”的那个文件。
让我们来看一个例子。到目前为止,在我们的应用中,我们已经为用户创建了一个注册页面,以便他们创建自己的账户,但还没有登录页面。一旦我们创建了登录页面,就需要有一种方法将这两个页面联系起来,使它们成为同一个应用的一部分。
我们将通过再次修订 main.py 文件来实现这一点,使用上面讨论的多页方法。我们在 main.py 中包含的注册流程将不得不移动到不同的文件(frontend/signup.py)。新的 main.py 如列表 8.8 所示。
列表 8.8 main.py 修订(再次)
import streamlit as st
from backend.hub import Hub
pages = {
"login": st.Page("frontend/login.py", title="Log in",
icon=":material/login:"),
"signup": st.Page("frontend/signup.py", title="Sign up",
icon=":material/person_add:"),
}
if 'hub' not in st.session_state:
config = st.secrets['config']
st.session_state.hub = Hub(config)
page = st.navigation([pages['login'], pages['signup']])
page.run()
你在这里首先会注意到的是 pages 字典。pages 中的键是 "login" 和 "signup",这是我们希望在应用中出现的页面名称。值是 st.Page 对象。让我们检查第一个:
st.Page("frontend/login.py", title="Log in", icon=":material/login:")
st.Page 是 Streamlit 定义多页应用中单个页面的方式。你传递给它的第一个参数是该页面的 Python 脚本路径——在本例中是 frontend/login.py,但目前还不存在。我们还传递给它一个合理的标题。
最后一个参数是页面的图标。它有一个奇特的价值::material/login:。
这展示了在 Streamlit 中显示图标的一种巧妙方法。语法 :material/<icon_name> 被大多数接受可显示文本的小部件所接受,并在渲染到屏幕上时转换为图像。
你可以在 Google 的 Material Symbols 库中看到支持的图标,网址是 https://fonts.google.com/icons?icon.set=Material+Symbols。在这种情况下,我们选择了“登录”图标。每次你需要显示一个图标时,你都可以访问那个 URL,点击你想要的图标,从右侧打开的侧边栏中识别其图标名称,并将其替换为文本 :material/<icon_name>: 中的。
现在,将你的注意力转向下面更下面的以下几行:
page = st.navigation([pages['login'], pages['signup']])
page.run()
在这里,我们将 pages 字典中的两个 st.Page 对象(pages['login'] 和 pages['signup'])传递给 st.navigation,一个新的 Streamlit 小部件。
st.navigation 用于配置多页 Streamlit 应用中可用的页面,显示一个用户可以使用它来选择要访问的页面的导航栏。
它接受一个由形成导航选项的Page对象组成的列表,并从列表中返回一个单一的Page对象。这个返回项是用户选择的页面,如果没有选择任何项,则是列表中的第一个项目。
一旦返回了一个页面,就可以使用它的.run()方法来加载它。
你还会看到我们将Hub实例(hub)保存到st.session_state中,但没有对它做任何其他操作。这是因为会话状态在多页应用中的页面之间是共享的。所以如果你在任意页面将某个东西保存到st.session_state中,它也会在其他页面中可访问。在这种情况下,我们将在其他页面中使用保存的hub对象。
关于我们之前提到的注册流程呢?嗯,现在我们的应用是多页的,我们将它移动到自己的页面,即signup.py,在名为frontend的新文件夹中。正如列表 8.9 所示,内容大部分只是直接从我们之前的main.py复制过来,没有做任何修改。
列表 8.9 frontend/signup.py
import streamlit as st
hub = st.session_state.hub
with st.container(border=True):
st.title("Sign up")
username = st.text_input("Username")
password = st.text_input("Password", type="password")
confirm_password = st.text_input("Confirm password", type="password")
if st.button("Create account", type="primary"):
if password != confirm_password:
st.error("Passwords do not match")
else:
user = hub.user_service.create_user(username, password)
if user:
st.success("Account created successfully")
else:
st.error("Username already exists")
与列表 8.7 相比,我们做的唯一改变是从我们之前在新的main.py中保存的st.session_state中获取hub变量的值。
8.4.2 实现登录
在我们的多页应用基础设施到位后,是时候构建登录功能了。在我们设置登录页面之前,让我们确保我们的后端有我们需要的功能。
在 UserService 中认证用户
正如我们之前讨论的,所有与用户相关的功能都需要在UserService中实现。目前,这个类有create_user和get_user方法。我们将实现一个新的get_authenticated_user方法:
from backend.user import User
class UserService:
...
def get_user(self, username):
...
...
def get_authenticated_user(self, username, password):
user = self.get_user(username)
if user and user.authenticate(password):
return user
return None
get_authenticated_user接受用户名和密码作为参数。它首先调用我们之前定义的get_user方法,查看是否存在具有该用户名的用户。如果存在,它将在返回的User对象上调用authenticate方法。回想一下,User类中的authenticate方法比较给定密码的哈希值与实际密码的哈希值。
如果认证成功,将返回User对象。如果失败,该方法返回None,调用代码可以有两种解释:要么不存在这样的用户,要么密码不正确。为了简化,我们不会在返回值中区分这两种情况。
创建登录页面
在UserService中我们就需要这些了。现在我们可以继续创建一个登录页面,以补充我们之前创建的注册页面。
在frontend/目录下创建一个名为login.py的新文件,内容如列表 8.10 所示。
列表 8.10 frontend/login.py
import streamlit as st
hub = st.session_state.hub
with st.container(border=True):
st.title("Log in")
username = st.text_input("Username", key="login_username")
password = st.text_input("Password", type="password")
if st.button("Log in", type="primary"):
user = hub.user_service.get_authenticated_user(username, password)
if user:
st.session_state.logged_in = True
st.session_state.user = user
st.success("Logged in successfully")
else:
st.error("Invalid username or password")
这个页面与signup.py相当相似,应该很容易根据你对 Streamlit 当前的理解来理解。
这里需要关注的部分是当点击“登录”按钮时会发生什么。我们首先调用在UserService中定义的认证方法:
user = hub.user_service.get_authenticated_user(username, password)
正如我们所见,如果该方法返回User对象,则认证成功;如果返回None,则认证失败。
我们将这个条件写成user:。对于实际的登录,我们将使用一个非常简单的方法——在st.session_state下存储一个名为logged_in的布尔变量,以及登录用户的User对象(简单地命名为user)。
我们还会根据登录是否成功显示成功或错误消息。
到目前为止,你应该使用streamlit run main.py重新运行你的应用。尝试使用你之前创建的账户登录。你应该看到类似于图 8.8 的内容。

图 8.8 Haiku Haven 的登录页面(完整代码请见 GitHub 仓库中的 chapter_8/in_progress_03)
注意由st.navigation创建的导航面板,它占据了侧边栏,并包含导航到任一页面的链接(以及我们添加的图标!)。
8.4.3 在页面之间导航
虽然我们目前在注册/登录流程中已经有了最基本的需求,但肯定有改进的空间。例如,如果用户在登录页面但没有账户,应该有一个有用的链接直接注册,反之亦然。
此外,当用户登录时,我们应该带他们到一个登录页面,并给他们提供登出的能力。
图 8.9 展示了我们想要设计的理想的注册/登录/登出流程。

图 8.9 通过重定向和页面链接展示页面之间连接的图表
除了能够在注册和登录页面之间来回切换,并在登录时重定向到主页外,我们还想根据我们是否登录来显示导航面板的不同选项。
如果用户已登录,他们应该看到主页并能够登出——这会再次显示登录页面。如果他们未登录,他们应该看到注册或登录的选项。
将页面字典移动到自己的文件中
由于我们接下来将要在各个页面之间进行导航,因此将pages字典(位于main.py中,定义了可用的页面)放入一个单独的文件中会更清晰,所以让我们将这部分代码移动到frontend/pages.py,如列表 8.11 所示。
列表 8.11 frontend/pages.py
import streamlit as st
pages = {
"login": st.Page("frontend/login.py", title="Log in",
icon=":material/login:"),
"signup": st.Page("frontend/signup.py", title="Sign up",
icon=":material/person_add:"),
"home": st.Page("frontend/home.py", title="Home",
icon=":material/home:"),
"logout": st.Page("frontend/logout.py", title="Log out",
icon=":material/logout:")
}
你会注意到我们添加了两个新页面:home,它应该代表登录后的主页,以及logout,它将用户登出。
页面之间的链接
Streamlit 允许你通过一个名为st.page_link的小部件在多页面应用中创建页面之间的链接。
让我们使用这个链接在login和signup页面之间建立联系。
login.py应该看起来像这样,在底部添加了页面链接:
import streamlit as st
from frontend.pages import pages
...
with st.container(border=True):
...
else:
st.error("Invalid username or password")
st.page_link(pages["signup"], label="Don't have an account? Sign up!")
st.page_link非常容易理解;第一个参数是我们想要链接到的st.Page对象(从pages导入,位于pages.py),第二个是标签文本。
你也可以将常规 URL 作为第一个参数传递,以防你想链接到外部页面。signup.py有非常相似的改变:
import streamlit as st
from frontend.pages import pages
...
with st.container(border=True):
...
else:
st.error("Invalid username or password")
st.page_link(pages["signup"], label="Don't have an account? Sign up!")
动态更改 st.navigation
下一个我们将实现的功能是,在导航栏中显示用户当前上下文正确的页面,即当用户注销时显示signup和login,当用户登录时显示home和logout。
编辑main.py使其看起来像这样:
import streamlit as st
from backend.hub import Hub
from frontend.pages import pages
if 'hub' not in st.session_state:
config = st.secrets['config']
st.session_state.hub = Hub(config)
if 'logged_in' in st.session_state and st.session_state.logged_in:
page = st.navigation([pages['home'], pages['logout']])
else:
page = st.navigation([pages['login'], pages['signup']])
page.run()
显然,pages字典现在定义在pages.py中,并导入到main.py中。
如上图所示,为了动态更改导航面板中的内容,我们使用用户登录时保存的logged_in会话状态变量,并相应地改变分配给page的st.navigation对象。
如果用户已登录,导航栏现在将显示home和logout选项。由于pages['home']是传递给st.navigation列表中的第一个项目,因此当用户登录时,默认加载的页面就是home页面。
现在,让我们在frontend/home.py(列表 8.12)中实际设置一个占位符页面,以便让已登录用户有所见。
列表 8.12 frontend/home.py
import streamlit as st
user = st.session_state.user
st.title(f"Welcome, {user.username}!")
目前这里没有什么惊天动地的事情;我们只是显示一个包含已登录用户用户名的问候语。回想一下,我们在login.py中将已登录的User对象保存到st.session_state.user中。
登录和注销的自动重定向
我们提出的理想登录流程要求用户在登录和注销时自动重定向。这究竟是如何工作的呢?
记住,当用户点击“登录”按钮时,logged_in会话状态变量被设置为True。
这意味着在下次重新运行时,main.py将获取logged_in的更改值,显示新的导航面板并加载home.py。
为了真正实现无缝操作,我们必须触发重新运行。因此,在login.py中添加一个st.rerun()。
user = hub.user_service.get_authenticated_user(username, password)
if user:
st.session_state.logged_in = True
st.session_state.user = user
st.rerun()
至于注销?嗯,那将逆转登录时发生的所有事情。创建一个包含列表 8.13 内容的logout.py。
列表 8.13 frontend/logout.py
import streamlit as st
st.session_state.user = None
st.session_state.logged_in = False
st.rerun()
这样就完成了我们的注册/登录/注销流程!重新运行应用程序并尝试一下!当你登录时,你现在应该看到一个不同的导航面板和加载的首页(见图 8.10)。

图 8.10 登录页面中导航栏的不同选项(完整代码请见 GitHub 仓库中的 chapter_8/in_progress_04)
点击导航栏中的“注销”将重新加载登录和注册页面,底部有页面链接相互跳转。
8.5 创建、读取、更新和删除俳句
现在用户身份验证已经处理完毕,我们终于可以着手处理我们应用程序的核心功能:创建、读取、更新和删除俳句。我们将从创建俳句开始,将这种行为封装在HaikuService类中,然后对前端进行适当的修改。
8.5.1 定义 HaikuService 类
我们在俳句服务中遵循的代码结构与我们已经在UserService中拥有的结构非常相似。
让我们从表示俳句的 Haiku 数据类开始。在 backend/ 文件夹中创建 haiku.py,如列表 8.14 所示。
列表 8.14 backend/haiku.py
from dataclasses import dataclass
@dataclass
class Haiku:
haiku_id: int
created_at: str
author: str
text: str
与 User 类一样,字段反映了我们相应的数据库表(haikus)中的字段。
HaikuService 本身如列表 8.15 所示。
列表 8.15 backend/haiku_service.py
from backend.haiku import Haiku
class HaikuService:
def __init__(self, database):
self.database = database
def create_haiku(self, author, haiku_text):
query = '''
INSERT INTO haikus (author, text)
VALUES (%s, %s)
RETURNING haiku_id, created_at, author, text
'''
params = (author, haiku_text)
results = self.database.execute_query(query, params)
return Haiku(*results[0]) if results else None
再次,代码与 UserService 的代码相当类似,因此不需要详细的解释。
正如我们所看到的,我们只需要在我们的 SQL 查询中提供 author 和 text 字段;数据库自动提供 haiku_id 和 created_at,并且所有字段都按照 RETURNING 子句返回。
为了总结后端更改,让我们在 hub.py 中添加一个 HaikuService 实例:
...
from backend.haiku_service import HaikuService
class Hub:
def __init__(self, config):
database = Database(config['connection_string'])
self.user_service = UserService(database)
self.haiku_service = HaikuService(database)
这将使俳句创建功能可以从前端访问,正如我们即将看到的。
8.5.2 允许用户创建俳句
我们之前的 home.py 当然只是一个占位符。我们的实际登录主页理想上应该有创建俳句并显示俳句的方式。
为了创建俳句,让我们创建一个类似于第七章中创建的模态对话框。
创建一个新文件,frontend/haiku_editor.py,如列表 8.16 所示。
列表 8.16 frontend/haiku_editor.py
import streamlit as st
@st.dialog("Haiku editor", width="large")
def haiku_editor(hub, user):
haiku_text = st.text_area('Enter a haiku')
if st.button('Save haiku', type='primary'):
haiku = hub.haiku_service.create_haiku(user.username, haiku_text)
if haiku:
st.success('Haiku saved successfully!')
else:
st.error('Failed to save haiku')
haiku_editor 函数被 st.dialog 装饰器装饰——正如我们在上一章中看到的,它在模态屏幕中执行其主体。
haiku_editor 的主体很简单。我们首先接受用户在 st.text_area 小部件中输入的俳句文本:
haiku_text = st.text_area('Enter a haiku')
st.text_area 正如您所期望的那样——一个用于输入多行文本的区域。
在点击“保存俳句”时,我们调用 HaikuService 下的 create_haiku 方法将其保存到数据库,并显示相应的成功/失败消息。
通过在 home.py 中包含一个“添加俳句”按钮来关闭循环,该按钮触发我们刚刚定义的 haiku_editor 对话框:
import streamlit as st
from frontend.haiku_editor import haiku_editor
hub = st.session_state.hub
user = st.session_state.user
st.title(f"Welcome, {user.username}!")
if st.button(':material/add_circle: Haiku', type='primary'):
haiku_editor(hub, user)
如您所见,我们再次使用了一个 Material 图标,这次是在按钮标签中的“添加”一词的位置。
让我们看看到目前为止一切是否正常工作!重新运行您的 Streamlit 应用程序,登录,并添加一个俳句(请参阅图 8.11)。

图 8.11 创建俳句(有关完整代码,请参阅 GitHub 仓库中的 chapter_8/in_progress_05)
为了让你确信确实创建了一个俳句,你可以查询你的 haikus 表。
8.5.3 其他 CRUD 操作:读取、更新、删除
为了使我们的 CRUD 应用程序功能完整,我们还需要实现三个更多操作:
-
读取 当前用户的俳句从数据库中读取并在应用程序中列出
-
更新 给定的俳句
-
完全删除 一个俳句
在 HaikuService 中定义操作
如前所述,让我们首先在 haiku_service.py 中的 HaikuService 类中定义这些操作:
from backend.haiku import Haiku
class HaikuService:
...
def create_haiku(self, author, haiku_text):
...
def get_haikus_by_author(self, author):
query = 'SELECT * FROM haikus WHERE author = %s'
params = (author,)
results = self.database.execute_query(query, params)
return [Haiku(*row) for row in results]
def update_haiku(self, haiku_id, haiku_text):
query = 'UPDATE haikus SET text = %s WHERE haiku_id = %s RETURNING *'
params = (haiku_text, haiku_id)
results = self.database.execute_query(query, params)
return Haiku(*results[0]) if results else None
def delete_haiku(self, haiku_id):
query = 'DELETE FROM haikus WHERE haiku_id = %s RETURNING *'
params = (haiku_id,)
results = self.database.execute_query(query, params)
return Haiku(*results[0]) if results else None
我们为每个操作定义一个方法:get_haikus_by_author 用于读取俳句,update_haiku 用于更新,delete_haiku 用于删除俳句。
在每种情况下,我们都使用之前看到的相同模式——我们运行一个 SQL 命令,将结果转换为 Haiku 对象,并返回它们。
在get_haikus_by_author的情况下,由于可能存在多个作者,我们返回一个包含 Haiku 对象的列表作为结果。
在update_haiku和delete_haiku中,我们分别使用haiku_id——俳句的唯一标识符作为参数,分别使用UPDATE..SET和DELETE FROM SQL 命令来实现所需的结果。
创建用户界面
我们的后端方法返回Haiku类的实例,但我们如何在前端显示这些实例?
我们可能需要创建一个display_haiku函数,它接受一个Haiku对象并在屏幕上显示它。下一个自然的问题是:我们显示什么?用户可能想看到什么?
Haiku类有四个属性:haiku_id、created_at、author和text。
在这些中,haiku_id是一个内部标识符,对最终用户没有意义,因此我们可以排除它。created_at可能有助于唤醒用户对俳句最初创建时间的记忆。由于我们只将显示当前登录用户的俳句,因此显示author字段将是多余的。而text是俳句的内容,所以我们显然想显示它。
这就给出了:created_at和text。还有其他什么吗?嗯,我们还想给用户编辑或删除特定俳句的选项,所以让我们加入几个按钮。
列表 8.17 frontend/haiku_display.py
import streamlit as st
hub = st.session_state.hub
user = st.session_state.user
def get_haiku_created_display(haiku):
day = haiku.created_at.strftime('%Y-%m-%d')
time = haiku.created_at.strftime('%H:%M')
return f':gray[:material/calendar_month: {day} \n :material/schedule: {time}]'
def get_haiku_text_display(haiku):
display_text = haiku.text.replace('\n', ' \n')
return f':green[{display_text}]'
def edit_button(haiku):
if st.button(':material/edit:', key=f"edit_{haiku.haiku_id}"):
pass
def delete_button(haiku):
if st.button(':material/delete:', key=f"delete_{haiku.haiku_id}"):
pass
def display_haiku(haiku):
with st.container(border=True):
cols = st.columns([2, 5, 1, 1])
created_col, text_col, edit_col, delete_col = cols
created_col.markdown(get_haiku_created_display(haiku))
text_col.markdown(get_haiku_text_display(haiku))
with edit_col:
edit_button(haiku)
with delete_col:
delete_button(haiku)
这里要关注的关键函数是最后一个:display_haiku。给定haiku,即Haiku类的一个实例,它为我们要显示的四个事物创建了四个列:created_at、text、编辑按钮和删除按钮。
这些的实际渲染在每个自己的函数中完成。
get_haiku_created_display接受俳句的created_at属性——一个时间戳,并使用名为strftime的方法将其分解为日期和时间,该方法用于根据格式字符串格式化时间戳。在这种情况下,%Y-%m-%d将其格式化为仅日期,而%H:%M提取小时和分钟。
f':gray[:material/calendar_month: {day} \n :material/schedule: {time}]'
这里有几个事情在进行。在 Streamlit 中,:<color>[<text>]语法用于显示不同颜色的文本。例如,字符串:red[Hello]会被st.write或st.markdown等小部件解释为红色文本的单词Hello。
我们还看到了之前看到的图标语法。在这里,我们使用日历图标表示日期,使用时钟图标(schedule)表示时间,为日期和时间创建了一个用户友好的显示。
get_haiku_text_display的目的是显示俳句的内容。为什么我们有以下替换方法?为什么不直接显示内容呢?
haiku.text.replace('\n', ' \n')
这是一种解决方案。Streamlit 处理文本中的换行符相当奇怪。为了使文本小部件如st.markdown正确显示换行符\n,我们必须在它前面加上两个空格,即' \n'而不是仅仅'\n'。
edit_button和delete_button函数简单地显示st.button小部件。你会注意到我们使用图标作为它们的标签,并给它们小部件键——这是 Streamlit 在页面上显示许多俳句时区分它们所必需的。我们现在给它们带有pass的占位符主体;我们稍后会回来处理它们。
由于我们在整个应用程序中使用了这么多图标,如果我们给它们更好的名字(例如,CLOCK而不是:material/schedule:)并将它们放在更中心的位置,我们的代码实际上会更容易阅读。
让我们把所有的图标放入它们自己的文件中,frontend/icons.py(列表 8.18)并导入它们。
列表 8.18 frontend/icons.py
LOGIN = ":material/login:"
SIGNUP = ":material/person_add:"
HOME = ":material/home:"
LOGOUT = ":material/logout:"
ADD = ":material/add_circle:"
CALENDAR = ":material/calendar_month:"
CLOCK = ":material/schedule:"
EDIT = ":material/edit:"
DELETE = ":material/delete:"
我们现在可以更改haiku_display.py:
import streamlit as st
from frontend.icons import CALENDAR, CLOCK, EDIT, DELETE
...
def get_haiku_created_display(haiku):
...
return f':gray[{CALENDAR} {day} \n {CLOCK} {time}]'
...
def edit_button(haiku):
if st.button(f'{EDIT}', key=f"edit_{haiku.haiku_id}"):
pass
def delete_button(haiku):
if st.button(f'{DELETE}', key=f"delete_{haiku.haiku_id}"):
pass
...
这样就更容易阅读了!现在让我们调用display_haiku来显示用户创建的俳句列表!我们将编辑home.py来完成此操作:
import streamlit as st
from frontend.haiku_editor import haiku_editor
from frontend.haiku_display import display_haiku
from frontend.icons import ADD
...
if st.button(f'{ADD} Haiku', type='primary'):
haiku_editor(hub, user)
haikus = hub.haiku_service.get_haikus_by_author(user.username)
if len(haikus) == 0:
st.info("You haven't written any haikus yet.")
else:
for haiku in haikus:
display_haiku(haiku)
这里的更改相当简单。在之前创建的添加按钮之后——我们现在将其更改为使用从icons.py导入的图标——我们显示一个标题,说明<username>'s haikus,以及一个分隔符(由divider="gray"控制的水平线)。
然后,我们调用在HaikuService中定义的get_haikus_by_author方法,并遍历结果,对每个结果调用display_haiku。如果没有俳句,我们显示一个st.info消息来说明这一点。
现在重新运行你的应用程序,以查看图 8.12 中的更改(在添加另一个俳句后)!

图 8.12 列表创建的俳句(在 GitHub 仓库的 chapter_8/in_progress_07 中查看完整代码)
注意,我们还在一个地方使用了图标:pages.py,在那里我们定义了多页应用程序的页面。请继续更新该文件:
import streamlit as st
from frontend.icons import LOGIN, LOGOUT, SIGNUP, HOME
pages = {
"login": st.Page("frontend/login.py", title="Log in", icon=LOGIN),
"signup": st.Page("frontend/signup.py", title="Sign up", icon=SIGNUP),
"home": st.Page("frontend/home.py", title="Home", icon=HOME),
"logout": st.Page("frontend/logout.py", title="Log out", icon=LOGOUT)
}
添加更新和删除功能
让我们回到编辑和删除按钮,它们目前下面有占位符。编辑按钮应该允许用户编辑他们现有的俳句之一。我们可以重用我们已创建的俳句编辑器对话框来与“添加俳句”按钮一起使用:
import streamlit as st
@st.dialog("Haiku editor", width="large")
def haiku_editor(hub, user, haiku=None):
default_text = haiku.text if haiku else ''
haiku_text = st.text_area('Enter a haiku', value=default_text)
if st.button('Save haiku', type='primary'):
if haiku:
new_haiku = hub.haiku_service.update_haiku(haiku.haiku_id, haiku_text)
else:
new_haiku = hub.haiku_service.create_haiku(user.username, haiku_text)
if new_haiku:
st.success('Haiku saved successfully!')
st.rerun()
else:
st.error('Failed to save haiku')
haiku_editor函数现在接受一个haiku参数,默认值为None。如果我们调用编辑器来编辑现有的俳句,我们可以将相应的Haiku实例传递给haiku。否则,我们正在调用对话框来添加俳句,因此我们传递None。
在函数的其余部分,我们将使用条件if haiku来检查我们是在执行编辑操作还是添加操作。
在接下来的两行中,如果我们处于编辑操作,我们将使用st.text_area的value参数将现有的俳句文本预先填充到文本区域作为默认值。
default_text = haiku.text if haiku else ''
haiku_text = st.text_area('Enter a haiku', value=default_text)
然后,一旦点击保存按钮,我们就从 HaikuService 中选择 update_haiku 或 create_haiku 方法来执行。在前者的情况下,我们传递现有俳句的 haiku_id 来标识我们想要编辑的俳句。
如果操作成功——我们通过检查返回值 new_haiku 来确定——我们发出 st.rerun()。这会重新运行整个应用程序,在这个过程中关闭对话框,因为最初触发它的按钮现在处于“未点击”状态。
现在,我们可以替换 haiku_display.py 中编辑按钮下的占位符:
def edit_button(haiku):
if st.button(f'{EDIT}', key=f"edit_{haiku.haiku_id}"):
haiku_editor(app, user, haiku)
在同一页面上,让我们也让删除按钮触发删除操作:
def delete_button(haiku):
if st.button(f'{DELETE}', key=f"delete_{haiku.haiku_id}"):
deleted_haiku = app.haiku_service.delete_haiku(haiku.haiku_id)
if deleted_haiku:
st.rerun()
else:
st.error("Failed to delete haiku.")
在这里,我们在 HaikuService 中调用 delete_haiku 方法。如果删除成功,我们执行 st.rerun() 以更新俳句列表并不再显示已删除的俳句。如果由于任何原因删除失败,我们将显示错误。
现在重新运行应用程序并尝试编辑或删除俳句(图 8.13)!

图 8.13 删除俳句(完整代码请参阅 GitHub 仓库中的 chapter_8/in_progress_08)
俳句天堂现在已经完全建成——至少我们有一个适合部署到生产的版本。不过,在那样做之前,还有一些最终问题需要我们解决。
8.6 多用户考虑
虽然“俳句天堂”可以由许多用户同时使用,但我们需要能够在这类用户之间有效地共享资源。我们在这里负责管理的主要资源是数据库。
8.6.1 使用 st.cache_resource 共享数据库连接池
让我们考虑当有多个用户同时访问 Streamlit 应用程序时,应用程序是如何工作的。虽然有一个单独的 Streamlit 服务器提供应用程序服务,但每次用户访问它时,都会创建一个新的应用程序实例,为该用户创建所有运行应用程序所需的对象。
大多数时候,我们希望这样;这确保了不同的用户会话不会相互干扰。然而,有些东西我们 不 每次有人在新浏览器标签页中加载应用程序时都希望重新创建。
一个关键示例是我们 Database 类中创建的数据库连接池。事实上,拥有数据库连接池的全部意义在于,当多个用户会话需要访问数据库时,他们可以通过从 共享 池中请求连接来实现,完成使用后将其返回。
这意味着应该只创建 Database 类的一个实例,以便所有用户共享。然而,我们还没有这样设置。目前,每次 新用户加载应用程序——或者用户在新的浏览器标签页中加载应用程序——就会开始一个新的会话,并创建一个新的 Hub 类实例,这意味着也会创建一个新的 Database 实例。
幸运的是,Streamlit 提供了一个解决方案:st.cache_resource。
就像我们在第六章中使用的 st.cache_data——它使我们的度量仪表板中的数据加载更快——st.cache_resource 是确保在应用程序的所有用户中只存在一个实例的一种方式。
虽然 st.cache_data 用于缓存像 Pandas 数据框或 API 调用结果这样的东西,但 st.cache_resource 用于像数据库连接这样的资源。在这种情况下,我们将用它来存储我们想要创建的 Database 类的单个实例。
这需要我们对应用程序进行一点重构。回想一下,目前(在 hub.py 中)我们的 Hub 类接受一个 config 对象并自己创建 Database 实例:
...
class Hub:
def __init__(self, config):
database = Database(config['connection_string'])
self.user_service = UserService(database)
self.haiku_service = HaikuService(database)
但在我们的新方案中,Hub 类将为每个会话创建一个新的实例,这意味着一个新的 Database 实例也将持续被创建。相反,我们将让 Hub 的 __init__ 接受一个已经创建的 Database 实例,它可以简单地将其传递给 UserService 和 HaikuService。
这样,我们就可以始终传递 Database 类的相同实例,避免每次都创建它。hub.py 现在将看起来像这样:
...
class Hub:
def __init__(self, database):
self.user_service = UserService(database)
self.haiku_service = HaikuService(database)
那么,我们在哪里创建 Database 实例呢?在 main.py 中,这是我们使用 st.cache_resource 的地方:
...
from backend.database import Database
from frontend.pages import pages
@st.cache_resource
def get_database():
connection_string = st.secrets['config']['connection_string']
database = Database(connection_string)
return database
if 'hub' not in st.session_state:
st.session_state.hub = Hub(get_database())
...
就像 st.cache_data 一样,st.cache_resource 是一个应用于函数的装饰器。在这里,我们定义了一个新的函数来装饰,称为 get_database,它将返回缓存的 Database 对象。
get_database 现在将只为启动的特定 Streamlit 服务器运行一次,即单个 streamlit run 命令——当 main.py 首次加载时。对于所有后续运行,get_database 将返回缓存的 Database 实例,从而确保永远只创建这样一个实例。这个缓存的实例将一直持续到服务器进程终止。
8.6.2 使用 atexit 清理数据库连接
我们确保每个 Streamlit 应用服务器运行实例只创建一个 Database 实例。这是高效管理数据库资源的一部分。另一部分是安全地清理我们创建的任何连接。
我们的 Database 类有一个 close_all 方法:
def close_all(self):
print("Closing all connections...")
self.connection_pool.closeall()
然而,如果你检查我们到目前为止的代码,我们实际上并没有在任何地方调用这个方法。
为了正确清理我们的连接,我们希望这个方法只在 Streamlit 服务器终止时被调用一次。我们如何实现这一点?
解决方案是 atexit 模块,它是 Python 内置的。atexit 允许你在 Python 解释器即将退出时自动执行函数。
这是我们将如何修改 main.py 以使用 atexit 注册一个清理数据库连接的函数:
import streamlit as st
import atexit
...
@st.cache_resource
def get_database():
connection_string = st.secrets['config']['connection_string']
database = Database(connection_string)
atexit.register(lambda db: db.close_all(), database)
return database
...
atexit.register 接受一个要注册的函数,以及我们想要传递给该函数的任何参数的值。在上面的代码中,我们注册的函数是一个单行 lambda 函数:lambda db: db.close_all()。
它接受一个参数——db,这是数据库实例。这个函数所做的只是调用 db 的 close_all 方法。我们传递给 atexit.register 的第二个参数是 database,这是上面一行创建的 Database 实例。
调用 atexit.register “安排”一个函数在 Python 本身退出时执行,在 streamlit run 的情况下,这发生在 Streamlit 服务器关闭时。
为什么我们把 atexit.register 的调用放在 get_database 中?为什么不放在文件的其他地方,比如文件末尾?嗯,就像创建 Database 对象一样,我们只想在整个用户中执行一次 atexit 函数的注册——因为只有一个 Streamlit 服务器。这意味着我们必须在带有 st.cache_resource 装饰器的函数中调用 atexit.register,否则这个注册将会多次发生。
要查看这一变化的效果,尝试使用 streamlit run 重新启动应用服务器,然后按 Ctrl+C 退出它。您应该会看到消息 Closing all connections...,这表明已经调用了 close_all。
8.7 部署 Haiku Haven
由于 Haiku Haven 在本地已经运行,现在是时候在 Community Cloud 上将我们的应用投入生产。这个过程与我们在第五章以来一直遵循的过程相同,但这里有一个额外的复杂因素:我们的应用需要一个正在运行的 PostgreSQL 服务器来托管我们的数据库。
8.7.1 在生产中设置托管 PostgreSQL 服务器
在我们本地开发时,在相同机器上安装 PostgreSQL 是一件简单的事情,但 Streamlit Community Cloud 并不提供这样的选项。相反,我们需要在某个地方设置一个外部 PostgreSQL 服务器。
我们将使用一个名为 Neon 的基于云的托管 PostgreSQL 服务,这使得这个过程变得非常简单,并且有显著的免费配额。现在就使用 Neon 创建一个账户,网址为 https://neon.tech/。注册过程相当简单;如果您愿意,可以选择使用 GitHub 或 Google 账户注册。
您将被要求输入一个项目名称和一个数据库名称。项目名称可以是您喜欢的任何名称(例如 Haiku Haven?),而数据库名称应该是您在本地 Postgres 中命名数据库的名称——如果您一直忠实地跟随,那么应该是 haikudb。
您可能还会被要求选择一个云提供商和位置——这些可以是您喜欢的任何内容,尽管我选择了 AWS 作为提供商。
一旦您的账户设置好,导航到快速入门页面,您会看到一个看起来像这样的连接字符串:
postgresql://haikudb_owner:Dxg2HFXreSZ3@ep-flower-dust-a63e8evn.us-west-2.aws.neon.tech/haikudb?sslmode=require
这是我们在生产中使用的连接字符串。Neon 已经为您设置了一个用户名(haikudb_owner)和密码。请将此字符串保存在安全的地方。
接下来,你需要在 Neon 中再次设置 users 和 haikus 表。为此,转到 SQL 编辑器标签。这就是你可以像在 psql 提示符中一样输入 SQL 命令的地方。要创建表,请参考第 8.2.4 节并获取我们本地执行的 CREATE TABLE 命令。你可以在 Neon 的 SQL 编辑器中执行这些命令,无需任何更改。
8.7.2 部署到社区云
部署过程的其余部分应该很简单,基本上与我们在第五章中做的是一样的。
确保创建一个 requirements.txt 文件,这样社区云就会知道安装所需的第三方模块——主要是 psycopg2 和 bcrypt。
为了参考,列表 8.19 显示了我使用的 requirements.txt。
列表 8.19 requirements.txt
streamlit==1.40.2
psycopg2-binary==2.9.10
bcrypt==4.2.0
注意,我使用了 psycopg2-binary 而不是 psycopg2;这是因为当我尝试使用后者时,社区云抛出了一个错误,但使用前者时没有。
完成部署后,你需要复制 secrets.toml 的内容,并将其粘贴到社区云的秘密设置中(参考第五章以刷新记忆),用从 Neon 复制的连接字符串替换连接字符串。
就这些了,朋友们!Haiku Haven 现已上线!告诉你的所有朋友,他们可以在你全新的网络应用上释放他们十七音节的创造力!
至于我们,让我们翻过 CRUD 一页,尝试构建 AI 应用程序。
8.8 摘要
-
CRUD 代表创建-读取-更新-删除,这是大多数应用执行的四项基本操作。
-
关系型数据库如 PostgreSQL 根据模式在表中组织数据,具有行和列。
-
设计应用程序的数据模型涉及识别实体、定义它们之间的关系、列出它们的属性,并将这些转换为模式,通常在实体-关系(ER)图的辅助下完成。
-
SQL(结构化查询语言)支持创建表(
CREATE TABLE)、插入行(INSERT INTO)、读取数据(SELECT..FROM)、更新行(UPDATE..SET)、删除行(DELETE FROM)和删除表(DROP TABLE)的命令。 -
psycopg2是一个用于通过共享连接池连接到 PostgreSQL 的 Python 模块。 -
永远不要以纯文本形式存储密码;相反,使用像
bcrypt这样的库对它们进行散列,并存储散列版本。为了进行身份验证,散列给定的密码并与存储的散列进行比较。 -
st.Page对象对应于 Streamlit 中多页应用中的单个页面。 -
st.navigation用于创建导航栏并指定应用中的页面。 -
st.page_link在多页应用中创建页面之间的链接。 -
st.cache_resource用于缓存资源,如数据库连接,并在用户之间共享。 -
使用内置的
atexit模块的atexit.register来注册一个在 Streamlit 服务器关闭时执行的函数。 -
当部署到生产环境时,你需要单独设置你的数据库服务器,可能使用 Neon 这样的托管服务来处理 PostgreSQL。
第九章:9 一个 AI 驱动的益智游戏
本章涵盖
-
将您的应用程序连接到大型语言模型 (LLM)
-
工程化 LLM 提示以实现您期望的结果
-
使用结构化输出以自定义可解析格式获取 LLM 响应
-
在顺序 Streamlit 应用中管理状态
-
使用 st.data_editor 创建可编辑的表格
创建软件与几年前相比有显著不同。这种差异源于人工智能(Artificial Intelligence)领域的重大发展,除非你一直住在山洞里,否则你可能已经听说过。
我当然是在谈论 LLM 或大型语言模型的突破,以及它们带来的令人兴奋的可能性。通过处理和生成自然语言,LLM 可以理解上下文,回答复杂问题,甚至可以自己编写软件——所有这些都能以惊人的流畅度完成。曾经需要特定领域专业知识或费尽心思编程的任务,现在只需几个精心设计的“提示”就能完成。
在本章中,我们将深入了解如何在您的应用程序中利用 LLM 的力量,依靠 AI 提示和响应来实现五年前需要高度先进技术的产品功能。在这个过程中,我们还将讨论如何调整您的 LLM 交互以获得您想要的结果,以及如何在不烧穿您的口袋的情况下做到这一点。
注意
本书 GitHub 仓库为 github.com/aneevdavis/streamlit-in-action。chapter_09 文件夹包含本章的代码和一个 requirements.txt 文件,其中包含所有必需 Python 库的确切版本。
9.1 事实狂热:一个 AI 益智游戏
如果你小时候看过游戏节目 Jeopardy! ——或者无论如何作为一个成年人——你一定会喜欢这一章。由于我在美国以外长大,我在三十多岁时才看了这个节目的第一集,但在我小时候,我并不缺少痴迷的益智游戏——Mastermind、Bournvita Quiz Contest 和 Kaun Banega Crorepati?(这是对原始英国节目 Who Wants to Be a Millionaire? 的印度版本)都是我青春期的常客。
十五岁的我如果在这本书中不包括至少一个益智应用,成年后的我绝不会原谅自己。幸运的是,益智游戏非常适合我们的第一个 AI 驱动的 Streamlit 应用,该应用将生成问题、评估答案,甚至以各种问答主持风格进行混合,所有这些都将使用人工智能完成。
9.1.1 陈述概念和需求
我们将要采取的第一步是陈述我们想要构建的应用程序的概念。
概念
事实狂热,一款询问用户一系列知识问答并使用 AI 评估其答案的益智游戏
就像本书中我们多次做的那样,需求将进一步阐述这个简单想法。
需求
事实狂热将:
-
使用 AI 模型生成知识问答
-
向玩家提出这些问题
-
允许用户对每个问题输入自由文本响应
-
使用 AI 评估答案是否正确,并提供正确答案
-
跟踪玩家的分数
-
允许玩家为问题设置难度级别
-
提供多种问答主持人说话风格
虽然我们通常希望我们的需求不包含“实现”语言(如第三章所述),但在这个案例中,我们应用程序的整个目的就是展示 AI 的使用——因此我们肯定需要使用 AI 模型来执行其功能。
我们还增加了一个有趣元素,即问答主持人的说话风格——换句话说,在提问时模仿各种人的风格,这是我们只能通过 AI 才能实现的事情。
范围之外的内容
我们省略了什么?虽然一个专业的知识竞赛游戏可以非常复杂,但在构建“事实狂热”时,我们希望创建一个最小化的应用程序,以便亲身体验我们之前未曾接触过的主题。因此,我们不会关注以下任何一项:
-
持久存储和检索问题、答案和分数
-
创建和管理用户
-
允许用户选择特定的问题类别
将上述项目排除在范围之外将使我们能够专注于诸如与大型语言模型(LLMs)交互、状态管理以及当然,新的 Streamlit 元素等问题。
9.1.2 可视化用户体验
为了让我们对“事实狂热”有一个具体的了解,请查看我们提出的 UI 草图,如图 9.1 所示。

图 9.1 “事实狂热”的 UI 草图
图表中显示的游戏窗口具有两列布局。左侧列有一个“新游戏”按钮,以及一些设置:问答主持人和难度。
参考上一节中确定的最后一个需求,问答主持人设置应该使用 AI 来模仿各种角色的说话风格。如图 9.1 所示,选定的值是“Gollum”,《指环王》中的一个角色,他使用独特的嘶嘶声说话,使用诸如“我的宝贝”之类的短语。
右侧的列显示了用 Gollum 的声音“说出”的 AI 生成问题,以及玩家的分数和一个输入答案的框。
除了花招之外,这是一个相当标准的问答知识竞赛游戏,允许玩家输入自由文本来回答。
9.1.3 实施头脑风暴
尽管我们将“外包”我们逻辑的大部分部分给 LLM,但我们仍然需要拥有整体的游戏流程。图 9.2 显示了我们在本章其余部分将努力实现的设计。
与我们编写的一些其他应用程序不同,用户可以在任何时刻执行各种操作,而“事实狂热”则相当线性。如图所示,基本逻辑在一个循环中运行——使用大型语言模型(LLM)检索一个知识问题,将其提出给玩家,让 LLM 评估答案,声明提供的答案是否正确,然后对下一个问题重复整个过程,直到游戏结束。

图 9.2 基于 AI 的知识问答游戏的逻辑流程
在以后,我们将添加一些功能,如允许玩家设置难度级别和问答大师,但图 9.2 很好地代表了核心流程。
9.2 使用 AI 生成知识问答问题
在“知识狂潮”的核心是一个 AI 模型,它为知识问答体验提供动力。该模型生成问题、评估玩家回答,甚至为问答大师增添个性。为了实现所有这些,我们使用大型语言模型(LLM)——这是一种强大的 AI 系统,旨在处理和生成类似人类的文本。
LLM 是在大量文本数据上训练的,这使得它们具有极大的灵活性。它们可以执行从回答事实问题到创作诗歌、编写代码,以及对我们来说更重要的是——扮演问答大师等多种任务。流行的 LLM 例子包括 OpenAI 的 GPT 系列、Anthropic 的 Claude 和 Google 的 Gemini——所有这些都利用了尖端的机器学习技术来生成连贯、上下文适当的文本。
9.2.1 为什么在“知识狂潮”中使用 LLM?
什么使 LLM 适合用于我们的知识问答游戏?为了回答这个问题,让我们花一分钟时间考虑这种游戏的一些可能组成部分以及 LLM 如何支持构建每一个部分:
一个巨大的问题列表
没有 LLM,我们需要维护一套足够大且多样化的知识问答题库,以保持我们的应用具有吸引力。由于今天的主要 LLM 都是基于历史、文化、地理、天文学以及你能想到的任何其他知识问答类别的大量文本和数据进行训练,因此它们能够即时生成问题。
评估答案的能力
如果我们走传统的非 LLM 路线,我们实际上只能提出多项选择题,因为我们需要准确地将玩家给出的答案与实际答案匹配。另一方面,LLM 可以解释和响应自由文本用户输入。这使得我们能够提出开放式知识问答问题,同时仍然正确评估玩家的回答。
娱乐价值
除了能够处理事实信息外,LLM 还可以发挥创意,提供幽默来吸引参与。在章节的后面,我们将要求它们模仿各种角色的风格,作为问答大师,从而给游戏赋予个性。
因此,在“知识狂潮”中,LLM 将扮演问题生成者、答案评估者和喜剧提供者的三重角色。在下一节中,我们将与顶级 LLM 提供商 OpenAI 建立账户,使我们能够首次开始与 LLM 互动。
备注
虽然 LLM(大型语言模型)无疑非常强大,但它们并非全知全能。它们依赖于训练数据中的模式来生成回答,并且有时可能会犯错,尤其是在评估高度细微或模糊的回答时。然而,对于我们的 Trivia(知识问答)应用来说,这些模型在智能、多样性和娱乐性之间达到了完美的平衡。
9.2.2 设置 OpenAI API 密钥
对于本章,我们将使用 OpenAI 提供的 LLM,可能是最近在科技新闻中占据主导地位的新兴 AI 公司中最知名的一个。
正如我们在本书中多次做的那样,我们需要在第三方服务中开设账户,并将我们的 Python 代码与之连接。
如果你还没有 OpenAI 账户(你的 ChatGPT 账户也算),请访问platform.openai.com/并注册一个账户。
注意
如果你最近创建了 OpenAI 账户或者现在才创建,你的账户可能已经应用了一些免费信用额度。然而,免费层目前有相对严格的限制,例如每分钟只能调用某些模型三次。虽然技术上你可以使用免费层来完成本章(假设你的账户最初就有免费信用额度),如果你计划进行任何数量的严肃 AI 开发,我建议升级到付费层。正如你将看到的,你可以以相当低的价格获得大量的 LLM 使用量。虽然你可能需要购买 5 美元的使用信用额度才能达到最低付费层,但你在这章中不需要花费太多。为了参考,我在开发这个课程时进行的所有测试中,花费的信用额度不到 15 美分。你可以在名为“成本考虑”的侧边栏中了解更多关于成本优化的信息。
登录后,转到设置页面——在撰写本文时,你可以通过点击页面右上角的齿轮图标来完成此操作——然后点击左侧面板中的“API 密钥”。
创建一个新的密钥并将其记录下来。你可以保留所有默认设置不变(见图 9.3)。

图 9.3 在你的 OpenAI 账户中创建密钥
你只能看到一次你的密钥,所以将其复制并粘贴到某个地方。不用说,但请确保安全!
9.2.3 在 Python 中调用 OpenAI API
在我们开始开发应用程序之前,让我们确保可以在 Python 中无任何问题地调用 OpenAI API。
首先,使用pip install openai安装 OpenAI 的 Python 库。技术上,你可以使用requests模块通过 HTTP 调用调用 API(正如我们在第五章中所做的那样),但这个库更方便使用。
完成后,打开 Python shell 并导入OpenAI类:
>>> from openai import OpenAI
OpenAI类允许你使用 API 密钥实例化一个客户端,以便调用 OpenAI API:
>>> client = OpenAI(api_key='sk-proj-...')
将sk-proj-...替换为上一步中复制的实际 API 密钥。创建了一个client对象后,让我们准备发送给 LLM 的指令:
>>> messages = [
... {'role': 'system', 'content': 'You are a helpful programming assistant'},
... {'role': 'user', 'content': 'Explain what Streamlit is in 10 words or fewer'}
... ]
你发送给 LLM 的每个请求都称为prompt。prompt——至少是我们在这里将使用的类型——由消息组成。在上面的代码中,我们将这些消息组装成一个列表。每个消息都采用字典的形式,包含两个键:role和content。
role 可以是 user、system 或 assistant 之一。我们将在稍后探索更多示例,但 role 的值表示对话中说话者的视角:
-
system代表对模型的指令或上下文设置,例如它应该如何行为的规则。 -
user代表与模型交互的人(您)。 -
assistant代表模型的响应——我们将在下一章讨论这一点。
我们在这里创建的提示告诉 LLM(在系统消息中)它应该表现得像一个有帮助的编程助手。我们希望 LLM 响应的实际指令在用户消息中:用 10 个词或更少解释 Streamlit。
我们现在可以向 API 发出实际请求:
completion = client.chat.completions.create(model='gpt-4o-mini', messages=messages)
OpenAI API 有几个不同的端点——一个用于将音频转换为文本,一个用于创建图像,等等。我们将使用的是 chat completions 端点,它用于文本生成。
给定一个对话中的消息列表,此端点应返回接下来会发生什么——因此有 completion 这个术语。OpenAI 有许多我们可以使用的模型,但我们在这里选择了 gpt-4o-mini,它在智能、速度和成本之间提供了良好的平衡。
注意
虽然 gpt-4o-mini 目前是 OpenAI 为我们用例提供的最合适的模型,鉴于人工智能领域的快速发展,到这本书印刷的时候,我们可能会有更智能且更便宜的更新模型。请密切关注 OpenAI 的定价页面 openai.com/api/pricing/,以确保您使用的是最适合的模型。
上述语句需要几秒钟才能执行,并将返回一个 ChatCompletion 对象。如果您愿意,可以通过在壳中仅输入 completion 来检查此对象,但您可以通过以下方式访问我们想要的实际文本响应:
>>> completion.choices[0].message.content
'Streamlit is a framework for building interactive web applications easily.'
我无法用更好的方式来表达!这标志着我们与 LLM 的第一次程序性交互。接下来,让我们将其构建到我们的问答游戏代码中。
9.2.4 编写 LLM 类
在第八章中,我们创建了一个 Database 类,它封装了我们的应用程序可以与外部数据库交互的交互。在这一章中,我们将采用相同的模式,使用一个 Llm 类来处理与外部 LLM 的所有通信。这使我们能够将 LLM 的通信逻辑与我们的应用程序的其他部分分开,使其更容易维护、测试,甚至完全替换,而无需触及剩余的代码。
我们在前一节中已经介绍了调用 LLM 的基础知识,所以剩下的只是将逻辑放入一个类中。
创建一个新文件,llm.py,内容如列表 9.1 所示。
列表 9.1 llm.py
from openai import OpenAI
class Llm:
def __init__(self, api_key):
self.client = OpenAI(api_key=api_key)
@staticmethod
def construct_messages(user_msg, sys_msg=None):
messages = []
if sys_msg:
messages.append({"role": "system", "content": sys_msg})
messages.append({"role": "user", "content": user_msg})
return messages
def ask(self, user_msg, sys_msg=None):
messages = self.construct_messages(user_msg, sys_msg)
completion = self.client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
)
return completion.choices[0].message.content
(GitHub 仓库中的 chapter_09/in_progress_01/llm.py)
Llm 类的 __init__ 方法只是使用传递给它的 API 密钥创建一个新的 OpenAI 客户端对象,并将其分配给 self.client。
ask 方法是类外部的逻辑与之交互的部分,它返回 LLM 对我们提示的响应。其代码基本上与我们之前在 Python 命令行中运行的是相同的,除了我们现在将 user_msg 和 sys_msg 作为参数传入,并将创建 messages 列表的操作放在一个名为 construct_messages 的独立方法中。
由于我们不必传递系统角色消息——LLM 无论如何都会尽力帮助——我们给 sys_msg 设置了一个默认值 None。construct_messages 方法在生成 messages 列表时考虑到这一点。由于这是一个不依赖于对象中其他任何内容的实用函数,我们通过使用 @staticmethod 装饰器将其设为静态方法。
我们将在本章的后面进一步细化 Llm 类,但现在是时候继续编写调用它的代码了。
9.2.5 Game 类
与第八章一样,我们将有一个单独的类——命名为 Game 的类——它包含所有应用程序前端将直接调用的后端逻辑。这与第八章中的 Hub 类有些类似,尽管我们将 Game 的结构设计得不同。
目前我们将保持代码相当简单,因为它的主要功能只是将一个提示传递给我们的 Llm 类。我们将放置在 game.py 中的 Game 类的初始版本在列表 9.2 中展示。
列表 9.2 game.py
from llm import Llm
class Game:
def __init__(self, llm_api_key):
self.llm = Llm(llm_api_key)
def ask_llm_for_question(self):
return self.llm.ask(
'Ask a trivia question. Do not provide choices or the answer.',
'You are a quizmaster.'
)
(chapter_09/in_progress_01/game.py 在 GitHub 仓库中)
Game 实例的初始化(通过 __init__)涉及通过传递 API 密钥创建一个 Llm 对象,我们预计将从调用代码中获取这个 API 密钥。
ask_llm_for_question 方法传递一个简单的提示,要求 LLM 生成一个知识问答问题。注意,系统消息现在告诉 LLM 要表现得像一位问答大师。
用户消息指示 LLM 提出一个问题,并警告它不要提供任何选项或泄露答案。
9.2.6 在我们的应用程序中调用 Game 类
我们现在可以编写一个前端代码的最小版本来测试我们所做的一切。像往常一样,我们的 API 密钥需要保密并安全存放,所以我们将它放在一个新创建的 .streamlit 目录下的 secrets.toml 文件中,如列表 9.3 所示。
列表 9.3 .streamlit/secrets.toml
llm_api_key = "sk-proj-..." #A
A 将 sk-proj-... 替换为你的实际 API 密钥。
我们这次将 secrets.toml 保持得相当简单,采用非嵌套结构——注意 [config] 类似的部分缺失。毕竟,TOML 中的 O 也代表“明显”。
现在就创建 main.py(如列表 9.4 所示)吧。
列表 9.4 main.py
import streamlit as st
from game import Game
game = Game(st.secrets['llm_api_key'])
question = game.ask_llm_for_question()
st.container(border=True).write(question)
(chapter_09/in_progress_01/main.py 在 GitHub 仓库中)
目前这里还没有什么特别之处;我们只是创建了一个名为 game 的 Game 实例,调用它的 ask_llm_for_question 方法来生成一个知识问答问题,并将其写入屏幕。
注意我们如何将 st.container 与边框和 st.write 结合成一个单独的语句:
st.container(border=True).write(question)
简洁而美观,就像 Streamlit 本身一样。使用streamlit run main.py运行你的应用程序(如前几章所述,请确保首先cd到包含main.py的目录,以便 Streamlit 可以找到.streamlit目录)以查看类似图 9.4 的内容。

图 9.4 应用程序现在可以成功调用 OpenAI API 以获取一个知识问题(有关完整代码,请参阅 GitHub 仓库中的 chapter_09/in_progress_01)
太好了——我们的 AI 测验大师现在可以向玩家提问了!接下来我们将评估玩家的答案。
9.3 使用 AI 评估答案
我们提供给 GPT-4o mini 的提示非常简单。至关重要的是,我们不需要对输出进行任何特别复杂的处理——只需将其原样显示在屏幕上即可。因此,我们并不真正关心 LLM 响应的格式。
然而,评估答案提出了一个稍微不同的挑战。当玩家在应用程序中输入答案时,我们需要 LLM 告诉我们两件事:
-
答案是否正确
-
如果不是,那么正确答案实际上是什么
如果你之前与 AI 助手互动过,这听起来完全在其能力范围内。但让我们考察一个实际挑战:如何可靠地解析 LLM 的响应?
例如,假设我们告诉 LLM:“嘿,你问了这个问题:
LLM 执行其操作并响应:
“不正确,正确答案是
我们该如何处理这个回复?当然,我们可以在屏幕上显示它,但我们可能还需要执行其他操作,比如决定是否增加玩家的分数。这意味着我们需要解析回复来理解答案是否正确。
我们该如何做?一个简单的方法是查找响应中的“incorrect”一词,并根据情况标记答案是否正确。
但如果 LLM 的回答实际上是“不,那不对,正确答案是
问题是,LLM 有无数种创造性的回答方式,虽然我们作为人类能够理解它们,但我们仍然需要一个简单的方法来确定它们在机器友好的方式下的含义。
我们可以在提示中要求 LLM 包含“正确”或“不正确”等词语,但仍然可能会偶尔出错。幸运的是,有一个更好的方法。
9.3.1 结构化输出
能够可靠地解析 LLM 的输出是开发者的一个自然关注点,因此 OpenAI 为此提供了一个名为结构化输出的解决方案。
结构化输出是一个确保模型将生成一个遵循你提供的模式的响应的功能,这使得程序化解析变得简单。
对于我们的用例,这意味着我们可以请求 LLM 在其响应中提供两个结构化字段:一个表示提供的答案是否正确的布尔字段,以及实际的正确答案。
让我们创建这个模式作为一个名为 AnswerEvaluation 的类(列表 9.5)。我们需要第三方 pydantic 模块来使这工作,所以首先使用 pip install pydantic 安装它。
列表 9.5 answer_evaluation.py
from pydantic import BaseModel
class AnswerEvaluation(BaseModel):
is_correct: bool
correct_answer: str
(GitHub 仓库中的 chapter_09/in_progress_02/answer_evaluation.py)
pydantic 是一个使用类型提示来确保数据符合指定类型的验证库。
我们从 pydantic 导入的 BaseModel 是一个允许你定义模式并执行数据验证的类。它与 OpenAI 的结构化输出配合得很好。
我们定义的类 AnswerEvaluation 是 BaseModel 的一个 子类。子类 和 超类 与面向对象编程中的 继承 概念相关。详细解释继承超出了本书的范围,但只需知道子类可以 继承 其超类中的功能属性,允许你重用代码并在现有功能的基础上构建,而不需要从头开始。
在这种情况下,AnswerEvaluation(子类)继承了 BaseModel(超类)的功能,如数据验证、序列化和类型检查,这使得定义和操作结构化数据变得容易。
AnswerEvaluation 的主体与如果你将其视为数据类时可能期望的相同。确实,数据类与 pydantic 的 BaseModel 类似,但数据类不提供 BaseModel 所提供的复杂验证和相关功能。
幸运的是,我们实际上并不需要担心这个工作原理的内部细节——只需注意 AnswerEvaluation 有我们之前提到的两个字段:一个布尔字段 is_correct 和一个字符串 correct_answer。
接下来,让我们修改 Llm 类中的 ask 函数(llm.py)以支持结构化输出:
from openai import OpenAI
class Llm:
...
def ask(self, user_message, sys_message=None, schema=None):
messages = self.construct_messages(user_message, sys_message)
if schema:
completion = self.client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=messages,
response_format=schema
)
return completion.choices[0].message.parsed
else:
completion = self.client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
)
return completion.choices[0].message.content
(GitHub 仓库中的 chapter_09/in_progress_02/llm.py)
在这里,我们添加了一个默认值为 None 的名为 schema 的参数。如果提供了值(if schema),我们将调用我们 OpenAI 客户端中的不同方法(与之前的 chat.completions.create 相比,现在是 beta.chat.completions.parse)。
我们传递给这个新方法的第一个和第二个参数与之前相同,但我们增加了一个第三个参数:response_format,我们将 schema 的值传递给它。
我们返回的最终值也有所不同:completion.choices[0].message.parsed 而不是 completion.choices[0].message.content。
如果没有提供 schema,我们将默认回退到之前的行为,从而确保 ask 方法可以处理结构化输出和常规文本。
我们需要传递给 schema 的值是一个 类——不是类的 实例,而是类 本身。然后返回的值将是一个该类的 实例,因此将遵循架构。正如你可能已经猜到的,对于我们的用例,我们将传递 AnswerEvaluation 类给 schema。
我们将在稍后创建这个调用代码,但首先让我们创建一个新的 LLM 提示,要求模型评估玩家的答案。
在开发过程中,你应该预计需要多次调整你的提示以获得更好的结果——事实上,近年来出现了一个名为 提示工程 的整个领域。
由于我们的提示与代码有实质性不同,所以让我们将它们放在不同的文件中,这样我们就可以在不触及其余代码的情况下编辑它们。我们将命名为 prompts.py 并在列表 9.6 中给出其内容。
列表 9.6 prompts.py
QUESTION_PROMPT = {
'system': 'You are a quizmaster.',
'user': 'Ask a trivia question. Do not provide choices or the answer.'
}
ANSWER_PROMPT = {
'system': 'You are an expert quizmaster.',
'user': '''
You have asked the following question: {question}
The player answered the following: {answer}
Evaluate if the answer provided by the player is close enough
to be correct.
Also, provide the correct answer.
'''
}
(GitHub 仓库中的 chapter_09/in_progress_02/prompts.py)
每个提示都结构化为一个字典,其中 system 和 user 键对应于系统和用户消息。
QUESTION_PROMPT 是我们之前的提示,而 ANSWER_PROMPT 是新的。请注意,其用户消息(如果你想知道语法,它是一个由 ''' 包围的 Python 多行字符串)包含以下这些行:
You have asked the following question: {question}
The player answered the following: {answer}
我们在这里将 {question} 和 {answer} 视为变量,当我们稍后向 LLM 发送提示时,可以用真实值替换它们。
还要注意这条消息的最后两行,我们在这里告诉 LLM 评估答案的正确性,并且 还要 提供正确答案。这个模型足够智能,可以解释这个指令,并以我们的 AnswerEvaluation 架构提供结果。
说到这个,让我们实际编写代码,将这个架构连同提示一起传递,以满足我们的答案评估用例。我们将通过修改 game.py 来做这件事:
from llm import Llm
from prompts import QUESTION_PROMPT, ANSWER_PROMPT
from answer_evaluation import AnswerEvaluation
class Game:
def __init__(self, llm_api_key):
self.llm = Llm(llm_api_key)
def ask_llm_for_question(self):
usr_msg, sys_msg = QUESTION_PROMPT['user'], QUESTION_PROMPT['system']
return self.llm.ask(usr_msg, sys_msg)
def ask_llm_to_evaluate_answer(self, question, answer):
sys_msg = ANSWER_PROMPT['system']
user_msg = (
ANSWER_PROMPT['user']
.replace('{question}', question)
.replace('{answer}', answer)
)
return self.llm.ask(user_msg, sys_msg, AnswerEvaluation)
(GitHub 仓库中的 chapter_09/in_progress_02/game.py)
我们已经重构了 ask_llms_for_question 方法,以使用我们新的 prompts.py 模块。
但这里的主要变化是新的函数,ask_llm_to_evaluate_answer,它接受最初提出的 question 和用户提供的 answer,将这些值插入我们之前讨论的用户消息中的 {question} 和 {answer} 插槽。
这次,我们将 AnswerEvaluation(从 answer_evaluation.py 导入)作为第三个参数传递给 self.llm 的 ask 方法——schema,如你希望回忆的那样。这个有趣的一个方面是我们在这里传递的是 AnswerEvaluation 本身,而不是 AnswerEvaluation 的 实例,而是类。在 Python 中,你代码中的大多数结构都是 对象,你可以像这样传递它们,包括类——这是使强大的灵活编程模式成为可能的东西。
但我跑题了。让我们回到使我们的游戏能够接受和评估玩家答案的步骤。最后一步是对 main.py 中的前端进行必要的更改:
import streamlit as st
from game import Game
game = Game(st.secrets['llm_api_key'])
question = game.ask_llm_for_question()
st.container(border=True).write(question)
answer = st.text_input("Enter your answer")
if st.button("Submit"):
evaluation = game.ask_llm_to_evaluate_answer(question, answer)
if evaluation.is_correct:
st.success("That's correct!")
else:
st.error("Sorry, that's incorrect.")
st.info(f"The correct answer was: {evaluation.correct_answer}")
(GitHub 仓库中的 chapter_09/in_progress_02/game.py)
你应该能够很容易地理解我们在这里所做的事情。一旦我们向玩家提出了这个趣味问题,我们就会显示一个文本输入框供他们输入答案,以及一个“提交”按钮,当点击这个按钮时,会触发game.py中的ask_llm_to_evaluate_answer方法。
结果值——存储在evaluation中——是AnswerEvaluation类的一个实例。我们使用它的is_correct属性来显示适当的正确/错误信息,以及evaluation.correct_answer来显示正确答案。
现在尝试重新运行你的应用程序,并为趣味问题提供一个答案。图 9.5 显示了我是如何做到的。

图 9.5 由于会话状态的问题,我们的答案与错误的问题匹配(有关完整代码,请参阅 GitHub 仓库中的 chapter_09/in_progress_02)
哎呀!这里有些鬼鬼祟祟的事情!我在图 9.5 的左侧部分回答“Ottawa”的问题时,答案是“加拿大的首都是什么?”。但是当我按下“提交”按钮时,应用程序显示了一个不同的问题——“我们太阳系中最大的行星是什么?”——然后竟然厚颜无耻地告诉我,“Ottawa”实际上不是那个问题的正确答案。
你会在测试中注意到类似的诱饵和替换。发生了什么事?我们的 AI 统治者是在玩弄我们吗?
事实上,LLM 对这种所谓的恶作剧完全无辜。问题——正如我们在本书中多次看到的那样——与会话状态和 Streamlit 应用程序的重运行有关。在这个特定的情况下,点击“提交”按钮会从“顶部”触发重运行,这意味着在if st.button("Submit"):下的代码运行之前,game.ask_llm_for_question()会被再次调用,从而产生一个新的问题,这个新问题作为ask_llm_to_evaluate_answer的第一个参数传递,同时从文本输入中提取“Ottawa”作为第二个参数。
嗯,至少,我们代码中的结构化输出部分似乎是在正常工作,因为木星确实是太阳系中最大的行星,而 Ottawa 是不正确的。
然而,为了使 Fact Frenzy 表现出我们预期的行为,我们将在下一节中仔细思考状态管理。
9.4 游戏状态之间的移动
在前面的章节中,Streamlit 的重运行整个应用程序的模型意味着我们必须广泛使用st.session_state来让应用程序记住值。这一点在这里同样适用。
然而,Fact Frenzy 在某种意义上是顺序的,而我们的先前的应用程序并不是。你可以把我们的游戏期望的行为看作是在各种状态之间移动,在每个状态中采取不同的动作并显示不同的小部件。图 9.6 说明了这一点。

图 9.6 我们的游戏由四个连续的状态组成
该图确定了游戏可能处于的四个状态:
-
GET_QUESTION,其中我们从 LLM 检索一个问题
-
ASK_QUESTION,其中我们向玩家提出这个问题
-
EVALUATE_ANSWER,涉及调用 LLM 来评估答案 -
STATE_RESULT,在这里我们告诉玩家他们是否正确
这些操作应该在游戏处于对应状态时才发生。此外,我们还需要确保我们对每个 LLM 请求只进行一次——因为否则会搞乱我们的逻辑,并且会花费金钱。
在此类顺序应用程序(如本例以及你未来可能编写的其他应用程序)中,我们将发现一个有用的模式是,在应用程序的中心类(在我们的例子中是 Game)中正式保留一个状态/状态属性,并配合修改它的方法,并在前端使用基于此属性的逻辑条件来显示屏幕上的正确元素。图 9.7 应该会使我所说的更清晰。

图 9.7 前端基于游戏状态的分支
正如你在图 9.7 中看到我们提出的逻辑,Game 类将有一个 status 属性,表示游戏所处的状态。我们将在前端读取这个状态,在各个显示元素之间进行分支——无论是 ASK_QUESTION 状态中的答案文本输入,还是 GET_QUESTION 和 EVALUATE_ANSWER 状态期间的等待 LLM 请求的状态元素。
Game 类也有改变其 status 属性的方法,控制所有这些。obtain_question 方法可能会从 LLM 获取问题,并在完成后将状态更改为 ASK_QUESTION。accept_answer 方法将接受玩家的答案,并将状态切换到 EVALUATE_ANSWER,而 evaluate_answer 方法除了让 LLM 给我们结果外,还会将状态切换到 STATE_RESULT。
让我们把这些都付诸实践,从 Game 类开始:
列表 9.7 game.py(已修改)
from llm import Llm
from prompts import QUESTION_PROMPT, ANSWER_PROMPT
from answer_evaluation import AnswerEvaluation
class Game:
def __init__(self, llm_api_key):
self.llm = Llm(llm_api_key)
self.status = 'GET_QUESTION'
self.curr_question = None
self.curr_answer = None
self.curr_eval = None
def ask_llm_for_question(self):
usr_msg, sys_msg = QUESTION_PROMPT['user'], QUESTION_PROMPT['system']
return self.llm.ask(usr_msg, sys_msg)
def ask_llm_to_evaluate_answer(self):
sys_msg = ANSWER_PROMPT['system']
user_msg = (
ANSWER_PROMPT['user']
.replace('{question}', self.curr_question)
.replace('{answer}', self.curr_answer)
)
reply = self.llm.ask(user_msg, sys_msg, AnswerEvaluation)
return reply
def obtain_question(self):
self.curr_question = self.ask_llm_for_question()
self.status = 'ASK_QUESTION'
return self.curr_question
def accept_answer(self, answer):
self.curr_answer = answer
self.status = 'EVALUATE_ANSWER'
def evaluate_answer(self):
self.curr_eval = self.ask_llm_to_evaluate_answer()
self.status = 'STATE_RESULT'
(GitHub 仓库中的 chapter_09/in_progress_03/game.py)
正如之前讨论的那样,这里第一个大的变化是引入了 self.status 属性,它正式表示游戏的状态。我们将其初始化为 GET_QUESTION,因为这是我们想要的第一种顺序状态。
你会注意到我们还有三个其他属性——curr_question、curr_answer 和 curr_eval——用于在 Game 实例中保存当前问题、答案和评估。这与 game.py 的早期版本不同,那时我们将 question 和 answer 作为类外部的变量来处理。在类内部跟踪这些内容更适合我们新的 状态化 方法。
你会在 ask_llm_to_evaluate_answer 方法中看到这一点,在那里我们放弃了 question 和 answer 参数,转而使用 self.curr_question 和 self.curr_answer 属性。
此外,我们引入了三种新的方法(之前也讨论过)——obtain_question、accept_answer和evaluate_answer。obtain_question和evaluate_answer分别是ask_llm_for_question和ask_llm_to_evaluate_answer的包装器,每个只是将结果分配给其关联的属性——self.curr_question或self.curr_answer——然后在代码中添加一行以将self.status移动到其下一个值。
accept_answer甚至更简单;它只是将self.answer设置为玩家可能提供的答案。
我们需要实现的用于实现我们设想的状态管理方法的更改集的第二部分在于main.py:
import streamlit as st
from game import Game
if 'game' not in st.session_state:
st.session_state.game = Game(st.secrets['llm_api_key'])
game = st.session_state.game
if game.status == 'GET_QUESTION':
with st.spinner('Obtaining question...') as status:
question = game.obtain_question()
st.rerun()
elif game.status == 'ASK_QUESTION':
st.container(border=True).write(game.curr_question)
answer = st.text_input("Enter your answer")
if st.button("Submit", type='primary'):
game.accept_answer(answer)
st.rerun()
elif game.status == 'EVALUATE_ANSWER':
with st.spinner('Evaluating answer...') as status:
game.evaluate_answer()
st.rerun()
elif game.status == 'STATE_RESULT':
if game.curr_eval.is_correct:
st.success("That's correct!")
else:
st.error("Sorry, that's incorrect.")
st.info(f"The correct answer was: {game.curr_eval.correct_answer}")
(GitHub 仓库中的chapter_09/in_progress_03/main.py)
我们首先将我们的Game实例放置在st.session_state中,确保我们在重试时将处理相同的实例:
if 'game' not in st.session_state:
st.session_state.game = Game(st.secrets['llm_api_key'])
game = st.session_state.game
这里的最后一行是为了方便,使我们能够简单地用game来引用我们的Game实例,而不是每次都写出st.session_state.game。
剩余的代码基于game的status属性构建条件分支。让我们简要考虑每个这样的条件:
if game.status == 'GET_QUESTION':
with st.spinner('Obtaining question...') as status:
question = game.obtain_question()
st.rerun()
第一个分支处理GET_QUESTION状态。应用无需用户交互即可处理此状态,因为它只是从 LLM 获取问题。然而,这可能需要一定的时间,因此我们显示所谓的状态元素。
状态元素只是一个小部件,它在后台进行长时间运行的操作时向用户提供一些指示。Streamlit 有几种不同的状态元素——st.spinner、st.status、st.toast、st.progress——每个都有略微不同的特性。
我们在这里使用的st.spinner简单地显示一个旋转的圆形动画(我们在第六章中看到过,当时我们将show_spinner参数应用于@st.cache_data),直到后台操作完成。
注意我们在调用game.obtain_question()之后调用的st.rerun()。这是因为一旦obtain_question(来自game.py)将状态更改为ASK_QUESTION,我们需要代码再次运行,以便进入由elif game.status == 'ASK_QUESTION':给出的下一个条件分支。
剩余的分支相当相似。在每种情况下,都存在某种触发器导致应用移动到下一个状态,随后进行重试。在ASK_QUESTION状态中,点击“提交”会调用game.accept_answer(answer),这将设置game的curr_answer属性并将状态更改为EVALUATE_ANSWER。
在EVALUATE_ANSWER中,我们调用game.evaluate_answer()并在等待它返回时显示另一个st.spinner,最终将状态更改为STATE_RESULT。
重试后,我们只需根据game.curr_eval,我们的AnswerEvaluation对象,显示适当的消息。
现在通过重试应用查看结果(图 9.8)

图 9.8 问题-答案不匹配的问题已解决(完整代码请见 GitHub 仓库中的 chapter_09/in_progress_03)
这次你会看到应用程序正确匹配问题和答案,解决了我们之前看到的问题。
9.5 游戏机制:计分、新游戏按钮和游戏结束
目前,Fact Frenzy 只做了我们需要的最基本的事情:向玩家提问,评估答案,全部使用 AI。但这并不是一个真正的游戏;没有得分,也没有游戏开始和结束的概念。让我们逐一解决这些问题。
计分
我们希望 Fact Frenzy 易于上手,所以我们将保持我们的计分机制简单;每个正确答案加一分。
这应该相当简单就可以集成到Game类中:
...
class Game:
def __init__(self, llm_api_key):
self.llm = Llm(llm_api_key)
...
self.score = 0
...
def evaluate_answer(self):
self.curr_eval = self.ask_llm_to_evaluate_answer()
if self.curr_eval.is_correct:
self.score += 1
self.status = 'STATE_RESULT'
(GitHub 仓库中的chapter_09/in_progress_04/game.py)
我们只需在__init__中将Game实例的score属性添加为新的属性,并将其设置为 0 以开始。
在evaluate_answer方法中,如果 LLM 确定答案正确,我们将这个分数加 1。
游戏结束
我们的应用程序还没有结束游戏的任何概念,所以让我们定义这个。同样,我们将保持逻辑简单:我们将询问一个预定义的问题数量,当所有这些问题都被问过并回答后,游戏结束。
这涉及到再次修改Game类:
...
class Game:
def __init__(self, llm_api_key):
...
self.score = 0
self.num_questions_completed = 0
self.max_questions = 1
...
def evaluate_answer(self):
self.curr_eval = self.ask_llm_to_evaluate_answer()
self.num_questions_completed += 1
if self.curr_eval.is_correct:
self.score += 1
self.status = 'STATE_RESULT'
def is_over(self):
return self.num_questions_completed >= self.max_questions
(GitHub 仓库中的chapter_09/in_progress_04/game.py)
再次强调,这应该很容易理解。我们在__init__中向实例添加了两个额外的属性:num_question_completed和max_questions(目前设置为 1,因为我们实际上还不支持多个问题——这将在下一节中介绍)。
在evaluate_answer中,我们将num_questions_completed加 1,并添加一个名为is_over的新方法,如果完成的问题数量与self.max_questions匹配或超过,则返回True。
新游戏按钮
目前,Fact Frenzy 在页面加载后立即直接向 LLM 请求问题。一个“新游戏”按钮将允许用户在参与 LLM 之前触发游戏的开始或执行我们可能希望添加的任何其他操作。
这将主要影响我们的前端代码,所以让我们更新main.py:
import streamlit as st
from game import Game
def start_new_game():
st.session_state.game = Game(st.secrets['llm_api_key'])
st.rerun()
def new_game_button(game):
if game and not game.is_over():
button_text, button_type = "Restart game", "secondary"
else:
button_text, button_type = "Start new game", "primary"
if st.button(button_text, use_container_width=True, type=button_type):
start_new_game()
game = st.session_state.game if 'game' in st.session_state else None
side_col, main_col = st.columns([2, 3])
with side_col:
st.header("⚡ Fact Frenzy", divider='gray')
new_game_button(game)
with main_col:
if game:
st.header("Question", divider='gray')
if game.status == 'GET_QUESTION':
...
...
elif game.status == 'STATE_RESULT':
if game.curr_eval.is_correct:
st.success("That's correct!")
else:
st.error("Sorry, that's incorrect.")
st.info(f"The correct answer was: {game.curr_eval.correct_answer}")
if game.is_over():
with st.container(border=True):
st.markdown(f"Game over! Your final score is: **{game.score}**")
(GitHub 仓库中的chapter_09/in_progress_04/main.py)
这里有几个变化需要强调。首先,有两个新的函数:start_new_game和new_game_button,我们将在下一部分中探讨。
由于现在游戏可能还没有开始——在点击“新游戏”按钮之前,如果它还没有被添加到st.session_state中,我们允许游戏为None:
game = st.session_state.game if 'game' in st.session_state else None
我们还改变了应用程序的布局,使其有两列:一个侧边栏(side_col)和一个主要列(main_col):
side_col, main_col = st.columns([2, 3])
这个侧边栏本可以简单地是一个st.sidebar,但在后面的章节中,我们会发现我们需要这个列比默认的st.sidebar提供更高的宽度。
无论如何,side_col 包含一个介绍疯狂事实的标题,以及一个对 new_game_button 的调用:
with side_col:
st.header("⚡ Fact Frenzy", divider='gray')
new_game_button(game)
在第八章,我们使用了 Material 库来显示图标。在这里,我们使用不同的方法为疯狂事实添加了一个闪电图标:通过将表情符号粘贴到我们的代码中。我们能够这样做是因为表情符号是 Unicode 标准的一部分,该标准定义了在不同系统和平台间表示文本和符号的一致方式。每次你想添加表情符号时,你都可以在像 emojipedia.org 这样的网站上搜索它并复制它。
new_game_button 函数定义如下:
def new_game_button(game):
if game and not game.is_over():
button_text, button_type = "Restart game", "secondary"
else:
button_text, button_type = "Start new game", "primary"
if st.button(button_text, use_container_width=True, type=button_type):
start_new_game()
从本质上讲,我们正在检查游戏是否已经在进行中——if game and not game.is_over() 确定变量 game 不为 None,并且其 is_over 方法返回 False——并根据结果显示不同的按钮。
我们改变了按钮的两个特性——其文本和其类型。如果游戏正在进行中,文本显示为“重新开始游戏”,如果没有进行,则显示为“开始新游戏”。
那按钮的 type 参数呢?我们可能在之前的章节中给它赋过值,但现在让我们更彻底地检查它。type 可以取的三个值是 primary、secondary 和 tertiary——每个值都表示按钮应该有多突出。类型为 primary 的按钮具有实色(通常是橙色)和白色文本,secondary 按钮如果没有指定类型则是白色和实色文本,而 tertiary 按钮则更为微妙,看起来像普通文本而没有边框。
在 UI 设计中,引导用户在任意时刻采取“正确”或最可能想要采取的操作是一个好的实践——这会使设计更加直观。如果游戏尚未开始,最合理的选项是点击“开始新游戏”按钮,因此我们将其类型设置为 primary。如果游戏正在进行,默认操作应该是回答问题,而不是重新开始游戏。因此,虽然我们提供了这种可能性,但我们并没有过分强调它。
然而,按钮中的这些差异主要是外观上的。在两种情况下,点击都会发出对 start_new_game 的调用,该调用具有以下代码:
def start_new_game():
st.session_state.game = Game(st.secrets['llm_api_key'])
st.rerun()
如前所述,我们创建了一个 Game 实例并将其分配给 st.session_state.game。由于 game 在 st.session_state 中的存在会改变屏幕上应该显示的内容,我们也发出了一个 st.rerun()。
通过将此逻辑封装在函数中,我们防止它默认执行,而是要求实际点击新游戏按钮。
游戏的主要列——main_col——当然是内容应该显示的地方。在本轮 main.py 的迭代中,我们只是将之前拥有的小部件移动到了 main_col。尽管如此,还有一些值得强调的添加。
如果game是None——这意味着还没有开始游戏——我们希望主列完全为空,以便玩家的注意力集中在side_col上。这也解释了为什么在main_col下的代码以if game开始的原因:
with main_col:
if game:
st.header("Question", divider='gray')
if game.status == 'GET_QUESTION':
...
我们还添加了一个标题,上面只写着“问题。”稍后我们会更新它以显示问题编号。
最后,我们在STATE_RESULT状态下添加了一些逻辑来处理游戏结束的情况:
elif game.status == 'STATE_RESULT':
...
if game.is_over():
with st.container(border=True):
st.markdown(f"Game over! Your final score is: **{game.score}**")
这应该相当清晰。我们使用之前定义的is_over方法来检查游戏是否结束,如果游戏结束,则显示相应的消息和最终得分(game.score)。
这就结束了我们代码的另一个迭代。请重新运行您的应用程序以获取图 9.9。

图 9.9 记分、新游戏按钮和游戏结束(完整代码请见 GitHub 仓库中的 chapter_09/in_progress_04)
太棒了!Fact Frenzy 开始看起来相当漂亮。下一个任务是添加对多个问题的支持!
9.6 包含多个问题
在上一节中,我们向我们的应用程序引入了关键的游戏机制——添加得分系统和定义游戏的开始和结束,使其更像一个真正的游戏。
尽管如此,Fact Frenzy 仍然只问一个问题,所以它现在还不是很多。是时候改变这一点了。但在我们这样做之前,让我们探索我们将面临的一个与 LLM 相关的挑战。
9.6.1 响应变异性,或缺乏变异性
在许多方面,一个大型语言模型(LLM)就像一个黑盒。与倾向于确定性(即相同的输入总是产生相同的输出)的“常规”代码不同,LLMs 是概率性的,这意味着根据一组概率,你可能对相同的输入(或类似的输入)得到不同的响应。
根据你试图实现的目标,这种变异性可能是一件好事或坏事。例如,如果你试图让 LLM 生成诗歌,你可能希望响应中有相当高的创造性和变异性,而如果你在评估一个数学方程式,你希望更少。
类似于 OpenAI 这样的供应商通常只公开一些用于这种变异性的控制,这使得管理起来更容易,但通常我们需要设计提示来从模型中提取我们想要的行为。
对于我们生成问题的用例,我们希望有相对较高的变异性。如果你已经玩了一段时间我们的当前应用程序,你可能已经注意到我们从 LLM 得到的问题经常重复。例如,在我的测试中,该模型特别偏爱询问太阳系中唯一一个侧向旋转的行星。
这对我们来说不起作用。首先,如果一个游戏包含多个问题,所有这些问题必须是唯一的。其次,即使同一个问题在同一个游戏中没有重复,我们也不想它在不同的游戏中重复得太频繁。
让我们看看我们可以控制 LLM 答案变异性的一些方法。
注意
LLMs 基于概率模式(而不是事实理解)生成文本的事实的一个后果是我们所说的“幻觉”——即模型产生看似合理但实际上错误或完全虚构的输出。这些幻觉的产生是因为 LLMs 依赖于其训练数据中的统计关系,这有时会导致自信但误导性的回应。存在一些策略可以减少幻觉的可能性,例如使 LLMs 能够连接到外部信息源,但无法保证它们绝对不会发生。处理幻觉超出了本章的范围——我们将在下一章中解决使用我们自己的来源补充 LLM 知识库的问题。只需注意,我们的应用程序可能会偶尔产生一个非事实性的问题或答案。幸运的是,根据我的测试,这些情况通常很少发生。
变化温度和 top_p
我们发送给大型语言模型的提示和从它那里得到的响应都由标记组成。一个标记可能是一个单词,也可能是一个单词的一部分。
在本质上,LLM 按步骤或更确切地说按标记逐步构建对提示的响应。在每一步中,它考虑将包括在其响应中的下一个标记的广泛可能性,并为每个标记选项分配一个概率(从高中数学,一个介于 0 和 1 之间的数字)。
这些标记形成了一个所谓的概率分布——将其想象为一个曲线,表示每个标记成为下一个标记的可能性,其中更可能的标记在曲线上的位置高于不太可能的标记。
OpenAI 提供了两个参数——temperature和top_p——可以调整此曲线的组成。图 9.10 说明了这些参数变化的影响

图 9.10 温度和 top_p 如何影响 LLM 输出的创造性和可预测性
temperature可以取 0 到 2 之间的值,较高的值会使曲线更平坦,而较低的值会使曲线更明显。因此,较高的温度倾向于“平衡”曲线,增加选择不太可能的标记选项的概率,这使得 LLM 承担更多的“风险”,并增加了其响应的整体创造性。
top_p可以从 0 到 1。它代表了模型将选择的标记的累积概率的截止值。以一个例子来说明,LLM 可能确定其响应中五个最有可能的下一个标记及其相应的概率是:“但是”:0.6,“然后”:0.2,“一个”:0.1,“这个”:0.06,和“那个”:0.02——所有其他标记的概率都低得多。
top_p为 0.8 意味着模型应该只选择具有至少 0.8 组合概率的最可能标记。在这种情况下,由于“but”和“then”一起覆盖了 0.6 + 0.2 >= 0.8 的概率,模型将丢弃其他所有内容。
另一方面,top_p为 0.95 将要求模型也考虑“a”和“this”以覆盖所需的累积概率(0.6 + 0.2 + 0.1 + 0.06 >= 0.95)。
因此,更高的top_p意味着 LLM 将考虑更多的标记选项,这可能会降低响应的可预测性和连贯性,但会增加其多样性。
回到我们最初的目标,即生成各种问题,我们可能需要一个适度的temperature——比如说 0.7 到 0.8,以及相对较高的top_p,比如说 0.9。
在提示词中包含先前的问题
正如之前讨论的,虽然理想情况下问题不应在不同游戏中重复,但我们实际上必须保证在单个游戏中不会重复提出相同的问题。
幸运的是,这很容易实现——通过明确告诉 LLM 游戏中已经提出的问题,这样它就知道要避开这些问题。
为了加强这一点,我们甚至可以告诉 LLM 确保永远不要提出相同的问题两次。
注入随机性
获取更广泛的问题种类的一种方法是在提示词中注入一些结构化的随机性。你可能听说过一个叫做“Mad Libs”的单词游戏,玩家被提供了一个故事,其中各种词性被空白代替。然后每个玩家用他们选择的单词填充一个空白,完成的故事常常非常有趣。
我们在这里也可以做类似的事情。我们可以将提示词改为类似于“在类别 ______ 和该类别内的主题 ______ 中生成一个独特的趣味问题。问题应参考一个名字以字母 ______ 开头的人或事物”。
在我们的代码中,我们可以维护类别、主题和字母的列表,随机从每个列表中选取一个来填充空白,在将提示发送给 LLM 之前。如果我们有 10 个类别,每个类别中有 10 个主题,那么我们就会有 26(字母表中的字母)x 10 x 10 = 2600 种独特的组合,再加上 LLM 本身提供的可变性。
或者为了省去维护这些列表的麻烦,为什么不先让 LLM 选择一个类别和主题呢?有趣的是,这样做似乎增加了生成的响应的多样性。
另一种注入随机性的方法是在提示词中明确提供一个随机种子。在编程中,随机种子是一个值(通常是一个整数),用于初始化随机数生成器。虽然不清楚将一个添加到提示词的文本中实际上是否会导致 LLM 生成随机数,但在我的测试中,这样做确实增加了响应的变异性。
注意
这里需要注意的是,修改你的提示以获得你想要的结果并不是纯粹的科学;通常你需要尝试各种技术和提示来识别最佳的方法。你也可能会看到令人惊讶的结果——例如,AI 研究人员发现,让 LLM 逐步思考解决问题的方法往往可以提高其完成任务的表现。
9.6.2 实现多个问题
现在我们已经回顾了在 LLMs 中变异性是如何工作的以及确保每次都能得到不同问题的可能方法,让我们修改“事实狂热”游戏,使其在游戏中向用户提出多个问题。
修改 LLM 提示
让我们先对我们的提示进行必要的更改,以便将我们所学的内容付诸实践。
在我们这样做之前——我们的Llm类目前还没有提供更改temperature和top_p的方法,因此我们应该修改其代码(在llm.py中)如下:
from openai import OpenAI
class Llm:
...
def ask(self, user_message, sys_message=None, schema=None,
temperature=None, top_p=None):
messages = self.construct_messages(user_message, sys_message)
llm_args = {'model': 'gpt-4o-mini', 'messages': messages}
if temperature:
llm_args['temperature'] = temperature
if top_p:
llm_args['top_p'] = top_p
if schema:
completion = self.client.beta.chat.completions.parse(
response_format=schema,
**llm_args
)
return completion.choices[0].message.parsed
else:
completion = self.client.chat.completions.create(**llm_args)
return completion.choices[0].message.content
(GitHub 仓库中的chapter_09/in_progress_05/llm.py)
如上图所示,我们对Llm类中的ask方法进行了相当大的重构。首先,它接受temperature和top_p作为新的参数,两者默认为None。
而不是重复将model、messages、temperature和top_p参数传递给 OpenAI 客户端的beta.chat.completions.parse或chat.completions.create,我们构建一个llm_args字典,根据每个参数是否提供来保存正确的参数及其值。
然后,我们使用**字典解包操作符(我们在第七章中遇到过的)将参数传递给 OpenAI 方法。请注意,我们可以将此与传递参数的正常方式结合起来:
completion = self.client.beta.chat.completions.parse(
response_format=schema,
**llm_args
)
在这里,我们以常规方式传递reponse_format,但对于剩余的参数,我们解包llm_args。
接下来,在prompts.py中,编辑我们的问题生成提示,使其现在读取:
QUESTION_PROMPT = {
'system': 'You are a quizmaster who never asks the same question twice.',
'user': '''
First think of a unique category for a trivia question.
Then think of a topic within that category.
Finally, ask a unique trivia question, generated using the random seed
{seed}, without revealing the category or topic.
Do not provide choices, or reveal the answer.
The following questions have already been asked:
{already_asked}
'''
}
ANSWER_PROMPT = {
...
(GitHub 仓库中的chapter_09/in_progress_05/prompts.py)
你会看到我们已经结合了上一节中讨论的几种技术:
-
系统提示要求 LLM 表现得像一个从未重复提问的问答大师。
-
我们要求 LLM 想一个独特的类别及其内的一个主题。
-
我们添加了一个“种子”变量,并要求 LLM 使用该种子生成问题。
-
在提示的最后,为了参考,我们提供了已经提出的问题,这样 LLM 就可以避免这些问题。
我们需要在Game类中伴随这些更改进行额外的更改。除了 LLM 相关的内容外,为了在游戏中提出多个问题,我们需要能够多次重复从第一个游戏状态到最后一个状态的运动。实际上,我们的状态图现在变成了一个循环,而不是一条线,如图 9.11 所示。

图 9.11 为了实现多个问题,我们现在循环遍历游戏状态
这意味着我们还需要另一个方法来从STATE_RESULT状态回到GET_QUESTION状态。现在让我们添加这个方法以及game.py中的其他更改:
import time
from llm import Llm
...
class Game:
def __init__(self, llm_api_key):
...
self.max_questions = 5
self.questions = []
def ask_llm_for_question(self):
seed = int(time.time())
sys_msg = QUESTION_PROMPT['system']
usr_msg = (
QUESTION_PROMPT['user']
.replace('{already_asked}', '\n'.join(self.questions))
.replace('{seed}', str(seed))
)
return self.llm.ask(usr_msg, sys_msg, temperature=0.7, top_p=0.9)
...
def obtain_question(self):
self.curr_question = self.ask_llm_for_question()
self.questions.append(self.curr_question)
self.status = 'ASK_QUESTION'
return self.curr_question
...
def proceed_to_next_question(self):
self.status = 'GET_QUESTION'
...
(GitHub 仓库中的chapter_09/in_progress_05/game.py)
你会看到我们在__init__中添加了一个新的属性self.questions,并将其初始化为空列表。正如你可能猜到的,这将保存我们从 LLM 获取的所有问题。我们通过在obtain_questions方法中的这个添加来实现:
self.questions.append(self.curr_question)
此外,由于我们最终将有多于一个问题要问,我们将self.max_questions的值更改为 5。你可以随意将其更改为你喜欢的任何数字。
我们完全重写了ask_llm_for_question方法,因为我们的用户消息现在有几个变量我们需要提供值。{already_asked}可以简单地替换为self.questions(列表项之间用换行符分隔)。
对于随机种子,我们简单地使用当前时间戳转换为整数:
seed = int(time.time())
由于时间戳总是按定义增加,所以当前时间戳保证是 LLM 之前从未从我们这里得到过的。
我们现在还向self.llm.ask传递temperature和top_p值,以符合我们对这些参数的探索。
为了启用多个问题,新添加的proceed_to_next_question将游戏状态重置为GET_QUESTION,完成图 9.11 中的状态循环。
对前端所需的更改相对简单。编辑main.py如下:
import streamlit as st
from game import Game
...
with main_col:
if game:
st.header(
f"Question {len(game.questions)} / {game.max_questions}",
divider='gray'
)
st.subheader(f"Score: {game.score}")
if game.status == 'GET_QUESTION':
...
...
elif game.status == 'STATE_RESULT':
...
if game.is_over():
with st.container(border=True):
st.markdown(f"Game over! Your final score is: **{game.score}**")
else:
st.button(
"Next question",
type='primary',
on_click=lambda: game.proceed_to_next_question()
)
(GitHub 仓库中的chapter_09/in_progress_05/main.py)
首先,我们修改了主列的标题,以提供问题编号(len(game.questions))和总问题数(game.max_questions):
st.header(
f"Question {len(game.questions)} / {game.max_questions}",
divider='gray'
)
我们还添加了一个子标题来显示分数:
st.subheader(f"Score: {game.score}")
为了便于从STATE_RESULT状态切换到GET_QUESTION状态,我们添加了一个else子句,如果游戏没有结束,则会显示一个“下一个问题”按钮,点击该按钮会触发game对象的proceed_to_next_question()方法。
注意我们编写st.button小部件的不寻常方式:
st.button(
"Next question",
type='primary',
on_click=lambda: game.proceed_to_next_question()
)
st.button的on_click参数允许你指定在按钮被点击时执行的功能。我们也可以像我们在本书中迄今为止所做的那样编写这个,即:
if st.button("Next question", type='primary'):
game.proceed_to_next_question()
st.rerun()
差异在于触发函数实际执行的时间。具体来说:
-
当我们使用
if st.button表示法时,按钮点击首先会触发页面的重新运行,导致按钮上方的内容再次重新渲染,然后在if代码执行之前——由st.button在重新运行中评估为True的事实触发。在这段代码执行后,我们可能需要手动再次触发上述的重新运行——就像我们在整本书中一直做的那样——以查看它引起的页面上的任何变化。 -
使用
on_click注记,按钮点击会导致在on_click下列出的函数(顺便提一下,被称为回调函数)在页面重新运行和按钮上方的内容重新渲染之前执行。在这种情况下,我们不需要手动调用st.rerun(),因为由按钮点击触发的重新运行已经考虑了回调函数执行后所做的更改。
那么为什么我们一直没在使用这种方法呢?嗯,对于简单的应用来说,if st.button 结构通常更容易理解。此外,回调函数还有一些限制——例如,你无法在回调函数中触发应用的重新运行。
在任何情况下,你现在应该能够重新运行你的应用来尝试一个工作中的多问题游戏(图 9.12)。

图 9.12 一个工作中的多问题知识竞赛游戏(完整代码请见 GitHub 仓库中的 chapter_09/in_progress_05)
我们的知识竞赛游戏在技术上现在已经完成了,但让我们看看在下一节中我们能否让它更加吸引人。
成本考虑
LLMs 是一个令人难以置信的通用工具,但重要的是要意识到,在你的应用中使用它们——尤其是在可能被数百名用户访问的生产环境中——并不是免费的。你最不希望的就是意外地产生一大笔费用。
成本计算方式
当使用 LLM 时,成本通常基于处理的标记数量计算。如第 9.5.1 节所述,“标记”代表模型读取(输入)或生成(输出)的文本块——一个单词或单词的一部分。
OpenAI 根据处理过的输入和输出标记的总和来收费。这意味着输入提示的大小和输出文本的大小都会影响成本。定价也因模型而异。在撰写本文时,我们在这章中使用的模型——gpt-4o-mini——处理每 100 万个标记的费用为 15 美分。
你可以使用像 platform.openai.com/tokenizer 这样的工具来计算文本中的标记数量并确定成本。
成本优化策略
在与 LLM 一起工作时,有几种方法可以优化成本。以下是一些想法:
-
保持你的输入提示简短而直接,以减少与输入标记相关的成本。
-
通过指示 LLM 保持其响应简短,或通过显式地将处理的标记数量限制在最大值(例如,通过在调用 OpenAI 端点时传递
max_tokens参数的值)来减小输出文本的大小。 -
将多个请求批量处理,以减少发送给 LLM 的总提示数。例如,在我们需要新的问题时,不是每次都提供一个之前提出的问题列表——就像我们在这里做的那样——我们只需简单地让 LLM 一次性生成我们想要的全部问题数量。
-
对于一些提示,使用能力较低但更便宜的模式。在我们的案例中,我们使用 gpt-4o-mini,它在成本和智能之间取得了相当好的平衡,但根据您的应用程序,对于更简单的任务,可能可以使用甚至更便宜的模型。熟悉 OpenAI 的定价页面。
-
通过让用户提供他们自己的 LLM API 密钥,完全避免 LLM 成本。对于我们的游戏来说,这会严重影响用户体验,因为它要求玩家在玩游戏之前创建一个 OpenAI 账户,但你保证在 LLM 相关的成本上不会花一分钱。
9.7 添加 AI 个性
作为一款信息类知识问答游戏,事实狂热现在运行得非常顺畅。从开始新游戏到循环提问并计分,直到游戏结束的端到端流程已经建立。
然而,它仍然缺少某种“我不知道是什么”的东西——它相当枯燥且机械。如果我们能给我们的游戏赋予个性会怎么样?幸运的是,这正是 LLMs 擅长的。例如,我们可以要求 GPT-4o 在提问时模仿各种角色的风格。
事实上,我们可以让玩家选择他们希望他们的问答大师扮演的角色。听起来很有趣?让我们开始吧!
9.7.1 添加游戏设置
目前我们还没有一个页面或位置在应用程序中让玩家查看或更改任何设置,所以我们将首先解决这个问题。
我们希望用户能够设置哪些选项?我们已经讨论了问答大师的说话风格,所以这可以成为第一个。我们还可以让玩家选择一个适合他们的难度级别。
对于“设置编辑器”,我们可以使用几种不同的设计,但我想要利用这个机会介绍一个我们之前没有遇到过的实用 Streamlit 小部件:st.data_editor。
st.data_editor
在第六章和第七章中,我们学习了 Pandas 数据框,它使 Python 中的表格数据处理变得容易。我们发现了st.dataframe,它用于在 Streamlit 应用程序中将数据框渲染为表格以供查看。
st.data_editor 也可以显示数据框,但它还使它们可编辑,为用户提供可能在电子表格中期望的体验。
这与向我们的应用程序添加设置有什么关系呢?嗯,我们可以将我们想要的设置放在一个数据框中,并允许人们编辑数据框来修改设置。
在上面几段讨论的两个设置中,设置数据框可能看起来如下所示:
+----------------+------------+
| Quizmaster | Difficulty |
+----------------+------------+
| Alex Trebek | Medium |
+----------------+------------+
如果这个数据框存储在default_settings中,我们可能编写我们的st.data_editor小部件如下所示:
settings = st.data_editor(default_settings, num_rows='fixed', key='settings_editor')
这将显示图 9.13 中所示的控件。

图 9.13 st.data_editor 的简单输出
这里的第一个参数是我们想要编辑的数据的初始状态——在这种情况下,默认设置。
num_rows='fixed'意味着数据编辑器小部件不应允许用户添加任何新行。这很有意义,因为我们不希望在上述数据框中有多行——单个设置只能有一个值。
在用户与数据编辑器交互之前的任何一次应用程序运行中,settings将保持与default_settings相同的值。一旦用户更改了设置——例如,他们可能会将难度更改为Easy——settings将在未来的重新运行中保持编辑过的数据框,直到用户再次编辑它。
注意
key='settings_editor'参数为数据编辑器会话状态添加了一个小部件键。虽然这对应用程序正确运行不是严格必需的,但它保护我们免受 Streamlit 的一个特定怪癖的影响,即在没有显式键的情况下,如果在特定运行中由于某种原因没有渲染该小部件,它会忘记小部件的值。添加小部件键不会给我们带来任何成本,因此提供一个小部件键更安全,可以避免未预见的错误。
回到我们的示例,我们不一定希望用户必须输入测验主持人的名字或难度级别;我们更希望他们从选项列表中选择。st.data_editor通过列配置支持这一点:
st.data_editor(
default_settings,
column_config={
'Quizmaster': st.column_config.SelectboxColumn(
options=['Alex Trebek', 'Eminem', 'Gollum'],
required=True
),
'Difficulty': st.column_config.SelectboxColumn(
options=['Easy', 'Medium', 'Difficult'],
required=True
)
},
num_rows='fixed',
key='settings_editor'
)
在这里,我们对可编辑的数据进行了更细粒度的控制,通过st.column_config配置数据中的每一列。
对于我们每个列,我们使用一个SelectBoxColumn,它允许我们指定一个可供选择的选项列表,以及是否必须指定一个值(required参数,如上设置为True)。
结果如图 9.14 所示。

图 9.14 st.data_editor 显示了一个具有 SelectBoxColumn 的列
st.column_config除了SelectBoxColumn之外,还支持许多不同的列类型,例如用于布尔值的CheckboxColumn、显示日期/时间选择器的DatetimeColumn,以及用于可点击 URL 的LinkColumn。
它还支持非可编辑类型,可以与st.dataframe一起使用,包括AreaChartColumn、BarChartColumn、用于列表的ListColumn,甚至用于数字的ProgressColumn(以进度条的形式显示,而不是目标)。
创建设置编辑器
既然我们已经了解了st.data_editor的工作原理,让我们继续创建一个设置编辑器 UI。我们将把这个放在一个名为settings.py的新文件中(如列表 9.8 所示)。
列表 9.8 settings.py
import streamlit as st
QM_OPTIONS = ["Alex Trebek", "Eminem", "Gollum", "Gruk the Caveman"]
DIFFICULTY_OPTIONS = ["Easy", "Medium", "Hard"]
default_settings = {
"Quizmaster": [QM_OPTIONS[0]],
"Difficulty": [DIFFICULTY_OPTIONS[1]]
}
def settings_editor():
with st.popover("Settings", use_container_width=True):
return st.data_editor(
default_settings,
key='settings_editor',
column_config={
'Quizmaster': st.column_config.SelectboxColumn(
options=QM_OPTIONS, required=True),
'Difficulty': st.column_config.SelectboxColumn(
options=DIFFICULTY_OPTIONS, required=True)
},
num_rows='fixed',
use_container_width=True,
)
(GitHub 仓库中的chapter_09/in_progress_06/settings.py)
我们将测验主持人和难度设置的选项直接放置在顶部,以便于访问,并在QM_OPTIONS和DIFFICULTY_OPTIONS下列出它们。
测验主持人选项从实际的测验主持人、已故的Jeopardy!名人亚历克斯·特雷贝克,到一系列虚构角色,如《指环王》中的咕噜,以及穴居人 Gruk,一个完全虚构的人物,让 LLM 尽情发挥。
我们这样初始化了default_settings:
default_settings = {
"Quizmaster": [QM_OPTIONS[0]],
"Difficulty": [DIFFICULTY_OPTIONS[1]]
}
注意,这不是我们最初建议的数据框——它是一个字典,每个设置的名称作为键,包含该设置默认选项的单元素列表作为相应的值。
有趣的是,st.data_editor可以显示不是 Pandas 数据框的东西。这包括原生 Python 类型,如字典、列表和集合。能够显示这些类型的能力甚至适用于st.dataframe,尽管其名称。在这种情况下,这意味着我们实际上不必将设置作为数据框来维护;我们可以使用上面更易读的字典形式。
settings_editor函数渲染实际的设置编辑器用户界面。我们将一切放置在另一个新的 Streamlit 小部件st.popover中:
with st.popover("Settings", use_container_width=True):
...
st.popover显示一个弹出小部件,这是一个可以通过点击相关按钮触发的弹出屏幕——类似于st.expander。第一个参数是触发st.popover的按钮的标签。
弹出窗口的内容是在with st.popover(...)上下文管理器中编写的。在这种情况下,我们显示st.data_editor小部件并返回其值,即编辑后的settings字典:
return st.data_editor(
default_settings,
key='settings_editor',
column_config={
'Quizmaster': st.column_config.SelectboxColumn(
options=QM_OPTIONS, required=True),
'Difficulty': st.column_config.SelectboxColumn(
options=DIFFICULTY_OPTIONS, required=True)
},
num_rows='fixed',
use_container_width=True,
)
这基本上是我们之前在讨论st.data_editor时编写的相同代码,尽管增加了一个use_container_width=True参数,该参数调整了弹出窗口的宽度。
应用设置
我们如何在 Fact Frenzy 中使用这些设置?问答大师和难度设置都与 LLM 生成的文本问题相关,所以让我们在prompts.py中的问题提示中包含它们,变成:
QUESTION_PROMPT = {
'system': '''
You are a quizmaster who mimics the speaking style of {quizmaster} and
never asks the same question twice.
''',
'user': '''
First think of a unique category for a trivia question.
Then think of a topic within that category.
Finally, ask a unique trivia question that has a difficulty rating of
{difficulty} and is generated using the random seed {seed}, without
revealing the category or topic.
Do not provide choices, or reveal the answer.
The following questions have already been asked:
{already_asked}
'''
}
...
(GitHub 仓库中的chapter_09/in_progress_06/prompts.py)
显然,有很多方法可以将我们这两个新变量融入到提示中——上面只是其中一种。
我们接下来将修改game.py:
...
class Game:
def __init__(self, llm_api_key, settings):
self.llm = Llm(llm_api_key)
self.settings = settings
self.status = 'GET_QUESTION'
...
def get_setting(self, setting_name):
return self.settings[setting_name][0]
def modify_settings(self, new_settings):
self.settings = new_settings
def ask_llm_for_question(self):
seed = int(time.time())
sys_msg = (
QUESTION_PROMPT['system']
.replace('{quizmaster}', self.get_setting('Quizmaster'))
)
usr_msg = (
QUESTION_PROMPT['user']
.replace('{already_asked}', '\n'.join(self.questions))
.replace('{seed}', str(seed))
.replace('{difficulty}', self.get_setting('Difficulty'))
)
return self.llm.ask(usr_msg, sys_msg)
def ask_llm_to_evaluate_answer(self):
...
...
(GitHub 仓库中的chapter_09/in_progress_06/game.py)
Game的__init__现在接受一个settings参数,正如你所期望的,它是我们在settings.py中使用的字典格式。这被分配给self.settings,以便其他方法可以访问它。
我们添加了两个相关方法:get_setting和modify_settings。get_setting处理获取给定设置的值,这有点棘手,因为每个字典值都是一个单元素列表(这样设计是为了与st.data_editor兼容)。get_setting抽象出这种有些不美观的逻辑,所以我们将其限制在代码的一个地方。
modify_settings用给定的new_settings字典替换self.settings。当用户更改设置时,这将会发挥作用。
转到ask_llm_for_question方法,我们将添加到提示中的{quizmaster}和{difficulty}变量替换为通过get_setting获取的设置中的相应值。
现在只剩下对main.py的修改,让我们进行这些修改:
import streamlit as st
from game import Game
from settings import default_settings, settings_editor
def start_new_game():
st.session_state.game = Game(st.secrets['llm_api_key'], default_settings)
st.rerun()
...
with side_col:
st.header("⚡ Fact Frenzy", divider='gray')
settings = settings_editor()
new_game_button(game)
with main_col:
if game:
game.modify_settings(settings)
st.header(
...
...
(GitHub 仓库中的chapter_09/in_progress_06/main.py)
在start_new_game中,为了获取初始的Game实例,我们现在直接从settings.py导入default_settings。
实际设置编辑器显示在侧列(side_col)中,位于新游戏按钮上方。返回值——回想一下,在用户更改任何设置的值之前,这将是一个default_settings字典,之后是修改后的字典——存储在settings变量中。
最后,在每次重新运行时——前提是我们处于游戏状态——我们运行game.modify_settings(settings)来获取用户对设置所做的任何更改。
这样就完成了。再次运行你的应用以查看图 9.15。尝试调整 AI 问答大师选项和难度;现在阅读问题更有趣了!

图 9.15 Fact Frenzy 的最终版本,带有可编辑的设置(完整代码请参阅 GitHub 仓库中的 chapter_09/in_progress_06)
这就结束了我们对 Fact Frenzy 的开发,这是我们将在本书中构建的第一个——也是唯一一个——游戏。在第十章中,我们将继续探索 AI 应用,使用一个更实用的应用:客户支持聊天机器人。
9.8 摘要
-
大型语言模型(LLM)是一种设计用于处理和生成类似人类文本的 AI 系统。
-
LLM 可以执行创造性和分析性任务。
-
OpenAI,最受欢迎的 LLM 提供商之一,允许开发者通过 API 访问其 GPT 系列。
-
openai库提供了一种方便的方法,在 Python 中调用 OpenAI API。 -
你可以将对话传递给 OpenAI API 的聊天完成端点,消息标记为
"system"、"assistant"或"user",这将导致模型以逻辑方式完成对话。 -
结构化输出是 OpenAI 提供的一项功能,确保模型将生成符合给定模式的响应。
-
线性 Streamlit 应用中的常见模式是基于存储在
st.session_state中的变量实现基于条件的分支逻辑。 -
你可以调整
temperature和top_p等参数来影响由 LLM 生成的响应的创造性和可预测性。 -
在提示中注入随机性是确保我们得到不同响应的相似提示的好方法。
-
在基于 LLM 的应用中优化成本很重要。你可以通过让 LLM 处理更少的输入和输出标记、减少提示数量、使用更便宜的模型,甚至通过要求用户提供自己的 API 密钥来让用户承担成本来实现。
-
st.data_editor提供了一种在 Streamlit 应用中创建可编辑表格的方法。 -
st.column_config允许你在st.data_editor和st.dataframe中配置列,使其成为特定可编辑和非可编辑类型。 -
st.popover通过点击相关按钮显示一个小弹出屏幕。
第十章:10 使用 LangGraph 和 Streamlit 的客户支持聊天机器人
本章涵盖
-
使用 Streamlit 的聊天元素开发聊天机器人前端
-
使用 LangGraph 和 LangChain 简化高级 AI 应用
-
嵌入和向量数据库是如何工作的
-
使用检索增强生成(RAG)增强 LLM 的预训练知识
-
使 LLM 能够访问和执行现实世界的操作
创建一个有趣且引人入胜的体验——就像我们在第九章中构建的问答游戏一样——令人兴奋,但 AI 的真正力量在于其推动真实商业价值的能力。AI 不仅仅是回答问题或生成文本;它是关于改变行业,简化运营,并使全新的商业模式成为可能。
然而,构建能够带来经济价值的 AI 应用,仅仅调用预训练模型是不够的。为了使 AI 在现实场景中发挥作用,它需要了解其操作的环境,连接到外部数据源,并采取有意义的行动。公司需要 AI 理解并响应特定领域的查询,与业务系统交互,并提供个性化帮助。
在本章中,我们将构建这样一个应用:一个客户服务聊天机器人,它将检索真实公司的数据,帮助客户跟踪和取消订单,并智能地决定何时将问题升级给人工客服。到本章结束时,你将了解如何将 LLM 与私有知识库集成,实现检索增强生成(RAG),并使 AI 代理能够在现实世界中采取行动。让我们深入探讨。
注意
本书 GitHub 仓库的链接是github.com/aneevdavis/streamlit-in-action。chapter_10 文件夹包含本章的代码和一个 requirements.txt 文件,其中包含所有必需 Python 库的确切版本。
10.1 Nibby:一个客户服务机器人
在 Note n' Nib 的新任 CEO 的领导下——以其传奇般的决策能力而闻名,得益于公司内备受推崇的某个仪表板——品牌已经发展成为一家销售激增的文具巨头。
但成功也带来了自己的挑战。客户支持部门被急于收到订单或寻求钢笔维护建议的买家电话淹没。在一个月的长时间等待投诉之后,CEO 召集了公司公认的可靠创新者。
因此,你被委以解决支持危机的重任。当你不在进行 Streamlit 研讨会时,你正在研究 AI 的最新进展;不久,一个引人入胜的可能性击中了你:自动化客户支持是否可能?
在一个不眠之夜中,你草拟了 Streamlit 支持机器人 Nibby 的计划。你项目的消息在公司中传播。“我们得救了!”有些人宣称。“Nibby 不会让我们失望!”怀疑者嘲笑:“这是愚蠢!没有机器人能解决这个问题。”
谁将证明是正确的?让我们拭目以待。
10.1.1 陈述概念和需求
与往常一样,我们从一个简短的描述开始,说明我们打算构建什么。
概念
Nibby,一个客户支持聊天机器人,可以帮助 Note n' Nib 的客户处理信息和基本服务请求
“客户支持”显然覆盖了很大范围,因此让我们更清楚地定义具体要求。
需求
在我们自动化的客户支持愿景中,Nibby 将能够:
-
与客户进行类似人类的对话
-
根据自定义知识库回答关于 Note n' Nib 及其产品的相关问题
-
处理客户提出的以下请求:
-
跟踪订单
-
取消订单
-
-
如果无法自行满足请求,则将请求重定向到人工客户支持代理
从本质上讲,我们希望 Nibby 尽可能多地减轻 Note n' Nib 过度劳累的人工支持代理的负担。Nibby 应作为“前线”代理,能够处理大多数基本请求,例如提供产品信息或取消订单,并在必要时才将请求转接到人工代理。
范围之外的内容
为了使这个项目可管理,并且足够小,可以放入本章,我们将决定不实现以下功能:
-
存储或记住与用户的先前对话
-
上述两个提供的“操作”之外的任何“操作”,即跟踪和取消订单
-
两个讨论的操作的实际工作逻辑,例如,构建订单跟踪或取消系统
从学习角度来看,我们真正希望专注于构建一个相对复杂的 AI 系统,该系统能够与用户进行对话,理解自定义知识库,并执行现实世界的操作。
我们使应用程序能够执行的具体操作并不重要。例如,我们的应用程序可以取消订单——而不是替换物品——这并不具有特别的意义。实际上,如上述第三点所暗示的,我们将实现的订单取消是虚拟的“玩具”逻辑。真正重要的是,我们的机器人应该能够根据用户与其进行的自由形式对话智能地选择运行该逻辑。
10.1.2 可视化用户体验
Nibby 的用户界面可能是本书中所有应用程序中最直接的。图 10.1 显示了我们将构建的内容草图。

图 10.1 Nibby,我们的客户支持聊天机器人的 UI 草图。
Nibby 的用户界面与您可能使用过的任何聊天或即时通讯应用没有显著区别——从手机上的 WhatsApp 到公司笔记本电脑上的 Slack。您会注意到底部有一个熟悉的文本框,供用户输入消息。每个用户消息都会触发一个 AI 响应,并将其附加到上面的对话视图中。
10.1.3 实施方案头脑风暴
构建这个应用程序的难点在于后端——特别是让机器人正确回答问题并连接到外部工具。图 10.2 显示了我们的总体设计。

图 10.2 Nibby 的整体设计
虽然第九章中的 Trivia 应用在状态管理方面有一个有趣的设计,但其“智能”方面相当简单——向 LLM 提供一个提示并让它做出回应。
另一方面,我们构想的客户支持应用在至少两个方面有更复杂的设计:
-
它需要一种方法来增强客户的查询,加入公司内部代理人员所拥有的私人知识。
-
它需要能够在现实世界中执行代码。
图 10.2 展示了我们如何实现这些功能的基本概述。当用户消息通过我们的前端传入时,我们从私有知识库中检索与消息相关的上下文——正如我们稍后将要看到的,这是一个向量数据库——并将其发送给 LLM。
我们还将把我们的机器人能够执行的操作组织成所谓的“工具”,并让 LLM 了解它们的存在、每个工具的功能以及如何调用它们。对于任何给定的输入,LLM 可以发出对工具的调用或直接对用户做出回应。
在前一种情况下,我们按照 LLM 指定的方式执行工具,并将结果发送进行进一步处理;而在后一种情况下,我们将回复附加到前端上的对话视图中,并等待用户的下一条消息。
10.1.4 安装依赖项
在本章中,我们将使用几个 Python 库。为了提前做好准备,可以通过运行以下命令一次性安装它们:
pip install langchain-community langchain-core langchain-openai langchain-pinecone langgraph pinecone toml
10.2 创建基本聊天机器人
第九章介绍了 LLM,并演示了如何使用 OpenAI API 进行简单应用。虽然 OpenAI 的 API 易于集成,但开发更复杂的 AI 驱动应用——例如利用检索增强生成(RAG)或基于代理的工作流程,我们很快就会遇到——增加了复杂性。
一个新的生态系统已经出现,提供了库和工具,使得创建复杂的 AI 应用尽可能容易。在本章中,我们将探讨 LangGraph 和 LangChain 这两个库,它们协同工作以简化应用创建过程。
10.2.1 LangGraph 和 LangChain 简介
LLMs 无疑是过去十年中最有影响力的技术进步。其核心在于与 LLM 的交互,包括提供 LLM 可以“完成”的提示。所有其他功能都是围绕这一点构建的。
与现代 AI 应用必须处理的复杂性相比:
-
处理多步骤工作流程(例如,在回复之前检索信息)
-
与外部工具集成
-
在多个回合中保留对话上下文
手动管理这种复杂性是困难的,这就是 LangChain 和 LangGraph——都是 Python 库——发挥作用的地方。LangChain 提供了与 LLMs 一起工作的构建块,包括提示管理、记忆和工具集成。LangGraph——由同一家公司开发——通过将 AI 工作流程结构化为图来扩展 LangChain,允许决策、分支逻辑和多步处理。通过结合这些,我们可以设计结构化、智能的 AI 应用,这些应用超越了简单的聊天响应——使 Nibby 能够检索知识、调用 API 并动态做出决策。
在本章的其余部分,我们将广泛使用这些库来实现我们想要的功能。
注意
由于我们将在 LangGraph 中将我们的聊天机器人建模为一个图,所以我们主要会讨论和引用 LangGraph 而不是 LangChain。然而,你会注意到我们将使用的许多底层类和函数都是从 LangChain 导入的。
10.2.2 图、节点、边和状态
在 LangGraph 中,你通过构建一个图来构建一个 AI 应用,这个图由节点组成,这些节点会转换应用的状态。如果你没有计算机科学背景,这个声明可能会让你感到困惑,所以让我们来分解它。
图究竟是什么?
在图论中,图是由相互连接的顶点(也称为节点)和边组成的网络,这些边将顶点相互连接。软件开发者经常使用图来创建现实世界对象及其关系的概念模型。例如,图 10.3 显示了可能在社会媒体网站上找到的人的图。

图 10.3 使用图来模拟社交网络中的朋友关系
在这里,每个人都是一个顶点或节点(用圆圈表示),任何两个人之间的“朋友”关系是一条边(圆圈之间的线条)。
通过这种方式模拟关系,社交媒体网站可以应用为图开发的算法来执行有用的现实世界任务。例如,有一个称为广度优先搜索(BFS)的算法,它可以从一个节点找到到任何其他节点的最短路径。在这种情况下,我们可以用它来找到连接两个人的最少共同朋友数量。
在 LangGraph 中,我们将应用建模为一个动作的图,其中节点表示应用执行的单个动作。图有一个状态,简单地说,是一组具有值的命名属性(类似于 Streamit 的“会话状态”概念)。每个节点将图的当前状态作为输入,对状态进行一些修改,并将新的状态作为其输出返回。

图 10.4 在 LangGraph 中,节点接收图状态并对其进行修改。
图中的边表示两个节点之间的连接,或者换句话说,一个节点的输出可能是另一个节点的输入。与社交媒体图中的边没有方向(即如果两个人是朋友,那么每个人都是对方的朋友)的情况不同,LangGraph 中的边是有向的,因为边中的一个节点在另一个节点之前执行。从视觉上看,我们用边上的箭头来表示方向(图 10.4)。
图的输入是其初始状态,该状态传递给第一个执行的节点,而输出是最后一个执行的节点返回的最终状态。
那是相当多的理论;现在让我们考虑一个玩具示例(图 10.5)来使这一切变得真实。

图 10.5 LangGraph 中计算数字平方和的图
图 10.5 中所示的应用非常简单。没有涉及 AI;它只是一个程序,它接受一个数字列表并返回它们的平方和——例如,对于输入[3, 4],图将计算输出25(3² + 4² = 25)。
图的状态包含三个值:numbers、squares和answer。numbers保存输入值列表(例如[3, 4]),而squares和answer最初没有值。
每个 LangGraph 图都有一个名为START和END的虚拟节点,它们代表执行的开始和结束。还有两个其他“真实”节点:squaring_node和summing_node。
下面是如何执行这个图的:
-
START节点接收初始状态。 -
由于从
START到squaring_node存在有向边,因此squaring_node首先执行。 -
squaring_node接收初始状态,将numbers列表中的数字平方,并将新的列表([9, 16])保存到状态中的变量squares下。 -
由于从
squaring_node到summing_node存在边,summing_node以squaring_node返回的修改后的状态作为输入。 -
summing_node将squares中的数字相加,并将结果保存为answer。 -
summing_node有一个指向END的边,这意味着执行的结束。返回的最终状态将在answer下包含25。
当然,这是一个只有一个执行路径的简单图。在本章的稍后部分,你将遇到一个有多个路径的图——在这个点上,一个节点可能根据该点的状态分支到多个节点。
希望这有助于澄清图的概念以及 LangGraph 如何使用它们来执行任务。现在是时候利用我们所学的内容开始构建我们的应用了。
10.2.3 一个单节点 LLM 图
我们在上一节中构建的基本图与 AI 或 LLM 无关。实际上,你可以使用 LangGraph 构建任何你想要的东西,无论是否涉及 AI,但实践中,LangGraph 的目的是使构建 AI 应用变得更加容易。
创建和运行你的第一个图
在第九章中,我们遇到了 OpenAI API 的聊天完成端点。在这个端点中,我们向 API 传递一个消息列表,API 预测对话中的下一个消息。在 LangGraph 中,这样的应用可以由一个简单的单节点图表示,如图 10.6 所示。

图 10.6 一个基本的单节点(除 START 和 END 外)图
图的状态由一个变量 messages 组成,正如你所预期的那样,它是一个消息列表。
图中的唯一节点 assistant_node 将 messages 传递给 LLM,并返回添加了 AI 响应消息的相同列表。
列表 10.1 显示了这个图被转换成实际的(非 Streamlit)Python 代码。
列表 10.1 graph_example.py
from langgraph.graph import START, END, StateGraph
from langchain_core.messages import AnyMessage, HumanMessage
from langchain_openai import ChatOpenAI
from typing import TypedDict
llm = ChatOpenAI(model_name="gpt-4o-mini", openai_api_key="sk-proj-...")
class MyGraphState(TypedDict):
messages: list[AnyMessage]
builder = StateGraph(MyGraphState)
def assistant_node(state):
messages = state["messages"]
ai_response_message = llm.invoke(messages)
return {"messages": messages + [ai_response_message]}
builder.add_node("assistant", assistant_node)
builder.add_edge(START, "assistant")
builder.add_edge("assistant", END)
graph = builder.compile()
input_message = input("Talk to the bot: ")
initial_state = {"messages": [HumanMessage(content=input_message)]}
final_state = graph.invoke(initial_state)
print("Bot:\n" + final_state["messages"][-1].content)
(GitHub 仓库中的 chapter_10/graph_example.py)
首先,我们在这行初始化 LLM:
llm = ChatOpenAI(model_name="gpt-4o-mini", openai_api_key="sk-proj-...")
这与我们第九章创建 OpenAI API 客户端时所做的类似。LangChain——一个与 LangGraph 密切相关的库——提供了一个名为 ChatOpenAI 的类,它基本上做的是同样的事情,但使用起来稍微简单一些。像之前一样,别忘了将 sk-proj... 替换为你的实际 OpenAI API 密钥。
考虑下一部分:
class MyGraphState(TypedDict):
messages: list[AnyMessage]
正如我们之前讨论的,一个图有一个状态。对于你定义的每个图,你通过创建一个包含那些字段的类来指定状态中存在的字段。
在上述两行中,我们正在创建 MyGraphState 来表示我们即将定义的图的状态。按照图 10.6 中的示例,MyGraphState 包含一个字段——messages——它是一个 AnyMessage 类型的对象列表。
在第九章中,我们了解到在(OpenAI)LLM 对话中,每条消息都有一个角色——其中一个是 "user"、"assistant" 或 "system"。在 LangChain 中,这个概念由 AnyMessage 超类表示。HumanMessage、AIMessage 和 SystemMessage 是继承自 AnyMessage 的子类,分别对应 "user"、"assistant" 和 "system" 角色。
MyGraphState 本身是 TypedDict 的子类,它是 Python 的 typing 模块中的一个专用字典类型,允许我们定义一个具有固定键集及其相关值类型的字典。由于它继承了 TypedDict 的所有行为,我们可以将 TypedDict 的实例——以及因此 MyGraphState——视为常规字典,使用相同的语法来访问其键(即类中的字段)和值。
注意
我们实际上并不必须使用 TypedDict 来表示图的状态。我们也可以使用常规类、dataclass 或 Pydantic 的 BaseModel,这是我们第九章中使用过的。我们在这里引入并使用 TypedDict 是因为它与 MessagesState(我们将很快讨论的内置 LangGraph 类)配合得很好。
下一个语句builder = StateGraph(MyGraphState)初始化了图的构建。在这里,我们告诉 LangGraph 我们正在构建一个StateGraph——我们一直在谈论的类型,其中节点从共享状态中读取和写入——其状态由一个MyGraphState实例表示(正如我们所见,它将有一个messages列表)。
我们现在这样定义我们图中的唯一节点:
def assistant_node(state):
messages = state["messages"]
ai_response_message = llm.invoke(messages)
return {"messages": messages + [ai_response_message]}
LangGraph 图中的每个节点都是一个常规的 Python 函数,它接受图的当前状态——一个MyGraphState实例——作为输入,并返回它想要修改的状态部分。
我们定义的assistant_node相当简单;它只是将messages列表(使用方括号作为state["messages"]访问,就像在常规字典中一样)传递给llm的invoke方法,以获取 AI 的响应消息。然后它修改状态中的"messages"键,将ai_response_message添加到末尾,并返回结果。
注意
在上述代码中,由于MyGraphState只有一个键,即messages,所以看起来assistant_node只是返回了整个修改后的状态。这并不完全正确——实际上,它只返回它想要修改的键,而其他键保持不变。这一点将在后面的章节中变得清晰。
现在我们已经创建了唯一的节点,是时候将其放入我们的图中:
builder.add_node("assistant", assistant_node)
builder.add_edge(START, "assistant")
builder.add_edge("assistant", END)
第一行向图中添加了一个名为assistant的节点,指向我们刚刚开发的assistant_node函数作为节点的逻辑。
如前所述,每个图都有一个虚拟的START和END节点。剩下的两行创建了从START到我们的assistant节点,以及从我们的assistant节点到END的有向边,从而完成了图的构建。
紧接着的行,graph = builder.compile(),编译了图,使其准备执行。
文件中的最后几行代码展示了如何调用一个图:
input_message = input("Talk to the bot: ")
initial_state = {"messages": [HumanMessage(content=input_message)]}
final_state = graph.invoke(initial_state)
print("The LLM responded with:\n" + final_state["messages"][-1].content)
我们首先使用input()函数——在终端中提示用户输入一些内容——来收集用户的输入消息。
我们然后以字典的形式构建图的起始状态,键为"messages"。消息本身是一个HumanMessage实例,其内容属性设置为刚刚收集到的input_message。
将initial_state传递给图的invoke方法最终导致图执行,有效地通过assistant_node将我们的用户输入传递给 LLM,并返回最终状态。
final_state包含到目前为止对话中的所有消息(我们的用户消息和 LLM 的响应消息),因此我们使用final_state["messages"][-1]访问响应消息并将其内容打印到屏幕上。
要看到这个功能如何工作,将所有代码复制到一个名为graph_example.py的新文件中,然后在终端中使用python命令运行你的代码,如下所示:
python graph_example.py
当你看到"Talk to the bot"提示时,输入一条消息。以下是一个示例输出:
$ python graph_example.py
Talk to the bot: Howdy! Could you write a haiku about Note n' Nib for me?
Bot:
Ink and paper dance,
Whispers of thoughts intertwine—
Note n' Nib's embrace.
看起来 AI 偷走了我的俳句梦想。也许我会转向表演艺术——每个人都喜欢一个好的默剧演员。
将我们的图转换为类
我们已经在终端运行了我们的第一个图,但我们真正想要的是用它来为我们的客户支持机器人提供动力。我们将像在前两章中做的那样,使用面向对象的原则来组织我们的代码。
让我们先从将上一节中编写的代码转换为graph.py中的SupportAgentGraph类开始,如列表 10.2 所示。
列表 10.2 graph.py
from langgraph.graph import START, END, StateGraph, MessagesState
from langchain_core.messages import HumanMessage
class SupportAgentGraph:
def __init__(self, llm):
self.llm = llm
self.graph = self.build_graph()
def get_assistant_node(self):
def assistant_node(state):
ai_response_message = self.llm.invoke(state["messages"])
return {"messages": [ai_response_message]}
return assistant_node
def build_graph(self):
builder = StateGraph(MessagesState)
builder.add_node("assistant", self.get_assistant_node())
builder.add_edge(START, "assistant")
builder.add_edge("assistant", END)
return builder.compile()
def invoke(self, human_message_text):
human_msg = HumanMessage(content=human_message_text)
state = {"messages": [human_msg]}
return self.graph.invoke(state)
(GitHub 仓库中的chapter_10/in_progress_01/graph.py)
这里的代码与graph_example.py中的代码非常相似,但我想要强调一些差异。
最明显的是,我们将我们的图封装在一个类中——SupportAgentGraph——它有一个用于构建实际图的方法(build_graph)和另一个方法(invoke),用于通过传递人类(用户)消息来调用它。
我们不是在类中创建 LLM 对象,而是在SupportAgentGraph的__init__中将其作为参数接受,通过调用self.build_graph()构建图,并将其保存为self.graph以供将来调用。
你会注意到我们之前定义的MyGraphState类,现在无处可寻。我们已经将其替换为MessagesState,这是一个内置的 LangGraph 类,它或多或少能做同样的事情。MessagesState,就像MyGraphState一样,有一个messages字段,它是一个AnyMessage对象的列表。MyGraphState和MessagesState之间最大的区别在于如何在节点中修改messages字段——关于这一点,我们稍后再谈。
接下来,考虑get_assistant_node方法:
def get_assistant_node(self):
def assistant_node(state):
ai_response_message = self.llm.invoke(state["messages"])
return {"messages": [ai_response_message]}
return assistant_node
这个方法有一个函数定义——为assistant_node,我们在上一节中遇到了它——嵌套在其中。它似乎除了返回函数之外什么也没做。这是怎么回事?
好吧,既然assistant_node需要访问 LLM 对象(self.llm),其代码必须位于SupportAgentGraph类的某个方法内部。但是assistant_node不能自身成为类的一个方法,因为传递给方法的第一参数是self——类的当前实例——而传递给有效 LangGraph 节点的第一个(也是唯一一个)参数必须是图状态。
因此,我们定义assistant_node为外层方法get_assistant_node内部的内联函数——利用外层方法的范围在内部函数中访问self.llm——并且让外层方法返回内部函数,这样我们就可以将其插入到图中。这种编程模式被称为闭包,因为内部函数保留了对其封装范围中变量的访问,即使外层函数已经返回。
上述节点的插入发生在build_graph方法的这一行:
builder.add_node("assistant", self.get_assistant_node())
由于get_assistant_node()返回assistant_node函数(而不是调用它),我们可以使用对get_assistant_node的调用来引用内部函数。
assistant_node函数与我们在上一节中定义的同名函数在一点上有所不同。考虑返回语句,它已经从:
return {"messages": messages + [ai_response_message]}
到:
return {"messages": [ai_response_message]}
为什么我们不再返回messages列表中的其他项了?答案与我们将MyGraphState替换为MessagesState有关。您可以看到,LangGraph 的StateGraph中的每个节点都接收完整的状态作为输入,但返回的值被视为状态中每个键的更新集合。这些更新如何与现有值合并取决于我们在状态类型中如何指定。
在MyGraphState中,我们没有提到任何特定的处理方式,因此与键messages关联的值简单地被节点返回的该键的值所替换。这就是为什么我们需要返回整个列表——否则我们就会丢失早期的消息。
另一方面,MessagesState内部指定节点返回的值应该追加到现有列表中。因此,ai_response_message简单地附加到现有消息上,我们不需要单独返回旧消息。
注意
MessagesState通过一个名为add_messages的函数实现了这个追加功能。实际上,MyGraphState和MessagesState之间的唯一区别在于MessagesState中的messages字段(内部)定义如下:
messages: Annotated[list[AnyMessage], add_messages]
我不会详细说明这一点,但本质上这是说,当发生更新时,应该由add_messages函数而不是简单的替换来处理。
呼吁!这有很多解释,但希望你现在已经理解了在 LangGraph 中如何建模图。
Bot 类
现在让我们暂时放下SupportAgentGraph,转向我们的主要后端类,我们将称之为Bot。Bot将是 Streamlit 前端进入后端的单一入口点,类似于前几章中的Game和Hub类。
重要的是,Bot将提供SupportAgentGraph需要的 LLM 对象,并提供一个用户友好的方法,我们的前端可以调用该方法与机器人聊天。
要创建它,将列表 10.3 中的代码复制到一个新文件中,命名为bot.py。
列表 10.3 bot.py
from langchain_openai import ChatOpenAI
from graph import SupportAgentGraph
class Bot:
def __init__(self, api_keys):
self.api_keys = api_keys
self.llm = self.get_llm()
self.graph = SupportAgentGraph(llm=self.llm)
def get_llm(self):
return ChatOpenAI(
model_name="gpt-4o-mini",
openai_api_key=self.api_keys["OPENAI_API_KEY"],
max_tokens=2000
)
def chat(self, human_message_text):
final_state = self.graph.invoke(human_message_text)
return final_state["messages"][-1].content
(位于 GitHub 仓库的chapter_10/in_progress_01/bot.py)
幸运的是,Bot类比SupportAgentGraph简单得多。__init__接受一个 API 密钥字典——提示一下,我们还将通过st.secrets再次提供——在通过调用get_llm方法设置 LLM 对象并将它传递给保存到self.graph的SupportAgentGraph实例之前。
get_llm简单地使用 LangChain 的ChatOpenAI类来创建 LLM 对象,正如之前讨论的那样。注意,我们添加了一个名为max_tokens的新参数。你可能还记得,从前一章中,token 是语言模型处理文本的基本单位。通过设置max_tokens=2000,我们告诉 OpenAI 的 API 将响应限制在最多 2000 个 token(大约 1,500 个单词),这有助于成本降低并保持响应(相对)简洁。
chat方法抽象掉了处理图和状态复杂性的问题。它有一个简单的契约——放入一个人类消息字符串,并得到一个 AI 响应字符串。它通过调用我们的SupportAgentGraph实例的invoke方法,并返回最后一条消息的内容——碰巧是 AI 消息,正如我们之前看到的。
Streamlit 中的聊天机器人前端
我们的应用程序后端现在已准备就绪,所以让我们接下来关注前端。Streamlit 在聊天机器人界面方面非常出色,因为它对其有原生支持。
这一点在事实中很明显,即我们的frontend.py的第一个迭代版本——如列表 10.4 所示——只有 12 行长。
列表 10.4 frontend.py
import streamlit as st
from bot import Bot
if "bot" not in st.session_state:
api_keys = st.secrets["api_keys"]
st.session_state.bot = Bot(api_keys)
bot = st.session_state.bot
if human_message_text := st.chat_input("Chat with me!"):
st.chat_message("human").markdown(human_message_text)
ai_message_text = bot.chat(human_message_text)
st.chat_message("ai").markdown(ai_message_text)
(GitHub 仓库中的chapter_10/in_progress_01/frontend.py)
我们首先在st.session_state中放入对Bot实例的引用——bot——这基本上是我们在前两章中用于Hub和Game类的相同模式。为此,我们传递st.secrets中的api_keys对象。我们稍后会创建secrets.toml。
最有趣的部分是最后四行。其中第一行引入了一个新的 Streamlit 小部件,称为st.chat_input:
if human_message_text := st.chat_input("Chat with me!"):
...
st.chat_input创建了一个带有“发送”图标的文本输入框,这可能与你在各种消息应用中可能习惯的类似。除了“发送”图标之外,它在几个明显的方面与st.text_input不同:
-
它被固定在屏幕的底部或者你放入其中的包含小部件里
-
与
st.text_input不同,后者在用户点击出文本框时返回一个值,st.chat_input只有在用户点击“发送”或按 Enter 键时才返回一个值。
除了st.chat_input之外,上面的代码可能还因为另一个原因看起来不熟悉;我们使用了字符序列:=,这在 Python 中被称为walrus 运算符(因为如果你把头歪向一边,它看起来有点像海象)。
Walrus 运算符只是一个使你的代码稍微更简洁的技巧。它允许你将值分配给变量作为更大表达式的部分,而不是需要单独一行进行赋值。换句话说,我们不需要讨论的行,我们可以写出以下内容来获得相同的效果:
human_message_text = st.chat_input("Chat with Nibby!")
if human_message_text:
...
注意
Python 开发者对于 walrus 运算符是否增加了或减少了代码的可读性意见不一。无论你是否选择使用它,了解它是什么都是一个好主意。
一旦我们收到用户的输入消息,我们就可以显示对话:
st.chat_message("human").markdown(human_message_text)
ai_message_text = bot.chat(human_message_text)
st.chat_message("ai").markdown(ai_message_text)
st.chat_message 是一个 Streamlit 显示小部件,它接受两个字符串之一——"human"或"ai",并相应地设置容器样式。这包括显示与用户或机器人对应的头像。
在这种情况下,我们使用 st.chat_message("human") 显示 human_message_text,调用我们的 Bot 实例的 chat 方法,并使用 st.chat_message("ai") 显示 AI 响应文本。
为了清楚起见,st.chat_message 与其他 Streamlit 元素(如 st.column)类似,我们也可以这样写:
with st.chat_message("human"):
st.markdown(human_message_text)
要完成 Nibby 的第一个版本,我们需要在新的 .streamlit 文件夹中创建一个 secrets.toml 文件来存储我们的 OpenAI API 密钥。此文件的 内容如列表 10.5 所示。
列表 10.5 .streamlit/secrets.toml
[api_keys]
OPENAI_API_KEY = 'sk-proj-...' #A
A 将 sk-proj-... 替换为您的实际 OpenAI API 密钥。
运行您的应用程序以 streamlit run frontend.py 测试它。图 10.7 展示了我们的聊天机器人正在运行。

图 10.7 Streamlit 中的单提示单响应聊天机器人(完整的代码在 GitHub 仓库的 chapter_10/in_progress_01 中)。
太棒了!注意人类和机器人的头像,以及微妙的背景阴影,以区分两种显示的消息类型。
如果你玩弄这个应用程序,你会意识到 Nibby 目前还不能进行对话,只能对单条消息做出响应。接下来,让我们修复这个问题!
10.3 多轮对话
虽然有些努力,但我们已经构建了 Nibby 的初始版本。不幸的是,目前 Nibby 对对话的理解是单条消息的单个响应。
例如,考虑图 10.8 中的交换。

图 10.8 我们的聊天机器人没有记住我们给它提供的信息。
这里有两个问题:
-
机器人没有记住我在上一条消息中给它提供的信息。
-
我们的前端将第二个消息-响应对视为一个全新的对话,删除了第一个的所有痕迹。
在本节中,我们将对 Nibby 进行迭代,解决这两个问题。
10.3.1 为我们的图添加内存
回想一下我们创建的简单单节点图:它从一个包含人类消息的状态开始,将此消息传递给 LLM,将 AI 的响应追加到状态中,然后返回更新后的状态。
如果你再次调用图并带有后续消息会发生什么?嗯,过程会重复——创建一个新的状态,只包含后续消息,并将其传递给图,图将其视为全新的独立执行。
这是一个明显的问题,因为对话很少只由一条消息和响应组成。用户会想要跟进,聊天机器人需要记住之前的内容。
为了使我们的图能够记住之前的执行,我们需要持久化状态,而不是每次都从头开始。幸运的是,LangGraph 通过检查点器的概念使这一点变得简单,它可以在每个步骤中保存图的状态。
具体来说,我们将使用检查点器来允许我们的图状态存储在内存中。然后我们可以为图的每次调用分配一个thread ID。每次我们调用图时传递相同的线程 ID,图将回忆之前为该线程 ID 存储在内存中的状态,并从那里开始,而不是从一张白纸开始。
为了实现这一点,对graph.py进行以下更改:
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, END, StateGraph, MessagesState
...
class SupportAgentGraph:
def __init__(self, llm):
self.llm = llm
self.config = {"configurable": {"thread_id": "1"}}
...
...
def build_graph(self):
memory = MemorySaver()
builder = StateGraph(MessagesState)
...
return builder.compile(checkpointer=memory)
def invoke(self, human_message_text):
...
return self.graph.invoke(state, self.config)
(位于 GitHub 仓库的chapter_10/in_progress_02/graph.py)
让我们从上面的代码开始讨论build_graph。我们在该方法的顶部添加了一行:
memory = MemorySaver()
MemorySaver是 LangGraph 内置的检查点器,可以存储图的状态。根据您希望将图状态保存的位置,还有各种其他类型的检查点器可用。例如,您可以使用不同的检查点器将对话存储在数据库中,如 PostgreSQL 或 SQLite。
当我们在方法末尾编译我们的图时,我们将这个值传递给它:
return builder.compile(checkpointer=memory)
这允许我们的图保存其状态,但这还不够。如果我们不再进行任何更改,每次图调用仍然是一个新的、独立的调用。我们需要一种方法来告诉图,特定的调用属于它之前见过的某个*thread*。
将你的注意力转向__init__方法,我们在一个名为self.config的字段中分配了一个看起来很奇怪的值:
self.config = {"configurable": {"thread_id": "1"}}
这里需要注意的重要部分是{"thread_id": "1"}。在下面的invoke方法中,我们在调用图时传递这个值:
return self.graph.invoke(state, self.config)
我们实际上是将线程 ID 1传递给图,这样它就知道每次我们调用它时,我们总是在同一个会话线程中,该线程的 ID 为1。
由于这个更改,图的第一次调用(例如,在引发这些更改的示例中"Hi, my name's Bob")将保存在线程 ID 1下。此时,状态将包含两条消息:原始的人类消息和 AI 的回复。
当后续消息("What's my name")到达时,因为我们已经有一个 ID 为1的现有线程,它将被附加到现有状态。传递给assistant_node(因此是 LLM)的状态将包含三条消息,使其能够正确响应。
您可能还有其他问题
在这个阶段可能会出现两个自然的问题:
-
为什么线程 ID 总是 1?
-
回想一下,我们的 Streamlit 应用会话不会在单个浏览器刷新后持久化。因此,每次用户通过在新标签页中打开它或刷新浏览器来访问应用时,
SupportAgentGraph实例都会重新构建,并且图会使用新的MemorySaver对象重新编译。由于MemorySaver在内存中存储图状态而不是将其持久化到外部数据存储(如 PostgreSQL),因此来自不同浏览器会话的任何线程(无论线程 ID 是多少)都是不可访问的,因此我们可以安全地使用相同的线程 ID1来处理新会话。 -
简而言之,我们设置的方式保证了单个图实例在其生命周期中最多只能看到一次对话,因此我们只需要指定一个线程 ID。
-
为什么 self.config 的值如此复杂?
-
看看我们对检查点和内存的解释,似乎我们在调用图时只需要传递值
1。那么为什么我们有这个怪物:{"configurable": {"thread_id": "1"}}? -
虽然它们超出了本书的范围,但 LangGraph 在调用图时提供了许多选项,例如指定元数据或它能够进行的并行调用数量。线程 ID 是我们在这里使用的唯一配置,但它远非唯一可用的配置。self.config 看似复杂的结构反映了这一点。
再次运行 Nibby 并输入之前相同的消息。这次你应该会看到类似于图 10.9 的内容。

图 10.9 Nibby 现在记得我们在对话中之前告诉它的信息(请参阅 GitHub 仓库中的 chapter_10/in_progress_02 以获取完整代码)。
如您所见,应用程序这次确实记得我们之前给出的信息,但我们仍然需要更新前端以显示整个对话。
10.3.2 显示对话历史
我们当前的 Streamlit 前端仅设置用来显示最新的用户输入的消息和 AI 的响应。
要显示完整的历史记录,我们首先需要在后端公开它。让我们首先在graph.py中添加一个方法来获取到目前为止的整个对话:
...
class SupportAgentGraph:
...
def get_conversation(self):
state = self.graph.get_state(self.config)
if "messages" not in state.values:
return []
return state.values["messages"]
(GitHub 仓库中的chapter_10/in_progress_03/graph.py)
SupportAgentGraph中的get_conversation方法简单地返回图中当前状态的messages列表。
要做到这一点,它首先获取对状态的引用(self.graph.get_state(self.config)),然后使用state.values["messages"]访问"messages"键。将self.config传递给get_state是必要的,以获取正确的对话线程,尽管——如前一小节中的侧边栏所讨论的——只有一个。
接下来,让我们在bot.py中公开完整的messages列表:
...
class Bot:
...
def get_history(self):
return self.graph.get_conversation()
(GitHub 仓库中的chapter_10/in_progress_03/bot.py)
get_history方法所做的只是将我们刚刚定义的get_conversation方法的输出忠实地传递给其调用者。
We can now make the changes required in frontend.py:
...
bot = st.session_state.bot
for message in bot.get_history():
st.chat_message(message.type).markdown(message.content)
if human_message_text := st.chat_input("Chat with Nibby!"):
...
(GitHub 仓库中的chapter_10/in_progress_03/frontend.py)
我们调用bot.get_history()来获取消息列表,并遍历它,在每个自己的st.chat_message容器中显示每条消息。
回想一下,messages列表中的每个消息都是HumanMessage或AIMessage的实例。无论如何,它都有一个类型字段,对于HumanMessage,其值为"human",对于AIMessage,其值为"ai"。因此,它完美地作为st.chat_message中的类型指示符参数使用。
message.content包含消息的文本,所以我们使用st.markdown来显示它。
重新运行应用程序以查看图 10.10。

图 10.10 我们的前端现在显示完整的对话历史(完整代码请参阅 GitHub 仓库中的 chapter_10/in_progress_03)
如预期,Nibby 现在显示了完整的对话,这样我们就可以跟踪正在发生的事情。
10.4 限制我们的机器人仅用于客户支持
到目前为止,我们一直专注于确保 Nibby 的基本功能正确——包括调用 LLM 和处理完整的对话。结果是你可以询问几乎任何问题的通用聊天机器人。
例如,考虑如果我们要求 Nibby 唱一首歌会发生什么(图 10.11):

图 10.11 Nibby 迎合无聊的请求,可能会给我们带来费用。
Nibby 确实可以唱歌。它还可以帮助你解决数学问题或撰写关于罗马帝国衰亡的论文。不幸的是,它所有这些都由公司承担费用。记住,与基于云的 LLM 交互是需要付费的。
每当有人对你的客户支持机器人提出无聊的请求,并且机器人用冗长的回复来迎合这个请求时,它就会消耗宝贵的 LLM 令牌,并给你带来一些费用。当然,每条消息只占几分之一的美分,但如果你把所有请求编码帮助或扮演《星际迷航》中角色的请求加起来,突然间你的老板想知道为什么季度收益报告中有一个像 Nibby 一样的洞。
当然,我在这里是在夸张,但观点是:我们希望 Nibby 严格专注于业务。
LLM 最好的地方在于你可以简单地告诉它它应该做什么或不做什么,所以这实际上是一个简单的修复;我们将简单地添加一个合适的提示。
10.4.1 创建基本提示
正如我们在第九章所做的那样,我们希望给 LLM 一些关于我们希望它服务的用例的背景信息。在那个章节中,我们通过创建一个带有角色"system"的消息来实现这一点。这里我们也将做同样的事情,尽管我们将使用的抽象略有不同。
创建一个名为prompts.py的文件,内容如列表 10.6 所示。
列表 10.6 prompts.py
BASE_SYS_MSG = """
You are a customer support agent for Note n' Nib, an online stationery
retailer. You are tasked with providing customer support to customers who
have questions or concerns about the products or services offered by the
company.
You must refuse to answer any questions or entertain any requests that
are not related to Note n' Nib or its products and services.
"""
(chapter_10/in_progress_04/prompts.py 在 GitHub 仓库中)
这个提示给 Nibby 第一个暗示,Note n' Nib 存在,并且它应该为公司提供客户支持。
重要的是,BASE_SYS_MSG还有一个指令拒绝与 Note n' Nib 无关的任何请求。接下来,让我们将这个指令整合到我们的图中。
10.4.2 在我们的图中插入一个基本上下文节点
正如我们在第九章所学到的,使用 OpenAI 的聊天完成端点涉及向 LLM 传递一系列消息。
在我们当前的图中,列表从用户的第一个指令开始,只包含用户消息和 AI 响应。为了防止 Nibby 对无聊的请求做出回应,我们只需要将我们刚刚创建的系统提示作为列表中的第一条消息插入到我们发送给 LLM 的列表中。
我们将通过在图中插入一个新节点来添加系统消息到图状态,并修改现有的assistant_node在传递任何其他信息之前将此消息传递给 LLM。
图 10.12 显示了新图的视觉表示。

图 10.12 在我们的图中添加基本上下文节点
对graph.py所需的更改在列表 10.7 中显示:
列表 10.7 graph.py(已修改)
...
from langchain_core.messages import HumanMessage, SystemMessage
from prompts import *
class AgentState(MessagesState):
sys_msg_text: str
class SupportAgentGraph:
def __init__(self, llm):
...
@staticmethod
def base_context_node(state):
return {"sys_msg_text": BASE_SYS_MSG}
def get_assistant_node(self):
def assistant_node(state):
sys_msg = SystemMessage(content=state["sys_msg_text"])
messages_to_send = [sys_msg] + state["messages"]
ai_response_message = self.llm.invoke(messages_to_send)
return {"messages": [ai_response_message]}
return assistant_node
def build_graph(self):
memory = MemorySaver()
builder = StateGraph(AgentState)
builder.add_node("base_context", self.base_context_node)
builder.add_node("assistant", self.get_assistant_node())
builder.add_edge(START, "base_context")
builder.add_edge("base_context", "assistant")
builder.add_edge("assistant", END)
return builder.compile(checkpointer=memory)
def invoke(self, human_message_text):
...
...
(GitHub 仓库中的chapter_10/in_progress_04/graph.py)
从顶部开始,我们添加了一些导入;我们需要SystemMessage类以及HumanMessage,所以这是第一个。
语句from prompts import *允许我们仅使用变量名——而不是像prompt.这样的前缀——访问我们可能添加到prompts.py中的任何提示。由于我们在这里使用的是*通配符而不是导入特定对象,prompt.py的全局作用域中的每个对象都成为graph.py作用域的一部分。在这种情况下,这意味着我们可以直接引用BASE_SYS_MSG,就像我们在代码中稍后所做的那样。
我们定义了一个新的AgentState类:
class AgentState(MessagesState):
sys_msg_text: str
AgentState从MessagesState继承,因此它也包含我们迄今为止一直在使用的messages字段。我们在这里实际上是在状态中添加一个新的字段——称为sys_msg_text——用来存储系统消息的文本。
接下来,在类内部,我们添加了一个新的静态方法:
@staticmethod
def base_context_node(state):
return {"sys_msg_text": BASE_SYS_MSG}
这个函数代表我们在图中添加的新节点,称为base_context。这个节点所做的只是填充我们添加到状态中的sys_msg_text字段。通过返回{"sys_msg_text": BASE_SYS_MSG},这个节点将sys_msg_text设置为BASE_SYS_MSG——我们几分钟前创建的上下文提示——在图当前状态中。
要理解这是如何工作的,记住一个图节点并不返回整个状态;相反,它只返回需要修改的状态中的键。因此,尽管这里没有提到messages字段,一旦这个节点被执行,状态将继续保留那个字段——未修改的——除了sys_msg_text。
注意
与messages的情况不同,当我们返回一个包含sys_msg_text键的字典时,它会替换状态中sys_msg_text的值。这是因为sys_msg_text使用默认的更新行为,而不是messages使用的(由add_messages函数内部启用的)追加行为。
为什么我们将base_context_node做成静态方法?好吧,再次回想一下,图中的每个节点都需要接受图状态作为其第一个参数。我们希望将base_context_node放在SupportAgentGraph中,以便于逻辑代码组织,但如果我们将其做成普通方法,它将需要接受类实例(self)作为第一个参数。将其做成静态方法消除了这个要求,并使我们能够添加一个状态参数。
一些读者可能会问,“等等,我们不是也为了同样的原因将 assistant_node 结构化为嵌套函数吗?为什么这里没有这样做?”
我们确实可以使用基于闭包的解决方案来处理 base_context_node,但我们不需要;与引用 self.llm 的 assistant_node 不同,base_context_node 完全不需要访问 self。因此,我们采用了更直接的技术,即应用 @staticmethod 装饰器到 base_context_node。
说到 assistant_node,考虑一下我们对它代码所做的更改:
sys_msg = SystemMessage(content=state["sys_msg_text"])
messages_to_send = [sys_msg] + state["messages"]
ai_response_message = self.llm.invoke(messages_to_send)
我们现在不是直接用 state["messages"] 调用 LLM,而是创建一个 SystemMessage 对象,其中 sys_msg_text 字段是我们填充在 base_context_node 中的内容,并将其添加到 state["messages"] 的前面,以形成传递给 LLM 的列表。
最后,请注意我们对 build_graph 的更新。由于我们已经扩展了 MessagesState 以包括 sys_msg_text 字段,我们使用它来初始化 StateGraph:
builder = StateGraph(AgentState)
我们像这样添加基础上下文节点:
builder.add_node("base_context", self.base_context_node)
注意我们在这里是如何传递对 self.base_context_node 方法的引用,而不是用双括号调用它。
我们还重新排列了图中的边,在 START 和 assistant 之间插入 base_context 节点:
builder.add_edge(START, "base_context")
builder.add_edge("base_context", "assistant")
这应该就是我们所需要的。继续运行您的应用程序。尝试再次请求机器人唱歌,以获得类似于图 10.13 的响应。

图 10.13 Nibby 现在拒绝处理无聊的请求(完整的代码请参阅 GitHub 仓库中的 chapter_10/in_progress_04)。
看起来 Nibby 收到了通知!它不会再帮助用户处理不相关的请求了。在下一节中,我们将解决相反的问题:让它帮助回答相关的问题。
10.5 检索增强生成
模型如 GPT-4o 非常有效,因为它们已经在大量公开可用的信息语料库上进行了预训练,例如书籍、杂志和网站。这就是为什么我们的第九章中的知识问答应用“Fact Frenzy”能够就如此广泛的主题提问和回答问题。
然而,许多更具经济价值的生成式 AI 用例需要的信息不仅限于公共领域。真正将 AI 调整为适合您特定用例通常需要提供只有您拥有的私有信息。
以 Nibby 为例,它最终旨在帮助 Note n' Nib 的客户查询。如果我们向 Nibby 提出一个关于文具产品的有效问题会发生什么?图 10.14 展示了这样的对话。

图 10.14 Nibby 在不知道答案时编造信息。
看起来 Nibby 已经做得很好了,对吧?并不完全是这样。我们从未告诉我们的机器人 Note n' Nib 携带哪些类型的笔,所以它从哪里得到这些信息呢?此外,我们在第六章中遇到的虚构品牌——InkStream 和 RoyalQuill——在哪里?实际上,Nibby 没有关于钢笔的信息,因此只是简单地虚构了这个回答!
在本节中,我们将发现一种方法,通过我们提供的自定义信息来增强 Nibby 现有的世界知识库。
10.5.1 什么是检索增强生成?
那么,我们如何补充一个 LLM 训练的所有信息以及我们自己的信息呢?对于相对较小的信息片段,实际上是非常容易的——事实上,我们已经有了解决方法!我们只需要将信息作为我们提示的一部分提供给 LLM!
我们可以通过在发送给 LLM 的系统消息中直接列出 Note n' Nib 销售的产品,简单地让 Nibby 正确回答我们在图 10.14 中提出的问题。
那么,其他问题呢?技术上,我们可以直接在提示中给出模型可能需要回答任何问题的所有上下文信息。我们可以提供此类信息的最大量是以标记计的,称为模型的上下文窗口长度。
相对较新的模型拥有巨大的上下文窗口。例如,gpt-4o-mini 可以处理高达 128,000 个标记(大约 96,000 个单词,因为——平均而言——一个标记大约是三分之二的单词),而 o3-mini——来自 OpenAI 的一个较新的推理模型——上下文窗口为 200,000 个标记。其他提供商的模型在一个提示中可以处理更多的标记。Google 的 Gemini 2.0 Pro 的上下文窗口长达惊人的2 百万个标记——足以容纳整个《哈利·波特》系列书籍,还剩下足够的空间来容纳几乎所有的《指环王》三部曲。
难道我们的问题就解决了?我们可以简单地组装我们关于 Note n' Nib 的所有信息,并在每个提示中将其提供给 LLM,对吗?
当然,我们可以这样做,但可能出于几个原因我们不想这么做:
-
LLM 容易受到信息过载的影响;我们通常看到在极其大的提示下性能下降。
-
即使没有这种退化,LLM 提供商通常按标记收费,所以如果我们必须在每次 LLM 调用中传递我们的整个自定义知识库,成本将会飙升。
不,我们需要一个不同的解决方案。如果我们能够读取用户的问题,并仅向 LLM 提供 回答问题所需的我们知识库中的相关部分。
而且——如果你还没有意识到这个讨论的方向——这正是检索增强生成(RAG)的含义。
RAG 具有以下基本步骤:
-
读取用户的问题
-
检索与问题相关的上下文信息从知识库中
-
增强问题,添加回答它所需的上下文
-
生成问题的答案,通过将问题和上下文输入到 LLM 中
RAG 的难点在于检索步骤。具体来说,给定一个用户问题和大型自定义知识库,你如何识别与问题相关的知识库部分,并仅从库中提取这些部分?
答案在于嵌入的概念以及一个名为向量数据库的软件。
嵌入和向量数据库
虽然从严格意义上讲,我们不需要学习嵌入或向量数据库的工作原理来实现 RAG,但了解这些概念的基本知识会是一个好主意。
让我们从简化示例开始,以实现这一点。假设你在朋友圈中以电影爱好者而闻名。你的朋友走到你面前说:“嘿,我昨天看了黑暗骑士,非常喜欢!你能推荐一部类似的电影吗?”
你陷入了困境,因为你虽然对电影有百科全书式的了解,但你不确定如何确切地衡量两部电影之间的相似度,以便推荐与黑暗骑士最相似的电影。拒绝接受失败,你逃到你的地下巢穴,独自尝试解决这个问题。
最终,你提出了一套系统。你认为当人们表达对各种电影的偏好时,他们潜意识中在谈论两个属性:喜剧值和每小时爆炸次数。因此,你将你的整个电影目录与这两个尺度进行比较,并在图表上绘制结果(部分如图 10.15 所示)。

图 10.15 将电影转换为向量并在图表上绘制
如您所见,黑暗骑士的喜剧值为1.2,但每小时爆炸次数相对较高,为6。我们可以将其表示为一个数字列表:[1.2, 6],称为一个向量。我们可以称这个向量[1.2, 6]为电影黑暗骑士在二维喜剧值/每小时爆炸次数空间中的嵌入。
以这种方式将电影转换为数字,使得可以衡量它们的相似度。例如,办公室空间在同一空间中表示为[7.2, 0.4]。通过考虑它们的几何距离,可以数学地计算出办公室空间和黑暗骑士之间的相似度(或者更确切地说,是缺乏相似度)。两部电影嵌入的几何距离越近——通过它们之间画出的直线长度来衡量——它们背后的电影就越相似。
经过几次这样的计算后,你发现具有向量[1.1, 5.9]的终极越狱与黑暗骑士最接近。在完成你的研究后,你回到朋友那里告诉他们(你的朋友回答说:“谢天谢地你还在!已经两年了,你去哪了了?”)。
我们面临的 Nibby 问题与上述电影推荐问题类似。给定一个用户的消息(你朋友喜欢的电影),以及一个知识库(你的电影目录),我们必须找到与用户消息最相关(最“相似”)的段落/片段(电影)。
为了高效地回答这个问题,我们需要两样东西:
-
一种将给定文本转换为能够捕捉其意义(或语义)的嵌入的方法
-
一种存储这些嵌入并快速计算它们之间距离的方法
显然,上面提到的电影例子过于简单。我们的“空间”只有两个维度:喜剧价值和每小时爆炸次数。编码文本的意义需要更多的维度(如数百或数千),而这些维度本身也不会是像“喜剧价值”这样人类可理解的概念。我们将使用 OpenAI 提供的文本嵌入模型来满足我们的用例。
为了存储嵌入,我们将使用一个名为向量数据库的程序。向量数据库使得计算嵌入之间的距离或找到与特定嵌入最接近的条目变得容易。我们不会使用向量之间的“直线”(或欧几里得)距离,而是使用一个称为余弦相似度的分数,它通过测量两个向量之间的角度来确定它们的相似度。我们将使用的向量数据库是 Pinecone,这是一个云托管服务。
10.5.2 在我们的应用中实现 RAG
在掌握了 RAG 的概念理解之后,我们现在将专注于将其实现,以帮助 Nibby 回答客户问题。
准备知识库
本章节的 GitHub 文件夹(github.com/aneevdavis/streamlit-in-action/tree/main/chapter_10)中有一个名为 articles 的子目录,其中包含一系列针对 Note n' Nib 的客户支持文章。每篇文章都是一个包含有关 Note n' Nib 产品信息或其业务运营方式的文本文件。例如,our_products.txt 包含产品描述列表,而 fountain_pen_maintenance.txt 则是关于维护 RoyalQuill 和 InkStream 笔的说明。
下面是我们将要引用的文章摘录:
Proper care ensures your InkStream and RoyalQuill fountain pens write smoothly for years.
- Cleaning: Flush the nib with warm water every few weeks.
- Refilling: Use high-quality ink to prevent clogging.
- Storage: Store pens upright to avoid leaks and ensure ink flow.
现在将 articles 文件夹复制到您的当前工作目录中。这是 Nibby 将能够访问的知识库。
设置向量数据库
如前一小节简要提到的,我们将使用 Pinecone,这是一个针对快速和可扩展的相似性搜索进行优化的托管向量数据库,它因适用于人工智能应用而受到欢迎。Pinecone 的免费“入门”计划对于本章来说已经足够了。
前往 www.pinecone.io/ 现在注册一个账户。一旦设置完成,你将获得一个 API 密钥,你应该立即保存。完成之后,创建一个新的 索引。索引类似于常规的非向量数据库(如 PostgreSQL)中的表。索引将存储我们的支持文章的各个部分及其嵌入。
在创建过程中,你将需要提供各种选项的值:
-
索引名称:你可以使用任何你喜欢的名称,但请记住保存它,因为我们将在代码中使用它。
-
模型配置:选择
text-embedding-ada-002,这是我们将要使用的 OpenAI 嵌入模型;维度的值应自动设置为 1,536。 -
度量标准:选择
cosine以使用我们之前简要讨论过的余弦相似度分数。 -
容量模式 应该是
Serverless -
云提供商:
AWS对此是合适的。 -
区域:在撰写本文时,免费计划中只有
us-east-1可用,所以请选择它。
知识库的导入
要将向量存储集成到我们的聊天机器人应用程序中,我们将创建一个 VectorStore 类。在这样做之前,将你的 Pinecone API 密钥和刚刚创建的索引名称添加到 secrets.toml 中,使其看起来像这样:
[api_keys]
OPENAI_API_KEY = 'sk-proj-...' #A
VECTOR_STORE_API_KEY = 'pcsk_...' #B
[config]
VECTOR_STORE_INDEX_NAME = 'index_name_you_chose' #C
A 用你的实际 OpenAI API 密钥替换。
B 用你的 Pinecone API 密钥替换。
C 用实际的索引名称替换 sk-proj-...。
我们在 api_keys 中添加了一个新键,并在 config 部分添加了一个新部分来保存索引名称。
接下来是 VectorStore 类。创建一个名为 vector_store.py 的文件,其内容如列表 10.8 所示。
列表 10.8 vector_store.py
from pinecone import Pinecone
from langchain_openai import OpenAIEmbeddings
from langchain_pinecone import PineconeVectorStore
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
class VectorStore:
def __init__(self, api_keys, index_name):
pc = Pinecone(api_key=api_keys["VECTOR_STORE_API_KEY"])
embeddings = OpenAIEmbeddings(api_key=api_keys["OPENAI_API_KEY"])
index = pc.Index(index_name)
self.store = PineconeVectorStore(index=index, embedding=embeddings)
def ingest_folder(self, folder_path):
loader = DirectoryLoader(
folder_path,
glob="**/*.txt",
loader_cls=TextLoader
)
documents = loader.load()
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
texts = splitter.split_documents(documents)
self.store.add_documents(texts)
def retrieve(self, query):
return self.store.similarity_search(query)
(GitHub 仓库中的 chapter_10/in_progress_05/vector_store.py)
这里有很多事情要做,所以让我们一步一步地来看。
__init__ 包含设置向量存储连接所需的样板代码。它接受两个参数:api_keys(API 密钥的字典)和 index_name(Pinecone 索引的名称)。
__init__ 首先使用我们一分钟前记录的 Pinecone API 密钥创建 Pinecone 对象——pc,然后通过传递 OpenAI 密钥创建一个 OpenAIEmbeddings 对象。pc.Index(index_name) 指的是我们之前创建的索引。最后,我们从索引和嵌入中获取一个向量存储对象,并将其分配给 self.store,以便我们可以在其他方法中使用它。
ingest_folder 方法接受文件夹的路径并将内容保存到 Pinecone 索引中。考虑这个方法的第一个部分:
loader = DirectoryLoader(
folder_path,
glob="**/*.txt",
loader_cls=TextLoader
)
LangChain 提供了各种 文档加载器,以帮助将各种类型的数据(文本文件、PDF、网页、数据库等)导入并解析成适合处理的结构化格式。DirectoryLoader 使从指定目录加载文件变得容易。
glob="**/*.txt" 确保包含文件夹(包括子文件夹)中的所有文本文件(.txt)。
loader_cls=TextLoader 告诉 DirectoryLoader 使用另一个名为 TextLoader 的加载器类(由 LangChain 提供)来加载单个文本文件。
一旦创建 DirectoryLoader,下一步是加载文档:
documents = loader.load()
这将读取目录中的所有 .txt 文件并将它们加载到一个 Document 对象列表中,LangChain 使用它来存储原始文本和元数据。
目前,每个 Document 都包含一整篇文章。虽然我们文件夹中的文章相对较小,但可以很容易地想象一个支持文章可能长达数千个单词。从我们的知识库中获取相关文本的目的就是为了减少整个提示的大小;简单地获取整个文章是达不到这个目的的。
因此,我们希望将文章分成可管理的、大致相等大小的文本块。这正是我们代码的下一部分所做的事情:
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=200
)
texts = splitter.split_documents(documents)
RecursiveCharacterTextSplitter 是 LangChain 中的一个文本分割实用工具,可以在保留有意义上下文的同时将文档分割成块。
chunk_size=500 设置每个块的长度为 500 个字符。
chunk_overlap=200 表示块之间将有一个 200 个字符的重叠,以保持上下文。
在分割文档后,texts 中可用的块准备存储:
self.store.add_documents(texts)
add_documents 函数将分割后的文本块添加到 Pinecone 索引中,存储使用 text-embedding-ada-002 模型生成的文本和嵌入,使其可搜索。
retrieve 方法允许调用者查询向量存储:
def retrieve(self, query):
return self.store.similarity_search(query)
我们 PineconeVectorStore 实例(self.store)的 similarity_search 方法使用生成的嵌入在向量存储中搜索与 query(用户的消息)相似的文档,根据查询返回相关 Document 对象的列表。
到目前为止,我们已经将向量存储功能编码到一个方便的小类中;接下来,让我们使用这个类来导入我们的 articles/ 文件夹。
这是一个 离线 步骤,您只需要执行一次;一旦您将文章存储在 Pinecone 中,它们将保留在那里,直到您删除它们。
现在创建一个名为 ingest_to_vector_store.py 的文件,并将列表 10.9 的内容复制进去。
列表 10.9 ingest_to_vector_store.py
import toml
from vector_store import VectorStore
secrets = toml.load(".streamlit/secrets.toml")
api_keys = secrets["api_keys"]
index_name = secrets["config"]["VECTOR_STORE_INDEX_NAME"]
vector_store = VectorStore(api_keys, index_name)
vector_store.ingest_folder("articles/")
(chapter_10/in_progress_05/ingest_to_vector_store.py 在 GitHub 仓库中)
由于这不是使用 Streamlit 运行的,我们直接使用 toml 模块来读取我们的 secrets.toml:
secrets = toml.load(".streamlit/secrets.toml")
在此之后,secrets 应该是一个包含我们之前组织到 secrets.toml 中的 api_keys 和 config 键的字典。
接下来,我们获取所需的值:
api_keys = secrets["api_keys"]
index_name = secrets["config"]["VECTOR_STORE_INDEX_NAME"]
我们现在可以实例化我们的 VectorStore 类:
vector_store = VectorStore(api_keys, index_name)
最后,我们触发 ingest_folder 方法:
vector_store.ingest_folder("articles/")
要执行实际的导入,请在您的终端中使用 python 命令运行此文件:
python ingest_to_vector_store.py
一旦完成,您可以去 Pinecone 网站上与您的索引对应的页面查看新导入的块,如图 10.16 所示。

图 10.16 你可以在 Pinecone 网站上看到你的索引中的块。
注意源字段,它包含每个块来自的文件名。你还可以点击记录上的编辑按钮来添加更多元数据或查看其数值向量值。如果你要计数,你会找到 1536 个,这对应于嵌入模型的维度数。
向图中添加 RAG
我们需要的用于 RAG 的 Pinecone 索引已经准备好了,但我们还需要将其功能整合到我们的聊天机器人中。为此,让我们首先列出 Nibby 需要使用知识库的附加说明。将以下内容追加到prompts.py:
SYS_MSG_AUGMENTATION = """
You have the following excerpts from Note n' Nib's
customer service manual:
{docs_content}
If you're unable to answer the customer's question confidently with the
given information, please redirect the user to call a human customer
service representative at 1-800-NOTENIB.
"""
(GitHub 仓库中的chapter_10/in_progress_05/prompts.py)
在不久的将来,我们将编写逻辑来用我们从 Pinecone 检索到的文档块替换{docs_content}。这里的想法是给 Nibby 提供所需的上下文,并让它停止在没有信心的情况下凭空编造答案。
接下来,让我们修改graph.py,如列表 10.10 所示,以实现 RAG。
列表 10.10 graph.py(带有 RAG 节点)
...
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.documents import Document
from prompts import *
class AgentState(MessagesState):
sys_msg_text: str
retrieved_docs: list[Document]
class SupportAgentGraph:
def __init__(self, llm, vector_store):
self.llm = llm
self.vector_store = vector_store
self.config = {"configurable": {"thread_id": "1"}}
self.graph = self.build_graph()
...
def get_retrieve_node(self):
def retrieve_node(state: AgentState):
messages = state["messages"]
message_contents = [message.content for message in messages]
retrieval_query = "\n".join(message_contents)
docs = self.vector_store.retrieve(retrieval_query)
return {"retrieved_docs": docs}
return retrieve_node
@staticmethod
def augment_node(state: AgentState):
docs = state["retrieved_docs"]
docs_content_list = [doc.page_content for doc in docs]
content = "\n".join(docs_content_list)
new_text = SYS_MSG_AUGMENTATION.replace("{docs_content}", content)
return {"sys_msg_text": BASE_SYS_MSG + "\n\n" + new_text}
...
def build_graph(self):
...
builder.add_node("base_context", self.base_context_node)
builder.add_node("retrieve", self.get_retrieve_node())
builder.add_node("augment", self.augment_node)
builder.add_node("assistant", self.get_assistant_node())
builder.add_edge(START, "base_context")
builder.add_edge("base_context", "retrieve")
builder.add_edge("retrieve", "augment")
builder.add_edge("augment", "assistant")
builder.add_edge("assistant", END)
return builder.compile(checkpointer=memory)
...
(GitHub 仓库中的chapter_10/in_progress_05/graph.py)
第一个更改是AgentState,现在看起来是这样的:
class AgentState(MessagesState):
sys_msg_text: str
retrieved_docs: list[Document]
现在,我们将从 Pinecone 检索到的块列表存储在图的retrieved_docs变量中。
__init__现在接受vector_store——我们的VectorStore类的一个实例——作为参数,并将其保存到self.vector_store。
注意,我们不是在graph.py中创建VectorStore实例,而是选择在别处(bot.py,我们很快就会知道)创建它,并将其简单地传递给SupportAgentGraph类。这是因为我们希望graph.py只包含图的逻辑核心。图所依赖的对象,如llm和vector_store,应该传递给它。这种编码模式被称为依赖注入,在编写自动化测试时很有帮助。
接下来,我们需要将检索增强生成过程引入我们的图中。图 10.17 显示了本节结束时图应该的样子。

图 10.17 我们现在在图中有了用于 RAG 的检索和增强节点。
我们在base_context和assistant节点之间插入了两个节点:一个用于从与用户查询相关的知识库中检索上下文的retrieve节点,以及一个用于将此信息添加到提示中的augment节点。
这里是retrieve节点的代码:
def get_retrieve_node(self):
def retrieve_node(state: AgentState):
messages = state["messages"]
message_contents = [message.content for message in messages]
retrieval_query = "\n".join(message_contents)
docs = self.vector_store.retrieve(retrieval_query)
return {"retrieved_docs": docs}
return retrieve_node
就像assistant_node的情况一样,retrieve_node被结构化为一个类方法内的嵌套函数。它只是提取对话中所有消息的内容,并将它们放入一个单独的字符串中,形成我们将传递给向量存储的“查询”。
这里的想法是找到与对话最相关的块。由于我们是在与查询测量相关性,所以这仅仅是对话的文本是有意义的。
一旦我们有了查询,我们可以调用我们之前定义的retrieve方法,并以字典的形式返回检索到的文档列表,从而更新图状态中的retrieved_docs键。
注意
虽然我们在这里通过包含整个对话的文本在检索查询中保持了简单,但随着对话变得越来越长,你可能会遇到挑战。对于任何特定的 AI 响应,对话中最最近的消息可能更有语境相关性——因此,从对话的最后几条,比如最后五或六条消息中形成检索查询可能是个好主意。
下面的图显示了增强节点:
@staticmethod
def augment_node(state: AgentState):
docs = state["retrieved_docs"]
docs_content_list = [doc.page_content for doc in docs]
content = "\n".join(docs_content_list)
new_text = SYS_MSG_AUGMENTATION.replace("{docs_content}", content)
return {"sys_msg_text": BASE_SYS_MSG + "\n\n" + new_text}
这个不需要从SupportAgentGraph访问任何内容,所以我们将其结构化为一个静态方法,就像我们对base_context_node所做的那样。
augment_node主要做的是繁琐的工作,将检索到的块整理并插入到系统消息中。一旦它通过连接检索到的Document块的 内容形成了一个字符串,它就简单地将其插入到我们添加到prompts.py中的SYS_MSG_AUGMENTATION值的文本中,替换{docs_content}。
在此节点的末尾,sys_msg_text包含完整的系统消息——早些时候的基本消息警告 Nibby 不要回答无聊的问题,以及检索到的上下文。
我们已经在 RAG 的“生成”步骤中有一个节点了——assistant_node——因此不需要再添加一个。
根据 10.17 图,对build_graph的修改应该是相当明显的;我们正式添加了retrieve和augment节点,并将它们连接到正确的边上。
下一个要编辑的文件是bot.py。进行以下更改:
...
from vector_store import VectorStore
class Bot:
def __init__(self, api_keys, config):
self.api_keys = api_keys
self.config = config
self.llm = self.get_llm()
self.vector_store = self.get_vector_store()
self.graph = SupportAgentGraph(
llm=self.llm, vector_store=self.vector_store)
def get_vector_store(self):
index_name = self.config["VECTOR_STORE_INDEX_NAME"]
return VectorStore(api_keys=self.api_keys, index_name=index_name)
...
(GitHub 仓库中的chapter_10/in_progress_05/bot.py)
__init__现在接受一个config参数,保存到self.config。它还通过调用我们下面将要讨论的get_vector_store方法创建vector_store对象,并将其传递给SupportAgentGraph构造函数。
get_vector_store包含完成循环所需的代码。它从self.config获取 Pinecone 索引名称,并在返回之前将self.api_keys和index_name传递给创建VectorStore实例。
我们需要在frontend.py中进行的最后一个更改相当小:
...
if "bot" not in st.session_state:
api_keys = st.secrets["api_keys"]
config = st.secrets["config"]
st.session_state.bot = Bot(api_keys, config)
bot = st.session_state.bot
...
(GitHub 仓库中的chapter_10/in_progress_05/frontend.py)
由于Bot类现在接受一个config参数,我们从st.secrets获取其值,并在实例化类时传递它。
让我们再次尝试询问 Nibby Note n' Nib 销售什么产品。重新运行应用程序并与它交谈。图 10.18 显示了新的交互示例。

图 10.18 现在 Nibby 可以访问并使用我们的知识库中的信息(有关完整代码,请参阅 GitHub 仓库中的chapter_10/in_progress_05)。
注意 Nibby 如何使用我们的知识库中的信息回答我们的问题,并在遇到不知道如何回答的问题时转向 1-800 号码。
10.6 将我们的机器人转换为代理
给 Nibby 访问 Note n' Nib 的客户支持知识库已经使 Nibby 成为一个有能力的助手,但它仍然是一个纯粹的信息型机器人。当客户拨打支持电话或与服务代表聊天时,他们往往更希望得到针对他们特定情况或订单的帮助。
例如,一个客户可能会想知道为什么他们下订单的到达时间这么长,并想检查状态,或者他们可能只想取消订单。为了解决这类问题,Nibby 不能仅仅依赖于静态的文本文章;它需要连接到 Note n' Nib 的系统并检索正确的信息或采取适当的行动。
能够以这种方式与真实世界互动的 AI 应用有一个特殊的名称:代理。
10.6.1 什么是代理?
传统的 AI 聊天机器人遵循问答模式,提供有帮助但静态的响应。然而,当用户需要个性化帮助——例如检查订单状态、更新地址或取消订单时——信息型机器人就不够用了。
这就是代理的用武之地。与被动的聊天机器人不同,AI 代理——也称为代理应用——可以进行推理、规划和与外部系统交互以完成任务。它们不仅检索知识;它们根据知识采取行动。这些代理通常依赖于工具使用,这意味着它们可以调用 API、运行数据库查询,甚至在实际应用中触发工作流程。
例如,与其告诉客户联系客户服务电话以获取订单跟踪号,代理可以获取跟踪详情并提供直接更新。而不是将用户引导到取消政策页面,它可以代表他们处理取消请求。
简而言之,代理通过允许 AI 与人们已经使用的系统接口,使 AI 变得实用。使代理有效工作的关键是能够使其动态推理和决定下一步行动的框架。这样一个流行的框架被称为ReAct,即推理+行动。
10.6.2 ReAct 框架
要作为一个真正的代理,一个 AI 系统必须做的不仅仅是检索事实——它需要对该情况进行推理,确定正确的行动,执行该行动,然后将结果纳入其下一步行动。ReAct 框架旨在促进这一过程。
注意
ReAct AI 框架与用于构建 Web 应用的 JavaScript 工具包 React 不可混淆。碰巧的是,我们将在第十三章遇到后者。
图 10.19 是 ReAct 框架的视觉表示。

图 10.19 ReAct 框架
ReAct 将 AI 代理的行为结构化为推理步骤和行动的交织:
-
原因:代理分析用户的查询,将其分解为逻辑步骤,并确定需要做什么。
-
行动:代理采取具体行动,例如调用 API 或查询数据库,以检索相关数据或执行任务。
-
原因: 代理将动作的结果纳入其推理中,并决定是否需要进一步的操作。
-
重复: 如果需要进一步的操作,循环将重复。
-
响应: 如果不需要进一步的操作,向用户响应。
例如,考虑一个客户询问 Nibby:“我的订单状态是什么?”使用 ReAct 框架,机器人可能会遵循以下步骤:
-
原因: "客户想要检查他们的订单状态。我需要从订单数据库中获取订单详情。"
-
行动: 调用 Note & Nib 的订单管理系统以检索订单状态。
-
原因: "系统显示订单已发货并正在运输中。我应该提供预计的交货日期。"
-
响应: 向客户提供最新的跟踪细节和预计交货日期。
创建代理可以使用的工具
工具的概念对于开发 ReAct AI 代理的过程至关重要。在人工智能的术语中,工具是一个 AI 代理可以调用来执行现实世界动作的函数或 API。
回顾我们起草的要求,我们希望我们的机器人能够代表 Note n' Nib 的客户跟踪和取消订单。当然,Note n' Nib 是一个虚构的公司,没有真实的订单或客户。
这意味着我们需要一些示例数据来工作。你可以在 GitHub 仓库中的 database.py 文件中找到这些数据(github.com/aneevdavis/streamlit-in-action/tree/main/chapter_10/in_progress_06/). 将文件复制到你的工作目录。列表 10.11 展示了其中的几个摘录。
列表 10.11 database.py
users = {
1: {
"first_name": "Alice",
"last_name": "Johnson",
"date_of_birth": "1990-05-14",
"email_address": "alice.johnson@example.com"
},
...
}
orders = {
101: {
"user_id": 1,
"order_placed_date": "2025-02-10",
"order_status": "Shipped",
"tracking_number": "TRK123456789",
"items_purchased": ["RoyalQuill", "RedPinner"],
"quantity": [1, 1],
"shipping_address": "123 Main St, Springfield, IL, 62701",
"expected_delivery_date": "2025-02-18"
},
...
}
(GitHub 仓库中的chapter_10/in_progress_06/database.py)
文件中有两个字典——users和orders,分别以用户 ID 和订单 ID 为键。列表中突出显示了用户 ID 1(Alice Johnson)和用户在2025-02-10放置的相应订单。
在实际应用中,这些信息会被存储在像 PostgreSQL 这样的数据库中。然而,由于我们的重点是构建 AI 代理的机制,database.py中的静态数据将满足我们的需求。
仓库中还有一个名为tools.py的文件。也要将这个文件复制过来。
tools.py包含 Nibby 将能够调用的所有函数或工具。该文件定义了四个这样的函数:
-
retrieve_user_id根据用户的电子邮件地址和出生日期查找用户的用户 ID。 -
get_order_id根据放置订单的用户 ID 和放置日期返回特定订单的 ID。为了简单起见,我们假设用户在特定的一天只能放置一个订单。 -
get_order_status接受一个订单 ID,并返回其订单状态、跟踪号和预计交货日期。 -
cancel_order根据订单 ID 取消订单。
我们对这些函数的实际实现不感兴趣,尽管你可以在 tools.py 中阅读它们。重要的是 Nibby 需要能够正确地调用并使用它们。
对于这个,我们将依赖于类型提示和文档字符串。例如,考虑 tools.py 中这些函数定义中的一个:
def retrieve_user_id(email: str, dob: str) -> str:
"""
Look up a user's user ID, given their email address and date of birth.
If the user is not found, return None.
Args:
email (str): The email address of the user.
dob (str): The date of birth of the user in the format "YYYY-MM-DD".
Returns:
int: The user ID of the user, or None if the user is not found.
"""
for user_id, user_info in users.items():
if (user_info["email_address"] == email and
user_info["date_of_birth"] == dob):
return user_id
(chapter_10/in_progress_06/tools.py 在 GitHub 仓库中)
注意我们如何在函数签名中使用类型提示((email: str, dob: str) -> str),并在文档字符串中详细解释函数的功能(签名后的多行字符串),包括参数和返回值的详细信息。
这些辅助项是 AI 模型将用来确定在何时调用哪个工具的。
在 tools.py 文件的底部,有一行代码将文件中的所有函数导出为一个名为 tools 的列表:
tools = [retrieve_user_id, get_order_id, get_order_status, cancel_order]
我们将在我们的图中稍后使用 tools 变量。
10.6.3 使我们的图具有代理性
考虑一个例子,看看 Nibby 如何使用我们提供的四个工具来帮助客户。假设示例数据中的客户 Alice Johnson 想要知道她订单的状态(如 database.py 中摘录的订单 101)。以下是可能的交互过程:
-
用户: "嗨,我叫 Alice!我在 2025 年 2 月 10 日下了一单。你能检查一下它的状态吗?"
-
Nibby (自我推理): "我有一个名为
get_order_status的工具,它可以给我提供订单的状态。要调用它,我需要一个order_id。我没有一个,但我可以通过调用get_order_id工具来获取一个,这个工具需要一个user_id和一个order_placed_date。用户说order_placed_date是 2025 年 2 月 10 日,所以我有了这个信息。我还需要一个user_id。为了获得 这个,我可以调用retrieve_user_id工具,它需要一个电子邮件和一个出生日期。因此,我应该向客户询问这些信息。" -
Nibby: "你能给我你的电子邮件地址和出生日期吗?"
-
用户: "当然,我的电子邮件是 alice.johnson@example.com,我的出生日期是 1990 年 5 月 10 日。"
-
Nibby (工具调用): 使用参数
email=alice.johnson@example.com和dob=1990-05-10调用工具retrieve_user_id -
retrieve_user_id('alice.johnson@example.com', '1990-05-10'): <返回值1,这是 Alice 的user_id> -
Nibby (工具调用): 使用参数
user_id=1和order_placed_date=2025-02-10调用工具get_order_id -
get_order_id(1, 1990-05-10): <返回值101,正确的订单 ID> -
Nibby (工具调用): 使用参数
order_id=101调用工具get_order_status -
get_order_status(101): <返回一个字典:
-
Nibby: "你的订单已经发货,预计将于 2025 年 2 月 18 日到达。你可以使用跟踪号码 TRK123456789 来跟踪它。"
注意机器人需要交替与用户交谈、推理下一步需要做什么,以及发出对工具的调用。我们如何将所有这些编码起来?LangGraph 实际上使这一切出奇地简单。
首先,让我们修改bot.py,让我们的 LLM 了解它可用的工具:
...
from vector_store import VectorStore
from tools import tools
class Bot:
def __init__(self, api_keys, config):
...
self.llm = self.get_llm().bind_tools(tools)
...
...
在这里,我们将之前定义的工具(从tools.py导入)绑定到我们的 LLM 对象上,以便它知道它们的存在。因此,如果 LLM 认为合适,它可以以工具调用的形式进行响应。
在上面的示例中,工具调用被标记为“Nibby(工具调用)”。在实际情况中,这些是由 LLM 产生的AIMessage,它们有一个名为tool_calls的属性,其中包含 LLM 希望我们代表它调用的任何工具及其参数的信息。
绑定逻辑使用我们在tools.py中指定的文档字符串和类型提示来向 LLM 解释每个工具的作用以及如何使用它。
那么,图本身呢?列表 10.12 显示了将我们的机器人变成代理所需的对graph.py的更改。
列表 10.12 graph.py(用于代理应用程序)
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, StateGraph, MessagesState
from langgraph.prebuilt import tools_condition, ToolNode
from langchain_core.documents import Document
from langchain_core.messages import HumanMessage, SystemMessage
from prompts import *
from tools import tools
...
class SupportAgentGraph:
...
def build_graph(self):
...
builder.add_node("tools", ToolNode(tools))
...
builder.add_edge("augment", "assistant")
builder.add_conditional_edges("assistant", tools_condition)
builder.add_edge("tools", "assistant")
return builder.compile(checkpointer=memory)
...
(GitHub 仓库中的chapter_10/in_progress_06/graph.py)
令人难以置信的是,我们只需要在build_graph方法中添加三行,并导入一些额外的项目!
让我们逐一查看行添加,从第一个开始:
builder.add_node("tools", ToolNode(tools))
这在我们的图中添加了一个名为工具的节点。ToolNode是 LangGraph 中已经构建好的节点,所以我们不需要自己定义它。它本质上执行以下操作:
-
它取消息列表(来自图状态)中的最后一条消息,并根据我们传递给它的工具列表执行其中的任何工具调用。
-
将工具(的)返回值作为
ToolMessage——就像HumanMessage和AIMessage一样——追加到消息列表中。
在ToolNode的末尾,消息变量中的最后一条消息是一个表示调用工具输出的ToolMessage。
我们现在有了所有需要的部分,但我们如何协调本节开头示例交互中概述的那种思维过程?
在我们深入探讨之前,请将注意力转向图 10.20,这是我们进行这些更改后图将看起来的样子:

图 10.20 我们现在的图已经有一个工具节点和一个条件边
注意现在从助手节点流出两条线——一条像以前一样流向END,而另一条流向我们新添加的tools节点。
在 LangGraph 中,这被称为条件边。我们可以根据指定的条件从多个选项中选择下一个要执行的节点。条件边被实现为一个具有以下形式的函数:
def some_condition(state):
# Branching
if <something is true>:
return "name_of_node_1"
elif <something else is true>:
return "name_of_node_2"
...
在我们的情况下,我们实际上不需要构建自己的条件边,因为 LangGraph 已经有了我们想要的:
builder.add_conditional_edges("assistant", tools_condition)
tools_condition——从langgraph.prebuilt导入——如果会话中的最后一条消息(即 LLM 的响应)包含任何工具调用,则简单地路由到我们的ToolNode(命名为tools),否则路由到 END。
由于tools_condition已经具有路由到END的逻辑,我们可以删除创建assistant和END之间直接(非条件)边的早期行。
一旦ToolNode执行完毕,我们需要 LLM 读取返回值并决定如何处理它——无论是调用另一个工具,还是响应用户。
因此,我们在图中创建了一个循环,通过将工具节点回连到assistant:
builder.add_edge("tools", "assistant")
那就是全部!现在当收到请求时,LLM 将推理出要做什么。如果它决定调用工具,它将在其响应中放置一个工具调用,导致tools_condition路由到执行调用的ToolNode。由于ToolNode有一个直接连接到assistant的边,LLM 将获得带有附加ToolMessage的更新后的消息列表,并可以再次推理出如何处理响应。
如果 LLM 决定不需要再调用任何工具,或者流程的下一步是从用户那里获取一些信息,它将不会在其响应中包含任何工具调用,这意味着tools_condition将路由到END,并将最终消息显示给用户。
虽然这已经足够让机器人正确工作,但我们还需要在graph.py中做出最后一个更改,这与前端显示给客户的内容相关。
如上所述的段落所希望清楚地表明,assistant和tools节点之间的通信是通过图状态中的messages变量进行的,并包括两种类型的内部消息:包含工具调用的AIMessages和包含工具返回值的ToolMessages。
由于我们不希望将这些内部消息暴露给我们的 Streamlit 应用程序的用户,我们需要在将会话历史记录返回时隐藏它们。回想一下,这个历史记录是通过bot.py中的get_history方法传递的,该方法调用graph.py中的get_conversation方法。
让我们在graph.py中做出适当的更改以删除这些消息:
...
@staticmethod
def is_internal_message(msg):
return msg.type == "tool" or "tool_calls" in msg.additional_kwargs
def get_conversation(self):
state = self.graph.get_state(self.config)
if "messages" not in state.values:
return []
messages = state.values["messages"]
return [msg for msg in messages if not self.is_internal_message(msg)]
(GitHub 仓库中的chapter_10/in_progress_06/graph.py)
首先,我们定义is_internal_message,这是一个静态方法,用于确定我们传递给它的消息是否是“内部”的,即不适合显示给用户的消息。在上文中,我们将内部消息定义为具有类型"tool"——这是ToolMessages的情况——或者具有"tool_calls"属性(在additional_kwargs中,这是 LLM 将用于在消息中设置元数据的属性)。
然后,我们不再从get_conversation状态中返回所有消息,而是现在只过滤非内部消息并只返回这些消息。
在完成这些之后,重新运行应用程序并测试其新功能!图 10.21 显示了示例。

图 10.21 Nibby 可以处理现实世界的操作,如跟踪和取消订单(完整代码请见 GitHub 仓库中的 chapter_10/in_progress_06)。
通过允许 Nibby 访问外部工具,我们赋予了它超能力,并为 Note n' Nib 的客户支持部门节省了大量时间!
这是我们迄今为止最先进的程序。如果你一直在跟进并自己从事这些项目,你可能很欣赏这样一个事实:应用越复杂,当用户真正开始与之互动时,在现实世界中可能出错的事情就越多。在下一章中,我们将探讨如何捕捉这些问题。
提前ms并测试你的应用,使其尽可能健壮。
10.7 摘要
-
现实世界的 AI 应用需要高级功能,例如知识检索和动作执行。
-
LangGraph 将 AI 工作流程结构化为图;节点代表 AI 过程中的步骤,边定义它们之间的流动。
-
LangGraph 中的每个节点都接收图状态并对其进行修改。
-
st.chat_input渲染一个文本框,用户可以在其中输入消息。 -
st.chat_message适当地显示人类和 AI 的消息。 -
LangGraph 使用检查点器来在多个图执行之间持久化信息。
-
明确指导 LLM 保持主题并忽略不相关的请求是很重要的;系统消息是这样一个好地方。
-
嵌入涉及将对象(如文本)转换为称为向量的数字列表,以使用相似性搜索找到相关内容。
-
检索增强生成(RAG)是一种技术,通过从 Pinecone 这样的向量数据库中检索与用户查询相关的上下文块,为预训练的 LLM 提供定制知识。
-
工具是一个经过良好文档化的函数,LLM 可以选择在响应用户查询时调用。
-
一个代理应用——或者简单地称为代理——是那种可以使用工具与真实世界互动的应用。
-
LangGraph 通过其
ToolNode和tools_condition抽象,使得编写代理应用变得极其简单。


浙公网安备 33010602011771号