Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 4 期:记住对话——checkpointer 与 thread_id

第 3 期的客服每次运行都是新对话。客人问完改期再问“那退款呢“,它不知道“那“指什么。 上册练习 11 解决过这个问题:把 messages 数组写进会话文件,下次按会话 id 读回来接着跑。

LangGraph 里这叫 checkpointer。这一期节点和边一行不改,只在编译时挂上它, 然后跨进程接着聊。

敲进去

第 4 期的代码在 code/ep04/state.pytools.pyprompts.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 重新跑起。 这一期不展开,加分练习里试一下。

加分练习

  1. SqliteSaver 换成 from langgraph.checkpoint.memory import InMemorySaver, 跑实验一、二。第二轮为什么什么都不记得了?
  2. t1 再问一句“帮我看看 KL-901 的情况“,然后 --history t1。消息条数涨了多少, checkpoint 涨了多少,对照“每步落一次“那条规律验算。
  3. agent 节点里加一行:如果 messages 超过 20 条,只把最近 10 条发给模型 (系统提示词照常带)。这是练习 12 那个预算的最简版本。想一想它会丢掉什么。
  4. graph.get_state_history(config) 找到第一轮 get_order 刚跑完那一步的快照, 拿它的 config 再 invoke 一次,问一个不同的问题。看看图从哪里接着跑。