Solon v3.9.0

react - 示例3 - 人工介入(HITL)

</> markdown
2026年1月24日 下午9:41:08

在涉及资金退款、敏感数据修改或关键业务决策时,全自动的 AI Agent 存在安全风险。人工介入(Human-In-The-Loop, HITL) 是确保 AI 在可控范围内运行的关键防御机制。

1、业务场景与原理

场景:

客户要求退款。Agent 解析需求后准备调用 do_refund 工具。由于退款属于敏感操作,系统会自动触发拦截器中断流程,等待人工审核。只有审核通过,Agent 才会继续执行后续的支付接口调用。

技术核心:

  • 状态挂起:通过 FlowContext.stop() 暂停执行流,但保留当前的会话状态(Thought 和 Action 参数)。
  • 上下文恢复:利用 AgentSession 持久化执行轨迹,人工注入批准信号后,Agent 能从“断点”处无缝继续。

拦截器控制时机说明:

在 ReActInterceptor 中,我们通常有三个关键控制点:

拦截点触发时机适用场景
onAction模型解析出工具参数,但尚未进入流程图节点参数合法性预校验、权限检查。
onNodeStart进入 ID_ACTION 节点开始时细粒度的执行前拦截。
onNodeEnd节点逻辑执行完成时推荐点。用于检查审批信号,决定是否允许流程走向下一步。

此方案分析:

  • 无损恢复:通过 AgentSession 持久化,你可以将审批流程拉长到数小时甚至数天(如持久化到 Redis),用户刷新页面后 Agent 依然能记得此前的推理进度。
  • 安全闭环:Action 的具体参数(如退款订单号、金额)在拦截那一刻已经由模型生成并锁存在上下文中,人工审核的是确定的行为,防止了恢复后的逻辑偏移。
  • 开发透明:业务工具类(RefundTools)不需要写任何关于审批的逻辑,实现了业务与安全控制的完美解耦。

2、示例代码

通过实现 ReActInterceptor 的生命周期方法,我们可以在 Agent 执行动作(Action)前进行拦截。

import demo.ai.llm.LlmUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.noear.solon.ai.agent.AgentSession;
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.react.ReActInterceptor;
import org.noear.solon.ai.agent.react.ReActResponse;
import org.noear.solon.ai.agent.react.ReActTrace;
import org.noear.solon.ai.agent.session.InMemoryAgentSession;
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.chat.prompt.Prompt;
import org.noear.solon.ai.chat.tool.MethodToolProvider;
import org.noear.solon.annotation.Param;
import org.noear.solon.flow.FlowContext;
import org.noear.solon.flow.FlowException;
import org.noear.solon.flow.Node;
import org.noear.solon.flow.intercept.FlowInvocation;

import java.util.Map;

/**
 * ReActAgent 人工介入(HITL)场景测试
 *
 * <p>测试场景:Agent 申请退款时,拦截器检测到敏感操作节点,执行中断等待人工审批。
 * 人工审核通过后,恢复执行完成退款流程。</p>
 */
public class ReActAgentHitlTest {

    /**
     * 测试人工介入审批流程
     *
     * <p>验证流程:</p>
     * <ol>
     * <li>发起退款请求 -> 触发拦截器中断</li>
     * <li>人工介入审批 -> 注入批准信号</li>
     * <li>恢复执行 -> 完成退款操作</li>
     * </ol>
     *
     * @throws Throwable 如果执行过程中出现异常
     */
    @Test
    public void testHumanInTheLoop() throws Throwable {
        // 获取聊天模型
        ChatModel chatModel = LlmUtil.getChatModel();

        // 1. 定义人工介入拦截器 - 检测敏感操作节点
        ReActInterceptor hitlInterceptor = new ReActInterceptor() {
            @Override
            public void onNodeEnd(FlowContext ctx, Node node) {
                // 当进入工具执行节点时,检查是否已获得人工批准
                if (ReActAgent.ID_ACTION.equals(node.getId())) {
                    Boolean approved = ctx.getAs("is_approved");
                    if (approved == null) {
                        System.out.println("[拦截器] 检测到敏感工具调用,等待人工审批...");
                        ctx.stop(); // 中断流程,等待人工介入
                    }
                }
            }
        };

        // 2. 构建 ReActAgent 并配置拦截器
        ReActAgent agent = ReActAgent.of(chatModel)
                .defaultToolAdd(new RefundTools())
                .modelOptions(o -> o.temperature(0.0))
                .build();

        // 3. 创建 AgentSession(包装 FlowContext)
        AgentSession session = InMemoryAgentSession.of("hitl_session_123");
        String prompt = "订单 ORD_888 没收到货,请帮我全额退款。";

        // --- 第一步:发起请求,预期会被拦截 ---
        System.out.println("--- 第一次调用 (预期拦截) ---");
        ReActResponse resp = agent.prompt(prompt)
                .options(o -> o.interceptorAdd(hitlInterceptor)) //添加拦截器
                .session(session)
                .call();

        System.out.println(resp.getMetrics());
        Assertions.assertTrue(resp.getMetrics().getTotalTokens() > 0);

        String result1 = resp.getContent();

        // 通过 session.getSnapshot() 获取底层的 FlowContext 进行验证
        FlowContext context = session.getSnapshot();

        // 验证:流程应该被拦截并停止,最后停留在工具节点
        Assertions.assertTrue(context.isStopped(), "流程应该被拦截并停止");
        Assertions.assertEquals(ReActAgent.ID_ACTION, context.lastNodeId(), "最后应停留在工具节点");

        // 获取执行状态追踪,验证已有执行步骤
        ReActTrace state = context.getAs("__" + agent.name());
        Assertions.assertTrue(state.getStepCount() > 0, "应该已有执行步骤");
        System.out.println("当前状态:" + state.getRoute());

        // --- 第二步:人工介入,注入批准信号 ---
        System.out.println("\n--- 人工介入:批准退款 ---");
        context.put("is_approved", true);

        // --- 第三步:恢复执行 ---
        System.out.println("--- 第二次调用 (恢复执行) ---");
        // 恢复时传入相同的 session,prompt 会从 state 中自动获取
        String result2 = agent.prompt(prompt)
                .options(o -> o.interceptorAdd(hitlInterceptor)) //添加拦截器
                .session(session)
                .call()
                .getContent();

        // 验证:最终结果应包含退款成功的关键字
        Assertions.assertNotNull(result2, "结果不应为空");
        Assertions.assertTrue(result2.contains("成功") || result2.contains("退款"),
                "审批后应执行成功,实际结果:" + result2);
        System.out.println("最终答复: " + result2);

        // 验证流程已正常结束
        Assertions.assertFalse(context.isStopped(), "恢复后流程应正常结束");
    }

    /**
     * 退款工具类
     *
     * <p>提供退款相关功能工具,用于测试敏感操作拦截场景</p>
     */
    public static class RefundTools {
        /**
         * 执行退款操作
         *
         * @param orderId 订单号
         * @return 退款结果信息
         */
        @ToolMapping(description = "执行退款操作")
        public String do_refund(@Param(description = "订单号") String orderId) {
            return "订单 " + orderId + " 已退款成功,金额将原路返回。";
        }
    }
}