为受控 Agent 增添 HITL:从 Graph interrupt 到 PostgreSQL checkpoint resume

时间:2026-07-01 09:27:41 来源:互联网

Agent项目开发中,HITL(Human-in-the-Loop)往往容易被低估其重要性。初看似乎只需在页面添加一个确认按钮,但在实际的Agent执行链路中,需要考虑的问题要复杂得多。

刚开始看,它好像只是“在页面上加一个确认按钮”。但真正落到 Agent 执行链路里,要考虑的问题会多很多:

Graph 在哪里暂停?
暂停时保存什么状态?
用户审核结果怎么校验?
恢复时从哪里继续?
会不会重复恢复?
恢复后的内容写回哪里?
数据库里记录业务状态,还是记录 Graph 状态?

因此,v0.3.0 所做的并非简单地增加一个审核卡片。其核心目标是,将“人工介入 Agent 执行”这一过程,系统化地整理为一套可暂停、可校验、可恢复且能防重复提交的执行协议。


1. HITL 是什么:让人进入 Agent 执行链路

HITL,全称是 Human-in-the-loop,通常可以理解成“人在环路中”。

普通 AI 对话里,用户一般只在开头输入需求,后面由模型一次性生成结果。

但 Agent 不太一样。它可能会经历多个阶段:

理解需求
制定策略
读取上下文
生成草稿
校验结果
必要时修订
输出最终产物

在这些阶段中,部分节点并不适合完全交由模型自动决策。

比如:

生成前的策略是否合理?
模型是否应该继续修订?
当前草稿是否应该由用户直接接管?
本轮运行是否应该终止?

HITL 旨在解决一个核心问题:在关键节点暂停 Agent,让用户做出一个受控决策,随后 Agent 再从中断点继续执行。

这与普通的页面确认框有本质区别,因为 Agent 的暂停需要真实地介入执行系统:

Graph 要暂停
状态要保存
用户决策要校验
恢复时要回到同一条执行线程
后续输出还要接回原来的消息

这正是 v0.3.0 的核心变化所在。


2. v0.3.0 的目标:让 Tasklist Agent 可以暂停、审核、恢复

在 v0.3.0 版本之前,AI Mind 的 Tasklist Agent 已经是一个受控的单 Agent。

它并非开放式 Agent,不会自由调用工具、读取文件或者随意扩大执行范围。

它的入口非常明确:

/tasklist + @docs://versions/*.md

在没有 HITL 的情况下,它更像一条一次性执行的流水线:

用户发起 /tasklist
  -> 读取版本方案
  -> 判断方案是否足够
  -> 决定 tasklist 拆分策略
  -> 生成 tasklist 草稿
  -> 校验草稿
  -> 必要时修订
  -> 输出最终产物

这条链路虽然能够运行,但用户只能在最终结果中看到 Agent 的判断。

如果中间的策略方向出现偏差,后续生成的草稿也很可能随之偏离正确轨道。

因此,v0.3.0 新增了两个审核点。

第一个是策略审核。

拆分策略确定后必须暂停。
用户审核后,Agent 才能继续生成 tasklist。

用户可以选择的选项包括:

approve:确认继续
edit:修改结构化策略后继续
reject:终止本轮运行
respond:给出反馈,让 Agent 重新生成策略

第二个是修订审核。

它并非每次都出现,只有当系统判断“确实需要立即修订”时才会触发:

存在需要立即修订的问题
  -> 触发修订审核不存在需要立即修订的问题
  -> 不暂停,继续输出最终结果

最终的设计可以概括为:

策略必审
修订条件审
最多两轮受控修订

这个取舍非常重要。

HITL 并非要求在每一步都停下来询问用户。过度的暂停会破坏 Agent 执行的连续性,同时也会增加用户的负担。

v0.3.0 只将人工介入放在关键的授权节点上,其余流程仍然由受控的 Graph 继续自动执行。


3. v0.2.4 的 GraphState 收口是前提

这里简单提一下前一个版本。

v0.2.4 所做的工作是 Graph 单状态模型的收口。

在更早的迁移阶段,Tasklist Agent 虽然已经接入了 LangGraph,但其内部仍然存在一些过渡性的结构:

GraphState
  -> 旧 AgentState
  -> 领域步骤
  -> 旧 AgentState
  -> GraphState 状态补丁

这在迁移阶段可以降低风险,但到了需要实现 HITL、中断和恢复的版本时,这种结构就会变得别扭。

因为在恢复执行时,必须回答一个问题:应当从哪个状态恢复?v0.2.4 先将内部的运行态收敛到 GraphState。到了 v0.3.0,审核决策、状态补丁、路由判断以及 checkpoint 恢复才能够围绕同一份状态继续推进。

一句话概括:


4. HITL 的完整链路:从首次请求到恢复执行

没有 HITL 时,一次请求的流程通常是这样的:

POST /api/chat
  -> 创建 GraphState
  -> 执行 Graph
  -> 输出最终产物
  -> 结束流式响应

引入 HITL 后,这条链路会被拆分为两段。

第一段是首次执行,运行到审核节点后暂停:

POST /api/chat
  -> 识别 /tasklist
  -> 创建 AgentRun
  -> 创建 threadId
  -> 选择 checkpointer
  -> 执行 Graph
  -> 进入审核节点
  -> interrupt(payload)
  -> 保存 checkpoint
  -> 保存 AgentInterrupt
  -> 输出审核卡片
  -> 关闭本次 HTTP 响应

这里的状态既非失败,也非完成,而是暂停。

Graph 已经运行到审核点
当前 thread 的 checkpoint 已保存
业务状态进入 paused
前端展示审核卡片

用户提交审核结果后,会发起一个恢复请求:

POST /api/agent-runs/:runId/resume
  -> 校验会话归属
  -> 校验 run 状态
  -> 校验是否存在待处理审核点
  -> 校验审核决策是否合法
  -> 消费当前审核点
  -> 使用同一个 threadId 恢复 Graph
  -> 继续后续节点
  -> 输出最终产物或进入下一个审核点

这便是 HITL 的核心链路。

恢复执行并非重新发起一次 Agent,而是回到同一个 Graph thread,从之前保存的 checkpoint 位置继续向下执行。


5. LangGraph、后端、数据库、前端分别负责什么

HITL 并非某一层单独能够完成的能力。

在 v0.3.0 中,它至少涉及四个层面:

LangGraph
后端运行时
数据库
前端消息与审核交互

每一层的职责都需要清晰界定。


5.1 LangGraph 负责暂停和恢复 Graph

LangGraph 负责 Graph 层面的执行语义:

执行节点
在审核节点触发 interrupt
通过 checkpointer 保存 checkpoint
通过 threadId 找到同一条执行线程
resume 后继续运行 Graph

也就是说,LangGraph 解决的是以下问题:

Graph 运行到哪里了?
暂停时状态怎么保存?
恢复时怎么从同一个 thread 继续?

但它不负责业务系统层面的问题。

比如:

这个 run 属于谁?
用户有没有权限恢复?
当前审核点是否已经被消费?
审核决策是否在允许范围内?
前端应该更新哪一条消息?
当前 run 应该展示 paused 还是 completed?

这些问题需要后端运行时和业务数据库来处理。


5.2 后端运行时负责把 Graph 能力变成业务流程

后端运行时的职责,是将 LangGraph 的 interrupt 和 resume 能力,包装成一个在业务上可控的 Agent 运行流程。

在首次请求时,后端需要执行以下步骤:

识别 Tasklist Agent 入口
创建 AgentRun
生成 threadId
选择 checkpointer
执行 Graph
捕获 interrupt
持久化 AgentInterrupt
向前端输出审核卡片
关闭当前响应

在收到恢复请求时,后端需要执行:

校验 run 是否存在
校验会话归属是否匹配
校验 run 是否处于 paused
校验是否存在待处理审核点
校验审核决策是否合法
校验 agentVersion / graphVersion 是否匹配
消费待处理审核点
调用 resume 继续执行 Graph
把后续输出写回原助手消息

这里最关键的一点是:后端不能将前端提交的审核结果直接透传给 Graph。

用户提交的是外部输入。外部输入在进入 Agent 执行链路之前,必须先经过服务端校验。

否则,HITL 就会变成另一个随时可以下达指令的自由入口。


5.3 数据库负责业务状态和 checkpoint

数据库中主要包含两类状态。

第一类是业务状态,由 Prisma 管理:

agent_runs
agent_interrupts

它们关注的是:

这个 run 当前是什么状态?
现在有没有待处理审核点?
这个 run 属于哪个会话?
对应哪条助手消息?
用户提交了什么审核决策?
是否已经恢复过?
最终结果是 completed、blocked、rejected 还是 failed?

第二类是 Graph checkpoint,由 LangGraph 的 PostgresSaver 管理。

它关注的是:

GraphState 快照是什么?
thread checkpoint 在哪里?
恢复时如何继续执行 Graph?

这两类状态不能混淆。

可以这样理解:

checkpoint 让 Graph 知道从哪里继续。
AgentRun 让业务系统知道谁能继续、能不能继续、继续后是什么状态。

因此,v0.3.0 没有将完整的 GraphState 存入 AgentRun,也没有试图用 checkpoint 来替代业务 run 的查询。

PostgresSaver 和 Prisma 共用同一个 PostgreSQL 实例,但它们的职责边界是清晰分离的。


5.4 前端负责审核交互和原消息续写

前端主要承担交互和展示的职责。

当执行到达审核点后,前端需要展示审核卡片。

在待处理审核期间,输入框需要被禁用:

不能发起新消息
不能重新生成上一轮
只能提交当前审核决策
或者拒绝当前 run

用户提交审核决策后,前端会进入恢复中状态,等待后端继续返回流式内容。

这里还有一个重要的体验细节:后续的执行轨迹、产物以及最终的文本,都应当继续合并到原始的助手消息中。因为从用户的视角来看,这并非一轮新的对话,而是同一轮 Agent 执行的延续:

开始执行-> 中间暂停-> 用户审核-> 继续执行-> 输出结果

这样一来,用户看到的是一条完整的 Agent 执行过程,避免了信息的脱节感。


6. 审核节点为什么必须无副作用

这是实现 HITL 时一个非常关键的原则。

LangGraph 的 interrupt 有一个重要的语义:恢复后,会从触发 interrupt 的节点起点重新执行。

这意味着,审核节点不能在 interrupt 之前执行任何不可重复的副作用操作。

因此,v0.3.0 对审核节点的边界要求非常严格:

不调用模型
不调用工具
不读取资源
不写数据库
不写文件
不发送产物
不依赖 request / writer / AbortSignal
只构造 interrupt payload
resume 后只解析审核决策
最后返回 GraphState 状态补丁

原因非常直接。

如果审核节点在 interrupt 前调用了模型,当 resume 时重新执行这个节点,就会导致模型被重复调用。同理,写入数据库、发送产物等操作也会因重复执行而产生错误数据。

所以,审核节点最好是一个非常“纯粹”的节点:

首次执行:
  构造 payload
  interrupt(payload)恢复后重新执行:
  接收审核决策
  校验并转换为 GraphState 状态补丁

可以简单概括为:无副作用的审核节点,是保障 HITL 过程幂等性和安全性的基石。


7. 业务状态和 checkpoint 为什么必须分开

在进行 Agent resume 的设计时,人们很容易将两个概念混淆:Graph checkpoint 和业务 run 状态。

然而,它们要解决的问题是不同的。

Graph checkpoint 解决的是技术层面的恢复问题:

GraphState 保存在哪里?
恢复时从哪个 thread 继续?
LangGraph 能不能接着执行?

业务 run 状态解决的则是产品和后端的控制问题:

这个 run 是不是 paused?
这个审核点是不是还在等待处理?
这个用户有没有权限提交审核决策?
是否重复恢复?
是否跨版本恢复?
最终是 completed、blocked、rejected 还是 failed?

因此,v0.3.0 的分工非常明确:

PostgresSaver:
负责 Graph checkpoint。Prisma:
负责 AgentRun / AgentInterrupt 业务表。AgentRunService:
负责状态迁移、所有权校验、版本校验、重复恢复防护。

这样的分层设计,让整个系统的职责变得非常清晰。

当 Graph 需要恢复执行时,它只需要查找 checkpoint。
当后端需要判断用户能否提交审核时,它只需查看 AgentRun / AgentInterrupt。
当前端需要展示待处理的审核任务时,它读取业务 DTO,而不是直接读取 checkpoint。

这也是本版本中一个非常重要的架构经验:将技术恢复能力与业务控制逻辑解耦。


8. resume 如何避免重复提交:事务和行锁

恢复请求天然存在并发风险。

例如:用户连续点击两次按钮、浏览器重试请求、网络抖动导致的重复提交,或者两个恢复请求几乎同时到达后端。

如果没有适当的保护机制,可能会出现以下问题:

同一个审核点被消费两次
Graph 被恢复两次
生成两个产物
run 状态被写乱

因此,在恢复阶段至少需要确保以下几点:

同一个待处理审核点只能被消费一次
run 从 paused -> resuming 是原子的
interrupt 从 pending -> decided 是原子的
审核决策写入和状态迁移在同一个事务内完成

可以将这个过程理解为一次“消费待处理审核点”的事务操作:

begin transaction
1. 锁定当前 run 和待处理审核点
2. 确认 run.status = paused
3. 确认 interrupt.status = pending
4. 写入用户审核决策
5. interrupt.status = decided
6. run.status = resuming
commit

事务提交成功后,才真正开始恢复 Graph:使用同一个 threadId 继续执行。

在 PostgreSQL 中,这类并发保护可以通过“事务+行锁”实现,或者通过带状态条件的更新语句来实现。

重点不在于具体的锁写法,而是要保证一个原则:原子性锁定和条件更新,杜绝并发状态下处的重复消费。

如果第二个请求进来时,发现审核点已经不是 pending 状态,或者 run 已经不是 paused 状态,就应该直接让请求失败,而不是继续执行恢复流程。

这一层防重入机制非常重要,因为用户看起来只是点击了一个按钮,但后端实际执行的是一个涉及状态迁移和 Graph 恢复的关键动作。


9. 为什么 resume 要继续更新原助手消息

在普通的聊天场景中,一次请求通常对应生成一条助手消息。

但 HITL 的场景有所不同。

当首次请求执行到 interrupt 时,这条助手消息实际上还没有完成。它只是进入了一个名为 paused 的暂停状态。

如果恢复执行后新建一条助手消息,用户看到的界面会变得非常割裂,好像第一条消息被抛弃,又重新开始了一条。这会破坏 HITL 的语义连贯性。

因为真实发生的情况是:同一轮 Agent 执行,中间暂停,等待用户审核,然后继续执行,最终输出结果。

所以,v0.3.0 的做法是这样的:首次请求创建助手消息;执行到 interrupt 后,这条消息进入 paused 状态;用户提交审核并恢复后,这条消息进入 resuming 状态;后续的执行轨迹、产物和最终文本都继续写回这条原始消息中。

这样,前端呈现给用户的将是一条完整的 Agent 执行轨迹:

开始执行-> 策略审核-> 用户 approve / edit / respond-> 继续生成-> 可能进入修订审核-> 再继续-> 输出产物

HITL 在前端的设计目标,并非是“再生成一条新的回复”,而是“续写同一次 Agent 的运行过程”。


10. 这版刻意不做什么

v0.3.0 虽然实现了 HITL、checkpoint 和 resume 功能,但它并没有将 AI Mind 转变为一个通用的 Agent 平台。

这一版本明确不做以下事情:

不支持刷新恢复 pending HITL
不做 Run History
不做 Time Travel
不做完整聊天消息持久化
不做多人审批
不做通用工具审批
不扩展普通聊天 / Reader Skill / Utility Skill / MCP
不把 Tasklist Agent 扩展成通用 Agent 平台

其中最值得说明的是:不支持刷新恢复 pending HITL。

这一版本支持的是同页内的暂停和恢复,而非完整的跨会话恢复。如果页面刷新后,只恢复出一张审核卡片,但上方没有完整的消息、执行轨迹和产物上下文,用户会看到一个错位的状态。

真正的刷新恢复功能,还需要支持消息持久化、执行轨迹重建、产物重建、待处理审核上下文重建以及原助手消息重新绑定等一系列复杂操作。

这些事情并不是 v0.3.0 的重点。因此,本版本选择明确收窄范围:只支持同页面的暂停与恢复,不支持页面刷新后的状态恢复。

这个边界看起来可能有些保守,但它能有效避免将不完整的会话恢复功能包装成一个看似完整的能力推向用户。


11. 总结:HITL 的核心是可恢复的受控执行协议

在完成 v0.3.0 的开发后,我对 HITL 的理解变得更加清晰。它并非一个简单的确认按钮,也非一个前端弹窗。

在 Agent 运行时中,一个完整的 HITL 实现至少包含以下组成部分:Graph interrupt、可序列化的 interrupt payload、结构化的审核决策、PostgreSQL checkpoint、AgentRun 与 AgentInterrupt 记录、会话归属校验、重复恢复防护、同 thread 恢复机制、原助手消息续写,以及明确的能力边界。

如果只做前端弹窗而忽略后端集成,Agent 实际上并未真正暂停。如果只有 checkpoint 而没有 AgentRun 来管理业务状态,业务系统将无法判断谁能恢复以及是否允许恢复。反之,如果只有 AgentRun 而没有 checkpoint,Graph 则无法从中断处继续执行。若缺少事务和防重入机制,同一个审核决策可能被错误地消费两次。若恢复后新建消息,用户看到的执行轨迹则会断裂,导致前后不连贯。

因此,我认为 v0.3.0 的核心价值并非仅仅给 Tasklist Agent 加了一个“审核按钮”,而是成功构建了一套可恢复的受控执行协议。这套协议将是受控 Agent 在未来继续演进过程中,迈出的非常关键的一步。