给受控 Agent 加 HITL:从 Graph interrupt 到 PostgreSQL checkpoint resume

本文基于 AI Mind 项目的真实实现整理。

GitHub:github.com/HWYD/ai-min...

对应代码版本:v0.3.0

线上链接:ai.hwyblog.cloud/instant-min...

AI Mind 是一个持续迭代中的 Next.js AI Chat 项目。从最基础的本地聊天开始,逐步加入流式协议、工具调用、MCP、Skill 和 Agent 能力。

如果你对这个项目感兴趣,或者这篇文章对你有一点帮助,也欢迎顺手到 GitHub 帮 AI Mind 点个 Star⭐,这会是对我继续更新很大的鼓励。

在 Agent 项目里,HITL 是一个很容易被低估的能力。

刚开始看,它好像只是"在页面上加一个确认按钮"。但真正落到 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,不会自由调用工具、自由读取文件、自由写文件,也不会随意扩大执行范围。

它的入口很明确:

ruby 复制代码
/tasklist + @docs://versions/*.md

没有 HITL 的时候,它更像一条一次性执行流水线:

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

这条链路能跑通,但用户只能在最终结果里看到 Agent 的判断。

如果中间策略方向不对,后面的草稿大概率也会跟着偏掉。

所以 v0.3.0 增加了两个审核点。

第一个是策略审核。

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

用户可以选择:

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

第二个是修订审核。

它不是每次都出现,而是只有系统判断"确实需要立即修订"时才触发:

rust 复制代码
存在需要立即修订的问题
  -> 触发修订审核

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

最终设计可以概括为:

复制代码
策略必审
修订条件审
最多两轮受控修订

这个取舍很重要。

HITL 不是让每一步都停下来问用户。过度暂停会破坏 Agent 的连续性,也会增加用户负担。

v0.3.0 只把人放在关键授权点上,剩下的流程仍然由受控 Graph 继续执行。


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

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

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

更早的迁移阶段里,Tasklist Agent 虽然已经接入 LangGraph,但内部还存在类似这样的过渡结构:

rust 复制代码
GraphState
  -> 旧 AgentState
  -> 领域步骤
  -> 旧 AgentState
  -> GraphState 状态补丁

这在迁移阶段可以降低风险,但到了 HITL / interrupt / resume 这一版,就会变得别扭。

因为恢复执行时必须回答一个问题:

checkpoint 恢复的到底是哪一份状态?

所以 v0.2.4 先把内部运行态收敛到 GraphState。到了 v0.3.0,审核决策、状态补丁、路由判断和 checkpoint resume 才能围绕同一份状态继续往下走。

一句话概括:

先收状态事实源,再做中断恢复。


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

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

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

加入 HITL 后,这条链路会拆成两段。

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

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

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

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

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

rust 复制代码
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 层面的执行语义:

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

也就是说,LangGraph 解决的是:

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

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

比如:

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

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


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

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

首次请求时,后端需要:

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

恢复请求时,后端需要:

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

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

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

否则 HITL 会变成另一个自由指令入口。


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

数据库里有两类状态。

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

复制代码
agent_runs
agent_interrupts

它们关心的是:

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

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

它关心的是:

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

这两类状态不能混在一起。

可以这样理解:

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

所以 v0.3.0 没有把完整 GraphState 存进 AgentRun,也没有试图用 checkpoint 替代业务 run 查询。

PostgresSaver 和 Prisma 共用同一个 PostgreSQL 实例,但职责边界是分开的。


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

前端负责的是交互和展示。

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

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

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

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

这里还有一个重要体验细节:

resume 后不能新建一条助手消息,而应该继续更新原来的助手消息。

因为从用户视角看,这不是一轮新的对话,而是同一轮 Agent 执行:

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

所以后续执行轨迹、产物和最终文本都应该继续合并到原助手消息里。

这样用户看到的是一条完整的 Agent 执行过程,而不是两条断开的回复。


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

这是实现 HITL 时非常关键的一点。

LangGraph interrupt 有一个重要语义:

lua 复制代码
resume 后,会从触发 interrupt 的节点起点重新执行。

这意味着审核节点不能在 interrupt 前做不可重复的副作用。

所以 v0.3.0 对审核节点的边界要求很严格:

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

原因很直接。

如果审核节点在 interrupt 前调用了模型,resume 时重新执行这个节点,就可能重复调用模型。

如果审核节点在 interrupt 前写了数据库,resume 时重新执行,就可能重复写入。

如果审核节点在 interrupt 前发送了产物,resume 时重新执行,就可能重复输出。

所以审核节点最好是一个很纯的节点:

scss 复制代码
首次执行:
  构造 payload
  interrupt(payload)

恢复后重新执行:
  接收审核决策
  校验并转换为 GraphState 状态补丁

可以简单概括为:

审核节点越纯,恢复语义越稳定。


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

做 Agent resume 时,很容易把两个概念混在一起:

arduino 复制代码
Graph checkpoint
业务 run 状态

但它们解决的问题不同。

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

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

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

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

所以 v0.3.0 的分工是:

复制代码
PostgresSaver:
负责 Graph checkpoint。

Prisma:
负责 AgentRun / AgentInterrupt 业务表。

AgentRunService:
负责状态迁移、所有权校验、版本校验、重复恢复防护。

这样分层后,系统会清楚很多。

当 Graph 需要恢复执行时,找 checkpoint。

当后端需要判断用户能不能提交审核时,看 AgentRun / AgentInterrupt。

当前端需要展示待处理审核时,读业务 DTO,而不是直接读 checkpoint。

这也是这版里很重要的架构经验:

checkpoint 是技术恢复状态,AgentRun 是业务可见状态。


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

恢复请求天然有并发风险。

比如:

复制代码
用户连续点击两次
浏览器重试请求
网络抖动导致重复提交
两个恢复请求几乎同时到达

如果没有保护,可能出现:

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

所以恢复阶段至少要保证:

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

可以把它理解成一次"消费待处理审核点"的过程:

ini 复制代码
begin transaction

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

commit

事务提交成功后,才真正恢复 Graph:

复制代码
使用同一个 threadId 继续执行 Graph

在 PostgreSQL 里,这类并发保护可以通过事务 + 行锁实现,也可以通过带状态条件的更新实现。

重点不是具体锁写法,而是要保证一件事:

同一个待处理审核点,只有一个恢复请求能消费成功。

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

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


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

在普通聊天里,一次请求通常对应一条助手消息。

但 HITL 场景不同。

首次请求执行到 interrupt 时,这条助手消息还没有真正完成。它只是进入了 paused 状态。

如果恢复后新建一条助手消息,用户看到的会像这样:

复制代码
第一条 Agent 消息暂停了。
第二条 Agent 消息又重新开始了。

这会破坏 HITL 的语义。

因为真实发生的是:

rust 复制代码
同一轮 Agent 执行
  -> 中间暂停
  -> 用户审核
  -> 继续执行
  -> 输出结果

所以 v0.3.0 要做的是:

复制代码
首次请求创建助手消息
执行到 interrupt 后,这条消息进入 paused
用户恢复后,这条消息进入 resuming
后续执行轨迹、产物、最终文本继续写回这条消息

这样前端呈现出来的是一条完整的 Agent 执行轨迹:

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

HITL 的前端目标不是"再生成一条回复",而是续写同一次 Agent 运行。


10. 这版刻意不做什么

v0.3.0 做了 HITL、checkpoint 和 resume,但它没有把 AI Mind 变成通用 Agent 平台。

这一版不做:

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

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

这一版支持的是同页暂停和恢复,不是完整会话恢复。

如果页面刷新后,只恢复一张审核卡片,但上方没有完整消息、执行轨迹和产物上下文,用户会看到一个错位状态。

真正的刷新恢复还需要:

复制代码
消息持久化
执行轨迹重建
产物重建
待处理审核上下文重建
原助手消息重新绑定

这些不是 v0.3.0 的重点。

所以这一版选择明确收口:

刷新视为放弃当前前端会话,用户需要重新发起 /tasklist

这个边界看起来保守,但它能避免把半成品会话恢复包装成完整能力。


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

做完 v0.3.0 后,我对 HITL 的理解更清楚了。

它不是一个确认按钮,也不是一个前端弹窗。

在 Agent 运行时里,HITL 至少包含这些部分:

arduino 复制代码
Graph interrupt
可序列化的 interrupt payload
结构化审核决策
PostgreSQL checkpoint
AgentRun / AgentInterrupt
会话归属校验
重复恢复防护
同 thread 恢复
原助手消息续写
明确的能力边界

如果只做前端弹窗,Agent 并没有真正暂停。

如果只有 checkpoint,没有 AgentRun,业务系统不知道谁能恢复、能不能恢复。

如果只有 AgentRun,没有 checkpoint,Graph 无法从中断处继续。

如果没有事务和防重入,同一个审核决策可能被消费两次。

如果恢复后新建消息,用户看到的执行轨迹会断开。

所以我觉得 v0.3.0 的核心价值不是"给 Tasklist Agent 加了审核按钮",而是:

把"人可以介入 Agent 执行"这件事,变成一套可校验、可暂停、可恢复、可防重入的执行协议。

这也是受控 Agent 继续往前演进时很关键的一步。


项目地址

👉 GitHub:github.com/HWYD/ai-min...

👉 线上体验:ai.hwyblog.cloud/instant-min...

如果这篇文章或者 AI Mind 项目对你有所帮助,也欢迎顺手帮项目点个 Star⭐。这个支持对我来说很重要,也会让我更有动力继续整理后续版本的实现过程、设计取舍和踩坑复盘。

相关推荐
九酒8 小时前
AI Agent 开发踩坑记:口播功能非得用 APP 原生实现吗?
前端·人工智能·agent
Jackson__9 小时前
做了一段时间的AI coding后,我终于搞清了 CLI 和 MCP 的区别
前端·agent·ai编程
小孔菜菜15 小时前
LLM / Agent / Skills / MCP 协同关系深度解析
agent
JouYY16 小时前
聊一下多 Agent 编排架构的应用实践
架构·llm·agent
米小虾18 小时前
Loop Engineering —— 循环的设计与自主执行
人工智能·agent
米小虾18 小时前
Harness Engineering —— 系统的安全护栏
人工智能·agent
武子康20 小时前
调查研究-200 llama.cpp b9754:一次很小但很关键的 Agent 工具调用修复
人工智能·agent·llama
武子康20 小时前
调查研究-199 MCP Zero-Touch OAuth:为什么它是 MCP 进入企业生产的关键门槛?
人工智能·agent·mcp
用户9478505292721 小时前
Skill用得好,下班走得早:一文讲透Skill的结构与设计
agent