PERSONAL LAB / ai/agent开发
AI Agent 开发

07 状态、持久化与恢复

演示时 Agent 跑一分钟就结束,状态放在内存里没问题。真实场景不一样:一次任务跑十几分钟,中途 API 余额用完、网络断了、服务器重启;或者跑到一半要等人审批,一等就是一下午。这时候如果只能从头再来,前面花的时间和钱就白费了,而且已经做过的写操作可能被重复执行。

这篇要讲清楚:Agent 有哪几种状态;检查点(checkpoint)怎么存、存在哪一步;恢复时怎么避免重复执行有副作用的操作;人工审批怎么实现;LangGraph 的持久化机制和它的边界。

怎么读:

前置:05 Agent 循环06 上下文工程

第一部分 速记页

问题一句话答案
Agent 需要保存哪些状态消息历史、执行进度(第几步、做过哪些调用)、预算用量、外部环境状态(文件、数据库)、待确认的操作
只存聊天记录能恢复吗不能。还要知道哪些动作已提交、是否确认完成,外部环境是否还是当时的样子,预算用了多少
检查点在执行过程中把状态快照写进持久存储,崩溃后从最近的快照继续
检查点存在哪一步粒度越细恢复越准,但写得越频繁;LangGraph 在每个超级步(super-step)后保存
最危险的时间窗口副作用已经发生、但记录还没写下的那一刻崩溃;恢复后会再做一次
幂等同一个操作执行一次和多次效果相同;读文件天然幂等,发邮件、扣款不是
幂等键给每个逻辑操作一个唯一编号,执行前查是否已经做过;最可靠的是下游服务本身支持
恢复后预算延续原来的用量,不能每次重启都重新获得完整额度
人工审批怎么实现遇到高风险操作时保存状态并暂停,进程可以退出;人决定后读回状态继续
LangGraph 的 interrupt在节点里调 interrupt() 暂停,用 Command(resume=值) 恢复;恢复时整个节点从头重新执行
LangGraph 检查点需要什么编译时传 checkpointer(SqliteSaver / PostgresSaver 等),调用时传 thread_id
我项目的续跑实测第 12 步 kill -9--resume 后从第 13 步继续,第 18 步编译通过,工具调用无重复
我项目续跑的边界检查点粒度是节点,杀在工具节点执行到一半,恢复后这个节点里已跑过的工具会再执行

第二部分 易混对照

容易混的两个区别一句话记法
日志 / Trace vs 检查点日志记录发生过什么,供人排查;检查点保存能继续执行所需的完整状态能看 vs 能接着跑
检查点 vs 长期记忆检查点属于某一次任务(线程),恢复这次执行;长期记忆跨任务这次的存档 vs 以后的经验
重试 vs 恢复重试是同一个进程里某一步失败了再做;恢复是进程没了,新进程从存档继续原地再来 vs 读档
幂等 vs 去重幂等是操作本身重复无害;去重是靠记录拦住重复执行天生安全 vs 靠检查拦
至少一次 vs 恰好一次至少一次可能重复但不会漏;恰好一次既不重复也不漏,分布式系统里很难真正保证宁多勿漏 vs 理想状态
暂停等待 vs 阻塞等待暂停是存状态后进程可以退出,几天后再恢复;阻塞是进程挂着一直等存档下线 vs 占着位置等
thread_id vs 运行编号thread_id 是 LangGraph 里一条独立状态历史的标识;运行编号是业务自己的记录我项目里用 run-编号 作为 thread_id
时间回溯 vs 恢复恢复是从最新检查点继续;时间回溯是回到更早的检查点,可以修改状态后重新执行继续 vs 回到过去

第三部分 面试口述稿

3.1 “Agent 跑到一半崩了怎么办?”

要做到能恢复,需要三样东西。一是检查点:每完成一步就把状态写进持久存储,包括消息历史、当前步数、预算用量、做过的调用。二是恢复时核对外部状态:检查点里说装好了依赖,工作目录里是不是真的还在。三是处理有副作用的操作:如果崩溃正好发生在操作已经执行、但记录还没写下的时候,恢复后会再执行一次,所以写操作要么天然可以重复执行,要么用幂等键防重。

另外预算要延续,恢复后接着原来的步数算,不能每重启一次就重新给 40 步。

3.2 “你的项目怎么做断点续跑的?”

手写版不支持,崩了只能重跑。后来用 LangGraph 重写了同一个循环,拆成模型、工具、验收三个节点,用 SqliteSaver 做检查点,每次运行用 run-编号 作为 thread_id。命令行加 --resume run-编号 就从最后完成的步骤继续。

实测过一次:编 re2 跑到第 12 步时直接 kill -9,这时已经执行了 24 次工具调用、abseil 已经交叉编译装好了。续跑后从第 13 步开始,直接用装好的 abseil 配置 re2,第 18 步编译通过,运行记录里每个工具调用都只出现一次。

但我也要说清楚边界:LangGraph 检查点的粒度是节点,不是单次工具调用。那次杀的时候第 12 步的工具正好都执行完了。如果杀在工具节点执行到一半,恢复后这个节点里已经跑过的工具会再执行一次。编译命令重复执行问题不大,但如果是不能重复的操作就要另外做幂等。

3.3 “怎么保证工具不被重复执行?”

先按副作用分类。只读操作和天然幂等的操作不用管,重复执行没影响。有副作用的操作,常见做法是幂等键:每个逻辑操作生成一个唯一编号,比如运行编号加步数加调用编号,执行前查一下这个编号有没有做过。

但光靠自己记录还有一个时间窗口:操作已经发出去了、还没来得及记录”已完成”就崩了。真正可靠的做法是让下游服务本身支持幂等键,比如很多支付接口允许请求带一个幂等键,服务端看到重复的键直接返回上次结果。下游不支持时,恢复前先去查询状态,比如查一下邮件是否已经发出,再决定要不要重做。

3.4 “人工审批怎么实现?”

核心是暂停时不占用进程。遇到高风险操作,程序把当前状态和待确认的操作一起存下来,然后通知人,进程可以直接结束。人批准、拒绝或者修改参数之后,程序读回状态继续:批准就执行,拒绝就把原因作为工具结果回给模型,让它换别的办法。

用 LangGraph 的话,在节点里调 interrupt() 就会保存状态并暂停,恢复时用同一个 thread_id 传 Command(resume=决定)。有个坑要注意:恢复时整个节点会从头重新执行,interrupt() 之前的代码会再跑一遍,所以不能在它前面放有副作用的操作,应该放到它后面或者单独的节点里。

3.5 “为什么选 LangGraph 而不是自己写持久化?”

我两个都写过,结论是看需求。手写循环 65 行,一眼能看完,适合单 Agent、线性流程;LangGraph 版 126 行,控制流拆在几个函数里跳着看,但检查点、续跑、中断审批、查看历史状态这些都现成。评测上两者步数、token、通过率看不出差别。

如果任务短、不需要续跑,手写更直接;任务长、要人工审批、要能恢复,用框架省掉自己设计状态存储和恢复逻辑。但框架不会替你解决幂等,那个仍然要自己做。

第四部分 逐个详解

4.1 Agent 有哪些状态

官方定义:状态(state)是程序在某一时刻继续执行所需要的全部信息。持久化(persistence)是把状态写入进程退出后依然存在的存储(文件、数据库)。

打个比方:玩单机游戏时的”存档”。存档里不只有”当前在第几关”,还有血量、背包、任务进度。只记”第几关”,读档后就是满血空背包,和原来不是同一个局面。Agent 也是一样,只存聊天记录不够。

flowchart TB
    subgraph SG1["程序内部状态(可以存进检查点)"]
        M[消息历史]
        P[执行进度<br/>第几步 / 做过哪些调用 / 结果]
        B[预算用量<br/>步数 / token / 时间]
        Q[待确认的操作<br/>等人审批的调用]
        C[控制信息<br/>重复计数 / 当前计划]
    end
    subgraph SG2["外部环境状态(检查点存不下,只能核对)"]
        F[工作目录里的文件]
        D[数据库记录]
        X[已经发出的邮件 / 已调用的外部接口]
    end
    P -. "检查点说做过<br/>环境里是否真的还在?" .-> F
    Q -. "恢复前要确认<br/>是否已执行" .-> X

最容易被忽略的是外部环境状态。 检查点里记着”zlib 已经安装到 deps/“,但如果工作目录被清理了,恢复后按检查点继续一定出错。我项目真的遇到过:

评测时第 3 轮 re2 跑到第 28 步,DeepSeek 返回 402(余额不足)崩溃(run 208)。本想拿它演示续跑,但评测脚本跑完每个项目都会删掉工作目录,崩溃的也不例外。检查点里的状态还在,编了一半的源码没了,续不上。后来评测脚本改成崩溃时保留工作目录。

所以恢复有两个前提:程序状态存下来了,外部状态也还在(或者能重建)

4.2 手写一个检查点:实验 1

先不用框架,自己实现一个最简单的”每步存档、崩溃后读档”。只用标准库,保存为 checkpoint_resume.py

import json
import os
import tempfile
from pathlib import Path

PLAN = ["clone zlib", "build zlib", "install zlib", "configure libpng", "build libpng"]
MAX_STEPS = 5


def save(path, state):
    fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    with os.fdopen(fd, "w") as f:
        json.dump(state, f, ensure_ascii=False)
    os.replace(tmp, path)


def load(path):
    if not path.exists():
        return {"done": [], "steps_used": 0}
    return json.loads(path.read_text())


def run(path, crash_at=None):
    state = load(path)
    print(f"  从检查点读到: 已完成 {state['done']},已用 {state['steps_used']} 步")
    for action in PLAN:
        if action in state["done"]:
            continue
        if state["steps_used"] >= MAX_STEPS:
            return "budget_exceeded"
        if action == crash_at:
            raise RuntimeError(f"执行 {action} 时进程被杀")
        print(f"  执行 {action}")
        state["done"].append(action)
        state["steps_used"] += 1
        save(path, state)
    return "completed"


with tempfile.TemporaryDirectory() as d:
    ckpt = Path(d) / "run-217.json"
    print("第一次运行")
    try:
        run(ckpt, crash_at="configure libpng")
    except RuntimeError as e:
        print("  崩溃:", e)
    print("恢复运行")
    print("  结果:", run(ckpt))

实际输出:

第一次运行
  从检查点读到: 已完成 [],已用 0 步
  执行 clone zlib
  执行 build zlib
  执行 install zlib
  崩溃: 执行 configure libpng 时进程被杀
恢复运行
  从检查点读到: 已完成 ['clone zlib', 'build zlib', 'install zlib'],已用 3 步
  执行 configure libpng
  执行 build libpng
  结果: completed

逐段讲。

with 语句。 with 表达式 as 变量: 是上下文管理器(context manager)语法,用来保证资源用完后一定被清理,即使中间出了异常。

  • with tempfile.TemporaryDirectory() as d: 创建一个临时目录,路径存在 d 里;with 块结束时目录连同里面的文件自动删除
  • with os.fdopen(fd, "w") as f: 打开一个文件用于写入,块结束时自动关闭

② 原子写入 save 为什么不直接 open(path, "w") 写?因为如果写到一半进程被杀,文件只写了一半,JSON 就坏了,连原来的旧存档也没了

正确做法是”先写临时文件,再改名替换”:

  • tempfile.mkstemp(dir=path.parent, suffix=".tmp"):在同一个目录下创建一个临时文件,返回两个值:文件描述符 fd(操作系统层面代表这个打开文件的整数)和文件路径 tmp
  • os.fdopen(fd, "w"):把文件描述符包装成 Python 文件对象
  • json.dump(state, f):把字典写成 JSON 到文件里(dumps 是返回字符串,dump 是直接写文件)
  • os.replace(tmp, path):把临时文件改名成正式文件名。在同一个文件系统上,这个操作是原子的(atomic),要么完全成功、要么完全没发生,不存在”改了一半”

所以任何时刻,path 要么是完整的旧存档,要么是完整的新存档。

sequenceDiagram
    participant P as 程序
    participant T as run-217.json.tmp
    participant F as run-217.json
    Note over F: 旧存档(完整)
    P->>T: 写入新状态
    Note over P,T: 此时崩溃:旧存档仍完整,临时文件作废
    P->>F: os.replace 原子替换
    Note over F: 新存档(完整)

③ 读档 load 文件不存在就返回初始状态,否则读出来解析。path.read_text()pathlib 提供的方法,一次读完整个文件。

④ 执行 run

  • 先读档,打印已经完成了什么
  • 遍历计划,if action in state["done"]: continue 跳过已完成的步骤。这就是”恢复”的核心
  • 预算检查用的是存档里的 steps_used,恢复后接着算
  • raise RuntimeError(...) 模拟进程被杀
  • 每做完一步,更新状态并立刻存档

⑤ 主流程。 第一次运行在第 4 步崩溃,前 3 步已存档;恢复运行读到 3 步完成、用了 3 步,从第 4 步继续,最终完成。

这个实验隐藏了一个问题:崩溃发生在”执行之前”。如果崩溃发生在”执行完成、存档之前”呢?

        print(f"  执行 {action}")
        state["done"].append(action)
        state["steps_used"] += 1
        save(path, state)

假设 执行 configure libpng 真的跑完了(外部世界已经改变),但在 save 之前进程被杀,存档里没有这一步。恢复后会再执行一次。这就是下一节要讲的问题。

自己改一改:

  • MAX_STEPS 改成 4,恢复运行会怎样?预算延续起作用了吗
  • raise 那两行移到 print(f" 执行 {action}") 之后,看看恢复后 configure libpng 被执行了几次

4.3 崩溃时间窗口和幂等

一个有副作用的步骤,从开始到记录完成,可以拆成四个时刻。在不同时刻崩溃,恢复后的结果不同:

flowchart LR
    A[① 记录:准备执行] --> B[② 执行副作用<br/>文件写入 / 发邮件 / 扣款]
    B --> C[③ 收到结果]
    C --> D[④ 记录:已完成]
    A -. "在 ①② 之间崩溃" .-> R1[没执行过<br/>恢复后执行:正确]
    B -. "在 ②③ 之间崩溃" .-> R2[执行了但不知道结果<br/>状态不明]
    C -. "在 ③④ 之间崩溃" .-> R3[执行成功但没记下<br/>恢复后会重复执行]

②到④之间崩溃都会导致重复执行。这个窗口没法消除,只能让”重复执行”变得无害。

先说为什么会有”幂等键”这种做法。 “幂等”本来是数学词,HTTP 规范很早就规定 GET、PUT、DELETE 这类方法应当是幂等的,客户端没收到响应时可以放心重发;但 POST(比如”创建一笔扣款”)不是,重发一次就可能多扣一次钱。支付公司最怕的就是这个场景:请求发出去了,网络断了,客户端不知道扣没扣成功。不重试,钱可能没扣;重试,钱可能扣两遍。Stripe 的做法是让客户端给每个逻辑操作生成一个唯一编号,放在请求头 Idempotency-Key 里,服务端记下这个编号和结果,同一个编号再来就直接返回上次的结果,不再执行。Stripe 工程师 Brandur Leach 在 2017 年 2 月的文章 Designing robust and predictable APIs with idempotency 里系统讲了这套设计,之后不少 API 也用了同名的请求头,IETF 的 HTTP API 工作组也在把 Idempotency-Key 请求头写成标准草案。下面第 2 种办法就是这个思路。

幂等(idempotent):一个操作执行一次和执行多次,最终效果相同。

操作幂等吗说明
读文件、查询不改变任何东西
write_file 写入完整内容写两次同样内容,结果一样
cmake --build基本是第二次会发现已经编译过,跳过
往文件末尾追加一行执行两次多出两行
数据库 INSERT 新记录两条重复记录
数据库”有则更新、无则插入”(upsert)按主键判断
发邮件、发消息对方收到两封
扣款、转账扣两次钱

让非幂等操作变安全的三种办法:

  1. 改成幂等的写法:追加改成写入完整内容;INSERT 改成 upsert
  2. 幂等键(idempotency key):每个逻辑操作一个唯一编号,执行前查是否做过
  3. 执行前查询:恢复时先去外部系统确认是否已经执行(邮件是否已发出、订单是否已创建)

动手实验 2:模拟”发出去了但回执没收到”,对比有无幂等键。保存为 idempotency.py

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE emails (idempotency_key TEXT PRIMARY KEY, to_addr TEXT, status TEXT)")
outbox = []


def send_email_unsafe(to_addr, crash_after_send=False):
    outbox.append(to_addr)
    if crash_after_send:
        raise ConnectionError("发出去了,但回执没收到")
    return "sent"


def send_email_safe(key, to_addr, crash_after_send=False):
    row = db.execute("SELECT status FROM emails WHERE idempotency_key = ?", (key,)).fetchone()
    if row and row[0] == "sent":
        return "already_sent"
    if row is None:
        db.execute("INSERT INTO emails VALUES (?, ?, 'pending')", (key, to_addr))
        db.commit()
    outbox.append(to_addr)
    db.execute("UPDATE emails SET status = 'sent' WHERE idempotency_key = ?", (key,))
    db.commit()
    if crash_after_send:
        raise ConnectionError("发出去了,但回执没收到")
    return "sent"


def retry(func, *args):
    for attempt in range(1, 4):
        try:
            return func(*args, crash_after_send=(attempt == 1))
        except ConnectionError as e:
            print(f"    第 {attempt} 次失败: {e},重试")


print("不做幂等:")
outbox.clear()
print("    结果:", retry(send_email_unsafe, "boss@example.com"), "| 实际发出", len(outbox), "封")

print("用幂等键:")
outbox.clear()
print("    结果:", retry(send_email_safe, "run-217:step-9:call-2", "boss@example.com"), "| 实际发出", len(outbox), "封")

实际输出:

不做幂等:
    第 1 次失败: 发出去了,但回执没收到,重试
    结果: sent | 实际发出 2 封
用幂等键:
    第 1 次失败: 发出去了,但回执没收到,重试
    结果: already_sent | 实际发出 1 封

逐段讲。

① SQLite 数据库。 sqlite3 是 Python 自带的模块,SQLite 是一个存在单个文件里的轻量数据库,我项目的 runs.db 就是它。

  • sqlite3.connect(":memory:"):memory: 是特殊名字,表示数据库只在内存里,程序结束就没了,适合做实验
  • db.execute("CREATE TABLE ..."):执行 SQL 语句建表。idempotency_key TEXT PRIMARY KEY 表示这一列是主键,同一个值只能出现一次
  • outbox = [] 是一个列表,模拟”真正发出去的邮件”

② 不安全的发送。 先把邮件放进 outbox(副作用已经发生),然后模拟回执丢失抛异常。调用方以为失败了,重试,于是又发了一封。

③ 带幂等键的发送 send_email_safe

  • db.execute("SELECT ... WHERE idempotency_key = ?", (key,)):查询这个键的记录。? 是占位符,实际值通过后面的元组传入。(key,) 是只有一个元素的元组,逗号不能省,否则括号只是普通括号。用占位符而不是把值拼进 SQL 字符串,是为了防止 SQL 注入
  • .fetchone():取一行结果,没有就返回 None
  • if row and row[0] == "sent":已经发过就直接返回 already_sent
  • 没有记录就先插入一条 pending(准备发送),db.commit() 提交,让写入真正生效
  • 发送,然后把状态更新为 sent 并提交
  • 模拟回执丢失

④ 重试 retry(func, *args) 最多试 3 次。crash_after_send=(attempt == 1) 让第一次模拟回执丢失,后面正常。except ConnectionError as e 只接住网络类错误。

⑤ 结果。 不做幂等发了 2 封;有幂等键时第二次查到已经是 sent,直接返回,只发了 1 封。

这个实现仍然有漏洞,面试时要能说出来:如果崩溃发生在 outbox.append(真正发出)之后、UPDATE ... 'sent' 之前,记录停在 pending,重试时会再发一次。自己的数据库和外部邮件服务之间,没法用一次操作同时完成,这个窗口永远存在。可靠的做法:

  • 下游支持幂等键:把键一起发给邮件或支付服务,服务端看到重复的键直接返回上次结果。很多支付接口都提供这种请求参数
  • 遇到 pending 先查询:恢复时发现状态是 pending,先去邮件服务查”这个键对应的邮件发了没有”,再决定

幂等键怎么生成?要保证同一个逻辑操作每次重试都得到同一个键,不同操作得到不同的键。Agent 里常用”运行编号 + 步数 + 调用编号”,比如 run-217:step-9:call-2。不能用随机数或当前时间,那样每次重试键都不一样,等于没有。

4.4 恢复流程:不只是读档

把前面的内容合起来,一次完整的恢复应该这样:

flowchart TD
    S([收到恢复请求:run-217]) --> L[读取最新检查点]
    L --> E{检查点存在吗}
    E -- 不存在 --> X([报错:无法恢复])
    E -- 存在 --> V{外部环境还在吗<br/>工作目录 / 依赖 / 分支}
    V -- 不在 --> RB[重建环境或报告无法恢复]
    V -- 在 --> P{有 pending 状态的<br/>副作用操作吗}
    P -- 有 --> Q[去外部系统查询是否已执行<br/>更新记录]
    P -- 没有 --> BG
    Q --> BG{预算还有吗<br/>用检查点里的用量}
    BG -- 没有 --> BX([停止:budget_exceeded])
    BG -- 有 --> ENV[恢复运行环境<br/>环境变量 / 连接 / 工具配置]
    ENV --> C([从下一步继续执行])

我项目的 graph_agent.py 在恢复时做了其中几步:

  • graph.get_state(config).values 读检查点,读不到就 raise ValueError(f"找不到检查点 {resume}")
  • 从检查点里取出 projecttargetarchrun_id重新设置 ZIG_TARGETZIG_ARCHCMAKE_TOOLCHAIN_FILE 环境变量。环境变量存在进程里,新进程不会自动有,漏了这一步恢复后工具链就找不到
  • 步数 step 存在状态里,恢复后从原来的步数接着算
  • 运行记录复用原来的 run_id,不新开一条

没做的:没有检查工作目录是否还在(依赖评测脚本崩溃时保留目录);工具都是文件和构建命令,没有需要查询外部系统的操作。

4.5 LangGraph 的持久化机制

先说为什么会有 LangGraph。 Harrison Chase 在 2022 年 10 月发布了 LangChain,最初是一个把”提示词模板 → 模型 → 解析输出”这类步骤串起来的 Python 库,串起来的东西叫链(chain),是一条从头走到尾的直线。Agent 需要的却是循环:调模型、执行工具、再调模型,直到停下,中间还要记住走到哪了。LangChain 早期用一个现成的 AgentExecutor 把循环包起来,想改循环里某一步的逻辑、加一个审批节点、中途存档,都很别扭。2024 年 1 月,LangChain 团队发布 LangGraph,把 Agent 写成一张允许有环的图:节点是步骤,边决定下一步去哪,整张图共享一份状态。状态既然集中在一处,每一步结束把它存下来就是检查点,这也是本节要讲的持久化机制的来源。

官方说明LangGraph Persistence 文档):

  • 检查点保存器(checkpointer):负责把图的状态快照持久化。编译图时传入:graph = builder.compile(checkpointer=...)
  • 线程(thread):一条独立的执行和状态历史,用 thread_id 标识,调用时通过 config = {"configurable": {"thread_id": "..."}} 传入。同一个 thread_id 的多次调用共享状态
  • 超级步(super-step):图执行的一个”回合”,这一回合里可以运行的节点都执行完算一个超级步。每个超级步结束后自动保存一个检查点
  • 常用保存器:InMemorySaver(内存,重启丢失,测试用)、SqliteSaver(文件,开发和单机用)、PostgresSaver / AsyncPostgresSaver(生产用)
  • 节点在一个超级步中途失败时,这个超级步里未完成的写入不会进入检查点,保证状态一致
  • 可以用 get_state 看当前状态、get_state_history 看历史快照,从旧检查点重放或修改状态后继续(时间回溯)

我项目的 LangGraph 版每一步的检查点在哪里:

sequenceDiagram
    participant G as 图执行
    participant A as agent 节点
    participant T as tools 节点
    participant DB as checkpoints.db
    G->>A: 超级步 k:调模型
    A-->>G: 新消息 + step+1
    G->>DB: 保存检查点
    G->>T: 超级步 k+1:执行这一步所有工具调用
    Note over T: 调用 1 执行完<br/>调用 2 执行完<br/>调用 3 执行中...
    T-->>G: 全部工具结果
    G->>DB: 保存检查点
    Note over T,DB: 如果在"调用 3 执行中"被杀:<br/>检查点停在 agent 节点之后<br/>恢复会重新执行整个 tools 节点<br/>调用 1、2 会再执行一次

这就是”检查点粒度是节点”的含义。框架的检查点只能保证图的状态一致,保证不了节点内部已经发生的外部副作用不重复。

真实的续跑实验eval/REPORT.md 记录):

flowchart LR
    subgraph SG3["第一次运行 run-217"]
        S1[第 1-11 步] --> S12[第 12 步<br/>工具都执行完<br/>检查点已写入]
        S12 --> K[kill -9]
    end
    subgraph SG4["续跑 --resume run-217"]
        R13[第 13 步<br/>读到 abseil 已装好] --> R14[...] --> R18[第 18 步<br/>re2 编译通过]
    end
    K -. "读检查点<br/>24 次工具调用都已记录" .-> R13
  • 杀进程时已执行 24 次工具调用,abseil 已经交叉编译并装进项目目录
  • 续跑从第 13 步开始,直接用装好的 abseil 配置 re2,第 18 步编译通过
  • 运行记录里每一步的工具调用都只出现一次

要诚实说明的是:kill 的时机正好在第 12 步的工具节点执行完、检查点也写进去之后。这次实验证明了”检查点之间的状态能正确恢复”,没有证明”节点执行中途崩溃也不重复”,后者按机制分析是会重复的。

实际踩到的两个坑(REPORT 记录):

  • 线程:同步 stream 会在线程池里跑节点,原来 Trace 的 SQLite 连接不允许跨线程使用,要设 check_same_thread=Falsesqlite3.connect 默认只允许创建连接的那个线程使用它
  • 递归上限:很多教程说 LangGraph 默认只允许 25 个超级步,那是旧版本;实测 1.2 版默认值是 10007。项目里仍按 MAX_STEPS × 2 + 10 显式设了 recursion_limit,防止路由写错时死循环

4.6 人工审批:暂停不占进程

为什么不能用 input() 阻塞等待? 审批可能要几小时甚至几天。阻塞等待意味着进程一直挂着,服务器重启就全丢了,而且同时有 100 个任务在等审批就要挂 100 个进程。正确做法是存档后退出,有决定时再读档继续

sequenceDiagram
    participant U as 用户 / 审批人
    participant API as 后端服务
    participant G as Agent(LangGraph)
    participant DB as 检查点存储
    U->>API: 发起任务
    API->>G: invoke(输入, thread_id=run-1)
    G->>G: propose 节点生成补丁
    G->>G: approve 节点调用 interrupt()
    G->>DB: 保存状态
    G-->>API: 返回 __interrupt__(待审批内容)
    API-->>U: 通知:请审批这个补丁
    Note over G: 进程可以退出<br/>几小时后
    U->>API: 批准
    API->>G: invoke(Command(resume="yes"), thread_id=run-1)
    G->>DB: 读取状态
    G->>G: approve 节点从头重新执行<br/>interrupt() 返回 "yes"
    G->>G: apply 节点写入补丁
    G-->>API: 最终结果

动手实验 3:用 LangGraph 实现”生成补丁 → 人工审批 → 应用补丁”。不需要 API key,节点都是普通函数。需要 pip install langgraph langgraph-checkpoint-sqlite(我用的是 langgraph 1.2.11)。保存为 lg_interrupt.py

import sqlite3
from typing import TypedDict

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, interrupt


class State(TypedDict):
    patch: str
    approved: bool
    applied: bool


def propose(state: State):
    print("  [propose] 生成补丁")
    return {"patch": "CMakeLists.txt: option(PNG_TESTS OFF)"}


def approve(state: State):
    print("  [approve] 节点开始执行")
    decision = interrupt({"question": "应用这个补丁吗?", "patch": state["patch"]})
    print(f"  [approve] 收到人工决定: {decision}")
    return {"approved": decision == "yes"}


def apply_patch(state: State):
    print("  [apply] 写入补丁")
    return {"applied": True}


def route(state: State):
    return "apply" if state["approved"] else END


g = StateGraph(State)
g.add_node("propose", propose)
g.add_node("approve", approve)
g.add_node("apply", apply_patch)
g.add_edge(START, "propose")
g.add_edge("propose", "approve")
g.add_conditional_edges("approve", route, ["apply", END])
g.add_edge("apply", END)

conn = sqlite3.connect(":memory:", check_same_thread=False)
graph = g.compile(checkpointer=SqliteSaver(conn))
config = {"configurable": {"thread_id": "run-1"}}

print("第一次调用")
out = graph.invoke({"patch": "", "approved": False, "applied": False}, config)
print("  暂停,等待:", out["__interrupt__"][0].value)
print("  下一个要执行的节点:", graph.get_state(config).next)

print("人工批准后恢复")
out = graph.invoke(Command(resume="yes"), config)
print("  最终状态:", out)
print("  检查点数量:", len(list(graph.get_state_history(config))))

实际输出:

第一次调用
  [propose] 生成补丁
  [approve] 节点开始执行
  暂停,等待: {'question': '应用这个补丁吗?', 'patch': 'CMakeLists.txt: option(PNG_TESTS OFF)'}
  下一个要执行的节点: ('approve',)
人工批准后恢复
  [approve] 节点开始执行
  [approve] 收到人工决定: yes
  [apply] 写入补丁
  最终状态: {'patch': 'CMakeLists.txt: option(PNG_TESTS OFF)', 'approved': True, 'applied': True}
  检查点数量: 5

逐段讲。

TypedDict class State(TypedDict): 定义一个”带类型说明的字典”。它在运行时就是普通字典,类型注解只是说明每个键应该存什么类型。LangGraph 用它知道状态里有哪些字段。

② 节点函数。 每个节点是一个普通函数,参数是当前状态,返回一个字典,只写要更新的字段。LangGraph 把返回的字典合并进状态。比如 propose 只返回 patch,其他字段不变。

interrupt(...) 在节点内部调用时:

  • 第一次执行到这里:保存状态,暂停整个图,invoke 返回的结果里多一个 __interrupt__ 键,里面是传给 interrupt 的内容(要给审批人看的信息)
  • 恢复时:interrupt(...) 这个函数调用返回 Command(resume=...) 里传的值

④ 建图。

  • StateGraph(State):创建一个以 State 为状态结构的图
  • add_node("名字", 函数):添加节点
  • add_edge(A, B):固定边,A 执行完就执行 B。STARTEND 是特殊的起点和终点
  • add_conditional_edges("approve", route, ["apply", END]):条件边。approve 执行完调用 route(state),根据返回值决定去哪;第三个参数列出所有可能的去向
  • g.compile(checkpointer=SqliteSaver(conn)):编译成可执行的图,并指定用 SQLite 存检查点

⑤ 两次 invoke

  • 第一次传入初始状态和 config,执行 propose,进入 approve,遇到 interrupt 暂停
  • graph.get_state(config).next 显示下一个要执行的是 ('approve',),说明 approve 节点还没算完成
  • 第二次传入 Command(resume="yes")同一个 config(同一个 thread_id)

⑥ 最重要的观察:[approve] 节点开始执行 打印了两次。 恢复时,整个 approve 节点从头重新执行interrupt() 之前的代码又跑了一遍,这次 interrupt() 直接返回 "yes",继续往下。

官方文档(Interrupts)明确列出了由此带来的规则:

不要原因应该
interrupt() 之前做非幂等操作(比如创建记录)恢复时会再执行一次,产生重复副作用放在 interrupt() 之后,或放进单独的节点
用裸的 try/except 包住 interrupt()暂停是靠抛出特殊异常实现的,会被你的 except 吞掉不包,或只捕获具体的异常类型
条件性地跳过或调换多个 interrupt() 的顺序恢复时按调用顺序匹配恢复值,顺序变了会对不上保持调用顺序固定
传函数、类实例这种不能序列化的对象要存进检查点只传 JSON 能表示的数据
while True 循环反复 interrupt() 做校验每次恢复都重跑整个循环用条件边回到审批节点

⑦ 检查点数量 5。 我额外把 get_state_history 的每条记录打印出来看过:分别是收到输入时、进入 propose 前、propose 完成后(下一个是 approve)、approve 完成后(下一个是 apply)、apply 完成后。暂停时停在”propose 完成后”那个检查点上,approve 在它完成之前不会产生新检查点,这和”恢复时 approve 整个重跑”是一致的。每个超级步一份快照,也是”时间回溯”的基础。

审批还可以更细:批准、拒绝、修改参数后批准、要求模型补充说明。拒绝时要把理由作为工具结果回给模型,而不是直接结束任务,模型可能有别的办法。

4.7 什么操作需要审批

不是所有工具调用都要人确认,那样 Agent 就没意义了。按风险分级(和 04 篇的副作用分级对应):

级别例子策略
只读读文件、查询、搜索不审批
工作区内可逆写入在隔离的工作目录改文件、跑构建不审批,但保留版本可回滚
影响共享资源推送代码、改共享配置、写生产数据库审批,或在沙箱里演练后审批
不可逆、对外发邮件、付款、删除数据、部署上线必须审批,带幂等键
超出预期金额超过阈值、批量操作超过 N 条、访问敏感数据按规则触发审批

审批界面要给人看足够做决定的信息:要执行什么操作、参数是什么、为什么要做(模型给出的理由)、影响范围。只显示”Agent 请求执行 send_email,是否批准?“,人只能盲目点”是”。

4.8 什么时候需要持久化,什么时候不需要

场景需要吗理由
几秒到一分钟的单轮问答不需要失败了重跑成本低
几分钟的任务,没有写操作可选看重跑成本
十几分钟以上、token 花费大需要崩溃重跑浪费明显
有人工审批必须暂停期间不能占进程
有不可逆写操作必须,而且要幂等防止重复执行
多轮对话需要”记住上下文”需要会话状态也可以只存消息历史

我项目手写版不支持续跑,因为简单和中等项目几十秒就跑完;困难项目一次两三分钟、几十万 token,续跑才开始有实际价值。这是为什么后来要做 LangGraph 版的原因之一。

跑几小时、跨好几个上下文窗口的任务怎么接力。 上面讲的检查点解决”进程崩了接着跑”。任务长到一个上下文窗口装不下时,还有另一个问题:新开的会话不记得前面做了什么。Anthropic 的 Effective harnesses for long-running agents(2025-11)总结了两种典型失败:一是贪多,一次想做完所有功能,做到一半窗口满了,留下半成品也没留说明;二是后面的会话看到已经有不少代码,就以为做完了,提前宣布完成。他们的做法都是把状态写成文件:

做法解决什么
第一个会话先生成功能清单文件,200 多条功能全部标成”未通过”后面的会话知道还剩什么,不会提前宣布完成
清单用 JSON 而不是 Markdown文中说模型不太会乱改或覆盖 JSON 文件
每个会话只做一个功能避免贪多,做到一半窗口就满
做完就 git 提交,并更新进度文件下一个会话从进度文件和提交记录接手;改坏了能回退
用浏览器自动化像用户一样端到端验证只跑单元测试会误判功能已完成

这和本篇的思路一致:状态放在上下文之外、能被下一个执行者读到的地方,只是这里的”执行者”是新的模型会话,而不是重启的进程。

把会话记录和执行进程拆开。 Anthropic 2026 年 4 月的 Scaling Managed Agents 讲了他们托管 Agent 服务的架构,文中叫”把大脑和手分开”:

组件做什么挂了怎么办
会话日志只追加的事件记录,存在模型上下文和执行进程之外,需要时按区间读回本身是持久存储
外层循环(harness)调模型、分发工具调用,每做一步把事件写进日志;自己不保存状态新起一个,读日志从最后一条记录接着跑
沙箱执行代码和工具的容器,按需创建,坏了就换一个按固定配置重新创建

凭据不放进沙箱,由沙箱外的代理去取,模型写的代码拿不到。拆开之后,外层循环不用等容器启动就能开始调模型,首 token 延迟的中位数降了约 60%。这就是 12-Factor Agents 第 12 条”把 Agent 做成无状态的归约函数”在生产里的样子,和 pi 的只追加会话文件也是同一个方向。

第五部分 对照项目

本篇知识点项目里的位置做到了什么没做到或可以改进的
执行记录agent/trace.pyruns.dbrunstool_calls 两张表每次运行、每次工具调用都记录只能用来排查,不能恢复
检查点agent/graph_agent.pySqliteSaver + checkpoints.db每个超级步自动保存手写版没有
thread_idrun_configrun-编号和运行记录编号一致,方便对应
续跑--resume <thread_id>读检查点,恢复环境变量,复用 run_id,步数延续不检查工作目录是否还在
预算延续状态里的 step恢复后接着原步数算
外部状态保留eval/run_eval.py崩溃时保留工作目录早期版本删掉了目录导致 run 208 续不上
节点内幂等工具都是文件写入和构建命令,基本可重复杀在工具节点中途会重复执行已跑过的工具;没有幂等键
人工审批没有写文件、执行命令都不需要确认
并发安全Tracecheck_same_thread=False解决了线程池里跨线程用连接的报错多个进程同时写 SQLite 时要注意锁

建议阅读agent/graph_agent.pybuild_events 函数(续跑分支 if resume:),对照 4.4 节的恢复流程图。tests/test_graph_agent.py 里有续跑不重复执行工具的测试。

对照开源实现:pi

pi05 篇第五部分介绍过)没有用 LangGraph 这类框架,会话持久化是自己写的,思路和本篇实验 1 的”快照”完全不同:只追加的日志。源码以 commit 7b4cfd6 为准,格式说明见 coding-agent/docs/session-format.md

存什么、怎么存。 每个会话一个 JSONL 文件,放在 ~/.pi/agent/sessions/--<工作目录>--/<时间>_<会话编号>.jsonl。一行一条记录,只往文件末尾追加,从不改写已有的行:

{"type":"session","version":3,"id":"...","cwd":"/path/to/project"}
{"type":"message","id":"a1b2c3d4","parentId":null,"message":{"role":"user",...}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","message":{"role":"assistant","content":[{"type":"toolCall",...}],...}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","message":{"role":"toolResult",...}}
{"type":"model_change","id":"d4e5f6g7","parentId":"c3d4e5f6","provider":"openai","modelId":"..."}
{"type":"compaction","id":"e5f6g7h8","parentId":"d4e5f6g7","summary":"...","firstKeptEntryId":"c3d4e5f6"}

(字段有省略。)几个设计点:

设计做法为什么这样做
写入时机每条消息(用户、模型回复、工具结果)一结束就追加一行(agent-session.ts L669-L686粒度是单条消息,比 LangGraph 的”节点”细
记录之间的关系每条带 idparentId,整个会话是一棵树,“当前位置”是树上的一个叶子用户回到早先某一步重新来时,新消息挂在那一步下面,旧分支原样留在文件里,不用复制文件
压缩压缩结果也是追加一条 compaction 记录,旧消息不删历史完整可查;重建上下文时从叶子往根走,遇到压缩记录就用摘要替换它之前的内容
运行设置切换模型、改推理强度也各记一条恢复时从路径上找到最后一次设置,对应本篇 4.4 节的”恢复运行环境”
写坏了怎么办读文件时逐行解析,解析失败的行直接跳过;文件末尾缺换行符就补一个,保证下次追加从新的一行开始(session-manager.ts L503-L556崩溃时最多丢掉写了一半的最后一行,前面的记录不受影响

快照 vs 追加日志。 本篇实验 1 每步把整个状态覆盖写一次,所以要”先写临时文件再原子改名”防止写一半坏掉。pi 只追加不覆盖,一次崩溃最多坏最后一行,读的时候跳过就行,不需要原子改名。代价是文件只增不减,恢复时要从头读一遍再重建。

崩溃在工具执行中间会怎样? 这正是本篇 4.3 节说的危险窗口。模型发起工具调用的那条回复已经写进文件了,工具结果还没写。恢复后再发请求时,pi 在转换消息的环节(ai/src/api/transform-messages.ts L158-L222)做两件事:

  • 找出”有调用、没结果”的工具调用,每个补一条假的结果,内容是 No result provided,标记为错误。不补的话 API 会因为调用和结果不配对直接拒绝请求
  • 出错或被中止的模型回复整条跳过,不再发给模型

也就是说,pi 不判断这个工具到底执行了没有,也没有幂等键,直接告诉模型”没拿到结果”,让模型自己决定要不要重做。 这在它的场景下能接受:人在终端前看着;主要工具是读文件、改文件、跑命令,改文件用的是”精确查找替换”,已经改过的再改一次通常会报”找不到原文”,模型能发现。但如果工具是发邮件、扣款,这种做法就会重复执行,必须用 4.3 节的办法。

pi 正在补这一块。 agent 包里有一套还在开发的新运行时(代码在 agent/src/harness/,命令行产品目前只在实验性功能里用到它,默认流程还是前面讲的那套),设计文档 agent/docs/tool-durability.md 几乎就是本篇 4.3 节的工程版:

本篇 4.3 节新运行时的设计
① 记录:准备执行执行前先把校验过的参数写进会话,这个调用的状态标成”副作用进行中”(effect_pending
幂等 / 可重复每个工具声明自己能不能安全重做:replay: "safe""never",不声明就按 "never"
在 ②④ 之间崩溃恢复时,声明了 safe 的工具直接重跑;never 的不重跑,给模型回一条结果:工具最后一次保存的进度,加上一句”执行被中断,外部结果未知”(runtime/drive/tools.ts L44-L45、L515-L541
幂等键每次调用有一个 invocationId,重跑时保持不变;工具可以用它存取自己的小记录(getMemo / setMemo),比如”这封邮件已经发过了”
恰好一次很难文档把”任意外部操作恰好执行一次”明确列为不做的目标

文档里还讲了一个本篇没提到的并行场景:一批三个调用 A、B、C,B 和 C 先跑完,A 还在跑,这时崩溃。工具结果必须按 A、B、C 的顺序放进对话,所以 B、C 的结果还没来得及写,只在内存里,恢复后会被当成”没完成”而重跑。新设计的做法是:谁先跑完就先把完整结果单独存下来(状态 outcome_ready),等排在前面的都好了再按顺序放进对话。目标写得很直接:结果已经存下来的工具,永远不再重跑。

和我的项目对比:

pi我的项目(LangGraph 版)
存储方式只追加的 JSONL,一个会话一个文件SqliteSaver,每个超级步一个检查点
粒度单条消息节点
工具执行中途崩溃补一条”没有结果”的报错,交给模型判断整个工具节点重跑,已执行过的工具再执行一次
预算延续没有步数预算;每条回复都记了用量和费用,总花费可以重算步数存在状态里,续跑接着算
分支和回退会话是树,可以回到任意一步换个方向靠 LangGraph 的时间回溯
人工审批没有,也就不需要保存”待确认的操作”没有

第六部分 追问清单

你刚讲完下一个追问回答方向
需要保存的状态存聊天记录不够吗还要进度、预算、待确认操作;外部环境要核对
检查点多久存一次每步存;粒度和写入开销的取舍;框架按超级步
原子写入为什么不直接覆盖写写一半崩溃会同时丢掉新旧存档;临时文件 + 原子改名
续跑实测怎么证明没重复执行运行记录里每个调用只出现一次;有测试
检查点粒度节点中途崩溃怎么办会重复执行节点内已跑的工具;幂等或把工具调用拆成更细的节点
幂等键键怎么生成同一逻辑操作每次重试相同:运行 + 步数 + 调用编号;不能用随机数
幂等键的漏洞发送后记录前崩溃呢窗口无法消除;下游支持幂等键;pending 状态先查询
人工审批等待期间进程怎么办存状态后退出;恢复时读档;不用阻塞
LangGraph interrupt有什么坑节点从头重跑;interrupt 前不放副作用;不能被 try 吞掉;顺序固定
什么要审批全部都审批行不行按风险分级;审批界面信息要足够做决定
手写 vs 框架为什么用 LangGraph检查点、续跑、中断现成;幂等仍需自己做
外部状态恢复时工作目录没了怎么办run 208 的教训;恢复前核对,能重建就重建,不能就报告
存储方式除了快照还有别的做法吗只追加日志:pi 每条消息追加一行 JSONL,崩溃最多坏最后一行,读时跳过;代价是恢复要从头重建
中途崩溃工具调用发出去了、结果没记下,恢复后怎么发请求调用和结果必须配对;pi 给每个悬空调用补一条”没有结果”的报错,让模型判断;有副作用的工具仍要幂等
并行工具一批工具并行跑,崩溃时部分已完成,恢复怎么处理结果要按原顺序放进对话,但先完成的要先单独存下来;pi 新运行时用 outcome_ready 状态,存下来的不再重跑

第七部分 闭卷自测

1. 只把消息历史存下来,崩溃后能完整恢复吗?还缺什么?

答案

不能。还缺:执行进度(哪些调用已提交、是否确认完成)、预算用量、待确认的操作、控制信息(重复计数等);另外外部环境(文件、数据库)是否还是检查点对应的样子要核对。

2. 实验 1 的 save 为什么先写临时文件再 os.replace

答案

直接覆盖写时如果写到一半崩溃,文件内容不完整,旧存档也被破坏了。先写临时文件再原子改名,任何时刻正式文件要么是完整旧版、要么是完整新版。

3. 一个步骤在”执行完成、存档之前”崩溃,恢复后会发生什么?怎么应对?

答案

存档里没有这一步,恢复后会再执行一次。应对:操作本身幂等;用幂等键;恢复时先查询外部系统是否已执行。

4. 判断是否幂等:(a)写入完整文件内容(b)往日志末尾追加一行(c)数据库 upsert(d)发短信(e)cmake —build

答案

a 是;b 否;c 是;d 否;e 基本是(重复构建会跳过已编译的部分)。

5. 幂等键能不能用 uuid4() 随机生成?为什么?

答案

不能在重试时重新生成。每次重试键都不同,服务端就认不出是同一个操作,等于没有幂等。键要对同一个逻辑操作保持稳定,比如”运行编号 + 步数 + 调用编号”。如果用随机数,必须在第一次生成后存下来,重试时复用。

6. 实验 2 的 send_email_safe 还有什么情况会发两封?怎么彻底解决?

答案

在真正发出(outbox.append)之后、把状态更新为 sent 之前崩溃,记录停在 pending,重试时会再发。自己的数据库和外部服务之间无法一次性原子完成。解决:下游服务支持幂等键,重复请求直接返回上次结果;或者遇到 pending 时先去外部服务查询是否已发送。

7. LangGraph 需要哪两样东西才能支持检查点和恢复?

答案

编译图时传入 checkpointer(如 SqliteSaver、PostgresSaver);调用时在 config 里传 thread_id,恢复时用同一个 thread_id。

8. 实验 3 里 [approve] 节点开始执行 为什么打印了两次?这对写代码有什么要求?

答案

恢复时整个节点从头重新执行,interrupt() 之前的代码会再跑一遍,这次 interrupt() 直接返回恢复值。所以 interrupt() 之前不能放非幂等的副作用,应放在它之后或单独的节点里;也不能用裸 try/except 包住它。

9. 为什么人工审批不用 input() 阻塞等待?

答案

审批可能要几小时,阻塞会一直占着进程,服务器重启就丢失;同时有很多任务等审批时要挂很多进程。应该保存状态后让进程退出,有决定时读档继续。

10. 你项目的 kill -9 续跑实验证明了什么?没有证明什么?

答案

证明了:在检查点之间崩溃,续跑能从下一步继续、复用已完成的工作(abseil 已装好)、工具调用不重复。没有证明:在工具节点执行到一半崩溃不重复。那次杀的时机正好在节点完成、检查点写入之后;按机制,检查点粒度是节点,中途崩溃会重复执行节点内已跑过的工具。

11. run 208 为什么续不上?给了什么教训?

答案

DeepSeek 返回 402 崩溃后,评测脚本照常删除了工作目录。检查点里的程序状态还在,但编了一半的源码没了。教训:恢复需要程序状态和外部环境状态都在;崩溃时要保留外部状态,恢复前要核对。

12. 恢复后预算应该怎么算?为什么?

答案

延续检查点里记录的用量。否则每崩溃重启一次就重新获得完整预算,一个反复崩溃的任务可以无限花钱。我项目里步数存在状态里,续跑从第 13 步接着算。

13. pi 的会话文件只追加、不覆盖。和实验 1 的”每步覆盖写快照”比,各有什么好处?为什么 pi 不需要”先写临时文件再原子改名”?

答案

快照恢复快,读一个文件就是完整状态,但每次覆盖写都有写一半坏掉的风险,所以要原子改名。只追加日志从不改写已有内容,崩溃最多让最后一行不完整,读的时候跳过这一行即可,前面的记录都完好,所以不需要原子改名;还能保留完整历史、支持回到任意一步。代价是文件只增不减,恢复时要从头读一遍重建状态。

14. 工具调用已经写进会话、工具结果还没写就崩溃了。pi 恢复后怎么处理?这种做法适合什么场景,不适合什么场景?

答案

发请求前,给每个没有结果的工具调用补一条内容为 No result provided 的错误结果,让调用和结果重新配对,然后由模型决定要不要重做。它不判断工具到底执行了没有,也没有幂等键。适合人在旁边看着、工具大多可重复或能自己发现重复(比如查找替换式编辑)的交互式编程场景;不适合发邮件、扣款这类不可重复的操作,那种情况模型很可能再执行一次。

延伸阅读

  1. LangGraph:Persistence — 检查点、线程、超级步、时间回溯、各种保存器
  2. LangGraph:Interruptsinterrupt()Command(resume=...),节点重新执行的规则和常见错误
  3. 12-Factor Agents — 第 5 条”统一执行状态和业务状态”、第 6 条”用简单的接口启动 / 暂停 / 恢复”、第 12 条”把 Agent 做成无状态的归约函数”
  4. Anthropic:Building effective agents — Agent 在检查点或遇到阻碍时暂停等待人工反馈的建议
  5. 项目 eval/REPORT.md 的”LangGraph 重写对比”一节 — 续跑实测和踩过的坑的原始记录
  6. pi:Session File Format(commit 7b4cfd6)— 只追加的 JSONL 会话格式、树形分支、压缩记录和上下文重建规则
  7. Anthropic:Effective harnesses for long-running agents(2025-11)— 跨多个上下文窗口的任务怎么用清单、进度文件和 git 接力
  8. Anthropic:Scaling Managed Agents(2026-04)— 会话日志、无状态外层循环、沙箱三者分开后怎么恢复

下一篇:08 RAG 全链路——给 Agent 接上知识库,从切片、向量到检索评测。

Related · Agent 开发
⎇ main ai/agent开发 21 节 230 notes UTF-8