Human-in-the-Loop 与 Checkpoint 恢复机制
2025 年 3 月,某出行平台的 Agent 在一次凌晨故障排查中,把一名商务旅客的北京→上海机票自动改签成了深圳→上海。
改签指令是 Agent 自己生成的:它判断"北京航班延误且旅客有深圳出行记录",于是直接调用了 rebookFlight 工具。没有人批准,没有人确认,逻辑上完全正确,商业上彻底错误。
系列目标 :从零构建机票客服型 Agent「票小蜜」 本篇位置 :第 12 章 / 第二阶段 · 高级运行时控制 前置知识:第 10 章《ReactAgent 运行时》、第 11 章《Context Engineering》
一、推理正确 ≠ 行动安全
Agent 的推理能力是概率性的,工具调用是确定性的。这两者结合,产生了一个反直觉的风险:一个推理能力越强的 Agent,执行错误决策的破坏力越大。
一个 95% 正确率的 Agent,执行 100 次改签操作,会有约 5 次"推理正确,结论错"的改签落地。问题不在于 AI 判断失误,而在于整条链路里没有任何人能拦截它。Agent 查了用户历史、查了延误记录、生成了改签参数------每一步都"对"------没有任何一个节点报错,最终那次错误的改签就这样执行完了。
这是级联效应的典型案例:每一步局部正确,整体走偏,而且无处回头。
Human-in-the-Loop(HITL)的核心价值不是"不相信 AI",而是把不可逆操作的确认权归还给有完整判断上下文的人。客服人员在批准改签时,能看到旅客的实时状态、能判断航班延误的严重程度、能识别 Agent 推理的边界条件------Agent 做不到这一点,不是因为它笨,而是因为它没有这些"人类语境"。
哪些操作需要 HITL
不是所有工具都需要审批------审批太多,体验直接崩溃。判断标准只有两个:执行后用户会立刻感知 ,且AI 有可能判断错。
| 操作类型 | 示例 | 是否需要 HITL | 理由 |
|---|---|---|---|
| 写操作(不可逆) | 订票、改签、退款 | 必须 | 一旦执行无法撤销,成本极高 |
| 写操作(可逆) | 添加备注、暂存草稿 | 建议 | 出错影响面可控,可撤回 |
| 读操作 | 查航班、查政策 | 不需要 | 只读,零副作用 |
| 系统操作 | 发邮件、发短信 | 建议 | 用户可见,难以撤销 |

工程判断:宁可漏掉几个"建议加审批"的工具,也不要对读操作加审批。Agent 每查一次航班都要等人批准,用户会直接放弃。HITL 是高价值操作的安全阀,不是每个工具调用的护栏。
二、HITL 是怎么工作的
理解 HITL 机制,先要知道它挂在哪里:HumanInTheLoopHook 挂在 AFTER_MODEL 位置------LLM 已经生成了工具调用请求,但工具还没有真正执行。
这个位置的选择很有意思。此时 LLM 已经做完了推理("应该帮用户订 MU5678"),把意图以 AssistantMessage 的形式写进了 messages。HITL 就在这个瞬间介入,把"即将执行"变成"待批准"。
中断发生时的行为:
框架检测到工具名在 approvalOn 列表里,暂停整个图的执行,把当前 OverAllState 打快照存进 Checkpoint,然后把"待审批的工具调用信息"包装成 InterruptionMetadata 返回给调用者。整个过程对 Agent 运行时是透明的------图不知道自己被暂停了,只是还没有继续。
审批者拿到的是什么:
InterruptionMetadata 里有每个待审批工具的名称、入参(JSON 格式)、以及配置里写的人工提示语。这三样东西,是审批界面展示给客服人员的所有信息:
json
工具:bookFlight
入参:{"passengerName":"张三","flightNo":"MU5678","cabinClass":"economy","phone":"13800000001"}
提示:【订票确认】操作不可逆,请核验:乘客姓名是否正确、航班号是否匹配用户意图、舱位是否符合要求
三种审批决策:
| 决策 | FeedbackResult |
Agent 收到后的行为 |
|---|---|---|
| 批准 | APPROVED |
工具原样执行,继续推理 |
| 修改后执行 | EDITED |
用审批者修改后的参数替换原始参数,执行工具 |
| 拒绝 | REJECTED |
不执行工具,把拒绝原因作为 ToolResponseMessage 注入,LLM 重新推理 |
REJECTED 的行为值得单独解释:Agent 不会终止,它会重新推理------拒绝理由相当于给 LLM 的一条新指令,"刚才那个决策不对,原因是 X,请重新考虑"。这让 HITL 不只是一道门,而是一个反馈回路。 
工程判断 :
EDITED是三种决策里最难做好的。直接让客服修改 JSON 参数字符串,体验极差。生产中应该把bookFlight的参数字段渲染成表单,让客服在表单里改值,提交时再序列化成 JSON 传给 Agent。审批界面的质量直接决定了 HITL 是否被正确使用。
三、Checkpoint:为什么 Agent 能暂停再恢复
HITL 的暂停-恢复能力依赖的不是 Hook 本身,而是 Checkpoint。
每次图执行到节点时,CheckpointSaver 把当前 OverAllState 序列化存储,以 threadId 为键。HITL 中断发生时,当前状态就冻结在了这个快照里。下次用同一 threadId 调用时,图从最后一个快照读取状态,恢复执行------就像什么都没发生过。
用时序图来看:
scss
第一次 invokeAndGetOutput("帮我订票...")
→ N0(输入节点):Checkpoint[0] 落地
→ N1(LLM 节点):生成 bookFlight 调用
→ HumanInTheLoopHook 检测到,中断,Checkpoint[2] 落地 ← 冻结在这里
← 返回 InterruptionMetadata 给调用者
... 人工审批中(可能等待数小时)...
第二次 invokeAndGetOutput("")(携带审批结果)
→ 从 Checkpoint[2] 恢复,OverAllState 完整还原
→ HumanInTheLoopHook.afterModel() 处理 APPROVED
→ N2(工具节点):真正执行 bookFlight
→ N3(LLM 节点):根据订票结果生成最终回复
← 返回完整的 NodeOutput
threadId 是这套机制的"身份证"。审批前的请求和审批后的请求必须用同一个 threadId,框架才能找到对应的 Checkpoint。如果 threadId 变了,会从空白状态开始重新执行,之前的订票意图全部丢失。
如果没有 Checkpoint,能怎么做 HITL? 只有两条路:一是把完整的 OverAllState(包括所有历史消息、工具调用记录)序列化进 HTTP session,审批时再反序列化------状态一大,session 直接超限;二是把状态存进数据库,恢复时手动重建------一旦服务多实例部署,session 路由到不同机器,状态就找不到了。Checkpoint 解决的核心问题是:让状态的生命周期独立于 HTTP 请求和服务实例。

MemorySaver 是 Checkpoint 的最简单实现,把状态存在 JVM 堆里。服务重启后,所有中断中的审批请求消失。生产环境必须替换为 Redis 或数据库实现(第 13 章详细讲这个替换过程)。
工程判断 :审批窗口可以是 5 分钟,也可以是 24 小时------只要 Checkpoint 的 TTL 比审批窗口长,状态就在。不要在代码里用
sleep轮询审批结果,那是在 Tomcat 线程上等待,既浪费资源,也无法跨服务重启。
四、为票小蜜加上订票能力
票小蜜到目前为止只有只读工具(查航班、查政策)。本章加入两个写操作工具:bookFlight 和 rebookFlight。
4.1 定义工具
工具的入参描述(@JsonPropertyDescription)会直接出现在审批界面里,是客服人员做审批决策的主要依据。字段描述要写给人看,不是给 LLM 看:
java
// FlightTools.java 新增
public record BookingRequest(
@JsonPropertyDescription("乘客姓名,需与身份证完全一致")
String passengerName,
@JsonPropertyDescription("航班号,如 MU5678")
String flightNo,
@JsonPropertyDescription("舱位等级:economy(经济舱)/ business(商务舱)/ first(头等舱)")
String cabinClass,
@JsonPropertyDescription("乘客联系手机号,用于值机提醒")
String phone
) {}
public record RebookingRequest(
@JsonPropertyDescription("原订单号,格式 ORD-xxxxxx")
String originalOrderId,
@JsonPropertyDescription("新航班号")
String newFlightNo,
@JsonPropertyDescription("乘客姓名,用于核验身份")
String passengerName,
@JsonPropertyDescription("改签原因,例如:航班延误、行程变更")
String reason
) {}
@Bean
@Description("为乘客购买机票。操作不可逆,需要人工审批后执行。")
public Function<BookingRequest, String> bookFlight() {
return request -> {
// 生产环境替换为真实 GDS/OTA 系统调用
String orderId = "ORD-" + System.currentTimeMillis();
return "订票成功!订单号:%s,乘客:%s,航班:%s %s 舱"
.formatted(orderId, request.passengerName(), request.flightNo(), request.cabinClass());
};
}
@Bean
@Description("为乘客改签机票。会产生手续费,操作不可逆,需要人工审批后执行。")
public Function<RebookingRequest, String> rebookFlight() {
return request -> "改签成功!原订单:%s → 新航班:%s,手续费:¥200"
.formatted(request.originalOrderId(), request.newFlightNo());
}
工具描述里写"需要人工审批后执行",是给 LLM 的提示,让它在调用这个工具前先确认参数完整------一旦触发 HITL 等待审批,用户体验上有感知延迟,参数残缺导致的"审批后执行失败"代价更高。
4.2 注册 HumanInTheLoopHook
在 AgentConfig 里把 Hook 加进去,并配置哪些工具需要审批:
java
// AgentConfig.java --- ticketAgent Bean 内
HumanInTheLoopHook hitlHook = HumanInTheLoopHook.builder()
.approvalOn("bookFlight",
"【订票确认】操作不可逆,请核验:乘客姓名是否正确、航班号是否匹配用户意图、舱位是否符合要求")
.approvalOn("rebookFlight",
"【改签确认】将产生 ¥200 手续费,请确认:旅客已知晓费用、新航班符合需求、改签原因合理")
.build();
// HumanInTheLoopHook 必须放在 hooks 列表末尾
// 原因:限流和摘要处理在 BEFORE_MODEL 阶段执行,审批判断在 AFTER_MODEL 执行
// 如果顺序颠倒,limitHook 的中止逻辑可能先于 HITL 审批触发
.hooks(List.of(contextEnrichmentHook, loadUserProfileHook, userTierLimitHook,
limitHook, summarizationHook, metricsHook, savePreferenceHook, hitlHook))
approvalOn 的第二个参数是给审批者看的提示语,它会出现在 InterruptionMetadata.ToolFeedback.description 里。写清楚"核验什么",审批速度会快一倍------客服不需要猜测自己应该关注哪里。
五、两次调用,一次完整闭环
HITL 的 API 设计是"两次调用完成一个操作"。第一次触发中断,第二次携带审批结果恢复执行。
5.1 第一次调用:触发中断
关键点在于如何检测 HITL 触发,以及把哪些信息返回给前端的审批界面:
java
// Controller 层:发起订票请求
RunnableConfig config = RunnableConfig.builder()
.threadId(chatRequest.getThreadId()) // threadId 是状态恢复的唯一标识
.addMetadata("userTier", chatRequest.getUserTier())
.store(memoryStore)
.build();
Optional<NodeOutput> result = ticketAgent.invokeAndGetOutput(
"帮我订一张明天北京到上海的经济舱,乘客张三,手机 13800000001",
config);
// HITL 触发时,result.get() instanceof InterruptionMetadata 为 true
if (result.isPresent() && result.get() instanceof InterruptionMetadata meta) {
// 把 meta 序列化后返回给前端,前端展示给客服做审批
return ResponseEntity.ok(Map.of(
"type", "approval_required",
"threadId", chatRequest.getThreadId(),
"interruption", serializeInterruption(meta)
));
}
客服界面收到的 JSON 长这样:
json
{
"type": "approval_required",
"threadId": "sess-001",
"interruption": {
"node": "HITL",
"toolFeedbacks": [{
"id": "call_abc123",
"name": "bookFlight",
"description": "【订票确认】操作不可逆,请核验...",
"arguments": "{\"passengerName\":\"张三\",\"flightNo\":\"MU5678\",\"cabinClass\":\"economy\",\"phone\":\"13800000001\"}"
}]
}
}
5.2 第二次调用:携带审批结果恢复
客服做出决策后,把审批结果 POST 到 /api/ticket/approve:
java
// Controller 层:处理审批回调
@PostMapping("/approve")
public ResponseEntity<Map<String, Object>> approve(@RequestBody ApprovalRequest request)
throws GraphRunnerException {
// 从请求体重建 InterruptionMetadata
InterruptionMetadata.Builder feedbackBuilder = InterruptionMetadata.builder()
.nodeId(request.getInterruptionMetadata().getNode()) // ① 必须:指定中断节点
.state(new OverAllState(request.getInterruptionMetadata().getState())); // ② 必须:携带状态快照
request.getToolFeedbacks().stream()
.map(ApprovalRequest.ToolFeedbackPayload::toToolFeedback)
.forEach(feedbackBuilder::addToolFeedback);
// addHumanFeedback():不是 addMetadata(),两者路由逻辑不同
RunnableConfig resumeConfig = RunnableConfig.builder()
.threadId(request.getThreadId()) // ③ 必须:与第一次调用相同
.addHumanFeedback(feedbackBuilder.build())
.build();
// 空消息:状态已由 Checkpoint 恢复,不需要重新输入
Optional<NodeOutput> result = ticketAgent.invokeAndGetOutput("", resumeConfig);
// 审批后可能还有下一个 HITL(例如改签后还要确认退款)
if (result.isPresent() && result.get() instanceof InterruptionMetadata nextMeta) {
return ResponseEntity.ok(Map.of("type", "approval_required", ...));
}
return ResponseEntity.ok(Map.of("type", "message", "content", extractAssistantText(result)));
}
三个"必须"的解释:
① nodeId:框架用它定位 Checkpoint 里的中断点。错了或者漏了,恢复位置不对,Agent 可能从头重新执行。
② state:中断时的 OverAllState 快照。这里的 state 来自客户端发回来的 interruption.state,而不是重新从 OverAllState 里读------Checkpoint 的状态和这个 state 必须一致,否则恢复后的上下文会错位。
③ threadId 一致:这是框架找到对应 Checkpoint 的钥匙。threadId 变了,框架找不到快照,会重新开始一次全新的 Agent 执行。
5.3 端到端验证(curl)
bash
# 第一步:发起订票
curl -X POST http://localhost:8089/api/ticket/chat \
-H "Content-Type: application/json" \
-d '{"message":"帮我订明天北京到上海的经济舱,乘客张三,手机13800000001",
"threadId":"sess-001","userTier":"vip"}'
# → 返回 approval_required,客服看到审批请求
# 第二步:客服批准
curl -X POST http://localhost:8089/api/ticket/approve \
-H "Content-Type: application/json" \
-d '{"threadId":"sess-001",
"interruptionMetadata":{"node":"HITL","state":{...}},
"toolFeedbacks":[{"id":"call_abc123","name":"bookFlight","result":"APPROVED"}]}'
# → 返回 {"type":"message","content":"订票成功!订单号 ORD-1748765432,张三,MU5678 经济舱。"}
# 第二步(拒绝版本):客服发现舱位要求有误
# "result":"REJECTED","description":"乘客需要商务舱,请重新查询"
# → Agent 收到拒绝原因,重新推理,下一轮推荐商务舱选项
六、生产注意:超时与安全
6.1 审批超时的自动处理
HumanInTheLoopHook 本身不处理超时。如果客服 2 小时内没有做出审批,Checkpoint 里的状态会一直等待------不会自动拒绝,也不会通知用户。
业务层需要一个定时任务扫描超期审批:
java
@Scheduled(fixedDelay = 300_000) // 每 5 分钟扫描一次
public void expireStaleApprovals() {
List<ApprovalRecord> expired = approvalRepo
.findByStatusAndExpiresAtBefore(ApprovalStatus.PENDING, Instant.now());
for (ApprovalRecord record : expired) {
// 构建超时拒绝的 InterruptionMetadata,把拒绝理由注入 Agent
InterruptionMetadata rejection = buildRejectionMetadata(
record, "审批窗口已超时,操作已自动取消,请重新发起");
RunnableConfig config = RunnableConfig.builder()
.threadId(record.getThreadId())
.addHumanFeedback(rejection)
.build();
ticketAgent.invokeAndGetOutput("", config);
record.setStatus(ApprovalStatus.EXPIRED);
approvalRepo.save(record);
// 通知用户:"您的订票审批已超时,请重新提交"
}
}
工程判断:审批窗口不要一刀切。普通订票 24 小时,大额退款 72 小时,VIP 用户加速处理窗口缩短到 4 小时。按业务优先级分层,而不是所有操作都用同一个 TTL。
6.2 防伪造:/approve 接口的三层校验
/approve 接口接收的 state 里含有工具调用参数,生产环境必须防止以下攻击场景:
① 越权审批 :客服 A 提交了客服 B 处理的审批请求。解决方案:审批记录里存 assignedStaffId,/approve 接口验证当前登录用户与审批记录匹配。
② 参数篡改 :中间人修改了 toolFeedbacks.arguments 的值(把经济舱改成头等舱,把手机号改成自己的)。解决方案:HITL 触发时,把 arguments 的 SHA-256 哈希存进审批记录;/approve 收到时重新计算哈希,不一致则拒绝。
③ 权限不足 :审批 bookFlight 需要 BOOKING_APPROVER 角色,审批 rebookFlight 需要 REBOOK_APPROVER。解决方案:在 approvalOn 配置里绑定所需角色,Controller 里校验。
这三层校验加起来不超过 30 行代码,但能把 /approve 接口从"任何人发请求就执行"变成"经过身份验证的有权限的人才能执行"。
七、架构演进:第 12 章新增能力

| 变化点 | 内容 | 意义 |
|---|---|---|
| 新增工具 | bookFlight、rebookFlight |
Agent 具备真实写操作能力 |
| 新增 Hook | HumanInTheLoopHook(AFTER_MODEL) |
高风险工具落地前的人工审批门 |
| 新增接口 | POST /api/ticket/approve |
接收审批结果、恢复 Agent 执行 |
| API 变化 | invokeAndGetOutput() 替代 call() |
返回 Optional,通过 instanceof 判断中断 |
工程判断:HITL 不是一个"加上去就安全了"的功能,它是一个需要配套建设的系统------审批界面、超时策略、权限校验、Checkpoint 存储缺一不可。只加 Hook 不建这些,等于给门装了锁但没有钥匙管理制度。
票小蜜到第 12 章的能力演进:
bash
第 09 章 ReactAgent 基础运行时(能推理、能调工具)
第 10 章 State / Hook / Interceptor(能控制执行过程)
第 11 章 Context Engineering(每轮上下文精准注入)
第 12 章 ← 现在
写操作工具(bookFlight / rebookFlight)
HumanInTheLoopHook(高风险操作的审批门)
两段式 API(/chat 触发,/approve 恢复)
下一章解决一个遗留问题:MemorySaver 把 Checkpoint 存在 JVM 堆里,服务一重启,所有等待审批的请求消失。
八、踩坑记录
坑 1:用 call() 而不是 invokeAndGetOutput()
call() 在 HITL 触发时行为未定义------框架通过 invokeAndGetOutput() 的返回值传达中断,不是通过异常。用 call() 遇到 HITL 中断,要么拿到空结果,要么行为不可预期。统一用 invokeAndGetOutput(),通过 instanceof InterruptionMetadata 检测中断。
坑 2:构建审批结果时漏传 .nodeId() 或 .state()
这两个字段是框架定位恢复点的依据。漏任意一个,Agent 无法从中断处继续,会从头重新执行------用户看到 Agent"不记得刚才说要订什么票了"。直接从客户端回传的 interruptionMetadata 里读这两个字段,不要自己构造。
坑 3:用 addMetadata(HUMAN_FEEDBACK_METADATA_KEY, ...) 而不是 addHumanFeedback()
在纯 ReactAgent 场景下两者效果可能等价,但在 Graph 嵌套场景下(第 16 章的 Agent-as-Node),只有 addHumanFeedback() 能被正确路由到对应的 Agent 节点。统一用 addHumanFeedback(),不留隐患。
坑 4:SummarizationHook 把审批前的上下文压缩掉
触发 HITL 后,客服等了 20 分钟才审批。恢复时,SummarizationHook 在第一轮 BEFORE_MODEL 阶段把包含订票信息的历史消息摘要压缩了,Agent 重新推理时已经"忘了"当时要订什么票,开始重新追问用户。
修复:把 messagesToKeep 调大(默认 20 改到 30),确保审批窗口内的近期上下文不被裁剪。或者在 HITL 恢复时检测 turnCount,抑制摘要触发。
评论区聊聊
选型问题 :HITL 的三种决策(APPROVED / EDITED / REJECTED)里,EDITED 让客服直接修改工具参数。你在项目里怎么做审批界面的?是展示原始 JSON 让客服改,还是渲染成表单?哪种方式客服用起来错误率更低?
踩坑问题:审批超时自动拒绝这个逻辑,你们生产里踩过什么坑?我遇到过一个问题:定时任务扫到了已经被客服手动处理的记录(状态没及时更新),导致"二次拒绝"------Agent 给用户发了两条"审批超时"消息。你们怎么防这种竞态条件的?
前瞻问题:当一次对话需要连续审批两个操作(先确认改签,再确认退差价款),HITL 会触发两次中断。用户在第一次审批后还要等第二次,体验很差。你会怎么设计:是把两个审批合并成一次展示给客服,还是接受两次中断但优化等待时长?
代码仓库:spring-ai-alibaba-course(ticket-agent 模块,第 12 章代码)
系列目录 · 上一篇:第 11 章《Context Engineering:让 Agent 每一轮拿到正确上下文》
如果这篇文章对你有帮助,欢迎点赞收藏。