嘿,社区!👋
我叫 Dave Lin。在过去十年间,我一直在初创公司和大型科技公司构建和扩展产品,目前我正在短暂休假。我在过去三个月构建了 GPT Lab——一个 Streamlit 应用,它允许任何人与 AI 助手聊天或创建自己的 AI 助手(我们不称它们为聊天机器人 🤖)。
在这篇文章中,我将与您分享我从开发一个多页面应用和使用 OpenAI 大型语言模型中学到的十二个经验教训
- 构建您的应用以提高可维护性和可扩展性
- 使用由 session states 渲染的 UI 函数开发高级 UI
- 为多个页面创建可复用的 UI 元素
- 使用 Markdown 语言和静态 Streamlit 组件添加有限的样式
- 通过编程方式布局 Streamlit 元素
- 支持多种 OpenAI 完成端点
- 保护您的 AI 助手免受潜在的注入攻击
- 通过策略性地压缩聊天会话来确保流畅的聊天体验
- 保护用户隐私
- 分离开发数据库和生产数据库
- 托管选项和注意事项
- 作为独立开发者保护自己
但在我们开始讲有趣的部分之前,让我先告诉您…
是什么启发我构建 GPT Lab?
我第一次接触 Streamlit 是在 2022 年的 Snowflake Summit 上。会议期间,我立刻被它的易用性所吸引,并对其用于减少内部工具开发时间的想法产生了浓厚的兴趣。在一个下午,尽管之前没有 Streamlit 的经验,Python 技能也有些生疏,但我几乎完成了一个 Streamlit 应用,可以从 Snowflake 中检索和绘制数据(只差一个依赖下拉列表就完成了)。不幸的是,离开峰会后,现实生活占据了我的大部分时间,Streamlit 成了一个事后才想起来的事情。
然后是 2022 年 12 月,ChatGPT 的发布让我惊叹不已。玩了一周后,我开始思考 ChatGPT 是否可以成为人生导师(对我来说非常及时,因为当时我正在考虑下一步的职业发展)。在两周内,我创建并发布了一个私人的 Streamlit 人生导师应用,Coach Marlon。我收到了朋友和家人的积极评价,又花了一个月重构代码,添加了 Firebase 数据存储,并将“Coach Marlon”扩展为“Marlon's Lounge”,在那里你可以与其他导师聊天。虽然人们很喜欢这些导师,但他们也表达了创建自己的助手的兴趣。
最后,我又花了一个月重构应用结构,修改底层数据模型,并添加了对不向后兼容的 OpenAI chat-completion 端点的支持。在写了 2,800 行 Python 代码(其中 1,400 行是 Streamlit 代码)之后,GPT Lab 终于向世界揭开了面纱。🎈
1. 构建您的应用以提高可维护性和可扩展性
随着 Streamlit 应用从 Coach Marlon 演变为 Marlon's Lounge,最终发展到 GPT Lab,其复杂性急剧增加。在实现新功能时,我采取了必要的步骤来分离和模块化代码。
迭代 | 高级描述 | 文件结构 |
---|---|---|
Coach Marlon | 单页 Streamlit 应用,包含标题、一个文本输入框,以及由 Streamlit-chat 组件渲染的聊天消息。 | 一个 100 行的 python 文件 |
Marlon’s Lounge | 单页 Streamlit 应用,包含两个主要的 UI 视图:一个显示助手详细信息的 2 列视图,以及由 streamlit-chat 组件渲染的聊天视图(标题 + 文本输入框 + 聊天消息)。 | Streamlit 文件、一个包含与 Firestore DB 交互函数的 API 文件,以及一个包含 OpenAI 端点封装函数和用于用户电子邮件的单向哈希值生成的工具文件。 |
GPT Lab | 多页 Streamlit 应用,包含以下页面:home.py(介绍 GPT Lab)、lounge.py(显示推荐或用户助手的 2 列视图)、assistant.py(根据 session states 渲染助手详细信息、助手搜索视图或聊天视图)、lab.py(根据 session states 渲染助手配置页面、测试聊天视图和助手创建确认页面),以及 faq.py 和 terms.py(Markdown 页面)。 | 总共十四个 python 文件:六个主要的 Streamlit 文件、后端文件(api_util_firebase.py、api_users.py、api_bots.py、api_sessions.py、api_util_openai.py),以及应用相关文件(app_users.py、app_utils.py)。 |
尽管最初可能看起来有些过度,但代码模块化从长远来看加快了开发速度。它让我能够更好地隔离开发、测试和部署不同的功能。例如,我为后端 API 添加了对新发布的 GPT-4 模型支持,而无需将其引入前端,因为只有部分用户可以访问新模型。
2. 使用由 session states 渲染的 UI 函数开发高级 UI
随着 UI 复杂性的增加,我很快意识到将 UI 元素典型地嵌套在 if-else 语句中是不够的。我在我的 Streamlit 文件中采用了以下模式
- 改变 session states 并进行必要后端调用的 UI 元素处理函数
- 布局相关 UI 元素的函数
- 控制调用哪些 UI 元素组函数的 session states
我将使用助手页面来阐述这些概念。它展示了用户登录、助手搜索、助手详细信息、聊天视图和聊天会话回顾




该页面包含用于管理用户操作的处理函数以及布局相关 UI 元素的 UI 元素组函数。
处理函数
函数 | 高级描述 | |
---|---|---|
handler_bot_search | 处理助手搜索并为找到的助手设置 session state。 | |
handler_start_session | 管理启动新会话、设置 session state 变量并生成初始助手响应。 | |
handler_bot_cancellation | 如果用户选择寻找另一个助手,则重置与助手相关的 session state 变量。 | |
handler_user_chat | 处理用户聊天输入、获取助手响应,并将其附加到 session state。 | |
handler_end_session | 处理会话结束请求、获取聊天摘要,并设置 session_ended state 变量。 | |
handler_load_past_session | 管理恢复过去会话、获取聊天消息,并设置 session state 变量。 |
UI 元素组函数
函数 | 高级描述 | |
---|---|---|
render_user_login_required | 显示登录提示和组件。 | |
render_bot_search | 显示助手搜索输入和“切换到 Lounge”按钮。 | |
render_bot_details | 显示助手详细信息、开始会话/寻找另一个助手按钮以及过去会话列表。 | |
render_chat_session | 显示包含用户消息输入、结束会话按钮和聊天消息的聊天视图。如果会话已结束,则显示会话回顾。 | |
render_message | 在 2 列布局中渲染用户或助手头像和聊天消息。 |
最后,session state 变量控制显示哪些 UI 元素组。
if st.session_state.user_validated != 1:
render_user_login_required()
if st.session_state.user_validated == 1 and st.session_state.bot_validated == 0:
render_bot_search()
if st.session_state.user_validated == 1 and st.session_state.bot_validated == 1 and "session_id" not in st.session_state:
render_bot_details(st.session_state.bot_info)
if st.session_state.user_validated == 1 and st.session_state.bot_validated == 1 and "session_id" in st.session_state:
render_chat_session()
3. 创建可在多个页面上复用的 UI 元素
我为 OpenAI API key 登录 UI 元素创建了一个类
这个类让我可以避免在多个页面上重新创建相同的 UI 元素来控制相同的 session state 变量。该类包含用于管理 session state 变量、渲染 UI 元素和处理 UI 操作的方法
class app_user:
# initialize session state variables and container
def __init__(self):
if 'user' not in st.session_state:
st.session_state.user = {'id':None, 'api_key':None}
if 'user_validated' not in st.session_state:
st.session_state.user_validated = None
self.container = st.container()
# renders OpenAI key input box
# "password" type masks user input
# "current-password" autocomplete gets modern browsers to remember key
def view_get_info(self):
with self.container:
st.markdown(legal_prompt)
st.markdown(" \n")
st.info(user_key_prompt)
st.text_input("Enter your OpenAI API Key", key="user_key_input",on_change=self._validate_user_info, type="password", autocomplete="current-password")
# handler that calls a backend function to get or create a user record
def _validate_user_info(self):
u = au.users()
try:
user = u.get_create_user(api_key=st.session_state.user_key_input)
self._set_info(user_id=user['id'], api_key = st.session_state.user_key_input, user_hash=user['data']['user_hash'])
st.session_state.user_validated = 1
# displays error in the container below the text input
except u.OpenAIClientCredentialError as e:
with self.container:
st.error(user_key_failed)
except u.DBError as e:
with self.container:
st.warning("Something went wrong. Please try again.")
# redners success message
def view_success_confirmation(self):
st.write(user_key_success)
每个页面都可以实例化该类并调用必要的方法。例如,在 home.py 中,会同时调用 view_get_info()
和 view_success_confirmation()
vu = vuser.app_user()
if 'user' not in st.session_state or st.session_state.user_validated != 1:
vu.view_get_info()
else:
vu.view_success_confirmation()
在 assistant.py 中,可以调用 view_get_info()
,但可以跳过 view_success_confirmation()
def render_user_login_required():
st.title("AI Assistant")
st.write("Discover other Assistants in the Lounge, or locate a specific Assistant by its personalized code.")
ac.robo_avatar_component()
vu = vuser.app_user()
vu.view_get_info()
4. 使用 Markdown 语言和静态 Streamlit 组件添加有限的样式
虽然 Streamlit 应用通常开箱即用看起来不错,但少量像素级别的调整可以带来很大的不同。在 Streamlit 中添加样式有两种方法:创建自定义组件和通过 Markdown 注入样式。然而,为了保持一致的外观,重要的是不要过度使用这些方法。
在 GPT Lab 中,我为助手头像分隔线创建了一个自定义组件
最初,这个分隔线由一个 st.columns(9)
元素组成,每列中有一个头像。这看起来很棒,但在较小屏幕分辨率下,列会垂直堆叠。天哪!在 ChatGPT 的帮助下(因为我不是前端人员),我创建了一个自定义静态组件(只包含 CSS 和 HTML 代码)
def robo_avatar_component():
robo_html = "<div style='display: flex; flex-wrap: wrap; justify-content: left;'>"
# replace with your own array of strings to seed the DiceBear Avatars API
robo_avatar_seed = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
for i in range(1, 10):
avatar_url = "<https://api.dicebear.com/5.x/bottts-neutral/svg?seed={0}>".format(robo_avatar_seed[i-1])
robo_html += "<img src='{0}' style='width: {1}px; height: {1}px; margin: 10px;'>".format(avatar_url, 50)
robo_html += "</div>"
robo_html = """<style>
@media (max-width: 800px) {
img {
max-width: calc((100% - 60px) / 6);
height: auto;
margin: 0 10px 10px 0;
}
}
</style>""" + robo_html
c.html(robo_html, height=70)
这个静态组件最多显示九个均匀分布的头像。它通过保持头像水平对齐并减少在手机分辨率下可见的头像数量来适应较小屏幕的布局。
此外,我使用 wiki Markdown 在 CTA 链接前面添加了很棒的字体图标
def st_button(url, label, font_awesome_icon):
st.markdown('<link rel="stylesheet" href="<https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css>">', unsafe_allow_html=True)
button_code = f'''<a href="{url}" target=_blank><i class="fa {font_awesome_icon}"></i> {label}</a>'''
return st.markdown(button_code, unsafe_allow_html=True)
一般来说,我建议不要过度使用 Markdown 注入 CSS 的方法,原因有三
- 这会导致应用中的字体不一致
- 它往往与依赖系统偏好的深色/浅色主题融合不佳
- 总的来说,unsafe_allow_html 让我感到紧张
5. 通过编程方式布局 Streamlit 元素
我学到的最酷的事情是 Streamlit 元素可以通过编程方式布局。🤯 这让我能够创建更复杂和定制化的用户界面
在 Lounge 中,我使用以下代码片段在双列布局中动态布局助手。对于每个助手,我通过编程方式生成一个唯一的按钮 key(以避免元素 key 冲突)
def view_bot_grid(bot_dict, button_disabled=False, show_bot_id=False):
col1, col2 = st.columns(2)
for i in range(0,len(bot_dict)):
avatar_url = "<https://api.dicebear.com/5.x/bottts-neutral/svg?seed={0}>".format(bot_dict[i]['name'])
button_label="Chat with {0}".format(bot_dict[i]['name'])
button_key="Lounge_bot_{0}".format(bot_dict[i]["id"])
if i%2 == 0:
with col1:
cola, colb = st.columns([1,5])
cola.image(avatar_url, width=50)
if show_bot_id == False:
colb.markdown(f"{bot_dict[i]['name']} - {bot_dict[i]['tag_line']}")
else:
colb.markdown(f"{bot_dict[i]['name']} - {bot_dict[i]['tag_line']} \\nAssistant ID: {bot_dict[i]['id']}")
col1.write(bot_dict[i]['description'])
if col1.button(button_label, key=button_key, disabled=button_disabled):
st.session_state.bot_info=bot_dict[i]
st.session_state.bot_validated = 1
au.switch_page('assistant')
col1.write("\\n\\n")
else:
with col2:
col2a, col2b = st.columns([1,5])
col2a.image(avatar_url, width=50)
if show_bot_id == False:
col2b.markdown(f"{bot_dict[i]['name']} - {bot_dict[i]['tag_line']}")
else:
col2b.markdown(f"{bot_dict[i]['name']} - {bot_dict[i]['tag_line']} \\nAssistant ID: {bot_dict[i]['id']}")
col2.write(bot_dict[i]['description'])
if col2.button(button_label, key=button_key, disabled=button_disabled):
st.session_state.bot_info=bot_dict[i]
st.session_state.bot_validated = 1
au.switch_page('assistant')
col2.write("\\n\\n")
6. 支持多种 OpenAI 完成端点
OpenAI 有两个文本完成(GPT Lab 的主要用例)端点:completion 和 chat。较旧的模型(text-davinci-003
和更早版本)使用前者,较新的模型(gpt-3.5-turbo
和 gpt-4
)使用后者。
Completion 端点接受一个输入字符串并返回一个预测的完成文本。可以通过连接聊天消息来模拟聊天会话
初始 prompt 消息 + stop_sequence + AI 响应 1 + restart_sequence + 用户消息 1 + stop_sequence + AI 响应 2 + restart_sequence + … + 用户消息 N + stop_sequence
stop_sequence 确保模型不会产生幻觉,并能基于用户消息进行扩展。restart_sequence 虽然不是 API 所必需的,但能确保我能知道 AI 响应何时停止。
Chat 端点接受一个聊天消息列表并返回一个预测的聊天消息。每条聊天消息都是一个字典,包含两个字段:role 和 content。有三个角色:system、user 和 assistant。初始 prompt 以“system”消息发送,而用户消息以“user”消息发送。例如
[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Who won the world series in 2020?"},
{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
{"role": "user", "content": "Where was it played?"}
]
我在我的 OpenAI 封装器类中抽象了这种复杂性,以简化应用。向应用其余部分暴露一个使用统一聊天消息数据格式的函数
def get_ai_response(self, model_config_dict, init_prompt_msg, messages):
submit_messages = [{'role':'system','message':init_prompt_msg,'current_date':get_current_time()}]+ messages
new_messages = []
bot_message = ''
total_tokens = 0
if model_config_dict['model'] in ('gpt-3.5-turbo', 'gpt-4'):
try:
response = self._get_chat_completion(model_config_dict, submit_messages)
bot_message = response['choices'][0]['message']['content']
total_tokens = response['usage']['total_tokens']
except Exception as e:
raise
else:
try:
response = self._get_completion(model_config_dict, submit_messages)
bot_message = response['choices'][0]['text']
total_tokens = response['usage']['total_tokens']
except Exception as e:
raise
new_messages = messages + [{'role':'assistant','message':bot_message.strip(),'created_date':get_current_time()}]
return {'messages':new_messages, 'total_tokens':total_tokens}
根据模型不同,请求会被发送到不同的端点。这是创建 Completion 调用的内部函数
def _get_completion(self, model_config_dict, messages):
model_config_validated = self._validate_model_config(model_config_dict)
oai_message = self._messages_to_oai_prompt_str(messages)
if model_config_validated:
get_completion_call_string = (
"""openai.Completion.create(
model="{0}",
prompt="{1}",
temperature={2},
max_tokens={3},
top_p={4},
frequency_penalty={5},
presence_penalty={6},
stop=['{7}']
)""").format(
model_config_dict['model'],
oai_message,
model_config_dict['temperature'],
model_config_dict['max_tokens'],
model_config_dict['top_p'],
model_config_dict['frequency_penalty'],
model_config_dict['presence_penalty'],
self.stop_sequence
)
try:
completions = self._invoke_call(get_completion_call_string)
return completions
except Exception as e:
raise
else:
if not model_config_validated:
raise self.BadRequest("Bad Request. model_config_dict missing required fields")
它使用一个映射函数将字典列表转换为模型期望的连接字符串
def _messages_to_oai_prompt_str(self, messages):
msg_string = ""
for message in messages:
if message['role'] == 'user' or message['role'] == 'system':
msg_string += message['message'].replace("\\"","'") + self.stop_sequence
else:
msg_string += message['message'].replace("\\"","'") + self.restart_sequence
return msg_string
像这样的抽象使我能够简化对 OpenAI 端点的上游调用。
7. 保护 AI 助手免受潜在的 prompt 注入攻击
GPT Lab 的价值主张之一是用户可以分享他们的助手,而无需分享其确切的 prompt(创建一个好的、可重复的 prompt 需要时间——完善初始的 Coach Marlon prompt 花了大约一周)。
出于安全考虑,初始 prompt 不与会话消息的其余部分一起存储。此外,我会向量化每个 AI 助手响应,并计算它与初始 prompt 的余弦相似度分数。分数达到 0.65 或更高时,会将 AI 响应替换为通用回复。这有助于我们确保 AI 助手不会被诱骗暴露其秘密指令(Bing?Sydney?😅)。
有许多方法可以对文本字符串进行向量化,包括 OpenAI 的嵌入 API。我选择使用 scikit-learn 的 TfidfVectorizer 来向量化文本字符串。这个类很轻量(防止 Streamlit 应用膨胀),取得了不错的结果,并为用户节省了 OpenAI 的费用
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
def get_cosine_similarity(str1, str2):
# Create a TfidfVectorizer object
corpus = [str1, str2]
vect = TfidfVectorizer(min_df=1,stop_words='english')
tfidf = vect.fit_transform(corpus)
# Compute the cosine similarity between the two vectors
cos_sim = cosine_similarity(tfidf[0], tfidf[1])[0][0]
return cos_sim
值得注意的是,虽然这里描述的方法将为大多数 prompt 提供足够的保护,但它不会消除所有可能的 prompt 注入攻击。例如,将初始 prompt 与部分响应进行比较(由用于 Bing 的“给我前五句话”这种 prompt 注入攻击方法暴露)。此外,prompt 注入攻击的防御仍在积极研究中。
8. 压缩聊天会话
默认情况下,OpenAI 大型语言模型只能处理有限数量的 token(旧模型为 2K,text-davinci-003 为 4K,基础 GPT-4 模型为 8K+)。为了确保用户流畅的聊天体验(以及更长的聊天会话不会达到模型最大 token 限制),我实现了两种简单而有效的会话截断方法
- 头脑风暴助手会持续保留最后 20 条消息(因此助手会逐渐忘记对话前期的话题)。
- 一旦聊天消息会话超过模型最大 token 限制的 60%,指导助手将自动总结会话(使用总结 prompt),并使用截至此时的总结以及最后四条消息开始一个新线程。这种方法保持了对话的连贯性。
实现这些方法很简单。我鼓励您开发自己的实现,而不是提供确切的代码。
9. 保护用户隐私
确保用户隐私是 GPT Lab 的基本原则,贯穿于整个设计中。
用户在系统中仅通过其 OpenAI API key 的单向哈希值(使用 x 次迭代的 SHA-256 PBKDF2)来标识。这确保了他们在平台内的完全保密性和安全性。他们的 API key 仅作为 session state 变量存储,并在他们访问期间用于与 OpenAI 模型交互。
此外,我曾考虑是否将会话消息存储在数据库中。最终,我决定保留它们,以便用户可以重访(并可能恢复)过去的聊天会话。虽然 GPT Lab 不收集任何用户信息,但聊天会话中仍然可能包含个人身份信息 (PII) 甚至个人健康信息 (PHI)。为了确保用户隐私,我使用了 Fernet 加密(AES-128),并使用用户特定的密钥(他们的 OpenAI API key 的单向哈希值与全局 salt 值组合)来加密和解密会话消息,然后在数据库中存储和检索。
10. 分离开发数据库和生产数据库
我创建了两个数据库——一个用于开发和测试,一个用于生产。当我在本地开发时,我将本地的 secrets.toml
文件指向开发数据库。对于生产环境,我将 secrets.toml
指向生产数据库。这种方法让我可以在生产环境中准确衡量平台指标,并自由地尝试本地 schema 更改,而不必担心影响整体用户体验。
11. 托管选项和注意事项
我考虑了两种托管选项:Streamlit Community Cloud 和 Google Cloud Run。
我很欣赏 Streamlit Community Cloud 的简洁性(特别是持续部署方面),但它有每个应用 1GB 的限制,不支持自定义域名,并且没有提供关于它可以处理的并发用户数量的明确答案。
所以我尝试部署到 Google Cloud Run。为了使其工作,我做了一些不同的事情
- 移除了 streamlit-chat 组件(我无法让 React 组件加载。此外,React 组件不渲染 Markdown,而助手偶尔会返回 Markdown)。
- 使用 OS 环境变量存储数据库服务账号 JSON(而不是
secrets.toml
)。 - 在目录中创建了 Docker 文件
FROM python:3.10-slim
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./
RUN pip install -r requirements.txt
EXPOSE 8080
CMD streamlit run --server.port 8080 --browser.gatherUsageStats false --server.enableWebsocketCompression true home.py
- 设置了从我的 GitHub 仓库进行的持续部署(我设置了一个连接到仓库 main 分支的 Cloud Build Trigger,然后将这个 Cloud Build Trigger 连接到 Cloud Run 服务)。这里是参考文档。
最后,我决定使用 Streamlit Community Cloud 以最小化整体项目成本。此外,根据上述实验,1GB 对于应用的使用是足够的。
12. 作为独立开发者保护自己
虽然使用 Streamlit 创建应用很容易,但在将应用公开之前,务必考虑其潜在影响。考虑到大型语言模型的不可预测性以及任何人都可以创建关于任何主题的助手,我将 GPT Lab 的风险等级评定为相对较高。为了保护自己免受潜在问题的影响,我花时间起草了使用条款并成立了一家 LLC。虽然 GPT Lab 可能属于极端情况,但这里的教训适用于所有独立开发者。在公开任何应用之前,进行快速风险评估,以确定您的用例是否需要额外的预防措施。
总结
在过去三个月里,我学到了很多关于 OpenAI 的知识,并成功证明了使用 Streamlit 构建一个相当复杂的应用是可能的。虽然还有改进空间,但 GPT Lab 提供了一个视角,展示了 Streamlit 如何创建动态且相互连接的多页面应用。希望您喜欢这篇文章。请在 Twitter 或 Linkedin 上与我联系。我很乐意听到您的声音。
Streamlit 愉快!🎈
评论
在我们的论坛中继续对话 →