第 4 期:记住对话——checkpointer 与 thread_id
第 3 期的客服每次运行都是新对话。客人问完改期再问“那退款呢“,它不知道“那“指什么。 上册练习 11 解决过这个问题:把 messages 数组写进会话文件,下次按会话 id 读回来接着跑。
LangGraph 里这叫 checkpointer。这一期节点和边一行不改,只在编译时挂上它, 然后跨进程接着聊。
敲进去
第 4 期的代码在 code/ep04/。state.py、tools.py、prompts.py 和数据文件
从第 3 期原样复制,改动只在两处。
graph.py 的最后一行:
def build_graph(checkpointer):
builder = StateGraph(AgentState)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route, {"tools": "tools", END: END})
builder.add_edge("tools", "agent")
# 唯一的改动:checkpointer 在这里挂上。每个节点跑完,状态落一次盘。
return builder.compile(checkpointer=checkpointer)
build_graph 多了一个参数,compile 多了一个关键字。节点、边、条件边全没动。
main.py 负责造 checkpointer,并且告诉图“这是哪一场对话“:
from langgraph.checkpoint.sqlite import SqliteSaver
DB = Path(__file__).parent / "data" / "checkpoints.sqlite"
# SqliteSaver 把每一步的状态写进一个文件。进程退出再起来,文件还在。
with SqliteSaver.from_conn_string(str(DB)) as saver:
graph = build_graph(saver)
thread_id, question = args[0], " ".join(args[1:])
# thread_id 是"这是哪一场对话"。同一个 id 进来,就接着上次的状态跑。
config = {"configurable": {"thread_id": thread_id}}
state = {"messages": [HumanMessage(question)]} # 只传新的这一句,历史在 checkpoint 里
for update in graph.stream(state, config=config, stream_mode="updates"):
...
两个新概念。SqliteSaver 是 checkpointer 的一种实现,状态写进一个 SQLite 文件;
开发时还有 InMemorySaver,存在进程内存里,进程一退就没了。thread_id 通过 config
传给图,同一个 id 对应同一份状态。练习 11 的会话文件名,就是这里的 thread_id。
注意 state 里只有新的那一句。历史不用你传,图按 thread_id 从 checkpoint 里读出来,
再用第 3 期那个 add_messages reducer 把新消息追加上去。
main.py 还有一个 --history 模式,把某个 thread 存了什么打出来:
snapshot = graph.get_state(config)
msgs = snapshot.values.get("messages", [])
steps = sum(1 for _ in graph.get_state_history(config))
print(f"thread {thread_id}:{len(msgs)} 条消息,{steps} 个 checkpoint")
get_state 拿当前状态,get_state_history 拿这个 thread 从头到现在每一步的快照。
跑起来
每条命令是一个独立进程,跑完就退出。
cd code
uv run python -m ep04.main t1 "订单 KL-778 能改到下周六吗"
uv run python -m ep04.main t1 "那退款呢"
uv run python -m ep04.main t2 "那退款呢"
uv run python -m ep04.main --history t1
uv run python -m ep04.main --history t2
你应该看到什么
实验一:第一轮,跟第 3 期一样
[agent] 要调 get_order({'order_id': 'KL-778'})
[tools] get_order 返回:{"order_id": "KL-778", "customer": "王小姐", "product_id": "SKU-1001", ...
[agent] 要调 get_policy({'product_id': 'SKU-1001', 'topic': 'reschedule'})
[tools] get_policy 返回:东京迪士尼一日票 的 reschedule 政策:出行日前 3 天可免费改期一次,改期后不可再改;出行日前 3 天内不支持改期。
[agent] 回答:可以改期。您的出行日为 9/6,今天 9/2 仍在"出行日前 3 天"免费改期期内。下周六为 9/12,可免费改期一次(改后不可再改)。需要我为您操作吗?
跟第 3 期实验三同一条路。区别在看不见的地方:ep04/data/checkpoints.sqlite 出现了。
实验二:新进程,同一个 thread,接着问
[agent] 要调 get_policy({'product_id': 'SKU-1001', 'topic': 'refund'})
[tools] get_policy 返回:东京迪士尼一日票 的 refund 政策:出行日前 7 天可全额退款;7 天内不支持退款。
[agent] 回答:抱歉,该票不支持退款。出行日为 9/6,需在 9/1 前(出行前 7 天)申请才可全额退款,今天已超出时限。
问题只有三个字“那退款呢“。模型知道“那“是 KL-778,知道商品是 SKU-1001,知道出行日是 9/6, 所以没有重查订单,直接查了退款政策,还算了一次日期。这些信息全部来自上一个进程 留在文件里的对话记录。
实验三:换一个 thread,同样三个字
[agent] 回答:您好,请问您的订单号是多少?我需要先查询订单信息才能帮您核实退款政策。
t2 从没用过,模型什么都不知道,只能反问。thread 之间互不可见,
这就是多用户同时用一个服务时要的隔离。
实验四:看 t1 存了什么
thread t1:10 条消息,12 个 checkpoint
Human 订单 KL-778 能改到下周六吗
AI tool_calls=['get_order']
Tool {"order_id": "KL-778", "customer": "王小姐", "product_id": "SKU
AI tool_calls=['get_policy']
Tool 东京迪士尼一日票 的 reschedule 政策:出行日前 3 天可免费改期一次,改期后不可再改;出行日前 3 天内不支
AI 可以改期。您的出行日为 9/6,今天 9/2 仍在"出行日前 3 天"免费改期期内。下周六为 9/12,可免费改期一次(
Human 那退款呢
AI tool_calls=['get_policy']
Tool 东京迪士尼一日票 的 refund 政策:出行日前 7 天可全额退款;7 天内不支持退款。
AI 抱歉,该票不支持退款。出行日为 9/6,需在 9/1 前(出行前 7 天)申请才可全额退款,今天已超出时限。
两轮对话,十条消息,工具调用和工具结果都在里面。练习 11 的会话文件打开来就是这个样子。
t2 是 2 条消息 3 个 checkpoint。一个从没用过的 t9 是 0 条 0 个。
发生了什么
checkpointer 在每一步之后落盘,不是每一轮。 看数字:t1 两轮一共跑了 8 个节点 (第一轮 5 个,第二轮 3 个),checkpoint 却有 12 个;t2 跑了 1 个节点,checkpoint 有 3 个。 规律是每次 invoke 先记两个(输入进来一次,起点一次),之后每个节点跑完记一个。 练习 11 是一轮对话结束写一次文件,这里粒度细到节点。细到节点有什么用,第 5 期立刻用上: 图可以停在某个节点上,下次从那个节点继续。
thread_id 是状态的钥匙,不在状态里。 它走 config,跟状态分开。同一张编译好的图,
换一个 thread_id 就是另一场对话。第 12 期包成服务的时候,每个用户会话对应一个 thread_id,
图只有一份。
你只传增量,历史归 checkpointer。 每次 invoke 传进去的是新的那一条 HumanMessage。
图先读出这个 thread 上次的状态,再用 add_messages 把新消息合并上去。
第 3 期说 reducer 是“大家的记录“,这一期“大家“里多了一个:上一个进程。
节点和边一行没改。 记忆这个能力从编译参数上加进来的。第 3 期那张对照表里没有 “会话文件“这一行,因为练习 11 的持久化是在循环外面包的一层,这一期也在外面。
框架管存,不管删。 上册练习 12 算上下文预算,练习 13 在预算超了的时候让模型
总结自己。这两样 checkpointer 都不做,它只负责把状态完整存下来。对话越长,
每次发给模型的消息越多。什么时候裁、怎么裁,你要自己加一个节点或者在 agent 节点里
处理,langchain 里有 trim_messages 这类现成的裁剪函数可以用。第 1 期那张表把练习 11
到 13 都指向了 checkpointer,准确的说法是:存的部分它替你做了,预算和压缩没有。
常见问题
SqliteSaver 能上生产吗? 单机、单进程可以。多实例部署要换 PostgresSaver,
接口一样,第 13 期换。InMemorySaver 只用于开发和测试。
thread_id 谁来定? 你的应用层。命令行里是你敲的字符串,服务里通常是会话 id 或者用户 id 加会话序号。图不关心它长什么样,只当作键。
checkpoint 文件会一直长吗? 会。这一期两轮对话就写了 90 多 KB。生产上要有清理策略, 按 thread 的最后活跃时间删。这跟练习 11 的会话文件目录要定期清理是一回事。
同一个 thread 两个请求同时进来会怎样? 这一期没有处理。第 12 期包服务时再说, 到那时要么在应用层排队,要么保证一个 thread 同时只有一个请求在跑。
get_state_history 能干什么? 除了看历史,还能从历史里某一个 checkpoint 重新跑起。
这一期不展开,加分练习里试一下。
加分练习
- 把
SqliteSaver换成from langgraph.checkpoint.memory import InMemorySaver, 跑实验一、二。第二轮为什么什么都不记得了? - 用
t1再问一句“帮我看看 KL-901 的情况“,然后--history t1。消息条数涨了多少, checkpoint 涨了多少,对照“每步落一次“那条规律验算。 - 在
agent节点里加一行:如果messages超过 20 条,只把最近 10 条发给模型 (系统提示词照常带)。这是练习 12 那个预算的最简版本。想一想它会丢掉什么。 - 用
graph.get_state_history(config)找到第一轮get_order刚跑完那一步的快照, 拿它的config再 invoke 一次,问一个不同的问题。看看图从哪里接着跑。