用Streamlit给AI应用套个界面,10行代码出Web页面

3 阅读8分钟

前7篇搭了RAG、跑了Ollama、写了Agent、用了Dify——全是在终端里跑的。有读者问: "能不能有个界面?给老板演示总不能让他看终端吧?"

确实,终端里一堆日志输出,技术人员看着亲切,非技术人员看着懵。你跟老板说"你看这个AI回答多准",他看到的是满屏绿色的>>> Entering new AgentExecutor chain...

Streamlit就是干这个的——Python写的AI应用,10行代码变Web页面。 不用写HTML、不用写CSS、不用写JavaScript,纯Python搞定。

我花了一个下午把之前几篇文章的应用都套上了Streamlit界面,踩了3个坑。这篇把全过程写出来。


先说结论

维度终端运行Streamlit
交互方式命令行输入/输出网页聊天框
给老板看❌ 看不懂✅ 像产品
给客户用❌ 不会用终端✅ 打开浏览器就能用
开发量0(本身就是终端运行的)10-50行额外代码
前端知识不需要不需要

用Java人的理解:Streamlit ≈ 不用写前端,后端直接输出页面。比Thymeleaf还简单——Thymeleaf至少还得写HTML模板,Streamlit连模板都不用。


最简示例:10行代码出页面

# app.py
import streamlit as st
​
st.title("智能问答助手")
st.write("基于RAG的文档问答系统,输入问题开始提问")
​
question = st.text_input("你的问题:")
if question:
    st.write(f"AI回答:这是一个示例回答,实际使用时接入你的RAG链即可。")

运行:

streamlit run app.py

浏览器自动打开 http://localhost:8501,一个带标题、输入框、输出的网页就出来了。

10行代码,零前端知识。 这就是Streamlit。


实战:把第3篇的RAG应用套上界面

第3篇的终端版回顾

# 终端版:命令行一问一答
while True:
    q = input("\n问:")
    if q.lower() == "q":
        break
    print(f"答:{rag_chain.invoke(q)}")

改成Streamlit版

# app.py — RAG问答Web版
import streamlit as st
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
​
# ============ 页面配置 ============
st.set_page_config(page_title="文档问答助手", page_icon="📖")
st.title("📖 文档问答助手")
st.caption("基于RAG,让AI读懂你的文档")
​
# ============ 初始化(只跑一次) ============
@st.cache_resource
def init_rag():
    API_KEY = "你的API Key"
    BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
​
    embeddings = OpenAIEmbeddings(model="text-embedding-v3", base_url=BASE_URL, api_key=API_KEY)
    vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
​
    model = ChatOpenAI(model="qwen-plus", base_url=BASE_URL, api_key=API_KEY)
    prompt = ChatPromptTemplate.from_template(
        """严格基于以下参考内容回答问题。如果参考内容中没有相关信息,回答"根据现有文档,无法回答该问题"。不要编造。
​
参考内容:
{context}
​
问题:{question}"""
    )
​
    retriever = vectorstore.as_retriever(search_type="mmr", search_kwargs={"k": 3, "fetch_k": 10})
    rag_chain = (
        {"context": retriever | (lambda docs: "\n\n".join(d.page_content for d in docs)), "question": RunnablePassthrough()}
        | prompt | model | StrOutputParser()
    )
    return rag_chain
​
rag_chain = init_rag()
​
# ============ 对话界面 ============
if "messages" not in st.session_state:
    st.session_state.messages = []
​
# 显示历史对话
for msg in st.session_state.messages:
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])
​
# 输入框
if question := st.chat_input("输入你的问题:"):
    # 显示用户消息
    st.session_state.messages.append({"role": "user", "content": question})
    with st.chat_message("user"):
        st.markdown(question)
​
    # 生成AI回答
    with st.chat_message("assistant"):
        with st.spinner("思考中..."):
            answer = rag_chain.invoke(question)
        st.markdown(answer)
​
    st.session_state.messages.append({"role": "assistant", "content": answer})

运行:

streamlit run app.py

效果: 左上角标题"文档问答助手",中间聊天框,底部输入框,像ChatGPT一样的对话体验。历史记录保存在session里,刷新清零。

和终端版的核心区别只有一个: input()st.chat_input()print()st.markdown()


坑1:每次输入都重新初始化RAG,等30秒

翻车现场

我第一次写的时候没有加@st.cache_resource

# 每次用户输入问题,都重新初始化
rag_chain = init_rag()  # 加载向量库+构建链,耗时20-30秒
​
if question := st.chat_input("输入你的问题:"):
    answer = rag_chain.invoke(question)

结果:用户每输入一个问题,页面卡30秒——因为Streamlit每次交互都会重新执行整个脚本,向量库每次都重新加载。

正确做法:用@st.cache_resource缓存

@st.cache_resource  # 关键:只初始化一次,后续复用
def init_rag():
    # ... 加载向量库、构建链
    return rag_chain
​
rag_chain = init_rag()  # 第一次慢,之后秒加载

Streamlit的缓存机制有三个装饰器,必须搞清楚:

装饰器缓存什么什么时候用
@st.cache_resource对象(模型、向量库、连接池)AI应用必备,初始化只跑一次
@st.cache_data数据(DataFrame、列表、字典)数据查询结果缓存
不加每次重新执行默认行为,别用在这里

用Java人的理解:@st.cache_resource ≈ Spring的@Bean单例模式,@st.cache_data@Cacheable查询缓存。不加缓存 ≈ 每次请求new一个对象——能跑但效率极低。


坑2:流式输出不会写,等AI全部生成完才显示

翻车现场

大模型生成回答通常要5-15秒,如果等全部生成完再显示,用户体验很差:

# 等全部生成完才显示
answer = rag_chain.invoke(question)  # 卡5-15秒
st.markdown(answer)  # 一次性全出来

用户看到的效果:输入问题 → 等待15秒 → 突然冒出一大段文字。像死机了一样。

正确做法:用st.write_stream逐字显示

# 流式输出:逐字显示,像ChatGPT一样
with st.chat_message("assistant"):
    def stream_response():
        for chunk in rag_chain.stream(question):
            yield chunk
    response = st.write_stream(stream_response)
​
st.session_state.messages.append({"role": "assistant", "content": response})

效果: 输入问题 → 1-2秒后开始逐字显示,像ChatGPT一样的打字效果。

关键改动:

  • rag_chain.invoke()rag_chain.stream() — LangChain的流式接口
  • st.markdown()st.write_stream() — Streamlit的流式输出

两个都得换,少换一个都不行。


坑3:侧边栏放配置,主区域放对话,布局一搞就乱

翻车现场

我把所有东西都堆在主区域:

[标题]
[API Key输入框]
[模型选择下拉框]
[PDF上传按钮]
[对话区域]
[输入框]

一堆配置项和对话混在一起,用户分不清哪里是设置、哪里是聊天。

正确做法:侧边栏放配置,主区域只留对话

# ============ 侧边栏:配置区 ============
with st.sidebar:
    st.header("⚙️ 配置")
​
    model_name = st.selectbox(
        "选择模型",
        ["qwen-plus", "qwen-turbo", "qwen-max"],
        index=0,
    )
​
    temperature = st.slider(
        "Temperature",
        min_value=0.0, max_value=1.0, value=0.1, step=0.1,
        help="越低越确定,越高越随机",
    )
​
    st.divider()
​
    # 上传文档
    uploaded_file = st.file_uploader("上传文档", type=["pdf"])
    if uploaded_file:
        with st.spinner("正在处理文档..."):
            # 处理上传的PDF,重建向量库
            process_pdf(uploaded_file)
        st.success("文档处理完成!")
​
    st.divider()
​
    # 清空对话
    if st.button("🗑️ 清空对话"):
        st.session_state.messages = []
        st.rerun()
​
    st.divider()
    st.caption("Java后端转大模型系列 · 荣码")
​
# ============ 主区域:对话区 ============
st.title("📖 文档问答助手")
# ... 对话逻辑

布局原则:

区域放什么原因
侧边栏配置、设置、上传不干扰主体验,需要时展开
主区域标题 + 对话 + 输入用户90%时间在这里

用Java人的理解:这和前后端分离一个道理——侧边栏是"设置页面",主区域是"主业务页面"。别把管理功能和用户功能混在一起。


完整代码:RAG问答Web应用(含侧边栏+流式输出)

"""
Streamlit RAG问答应用 — Web界面版
运行:streamlit run app.py
依赖:pip install streamlit langchain langchain-openai langchain-chroma langchain-community pypdf
"""
import streamlit as st
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
​
# ============ 页面配置 ============
st.set_page_config(page_title="文档问答助手", page_icon="📖", layout="wide")
​
# ============ 侧边栏 ============
with st.sidebar:
    st.header("⚙️ 配置")
​
    model_name = st.selectbox("模型", ["qwen-plus", "qwen-turbo", "qwen-max"], index=0)
    temperature = st.slider("Temperature", 0.0, 1.0, 0.1, 0.1)
​
    st.divider()
    st.caption("📚 文档已预加载")
    st.caption("Java后端转大模型系列 · 荣码")
​
    if st.button("🗑️ 清空对话"):
        st.session_state.messages = []
        st.rerun()
​
# ============ 初始化RAG ============
API_KEY = "你的API Key"
BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"@st.cache_resource
def init_rag(_model_name, _temperature):
    embeddings = OpenAIEmbeddings(model="text-embedding-v3", base_url=BASE_URL, api_key=API_KEY)
    vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
    model = ChatOpenAI(model=_model_name, base_url=BASE_URL, api_key=API_KEY, temperature=_temperature, streaming=True)
    prompt = ChatPromptTemplate.from_template(
        """严格基于以下参考内容回答问题。如果参考内容中没有相关信息,回答"根据现有文档,无法回答该问题"。不要编造。
​
参考内容:
{context}
​
问题:{question}"""
    )
    retriever = vectorstore.as_retriever(search_type="mmr", search_kwargs={"k": 3, "fetch_k": 10})
    rag_chain = (
        {"context": retriever | (lambda docs: "\n\n".join(d.page_content for d in docs)), "question": RunnablePassthrough()}
        | prompt | model | StrOutputParser()
    )
    return rag_chain
​
rag_chain = init_rag(model_name, temperature)
​
# ============ 对话界面 ============
st.title("📖 文档问答助手")
st.caption("基于RAG,让AI读懂你的文档 · 换模型和参数请在左侧调整")
​
if "messages" not in st.session_state:
    st.session_state.messages = []
​
for msg in st.session_state.messages:
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])
​
if question := st.chat_input("输入你的问题:"):
    st.session_state.messages.append({"role": "user", "content": question})
    with st.chat_message("user"):
        st.markdown(question)
​
    with st.chat_message("assistant"):
        def stream_response():
            for chunk in rag_chain.stream(question):
                yield chunk
        response = st.write_stream(stream_response)
​
    st.session_state.messages.append({"role": "assistant", "content": response})

和终端版相比,新增代码不到30行,但体验提升10倍。

运行:

streamlit run app.py
# 自动打开浏览器 http://localhost:8501

3个坑的总结

#错误做法正确做法一句话
1每次输入重新初始化不加缓存@st.cache_resource等于Spring的@Bean单例
2等全部生成完才显示invoke+markdownstream+write_stream逐字输出才像ChatGPT
3配置和对话混在一起全堆主区域侧边栏放配置,主区域放对话设置是设置,聊天是聊天

Streamlit能做的远不止聊天界面

这篇只讲了最常用的聊天界面,Streamlit还能做这些:

功能代码量效果
数据表格展示st.dataframe(df)可排序、可搜索的表格
图表可视化st.line_chart(data)折线图、柱状图、散点图
文件上传st.file_uploader()拖拽上传,支持多种格式
多页面应用多个.py文件侧边栏自动生成导航
部署分享streamlit run 或 Streamlit Cloud免费云部署

我接下来的计划: 把第5篇的Embedding对比做成可视化Dashboard——选5个模型,跑测试,结果自动出图表。比Markdown里的表格直观10倍。


不想写Streamlit?还有一个更快的方案

如果你觉得Streamlit还要写代码,第7篇的Dify自带Web界面,发布就有一个可分享的链接。

方案代码量自定义程度适合场景
Dify发布0行快速给非技术人员用
Streamlit30-100行需要自定义界面和交互
前后端分离1000+行极高正式产品

我的选择:内部验证用Dify,对外展示用Streamlit,正式上线写前后端。 三个层次,按需选择。


你用过Streamlit吗?有什么好用的组件推荐?评论区聊聊 👇