Agent 中断后怎么继续:检查点与持久状态
正在读取学习记录…
检查点是在一个执行位置保存下来的进度快照,包含下一步的位置和已经取得的结果。持久状态是写入文件或数据库、进程退出后仍能读到的数据;检查点可以用这样的状态保存。
玩游戏时,存档记录角色位置和装备;工作流的存档记录任务位置和数据。程序还要明确约定什么时候保存、保存哪些字段,以及旧进度怎样由当前代码继续处理。
上一节把查询结果汇总起来。结果如果只存在内存里,关闭程序就会丢失。下面先整理文本中的空白,再统计单词数量;第一步完成后保存并退出,第二次启动继续第二步。

检查点保存哪些执行状态
恢复时需要找到任务、核对输入与流程版本,再取出中间结果和下一步位置。
先看最小的存档内容。整理空白之前,原文本是 " hello agent ";整理之后得到 "hello agent",接下来要统计单词。此时保存这两个信息就能理解恢复的核心:
{"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 创建并进入学习目录:
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 创建并进入学习目录:
mkdir "F:\学习\l4-checkpoints" -Force
cd "F:\学习\l4-checkpoints"保存下面的文件后,在这个终端运行 dir 核对文件名,再执行本节给出的运行命令。
"""将文本处理进度保存到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
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,先进入文件夹再执行第一条命令:
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表示全部完成。
再启动一个新进程:
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,原文件继续留存完成状态。
三次输出依次是:
{"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”,稍后才保存生成报告的输入,中间异常就可能留下逻辑不一致的状态。
可以把本例推进过程缩写为:
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 文档
旧版本为什么必须拒绝
想象两个worker都看到了 revision=0。第一个已经完成第一步并写成1,第二个还拿着旧状态。如果第二个直接覆盖整行,可能把第一份结果抹掉。
实验要求调用者携带自己看到的版本,更新时附带 WHERE revision=?。受影响行数不是1就拒绝;事务内还会先做一次版本检查。这个“只在版本仍一致时更新”的思路通常称为比较并交换,或者乐观并发控制。
本例的 BEGIN IMMEDIATE 已把SQLite写事务串行化;版本条件另外表达“旧调用不应覆盖新状态”的业务契约。多个进程还需要约定谁领取任务,以及领取后多久可以由其他进程接手;后续队列与分布式执行文章会展开。
run_id用于定位任务,owner用于限定归属。例子固定使用Alice;服务接口从已验证的登录身份取得归属,再用于读取和推进时的查询条件。
提交前失败、提交后退出,各看什么证据
运行测试:
python -X utf8 -m unittest -v test_checkpoint_lab.py| 测试 | 操作 | 应保持的事实 |
|---|---|---|
| 提交前异常 | 更新后、COMMIT前主动抛错 | revision仍为0,未提交结果回滚 |
| 旧revision推进 | 成功推进后再传0 | 冲突拒绝,状态不回退 |
| 输入被替换 | 同run换成不同文本 | 不静默沿用旧检查点 |
| 流程版本变化 | v2读取v1任务并尝试推进 | 拒绝执行,不猜迁移规则 |
| 跨owner | Bob查询或推进Alice任务 | 统一不可访问 |
| 已完成重入 | 再推进已完成任务 | 返回同一完成状态 |
| 两进程恢复 | 子进程step退出,再启动resume | 从1/1继续到2/2 |
两进程测试观察已提交状态是否能重新读取;提交前主动抛错的测试观察本次更新是否被回滚。对应到表中的动作,可以分别理解恢复和事务。
最容易误判的窗口:外部动作成功,检查点没保存
把第二步换成“给客户发一封邮件”。邮件服务收到请求、发送成功,但本地保存下一步之前进程退出。恢复时看到第二步尚未完成,再发一次,就可能产生重复邮件。
检查点只记录了本地已确认的状态,不能自动证明外部世界发生了什么。反过来,先把检查点标成“已发送”,然后才调用邮件服务,也可能在中间退出,造成没有发送却显示完成。
涉及外部写入时,可以给操作使用稳定业务编号,向服务查询原请求结果,或按幂等契约复用结果。结果暂时查不到时保存待核对状态,后续查询或人工处理。消息发送与本地变更还可以用事务发件箱把待发送记录一起保存。
如果模型调用在结果保存前中断,恢复后重新调用也可能产生新的费用和不同回答。恢复前还要核对调用结果和剩余额度,任务进度与调用预算分别记录。
和 LangGraph 的检查点对应在哪里
LangGraph的持久化文档区分checkpointer与store:前者保存线程范围的图状态,后者保存跨线程的应用数据。内存checkpointer方便开发,但进程重启会丢失,不能拿它验证持久恢复。持久化文档
框架还会处理线程、检查点命名空间、分支历史和多节点写入。理解稳定编号、位置和结果后,可以继续对照这些字段怎样帮助找回某次执行。Checkpointers指南
选择持久后端时,可分别检查进程退出后恢复、流程升级后读取旧任务,以及外部操作结果暂时未知时的处理路径。
下一篇事件溯源与确定性重放换一个角度:保存每次变化,再从变化记录重新计算当前状态。
课后练习展开习题收起习题
正在读取本地记录…
不调用模型,不执行代码。简答、代码和场景题仅保存答案并展示自查标准,不生成评分、徽章或认证。输入内容不会自动保存,请点击提交;不要填写个人信息或密钥。
选择题 · 什么能证明持久恢复?
哪项证据能支持本例不是仅在Python内存保存进度?
对应知识点:L4-checkpoints
简答题 · 三种版本信息
input fingerprint、workflow、revision分别防什么问题?
对应知识点:L4-checkpoints
代码题 · 提交前的失败窗口
使用crash_before_commit写测试,证明失败后旧状态保留。应该检查哪些字段?
对应知识点:L4-checkpoints
场景题 · 邮件发出但检查点没保存
邮件服务已经发送,保存next_step前进程退出。有检查点是否就能保证恢复后不重复发送?
对应知识点:L4-checkpoints
描述会发送给本站维护者,用于修订文章,保存90天。请只填写文章问题,省略个人信息、密钥和内部资料。