一、要解决的问题
生产审核系统通常是 机审 + 人审两级:
- 机审 :敏感词库(DFA/Trie/AC 自动机)+ 规则 + 模型分类,输出三态
通过 / 拒绝 / 待人工; - 人审 :不确定的进人工队列,运营在页面里
通过 / 拒绝 / 修改后通过; - 可追溯:谁、何时、改了什么,必须落库(合规刚需);
- 开关:高危场景强制人工,低危场景全自动。
本案例把第 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/
演示路径
- 不勾选 「人工审核模式」→ 点 id=2 的「审核」→ Agent 自己检测出"赌博"→ 直接
机审拒绝;id=1 →机审通过。 - 勾选 「人工审核模式」→ 点 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),或用独立的分类模型给置信度,再按阈值分流人工 |
| 无并发控制 | 恢复接口加幂等/乐观锁,避免重复提交决策 |
| 无超时 | 人的决策超时后自动转"拒绝"或留待下一班 |