用动态 interrupt 实现人工审批

本章目标
暂停退款流程、持久化上下文,并根据人工决定进入执行或拒绝分支。
生产审批应使用动态 interrupt():
from langgraph.types import Command, interrupt
def approval(state: SupportState) -> dict[str, bool]:
approved = interrupt(
{
"ticket_id": state["ticket_id"],
"question": "是否批准执行退款?",
"proposal": state["proposal"],
}
)
return {"approved": bool(approved)}
首次运行遇到中断后返回审批 payload。恢复时使用同一个 thread_id:

result = await graph.ainvoke(
Command(resume=True),
config=config,
version="v2",
)
interrupt_before 和 interrupt_after 是静态断点,适合调试,不作为本文的生产审批方案。
恢复安全规则

包含 interrupt() 的节点在恢复时从函数开头重新执行。因此:
- 中断之前只能放纯计算或幂等操作。
- 支付、退款、发送消息放在审批之后的独立节点。
- 中断 payload 必须可序列化。
- 不要用普通
try/except捕获interrupt()。
审批超时
interrupt() 默认无限等待。不要在 Web Worker 中 sleep() 或循环轮询。
生产做法是:
- 业务数据库记录审批请求和
deadline。 - 调度器扫描过期审批。
- 调度器使用原
thread_id调用Command(resume=False)。 - 拒绝分支记录“超时拒绝”原因。
这部分依赖企业现有调度平台,示例不伪造一个进程内定时器来冒充分布式调度。
审批记录必须独立存在
interrupt() 保存执行位置,但企业审批还需要可查询、可审计的业务记录。建议至少保存:
approval_id, tenant_id, ticket_id, thread_id,
status, proposal_hash, requested_at, deadline,
decided_at, decided_by, decision_reason, version
proposal_hash 防止审批人在看到方案 A 后,系统恢复时执行了方案 B。恢复前重新计算待执行参数并与审批记录比对;金额、收款方或动作类型变化时必须重新审批。
授权、审计和原子状态转换
审批接口不能只接收一个布尔值。生产入口应从身份系统获得 decided_by,检查审批人角色、租户、金额权限和职责分离规则,并把业务审批记录从 PENDING 原子更新为 APPROVED 或 REJECTED。
UPDATE approval_request
SET status = 'APPROVED', decided_by = :actor, version = version + 1
WHERE approval_id = :id AND status = 'PENDING' AND version = :expected_version;
受影响行数为零表示已经处理或版本冲突。只有成功完成该状态转换的请求可以提交 Command(resume=...)。当前示例通过读取 snapshot.next 返回 409,能阻止普通重复提交,但读状态和恢复之间不是跨实例原子操作,因此仍需要业务数据库或运行队列串行化。
超时任务也要幂等
调度器扫描到期记录后,应先原子地把 PENDING 改为 TIMED_OUT,再恢复图。多个调度器同时扫描时只有一个更新成功。恢复失败可以重试,因为业务记录已经固定为超时拒绝,Command(resume=False) 的后续副作用仍遵循幂等约束。
审批故障演练
至少覆盖:批准、拒绝、重复批准、批准与超时同时发生、无权限审批、方案被修改、恢复时数据库短暂不可用、审批完成后进程崩溃。每条路径都要验证“退款服务调用次数”,不能只验证 HTTP 状态码。
本章验收
- 审批记录包含操作者、原因、截止时间和方案摘要。
- 重复或并发审批只有一次业务状态转换成功。
- 恢复前会重新授权并校验批准内容没有变化。
- 超时处理由持久调度任务驱动,不占用 Web Worker。