1. 项目概述:用 Streamlit + LangChain 快速搭出能“说话”的 AI 界面,不是 demo,是能直接上线的最小可行产品
你有没有过这种经历:花三天调通了一个 RAG 流程,本地跑通了,向量检索准、LLM 回答稳,但一到给老板或客户演示,就得打开终端、粘贴命令、手动输入问题——对方眼睛瞬间就飘了。或者更糟:你把整个 pipeline 封装成 Flask API,再配 Nginx、写前端 HTML+JS,结果光是解决跨域和 CORS 就耗掉一整天,而核心逻辑只占代码量的 15%。这就是传统 Web 开发在 AI 应用落地时最真实的“效率断层”。而这个标题里提到的组合——Streamlit和LangChain——本质上不是教你怎么“写网页”,而是提供了一套专为 AI 工程师设计的“界面加速器”:它把 UI 构建的复杂度从“全栈开发”压缩到“函数式声明”,把部署门槛从“运维知识”拉低到“一个 pip install 和一行命令”。我去年带团队做内部知识助手时,用这套方案把从模型验证到可分享链接的全流程,从原计划的 5 天压到了 8 小时。关键不在于炫技,而在于它天然适配 AI 工作流的三个核心特征:状态弱(用户每次提问都是新会话)、交互轻(不需要复杂表单校验)、反馈快(响应延迟敏感,UI 必须即时反映 loading/完成状态)。Streamlit 的st.chat_message不是简单的 div 渲染,它背后绑定了完整的会话状态管理;LangChain 的RunnableWithMessageHistory也不是语法糖,它是把 LLM 的上下文记忆能力,直接映射成了 Python 可调用的对象。这两者叠加,等于把“让 AI 能对话”这件事,从需要 3 个角色协作(算法工程师写 prompt、后端写 API、前端写页面)的工程任务,变成一个人用不到 200 行 Python 就能交付的独立应用。它适合谁?不是给 UI 设计师练手的玩具,而是给数据科学家、算法工程师、甚至懂点 Python 的业务分析师用的“生产力杠杆”——你不需要知道 React 的生命周期,但必须清楚自己 prompt 里的 system message 是怎么影响回答风格的;你不用配置 Webpack,但得明白为什么st.cache_resource要放在 LLM 初始化外面,而st.cache_data要包住向量库加载。这篇文章不讲“Streamlit 是什么”,也不罗列 LangChain 所有模块,只聚焦一件事:当你手头有一个可用的 LLM 接口(本地 Ollama、OpenAI、或自托管的 vLLM),如何用最短路径、最少代码、最高容错率,把它变成一个真正能被非技术人员点击、输入、获得答案的界面。所有步骤我都实测过,参数值来自我们线上运行 7 个月的生产环境配置,不是教程里的默认值。
2. 核心架构拆解:为什么是 Streamlit + LangChain,而不是 Flask + FastAPI + 自己写状态管理?
2.1 不是“选框架”,而是“选工作流匹配度”
很多人第一反应是:“我用 Flask 更熟,为啥要换?” 这是个好问题,但答案不在框架本身,而在 AI 应用的交互范式上。我们来对比真实场景中的三个硬性约束:
状态管理成本:AI 对话本质是“多轮上下文依赖”,但每轮又相对独立。Flask 默认无状态,你要自己存 Redis、加 session key、处理过期、防并发冲突。而 Streamlit 内置
st.session_state,它不是一个全局变量,而是一个与当前浏览器标签页强绑定的、自动序列化/反序列化的字典对象。你写st.session_state.messages.append({"role": "user", "content": "你好"}),下次用户点刷新,这个列表还在;换一个标签页,就是全新的空列表。这背后是 Streamlit 在服务端为每个会话维护了一个独立的 Python 进程上下文,不是靠 cookie 或 token 模拟,是真隔离。LangChain 的ConversationBufferMemory或PostgresChatMessageHistory是为“长期记忆”设计的,比如客服机器人要记住用户上周投诉过什么;而 Streamlit 的session_state是为“本次会话”设计的,它轻、快、零配置。两者分工明确:LangChain 管跨请求的持久记忆(可选),Streamlit 管单次会话的瞬时状态(必选)。UI 响应粒度:AI 推理是“长耗时+流式输出”,用户需要看到“思考中…”、“正在检索…”、“逐字生成…”的实时反馈。Flask 返回 JSON 后,前端必须用 JS 监听 SSE 或轮询,还要处理 loading 状态切换、错误重试、流式 chunk 拼接。Streamlit 的
st.chat_input+st.chat_message组合,天然支持st.write_stream,你传入一个生成器函数(比如chain.stream({"input": user_input})),它会自动把每个 yield 出来的 token 渲染成打字效果,中间任何报错都会被捕获并显示在界面上,无需额外写 error boundary。我测试过,当 LLM 响应时间超过 8 秒时,Flask 方案的前端 JS 很容易因超时中断连接,而 Streamlit 的底层 WebSocket 会话保持机制更鲁棒。热重载与迭代速度:算法工程师改 prompt,需要立刻看到效果。Flask 改完 Python 文件要手动
kill -HUP或重启 gunicorn;Streamlit 只需保存文件,浏览器自动刷新,且保留当前session_state(可配置)。我们团队每天平均修改 prompt 12 次,这个特性省下的时间,够多跑 3 轮 A/B 测试。
提示:LangChain 的
Runnable接口设计,是这套组合能成立的关键前提。它把 LLM 调用、prompt 格式化、输出解析、错误处理全部封装成一个可.invoke()、.stream()、.batch()的统一对象。Streamlit 不关心你内部是调 OpenAI 还是本地 llama.cpp,只要它符合 Runnable 协议,就能无缝接入。这不是巧合,是 LangChain 2.0 重构时,就瞄准了 Streamlit、Gradio 这类快速原型工具做的深度适配。
2.2 架构分层:三层职责,绝不越界
我把最终应用拆成清晰的三层,每层只做一件事,且边界绝对分明:
表现层(Streamlit):只负责“画什么”和“什么时候画”。它定义页面结构(侧边栏参数、主聊天区、文件上传区)、触发事件(按钮点击、输入提交)、渲染结果(消息气泡、图表、表格)。它绝不包含任何业务逻辑:不拼接 prompt,不调用 LLM,不处理向量检索。它的核心变量只有两个:
st.session_state.messages(存储对话历史)和st.session_state.chain(存储已构建好的 Runnable 对象)。编排层(LangChain):只负责“怎么连”和“怎么走”。它把 LLM、PromptTemplate、Retriever、OutputParser 这些组件,用
|操作符串成一条可执行流水线。例如retriever | format_docs | prompt | llm | output_parser。这一层完全脱离 UI,可以单独单元测试:给定一个 query,检查返回的 docs 是否相关,检查 prompt 渲染是否正确,检查 LLM 输出是否被 parser 正确提取。我们要求所有 LangChain 代码必须通过pytest验证,确保chain.invoke({"input": "xxx"})在 CLI 下能稳定返回预期结构。数据层(外部服务):只负责“存什么”和“取什么”。包括向量数据库(Chroma、PGVector)、LLM API(OpenAI、Ollama、Together)、文档存储(S3、本地文件系统)。这一层对前两层完全透明,通过环境变量注入连接信息。比如
os.getenv("OLLAMA_BASE_URL"),而不是硬编码http://localhost:11434。这样,开发时用 Ollama,上线切到 vLLM,只需改一个环境变量,LangChain 流水线和 Streamlit 页面完全不动。
这种分层不是教条,而是血泪教训。我们最早版本把向量检索逻辑写在 Streamlit 的on_click回调里,结果用户连续点两次按钮,就触发了两次并发检索,导致 Chroma 报sqlite busy错误。后来把检索抽成独立的Retriever类,由 LangChain 统一管理生命周期,问题消失。分层的本质,是让每个模块的变更影响范围可控:改 UI 不动业务逻辑,调优 prompt 不改前端,换向量库不碰聊天界面。
2.3 为什么不用 Gradio?一个被低估的现实权衡
Gradio 也是热门选择,但它和 Streamlit 的定位差异,在于“谁在用”和“用多久”。Gradio 的强项是极简 API 包装:一个函数,三行代码,生成界面。但它默认的 UI 是“表单式”的,适合单次输入输出(比如图像分类、文本摘要),对多轮对话的支持是后期补的,ChatInterface组件的定制性远不如 Streamlit 的st.chat_message灵活。更重要的是部署:Gradio 默认生成的 share link 是临时的,要长期可用必须自己搭服务器;而 Streamlit Cloud(免费版)直接提供 HTTPS 域名、自动 SSL、流量监控,我们内部知识库上线第一天,就通过streamlit cloud deploy一键发布,链接发到企业微信,所有人立刻能用。当然,Gradio 在 Hugging Face Spaces 上集成更好,如果你的目标用户是开发者社区,那另当别论。但如果你的用户是销售、HR、一线工程师——他们只想点开链接、输入问题、得到答案——Streamlit 的“开箱即用”体验,胜过 Gradio 的“极简抽象”。
3. 核心细节解析:从零开始搭建,每一步背后的“为什么”和“踩过的坑”
3.1 环境准备:最小依赖集,拒绝“pip install langchain”式灾难
LangChain 官方文档建议pip install langchain,但这会安装全部集成模块,包括你永远用不到的langchain-google-genai、langchain-aws,总依赖超过 200 个包,构建 Docker 镜像时经常失败。我们必须精准安装,只取所需:
# 基础核心(必装) pip install langchain-core langchain-text-splitters langchain-chroma # LLM 接入(按需选一个) pip install langchain-openai # OpenAI pip install langchain-ollama # Ollama(本地模型) pip install langchain-together # Together AI # 向量库(按需选一个) pip install chromadb # Chroma(轻量,适合开发/小规模) pip install pgvector # PGVector(PostgreSQL,适合生产) # Streamlit(必装) pip install streamlit注意:
langchain这个“大包”已被官方标记为 deprecated,LangChain 2.0 之后,推荐按功能模块安装。我们线上环境用的是langchain-core==0.3.1+langchain-ollama==0.2.0,这两个版本兼容性最好,st.cache_resource能正确缓存 Ollama LLM 实例。曾试过langchain-ollama==0.1.0,在并发请求下会出现 connection reset,升级后解决。
Python 版本锁定在3.10或3.11。3.12对某些底层 C 扩展(如chromadb的 sqlite3 binding)支持不稳定,我们在 CI 中强制python:3.11-slim基础镜像。
3.2 Streamlit 页面骨架:12 行代码撑起整个 UI
不要被“构建 UI”吓到,Streamlit 的页面就是一个 Python 脚本,从上到下执行。这是我们的app.py最简骨架(已删减注释,实际代码含详细说明):
import streamlit as st from langchain_core.messages import AIMessage, HumanMessage # 1. 页面配置(必须放在最开头) st.set_page_config( page_title="AI 知识助手", page_icon="🤖", layout="centered", # 对话界面居中更友好 initial_sidebar_state="expanded" ) # 2. 初始化会话状态(关键!必须在任何 UI 组件之前) if "messages" not in st.session_state: st.session_state.messages = [ AIMessage(content="你好!我是公司知识助手,可以帮你查产品文档、流程规范、技术FAQ。请直接提问。") ] # 3. 侧边栏:参数控制区(不影响主流程,但提升专业感) with st.sidebar: st.header("⚙️ 配置") model_name = st.selectbox("选择模型", ["llama3:8b", "qwen2:7b", "gpt-4o-mini"]) temperature = st.slider("创意度 (temperature)", 0.0, 1.0, 0.3, 0.1) top_k = st.number_input("检索文档数 (top_k)", 1, 10, 3) # 4. 主聊天区:消息渲染 for msg in st.session_state.messages: st.chat_message(msg["role"]).write(msg["content"]) # 5. 输入框:触发核心逻辑 if user_input := st.chat_input("输入你的问题..."): # 添加用户消息到状态 st.session_state.messages.append({"role": "user", "content": user_input}) st.chat_message("user").write(user_input) # 这里是占位,真正的调用在下一节 with st.chat_message("assistant"): response = "正在思考..." # 占位,实际会被 replace st.write(response)这段代码的精妙之处在于执行顺序:st.set_page_config必须第一行,否则报错;st.session_state初始化必须在任何st.*组件之前,否则第一次访问会丢失初始消息;st.chat_input的:=海象运算符是 Python 3.8+ 特性,它先赋值再判断,避免用户点空输入框触发逻辑。我们曾把st.session_state初始化放在st.chat_input后面,导致新用户首次访问时,messages是空列表,没有欢迎语——这个 bug 花了 2 小时才定位到执行顺序问题。
3.3 LangChain 流水线构建:从“能跑”到“跑得稳”的三次迭代
第一次迭代:基础 RAG(能跑,但不准)
from langchain_ollama import ChatOllama from langchain_chroma import Chroma from langchain_core.prompts import ChatPromptTemplate from langchain_text_splitters import RecursiveCharacterTextSplitter # 加载向量库(开发时用本地 Chroma) vectorstore = Chroma( persist_directory="./chroma_db", embedding_function=... # 省略,实际用 OllamaEmbeddings ) # 构建最简流水线 llm = ChatOllama(model=model_name, temperature=temperature) retriever = vectorstore.as_retriever(search_kwargs={"k": top_k}) # Prompt 写死,简单粗暴 prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个专业的知识助手,根据提供的上下文回答问题。如果上下文没提到,就说不知道。"), ("human", "{input}") ]) # 组装 chain = retriever | prompt | llm问题很快暴露:用户问“报销流程怎么走?”,检索回来的文档是《2023 年差旅报销细则》,但 LLM 回答却大段复述细则原文,没提炼步骤。原因是 prompt 太弱,没告诉 LLM “要分步骤回答”。这是典型的新手陷阱:以为 RAG = 检索 + LLM,忽略了 prompt 的引导力。
第二次迭代:结构化 Prompt + 输出解析(跑得准,但慢)
我们升级 prompt,并加入StrOutputParser强制格式:
# 更强的 system prompt system_prompt = """你是一个严谨的知识助手。请严格按以下规则回答: 1. 先确认问题是否在提供的上下文中。 2. 如果是,用中文分步骤(1. 2. 3.)清晰列出操作流程,每步不超过 20 字。 3. 如果否,只回答“根据现有资料,无法确定,请联系XX部门”。 4. 禁止添加任何解释性文字、免责声明或额外信息。""" prompt = ChatPromptTemplate.from_messages([ ("system", system_prompt), ("human", "上下文:{context}\n\n问题:{input}") ]) # 关键:加入 context 注入 def format_docs(docs): return "\n\n".join([d.page_content for d in docs]) # 新流水线 chain = ( {"context": retriever | format_docs, "input": lambda x: x["input"]} | prompt | llm | StrOutputParser() )效果立竿见影,回答准确率从 65% 提升到 92%。但新问题来了:每次提问都要重新加载向量库和 LLM,冷启动慢。用户第一次提问等 5 秒,体验极差。
第三次迭代:资源缓存 + 状态管理(跑得稳,且快)
这才是生产级的关键:
import os from langchain_ollama import ChatOllama from langchain_chroma import Chroma from langchain_community.embeddings import OllamaEmbeddings # ✅ 用 st.cache_resource 缓存 LLM 和向量库(进程级单例) @st.cache_resource def get_llm(): return ChatOllama( model=os.getenv("OLLAMA_MODEL", "llama3:8b"), temperature=float(os.getenv("TEMPERATURE", "0.3")), base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") ) @st.cache_resource def get_vectorstore(): return Chroma( persist_directory="./chroma_db", embedding_function=OllamaEmbeddings( model=os.getenv("EMBEDDING_MODEL", "nomic-embed-text"), base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") ) ) # ✅ 用 st.cache_data 缓存向量库的检索器(因为 as_retriever() 是轻量操作) @st.cache_data def get_retriever(top_k: int): return get_vectorstore().as_retriever(search_kwargs={"k": top_k}) # ✅ 在页面顶部初始化,而非每次调用 llm = get_llm() vectorstore = get_vectorstore() # ✅ 在用户输入后,动态构建 chain(因为 top_k 可变) retriever = get_retriever(top_k) chain = ( {"context": retriever | format_docs, "input": lambda x: x["input"]} | prompt | llm | StrOutputParser() ) # ✅ 调用时用 stream,实现打字效果 with st.chat_message("assistant"): response = st.write_stream(chain.stream({"input": user_input})) st.session_state.messages.append({"role": "assistant", "content": response})st.cache_resource和st.cache_data的区别必须吃透:cache_resource缓存的是不可序列化的资源对象(如 LLM 连接、数据库连接池),它在 Streamlit 服务启动时创建一次,所有会话共享;cache_data缓存的是可序列化的数据或轻量对象(如检索器、预处理后的文本),它按参数哈希缓存,get_retriever(3)和get_retriever(5)是两个独立缓存。我们曾误用cache_data缓存 LLM,导致并发时出现 connection closed 错误——因为cache_data会 pickle/unpickle 对象,而网络连接不能被序列化。
3.4 文件上传与向量化:让用户自己喂数据,不是只读“知识库”
很多教程停在“已有向量库”,但真实场景是:销售要上传一份新合同模板,HR 要导入最新员工手册 PDF。我们必须支持动态上传、解析、向量化。核心是st.file_uploader+PyPDFLoader+Chroma.add_documents:
from langchain_community.document_loaders import PyPDFLoader from langchain_text_splitters import RecursiveCharacterTextSplitter # 侧边栏添加上传区 with st.sidebar: st.subheader("📁 上传文档") uploaded_files = st.file_uploader( "支持 PDF/TXT/MD", type=["pdf", "txt", "md"], accept_multiple_files=True ) # 处理上传(放在页面底部,避免阻塞 UI) if uploaded_files: documents = [] for file in uploaded_files: if file.name.endswith(".pdf"): loader = PyPDFLoader(file) elif file.name.endswith(".txt"): # 简单文本处理 documents.append(Document(page_content=file.getvalue().decode(), metadata={"source": file.name})) continue else: continue docs = loader.load() for doc in docs: doc.metadata["source"] = file.name # 记录来源 documents.extend(docs) # 分块(关键参数!) text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 不是越大越好!LLM 上下文有限,500 字符约 150 token chunk_overlap=50, # 重叠 10%,避免语义断裂 length_function=len, is_separator_regex=False, ) splits = text_splitter.split_documents(documents) # ✅ 向量库追加(注意:不是重建!) vectorstore.add_documents(splits) st.sidebar.success(f"✅ 已添加 {len(splits)} 个文本块")这里有两个魔鬼细节:
chunk_size=500是我们实测最优值。设成 1000,检索时可能把“报销流程”和“请假流程”混在一个块里,LLM 无法精准定位;设成 200,又导致信息碎片化,LLM 看不到完整上下文。我们用 100 份真实文档做了 A/B 测试,500 的召回准确率最高。vectorstore.add_documents()是追加,不是覆盖。很多教程写Chroma.from_documents(),那是新建库,会清空旧数据。生产环境必须用add_documents,且要加st.sidebar.success提示,否则用户不知道上传成功没。
4. 实操过程详解:从本地调试到一键部署,附完整可运行代码
4.1 本地开发:三步启动,5 分钟看到第一个 AI 对话
第一步:准备数据
下载一份公开的 PDF,比如 LangChain 官方文档 PDF ,放到项目根目录docs/下。
第二步:初始化向量库
新建init_db.py,只运行一次:
from langchain_community.document_loaders import PyPDFLoader from langchain_chroma import Chroma from langchain_community.embeddings import OllamaEmbeddings from langchain_text_splitters import RecursiveCharacterTextSplitter # 加载 PDF loader = PyPDFLoader("docs/docs.pdf") docs = loader.load() # 分块 text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50) splits = text_splitter.split_documents(docs) # 创建向量库 vectorstore = Chroma.from_documents( documents=splits, embedding=OllamaEmbeddings(model="nomic-embed-text"), persist_directory="./chroma_db" ) print(f"✅ 向量库已创建,共 {len(splits)} 个文本块")运行python init_db.py,等待 2 分钟(取决于 PDF 大小和 CPU),chroma_db/目录生成。
第三步:启动 Streamlit
确保 Ollama 已运行(ollama serve),然后:
streamlit run app.py --server.port=8501浏览器打开http://localhost:8501,输入 “How to use LangChain with Streamlit?”,几秒后看到回答。这就是最小闭环。
实操心得:第一次运行慢,是因为
st.cache_resource还没生效。第二次启动,LLM 和向量库都从缓存加载,秒开。我们团队约定:所有新成员入职,第一件事就是跑通这个本地流程,确保环境无误。
4.2 生产部署:Streamlit Cloud 免费版实战配置
Streamlit Cloud 是最省心的选择,免费版足够支撑内部工具。关键配置文件:
requirements.txt(精确到小数点后一位,避免版本冲突):
streamlit==1.33.0 langchain-core==0.3.1 langchain-ollama==0.2.0 langchain-chroma==0.1.2 langchain-text-splitters==0.3.1 chromadb==0.4.24 ollama==0.1.32.streamlit/config.toml(必须!否则中文乱码、宽屏失效):
[theme] base="light" primaryColor="#FF4B4B" backgroundColor="#FFFFFF" secondaryBackgroundColor="#F8F9FA" textColor="#262730" [server] enableCORS=false enableXsrfProtection=true port=8501部署命令:
在 GitHub 仓库根目录,执行:
streamlit cloud deploy --branch main --app app.py它会自动检测requirements.txt和config.toml,构建 Docker 镜像,部署到 AWS。整个过程 3 分钟,完成后得到一个https://yourname-stremlit-app.streamlit.app链接。我们线上知识库已稳定运行 217 天,日均请求 1200+,零宕机。
注意:Streamlit Cloud 不支持
ollama服务(因为它需要后台 daemon),所以生产环境必须换 LLM。我们用langchain-openai,在 Settings 中设置OPENAI_API_KEY环境变量。app.py中的 LLM 初始化改为:@st.cache_resource def get_llm(): return ChatOpenAI( model="gpt-4o-mini", temperature=0.3, api_key=os.getenv("OPENAI_API_KEY") )这样,开发用 Ollama(免费、离线),生产用 OpenAI(稳定、高可用),代码零修改。
4.3 完整可运行app.py(已整合所有最佳实践)
以下是经过我们生产环境验证的完整代码,复制即用(请替换你的向量库路径和 LLM 配置):
import os import streamlit as st from langchain_core.messages import AIMessage, HumanMessage from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langchain_ollama import ChatOllama from langchain_chroma import Chroma from langchain_community.embeddings import OllamaEmbeddings from langchain_text_splitters import RecursiveCharacterTextSplitter # =============== 页面配置 =============== st.set_page_config( page_title="AI 知识助手", page_icon="🤖", layout="centered", initial_sidebar_state="expanded" ) st.title("💬 AI 知识助手") # =============== 初始化会话状态 =============== if "messages" not in st.session_state: st.session_state.messages = [ AIMessage(content="你好!我是公司知识助手,可以帮你查产品文档、流程规范、技术FAQ。请直接提问。") ] # =============== 缓存资源 =============== @st.cache_resource def get_llm(): return ChatOllama( model=os.getenv("OLLAMA_MODEL", "llama3:8b"), temperature=float(os.getenv("TEMPERATURE", "0.3")), base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") ) @st.cache_resource def get_vectorstore(): return Chroma( persist_directory="./chroma_db", embedding_function=OllamaEmbeddings( model=os.getenv("EMBEDDING_MODEL", "nomic-embed-text"), base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") ) ) @st.cache_data def get_retriever(top_k: int): return get_vectorstore().as_retriever(search_kwargs={"k": top_k}) # =============== 构建 Prompt =============== system_prompt = """你是一个严谨的知识助手。请严格按以下规则回答: 1. 先确认问题是否在提供的上下文中。 2. 如果是,用中文分步骤(1. 2. 3.)清晰列出操作流程,每步不超过 20 字。 3. 如果否,只回答“根据现有资料,无法确定,请联系XX部门”。 4. 禁止添加任何解释性文字、免责声明或额外信息。""" prompt = ChatPromptTemplate.from_messages([ ("system", system_prompt), ("human", "上下文:{context}\n\n问题:{input}") ]) def format_docs(docs): return "\n\n".join([d.page_content for d in docs]) # =============== 侧边栏 =============== with st.sidebar: st.header("⚙️ 配置") model_name = st.selectbox("选择模型", ["llama3:8b", "qwen2:7b"]) temperature = st.slider("创意度 (temperature)", 0.0, 1.0, 0.3, 0.1) top_k = st.number_input("检索文档数 (top_k)", 1, 10, 3) st.subheader("📁 上传文档") uploaded_files = st.file_uploader( "支持 PDF/TXT/MD", type=["pdf", "txt", "md"], accept_multiple_files=True ) # =============== 处理上传 =============== if uploaded_files: from langchain_community.document_loaders import PyPDFLoader from langchain_core.documents import Document documents = [] for file in uploaded_files: try: if file.name.endswith(".pdf"): loader = PyPDFLoader(file) docs = loader.load() for doc in docs: doc.metadata["source"] = file.name documents.extend(docs) elif file.name.endswith(".txt"): content = file.getvalue().decode() documents.append(Document(page_content=content, metadata={"source": file.name})) except Exception as e: st.sidebar.error(f"❌ 解析 {file.name} 失败: {str(e)}") continue if documents: text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, length_function=len, ) splits = text_splitter.split_documents(documents) get_vectorstore().add_documents(splits) st.sidebar.success(f"✅ 已添加 {len(splits)} 个文本块") # =============== 渲染历史消息 =============== for msg in st.session_state.messages: st.chat_message(msg["role"]).write(msg["content"]) # =============== 处理用户输入 =============== if user_input := st.chat_input("输入你的问题..."): # 添加用户消息 st.session_state.messages.append({"role": "user", "content": user_input}) st.chat_message("user").write(user_input) # 构建链(动态,因 top_k 可变) llm = get_llm() retriever = get_retriever(top_k) chain = ( {"context": retriever | format_docs, "input": lambda x: x["input"]} | prompt | llm | StrOutputParser() ) # 调用并流式渲染 with st.chat_message("assistant"): response = st.write_stream(chain.stream({"input": user_input})) st.session_state.messages.append({"role": "assistant", "content": response})4.4 性能调优:让响应快 3 倍的 4 个关键参数
即使同一套代码,在不同参数下性能差异巨大。这是我们线上环境的黄金配置:
| 参数 | 推荐值 | 为什么 | 实测效果 |
|---|---|---|---|
chunk_size(文本分块) | 500 | 太大(1000)导致检索噪声多,太小(200)丢失上下文 | 500 时平均响应 2.1s,1000 时 3.8s |
top_k(检索数量) | 3 | 检索 5 个文档,LLM 要读 5 倍上下文,token 消耗翻倍 | top_k=3比=5快 40%,准确率只降 0.8% |
temperature(LLM 温度) | 0.3 | 0.7 以上开始“自由发挥”,偏离事实;0.1 以下过于死板 | 0.3 时人工评估准确率 92.3%,0.7 时 76.1% |
model(LLM 模型) | llama3:8b | qwen2:7b中文稍好但慢 25%;gemma2:2b快但幻觉率高 | llama3:8b在速度/准确率/资源占用上最均衡 |
实操心得:我们用
locust做了压力测试,单实例(2CPU/4GB)在top_k=3下,QPS 稳定在 8.2;top_k=5时 QPS 掉到 4.7,且错误率上升。参数不是拍脑袋,是用真实负载测出来的。
5. 常见问题与排查技巧实录:那些文档里不会写的“血泪经验”
5.1 问题速查表:高频故障与 1 分钟修复
| 现象 | 可能原因 | 快速诊断命令 | 修复方案 |
|---|---|---|---|
页面空白,控制台报WebSocket connection failed | Streamlit 服务未启动,或端口被 |