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 每一轮拿到正确上下文》

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


相关推荐
whinc12 小时前
Rust技术周刊 2026年第17周
后端·rust
whinc12 小时前
Rust技术周刊 2026年第18周
后端·rust
xqqxqxxq13 小时前
Java AI智能P图工具技术笔记
java·人工智能·笔记
whinc13 小时前
Rust技术周刊 2026年第16周
后端·rust
谷雨不太卷13 小时前
进程的状态码
java·前端·算法
jieyucx13 小时前
Go语言深度解剖:Map扩容机制全解析(增量扩容+等量扩容+渐进式迁移)
开发语言·后端·golang·map·扩容策略
顾温13 小时前
default——C#/C++
java·c++·c#
空中海13 小时前
02 ArkTS 语言与工程规范
java·前端·spring
楚国的小隐士13 小时前
在AI时代,如何从0接手一个项目?
java·ai·大模型·编程·ai编程·自闭症·自闭症谱系障碍·神经多样性
yaki_ya13 小时前
yaki-C语言:从概念基础到内存解析---数组(array)完全指南
java·c语言·算法