返回 AIPA 笔记
AIPA Day 83

HITL × durable execution 打通 — 审批等待是一种「对时钟的中断」

HITL × durable execution 打通 — 审批等待是一种「对时钟的中断」

2026-09-05
human-in-the-loopdurable-executionapproval-timeout

日期: 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)。但它们之间有一道没焊死的缝——审批等待本身的「超时」,到底由谁负责? 三个具体子问题:

  1. 审批等待怎么持久化——合规官审一份 SAR 可能跨天,进程不可能挂着内存等。Day 37 用 interrupt 落盘解决了「冻结-释放-恢复」,但没回答「等到第几天该怎样」。
  2. 跨会话恢复 + 审批超时策略——一个被冻结的调查,等了 N 天没人审,该自动批?自动拒?还是升级给上级?这个「超时默认动作」在 AML 里是合规红线级的设计决策。
  3. 杀进程重启可恢复待审状态——把 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、不重造:

  1. 幂等键护栏(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」。
  2. 状态保全展示(Day 82):重启后审批 UI 必须显式告诉合规官「你周一确认的 8 笔证据、改过的 Who 段落都在」——payload 是只读快照,杀进程不丢。
  3. 超时降级不无声(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 建)ApprovalTaskwakeAt / 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 → sensitive 24h),与 Day 81 渐进式授权「低风险自动/高风险强制人审」共用同一风险分档输入,不另造风险判定。
  • UI 落 src/components/aml/AmlSarPanel.tsx + AmlCopilot.tsxAmlSarPanel 现有 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 选型一致)。

参考资料

  1. 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 一手
  2. 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 一手
  3. 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 队列 + 从断点续跑不重跑
  4. 本项目 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.tssrc/agent/hitl/approvalQueue.tssrc/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。