TT Lab
开始
学习 学习路径 课程

AI 智能体 — 是图不是模型

还能回到昨天那个位置吗

在 TT Lab 中继续学习

一句话总结

只要加上 checkpointer,图就会在每一步把状态整个留下来。所以能接着运行,能回溯到过去,还能从那里分叉出新的分支。而且出于同样的理由,状态的大小就是保存成本。

为什么需要它

假设智能体跑十步,在第八步失败了。没有 checkpointer 的话,办法只有一个——从头再来。如果前七步写了文件、发了邮件,这些也会再发生一遍。

更常见的是这种情况。用户拿到答案后问:“如果第三步用了别的资料会怎样?”如果状态没有留下,就没有办法回答。只能修改输入从头再运行,那样连前两步做出的结果都要重新生成。本应在相同条件下比较,条件却变了。

checkpointer 用同一种办法解决这两个问题。每一步结束时,把那一时刻的完整状态保存一份。每个保存下来的时刻都带有一个标签(checkpoint_id),拿着这个标签,随时都可以回到那个位置。

工作原理

用法只有两行。编译时给出 checkpointer,运行时给出 thread_id。

from langgraph.checkpoint.memory import MemorySaver

app = graph.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "user-42"}}
app.invoke({"topic": "가을"}, config)

thread_id 是指向一段对话的名称。给同一个值,就在已保存的状态上接着运行;给不同的值,就从什么都没有的地方重新开始。Persistence 文档把这称为短期记忆——意思是只在一段对话之内延续的记忆。

这里重要的是保存下来的时刻有几个。把串联了三个节点的图在本实验环境(langgraph 0.2.60)中运行一次,再数 get_state_history(config),得到的是五个。接收输入的位置一个,每个节点结束时各一个共三个,再加上全部结束的位置一个。每多一个节点,检查点也多一个。

列表是从最新的开始给出的。想按时间顺序读,就得反转。一个快照中有那一时刻的值(values)、接下来要运行的节点(next),以及指向那个位置的 config。

回溯——从保存的位置重新运行

直接使用过去快照的 config,并把输入设为 None,就会从那个位置重新运行。

snapshot = ...            # next 가 ("write",) 인 스냅샷
app.invoke(None, snapshot.config)

关键是把输入设为 None。给新输入就是重新开始,而 None 的意思是“在保存的那个状态上接着运行”。实际测量,回溯到 write 之前,检查点增加了两个(write 和 review)。回溯到 plan 之前是三个,回溯到 review 之前是一个。重新运行了多少,就只增加多少。

分叉——修改值,走另一条路

在同一个坐标上改一个值放进去,就会从那里出现另一个分支。

forked = app.update_state(snapshot.config, {"angle": "비교"})
app.invoke(None, forked)

update_state 会在那个位置再叠加一个检查点,并返回带有新 checkpoint_id 的 config。用它接着运行,就会生成新的分支。

这里有一个常见的误解。原来的分支不会被删除。检查点原样保留,所以随时可以用那一时刻的 config 读取。但是,只给 thread_id 调用 get_state(config),返回的是新分支的末尾。因为线程的“当前”已经转移过去了。想把原来的结果和新结果并排比较,分叉之前就必须把那一时刻的 config 拿在手里。在本实验中测量,运行一次之后在 write 之前分叉,检查点从五个变成了八个——创建分支的那个位置一个,再加上重新运行的两个节点。Use time-travel 把这两件事分别称为重放和分叉来说明。

保存的是整个状态

不必为检查点里放什么而困惑。是整个状态,不是一部分。

所以状态里放进大的值,检查点也会跟着变大。用时间来说,会因机器和负载而不同,但同样的状态大小总是一样的,所以测量就行。在本实验的图中,把状态序列化后数字节。什么都没放时,五个检查点的总和是 399 字节。在状态中放入一个 1,500 字节的值,运行同一个图,总和变成了 6,447 字节。真正带着那个值的检查点,五个中有四个。放进去一次的值,被保存了四次。

这个比例会随着节点数增加而变大。节点有三十个,放进去一次的值就会被保存将近三十次。所以大的原文或表格放在状态之外(文件、存储),状态里只放指向它的值,会更好。

在现场相遇的样子

第一,对话无法延续。是没有加 checkpointer,或者加了却每次都新建 thread_id。不会报错,只是每次都像第一次那样表现。实际测量,对没有 checkpointer 的图用同一个 config 运行两次,两次都是重新开始。

第二,分叉之后看不到原来的结果。不是被删除,而是线程的“当前”转移了。只要拿着原来那一时刻的 config,就能原样读出来。

第三,保存量明显增加。把原文整个放进状态、并且经过很多节点的图就是这样。要找原因,必须测的是大小而不是时间。

第四,Pod 一挂就全没了。MemorySaver 如其名,放在进程内存里。新进程启动后,即使用同一个 thread_id 去问,也什么都没留下。用于实验或测试是合适的,但不适合生产环境。需要长期保存的话,就用写入外部存储的 checkpointer——有哪些,写在 Persistence 和图 API 参考里。

实际工作中真正重要的事

下一项实验要做什么

一步步扩展 /root/work/agckpt/ckpt.py。先用 MemorySaver 和 thread_id 确认同一段对话会延续、不同的对话各自保留,并数出检查点、读成列表。接着做出查找回溯坐标的函数,用那个坐标回溯,修改值分叉出新分支后,再把原来的分支重新读出来。然后把状态序列化后数字节,用数字确认一个大的值被装在了几个地方,最后亲手复现重新建立账簿会让什么消失。评分器会真正导入你的模块并运行,每次用不同的主题和不同大小的值来检验。