AgentDepthAgent 工程学习指南
输入概念或问题,检索正文和待编写目录。
第一次学习?从 L0 总览开始 →

Agent 中断后怎么继续:检查点与持久状态

我的学习 →

正在读取学习记录…

检查点是在一个执行位置保存下来的进度快照,包含下一步的位置和已经取得的结果。持久状态是写入文件或数据库、进程退出后仍能读到的数据;检查点可以用这样的状态保存。

玩游戏时,存档记录角色位置和装备;工作流的存档记录任务位置和数据。程序还要明确约定什么时候保存、保存哪些字段,以及旧进度怎样由当前代码继续处理。

上一节把查询结果汇总起来。结果如果只存在内存里,关闭程序就会丢失。下面先整理文本中的空白,再统计单词数量;第一步完成后保存并退出,第二次启动继续第二步。

完成第一项工作后把进度卡放进抽屉,回来时读卡接着做下一项
图1:用进度卡记下已完成的工作,回来后从下一项继续。
三个独立进程依次保存整理后的文本、恢复统计单词、读取已完成状态
图2:进程各自启动,数据库文件把执行进度接起来。

检查点保存哪些执行状态

恢复时需要找到任务、核对输入与流程版本,再取出中间结果和下一步位置。

先看最小的存档内容。整理空白之前,原文本是 " hello agent ";整理之后得到 "hello agent",接下来要统计单词。此时保存这两个信息就能理解恢复的核心:

JSON
{"next_step": 1, "data": {"normalized": "hello agent"}}

next_step=1 表示第0步已经完成,下一次从第1步开始;data 保存前一步的结果。重新启动后,程序读出它,用 hello agent 统计出2个单词。下面的完整实现再给存档加上任务身份和版本检查。

字段本例含义没有它的风险
run_id一次任务的稳定标识恢复时找错任务
owner任务归属的测试用户用任务编号越权读取他人状态
input / fingerprint原始输入及其摘要同一编号悄悄换了任务内容
workflow执行规则版本,本例v1新代码误解旧节点和旧状态
next_step下一个尚未完成的步骤将“当前节点”误当“已完成节点”
revision每次成功写入后的状态版本旧页面或旧worker覆盖新状态
data已确认的中间结果恢复后不得不重新计算前置步骤

fingerprint 是输入的摘要值,用于核对输入是否变化。数据库另存原始输入,因而仍需要按资料的访问权限管理文件。

输入摘要核对任务内容,状态版本核对最新进度,流程版本核对执行规则。三项分别记录,恢复时才能找出变化发生在哪一处。

例如两个窗口都读到 revision=0:第一个窗口保存成功后,版本变为1;第二个窗口仍拿着0,就先重新读取最新记录。这里的 revision 像进度卡的修订次数,随着保存递增。workflow='v1' 则是处理步骤所用规则的名称,只在规则升级时改变。

保存代码,分三次执行

先在 PowerShell 创建并进入学习目录:

Bash
mkdir "F:\学习\l4-checkpoints" -Force
cd "F:\学习\l4-checkpoints"

保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。

新建 F:\学习\l4-checkpoints 文件夹,把以下文件保存进去。SQLite用一个数据库文件保存表格数据,Python自带的sqlite3模块可以读写它;首次运行程序会自动建表。

  • checkpoint_lab.py:两步流程与检查点存储。
  • test_checkpoint_lab.py:七项自动测试。

读代码时先认三项操作:Store 管理数据库文件;get 读取一份存档;advance 完成下一步并保存新存档。main 根据命令选择“首次执行”或“恢复”。SQLite里的 runs 表相当于存档册,一行保存一次任务。

先在 PowerShell 创建并进入学习目录:

Bash
mkdir "F:\学习\l4-checkpoints" -Force
cd "F:\学习\l4-checkpoints"

保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。

Python
"""将文本处理进度保存到SQLite,在新进程中继续执行。"""
import argparse
import hashlib
import json
import sqlite3


class Conflict(ValueError):
    pass


class Store:
    def __init__(self, path, workflow_version='v1'):
        self.workflow_version = workflow_version
        self.db = sqlite3.connect(path, isolation_level=None, timeout=5)
        self.db.row_factory = sqlite3.Row
        self.db.execute('''CREATE TABLE IF NOT EXISTS runs(
            run_id TEXT PRIMARY KEY, owner TEXT NOT NULL, input TEXT NOT NULL,
            fingerprint TEXT NOT NULL, workflow TEXT NOT NULL,
            next_step INTEGER NOT NULL, revision INTEGER NOT NULL, data TEXT NOT NULL)''')

    def close(self):
        self.db.close()

    def create(self, run_id, owner, text):
        # 输入摘要用于检查同一个任务编号是否仍对应原来的文本。
        digest = hashlib.sha256(text.encode('utf-8')).hexdigest()
        self.db.execute('INSERT OR IGNORE INTO runs VALUES(?,?,?,?,?,0,0,?)',
                        (run_id, owner, text, digest, self.workflow_version, '{}'))
        row = self.get(run_id, owner)
        if row['fingerprint'] != digest or row['workflow'] != self.workflow_version:
            raise Conflict('same run requires same input and workflow')
        return row

    def get(self, run_id, owner):
        # 按任务和归属一起读取,随后把JSON字符串还原成字典。
        row = self.db.execute('SELECT * FROM runs WHERE run_id=? AND owner=?', (run_id, owner)).fetchone()
        if row is None:
            raise PermissionError('run unavailable')
        value = dict(row)
        value['data'] = json.loads(value['data'])
        return value

    def advance(self, run_id, owner, expected_revision, crash_before_commit=False):
        # 结果、下一步和版本在同一个事务里更新。
        self.db.execute('BEGIN IMMEDIATE')
        try:
            state = self.get(run_id, owner)
            if state['workflow'] != self.workflow_version or state['revision'] != expected_revision:
                raise Conflict('stale checkpoint or workflow mismatch')
            step, data = state['next_step'], state['data']
            # 第0步规范化空白,第1步统计单词,第2步表示已经完成。
            if step == 0:
                data['normalized'] = ' '.join(state['input'].split())
            elif step == 1:
                data['report'] = f"words={len(data['normalized'].split())}"
            elif step == 2:
                self.db.execute('COMMIT')
                return state
            else:
                raise Conflict('unknown step')
            changed = self.db.execute('''UPDATE runs SET next_step=?, revision=revision+1, data=?
                WHERE run_id=? AND owner=? AND revision=?''',
                (step+1, json.dumps(data), run_id, owner, expected_revision)).rowcount
            if changed != 1:
                raise Conflict('checkpoint write conflict')
            if crash_before_commit:
                # 主动触发异常,用来观察尚未提交的更新如何回滚。
                raise RuntimeError('simulated interruption before commit')
            self.db.execute('COMMIT')
        except BaseException:
            if self.db.in_transaction:
                self.db.execute('ROLLBACK')
            raise
        return self.get(run_id, owner)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--db', required=True)
    parser.add_argument('--phase', choices=['step', 'resume'], required=True)
    args = parser.parse_args()
    store = Store(args.db)
    try:
        state = store.create('r1', 'alice', '  hello   agent  ')
        if args.phase == 'step' and state['next_step'] == 0:
            state = store.advance('r1', 'alice', state['revision'])
        elif args.phase == 'resume':
            while state['next_step'] < 2:
                state = store.advance('r1', 'alice', state['revision'])
        print(json.dumps({key: state[key] for key in ['run_id', 'workflow', 'next_step', 'revision', 'data']}, ensure_ascii=False))
    finally:
        store.close()


if __name__ == '__main__':
    main()
完整代码:test_checkpoint_lab.py
Python
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from checkpoint_lab import Store, Conflict


class CheckpointTests(unittest.TestCase):
    def setUp(self):
        # 每项测试使用自己的数据库文件,互不影响进度。
        self.temp = tempfile.TemporaryDirectory()
        self.path = str(Path(self.temp.name) / 'state.sqlite')
        self.store = Store(self.path)
        self.store.create('r1', 'alice', '  hello   agent  ')

    def tearDown(self):
        self.store.close()
        self.temp.cleanup()

    def test_step_and_stale_version(self):
        state = self.store.advance('r1', 'alice', 0)
        self.assertEqual(state['data'], {'normalized': 'hello agent'})
        self.assertEqual(state['next_step'], 1)
        with self.assertRaises(Conflict):
            self.store.advance('r1', 'alice', 0)

    def test_rollback_before_commit(self):
        # 提交前主动抛错,随后读取数据库检查旧版本仍在。
        with self.assertRaises(RuntimeError):
            self.store.advance('r1', 'alice', 0, crash_before_commit=True)
        self.assertEqual(self.store.get('r1', 'alice')['revision'], 0)

    def test_owner_isolation(self):
        with self.assertRaises(PermissionError):
            self.store.get('r1', 'bob')
        with self.assertRaises(PermissionError):
            self.store.advance('r1', 'bob', 0)

    def test_input_reuse_conflict(self):
        self.store.create('r1', 'alice', '  hello   agent  ')
        with self.assertRaises(Conflict):
            self.store.create('r1', 'alice', 'different input')

    def test_workflow_version_rejected(self):
        other = Store(self.path, workflow_version='v2')
        try:
            with self.assertRaises(Conflict):
                other.advance('r1', 'alice', 0)
        finally:
            other.close()

    def test_complete_does_not_advance_again(self):
        self.store.advance('r1', 'alice', 0)
        state = self.store.advance('r1', 'alice', 1)
        self.assertEqual(state['data']['report'], 'words=2')
        self.assertEqual(self.store.advance('r1', 'alice', 2), state)

    def test_two_processes_resume_saved_state(self):
        # 启动两个独立进程,后者从前者保存的进度继续。
        script = str(Path(__file__).with_name('checkpoint_lab.py'))
        for phase in ['step', 'resume']:
            subprocess.run([sys.executable, script, '--db', self.path, '--phase', phase], check=True, capture_output=True)
        self.assertEqual(self.store.get('r1', 'alice')['next_step'], 2)
        self.assertEqual(self.store.get('r1', 'alice')['revision'], 2)


if __name__ == '__main__':
    unittest.main()

打开PowerShell,先进入文件夹再执行第一条命令:

Bash
cd "F:\学习\l4-checkpoints"
ls
python -X utf8 checkpoint_lab.py --db lesson.sqlite --phase step

它创建任务 r1,处理固定输入,然后把 next_step=1、revision=1 和 normalized=hello agent 保存到SQLite,关闭连接并退出。这里0表示第一步,1表示第二步,2表示全部完成。

再启动一个新进程:

Bash
python -X utf8 checkpoint_lab.py --db lesson.sqlite --phase resume

新进程不共享旧进程的Python变量。它打开同一个文件,读取已保存的规范化结果,执行第二步,写下 report=words=2,进度变为 next_step=2、revision=2。

第三次执行相同的 resume 命令。任务已经完成,程序直接返回当前状态,revision 保持为2。想重新观察第一步,把三条命令中的文件名一起换为 lesson-2.sqlite,原文件继续留存完成状态。

三次输出依次是:

TXT
{"run_id": "r1", "workflow": "v1", "next_step": 1, "revision": 1, "data": {"normalized": "hello agent"}}
{"run_id": "r1", "workflow": "v1", "next_step": 2, "revision": 2, "data": {"normalized": "hello agent", "report": "words=2"}}
{"run_id": "r1", "workflow": "v1", "next_step": 2, "revision": 2, "data": {"normalized": "hello agent", "report": "words=2"}}

沿着第二次运行读代码:create找到已存在的任务,get把保存的JSON还原成字典。advance看到next_step=1,使用其中的normalized统计单词,随后把报告与新位置一起保存。finally关闭连接,已经提交的数据仍留在文件中。

一次推进,为什么需要事务

本例在同一个SQLite事务中更新 data、next_step 和 revision。如果先保存“下一步是2”,稍后才保存生成报告的输入,中间异常就可能留下逻辑不一致的状态。

可以把本例推进过程缩写为:

TXT
BEGIN IMMEDIATE
state = get(run_id, owner)
check(state.workflow, expected_revision)
data = run_one_pure_step(state)
UPDATE runs SET data=?, next_step=?, revision=revision+1
    WHERE run_id=? AND owner=? AND revision=?
COMMIT

这是流程伪代码,完整可运行代码见上方文件代码区。BEGIN IMMEDIATE 用于在本实验中先取得写事务,再读状态和推进。两步计算很短,放在事务内有助于观察原子更新;网络查询或模型生成耗时较长时,可以先完成调用,再用短事务保存结果,减少占用写锁的时间。

Python示例设置 isolation_level=None,再显式使用BEGIN、COMMIT与ROLLBACK,避免把事务是否开始交给隐式行为猜测。保存更新时显式提交,遇到异常时显式回滚。Python sqlite3 文档

检查点流程图:读取身份隔离的持久状态,校验流程及revision,运行一个纯计算步骤,在同一事务保存结果和位置;失败回滚,新进程重新读取
图3:结果、位置和版本一起提交;提交前异常时回滚,恢复读取上次已提交状态。

旧版本为什么必须拒绝

想象两个worker都看到了 revision=0。第一个已经完成第一步并写成1,第二个还拿着旧状态。如果第二个直接覆盖整行,可能把第一份结果抹掉。

实验要求调用者携带自己看到的版本,更新时附带 WHERE revision=?。受影响行数不是1就拒绝;事务内还会先做一次版本检查。这个“只在版本仍一致时更新”的思路通常称为比较并交换,或者乐观并发控制。

本例的 BEGIN IMMEDIATE 已把SQLite写事务串行化;版本条件另外表达“旧调用不应覆盖新状态”的业务契约。多个进程还需要约定谁领取任务,以及领取后多久可以由其他进程接手;后续队列与分布式执行文章会展开。

run_id用于定位任务,owner用于限定归属。例子固定使用Alice;服务接口从已验证的登录身份取得归属,再用于读取和推进时的查询条件。

提交前失败、提交后退出,各看什么证据

运行测试:

Bash
python -X utf8 -m unittest -v test_checkpoint_lab.py
测试操作应保持的事实
提交前异常更新后、COMMIT前主动抛错revision仍为0,未提交结果回滚
旧revision推进成功推进后再传0冲突拒绝,状态不回退
输入被替换同run换成不同文本不静默沿用旧检查点
流程版本变化v2读取v1任务并尝试推进拒绝执行,不猜迁移规则
跨ownerBob查询或推进Alice任务统一不可访问
已完成重入再推进已完成任务返回同一完成状态
两进程恢复子进程step退出,再启动resume从1/1继续到2/2

两进程测试观察已提交状态是否能重新读取;提交前主动抛错的测试观察本次更新是否被回滚。对应到表中的动作,可以分别理解恢复和事务。

最容易误判的窗口:外部动作成功,检查点没保存

把第二步换成“给客户发一封邮件”。邮件服务收到请求、发送成功,但本地保存下一步之前进程退出。恢复时看到第二步尚未完成,再发一次,就可能产生重复邮件。

检查点只记录了本地已确认的状态,不能自动证明外部世界发生了什么。反过来,先把检查点标成“已发送”,然后才调用邮件服务,也可能在中间退出,造成没有发送却显示完成。

涉及外部写入时,可以给操作使用稳定业务编号,向服务查询原请求结果,或按幂等契约复用结果。结果暂时查不到时保存待核对状态,后续查询或人工处理。消息发送与本地变更还可以用事务发件箱把待发送记录一起保存。

如果模型调用在结果保存前中断,恢复后重新调用也可能产生新的费用和不同回答。恢复前还要核对调用结果和剩余额度,任务进度与调用预算分别记录。

和 LangGraph 的检查点对应在哪里

LangGraph的持久化文档区分checkpointer与store:前者保存线程范围的图状态,后者保存跨线程的应用数据。内存checkpointer方便开发,但进程重启会丢失,不能拿它验证持久恢复。持久化文档

框架还会处理线程、检查点命名空间、分支历史和多节点写入。理解稳定编号、位置和结果后,可以继续对照这些字段怎样帮助找回某次执行。Checkpointers指南

选择持久后端时,可分别检查进程退出后恢复、流程升级后读取旧任务,以及外部操作结果暂时未知时的处理路径。

下一篇事件溯源与确定性重放换一个角度:保存每次变化,再从变化记录重新计算当前状态。

课后练习展开习题收起习题

4 道练习 · 客观题规则评分 · 其余题目待批改

正在读取本地记录…

不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。

练习 1 / 4 · v1.0.0

选择题 · 什么能证明持久恢复?

哪项证据能支持本例不是仅在Python内存保存进度?

对应知识点:L4-checkpoints

选择一个答案
练习 2 / 4 · v1.0.0

简答题 · 三种版本信息

input fingerprint、workflow、revision分别防什么问题?

对应知识点:L4-checkpoints

练习 3 / 4 · v1.0.0

代码题 · 提交前的失败窗口

使用crash_before_commit写测试,证明失败后旧状态保留。应该检查哪些字段?

对应知识点:L4-checkpoints

练习 4 / 4 · v1.0.0

场景题 · 邮件发出但检查点没保存

邮件服务已经发送,保存next_step前进程退出。有检查点是否就能保证恢复后不重复发送?

对应知识点:L4-checkpoints

图片查看器
100%

文章配图 · 按 Esc 关闭