HITL 审批点持久化 — interrupt 节点与跨会话恢复
HITL 审批点持久化 — interrupt 节点与跨会话恢复
日期: 2026-07-21 阶段: Phase 2 - AI-native 参考架构 标签: #human-in-the-loop #interrupt #approval-queue
核心问题
Day 36 解决了「进程崩了怎么续跑」。今天解决一个主动暂停的对偶问题:AML 调查走到「提交 SAR」这一步前,必须由合规官人工审批——agent 不能自作主张上报监管。问题是:人工审批可能要几小时甚至跨天(合规官下班了、要走二次复核流程),而 agent 进程不可能挂着内存等一天。
所以核心矛盾是:审批的等待时长(小时/天级)远超进程的存活时长(分钟级)。直觉做法「阻塞等待人工输入」在生产里必崩——进程一重启,pending 的审批就丢了。今天回答:
- interrupt 节点机制——agent 怎么在审批点「冻结」自己、把状态落盘、然后释放进程,而不是傻等?
- 审批状态怎么跨会话恢复——合规官第二天回来点「批准」,怎么让一个全新的进程精确接回那个被冻结的调查、从断点继续?
- 这套机制怎么为 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.id(src/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 加一个「待审队列」面板,从approvalQueue读pending任务、按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 选型一致)。
参考资料
- LangChain — Interrupts(docs.langchain.com/oss/python/langgraph/interrupts, 2026):interrupt 抛特殊异常暂停;checkpointer 序列化全状态;thread_id 持久指针;Command(resume) 值成为 interrupt 返回值;resume 从节点开头重跑、前置副作用必须幂等;多 interrupt 用 id→value 映射;生产需持久 checkpointer
- LangChain — Making it easier to build human-in-the-loop agents with interrupt(langchain.com/blog, 2026):interrupt 原语动机与 HITL 模式
- Medium (Data Science Collective) — Architecting Human-in-the-Loop Agents: Interrupts, Persistence, and State Management in LangGraph(2026):执行器序列化完整状态快照 + 记录中断节点与 payload 到 thread_id;生产用 Sqlite/Redis/Postgres 后端
- LangGraph docs — Human-in-the-Loop:approve/edit/review 三类 HITL 节点模式 (2026)
- 本项目 Day 5(SAR narrative HITL agent UX)、Day 36(幂等键基础设施)、Day 17(漂移即降级治理哲学);
src/agent/trace/useTraceStore.ts、src/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 模式是否提供更强的审批持久化抽象。