返回 AIPA 笔记
AIPA Day 37

HITL 审批点持久化 — interrupt 节点与跨会话恢复

HITL 审批点持久化 — interrupt 节点与跨会话恢复

2026-07-21
human-in-the-loopinterruptapproval-queue

日期: 2026-07-21 阶段: Phase 2 - AI-native 参考架构 标签: #human-in-the-loop #interrupt #approval-queue

核心问题

Day 36 解决了「进程崩了怎么续跑」。今天解决一个主动暂停的对偶问题:AML 调查走到「提交 SAR」这一步前,必须由合规官人工审批——agent 不能自作主张上报监管。问题是:人工审批可能要几小时甚至跨天(合规官下班了、要走二次复核流程),而 agent 进程不可能挂着内存等一天。

所以核心矛盾是:审批的等待时长(小时/天级)远超进程的存活时长(分钟级)。直觉做法「阻塞等待人工输入」在生产里必崩——进程一重启,pending 的审批就丢了。今天回答:

  1. interrupt 节点机制——agent 怎么在审批点「冻结」自己、把状态落盘、然后释放进程,而不是傻等?
  2. 审批状态怎么跨会话恢复——合规官第二天回来点「批准」,怎么让一个全新的进程精确接回那个被冻结的调查、从断点继续?
  3. 这套机制怎么为 P3 的 AML HITL 复核打底——审批队列长什么样、SLA 怎么追。

这不是锦上添花。AML Copilot 的产品定位(Day 1/5)就是 copilot 不是 autopilot——「生成 SAR 草稿 + 人工复核 + 一键提交」,HITL 审批是合规底线,不是可选项。

关键内容

A. interrupt 节点机制:抛异常式暂停 + 状态序列化

LangGraph 的 interrupt() 给了这个模式的范本。它的暂停方式很反直觉——通过抛一个特殊异常来中断执行(LangChain docs, 2026):

"The way that interrupt pauses execution at the point of the call is by throwing a special exception."

抛异常的瞬间,runtime 把整个图状态序列化写入 checkpointer(持久化层):

"State is saved using the checkpointer so execution can be resumed later. In production, this should be a persistent checkpointer (e.g. backed by a database)."

序列化的内容包括「中断时刻所有 state key 的精确值,外加哪个节点中断了、中断的 payload(要给人看的审批材料)」。关键是异常抛出后进程就释放了——不占内存、不挂连接,审批要等多久都行。这与「阻塞等待」的本质区别:

阻塞等待(错):                    interrupt 落盘(对):
  approval = block_read()           raise Interrupt(payload)   ← 抛异常
  # 进程内存挂着等几小时             # runtime 捕获 → 序列化 state → 写 DB
  # 进程重启 → 状态全丢             # 进程释放,0 内存占用
                                    # ... 几小时/几天后 ...
                                    # 人工批准 → 新进程加载 state → 续跑

interrupt 的 payload 必须 JSON 可序列化(要落库、要跨进程传、要渲染给人看),这约束了审批材料的形态——不能塞一个闭包或一个活的 DB 连接进去,只能是纯数据快照。

B. thread_id 作为持久游标 + Command(resume) 跨会话恢复

恢复的钥匙是 thread_id——它是「持久游标 / 持久指针」(LangChain docs):

"thread_id is your persistent pointer: set config={'configurable': {'thread_id': ...}} to tell the checkpointer which state to load."

同一个 thread_id 复用 = 接回同一个 checkpoint;换一个新值 = 开一个空白新线程。恢复用 Command(resume=value),其中 value 成为当初那个 interrupt() 调用的返回值

"The value passed to Command(resume=...) becomes the return value of the interrupt call."

完整的 interrupt/resume 消息流(跨两个进程、跨会话):

[进程 P1, 会话 1]                          [持久层]            [进程 P2, 会话 2, 次日]
  run(case, thread_id=T)
    ...执行到 submit 前...
    payload = interrupt({                  ── 序列化 state ──►  checkpoint(T) {
      caseId, sarDraft, risk, evidence       (含 next=submit,    state, next_node,
    })  ──抛异常,P1 释放──►                  payload)              interrupt_payload }
                                                                       │
        合规官在审批 UI 看到 payload,审了一天 ─────────────────────────┤
                                                                       ▼
                                            ◄── 加载 checkpoint(T) ── resume(
                                                                       Command(resume=
                                                                         {decision:'approve',
                                                                          note:'...'}),
                                                                       thread_id=T)
                                            interrupt() 返回该 value,
                                            从 submit 节点继续 ──► SAR 提交

反直觉洞察①(resume 时,interrupt 之前的同节点代码会重新执行一遍):最容易踩的坑——很多人以为 resume 是「从 interrupt() 那一行的下一行继续」。错。 LangChain docs 明确:「执行恢复时,runtime 从节点开头重启整个节点,它不会从 interrupt 被调用的那行恢复。这意味着 interrupt 之前跑过的任何代码会再跑一遍。」于是若你在 interrupt 之前写了 db.create_audit_log(),resume 时审计日志会写两次。文档的硬规矩:「interrupt 之前的副作用必须幂等」,最佳实践是把副作用挪到 interrupt 之后、或单独成节点。这与 Day 36 的幂等键是同一个底层问题——HITL 暂停本质上就是一次「主动触发的重放」,所以 Day 36 的幂等纪律在这里同样适用,不是巧合。

C. 审批队列 schema 与并发审批

interrupt 落盘的是单个调查的冻结态;但产品侧需要一个审批队列视图——合规官要看「现在有哪些待审 SAR、按风险/SLA 排序」。队列 schema(v1 设计):

ApprovalTask {
  taskId:        string          // = thread_id,持久游标
  caseId:        string
  kind:          'sar_submit' | 'escalate' | 'close'
  payload:       JsonSnapshot    // interrupt 抛出的审批材料(只读快照)
  status:        'pending' | 'approved' | 'rejected' | 'expired'
  riskScore:     number          // 排序用
  createdAt:     number
  slaDeadline:   number          // createdAt + SLA窗口
  assignee?:     string          // 合规官
  decidedBy?:    string
  decidedAt?:    number
  resumeValue?:  Json            // 审批结论, 注入 Command(resume=...)
}

并发审批的细节(LangChain docs):当多个并行节点同时中断(比如一次调查里两份关联 SAR 同时待审),resume 要用**「interrupt id → resume value」的映射**,确保每个回应配对到正确的 interrupt:

"When resuming multiple interrupts with a single invocation, map each interrupt ID to its resume value. This ensures each response is paired with the correct interrupt at runtime."

各 HITL 模式的工程对照(说明为什么选 interrupt 落盘):

模式等待时长进程占用跨会话恢复进程崩溃幸存适用
阻塞读(block_read)分钟级全程占内存✗ 状态随进程丢demo 玩具
轮询标志位(poll flag)小时级全程占内存短任务
interrupt 落盘 + thread_id 恢复天级0(释放进程)✓(同 Day 36)生产 HITL
纯事件回调(无状态机)任意0需自管全部状态取决实现重造 LangGraph 轮子

SLA 与超时是审批队列绕不开的运营维度:pending 任务超过 slaDeadline 要么升级(escalate 给上级合规官)、要么过期回收(expired,调查降级回纯人工)——这与 Day 17 judge 校准的「漂移即降级」是同一治理哲学:自动化卡住时,有兜底的人工降级路径,绝不无声挂起。

设计要点/决策表

要点决策理由
暂停机制interrupt(抛异常 + 序列化落盘 + 释放进程)审批时长(天)远超进程存活(分钟),不能阻塞
持久游标thread_id = taskId,复用即接回 checkpoint跨进程/跨会话精确定位被冻结的调查
恢复入口Command(resume=审批结论),成为 interrupt 返回值把人工决策注入回断点,从下一节点续跑
副作用位置一律放 interrupt 之后或单独节点resume 重跑节点开头,前置副作用会重复
审批材料JSON 可序列化快照,只读要落库、跨进程、渲染给人;不能含闭包/连接
并发审批interrupt-id → value 映射多份 SAR 同时待审时配对不串
超时治理SLA 到期 escalate 或 expired 降级自动化卡住有人工兜底,绝不无声挂起

对本项目的落地

  • 新增 src/agent/hitl/approvalQueue.ts:定义 C 节 ApprovalTask 类型与 enqueue(task) / decide(taskId, resumeValue) → resumeConfig / expireOverdue(now)。v1 用 zustand store(对齐 useTraceStore 模式)持久到 IndexedDB,接口对齐未来 Postgres 审批表。taskId 直接复用 RunTrace.idsrc/agent/trace/types.ts)作为持久游标。
  • orchestrator 集成 interrupt 切口src/agent/orchestrator/orchestratorAgent.ts 现有 finalAnswer 工具是「直接出结论」;P2 在「提交 SAR」类高危动作前插一个 requestApproval 工具——它不执行提交,而是抛出审批 payload、endRun(aborted=true, reason='awaiting-approval')、写 approvalQueue。这复用现有 onFinalAnswer/budget 回调结构,无需重构主循环。
  • 复用 Day 36 幂等纪律:因 resume 会重跑节点开头(反直觉洞察①),SAR 提交、审计写库必须经 Day 36 的 recordOnce(idemKey) 通道,幂等键 = taskId-submit。HITL 暂停=主动重放,Day 36 与 Day 37 共享同一幂等基础设施,不重复造。
  • 审批 UI 接 agent-lab/agent-lab 现展示 trace;P2 加一个「待审队列」面板,从 approvalQueuepending 任务、按 riskScore/slaDeadline 排序,渲染 payload 里的 SAR 草稿 + 证据链(复用 Day 26 失败归因面板的证据渲染组件),合规官点批准/驳回即调 decide() 触发 resume。
  • 为 P3 打底:本日只落「审批点持久化 + 队列 schema + interrupt 切口」的机制层;真实 SLA 数值、合规官分派规则、二次复核流程留 P3。诚实标注:approvalQueue.ts 头注明确 v1 为浏览器内持久(IndexedDB),生产级审批工作流(含 SLA 告警、审计留痕)为 P3 动作;不谎称已接 LangGraph runtime——本项目 P2 自建轻量 interrupt/resume 语义,借鉴 LangGraph 设计但不引其运行时(与 Day 39 选型一致)。

参考资料

  1. LangChain — Interrupts(docs.langchain.com/oss/python/langgraph/interrupts, 2026):interrupt 抛特殊异常暂停;checkpointer 序列化全状态;thread_id 持久指针;Command(resume) 值成为 interrupt 返回值;resume 从节点开头重跑、前置副作用必须幂等;多 interrupt 用 id→value 映射;生产需持久 checkpointer
  2. LangChain — Making it easier to build human-in-the-loop agents with interrupt(langchain.com/blog, 2026):interrupt 原语动机与 HITL 模式
  3. Medium (Data Science Collective) — Architecting Human-in-the-Loop Agents: Interrupts, Persistence, and State Management in LangGraph(2026):执行器序列化完整状态快照 + 记录中断节点与 payload 到 thread_id;生产用 Sqlite/Redis/Postgres 后端
  4. LangGraph docs — Human-in-the-Loop:approve/edit/review 三类 HITL 节点模式 (2026)
  5. 本项目 Day 5(SAR narrative HITL agent UX)、Day 36(幂等键基础设施)、Day 17(漂移即降级治理哲学);src/agent/trace/useTraceStore.tssrc/agent/trace/types.ts(RunTrace.id 作持久游标)(2026-06)

SOTA 检查 (2026-06-11)

  • interrupt/resume + checkpointer 持久化是 2026 HITL 的主流范式:LangGraph 1.0(2025-10 首个稳定版,checkpointing/durable execution)把 interrupt 做成一等原语;OpenAI Agents SDK、Microsoft Agent Framework 1.0(2026-04)均有等价的暂停-审批-恢复机制。本笔记机制与主线同向,未见被替代。
  • 「resume 重跑节点开头」是 LangGraph 当前语义、是 live 踩坑点:这是 2026 社区高频 bug 来源(GitHub issue 多次出现「HITL resume not working / 副作用重复」),反直觉洞察①未过时;与 Day 36 幂等纪律同源,强化了「HITL 暂停=主动重放」的认知。
  • 审批队列 + SLA 治理是工程实践共识但无统一标准:各框架提供 interrupt 原语,但「审批队列 schema、SLA 升级、超时降级」属应用层自建,无事实标准。本项目自定义 ApprovalTask 合理;待跟踪是否出现 HITL 审批的标准化 schema(如 MCP Apps 2026-07-28 的服务端渲染 UI 可能承载审批界面)。
  • 过时认知警示:(1) 不要用「阻塞等待人工输入」实现 HITL——进程存活时长撑不住审批时长;(2) 不要把副作用放在 interrupt 之前——resume 会重复执行;(3) 不要假设 resume 从 interrupt 那行继续——它从节点开头重启。
  • 待跟踪:LangGraph interrupt 语义是否在后续版本改为「从 interrupt 行精确恢复」(消除反直觉洞察①);MCP Apps(2026-07-28 终版)服务端渲染沙箱 UI 能否承载 AML 审批界面,影响 P3 审批 UI 选型;Microsoft Agent Framework 1.0(2026-04,AutoGen+SK 统一后继)的 HITL 模式是否提供更强的审批持久化抽象。