十四、Spring AI Alibaba · 案例 · 基于 HumanInTheLoop 的内容审核系统

一、要解决的问题

生产审核系统通常是 机审 + 人审两级:

  1. 机审 :敏感词库(DFA/Trie/AC 自动机)+ 规则 + 模型分类,输出三态 通过 / 拒绝 / 待人工;
  2. 人审 :不确定的进人工队列,运营在页面里 通过 / 拒绝 / 修改后通过;
  3. 可追溯:谁、何时、改了什么,必须落库(合规刚需);
  4. 开关:高危场景强制人工,低危场景全自动。

本案例把第 2 步做成标准 HITL:Agent 要调用"发布"这个高危工具 时,被 HumanInTheLoopHook 拦下中断,人的决策作为 ToolFeedback 回传,Agent 从中断点继续跑完。


二、目录结构

复制代码
SAA-Demo2-Audit/
├── pom.xml                                     # 继承根工程,复用 spring-ai-alibaba
└── src/main/
    ├── java/com/ai/audit/
    │   ├── AuditApplication.java               # 启动类(端口 8081)
    │   ├── domain/AuditItem.java               # 审核条目 + 7 种状态
    │   ├── tool/ContentTools.java              # Agent 工具(检测 / 发布)
    │   ├── prompt/AuditPrompts.java            # 提示词(人工模式 / 自动模式)
    │   ├── agent/AuditAgentFactory.java        # Agent 工厂:两种模式各建一个 Agent
    │   ├── service/AuditService.java           # 核心:发起审核 + 中断存档 + 恢复执行
    │   └── controller/AuditController.java     # REST 接口
    └── resources/
        ├── application.yml                     # 端口 + DashScope api-key
        └── static/index.html                   # 列表 + 弹窗 + 模式开关

三、HITL 机制:一次审核 = 两次调用

复制代码
                 ┌───────────── 第 1 次调用 ─────────────┐
前端点「审核」
      │
      ▼
AuditService.start(id, human=true)
      │  agent.invokeAndGetOutput(prompt, threadId="audit-1")
      ▼
Agent 思考 → 调用 checkBannedWords ✅(普通工具,直接执行)
      │  → 准备调用 publishContent
      ▼
HumanInTheLoopHook 拦下 → 返回 InterruptionMetadata(中断)
      │
      │  sessions.put(id, Session{agent, threadId, interruption})   ← 存档
      ▼
HTTP 200 返回 WAITING_HUMAN + 待审批工具名 + 入参 JSON
      │
      ▼  前端弹出人工审核弹窗(人在这一步看内容、改内容、点按钮)
      │
      │  ←------------------------ 中间可能过了几秒 / 几分钟,HTTP 请求早已结束 ------------------------→
      │
                 ┌───────────── 第 2 次调用 ─────────────┐
前端点「通过 / 拒绝 / 修改并通过」
      │  POST /api/audit/{id}/human  {decision, content}
      ▼
AuditService.decide(...)
      │  取回 Session(同一个 agent 实例 + 同一个 threadId)
      │  用 pending 构造 ToolFeedback(APPROVED / REJECTED / EDITED)
      │  InterruptionMetadata.builder(node, state).toolFeedbacks(...)
      ▼
agent.invokeAndGetOutput("", config.addHumanFeedback(humanFeedback))
      │
      ▼  从检查点继续执行:APPROVED 就真执行 publishContent;REJECTED 跳过;EDITED 用改后的入参执行
      │
      ▼
状态落到 APPROVED / REJECTED / EDITED,sessions.remove(id)

恢复执行的三个必要条件(缺一不可)

条件 说明 本案例做法
同一个 Agent 实例 检查点存在 Agent 内部的 MemorySaver 里,新建实例就丢了 Session 里保存第一次的 agent
同一个 threadId 检查点按 threadId 索引 threadId = "audit-" + id,两次都用
带上中断现场 回传的 InterruptionMetadata 必须原样带 node + state InterruptionMetadata.builder(interruption.node(), interruption.state())

四、两种模式对比

人工审核模式(human=true) 自动审核模式(human=false)
Hook 挂 HumanInTheLoopHook 不挂
提示词 AuditPrompts.HUMAN(必须调 publishContent) AuditPrompts.AUTO(命中违禁词就别调)
是否中断 命中 publishContent 就中断 不中断,一口气跑完
终态 WAITING_HUMAN → APPROVED/REJECTED/EDITED AUTO_PASSED / AUTO_REJECTED
人的角色 决定放不放行 不介入

五、状态机

复制代码
                    ┌─ 人工模式:命中 publishContent 被 Hook 中断 ─┐
                    │                                              ↓
  PENDING ──────────┤                                      WAITING_HUMAN
                    │                                              ↓ 人提交决策
                    │              ┌──────────────────┬────────────┴────────────┐
                    │              ↓                  ↓                         ↓
                    │          APPROVED            REJECTED                   EDITED
                    │        (人工通过)         (人工拒绝)            (修改后通过)
                    │
                    └─ 自动模式:不挂 Hook,Agent 自己跑完 ──┬──> AUTO_PASSED(机审通过)
                                                            └──> AUTO_REJECTED(机审拒绝)
状态 含义
PENDING 待审核,还没跑过 Agent(列表显示"审核"按钮)
WAITING_HUMAN Agent 已中断,正卡着等人决策(列表显示"去审批"按钮)
AUTO_PASSED 自动模式下 Agent 自己跑完并发布成功
AUTO_REJECTED 自动模式下 Agent 判定不发布(Agent 无返回内容时兜底为它)
APPROVED 人工通过,Agent 原样执行发布
REJECTED 人工拒绝,Agent 恢复后不发布
EDITED 人工修改通过,执行的是改后的内容

六、关键代码讲解

1. 登记高危工具:AuditAgentFactory.createHumanAgent()

publishContent 被挂进 HumanInTheLoopHook ------ 这才是"什么时候需要人来审"的声明处 ,名字必须和 ContentTools 里的方法名一致。

java 复制代码
HumanInTheLoopHook hipHook = HumanInTheLoopHook.builder()
        .approvalOn("publishContent", ToolConfig.builder()
                .description("该操作会把内容公开发布到社区,请确认不含违禁词后再放行")
                .build())
        .build();

return ReactAgent.builder()
        .name("moderation_agent")
        .model(chatModel)
        .systemPrompt(AuditPrompts.SYSTEM)
        .instruction(AuditPrompts.HUMAN)   // 人工模式的提示词
        .methodTools(contentTools)
        .saver(new MemorySaver())          // 检查点:中断后靠它恢复
        .hooks(hipHook)                    // ← 自动模式不挂这一行
        .build();

自动模式 createAutoAgent() 与它唯一的区别就是不 .hooks(hipHook) ,且 instruction 换成 AuditPrompts.AUTO。

2. 提示词决定行为:AuditPrompts

两种模式的真正开关在提示词,而不只是 Hook:

java 复制代码
/** 人工模式:逼 Agent 走 publishContent,从而必然触发中断 */
public static final String HUMAN =
        "先调用 checkBannedWords 检测违禁词,然后必须调用 publishContent 提交发布,"
                + "最终是否放行由人工在 publishContent 环节决定。用中文简要说明检测结论。";

/** 自动模式:命中违禁词就自己拦截,不调用 publishContent */
public static final String AUTO =
        "先调用 checkBannedWords 检测违禁词。若命中违禁词,不要调用 publishContent,直接给出拒绝发布的结论;"
                + "若未命中,调用 publishContent 发布。用中文给出最终结论。";

3. 第一次调用:识别中断并存档 AuditService.start()

中断信号是 返回值类型 ------ NodeOutput 实际是 InterruptionMetadata 实例,不是异常,所以要用 instanceof 判断。

java 复制代码
String threadId = "audit-" + id;
ReactAgent agent = humanMode ? agentFactory.createHumanAgent() : agentFactory.createAutoAgent();

Optional<NodeOutput> output = agent.invokeAndGetOutput(prompt(item),
        RunnableConfig.builder().threadId(threadId).build());

// 命中需审批工具 -> 中断,等人工决策
if (node instanceof InterruptionMetadata interruption) {
    InterruptionMetadata.ToolFeedback pending = interruption.toolFeedbacks().get(0);

    // 存档:agent 实例 + threadId + 中断现场,三样缺一不可
    sessions.put(id, new Session(agent, threadId, interruption));

    return save(item, AuditItem.Status.WAITING_HUMAN,
            "等待人工审批:" + pending.getName(),
            pending.getName(), pending.getArguments());   // 工具名 + 入参 JSON 给前端展示
}

// 没中断(自动模式)-> Agent 自己跑完,直接判终态
String text = lastAssistantText(node.state());
boolean rejected = text.contains("拒绝") || text.contains("违规") || text.contains("不发布");
return save(item, rejected ? AUTO_REJECTED : AUTO_PASSED, "机审结论:" + text, null, null);

4. 第二次调用:封装决策并恢复执行 AuditService.decide()

java 复制代码
Session session = sessions.get(id);                          // ① 取回中断现场
InterruptionMetadata.ToolFeedback pending = session.interruption().toolFeedbacks().get(0);

// ② 用 pending 建 builder,再按人的决策覆写 result / arguments
InterruptionMetadata.ToolFeedback.Builder feedback =
        InterruptionMetadata.ToolFeedback.builder(pending);

switch (decision) {
    case "REJECTED" -> { feedback.result(FeedbackResult.REJECTED);       status = REJECTED; }
    case "EDITED"   -> { feedback.result(FeedbackResult.EDITED)
                                 .arguments(editedArgs(pending.getArguments(), content));
                         status = EDITED; }
    default         -> { feedback.result(FeedbackResult.APPROVED);       status = APPROVED; }
}

// ③ 原样带上 node + state,否则框架不知道从哪儿继续
InterruptionMetadata humanFeedback = InterruptionMetadata
        .builder(interruption.node(), interruption.state())
        .toolFeedbacks(List.of(feedback.build()))
        .build();

// ④ 同一 agent + 同一 threadId + addHumanFeedback,从检查点继续
Optional<NodeOutput> output = session.agent().invokeAndGetOutput("",
        RunnableConfig.builder()
                .threadId(session.threadId())
                .addHumanFeedback(humanFeedback)
                .build());

sessions.remove(id);

5. "修改后通过"如何生效:editedArgs()

EDITED 不是简单放行,而是把 Agent 提交的入参 JSON 里的 content 换成人工改后的文本,再交给工具执行:

java 复制代码
Map<String, Object> args = objectMapper.readValue(rawArgs, Map.class);
if (args.containsKey("content")) {
    args.put("content", newContent);       // 替换成弹窗里改过的内容
    return objectMapper.writeValueAsString(args);
}

所以点"修改并通过"后,控制台打印的 [工具执行] publishContent -> ... 是改后的文本。

6. 前端三处关键 JS

js 复制代码
// ① 发起审核:把模式开关透传给后端,中断了就自动弹窗
const human = document.getElementById('humanSwitch').checked;
const item = await fetch(`/api/audit/${id}/start?human=${human}`, { method: 'POST' }).then(okJson);
if (item.status === 'WAITING_HUMAN') openModal(item.id);

// ② 弹窗展示的正是后端从 InterruptionMetadata 里取出的待审批现场
document.getElementById('mTool').textContent = item.pendingTool;   // publishContent
document.getElementById('mArgs').textContent = item.pendingArgs;   // {"content":"..."}

// ③ 决策回传:后端封装成 ToolFeedback 恢复 Agent
fetch(`/api/audit/${currentId}/human`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ decision, content: document.getElementById('mContent').value })
});

7. 其余文件一句话说明

文件 作用
domain/AuditItem.java 审核条目 record + 7 种状态枚举,含 pendingTool / pendingArgs 两个"中断现场"字段
tool/ContentTools.java checkBannedWords(检测,不中断)+ publishContent(发布,被 Hook 登记)
controller/AuditController.java 3 个接口:GET /items、POST /{id}/start?human=、POST /{id}/human
application.yml 端口 8081 + spring.ai.dashscope.api-key(用环境变量注入)

七、接口说明

方法 路径 说明
GET /api/audit/items 列表
POST `/api/audit/{id}/start?human=true false`
POST /api/audit/{id}/human 提交人工决策,body:`{"decision":"APPROVED

八、运行与演示

powershell 复制代码
cd c:\Users\fei\Desktop\companyProject\springAlibabaAi-Demo
$env:JAVA_HOME = "D:\Software\jdk17"
$env:AI_DASHSCOPE_API_KEY = "sk-你的密钥"
.\mvnw.cmd -pl SAA-Demo2-Audit spring-boot:run

浏览器打开 http://localhost:8081/

演示路径

  1. 不勾选 「人工审核模式」→ 点 id=2 的「审核」→ Agent 自己检测出"赌博"→ 直接 机审拒绝;id=1 → 机审通过。
  2. 勾选 「人工审核模式」→ 点 id=1 的「审核」→ Agent 跑到 publishContent 被中断 → 列表变 待人工审批 并自动弹出弹窗:
    • 待审批工具:publishContent
    • 工具入参:{"content":"春天来了......"}
    • 可改内容后点 修改并通过 → 后端把入参 content 换成改后的文本 → Agent 用新入参执行发布 → 人工修改通过
    • 点 通过 → 原样发布 → 人工通过
    • 点 拒绝 → 不执行发布 → 人工拒绝

九、生产落地要改什么

现状(Demo) 生产做法
检查点存 MemorySaver(内存,只在 Agent 实例里) 换成可持久化 Saver(Redis / MySQL / MongoDB),否则服务重启或多副本就恢复不了
sessions 用内存 LinkedHashMap 与检查点一起落 Redis,带 TTL(如 30 分钟)自动作废,防止人开了弹窗不管导致堆积
store 内存 Map 换 JPA/MyBatis 落库
无操作人/时间 AuditItem 加 auditor、auditTime,决策写审计日志(合规刚需)
无鉴权 加 Spring Security,人工审核接口按角色授权
违禁词 List.of(...) 硬编码 DFA/Trie 词库 + 配置中心热加载
机审判定靠"文本含拒绝/违规"关键词 让 Agent 输出结构化结果(JSON Schema),或用独立的分类模型给置信度,再按阈值分流人工
无并发控制 恢复接口加幂等/乐观锁,避免重复提交决策
无超时 人的决策超时后自动转"拒绝"或留待下一班

相关推荐
杨运交1 小时前
[079][消息模块]基于 Redis Stream 的高可靠消息队列实现——Spring Data Redis 实战解析
数据库·redis·spring
SunnyDays10112 小时前
如何使用 Java 提取 PPT 中的文字:整份文档、指定幻灯片与表格内容
java·powerpoint
毕设单片机2 小时前
springboot志愿服务平台的设计与实现 36731
java·spring boot·mysql·志愿服务平台
IT_Octopus2 小时前
【零基础入门 LLM 开发 · Day 15】:LangChain 注册工具——告别手写 JSON Schema
java·langchain·json
Elastic 中国社区官方博客2 小时前
教程:监控 Java 应用
java·开发语言·数据库·elasticsearch·搜索引擎·全文检索
ShineWinsu2 小时前
对于JavaScript:初识JavaScript以及输入输出
java·开发语言·前端·javascript·css·面试·html
小小安于乱2 小时前
linux中java的springboot程序获取IP地址间歇性10秒卡顿问题
java·开发语言
云絮.3 小时前
mybatis(二)
java·数据库·mybatis