这一课把终端聊天变成网页聊天,继续使用已有的:
- 知识库检索、库存查询、会员报价。
- 人工审批与模拟订单保存。
- 耗时日志和 Token 记录。
不重写业务工具,只增加一个网页入口。 本课先做仅在你电脑上使用的演示版,不涉及公网部署或真实登录。
一、先理解网页和终端的区别
终端程序通过 while True 等待输入。
Streamlit 网页在用户提交消息、点击按钮时,会重新执行页面脚本。因此,不能每次重新创建图,也不能在页面加载时无条件调用模型。
我们使用 st.session_state,保存当前网页会话的图、聊天记录和待审批请求。官方说明
这里要区分:
| 状态 | 保存什么 |
|---|---|
st.session_state |
网页显示记录、图对象、审批状态 |
| LangGraph 检查点 | 图中的消息和执行状态 |
| SQLite 数据库 | 已保存的模拟订单 |
网页会话状态不是数据库,浏览器刷新、连接重建或服务器重启可能丢失它。
二、安装网页组件
在 CMD 中执行:
cd /d C:\Users\Administrator\langchain-study
.venv\Scripts\activate
python -m pip install streamlit
不需要升级已有的 LangChain 依赖。
三、新建网页入口
在项目根目录新建:
langchain-study\streamlit_app.py
注意:这个文件放在项目根目录,不是放进 bookstore_app 文件夹。
写入:
from uuid import uuid4
import streamlit as st
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.types import Command
from bookstore_app.config import USER_ID, RECURSION_LIMIT
from bookstore_app.graph import build_graph
from bookstore_app.tools import UserContext
st.set_page_config(
page_title="星河书店助手",
page_icon="📚",
layout="centered",
)
st.title("📚 星河书店助手")
st.caption("本地练习版:支持规则查询、库存、报价与模拟下单。")
st.warning("这里只保存模拟订单,不扣款、不扣库存。")
def create_session():
"""为当前网页会话创建独立的图和状态。"""
return {
"graph": build_graph(),
"context": UserContext(user_id=USER_ID),
"config": {
"configurable": {
"thread_id": (
f"web:{USER_ID}:{uuid4().hex}"
),
},
"recursion_limit": RECURSION_LIMIT,
},
"history": [],
"pending": [],
"busy": False,
"error": None,
}
# 只在当前网页会话首次打开时创建
if "bookstore_session" not in st.session_state:
try:
with st.spinner("正在初始化书店助手......"):
st.session_state["bookstore_session"] = (
create_session()
)
except Exception as error:
st.error(
f"初始化失败:{type(error).__name__}。"
"请检查模型配置和环境变量。"
)
st.stop()
app = st.session_state["bookstore_session"]
def run_graph(payload):
"""只在提交问题或审批决定时调用。"""
app["busy"] = True
try:
with st.spinner(
"正在处理,请稍候。首次查询知识库可能较慢......"
):
result = app["graph"].invoke(
payload,
config=app["config"],
context=app["context"],
)
app["pending"] = list(
result.get("__interrupt__", ())
)
if not app["pending"]:
last_message = result["messages"][-1]
if (
not isinstance(last_message, AIMessage)
or last_message.tool_calls
or not last_message.content
):
raise RuntimeError("未获得完整最终回答")
app["history"].append({
"role": "assistant",
"content": last_message.content,
})
except Exception as error:
# 不自动重试,避免在结果不确定时再次执行操作
app["error"] = type(error).__name__
# 正常返回或捕获异常后结束忙碌状态。
# 如果脚本被外部中断,没有走到这里,
# busy 会保留为 True,避免直接再次提交。
app["busy"] = False
st.sidebar.header("当前会话")
st.sidebar.write("演示用户:", app["context"].user_id)
st.sidebar.caption("A 为会员,B 为普通用户;这不是正式登录系统。")
locked = (
bool(app["pending"])
or app["busy"]
or app["error"] is not None
)
if st.sidebar.button("开启新对话", disabled=locked):
del st.session_state["bookstore_session"]
st.rerun()
# 只展示历史,不在这里调用模型
for item in app["history"]:
with st.chat_message(item["role"]):
st.markdown(item["content"])
if app["error"] is not None:
st.error(
f"本次处理失败:{app['error']}。"
"当前会话已停止提交,请检查终端和日志。"
)
st.warning(
"如果错误发生在批准模拟下单之后,"
"记录可能已经保存,请先核对数据库,"
"不要直接重复下单。"
)
st.stop()
if app["busy"]:
st.warning(
"上一操作尚未确认完成,暂不接受新提交。"
"请先检查终端和日志;不要反复点击或刷新重试。"
)
st.stop()
# 图暂停时,在网页上展示审批信息
if app["pending"]:
st.subheader("待确认的模拟订单")
for number, pending in enumerate(
app["pending"], start=1
):
details = pending.value
with st.container(border=True):
st.write(f"申请 {number}")
st.write("用户:", details["user_id"])
st.write("图书:", details["book_name"])
st.write("数量:", details["quantity"])
st.write(
"模拟金额:",
details["total_yuan"],
"元",
)
st.caption(
"以下按钮作用于上面列出的全部申请。"
"未点击同意前,不会批准这些申请。"
)
# 每批审批使用自己的按钮标识
approval_key = ":".join(
pending.id for pending in app["pending"]
)
left, right = st.columns(2)
reject = left.button(
"拒绝以上全部申请",
key=f"reject:{approval_key}",
)
approve = right.button(
"同意以上全部模拟申请",
key=f"approve:{approval_key}",
)
if reject or approve:
approved = bool(approve and not reject)
decisions = {
pending.id: approved
for pending in app["pending"]
}
# 先消费当前页面的审批请求,避免再次使用旧按钮
app["pending"] = []
app["history"].append({
"role": "assistant",
"content": (
"你已同意以上模拟申请,正在处理。"
if approved
else "你已拒绝以上模拟申请,正在处理。"
),
})
run_graph(Command(resume=decisions))
st.rerun()
# 等待审批期间,不接收新的聊天问题
st.chat_input(
"请先处理上方的模拟订单申请",
disabled=True,
)
st.stop()
question = st.chat_input(
"例如:周六几点关门?或两本三体多少钱?"
)
if question and question.strip():
question = question.strip()
app["history"].append({
"role": "user",
"content": question,
})
run_graph({
"messages": [HumanMessage(content=question)],
})
st.rerun()
本课不需要修改 graph.py、tools.py 或 knowledge.py。
四、启动网页
在同一个已激活虚拟环境、已配置密钥的 CMD 中运行:
python -m streamlit run streamlit_app.py --server.address 127.0.0.1
这条命令从项目根目录执行。
浏览器通常会自动打开;如果没有,点击终端显示的 Local URL ,默认通常是 http://localhost:8501。
终端不要关闭,它是网页服务的运行进程。停止服务时,在终端按 Ctrl+C。
五、三个关键设计
1.为什么保存整个图对象?
if "bookstore_session" not in st.session_state:
...
保证普通页面重跑时复用已有图,而不是重新创建 InMemorySaver,把对话记忆清掉。
原来的知识库缓存也继续生效。不过网页服务是一个新进程,第一次知识库查询仍需加载模型;它不会继承之前 CMD 聊天进程的内存缓存。
2.为什么不是每次都调用 graph.invoke()?
代码只在两个事件里执行它:
if question:
...
以及:
if reject or approve:
...
展示历史聊天时只渲染内容,不重新发送旧问题。
st.chat_input 用于接收本次提交的消息,配合 st.chat_message 展示聊天内容。官方说明
3.为什么异常后不自动重试?
假设:
- 用户批准模拟订单。
- 数据库保存成功。
- 模型组织最终回答时超时。
此时用户没看到成功回答,不代表订单记录没保存。
所以页面会停止继续提交,提示核对数据库,而不是自动再执行一次。原有幂等机制继续保留,但不能据此宣称已解决所有跨会话重复操作。
六、按顺序验收
当前配置是用户 B,先进行这些检查:
| 操作 | 预期 |
|---|---|
| 问"周六几点关门?" | 回答晚上十点,并显示来源 |
| 问"三体还有几本?" | 回答库存 8 本 |
| 接着问"那买两本多少钱?" | 沿用三体,报价 90 元 |
| 问"帮我模拟下单两本三体" | 显示书名、数量、金额和审批按钮 |
| 点击拒绝 | 返回取消结果,不新增本次订单记录 |
| 再明确发起一次申请并同意 | 保存模拟记录,显示结果 |
| 完成后点击"开启新对话" | 清空页面聊天历史,开始新的图会话 |
"开启新对话"不会删除之前保存的模拟订单。
第十五课的耗时和 Token 日志仍然输出到终端和原来的日志文件中,本课暂时不把日志面板搬进网页。
七、当前版本的边界
- 仅供本机演示,不是有真实登录和权限控制的线上系统。
- 页面普通重跑与浏览器刷新不是一回事;刷新或重启不能保证恢复聊天和待审批状态。
- 模拟订单保存在 SQLite 中,和网页临时状态不同。
- 处理请求、等待审批时,不要刷新页面或编辑代码触发重载。
- 本课减少正常页面重跑带来的重复执行,不承诺网络中断、崩溃等情况下的"绝不重复"。
本课目标:你能在网页上完成一轮知识库问答,以及一次拒绝、一次批准的模拟下单。
完成后进入最后一课:第十七课,整理启动说明、回归检查和项目验收。
运行截图:

星河书店助手效果演示