Agent 自动把机票改错了,推理完全正确——这才是真正的风险

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 线程上等待,既浪费资源,也无法跨服务重启。


四、为票小蜜加上订票能力

票小蜜到目前为止只有只读工具(查航班、查政策)。本章加入两个写操作工具:bookFlightrebookFlight

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 章新增能力

变化点 内容 意义
新增工具 bookFlightrebookFlight 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 每一轮拿到正确上下文》

如果这篇文章对你有帮助,欢迎点赞收藏。


相关推荐
itjinyin2 小时前
ShardingSphere-jdbc 5.5.0 + spring boot 基础配置 - 实战篇
java·spring boot·后端
丶小鱼丶2 小时前
Java虚拟机【JVM】
java·jvm
csdn2015_2 小时前
IDEA配置Continue
java·ide·intellij-idea
Victor3562 小时前
MongoDB(91)如何在MongoDB中使用TTL索引?
后端
老王以为2 小时前
前端重生之 - 前端视角下的 Python
前端·后端·python
Victor3562 小时前
MongoDB(92)什么是变更流(Change Streams)?
后端
云边有个稻草人2 小时前
Docker部署KingbaseES数据库操作指南
后端
NineData3 小时前
NineData亮相香港国际创科展InnoEX 2026,以AI加速布局全球市场
运维·后端
aq55356003 小时前
C语言、C++和C#:三大编程语言核心差异详解
java·开发语言·jvm