react - 示例3 - 人工介入(HITL)
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 + " 已退款成功,金额将原路返回。";
}
}
}