【案例】Streamlit + LangChain 智能聊天机器人

界面只是点单台:会话记忆、链路编排、流式输出全在后厨——换一个前端,后厨一行都不用改。

30″30 秒看懂这个案例

前五讲把中央厨房的每个工位都拆开讲过了:菜谱卡怎么填变量、传送带怎么把工位串起来、墙上那块备忘板怎么记住客人说过什么、店长怎么派外卖小哥跑腿。这一讲把它们装在一起,开门营业。

营业之后,多出来的只有一个东西:前厅的点单台。顾客站在点单台前说「我要一份番茄炒蛋,不要放葱」,点单台把这句话递进后厨,后厨照着流水线做完,再从传菜口一勺一勺端出来。顾客看不见备忘板,也看不见传送带——他只知道自己说了一句话,然后菜来了。

图① 前厅点单台与后厨:顾客只面对点单台,链路与备忘板都在后厨
图① 前厅点单台与后厨:顾客只面对点单台,链路与备忘板都在后厨
比喻里的角色对应的技术概念它到底干了什么
前厅点单台Streamlit 页面收一句话、画气泡,不碰模型、不碰历史
点单台上的桌号牌session_id决定这一单归哪块备忘板,写错就串桌
后厨的传送带LCEL 链(prompt | model | parser把人设、历史、这一问排好序送进模型
墙上那块备忘板BaseChatMessageHistory存这一桌说过的每一句,下一轮原样贴回菜谱卡
备忘板的限高线历史修剪与 Token 预算贴满了就撕掉最早的,人设那张永不撕
传菜口一勺一勺端流式输出(stream不等整盘做完,先让顾客尝到第一口
厨师大模型真正产出内容的人,云端或本地都行
⛔ 这一讲的铁律 界面只是点单台。 会话记忆、链路编排、流式输出都在后厨;换一个前端,后厨一行都不用改。 这句话不是设计口号,它是可以被验证的:本讲第 4.9 节会把 Streamlit 整个换成命令行,chat_core.py 一个字没动。
这一讲和「私有聊天机器人」那一讲的分工 Streamlit 控件本身的机制——st.chat_input 怎么用、为什么每次交互整个脚本会重跑一遍、session_state 为什么是唯一能跨重跑活下来的地方、Ollama 本地模型怎么接——在那一讲已经讲透,这里直接用结论,不重讲。
本讲的新东西只有一件:把 LangChain 的链与记忆接进来。接进来之后,历史不再是你手工维护的一个列表,而是链自己会读、会写的一块备忘板;模型换云端还是换本地,也不再牵动界面。
✅ 读完这一讲你会得到 一个三层分明、可交付的对话应用:会话之间互不串话、历史按预算自动修剪、答案逐字吐出、模型故障时页面不崩、云端与本地模型一键切换,外加一份改 TODO 就能用的单文件骨架和一份能进发版流程的验收脚本。

01概念:先选路,再分层

三种搭建方式怎么选、三层架构为什么必须分开、引入 LangChain 换来了什么

1.1 搭一个聊天机器人,有三条路

聊天机器人是一种基于自然语言处理技术的软件程序,能通过文本或语音与用户交互:理解意图、维持多轮对话的连贯性、按用户偏好给出定制回答,必要时还能执行任务——查信息、下预订、控制设备。这些能力今天已经不需要从零造,问题只剩下一个:从哪条路开始。

路线代表做法要会什么什么时候选它
无代码平台可视化配置智能体,选模型、挂插件、写开场白,再接到公众号不用写代码需求简单、要快速上线;不追求私有化
开源框架开发用对话库加 Web 框架自己搭有编程基础需求复杂、要高度定制;纯规则库的对话质量有限
大模型集成本讲这条:本地或云端部署模型,用框架编排,自己写界面编程 + 部署要高质量对话要掌控数据与链路

实际项目里这三条路并不互斥,常见的是结合其中一两种。本讲选的是「开源框架 + 大模型集成」的综合方案:后端用大模型提供对话能力,中间用 LangChain 编排链路与记忆,前端用 Streamlit 承载界面。这样既保住了数据与链路的掌控权,又不必为了一个输入框去写前端。

选型这件事本身就是知识点 这里选 Streamlit 不是因为它最强,而是因为它让你把注意力留在「链路怎么编排」上,而不是消耗在前端上。真要嵌进公司现有系统时,界面整个换掉、编排层原样搬走——这正是下一节分层的价值兑现时刻。

项目要达到的目标

01核心对话

理解并生成自然语言,中英文都能聊;能处理用户输入并给出准确、流畅的回复,而不是关键词匹配出来的模板话。

02实时交互

用户在页面输入文本,机器人实时响应并展示回复,对话过程流畅、延迟可控——这条要求直接决定了必须做流式。

03简洁界面

输入框、对话展示区一目了然,回复实时展示在对话区域,不需要用户学怎么用。

1.2 三层架构:界面层 / 编排层 / 模型层

很多人写第一个版本时,把页面和调模型的代码全塞进一个文件。跑得起来,但只要界面一换、模型一换、记忆策略一改,就要整个重写。这个案例从第一行代码就分成三层:

图② 三层架构:界面层 / 编排层(链 + 记忆)/ 模型层
图② 三层架构:界面层 / 编排层(链 + 记忆)/ 模型层
文件只负责绝不做
界面层chat_app.py收一句话、画气泡、显示状态不建链、不存历史、不认识任何模型
编排层chat_core.py 及其依赖建链、读写记忆、修剪、流式、兜底不导入 streamlit,不打印任何东西
模型层云端 API 或本地服务真正算答案不知道有没有界面这回事

分开写不是为了好看,是为了这四件具体的事:

好处具体表现
界面可替换换成命令行、Flask、企业微信回调,chat_core.py 一个字不用动(4.9 节实测)
故障可隔离出问题先单独跑 python3 chat_core.py——通了就是界面的锅,不通就是后厨的锅
改动可收敛换模型、改人设、加重试、加日志、改修剪策略,全都只改编排层
记忆只有一份历史只存在备忘板上,界面不再自己备一份——两边各存一份迟早对不上
⚠️ 最后一条最容易被忽略 上一讲的写法里,聊天记录存在页面的状态里,界面自己维护那个列表。一旦接入 LangChain 的记忆,历史的唯一归属就变成了备忘板。如果界面还留着自己那一份,就会出现「屏幕上有这句话、模型却没看见」,或者反过来——这是接入记忆之后最难查的一类 bug。本讲的界面层因此不存任何消息,气泡的数据源就是备忘板本身。

1.3 引入 LangChain 换来了什么

不用框架也能做聊天机器人:自己拼 messages 列表、自己发请求、自己切历史。上一讲就是这么做的,而且做得通。那为什么还要多一层?

这件事手工写法接入 LangChain 之后
历史怎么进请求自己维护列表,自己拼进请求体MessagesPlaceholder 是模板里的一个插槽,链自己会填
历史什么时候写回手工 append,漏一次模型就失忆RunnableWithMessageHistory 调用前读、拿到回复后写
多会话隔离自己维护「会话 ID → 列表」的字典工厂函数按 session_id 取容器,隔离逻辑收在一处
换存储调用处到处都要改只换工厂函数,链上其余代码不动
换模型请求体、字段名、流式格式各家不同init_chat_model("provider:model")换一个字符串
流式自己解析分片协议chain.stream(...) 直接给生成器
升级成 Agent从零实现「调用—执行—回填」循环链换成 create_agent界面层无感(4.10 节)

换来的是统一的接口和可替换的零件;代价是多一层依赖、多一套概念要学。对一次性 demo 来说不划算,对一个要长期改、要换模型、要上多用户的应用来说,省下的是后面每一次改动的成本

⛔ 别忘了模块铁律 LangChain 不做菜,它只是厨房。 引入它并不会让回答变得更聪明——回答的质量始终取决于那位厨师(模型)和你给的菜谱卡(提示词)。它换来的是标准化:工位固定、传送带固定、备忘板固定,所以换厨师、换前厅都不用重修厨房。

02原理:一句话在三层之间怎么走

调用链路、编排层的四个零件、会话隔离、修剪预算、流式传递

2.1 一次提问走过的完整路线

用户在页面敲下「我刚才说我叫什么」,回车。这一句话接下来要走七步,其中只有第 4 步在模型那边

① 点单台收单界面拿到文本,附上本次会话的桌号牌 session_id
② 取备忘板工厂函数按 session_id 找到这一桌的历史容器
③ 填菜谱卡人设 + 历史(填进插槽)+ 这一问,拼成完整消息列表
④ 厨师做菜模型收到完整上下文,开始逐块生成
⑤ 一勺一勺端出解析器逐块转成纯文本,沿链往回传
⑥ 写备忘板这一问一答成对写回历史容器
⑦ 画气泡界面边收边渲染,用户看到字一个个冒出来
步骤谁干的关键点
界面层桌号牌必须是服务端认过的,不能由前端随便传一个
② ③ ⑥RunnableWithMessageHistory读在调用前,写在拿到回复后,中间出异常这一轮就不会被写进去
模型不知道有历史这回事——历史对它来说就是这次请求里的普通消息
⑤ ⑦链 + 界面流式是一节一节往下传的,中间任何一节要攒齐才动,流就断在那里
⛔ 第 ④ 步值得单独记一遍 模型本身没有记忆。它每一轮都是从零开始读你这次发过去的消息。所谓「它记得我叫孙小空」,是因为第 ③ 步把上一轮的问答又原样塞了一遍进去。把备忘板拿掉,模型立刻失忆——这一点在「会话记忆与多轮上下文」那一讲有完整推演。

2.2 编排层的四个零件

后厨看着复杂,拆开只有四个零件,缺一不可,多一个都算冗余

1带插槽的提示词模板

ChatPromptTemplate 三段式:人设在最前、MessagesPlaceholder 在中间、本轮输入在最后。插槽就是「把备忘板整块贴进来」的位置。

2一条普通的 LCEL 链

prompt | model | parser。它本身完全不知道「记忆」这回事——链只管把输入变成输出,这正是它能被复用的原因。

3历史工厂函数

给一个 session_id,返回这个会话的历史容器。换存储只换这一个函数,内存、文件、Redis 对链来说没有区别。

4记忆包装器

RunnableWithMessageHistory 把前三者接起来,负责调用前读、拿到回复后写。包完之后它仍然是一个 Runnable,invoke / stream 照常用。

参数填什么填错的现象
input_messages_key本轮输入在入参字典里的键名抛键名相关的错,或历史写进去的是整个字典
history_messages_key模板里插槽的 variable_name历史填不进模板,表现为「完全没记忆」
工厂函数的入参默认只有 session_id 一个想按「用户 + 会话」两级隔离要另行声明字段
⚠️ 三段的顺序不能乱 人设 → 历史 → 本轮输入。把历史放到本轮输入之后,模型会把旧话当成最新指令;把人设放到历史之后,人设就会被越堆越长的历史往后推,约束力显著下降。

版本说明:这一套写法的来历

早期资料里常见的是 ConversationChainConversationBufferMemory,一行就能建出带记忆的对话。那一套现在已搬到 langchain-classic,不再是推荐写法;当代写法是「普通链 + RunnableWithMessageHistory」,也就是本讲用的这一套。

要做的事早期写法当代写法
建带记忆的对话ConversationChain + ConversationBufferMemoryprompt | model | parser 外包一层 RunnableWithMessageHistory
建模型各家伙伴包各自的类init_chat_model("provider:model") 统一入口
Agent 带记忆memory 对象挂在执行器上create_agent(..., checkpointer=...) + thread_id
导入旧链from langchain.chains import ...from langchain_classic.chains import ...

2.3 会话隔离:桌号牌决定你看到哪块板

线上不会只有一个人在聊天。谁看到哪块备忘板,完全由 session_id 决定——它是这个应用最敏感的一个字符串。

图③ 多用户多会话:一个会话 ID 对应一块独立的备忘板
图③ 多用户多会话:一个会话 ID 对应一块独立的备忘板
场景会话 ID看到的历史
同一个人,同一段对话一样完整的上下文,记得住名字和订单号
同一个人,点了「新建对话」换一个空白——这正是「新建对话」该有的表现
两个人同时在用各自不同互相看不见,这是底线
会话 ID 由前端传、服务端不校验可被篡改改一个参数就能读到别人的会话
⚠️ 会话标识不能由前端说了算 session_id 必须由服务端生成,或者由已验证的登录态派生。鉴权要在进入链之前做完,工厂函数里只做「按 key 取容器」这一件事。另外,会话 ID 不要拿手机号、邮箱这类可枚举的东西直接拼——那等于把别人的备忘板编号写在门口。
多用户多会话的隔离机制本身在「会话记忆与多轮上下文」那一讲有完整拆解,这里只落地成应用里的一个模块。

2.4 修剪与 Token 预算:备忘板有限高线

一块板子贴不下无限张便签。对话越长,每轮送进模型的历史越长,后果依次发生:

先是变慢输入越长,首字延迟越高
然后变贵历史每轮都要重发一遍,费用随轮数累积
最后被截断超出上下文窗口,最早的内容被静默丢掉且不报错
尺子怎么量优点缺点
按条数只保留最近 N 条算得快、好理解消息长短不均时很不准,一条长文就顶穿
按 Token 预算trim_messages 按估算值从后往前留与真实成本对齐要一个计数函数,略慢
摘要压缩把早期对话压成一段摘要长对话也能保住要点多一次模型调用,摘要本身可能丢细节
⛔ 不管用哪把尺子,有两条不能破 ① 人设那一条不参与裁剪。直接切最后 N 条会把最前面的人设一起切掉,聊到几十轮后机器人「性格突变」,而且极难查。
② 裁完不要以一句孤立的回答开头。历史第一条是助手的回答而没有对应的提问,模型会试图为它补一个语境,容易跑偏。

2.5 流式:从模型一路传到界面

同样一段两百字的回答,一次性返回要等模型全部生成完才给你,用户盯着空白等好几秒;流式是生成一点吐一点,首字延迟通常是前者的几分之一。内容总量没变,变的是等待体感——而项目需求里「对话过程流畅、延迟可控」这条,靠的就是它。

图④ 流式输出:一次性返回与逐字吐出的差别
图④ 流式输出:一次性返回与逐字吐出的差别

流式没有任何魔法,它就是一条逐节传递的管道

模型吐出一个内容块
解析器把这一块转成纯文本片段
记忆包装器片段照常往外传,全部结束后才把完整答案写回备忘板
界面边收边画
入口拿到的是用在哪
invoke完整结果后台任务、批处理、要拿整段做后处理时
stream最终输出的增量片段聊天界面逐字显示,本讲主用
astream_events链上每一步的事件要在界面显示「正在检索…/正在生成…」
⚠️ 流式最容易踩的坑:链上有一节不支持流 只要中间某一节必须拿到完整输入才能工作(比如一个普通的后处理函数、一个要求完整 JSON 的解析器),流就断在那里,后面只能等它攒齐再一次性吐出。
现象是:代码里明明写了 stream,界面却卡半天然后整段蹦出来。排查方法是把可疑的那一节摘掉再试一次。链本身的流式机制在「LCEL 与链的组合」那一讲有更细的拆解。
✅ 三条链路串起来看 session_id 决定「看到哪块板」,备忘板决定「模型记不记得」,流式决定「用户等多久看到第一个字」。这三件事都在后厨,界面一件也不参与——这就是本讲铁律的具体含义。

03最小代码:八十行跑通一个带记忆的聊天页

先把最短的一条路走完,再去拆完整案例

完整案例有十来个文件,但剥掉持久化、修剪、重试、模型切换之后,真正让它成立的只有八十行。先把这八十行跑通,后面每一节都只是在往上加一件事。

① 工厂函数会话 ID → 一块备忘板
② 建链人设 + 历史插槽 + 本轮输入,接上模型与解析器
③ 包记忆RunnableWithMessageHistory 负责读与写
④ 发桌号牌页面首次打开时生成一个会话 ID
⑤ 收一句话输入框拿到文本
⑥ 边收边画把链的 stream 交给界面渲染
完成历史已自动写回
min_app.py —— 最小可跑的带记忆聊天页最小代码
# -*- coding: utf-8 -*-
"""
最小可跑:Streamlit 点单台 + LangChain 后厨
============================================
全文只有三件事:

  1. 建一条带记忆的链(编排层,不认识 Streamlit)
  2. 页面把会话 ID 和这一轮问题递进去
  3. 把返回的文本画成气泡

启动:streamlit run min_app.py
依赖:pip install streamlit langchain langchain-openai
"""

import os
import uuid

import streamlit as st
from langchain.chat_models import init_chat_model
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory

# ---------------------------------------------------------------- 后厨
_store = {}


def get_history(session_id: str) -> InMemoryChatMessageHistory:
    """一个会话 ID 对应一块独立的备忘板。"""
    if session_id not in _store:
        _store[session_id] = InMemoryChatMessageHistory()
    return _store[session_id]


@st.cache_resource(show_spinner=False)
def build_chain():
    """@st.cache_resource 让这条链只建一次,不随页面重跑反复创建。"""
    model = init_chat_model(
        os.environ.get("CHAT_MODEL", "openai:gpt-4o-mini"),
        temperature=0.3,
        api_key=os.environ.get("OPENAI_API_KEY"),
    )
    prompt = ChatPromptTemplate.from_messages([
        ("system", "你是一个中文智能助手,回答简洁、准确。"),
        MessagesPlaceholder(variable_name="history"),
        ("human", "{question}"),
    ])
    chain = prompt | model | StrOutputParser()
    return RunnableWithMessageHistory(
        chain,
        get_history,
        input_messages_key="question",
        history_messages_key="history",
    )


# ---------------------------------------------------------------- 前厅
st.title("智能聊天机器人")

# 会话 ID 由服务端生成,存进 session_state,一个浏览器标签一份
if "sid" not in st.session_state:
    st.session_state["sid"] = str(uuid.uuid4())
if "bubbles" not in st.session_state:
    st.session_state["bubbles"] = []

for role, text in st.session_state["bubbles"]:
    with st.chat_message(role):
        st.markdown(text)

question = st.chat_input("说点什么")
if question:
    st.session_state["bubbles"].append(("user", question))
    with st.chat_message("user"):
        st.markdown(question)

    conversation = build_chain()
    cfg = {"configurable": {"session_id": st.session_state["sid"]}}
    with st.chat_message("assistant"):
        # 链的 stream 直接就是生成器,交给界面边收边渲染
        answer = st.write_stream(
            conversation.stream({"question": question}, config=cfg))
    st.session_state["bubbles"].append(("assistant", answer))
这一处作用去掉会怎样
get_history 里的 if同一个会话 ID 复用同一块板每轮新建一块空板,永远没有记忆
MessagesPlaceholder历史贴进模板的位置历史读出来了也填不进去,等于没读
history_messages_key="history"告诉包装器往哪个插槽填键名对不上,表现同样是「完全没记忆」
@st.cache_resource链只建一次每次交互重建链与客户端,变慢且白白占连接
config={"configurable": {"session_id": ...}}这一单归哪块板不传会直接报错,随手写死则所有人共用一块板
st.write_stream(...)边收边画,并返回完整文本改成一次性返回,用户要盯着空白等完
为什么这里还留着一个 bubbles 列表 最小版本图省事,界面自己存了一份气泡用于重绘。完整案例会把它删掉,改成直接从备忘板读——因为两边各存一份,迟早会出现「屏幕上有、模型没看见」的错位。这一处正是最小版和可交付版的第一个分水岭。
⚠️ 密钥与模型名一律走环境变量 代码里只出现 os.environ.get(...),不出现任何密钥字面量,也不把密钥打进日志或页面。换模型、换环境时改环境变量即可——改源码就一定会漏改。启动前先把 CHAT_MODEL 与凭据配好,再 streamlit run min_app.py
✅ 这一步的验收标准 先说「我叫孙小空」,再问「我叫什么名字」,答得出才算真通。页面能打开、能出字,只说明链通了;答对名字才同时证明了「历史读进去了」和「回答写回去了」两件事。

04完整案例:从最小版长成可交付的应用

先把后厨拆成能替换的零件,再装上前厅,最后逐项补齐交付要求

最小版有四个说不过去的地方:历史存在进程里重启就没、无限增长、会话 ID 随手生成没人校验、模型一抖整页崩。下面按顺序把它们补上。每一节只解决一个问题,代码文件也一一对应——这正是分层的另一个好处:每个问题都有明确的归属文件。

文件解决的问题
chat_core.py编排层建链、装记忆,对外只暴露四个函数
history_store.py编排层历史存在哪:内存 / 文件 / 可换 Redis
trim_policy.py编排层按条数或 Token 预算修剪,人设不裁
session_keys.py编排层会话 ID 的生成、派生与校验
stream_bridge.py编排层流式的三种消费方式与首字延迟度量
resilience.py编排层失败分类、退避重试、超时兜底
model_switch.py编排层云端与本地模型共用同一套编排
agent_mode.py编排层把链换成 Agent,界面层无感
chat_app.py界面层Streamlit 页面,只收单只画气泡
cli_probe.py界面层同一个后厨换成命令行,验证分层成立

4.1 编排层:整个后厨收在一个文件里

它对外只暴露四个函数——replyreply_streamhistory_ofreset界面层只认这四个,其余全是内部实现。这个文件不导入界面库,也不打印任何东西。

chat_core.py —— 编排层:建链、装记忆、对外四个函数编排层
# -*- coding: utf-8 -*-
"""
编排层:chat_core.py —— 整个后厨都在这一个文件里
==================================================
它对外只暴露四个函数,界面层只认这四个:

  reply(session_id, question)          一次性拿完整答案
  reply_stream(session_id, question)   生成器,边算边吐
  history_of(session_id)               取这块备忘板上的消息,用来重放气泡
  reset(session_id)                    清空这块备忘板

这个文件不导入 streamlit,也不打印任何东西。
换 Flask、换命令行、换企业微信回调,都能原样搬走。
"""

import os
from typing import Iterator, List

from langchain.chat_models import init_chat_model
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import BaseMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory

from history_store import get_history
from trim_policy import trim_history

SYSTEM_PROMPT = os.environ.get(
    "SYSTEM_PROMPT",
    "你是一个中文智能助手。回答简洁、准确、有条理。\n"
    "只依据用户提供的信息和你确知的常识回答;不确定就直说不确定,不要编造。",
)

MODEL_NAME = os.environ.get("CHAT_MODEL", "openai:gpt-4o-mini")
TEMPERATURE = float(os.environ.get("CHAT_TEMPERATURE", "0.3"))


def _build_prompt() -> ChatPromptTemplate:
    """三段式:人设在最前、历史插槽在中间、本轮输入在最后。

    MessagesPlaceholder 就是「把备忘板整块贴进来」的那个插槽,
    顺序写错(历史放到本轮输入之后)会让模型把旧话当成最新指令。
    """
    return ChatPromptTemplate.from_messages([
        ("system", SYSTEM_PROMPT),
        MessagesPlaceholder(variable_name="history"),
        ("human", "{question}"),
    ])


def _build_model():
    """密钥与模型名全部走环境变量,源码里不出现任何凭据。"""
    return init_chat_model(
        MODEL_NAME,
        temperature=TEMPERATURE,
        api_key=os.environ.get("OPENAI_API_KEY"),
        timeout=float(os.environ.get("CHAT_TIMEOUT", "60")),
        max_retries=int(os.environ.get("CHAT_MAX_RETRIES", "2")),
    )


def _history_with_trim(session_id: str) -> BaseChatMessageHistory:
    """工厂函数:取出这个会话的备忘板,顺手按预算修剪一次。

    修剪放在这里而不是放在界面层,是因为「一块板子最多贴多少张便签」
    属于后厨的规矩,前厅不该知道。
    """
    history = get_history(session_id)
    trim_history(history)
    return history


_chain = _build_prompt() | _build_model() | StrOutputParser()

conversation = RunnableWithMessageHistory(
    _chain,
    _history_with_trim,
    input_messages_key="question",
    history_messages_key="history",
)


def _cfg(session_id: str) -> dict:
    return {"configurable": {"session_id": session_id}}


def reply(session_id: str, question: str) -> str:
    return conversation.invoke({"question": question}, config=_cfg(session_id))


def reply_stream(session_id: str, question: str) -> Iterator[str]:
    """把链的增量片段原样透出去;写历史由 RunnableWithMessageHistory 负责。"""
    for chunk in conversation.stream({"question": question},
                                     config=_cfg(session_id)):
        if chunk:
            yield chunk


def history_of(session_id: str) -> List[BaseMessage]:
    return list(get_history(session_id).messages)


def reset(session_id: str) -> None:
    get_history(session_id).clear()


if __name__ == "__main__":
    # 脱离界面自测:通了说明后厨没问题,之后页面出错就是前厅的事
    sid = "selftest"
    print(reply(sid, "我叫孙小空,请记住"))
    print(reply(sid, "我叫什么名字?"))
    print("这块备忘板上有 %d 条消息" % len(history_of(sid)))
设计点为什么这么写
修剪放在工厂函数里「一块板最多贴多少张便签」是后厨的规矩,前厅不该知道;放这里还能保证每次取板都顺手修一次
人设、模型名、温度全走环境变量换环境不改源码;密钥更是只能走环境变量
timeoutmax_retries 交给模型客户端能在客户端解决的就别自己造轮子,外层只做兜底与文案翻译(4.7 节)
reply_stream 跳过空片段末片经常是空字符串,直接往界面上灌会多出无意义的渲染
__main__ 自测块能脱离界面单独跑,排查时这一点值一百行日志
✅ 写完先自测,再去碰界面 python3 chat_core.py 能打印出「你叫孙小空」,后厨就算过了。通了再写界面——之后页面出任何问题,你都能确定后厨这条链是好的。

4.2 历史存在哪:换存储只换一个函数

进程内字典有三个致命问题:重启即丢、多副本各存各的、内存只涨不降。前两个在单机开发时看不出来,一上线就集中爆发:用户刷新页面发现对话没了,或者同样的问题在两台机器上得到完全不同的上下文。

history_store.py —— 备忘板的容器:内存版与文件版可换存储
# -*- coding: utf-8 -*-
"""
备忘板的容器:history_store.py
================================
会话历史要存在哪,是整个应用最容易被低估的一个决定。

  进程内字典   开发够用;重启即丢、多副本各存各的、内存只涨不降
  本地文件     单机部署可用,重启还在;并发写要加锁
  Redis / 库   多副本、要过期、要审计时的正解

换存储只要换掉 get_history 的实现,链上其余代码一个字不用改——
这正是把工厂函数单独拿出来的价值。
"""

import json
import os
import threading
from typing import Dict, List

from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict

BACKEND = os.environ.get("HISTORY_BACKEND", "memory")   # memory | file
DATA_DIR = os.environ.get("HISTORY_DIR", "./chat_history")

_lock = threading.Lock()
_mem: Dict[str, BaseChatMessageHistory] = {}


class FileChatMessageHistory(BaseChatMessageHistory):
    """把一个会话的消息落成一个 JSON 文件。

    只需要实现三个成员,LangChain 就认它:
      messages      属性,返回 List[BaseMessage]
      add_messages  追加若干条,传空列表要能安全返回
      clear         清空本会话,对应界面上的「新建对话」
    """

    def __init__(self, session_id: str, directory: str = DATA_DIR):
        # 会话 ID 直接当文件名会被 ../ 穿越,只留安全字符
        safe = "".join(c for c in session_id if c.isalnum() or c in "-_")[:64]
        os.makedirs(directory, exist_ok=True)
        self.path = os.path.join(directory, "%s.json" % (safe or "anonymous"))

    @property
    def messages(self) -> List[BaseMessage]:
        if not os.path.exists(self.path):
            return []
        try:
            with open(self.path, encoding="utf-8") as f:
                return messages_from_dict(json.load(f))
        except (ValueError, OSError):
            # 单个会话文件写坏,不应该让整个应用起不来
            return []

    def add_messages(self, messages: List[BaseMessage]) -> None:
        if not messages:
            return
        with _lock:
            merged = self.messages + list(messages)
            tmp = self.path + ".tmp"
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(messages_to_dict(merged), f, ensure_ascii=False)
            os.replace(tmp, self.path)     # 原子替换,进程被杀也不会留半个文件

    def clear(self) -> None:
        with _lock:
            if os.path.exists(self.path):
                os.remove(self.path)


def get_history(session_id: str) -> BaseChatMessageHistory:
    """工厂函数:给一个会话 ID,返回它专属的那块备忘板。"""
    if BACKEND == "file":
        return FileChatMessageHistory(session_id)

    from langchain_core.chat_history import InMemoryChatMessageHistory
    with _lock:
        if session_id not in _mem:
            _mem[session_id] = InMemoryChatMessageHistory()
        return _mem[session_id]


def known_sessions() -> List[str]:
    """运维用:当前有哪些会话。内存版看字典,文件版看目录。"""
    if BACKEND == "file":
        if not os.path.isdir(DATA_DIR):
            return []
        return sorted(f[:-5] for f in os.listdir(DATA_DIR)
                      if f.endswith(".json"))
    return sorted(_mem)


if __name__ == "__main__":
    from langchain_core.messages import AIMessage, HumanMessage

    h = get_history("demo-001")
    h.add_messages([HumanMessage(content="我叫孙小空"),
                    AIMessage(content="记住了")])
    print("demo-001:", len(h.messages), "条")
    print("demo-002:", len(get_history("demo-002").messages), "条")   # 0
    print("当前会话:", known_sessions())
要实现的成员约定
messages属性,返回消息列表;读不出来时返回空列表而不是抛异常
add_messages(msgs)追加若干条;传空列表要能安全返回
clear()清空本会话,对应界面上的「新建对话」
存储重启还在多副本共享适用
进程内字典开发与调试
本地文件否(除非共享盘)单机部署、内部小范围使用
Redis / 数据库多副本、要过期、要审计
⚠️ 文件版里两处不能省的细节会话 ID 直接当文件名会被路径穿越——一个带 ../ 的 ID 就能写到任意目录,所以只保留安全字符并截断长度。
先写临时文件再原子替换。直接覆盖写,进程在中途被杀就会留下半个 JSON,下次启动整个会话读不出来。

4.3 修剪策略:两把尺子,一条底线

原理在 2.4 节讲过了,这里落地成一个可以单独跑的模块。两把尺子按需要选,底线只有一条:人设不能被裁掉

trim_policy.py —— 按条数或 Token 预算修剪历史修剪策略
# -*- coding: utf-8 -*-
"""
历史修剪与 Token 预算:trim_policy.py
=======================================
一块备忘板贴不下无限张便签。对话越长,每一轮送进模型的历史越长,
后果依次是:变慢 → 变贵 → 超出上下文窗口后被静默截断。

这里提供两把尺子,按需要选一把:

  按条数   trim_by_count   算得快,长短不均时不准
  按 Token trim_by_tokens  贵一点但准,生产建议用它

两把尺子都必须保证一件事:**system 那一条不能被裁掉**,
否则聊到几十轮后人设突然消失,表现为机器人「性格突变」。
"""

import os
from typing import List

from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import BaseMessage, trim_messages

MAX_MESSAGES = int(os.environ.get("MAX_MESSAGES", "20"))
MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "3000"))
STRATEGY = os.environ.get("TRIM_STRATEGY", "count")     # count | tokens


def _approx_tokens(messages: List[BaseMessage]) -> int:
    """粗略估算:中文按 1 字 ≈ 1 token 上限估,宁可高估也不要低估。

    真实计费以模型侧 tokenizer 为准,这里只用来做预算控制。
    """
    total = 0
    for m in messages:
        text = m.content if isinstance(m.content, str) else str(m.content)
        total += len(text) + 4          # 每条消息本身的角色与分隔开销
    return total


def trim_by_count(messages: List[BaseMessage],
                  limit: int = MAX_MESSAGES) -> List[BaseMessage]:
    """保留最近 limit 条,并且从一条用户消息开始,避免以孤立回答开头。"""
    if len(messages) <= limit:
        return list(messages)
    kept = list(messages[-limit:])
    while kept and kept[0].type == "ai":
        kept.pop(0)
    return kept


def trim_by_tokens(messages: List[BaseMessage],
                   limit: int = MAX_TOKENS) -> List[BaseMessage]:
    """用 langchain_core 的 trim_messages 按预算从后往前保留。

    start_on="human" 保证裁剪后第一条是用户消息;
    include_system=True 保证 system 不参与裁剪。
    """
    return trim_messages(
        messages,
        max_tokens=limit,
        strategy="last",
        token_counter=_approx_tokens,
        start_on="human",
        include_system=True,
        allow_partial=False,
    )


def trim_history(history: BaseChatMessageHistory) -> int:
    """就地修剪一块备忘板,返回裁掉的条数。

    实现方式是「清空再写回保留部分」,因为 BaseChatMessageHistory
    的公共约定里只有 add_messages 和 clear,没有「删掉前 N 条」。
    """
    messages = list(history.messages)
    if not messages:
        return 0

    kept = (trim_by_tokens(messages) if STRATEGY == "tokens"
            else trim_by_count(messages))
    dropped = len(messages) - len(kept)
    if dropped > 0:
        history.clear()
        history.add_messages(kept)
    return dropped


if __name__ == "__main__":
    from langchain_core.messages import AIMessage, HumanMessage, SystemMessage

    demo = [SystemMessage(content="你是客服助手")]
    for i in range(1, 16):
        demo.append(HumanMessage(content="第 %d 个问题" % i))
        demo.append(AIMessage(content="第 %d 个回答" % i))

    by_count = trim_by_count(demo, 8)
    by_token = trim_by_tokens(demo, 120)
    print("原始 %d 条,估算 %d token" % (len(demo), _approx_tokens(demo)))
    print("按条数保留 %d 条,首条是 %s" % (len(by_count), by_count[0].type))
    print("按预算保留 %d 条,system 还在:%s"
          % (len(by_token), any(m.type == "system" for m in by_token)))
参数作用怎么定
strategy="last"从后往前保留对话场景固定这个;"first" 用在要保住开头的场景
start_on="human"裁完第一条必须是用户消息避免以孤立回答开头
include_system=True人设不参与裁剪这一条就是底线本身
token_counter怎么算长度示例用字符数粗估,宁可高估;生产可换成模型侧的计数
为什么修剪要「清空再写回」 历史容器的公共约定里只有 add_messagesclear没有「删掉前 N 条」这种方法。所以就地修剪的通用做法是:读出来、算出要保留的部分、清空、再写回去。换成 Redis 实现时同理,只是这三步都发生在服务端。

4.4 会话标识:这个应用最敏感的字符串

匿名访客用随机 ID,登录用户由已验证的身份派生。不管哪种,都要经过一次校验再进链

session_keys.py —— 会话 ID 的生成、派生与校验会话隔离
# -*- coding: utf-8 -*-
"""
会话标识:session_keys.py
==========================
一个线上的聊天应用永远不止一个人在用。谁看到哪块备忘板,
全靠一个 session_id 决定——**它是这个应用最敏感的一个字符串**。

三条规矩:

  1. 会话 ID 由服务端生成或校验,绝不直接采信前端传来的值
  2. 一个人可以有多段会话(新建对话 = 换一块空白备忘板)
  3. 会话 ID 不要拿用户手机号、邮箱这类可枚举的东西直接拼

这里给出两种生成方式:匿名随机 ID,和登录态派生 ID。
"""

import hashlib
import hmac
import os
import re
import uuid

SECRET = os.environ.get("SESSION_SECRET", "")
SAFE = re.compile(r"^[A-Za-z0-9_-]{8,64}$")


def new_anonymous_id() -> str:
    """匿名访客:随机一个,够长、不可猜。"""
    return "anon-" + uuid.uuid4().hex


def derive_id(user_id: str, conversation_id: str) -> str:
    """登录用户:由服务端已验证的身份派生,前端改不动。

    用 HMAC 而不是直接拼接,是为了让 ID 既稳定(同一个人同一段会话
    每次算出来都一样)又不可反推(拿到 ID 也猜不出用户是谁)。
    """
    if not SECRET:
        raise RuntimeError("未配置 SESSION_SECRET,拒绝派生会话 ID")
    raw = "%s|%s" % (user_id, conversation_id)
    digest = hmac.new(SECRET.encode("utf-8"), raw.encode("utf-8"),
                      hashlib.sha256).hexdigest()
    return "u-" + digest[:40]


def validate(session_id: str) -> str:
    """任何从外部进来的会话 ID 都要先过这一关。

    不合格就抛异常,而不是「兜底给一个默认值」——
    默认值意味着所有非法请求共用同一块备忘板,那是最糟的串话方式。
    """
    if not session_id or not SAFE.match(session_id):
        raise ValueError("非法的会话标识")
    return session_id


def fingerprint(session_id: str) -> str:
    """写日志时用这个短指纹,不要把完整会话 ID 落进日志文件。"""
    return hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:8]


if __name__ == "__main__":
    a = new_anonymous_id()
    print("匿名会话:", a, "→ 校验通过:", validate(a) == a)
    print("日志指纹:", fingerprint(a))

    os.environ["SESSION_SECRET"] = "demo-secret-do-not-use-in-production"
    SECRET = os.environ["SESSION_SECRET"]
    s1 = derive_id("u1001", "c1")
    s2 = derive_id("u1001", "c2")
    s3 = derive_id("u2002", "c1")
    print("同人不同会话是否相同:", s1 == s2)      # False
    print("不同人同会话号是否相同:", s1 == s3)    # False
    print("同一入参两次是否稳定:", s1 == derive_id("u1001", "c1"))   # True

    for bad in ["", "../../etc/passwd", "abc"]:
        try:
            validate(bad)
        except ValueError as e:
            print("拒绝 %r%s" % (bad, e))
做法得到的 ID为什么
匿名随机一段随机十六进制够长、不可猜;浏览器标签关掉就作废
登录态派生身份的 HMAC 摘要前缀既稳定又不可反推:同一人同一段会话每次算出来一样,拿到 ID 也猜不出是谁
校验不合格直接抛异常不要兜底给默认值——那意味着所有非法请求共用一块板,是最糟的串话方式
日志只记短指纹八位哈希能判重复、还原不出会话,也就读不到别人的历史
⚠️ 直接拼接不等于派生用户ID + 会话号 拼成字符串当 key,看着也能隔离,但规律太明显:知道同事的工号就能算出他的会话 key。用 HMAC 派生的成本只有几行,换来的是「拿到一个 ID 也推不出下一个」。

4.5 界面层:删掉那份多余的气泡列表

和最小版最大的差别在这里:界面不再自己存消息。气泡的数据源就是备忘板,页面重绘时从后厨读一遍画出来。两边只留一份,就不会对不上。

chat_app.py —— 界面层:只收单、只画气泡界面层
# -*- coding: utf-8 -*-
"""
界面层:chat_app.py —— 只负责点单,不进后厨
==============================================
这个文件里没有一行 LangChain 代码。它只做四件事:

  1. 拿到这次会话的 session_id
  2. 把备忘板上的历史重放成气泡
  3. 收一句话,交给 chat_core
  4. 把返回的字流画出来

Streamlit 控件本身的机制(每次交互整脚本重跑、session_state 为什么必须用)
在「私有聊天机器人」那一讲已经讲透,这里只用结论。

启动:streamlit run chat_app.py
"""

import streamlit as st

import chat_core
import session_keys

st.set_page_config(page_title="智能聊天机器人", page_icon="💬")

# ① 会话标识:服务端生成一次,之后这一个浏览器标签一直用它
if "sid" not in st.session_state:
    st.session_state["sid"] = session_keys.new_anonymous_id()
sid = st.session_state["sid"]

# ② 侧边栏:把「当前连的是哪个模型、这块板上有多少条」摆出来
with st.sidebar:
    st.caption("会话指纹:%s" % session_keys.fingerprint(sid))
    st.caption("模型:%s" % chat_core.MODEL_NAME)
    st.caption("历史消息:%d 条" % len(chat_core.history_of(sid)))
    if st.button("新建对话"):
        chat_core.reset(sid)
        st.session_state["sid"] = session_keys.new_anonymous_id()
        st.rerun()

st.title("智能聊天机器人")

# ③ 重放历史:气泡的数据源是后厨的备忘板,界面自己不再存一份
#    两边各存一份迟早会对不上:界面上有、模型看不见,是最难查的那类 bug
ROLE = {"human": "user", "ai": "assistant"}
for message in chat_core.history_of(sid):
    role = ROLE.get(message.type)
    if role:
        with st.chat_message(role):
            st.markdown(message.content)

# ④ 收一句话,交给后厨
question = st.chat_input("输入你的问题")
if question:
    with st.chat_message("user"):
        st.markdown(question)

    with st.chat_message("assistant"):
        try:
            # write_stream 边收边画,返回值是拼好的完整文本
            st.write_stream(chat_core.reply_stream(sid, question))
        except Exception as exc:                      # noqa: BLE001
            # 后厨出故障时,页面要还在、错误要看得见,不能整页崩掉
            st.error("这次没能回答:%s" % exc)
            st.caption("稍后重试;若持续失败,检查模型服务与网络。")
段落做什么漏了会怎样
① 会话标识首次打开生成一个,存进页面状态每次交互换一个 ID,永远没有记忆
② 侧边栏显示会话指纹、模型、历史条数排查时问不清对方连的是哪台、聊了多久
③ 重放历史从备忘板读出来画气泡页面上只剩最新一条
④ 收单与流式交给后厨,边收边画
异常兜底try 包住调用,出错时给红条模型服务没起时整页崩掉,用户只会说「你这东西坏了」
新建对话清空旧板 + 换新 ID + 触发重绘点了没反应,或者换了 ID 旧板却留在内存里不释放
消息类型与气泡角色的映射 备忘板里的消息类型是 human / ai / system,而界面的气泡角色是 user / assistant。代码里用一个字典做映射,并且顺手把 system 过滤掉——人设是给模型看的,不该出现在用户屏幕上。

4.6 流式与度量:盯住首字延迟

流式做没做对,看一个数字就够了:首字延迟。它是用户按下回车到看见第一个字的时间,也是「这东西反应快不快」的全部主观来源。

stream_bridge.py —— 流式的三种消费方式与首字延迟度量流式
# -*- coding: utf-8 -*-
"""
流式怎么一路从模型传到界面:stream_bridge.py
==============================================
链的 stream() 本身就是一个生成器,中间没有任何魔法:
模型每吐一小块,链上每一节把它加工一下往下传,最后到你手里。

这个文件演示三件事:

  1. 最朴素的消费:for chunk in chain.stream(...)
  2. 把生成器包一层,顺带统计首字延迟、片数、总字数
  3. 用 astream_events 观察「现在流的是链上哪一节」

界面侧只需要拿到一个生成器,Streamlit 的 st.write_stream 会边收边画,
并把拼好的完整文本作为返回值给你——所以「流式显示」和
「把完整回答写回历史」两件事一行就办完了。
"""

import os
import time
from typing import Iterable, Iterator

from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "你是一个中文助手,回答控制在 200 字以内。"),
    ("human", "{question}"),
])
model = init_chat_model(os.environ.get("CHAT_MODEL", "openai:gpt-4o-mini"),
                        temperature=0.3,
                        api_key=os.environ.get("OPENAI_API_KEY"))
chain = prompt | model | StrOutputParser()


def measured(chunks: Iterable[str]) -> Iterator[str]:
    """包一层做度量:首字延迟是聊天体验里最该盯的那个数字。

    注意它仍然是生成器——包装不能把流攒起来,否则就退化成一次性返回。
    """
    started = time.time()
    first_at = None
    pieces = 0
    chars = 0
    for chunk in chunks:
        if not chunk:
            continue                      # 末片常常是空的,跳过
        if first_at is None:
            first_at = time.time() - started
        pieces += 1
        chars += len(chunk)
        yield chunk
    total = time.time() - started
    print("首字 %.2fs | 共 %d%d 字 | 总耗时 %.2fs"
          % (first_at or total, pieces, chars, total))


def stream_text(question: str) -> Iterator[str]:
    return measured(chain.stream({"question": question}))


async def watch_events(question: str) -> None:
    """想在界面上显示「正在检索…」「正在生成…」时用它。

    astream_events 给的是链上每一步的事件,而不只是最终文本:
    on_chat_model_stream 才是模型吐字,其余是节点的开始与结束。
    """
    async for ev in chain.astream_events({"question": question}):
        kind = ev["event"]
        if kind == "on_chat_model_stream":
            piece = ev["data"]["chunk"].content
            if piece:
                print(piece, end="", flush=True)
        elif kind in ("on_chain_start", "on_chain_end"):
            print("\n[%s] %s" % (kind, ev.get("name")))


if __name__ == "__main__":
    for piece in stream_text("用三句话解释什么是流式输出"):
        print(piece, end="", flush=True)
    print()

    # 事件版需要异步环境:
    # import asyncio; asyncio.run(watch_events("同上"))
观察到的现象多半是什么原因
只收到一片,且很晚才到链上有一节要攒齐才动,或反向代理把流缓冲住了
片数正常但首字很慢输入太长(历史没修剪)、模型排队、或本地模型算力不足
片数正常但界面一次性蹦出来包装函数把生成器攒成了列表,包装不能破坏流
末尾多出一个空气泡没跳过空片段
⚠️ 度量本身不能破坏流 给流加统计时,包装函数必须仍然是生成器——边透传边计数。写成「先收集成列表、算完再返回」,流式就退化成一次性返回了,而且从代码上看不出来,只有用户觉得「怎么又开始转圈了」。

4.7 重试与超时:失败要分类

模型调用是一次跨网络的远程请求,它一定会失败,只是频率问题。把失败分成三类,处理方式完全不同

类别典型该怎么做给用户看什么
可重试限流、5xx、连接超时退避后再试,多半能成「访问的人有点多,稍等几秒」
不可重试凭据无效、参数错误立刻失败,再试一百次也一样「服务配置有问题,请联系管理员」
超时总耗时超过时限主动掐断「这次等待超时了,换个更短的问法」
resilience.py —— 失败分类、退避重试、流式中断兜底容错
# -*- coding: utf-8 -*-
"""
错误处理、重试与超时:resilience.py
=====================================
模型调用是一次跨网络的远程请求,它一定会失败,只是频率问题。
把失败分成三类,处理方式完全不同:

  可重试   429 限流、5xx、连接超时 —— 退避后再试,多半能成
  不可重试 401 密钥错、400 参数错  —— 再试一百次也一样,直接报错
  超时     用户已经等太久          —— 主动掐断,给一句人话

重试次数与超时时间最好交给模型客户端自己做(init_chat_model 的
max_retries / timeout),这里再包一层是为了:统一兜底文案、
把技术异常翻译成用户看得懂的话、给运维留一条可统计的记录。
"""

import os
import random
import time
from typing import Callable, Iterator

MAX_ATTEMPTS = int(os.environ.get("CHAT_MAX_ATTEMPTS", "3"))
BASE_DELAY = float(os.environ.get("CHAT_BASE_DELAY", "1.0"))
DEADLINE = float(os.environ.get("CHAT_DEADLINE", "45"))

RETRYABLE_HINTS = ("timeout", "timed out", "rate limit", "429",
                   "500", "502", "503", "504", "connection", "temporarily")

FRIENDLY = {
    "auth": "服务配置有问题,请联系管理员(凭据未通过校验)。",
    "rate": "当前访问的人有点多,请稍等几秒再试。",
    "timeout": "这次等待超时了,换个更短的问法试试。",
    "other": "这次没能回答,请稍后重试。",
}


def classify(exc: Exception) -> str:
    text = ("%s %s" % (type(exc).__name__, exc)).lower()
    if "401" in text or "api key" in text or "unauthor" in text:
        return "auth"
    if "429" in text or "rate limit" in text:
        return "rate"
    if "timeout" in text or "timed out" in text:
        return "timeout"
    return "other"


def is_retryable(exc: Exception) -> bool:
    text = ("%s %s" % (type(exc).__name__, exc)).lower()
    if classify(exc) == "auth":
        return False
    return any(h in text for h in RETRYABLE_HINTS)


def call_with_retry(fn: Callable[[], str]) -> str:
    """指数退避 + 抖动。抖动很关键:没有它,一批用户会在同一毫秒齐刷刷重试。"""
    started = time.time()
    last = None
    for attempt in range(1, MAX_ATTEMPTS + 1):
        if time.time() - started > DEADLINE:
            raise TimeoutError("超过整体时限 %.0fs" % DEADLINE)
        try:
            return fn()
        except Exception as exc:                      # noqa: BLE001
            last = exc
            if attempt == MAX_ATTEMPTS or not is_retryable(exc):
                break
            delay = BASE_DELAY * (2 ** (attempt - 1)) * (1 + random.random() * 0.3)
            time.sleep(delay)
    raise last


def safe_stream(make_stream: Callable[[], Iterator[str]]) -> Iterator[str]:
    """流式版本:**只在第一片之前重试**。

    已经吐了半句话再重试,用户会看到同一段话说两遍。
    所以首片之后出错只能收尾,把残句留在屏幕上并追加一句提示。
    """
    for attempt in range(1, MAX_ATTEMPTS + 1):
        produced = False
        try:
            for chunk in make_stream():
                produced = True
                yield chunk
            return
        except Exception as exc:                      # noqa: BLE001
            if produced:
                yield "\n\n(回答被中断:%s)" % FRIENDLY[classify(exc)]
                return
            if attempt == MAX_ATTEMPTS or not is_retryable(exc):
                yield FRIENDLY[classify(exc)]
                return
            time.sleep(BASE_DELAY * (2 ** (attempt - 1)))


if __name__ == "__main__":
    calls = {"n": 0}

    def flaky():
        calls["n"] += 1
        if calls["n"] < 3:
            raise RuntimeError("503 service temporarily unavailable")
        return "第 %d 次成功" % calls["n"]

    print(call_with_retry(flaky))

    def bad_key():
        raise RuntimeError("401 invalid api key")

    try:
        call_with_retry(bad_key)
    except Exception as e:                            # noqa: BLE001
        print("不重试,直接失败:", classify(e), "→", FRIENDLY[classify(e)])
⛔ 流式重试只能在第一片之前 已经吐了半句话再重试,用户会看到同一段话说两遍,比报错还难堪。所以代码里用一个 produced 标记:首片之前失败可以重试;首片之后失败只能收尾,把残句留在屏幕上并追加一句说明。
退避一定要加抖动 固定间隔重试会让一批同时失败的用户在同一毫秒齐刷刷再来一次,把刚喘过气的服务再次压垮。乘一个随机系数,成本是一行代码。

4.8 换模型不换厨房

编排层里唯一和「具体哪家模型」有关的,就是建模型那一行。把它抽成一个函数,云端模型和本地模型就能共用同一套链、同一套记忆、同一套界面

model_switch.py —— 云端与本地共用同一套编排模型切换
# -*- coding: utf-8 -*-
"""
换厨师不换厨房:model_switch.py
=================================
编排层里唯一和「具体哪家模型」有关的,就是建模型那一行。
把它抽成一个函数,云端模型和本地 Ollama 就能共用同一套链、
同一套记忆、同一套界面——这正是分层的兑现时刻。

  云端     init_chat_model("openai:gpt-4o-mini")
  本地     init_chat_model("ollama:qwen2:7b")

init_chat_model 接受 "<provider>:<model>" 这种写法,
provider 对应装好的伙伴包(langchain-openai / langchain-ollama)。
本地模型不需要密钥,但需要 Ollama 服务已经起着。
"""

import os

from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate

PRESETS = {
    "cloud": {
        "spec": os.environ.get("CLOUD_MODEL", "openai:gpt-4o-mini"),
        "needs_key": True,
        "note": "效果稳定、开箱即用;数据要出公网,按 token 计费",
    },
    "local": {
        "spec": os.environ.get("LOCAL_MODEL", "ollama:qwen2:7b"),
        "needs_key": False,
        "note": "数据不出内网、无调用费;要自备显卡,效果取决于模型大小",
    },
}


def make_model(profile: str = None, **overrides):
    """按档位建模型。除了这个函数,其余代码不该出现任何厂商名字。"""
    profile = profile or os.environ.get("MODEL_PROFILE", "cloud")
    if profile not in PRESETS:
        raise ValueError("未知档位 %r,可选:%s" % (profile, list(PRESETS)))

    preset = PRESETS[profile]
    kwargs = {
        "temperature": float(os.environ.get("CHAT_TEMPERATURE", "0.3")),
        "timeout": float(os.environ.get("CHAT_TIMEOUT", "60")),
    }
    if preset["needs_key"]:
        key = os.environ.get("OPENAI_API_KEY")
        if not key:
            raise RuntimeError("档位 %s 需要 OPENAI_API_KEY,未配置则改用 local"
                               % profile)
        kwargs["api_key"] = key
    else:
        # 本地模型服务地址也走环境变量,换机器不改源码
        kwargs["base_url"] = os.environ.get("OLLAMA_HOST",
                                            "http://127.0.0.1:11434")
    kwargs.update(overrides)
    return init_chat_model(preset["spec"], **kwargs)


def make_chain(profile: str = None):
    """链的结构与档位完全无关,换档位只换中间那一节。"""
    prompt = ChatPromptTemplate.from_messages([
        ("system", "你是一个中文助手,回答简洁。"),
        ("human", "{question}"),
    ])
    return prompt | make_model(profile) | StrOutputParser()


def available_profiles():
    """给界面用:哪些档位现在真的能选。缺密钥的档位不该出现在下拉框里。"""
    usable = []
    for name, preset in PRESETS.items():
        if preset["needs_key"] and not os.environ.get("OPENAI_API_KEY"):
            continue
        usable.append(name)
    return usable or ["local"]


if __name__ == "__main__":
    for name, preset in PRESETS.items():
        print("%-6s %-24s %s" % (name, preset["spec"], preset["note"]))
    print("当前可选档位:", available_profiles())

    # 同一个问题跑两个档位,比较回答风格与耗时;没配密钥的档位会跳过
    for name in available_profiles():
        try:
            print("[%s] %s" % (name, make_chain(name).invoke(
                {"question": "一句话介绍你自己"})))
        except Exception as exc:                      # noqa: BLE001
            print("[%s] 不可用:%s" % (name, exc))
档位优点代价
云端效果稳定、开箱即用、不占本地算力数据要出公网,按用量计费
本地数据不出内网、无调用费用要自备显卡,效果取决于模型大小

本地模型的安装、显存占用、常驻托管在私有化部署那一个模块里已经讲过,这里只关心一件事:它进入这套编排的方式和云端模型完全一样——换一个模型标识字符串,链的结构一个字不变。

⚠️ 别把缺凭据的档位摆在下拉框里 available_profiles() 会把没配好凭据的档位过滤掉。让用户选一个注定报错的选项,是最没必要的一类差评——能在界面上避免的错误,就不要留到调用时再抛

4.9 换一个前端,验证铁律真的成立

「界面只是点单台」这句话,不验证一下就只是口号。下面这份把 Streamlit 换成纯命令行,chat_core.py 一个字都没改,直接导入过来用

cli_probe.py —— 同一个后厨,换成命令行前端分层验证
# -*- coding: utf-8 -*-
"""
不开界面也能聊:cli_probe.py
==============================
同一个 chat_core,换一个前端。这份脚本的价值有三个:

  1. 证明「界面只是点单台」这句话不是口号——后厨一行没改
  2. 排障时分层定位:命令行能聊 → 界面的锅;也不能聊 → 后厨的锅
  3. 服务器上没浏览器时,这是最快的验收手段

内置几个斜杠命令,用来观察后厨的状态:
  /new    换一块空白备忘板
  /hist   打印这块板上的全部消息
  /stat   看条数与估算 token
  /quit   退出
"""

import sys

import chat_core
import session_keys
from trim_policy import _approx_tokens


def show_history(sid: str) -> None:
    role = {"human": "我", "ai": "助手", "system": "人设"}
    for i, m in enumerate(chat_core.history_of(sid), 1):
        text = m.content if isinstance(m.content, str) else str(m.content)
        head = text.replace("\n", " ")[:60]
        print("  %2d. %-4s %s" % (i, role.get(m.type, m.type), head))


def main() -> int:
    sid = session_keys.new_anonymous_id()
    print("会话指纹 %s | 模型 %s | /quit 退出"
          % (session_keys.fingerprint(sid), chat_core.MODEL_NAME))

    while True:
        try:
            question = input("\n我> ").strip()
        except (EOFError, KeyboardInterrupt):
            print()
            return 0

        if not question:
            continue
        if question == "/quit":
            return 0
        if question == "/new":
            chat_core.reset(sid)
            sid = session_keys.new_anonymous_id()
            print("已换一块空白备忘板:%s" % session_keys.fingerprint(sid))
            continue
        if question == "/hist":
            show_history(sid)
            continue
        if question == "/stat":
            msgs = chat_core.history_of(sid)
            print("共 %d 条,估算 %d token" % (len(msgs), _approx_tokens(msgs)))
            continue

        print("助手> ", end="", flush=True)
        try:
            for piece in chat_core.reply_stream(sid, question):
                print(piece, end="", flush=True)
            print()
        except KeyboardInterrupt:
            # 这一轮答案不完整,把这一问一答从历史里撤掉,别让残句污染下一轮
            print("\n(已中断)")
            chat_core.reset(sid)
        except Exception as exc:                      # noqa: BLE001
            print("\n调用失败:%s" % exc)


if __name__ == "__main__":
    sys.exit(main())
它的实际用处说明
服务器上没浏览器这就是最快的验收手段,连上去直接聊
分层定位故障命令行能聊 → 界面的锅;也不能聊 → 后厨的锅
接企业微信、定时任务起点就是这份,把 input() 换成消息回调即可
/stat 看条数与估算 token直观感受「越聊越长」到底长多快,也验证修剪有没有生效
/hist 打印备忘板事后复盘模型到底看到了什么,比猜有用
✅ 铁律在这里被验证了 两个前端,一个跑在浏览器、一个跑在终端,共用同一个 chat_core、同一套记忆、同一套修剪与容错。这就是「换一个前端,后厨一行都不用改」的字面意思。

4.10 把链换成 Agent:界面层无感

前面的后厨是一条固定流水线。如果业务需要它自己决定「先查订单还是先查物流」,就把链换成 Agent——店长上岗,工具箱交给他。对界面层来说这个替换是无感的:它照样只调 replyreply_stream

agent_mode.py —— 后厨从链换成 Agent,函数签名保持不变回扣模块
# -*- coding: utf-8 -*-
"""
把后厨从「链」换成「店长」:agent_mode.py
==========================================
前面的应用是一条固定流水线:提示词 → 模型 → 解析器。
如果要让它自己决定「先查订单还是先查物流」,就把链换成 Agent。

对界面层来说,这个替换是无感的:它照样只调 reply / reply_stream。
后厨里换的东西有两处——

  记忆    RunnableWithMessageHistory → checkpointer + thread_id
  执行体  prompt | model | parser     → create_agent(...)

工具的协议层细节(模型怎么填参数、回执怎么贴回去)在 Function Call
那一讲;工具怎么写、description 怎么措辞在 Tools 与 Agent 实战那一讲。
这里只关心它怎么接进一个已有的聊天应用。
"""

import os
from typing import Iterator

from langchain.agents import create_agent
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver

SYSTEM_PROMPT = (
    "你是电商平台的客服助手。需要订单信息时调用工具,"
    "不要凭空猜测订单号或物流状态。"
)

_ORDERS = {
    "12345": {"status": "已发货", "amount": 299.0},
    "12346": {"status": "待付款", "amount": 158.0},
}


@tool
def query_order(order_id: str) -> str:
    """按订单号查询订单状态与金额。order_id 是纯数字的订单编号。"""
    row = _ORDERS.get(order_id.strip())
    if not row:
        return "未查到订单 %s" % order_id
    return "订单 %s%s,金额 %.2f 元" % (order_id, row["status"], row["amount"])


@tool
def refund_policy(keyword: str) -> str:
    """查询退换货政策。keyword 是要查的关键词,如「七天」「运费」。"""
    table = {
        "七天": "签收后七天内无理由退货,商品需不影响二次销售。",
        "运费": "质量问题运费由平台承担,非质量问题由买家承担。",
    }
    for k, v in table.items():
        if k in keyword:
            return v
    return "未找到相关条款,请转人工客服。"


# checkpointer 就是 Agent 这条路线上的备忘板;thread_id 相当于会话 ID
agent = create_agent(
    model=os.environ.get("CHAT_MODEL", "openai:gpt-4o-mini"),
    tools=[query_order, refund_policy],
    system_prompt=SYSTEM_PROMPT,
    checkpointer=InMemorySaver(),
)


def _cfg(session_id: str) -> dict:
    return {"configurable": {"thread_id": session_id}}


def reply(session_id: str, question: str) -> str:
    """和 chat_core.reply 同签名,界面层不用改一个字。"""
    result = agent.invoke(
        {"messages": [{"role": "user", "content": question}]},
        _cfg(session_id))
    return result["messages"][-1].content


def reply_stream(session_id: str, question: str) -> Iterator[str]:
    """流式:只把模型节点吐的字透给界面,工具执行过程不往界面上倒。

    v1 里模型节点名是 "model"(旧版叫 "agent"),流式过滤时别记错。
    """
    for chunk, meta in agent.stream(
            {"messages": [{"role": "user", "content": question}]},
            _cfg(session_id), stream_mode="messages"):
        if meta.get("langgraph_node") == "model" and chunk.content:
            yield chunk.content


if __name__ == "__main__":
    sid = "agent-demo"
    print(reply(sid, "帮我看下订单 12345 什么状态"))
    print(reply(sid, "那它多少钱来着?"))      # 考察 checkpointer 有没有生效
    print(reply(sid, "退货运费谁出?"))
换掉的东西链的写法Agent 的写法
执行体prompt | model | parsercreate_agent(model=..., tools=[...], system_prompt=...)
记忆RunnableWithMessageHistory + session_idcheckpointer + thread_id
流式过滤片段就是最终文本按节点名过滤,模型节点叫 model(旧版叫 agent
界面层调四个函数一个字不用改

工具的协议层细节——模型怎么把「要办哪件事、参数填什么」写成一张结构化委托单、回执怎么贴回对话——在 Function Call 那一讲;工具四要素怎么写、description 怎么措辞才会被选中,在 Tools 与 Agent 实战那一讲。这里只关心它怎么接进一个已有的应用。

⚠️ 别把工具执行过程原样倒给用户 流式过滤时只透出模型节点的内容。把工具的中间结果、内部检索日志一股脑推到聊天框里,既暴露内部实现,又让用户看不懂。要展示进度就用事件流做成一行状态提示,而不是把原始输出贴上去。
✅ 到这里,模块的六讲串成了一条链 菜谱卡与配菜台(Model I/O)→ 传送带(LCEL)→ 备忘板(Memory)→ 店长的循环(Agent)→ 委托单(Function Call)→ 前厅点单台(本讲)。同一个比喻从第一讲用到最后一讲,讲的其实是同一间厨房。

05骨架模板:拿去改就能交付

单文件骨架、验收脚本、上线前自检清单

5.1 单文件骨架

完整案例拆成十个文件,是为了讲清每个零件的边界。真要开一个新项目,先从这份单文件骨架起步更快:三层边界仍然在文件内部保持着,配置区的 TODO 改完就是你自己的业务助手;等到要拆的时候,把「后厨」整段剪进 chat_core.py 即可,前厅代码不用动。

chatbot_skeleton.py —— 单文件骨架,改 TODO 即可交付可复用模板
# -*- coding: utf-8 -*-
"""
骨架模板:chatbot_skeleton.py —— 改 TODO 就是你自己的业务助手
================================================================
这一份把前面拆开讲的零件装在一起,并且刻意写成**单文件**,
方便直接复制到新项目里跑起来。文件内部仍然保持三层边界:

  配置区(TODO 全在这里) → 后厨(建链、记忆、修剪) → 前厅(Streamlit)

要拆成多文件时,把「后厨」整段剪进 chat_core.py 即可,前厅代码不用改。

启动:streamlit run chatbot_skeleton.py
依赖:pip install streamlit langchain langchain-openai
"""

import os
import uuid

import streamlit as st
from langchain.chat_models import init_chat_model
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory

# ================================ 配置区 ================================
# TODO 1:换成你的模型。云端 "openai:gpt-4o-mini",本地 "ollama:qwen2:7b"
MODEL_SPEC = os.environ.get("CHAT_MODEL", "openai:gpt-4o-mini")

# TODO 2:写你的业务人设。务必写清边界,否则模型会一本正经地编
SYSTEM_PROMPT = (
    "你是某公司的智能助手。回答简洁、准确。\n"
    "只依据用户提供的信息和你确知的常识回答;不确定就说不确定,不要编造。"
)

# TODO 3:页面标题与开场提示
PAGE_TITLE = "智能助手"
INPUT_HINT = "输入你的问题"

# TODO 4:一块备忘板最多留多少条消息。太长会变慢、变贵,最后被静默截断
MAX_MESSAGES = int(os.environ.get("MAX_MESSAGES", "20"))

# TODO 5:需要持久化时,把 _store 换成文件或 Redis 实现
_store = {}

# ================================ 后厨 ==================================


def get_history(session_id: str) -> InMemoryChatMessageHistory:
    """工厂函数:一个会话 ID 一块备忘板,顺手修剪。"""
    if session_id not in _store:
        _store[session_id] = InMemoryChatMessageHistory()
    history = _store[session_id]

    messages = history.messages
    if len(messages) > MAX_MESSAGES:
        kept = list(messages[-MAX_MESSAGES:])
        while kept and kept[0].type == "ai":
            kept.pop(0)                 # 别让历史以一句孤立的回答开头
        history.clear()
        history.add_messages(kept)
    return history


@st.cache_resource(show_spinner=False)
def build_conversation():
    """只建一次。少了这个装饰器,页面每次重跑都会重新建链和客户端。"""
    model = init_chat_model(
        MODEL_SPEC,
        temperature=float(os.environ.get("CHAT_TEMPERATURE", "0.3")),
        api_key=os.environ.get("OPENAI_API_KEY"),
        timeout=float(os.environ.get("CHAT_TIMEOUT", "60")),
    )
    prompt = ChatPromptTemplate.from_messages([
        ("system", SYSTEM_PROMPT),
        MessagesPlaceholder(variable_name="history"),
        ("human", "{question}"),
    ])
    chain = prompt | model | StrOutputParser()
    return RunnableWithMessageHistory(
        chain,
        get_history,
        input_messages_key="question",
        history_messages_key="history",
    )


# ================================ 前厅 ==================================
st.set_page_config(page_title=PAGE_TITLE, page_icon="💬")

if "sid" not in st.session_state:
    st.session_state["sid"] = uuid.uuid4().hex
sid = st.session_state["sid"]

with st.sidebar:
    st.caption("模型:%s" % MODEL_SPEC)
    st.caption("历史:%d 条(上限 %d)"
               % (len(get_history(sid).messages), MAX_MESSAGES))
    if st.button("新建对话"):
        get_history(sid).clear()
        st.session_state["sid"] = uuid.uuid4().hex
        st.rerun()

st.title(PAGE_TITLE)

ROLE = {"human": "user", "ai": "assistant"}
for message in get_history(sid).messages:
    role = ROLE.get(message.type)
    if role:
        with st.chat_message(role):
            st.markdown(message.content)

question = st.chat_input(INPUT_HINT)
if question:
    with st.chat_message("user"):
        st.markdown(question)
    with st.chat_message("assistant"):
        try:
            st.write_stream(build_conversation().stream(
                {"question": question},
                config={"configurable": {"session_id": sid}}))
        except Exception as exc:                      # noqa: BLE001
            st.error("这次没能回答:%s" % exc)
TODO改成什么提示
MODEL_SPEC你的模型标识形如 provider:model本地模型要先把服务起起来
SYSTEM_PROMPT你的业务人设务必写清边界:「不确定就说不确定,不要编造」
PAGE_TITLE / INPUT_HINT标题与输入提示顺手告诉用户这个助手能干什么
MAX_MESSAGES备忘板的限高线先按平均消息长度估一个,再用 /stat 观察着调
_store历史容器要重启不丢就换成文件或 Redis 实现,其余代码不动
骨架已经替你处理好的在哪一段不做会怎样
历史自动修剪工厂函数里越聊越慢、越贵,最后被静默截断
人设不被裁掉修剪时只动最近 N 条聊久了机器人性格突变
链只建一次@st.cache_resource每次交互重建链与客户端
气泡只有一份数据源从备忘板重放屏幕上有、模型没看见
异常兜底调用外面的 try后厨一抖整页报红崩掉
新建对话清空旧板 + 换 ID + 重绘点了没反应,或旧板残留占内存

5.2 验收脚本:把手工点击变成可重复的检查

手工点一遍页面,改一次代码就要再点一遍,早晚会偷懒。把验收写成脚本,重点验的不是「有没有回答」,而是记忆、隔离、流式、修剪这四条链路

smoke_test.py —— 交付前验收,退出码可接进发版流程验收脚本
# -*- coding: utf-8 -*-
"""
交付前验收:smoke_test.py
===========================
手工点一遍页面,改一次代码就要再点一遍,早晚会偷懒。把验收写成脚本,
重点验的不是「有没有回答」,而是**记忆、隔离、流式、修剪**这四条链路。

退出码 0 表示全过,1 表示有失败,可以直接接进发版流程。
运行:python3 smoke_test.py
"""

import sys
import time

import chat_core
import session_keys
from trim_policy import trim_by_count

RESULTS = []


def check(name: str, ok: bool, detail: str = "") -> bool:
    RESULTS.append((name, ok, detail))
    print("%s %s %s" % ("[通过]" if ok else "[失败]", name, detail))
    return ok


def case_single_turn() -> bool:
    """1. 后厨能不能出菜。不通则后面的用例没有意义。"""
    text = chat_core.reply(session_keys.new_anonymous_id(), "回答一个字:好")
    return check("单轮调用", bool(text and text.strip()), "返回 %d 字" % len(text or ""))


def case_memory() -> bool:
    """2. 同一个会话 ID 里,第二轮能不能记得第一轮说的名字。"""
    sid = session_keys.new_anonymous_id()
    chat_core.reply(sid, "我叫孙小空,请记住这个名字")
    answer = chat_core.reply(sid, "我刚才说我叫什么?")
    return check("多轮记忆", "孙小空" in answer, answer.strip()[:40])


def case_isolation() -> bool:
    """3. 反向用例:换一个会话 ID 就不该记得。只验正向可能是模型蒙对的。"""
    sid_a, sid_b = (session_keys.new_anonymous_id() for _ in range(2))
    chat_core.reply(sid_a, "我叫孙小空,请记住这个名字")
    answer = chat_core.reply(sid_b, "我刚才说我叫什么?")
    return check("会话隔离", "孙小空" not in answer, answer.strip()[:40])


def case_stream() -> bool:
    """4. 流式要能收到多片;只收到一片说明流被谁缓冲住了。"""
    sid = session_keys.new_anonymous_id()
    started = time.time()
    first_at = None
    pieces = 0
    for chunk in chat_core.reply_stream(sid, "用三句话介绍中央厨房的分工"):
        if first_at is None:
            first_at = time.time() - started
        pieces += 1
    return check("流式分片", pieces > 1,
                 "%d 片,首字 %.2fs" % (pieces, first_at or -1))


def case_trim_keeps_system() -> bool:
    """5. 纯本地逻辑,不发请求:修剪之后人设必须还在。"""
    from langchain_core.messages import AIMessage, HumanMessage, SystemMessage

    msgs = [SystemMessage(content="你是客服助手")]
    for i in range(20):
        msgs += [HumanMessage(content="问%d" % i), AIMessage(content="答%d" % i)]
    kept = trim_by_count(msgs, 6)
    return check("修剪不裁人设", kept[0].type != "ai",
                 "保留 %d 条,首条 %s" % (len(kept), kept[0].type))


def main() -> int:
    if not case_single_turn():
        print("\n后厨不通,后续用例跳过。先查模型服务、密钥与网络。")
        return 1
    case_memory()
    case_isolation()
    case_stream()
    case_trim_keeps_system()

    failed = [n for n, ok, _ in RESULTS if not ok]
    print("\n%d 项,失败 %d 项" % (len(RESULTS), len(failed)))
    for name in failed:
        print("  · %s" % name)
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
用例验什么失败说明什么
1 单轮调用后厨能不能出菜模型服务、凭据或网络的问题,后面用例没意义,直接停
2 多轮记忆同一会话里第二轮记不记得名字插槽键名写错,或历史没写回
3 会话隔离反向用例:换会话 ID 就不该记得工厂函数把所有人指向了同一块板,这是最严重的一类事故
4 流式分片能不能收到多片只收到一片 → 链上有一节不支持流,或被代理缓冲
5 修剪不裁人设纯本地逻辑,不发请求人设会在聊得够久之后悄悄消失
✅ 第 3 个用例为什么必须有 只验「能记住」是不够的——万一是模型蒙对的呢?一正一反都符合预期,才能证明记忆真是那块备忘板带来的。这也是写测试的通用思路:正向证明功能存在,反向证明它没有越界。

5.3 上线前自检:本机跑通 ≠ 能交付

「本机打得开页面」离「同事随时能用、坏了有人知道」还差一整套。把清单写成可执行的检查项,缺什么一眼看得见——它不发任何模型请求,所以可以随时跑、也能进流水线

deploy_check.py —— 上线前配置与环境自检交付清单
# -*- coding: utf-8 -*-
"""
上线前自检:deploy_check.py
============================
「本机 streamlit run 打得开」离「同事随时能用、坏了有人知道」还差一整套。
这份脚本把交付清单写成可执行的检查项,缺什么一眼看得见。

它不发任何模型请求,只看配置与环境,所以可以随时跑、也能进 CI。
运行:python3 deploy_check.py
"""

import os
import shutil
import socket
import sys

CHECKS = []


def record(name, ok, detail, blocking=True):
    CHECKS.append((name, ok, detail, blocking))


def check_secrets():
    """密钥必须来自环境变量,源码里出现字面量是最常见的事故。"""
    key = os.environ.get("OPENAI_API_KEY", "")
    profile = os.environ.get("MODEL_PROFILE", "cloud")
    if profile == "local":
        record("模型凭据", True, "本地档位不需要密钥")
    else:
        record("模型凭据", bool(key),
               "已配置(长度 %d)" % len(key) if key else "未配置 OPENAI_API_KEY")


def check_session_secret():
    secret = os.environ.get("SESSION_SECRET", "")
    record("会话派生密钥", len(secret) >= 16,
           "已配置" if len(secret) >= 16 else "未配置或过短,登录态会话无法派生 ID")


def check_history_backend():
    backend = os.environ.get("HISTORY_BACKEND", "memory")
    if backend == "memory":
        record("历史存储", False,
               "进程内字典:重启即丢、多副本各存各的,仅适合开发", blocking=False)
    else:
        directory = os.environ.get("HISTORY_DIR", "./chat_history")
        writable = os.access(os.path.dirname(os.path.abspath(directory)) or ".",
                             os.W_OK)
        record("历史存储", writable, "%s(目录可写:%s)" % (backend, writable))


def check_trim():
    limit = int(os.environ.get("MAX_MESSAGES", "20"))
    record("历史上限", 2 <= limit <= 200,
           "MAX_MESSAGES=%d" % limit)


def check_timeout():
    timeout = float(os.environ.get("CHAT_TIMEOUT", "60"))
    record("超时设置", 5 <= timeout <= 180, "CHAT_TIMEOUT=%.0fs" % timeout)


def check_port(port=8501):
    """页面端口是否已被占用;同时提醒模型端口不应对外。"""
    with socket.socket() as s:
        s.settimeout(0.5)
        busy = s.connect_ex(("127.0.0.1", port)) == 0
    record("页面端口 %d" % port, True,
           "已有服务在监听" if busy else "空闲", blocking=False)


def check_process_manager():
    record("进程托管", shutil.which("systemctl") is not None,
           "有 systemctl,可写 service 常驻" if shutil.which("systemctl")
           else "未发现 systemctl,需另行托管进程", blocking=False)


def main():
    check_secrets()
    check_session_secret()
    check_history_backend()
    check_trim()
    check_timeout()
    check_port()
    check_process_manager()

    blocking_failed = 0
    for name, ok, detail, blocking in CHECKS:
        mark = "[通过]" if ok else ("[必修]" if blocking else "[提醒]")
        print("%s %-14s %s" % (mark, name, detail))
        if not ok and blocking:
            blocking_failed += 1

    print("\n还需人工确认:页面前置鉴权、模型端口只对网关开放、"
          "调用日志只记长度与短哈希。")
    print("必修项未通过 %d 条" % blocking_failed)
    return 1 if blocking_failed else 0


if __name__ == "__main__":
    sys.exit(main())
缺口怎么补脚本能不能自动查
凭据硬编码在源码里一律走环境变量能查是否配置,查不出源码里有没有字面量
历史重启就丢换文件或 Redis 存储
没有超时与上限CHAT_TIMEOUTMAX_MESSAGES
进程关掉就没了写成常驻服务托管只能提示环境是否具备
谁都能打开页面页面前置鉴权或接统一登录不能,人工确认
模型端口对外暴露只对网关开放不能,人工确认
日志把对话原文写进去只记长度与短哈希不能,人工确认
⚠️ 日志是最容易被忽略的泄漏口 内部助手的对话里经常夹着客户名、合同号、内部报价。把问题原文全写进日志,等于给做好的隔离开了一个后门——数据没出内网,却落到了一个没人管权限的文件里。记长度和短哈希就够判重复,还原不出原文。

三份产物怎么选

产物用途什么时候用
min_app.py理解机制的最短路径第一次接链与记忆时
chat_core.py 等十个文件讲清每个零件的边界要改记忆策略、换存储、加容错时按文件找
chatbot_skeleton.py拿去改的那一份开新项目、要交付给同事时

06易错点汇总

按「分层 / 记忆 / 会话 / 流式 / 容错 / 交付」六类归并,踩过一次就别再踩

⚠️ 一、分层与架构

  • 界面层直接建链、直接调模型。换前端时整套重写,出问题时也没法用 python3 chat_core.py 快速分层定位。编排层不许导入界面库,这条是分层是否成立的唯一硬标准。
  • 界面自己再存一份聊天记录。接入记忆之后,历史的唯一归属是备忘板。两边各存一份,迟早出现「屏幕上有、模型没看见」,而且这类错位极难查。气泡的数据源就该是备忘板本身。
  • 把修剪、重试、人设这些规矩写进界面层。它们是后厨的规矩,换一个前端就要重写一遍。
  • 没做「换前端」验证就宣称分层了。写一份几十行的命令行前端跑一遍,是最便宜的证明方式。
  • 模型名、服务地址、人设硬编码进源码。换环境就要改源码,改源码就会漏改。

⚠️ 二、记忆与上下文(本讲最大的一类)

  • 工厂函数每次都新建一个容器。少了「已存在就复用」那个判断,每轮都是一块空白板,表现为完全没有记忆
  • history_messages_key 与模板插槽的名字对不上。历史读出来了却填不进模板,现象和「根本没读」一模一样,查的时候容易往错误方向走。
  • 模板三段顺序写错。历史放到本轮输入之后,模型会把旧话当成最新指令;人设放到历史之后,约束力会随历史变长而稀释。
  • 历史无限增长。先变慢,再变贵,最后超出上下文窗口被静默截断且不报错——最难受的是它不报错。
  • 裁剪时把人设一起切掉。直接留最后 N 条,聊到几十轮后机器人性格突变。人设必须不参与裁剪。
  • 裁完以一句孤立的回答开头。没有对应的提问,模型会自行脑补语境,答案开始跑偏。
  • 以为修剪能「删掉前 N 条」。历史容器只有追加和清空两种操作,就地修剪的做法是「读出、算出保留部分、清空、写回」。
  • 用条数当唯一尺子。消息长短不均时,一条长文就能顶穿窗口。要准就按 Token 预算量。

⚠️ 三、会话标识与隔离

  • 会话 ID 写死成一个常量。开发时看不出问题,一上线所有人共用一块备忘板,互相看得见对方说过什么。这是本讲最严重的事故形态。
  • 直接采信前端传来的会话 ID。改一个参数就能读到别人的历史。鉴权必须在进入链之前做完,工厂函数只做「按 key 取容器」。
  • 校验不通过时兜底给一个默认 ID。等于把所有非法请求塞进同一块板,比直接报错更糟。
  • 用手机号、工号直接拼会话 key。规律太明显,知道同事工号就能算出他的 key。要派生就用带密钥的摘要。
  • 会话 ID 当文件名直接用。../ 的 ID 能写到任意目录,落盘前必须过滤字符并限长。
  • 「新建对话」只换了 ID,没清旧板。旧历史留在内存里只涨不降,长时间运行后内存被一堆没人再用的会话占满。
  • 把完整会话 ID 写进日志。日志一旦外泄,等于泄露了访问别人历史的钥匙。写短指纹就够用。

⚠️ 四、流式输出

  • 链上有一节要攒齐才动。流就断在那里,现象是「明明写了流式,界面还是卡半天然后整段蹦出来」。排查方法:把可疑的那一节摘掉再试。
  • 给流加统计时把生成器攒成了列表。包装必须仍然是生成器,边透传边计数,否则流式悄悄退化成一次性返回。
  • 不跳过空片段。末片经常是空的,直接渲染会多出无意义的空白。
  • 已经吐了半句话还去重试。用户会看到同一段话说两遍。流式重试只能发生在第一片之前。
  • 把工具执行的中间输出原样推到聊天框。既暴露内部实现,用户也看不懂。要显示进度就用事件流做成一行状态提示。
  • Agent 模式下按旧节点名过滤。当前版本模型节点叫 model,按旧名字过滤会一个字都收不到。
  • 反向代理缓冲了响应。本机好好的,上了网关就退化成一次性返回——这类问题查代码永远查不出来,要去看代理配置。

⚠️ 五、容错与稳定性

  • 没有异常兜底。模型服务没起、端口不通、超时都会抛异常,整页报红崩掉,用户只会反馈「你这东西坏了」。
  • 所有失败一律重试。凭据无效、参数错误再试一百次也一样,白白拖长用户等待。失败要先分类。
  • 重试不加抖动。一批同时失败的用户会在同一毫秒齐刷刷再来一次,把刚喘过气的服务再次压垮。
  • 只设单次超时,不设整体时限。三次重试各等一分钟,用户实际等了三分多钟还没结果。
  • 把技术异常原文直接甩给用户。一串英文堆栈既没用又吓人,更可能把内部地址、模型名甚至凭据片段暴露出去。翻译成一句人话,细节留给日志。
  • 中断的那一轮留在历史里。残缺的问答会污染下一轮上下文,该撤就撤掉。
  • 每次页面重跑都重建链与客户端。不加缓存时页面每交互一次就新建一遍,变慢还白占连接。

⚠️ 六、交付与安全

  • 把「本机跑起来了」当成「能交付了」。还差鉴权、常驻托管、持久化、日志、并发规划。
  • 为了让同事访问,顺手把模型端口也对外开了。要暴露的是聊天页面,模型端口仍然只对网关开放
  • 把对话原文全写进日志。内部助手的对话里常夹着客户名、合同号、报价,等于给隔离开了一个后门。只记长度与短哈希。
  • 凭据写死在源码里,或跟着代码一起提交。一律走环境变量,且不要打进日志和页面。
  • 人设里不写边界。没有「不确定就说不确定」这类约束,模型会一本正经地编,而同事会把它当成公司口径去执行
  • 把缺凭据的模型档位摆在下拉框里。让用户选一个注定报错的选项,是最没必要的一类差评。
  • 只验「能记住」,不验「不该记住的没记住」。少了反向用例,串会话这种事故要等用户来报。
  • 用旧写法起新项目。早期的一体化对话链已搬到 langchain-classic、不再是推荐写法;新项目直接用「普通链 + 记忆包装器」。

07自测题

点击题目展开答案;这 19 题说得清,这个案例就真的做得出来

一、架构与选型
搭一个聊天机器人有哪三条路?本讲为什么选第三条?

无代码平台(可视化配置,不写代码,需求简单时最快)、开源框架开发(自己搭,定制性强,但纯规则库的对话质量有限)、大模型集成(部署模型 + 框架编排 + 自写界面)。本讲走的是「开源框架 + 大模型集成」的综合路线:既保住数据与链路的掌控权,又能拿到高质量对话。三条路在实际项目里并不互斥,常见的是结合其中一两种。

三层架构分别是什么?各自绝对不做什么?

界面层只收一句话、画气泡、显示状态,不建链不存历史不认识模型;编排层建链、读写记忆、修剪、流式、兜底,不导入界面库、不打印任何东西模型层只负责算答案,不知道有没有界面。判断分层是否成立的硬标准就一条:编排层有没有导入界面库。

本讲的铁律是什么?怎么证明它成立?

界面只是点单台。会话记忆、链路编排、流式输出都在后厨;换一个前端,后厨一行都不用改。证明方式是 4.9 节:把 Streamlit 换成命令行前端,chat_core.py 一个字没动,两个前端共用同一套记忆、修剪与容错。

不用 LangChain 也能做聊天机器人,多这一层换来了什么?

换来统一接口与可替换零件:历史进出请求由记忆包装器负责(不用手工 append)、换存储只换工厂函数、换模型只换一个标识字符串、流式直接给生成器、要升级成 Agent 时界面层无感。代价是多一层依赖和一套概念。一次性 demo 不划算;要长期改、换模型、上多用户的应用很划算。

二、编排与记忆
编排层的四个零件是哪四个?

带插槽的提示词模板(人设 → MessagesPlaceholder → 本轮输入);② 一条普通的 LCEL 链prompt | model | parser,它本身不知道记忆这回事);③ 历史工厂函数(会话 ID → 历史容器);④ 记忆包装器 RunnableWithMessageHistory(调用前读、拿到回复后写)。缺一不可。

模型到底记不记得上一轮说过的话?

不记得。模型每一轮都是从零开始读你这次发过去的消息。所谓「它记得我叫孙小空」,是因为填菜谱卡那一步把上一轮的问答又原样塞了一遍进去。把备忘板拿掉,模型立刻失忆。

页面明明接了记忆,机器人却完全不记得,最可能的三个原因是什么?

工厂函数每次都新建容器(少了「已存在就复用」的判断);② history_messages_key 与模板插槽名字对不上,历史读出来了填不进去;③ 会话 ID 每次交互都换一个(没存进页面状态)。三种现象一模一样,都表现为「毫无记忆」,所以要逐个排除。

模板三段的顺序能不能调?

不能。必须是人设 → 历史 → 本轮输入。历史放到本轮输入之后,模型会把旧话当成最新指令;人设放到历史之后,它会被越堆越长的历史往后推,约束力显著下降。

早期资料里的一体化对话链现在还能用吗?该怎么表述?

那套(一体化对话链配缓冲记忆对象)已搬到 langchain-classic,不再是推荐写法,导入路径要相应改成从 langchain_classic 取。当代写法是「普通链 + RunnableWithMessageHistory」;Agent 那条路线则用 checkpointer + thread_id

三、会话隔离与修剪
为什么说 session_id 是这个应用最敏感的字符串?

因为谁看到哪块备忘板,完全由它决定。写死成常量 → 所有人共用一块板,互相看得见对方说过什么;直接采信前端传值 → 改一个参数就能读别人的历史。所以它必须由服务端生成或由已验证的登录态派生,鉴权要在进入链之前做完

校验不通过时,给一个默认会话 ID 兜底行不行?

不行,那比直接报错更糟:所有非法请求都会被塞进同一块备忘板,互相看到对方的内容。正确做法是抛异常拒绝。另外会话 ID 落盘当文件名前必须过滤字符并限长,否则带 ../ 的 ID 能写到任意目录。

历史无限增长会依次发生什么?

先变慢(输入越长首字延迟越高)→ 再变贵(历史每轮重发一遍,费用随轮数累积)→ 最后被静默截断(超出上下文窗口,最早的内容被丢掉且不报错)。最难受的是第三步不报错,只能靠观察发现。

修剪历史时有哪两条不能破的底线?

人设那一条不参与裁剪——直接留最后 N 条会把人设一起切掉,聊到几十轮后机器人性格突变,还极难查;② 裁完不要以一句孤立的回答开头——没有对应提问,模型会自行脑补语境,答案开始跑偏。

为什么就地修剪要写成「清空再写回」?

因为历史容器的公共约定里只有 add_messagesclear没有「删掉前 N 条」这种方法。所以通用做法是:读出来 → 算出要保留的部分 → 清空 → 写回去。换成 Redis 实现时同理,只是三步都发生在服务端。

四、流式、容错与交付
代码里写了流式,界面却卡半天然后整段蹦出来,怎么查?

典型原因是链上有一节必须拿到完整输入才能工作(普通后处理函数、要求完整结构的解析器),流断在那里。排查方法是把可疑的那一节摘掉再试一次。另外两种常见原因:给流加统计时把生成器攒成了列表;反向代理把响应缓冲住了(本机正常、上网关就退化,查代码永远查不出来)。

流式调用失败了能不能重试?

只能在第一片之前重试。已经吐了半句话再重试,用户会看到同一段话说两遍,比报错还难堪。首片之后失败只能收尾:把残句留在屏幕上,追加一句说明。实现上用一个「是否已产出」的标记来区分这两种情况。

模型调用失败该怎么分类处理?

可重试(限流、5xx、连接超时)→ 退避后再试,退避要加抖动,否则一批用户会在同一毫秒齐刷刷重试把服务再压垮;不可重试(凭据无效、参数错误)→ 立刻失败;超时 → 主动掐断。另外要设整体时限,否则三次重试各等一分钟,用户实际等了三分多钟。给用户看的永远是一句人话,技术细节留给日志。

验收脚本里为什么必须有「会话隔离」这个反向用例?

只验「能记住」不够——万一是模型蒙对的呢?一正一反都符合预期,才能证明记忆真是那块备忘板带来的。而且串会话是本讲最严重的事故形态,少了这个用例,只能等用户来报。

本机跑通之后,离真正交付还差哪几件事?

鉴权(页面前置登录,谁都能打开不行)、常驻托管(进程关掉就没了)、持久化(进程内字典重启即丢、多副本各存各的)、日志(不知道有没有人用、失败率多少;只记长度与短哈希,不记原文)、并发规划。还有一条容易忘:要暴露的是聊天页面,模型端口仍然只对网关开放

术语表与模块收束

本讲术语

术语含义
Streamlit纯 Python 的界面框架,本讲里只承担点单台的角色
LCEL用管道符把 Runnable 串成链的写法,prompt | model | parser
RunnableLangChain 的统一执行协议,invoke / batch / stream 都由它约定
Memory会话历史;本讲里就是墙上那块备忘板
MessagesPlaceholder提示词模板里的历史插槽,链自己往里填
RunnableWithMessageHistory记忆包装器:调用前读历史、拿到回复后写回
BaseChatMessageHistory历史容器的接口,只需实现 messages / add_messages / clear
session_id桌号牌,决定这一单落在哪块备忘板上
trim_messages按预算修剪历史的工具函数,能保住人设、能从用户消息开头
init_chat_modelprovider:model 建模型的统一入口
create_agent当前版本构建 Agent 的标准方式
checkpointerAgent 路线上的状态持久化组件,配 thread_id 区分会话
首字延迟按下回车到看见第一个字的时间,流式做没做对就看它
langchain-classic早期链与弃用功能的新家,旧写法的导入路径落在这里

这一讲用到的入口速查

要做的事入口要点
建模型init_chat_model("provider:model")凭据走环境变量,超时与重试交给它
建链prompt | model | parser链本身不知道记忆这回事
装记忆RunnableWithMessageHistory(...)两个 key 名必须与模板对上
指定会话config={"configurable": {"session_id": ...}}服务端生成或校验,绝不采信前端
一次性拿答案invoke后台任务、要整段做后处理时用
逐字输出stream聊天界面主用;包装它时别破坏生成器
看链上每一步astream_events要在界面显示进度提示时用
修剪历史trim_messages(...)保住人设、从用户消息开头
换成 Agentcreate_agent(..., checkpointer=...)会话键变成 thread_id,流式节点名是 model

交付前最后一遍检查

阶段怎么验通过标准
后厨通不通python3 chat_core.py脱离界面能拿到回答
记忆通不通先说名字,再问名字答得出才算真通
隔离通不通换一个会话 ID 再问一次答不出才算对
流式通不通数收到几片、首字多久多片且首字明显早于总耗时
修剪通不通命令行里 /stat 连看几轮条数到达上限后不再增长,人设还在
一次跑完python3 smoke_test.py退出码为 0
配置齐不齐python3 deploy_check.py必修项 0 条未通过

整个模块回头看

讲次厨房里的哪一块留下的铁律
LangChain 入门与 Model I/O菜谱卡、配菜台、摆盘工位LangChain ≠ 大模型,它一个 token 也不生成
LCEL 与链的组合传送带管道符不执行任何计算,只把上一节的输出接到下一节
会话记忆与多轮上下文墙上的备忘板模型本身没有记忆,全靠每轮把历史重新塞进去
Function Call委托单模型只填单子,执行的永远是你的代码
Agent 的原理店长的循环自主性长在循环里,循环的执行权仍在你手上
Tools 与 Agent 实战工具箱与名牌工具的 description 是写给模型看的
本讲前厅点单台 + 后厨界面只是点单台,换一个前端后厨一行都不用改
✅ 一句话收束整个模块 这六讲讲的始终是同一间厨房:先把工位、传送带、备忘板标准化,再给它配一个店长,最后在门口摆上点单台。做菜的从头到尾都是那位厨师——你真正搭起来的,是让他稳定出菜、让客人吃得顺手的那一整套流程。剩下的差距都在工程细节里,不在模型本身。