Files
zhuangang/topics/ai/langgraph/02-persistence-hitl.md
T

5.2 KiB
Raw Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
id title type domain stack tags level prerequisites est_hours related week order
ai.langgraph.persistence-hitl 检查点、人机协作和异常恢复:checkpointer、interrupt、RetryPolicy、任务状态 lesson ai
langgraph
checkpointer
thread_id
interrupt
Command
RetryPolicy
崩溃恢复
任务状态
advanced
ai.langgraph.state-graph
4
ai.langgraph.scenario.refund-approval
ai.agent-ops.eval-quality-gate
ai.langgraph.interview
12
2

检查点、人机协作和异常恢复:checkpointer、interrupt、RetryPolicy、任务状态

基于 langgraph 1.2 和 langgraph-checkpoint-sqlite 编写,以官方文档为准:https://docs.langchain.com/oss/python/langgraph/persistence

解释说明

检查点(checkpointer)

编译时传入 checkpointer,每执行完一步(super-step)就把状态保存下来,按 thread_id 区分不同的会话 / 任务:

实现 用途
InMemorySaver 测试、演示;进程退出就没了
SqliteSaver(langgraph-checkpoint-sqlite) 本地、单机
PostgresSaver(langgraph-checkpoint-postgres) 生产

有了检查点就能:多轮对话记住上下文、进程崩溃后从最近的检查点继续(invoke(None, config))、查看历史状态(get_state_history)、人工审批后继续。

人机协作(interrupt)

节点里调用 interrupt(要给人看的数据),图会暂停并保存状态;人处理完后调用 invoke(Command(resume=人的决定), config) 继续,interrupt() 的返回值就是人的决定。

注意:恢复时这个节点会从头重新执行,interrupt() 之前的代码会再跑一次,所以那里不要放发邮件、扣款这类副作用操作。

异常恢复

问题 做法
模型超时、限流(临时错误) 节点级 RetryPolicy(max_attempts=3, retry_on=...) 自动重试,带退避
重试仍失败 / 进程崩溃 已完成节点的结果在检查点中;修复后 invoke(None, config) 从失败节点继续
外部副作用(发送、扣款)可能重复 用业务 ID 做幂等键,重复执行也只生效一次
结构化输出不合法 pydantic 校验,失败时重试一次或进入“转人工”分支,不让整个流程崩掉

任务状态

长任务要有明确的状态字段,前端和运维都靠它:new → classified → drafted / waiting_approval → approved / rejected → sent / failed。状态写在图的 State 里,同时同步到业务表,方便列表页查询和告警(例如“等待审批超过 24 小时”)。

案例

退款工单:800 元的退款需要主管审批。流程在审批节点暂停,主管第二天在后台点“同意”,服务已经重启过,但因为检查点存在 SQLite / PostgreSQL 中,用同一个 thread_id 就能继续;发送时进程崩溃,恢复后不会重新起草、也不会重复发送。

代码示例

核心片段(完整可运行版本 examples/ticket-flow/ticket_flow.py,带 pytest 测试):

from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command, RetryPolicy, interrupt


def approve(state):
    # 恢复时本节点从头执行:interrupt 之前不要有副作用
    decision = interrupt({"ticket_id": state["ticket_id"], "draft": state["draft"]})
    return {"status": "approved" if decision["ok"] else "rejected"}


g.add_node("draft", draft, retry_policy=RetryPolicy(max_attempts=3, retry_on=TransientError))
g.add_node("approve", approve)

cfg = {"configurable": {"thread_id": "T-1001"}}
with SqliteSaver.from_conn_string("checkpoints.sqlite") as cp:
    app = g.compile(checkpointer=cp)
    app.invoke(ticket, cfg)                       # 停在 approve
    print(app.get_state(cfg).next)                # ('approve',)

# ……服务重启后……
with SqliteSaver.from_conn_string("checkpoints.sqlite") as cp:
    app = g.compile(checkpointer=cp)
    app.invoke(Command(resume={"ok": True}), cfg)  # 继续执行审批之后的节点
    app.invoke(None, cfg)                          # 如果中途崩溃,从最近的检查点继续

示例运行输出(节选):

等待节点: ('approve',) 状态: waiting_approval
发送时崩溃: 进程崩溃(模拟)
崩溃后停在: ('send',) 状态: approved
最终状态: sent 事件: ['classified:refund', 'drafted', 'approved', 'sent']

易错点

  • 没有传 thread_id:有检查点的图调用时必须提供,否则报错或者所有请求共享状态。
  • 用 InMemorySaver 上生产:重启后所有进行中的任务丢失。
  • interrupt() 之前做了副作用,恢复时重复执行。
  • 恢复时传了新的输入而不是 Command(resume=...) 或 None:会被当成新的一轮输入。
  • 重试所有异常:参数错误、权限错误重试没有意义,只重试临时错误。
  • 状态里放了大对象(整篇文档、二进制):每一步都要序列化保存,检查点表迅速膨胀。放 ID,内容存在别处。

面试常问

  • 检查点保存的是什么?什么时候保存?
  • interrupt 恢复时为什么节点会重新执行?会带来什么问题?
  • Agent 执行到一半服务重启了,怎么保证任务不丢、不重复?