HITL × durable execution 打通 — 审批等待是一种「对时钟的中断」
HITL × durable execution 打通 — 审批等待是一种「对时钟的中断」
日期: 2026-09-05 阶段: Phase 3 - AML 调查 Copilot 标签: #human-in-the-loop #durable-execution #approval-timeout
核心问题
Day 82 把「失败发生后怎么办」的错误恢复实装了,结尾留了一根线头:human_takeover 路径不是弹个窗,而是要走 Day 37 设计的 approvalQueue 同一条持久化通道——「Day 83 会把这条接口打通」。今天兑现这句话。
P2 时期我把两件事分开建了:Day 36/37 建了 checkpointMachine(崩溃续跑 + interrupt 落盘),Day 37 建了 approvalQueue(审批队列 schema)。但它们之间有一道没焊死的缝——审批等待本身的「超时」,到底由谁负责? 三个具体子问题:
- 审批等待怎么持久化——合规官审一份 SAR 可能跨天,进程不可能挂着内存等。Day 37 用 interrupt 落盘解决了「冻结-释放-恢复」,但没回答「等到第几天该怎样」。
- 跨会话恢复 + 审批超时策略——一个被冻结的调查,等了 N 天没人审,该自动批?自动拒?还是升级给上级?这个「超时默认动作」在 AML 里是合规红线级的设计决策。
- 杀进程重启可恢复待审状态——把 worker 直接 kill,重启后那批
pending的审批任务还在不在、能不能精确接回。这是 Day 36 崩溃恢复纪律在 HITL 上的延伸验证。
贯穿全篇的反直觉主线:「等人审批」和「等一个定时器」在 durable execution 里是同一个原语。一旦想通这一点,审批超时就不再是一段需要额外造的轮询逻辑,而是 interrupt 机制白送的能力——难的不是技术,是决定超时默认值朝哪边倒。
关键内容
A. 审批等待持久化:把「等人」建模成「等时钟」的同一个 interrupt
Day 37 已经讲清 interrupt 怎么冻结调查、释放进程。今天补上最关键的一块认知——LangGraph durable 范式(Vadim's blog, 2026)的一句话点破了 HITL 与崩溃恢复的统一性:
"treats a pause-for-a-human as the same
interrupt()primitive as a pause-for-a-clock"(把「等人」当成与「等时钟」完全相同的 interrupt 原语)
这句话的分量在于:审批超时不需要单独造一套 timer 机制。一个「等合规官审批、最多等 7 天」的暂停,在数据上等价于两个并发 interrupt——一个等人工 resume、一个等 wake_at = createdAt + 7d 的时钟 resume,谁先到谁触发。两者都只是 checkpoint 里的一条 status='waiting' 记录:
"a workflow that sleeps for a week and then continues mid-graph, without holding a thread or a connection"(睡一周再从图中间继续,全程不占线程/连接)
恢复靠一个外部 resume driver 轮询 checkpoint store(Vadim's blog, 2026):
"WHERE status='waiting' AND wake_at <= now" ... "the agent's lifetime is decoupled from any one process, and the checkpoint store is the single source of truth"
把这条范式落到 AML 审批任务上,ApprovalTask(Day 37 的 schema)只需补两个字段就能同时承载「等人」和「等超时」两种唤醒:
interface ApprovalTask {
taskId: string // = thread_id 持久游标(Day 37)
caseId: string
status: 'pending' | 'approved' | 'rejected' | 'expired' | 'escalated'
payload: JsonSnapshot // interrupt 抛出的只读审批材料
createdAt: number
wakeAt: number // ◄── 新增:超时唤醒时刻 = createdAt + ttl
ttlKind: 'ordinary' | 'sensitive' // ◄── 新增:决定 ttl 与超时默认动作
resumeValue?: Json // 人工决策;注入 Command(resume=...)
}
resume driver 的查询条件因此是一个 OR——人工 decide 命中 taskId,或时钟命中 wakeAt <= now,两条路殊途同归地把同一个 checkpoint 唤醒:
resume 唤醒条件 = (人工 decide(taskId) 到达) OR (now >= task.wakeAt)
↑ 等人那条 interrupt ↑ 等时钟那条 interrupt
两条都是对同一个 checkpoint(taskId) 的 resume;谁先触发谁定状态
反直觉洞察①(「等人审批」不是特殊逻辑,是「等时钟」的同构):直觉会把「审批超时」当成一个要额外写的功能——加个 cron、扫 pending 表、超时的标记掉。但 durable execution 范式揭示二者同构:人工 resume 和时钟 resume 注入的是同一个
interrupt()的返回值,区别只在「谁先到」和「带回什么resumeValue」。想通这点,超时不是新机制,是给同一个 interrupt 配一个wakeAt兜底闹钟。这也解释了为什么 Day 37 的slaDeadline字段当初就该是wakeAt——它本质是「等时钟那条 interrupt 的触发时刻」,不是一个独立的 SLA 计时器。本项目据此把 Day 37 的slaDeadline重命名/收敛为wakeAt,统一两种唤醒。
B. 跨会话恢复 + 审批超时策略:超时默认动作必须「拒」不能「批」
超时机制是白送的;难的是超时之后默认做什么。这是本日最重要的合规决策。行业 2026 的治理共识斩钉截铁(omnithium/dev.to, 2026):
"Timeout should equal denial (or at minimum, escalation), never approval. An agent that can get approval by waiting out a distracted reviewer is not governed, it is patient."(超时应等于拒绝,至少是升级,绝不能是批准。一个能靠「熬走分心的审核人」来拿到批准的 agent,不是被治理了,只是有耐心。)
这条原则对 AML 是生死线。设想反面:若超时默认「自动批准」,那么一个该被拦下的 SAR 草稿——比如 Day 82 里 typology_misjudge 误判、被转人工接管的——只要在审批队列里躺过 7 天没人点,就会被系统自动签发上报监管。这等于给「无声自动上报」开了一道后门,与 Day 5 确立的「copilot 不是 autopilot、SAR 签发法律责任在人」直接冲突。所以超时默认动作必须朝「安全的那边」倒:宁可拒/升级(最坏是漏报一次、人工补救),绝不自动批(最坏是错误上报,监管语境下等同伪造记录、不可逆)。
TTL 取值采用行业生产默认(digitalapplied, 2026),但按敏感度分档:
"Recommended defaults from production practitioners: a 7-day approval TTL for ordinary operations, 24 hours for sensitive ones."
| ttlKind | 触发场景 | TTL | 超时默认动作 | 理由 |
|---|---|---|---|---|
ordinary | 低风险案件的 SAR 复核 | 7 天 | escalate(升级上级合规官) | 给足复核窗口;超时不丢,升级兜底 |
sensitive | 高风险(high riskScore / 涉制裁名单 / 大额 CTR ≥ $10,000) | 24 小时 | escalate(缩短窗口、更快升级) | 高风险不能久拖;窗口短 + 强制升级 |
| (任一档) | 升级后仍超时(二次超时) | 二次窗口 | expired → 降级回纯人工 | 绝不 auto-approve;调查冻结、退出自动化 |
注意三档没有一档是 auto-approve——这是刻意的。超时只会让任务在「升级 → 再升级 → 过期降级回人工」的链条上滑动,永远不会自己把 SAR 推上报。approval_timeout_seconds 按动作类型可配(omnithium 范式:email 3600s、refund 900s),AML 的 SAR 提交属最高敏感档。
跨会话恢复的消息流(杀进程也不丢,对齐 Day 37 的 thread_id 游标):
[进程 P1, 周一] [checkpoint store] [进程 P2, 周四]
调查跑到 submit 前
interrupt({payload, wakeAt}) ── 落盘 ──► task(T) {
── 抛异常,P1 释放 ──► status='pending',
wakeAt=Mon+7d }
⟵⟵⟵ P1 进程被 kill / 部署滚动,内存全丢,但 checkpoint 还在 ⟶⟶⟶
│
resume driver 每 tick 扫: WHERE status='waiting' AND wakeAt<=now
│ 周四 now < wakeAt → 不唤醒
合规官周四在审批 UI 点「批准」 ───────────────────┤
▼
◄── 加载 checkpoint(T) ── P2.resume(
Command(resume={decision:'approve'}),
thread_id=T)
interrupt() 返回该 value → 从 submit 续跑
反直觉洞察②(在 AML 里,超时默认值的「方向」比 TTL 的「数值」重要一个数量级):调超时参数时,本能会纠结「7 天还是 5 天」「24 小时还是 48 小时」。但数值调错最多让审核人忙一点或松一点;方向调错(默认 auto-approve)是合规事故级缺陷——它把「人没看」悄悄变成「人同意了」,伪造了问责链上最关键的那一环(谁批准了上报)。所以本项目把「超时默认 = 绝不 auto-approve」写成代码层硬约束、不可配置关闭(与 Day 82「critical 类强制人工」同款硬约束),而把 TTL 数值做成可配。一个能被「熬到超时自动通过」的 AML Copilot,比一个会卡住报错的 Copilot 危险得多——这是 Day 82 反直觉洞察②(自动重试到成功最危险)在审批维度的同一条哲学。
C. 杀进程重启可恢复待审状态:HITL 暂停=主动重放,幂等纪律照旧适用
最后验证:把 worker 直接 kill,重启后那批 pending 审批能不能精确接回。这正是 Day 36 崩溃恢复纪律在 HITL 上的延伸——因为 HITL 暂停本质上就是一次「主动触发的重放」(Day 37 反直觉洞察①:resume 会从节点开头重跑、interrupt 之前的代码再跑一遍)。
恢复正确性的状态机(一个审批任务从冻结到唤醒的完整生命周期):
┌──────────────────────────────────────────────────┐
│ 调查执行到 submit 前 → interrupt({payload, wakeAt})│
│ 序列化 state 写 checkpoint,进程释放 │
└────────────────────────┬─────────────────────────┘
▼
status = 'pending' ◄──── 杀进程也不丢(checkpoint 是唯一真相)
│
┌────────────────────┼────────────────────────┐
▼ ▼ ▼
人工 decide(approve) 人工 decide(reject) now >= wakeAt (超时)
│ │ │
▼ ▼ ┌─────────┴─────────┐
resume(approve) resume(reject) 一次超时 二次超时
从 submit 续跑 调查冻结/退回 status='escalated' status='expired'
│ 升级上级合规官 降级回纯人工
▼ (重设 wakeAt) (绝不 auto-approve)
幂等键 = taskId-submit
INSERT ... ON CONFLICT DO NOTHING ◄── 复用 Day 36 recordOnce 通道
│
▼
SAR 提交副作用恰好一次
关键工程约束,三条全部继承自 P2、不重造:
- 幂等键护栏(Day 36):resume 重跑节点开头,SAR 提交、审计写库必须经
recordOnce(idemKey),idemKey = taskId + '-submit'。审批超时升级会让同一个 task 被多次 resume(升级 = 重设 wakeAt 再 interrupt),每次 resume 都重跑——若 SAR 提交不走幂等键,二次超时升级时可能重复上报。这正是 omnithium(2026)强调的「generate an idempotency key before the interruption and persist it in state, so a resumed action runs exactly once even if the approval flow retries」。 - 状态保全展示(Day 82):重启后审批 UI 必须显式告诉合规官「你周一确认的 8 笔证据、改过的 Who 段落都在」——
payload是只读快照,杀进程不丢。 - 超时降级不无声(Day 17/37 治理哲学):
expired不是把任务删了,是降级回纯人工并留痕——自动化卡住时永远有人工兜底路径,绝不无声挂起。
各 HITL 超时实现的工程对照(说明为什么选「interrupt + wakeAt 兜底闹钟」):
| 实现 | 超时是否持久 | 杀进程幸存 | 超时默认动作 | 风险 |
|---|---|---|---|---|
| 内存 setTimeout 倒计时 | ✗ 随进程丢 | ✗ | 进程重启即丢失,可能永不超时 | pending 永久挂起 |
| 应用层 cron 扫 pending 表 | ✓ | ✓(额外建表) | 需自管唤醒逻辑、易与 resume 竞态 | 重复唤醒/双重 resume |
| interrupt + wakeAt(等时钟同构) | ✓(checkpoint) | ✓(同 Day 36) | 配置化、绝不 auto-approve | 须守幂等键(Day 36) |
| 无超时(纯等人工) | n/a | ✓ | 永不超时→pending 永久积压 | 高风险案件久拖、无 SLA |
设计要点/决策表
| 要点 | 决策 | 理由 |
|---|---|---|
| 审批等待 | interrupt 落盘 + thread_id 游标(Day 37) | 审批跨天,进程不能阻塞挂内存 |
| 超时建模 | 「等人」与「等时钟」同一 interrupt,配 wakeAt 兜底 | durable 范式同构,不另造 timer 机制 |
| 超时默认动作 | 绝不 auto-approve,只 escalate→expired 降级回人工 | timeout=denial/escalation never approval(合规红线) |
| TTL 分档 | ordinary 7 天 / sensitive 24 小时,按 riskScore 定档 | 行业生产默认 + 高风险缩窗 |
| 硬约束位置 | 「禁 auto-approve」做成代码层不可配置关闭 | 方向错=伪造问责链,比数值错严重一个数量级 |
| 幂等键 | taskId-submit,走 Day 36 recordOnce 通道 | 超时升级=多次 resume=多次重放,须幂等防重复上报 |
| 超时降级 | expired 降级回纯人工 + 留痕,不删任务 | 自动化卡住有人工兜底,绝不无声挂起 |
对本项目的落地
- 扩展
src/agent/durable/checkpointMachine.ts:当前CheckpointMachine已有resumeFrom(cp)跨进程续跑语义(模拟「进程重启后从 durable store 读回 checkpoint」),P3 在其上加一个 interrupt 切口——新增Step的一种变体interruptStep:执行到此抛InterruptPending(类比已有的InjectedCrash),把{ payload, wakeAt }写进 checkpoint 的state、停在该步之前,等外部resumeFrom(cp, resumeValue)注入决策续跑。复用现有的「停在 crash 前最近 checkpoint、不回滚已落盘」机制,无需重构advance()主循环。 - 扩展
src/agent/hitl/approvalQueue.ts(Day 37 建):ApprovalTask补wakeAt/ttlKind字段(A 节),新增wakeDue(now) → ApprovalTask[](实现WHERE status='waiting' AND wakeAt<=now的内存等价物)与onTimeout(task) → 'escalated' | 'expired'(实装 B 节超时策略,函数体内硬编码「绝不返回 approved」,并加单测断言onTimeout的返回值集合不含'approved'——把合规红线钉成可回归的断言)。decide(taskId, resumeValue)已有,复用为人工那条 resume。 - TTL 定档复用 P2 风控网关语义:
ttlKind由案件riskScore决定(high →sensitive24h),与 Day 81 渐进式授权「低风险自动/高风险强制人审」共用同一风险分档输入,不另造风险判定。 - UI 落
src/components/aml/AmlSarPanel.tsx+AmlCopilot.tsx:AmlSarPanel现有SarReviewStatus = 'pending' | 'approved' | 'returned',P3 扩为含'escalated' | 'expired';待审任务旁渲染倒计时 + TTL 档位(sensitive 用 amber 提示「24h 窗口」),超时升级时显式标「已升级上级复核,你的证据集已保全」(对齐 Day 82 状态保全展示)。审批 UI 接/agent-lab待审队列面板(Day 37 已规划),按wakeAt临近度排序。 - 崩溃注入测试延伸:在 Day 36 的
crashRecovery.test.ts旁加hitlResume.test.ts——注入「pending 期间杀进程」,断言resumeFrom后任务状态、payload快照、wakeAt全部精确恢复,且超时升级后 SAR 提交副作用仍恰好一次(幂等键护栏未被升级路径击穿)。 - 诚实标注:
approvalQueue.ts头注明确 v1 为浏览器内持久(IndexedDB)+ 内存 resume driver(无真实 cron),生产级需后端共享 checkpoint store + 调度器;「禁 auto-approve」为产品硬约束不可配置;TTL 数值(7d/24h)为行业默认占位,真实值待 P3 与合规团队定。不谎称已接 LangGraph runtime——本项目自建轻量 interrupt/resume 语义,借鉴其「pause-for-human=pause-for-clock」设计但不引其运行时(与 Day 37/39 选型一致)。
参考资料
- Vadim's blog — Durable Execution in LangGraph: Agents That Survive Failure and Resume Where They Left Off(vadim.blog,2026):「pause-for-a-human 与 pause-for-a-clock 是同一个 interrupt 原语」;睡一周再从图中间续跑、不占线程;resume driver
WHERE status='waiting' AND wake_at<=now、批量 25/tick、checkpoint store 是唯一真相 — 本日 WebFetch 一手 - omnithium / DEV — Human-in-the-Loop Patterns for High-Stakes AI Agent Decisions(dev.to,2026):「Timeout should equal denial (or escalation), never approval」;
WorkflowStatus生命周期枚举(SUSPENDED/AWAITING_APPROVAL/APPROVED/DENIED);EscalationRouter按 required role/domain expertise 路由、最少负载分派;check_sla_violations()每 ~5min;幂等键须在中断前生成并持久化 — 本日 WebFetch 一手 - Digital Applied — Human-in-the-Loop Escalation Design for AI Agents 2026(digitalapplied.com,2026):7 天 ordinary / 24h sensitive 审批 TTL 生产默认;30 分钟升级 kill-switch 作 forcing function 防永久挂起;checkpoint 序列化 + TTL 队列 + 从断点续跑不重跑
- 本项目 Day 37(interrupt 落盘 / thread_id 游标 / ApprovalTask schema / 并发审批)、Day 36(at-least-once + 幂等键 recordOnce)、Day 82(错误恢复 human_takeover 接 approvalQueue / 状态保全展示)、Day 5(copilot 非 autopilot / SAR 签发责任在人)、Day 17(漂移即降级治理哲学);
src/agent/durable/checkpointMachine.ts、src/agent/hitl/approvalQueue.ts、src/components/aml/AmlSarPanel.tsx(2026-06)
SOTA 检查 (2026-06-11)
- 「HITL 审批 = durable execution 的 suspend/resume 原语」是 2026 主流共识:LangGraph(interrupt/checkpointer)、Temporal(durable timer + signal/update)、Inngest/Cloudflare/Vercel(2025 进入早期多数市场)口径一致——HITL 直接映射到 suspend/resume,能 pause 数小时/数天而不丢状态。本日 WebSearch 未见替代范式。
- 「pause-for-human = pause-for-clock 同一原语」是 live 的工程洞察:Vadim's blog(2026)明确把审批等待与定时等待统一到同一个 interrupt,这是把「审批超时」从「额外造的功能」降维成「白送的兜底闹钟」的关键认知,反直觉洞察①未过时。
- 「timeout ≠ approval」是受监管场景的硬治理原则、非通用结论:omnithium(2026)「能熬到超时自动通过的 agent 不是被治理而是有耐心」是 2026 高频引用的治理金句;在 AML 这种「错误上报不可逆」的场景比通用 SaaS 更刚性——本项目据此把「禁 auto-approve」钉成代码硬约束,是把通用 HITL 模式「为金融合规特化」的关键差异(Day 84 周总结会归纳)。
- TTL 数值(7d/24h)是行业默认非标准:digitalapplied/omnithium 给出生产默认值,但各家按动作敏感度自定(email 3600s vs refund 900s);AML 的 SAR 属最高敏感档,真实 TTL 须与合规团队按监管 SAR 提交时限(FinCEN 通常要求侦测后 30 日内提交)反推,W12 仅落机制与默认占位。
- 过时认知警示:(1) 不要用内存
setTimeout做审批超时——进程重启即丢、pending 永久挂起;(2) 不要让超时默认 auto-approve——伪造问责链,合规事故级;(3) 不要假设升级路径不会重复 resume——超时升级=多次重放,SAR 提交必须走幂等键(Day 36),否则二次升级重复上报。 - 待跟踪:MCP Apps(2026-07-28 终版)服务端渲染沙箱 UI 能否承载 AML 审批界面(含倒计时/升级提示),影响 P3 审批 UI 选型;Temporal/LangGraph 是否提供 HITL 审批超时的标准化「方向安全默认」(当前各家自定),以及 EscalationRouter 按 reviewer role 路由是否出现事实标准 schema。