Appearance
Human-in-the-loop:写操作必须有人的确认
1. 本节产出
一个审批网关:高危工具调用时暂停并生成审批单、人工批准/拒绝后继续执行、支持回滚、全程审计。并且审批超时有明确处理(不会让任务永远挂着)。
2. 前置依赖
- 03-05 状态机与持久化:状态可暂停可恢复
- 03-03 工具设计:工具已有
riskLevel
3. 为什么审批机制是 Agent 上线的必要条件
一个真实事故:Agent 接到「清理三个月前未支付的订单」的指令,它理解了任务、调用了删除接口,然后——删错了条件,删掉了 12000 条有效订单。
事后复盘发现三件事本可以阻止它:
| 缺失 | 如果有的话 |
|---|---|
| 工具风险分级 | 删除类工具会被标记为 DESTRUCTIVE |
| 审批 | 执行前会暂停,等人确认 |
| 回滚 | 即使错了也能恢复 |
核心判断:模型的理解能力不是 100% 可靠的,而业务操作的错误代价是不可逆的。 这两者之间的矛盾,只能用「人的确认」来弥合。
另一个角度:即使 Agent 99% 正确,1% 的错误率在高价值操作上也是不可接受的(100 次删除错 1 次 = 一次事故)。
4. 核心原理
4.1 风险分级与动作映射
| 风险级别 | 动作 | 例子 |
|---|---|---|
READ_ONLY | 直接执行 | 查询订单、查天气、检索文档 |
WRITE | 执行 + 记录审计 | 创建工单、发通知 |
DESTRUCTIVE | 暂停 + 审批 | 删除数据、批量更新 |
NEED_APPROVAL | 无论如何都审批 | 转账、对外发布、发邮件给客户 |
分级标准不是「技术上危险」,而是「业务上不可逆」。发一封邮件给客户,技术上很简单,但业务上不可逆——应该是 NEED_APPROVAL。
4.2 审批流程
Agent 决定调用高危工具
│
▼ 暂停,状态改为 WAITING_APPROVAL
生成审批单(含:工具名、参数、理由、上下文摘要)
│
▼ 通知审批人(站内 / 企微 / 邮件)
│
├─ 批准 → 执行工具 → 继续循环 → 状态 RUNNING
├─ 拒绝 → 把「用户拒绝了」作为 Observation 回填 → 继续循环
└─ 超时(如 30 分钟)→ 标记 TIMEOUT → 通知用户任务未完成「拒绝」不是终止任务,而是把拒绝作为 Observation 回填。这样模型还有机会换个方式(比如「用户拒绝了删除,那我改成标记待处理」)。这个设计让拒绝变得有建设性。
超时必须有处理。永远挂着的审批会让状态机里堆积大量僵尸任务。
4.3 审批单应该包含什么
审批单
运行 ID:run-3f2a
目标:清理三个月前未支付订单
待执行操作:
工具:deleteOrders
参数:{"before":"2026-07-03","status":"UNPAID"}
影响范围预估:约 12000 条(系统已自动估算)
模型给出的理由:
「用户要求清理三个月前未支付订单,该条件匹配约 12000 条记录」
前序步骤摘要:
已查询订单统计 → 已确认时间范围 → 未做备份
[批准] [拒绝] [拒绝并说明原因]「影响范围预估」和「前序步骤摘要」是审批人做判断的关键。只给一个工具名和参数,审批人无法判断该不该批。
4.4 回滚能力
执行前:保存操作前快照(或记录反向操作)
执行后:保留 undo 信息 N 天
回滚触发:用户发现错误 → 提交回滚 → 执行反向操作不是所有操作都能回滚(比如已发出的邮件)。所以分级时要区分:
- 可回滚:数据库操作 → 可考虑放宽(但仍建议审批)
- 不可回滚:对外通信、支付 → 必须审批
5. 代码走查
5.1 审批网关
java
// ch03-agent/src/main/java/com/aitech/agent/human/ApprovalGateway.java
@Service
public class ApprovalGateway {
/** 执行前拦截:需要审批则暂停 */
public GateResult inspect(Action action, AgentState state) {
ToolMeta meta = registry.meta(action.toolName());
if (meta == null) {
return GateResult.deny("工具未注册:" + action.toolName());
}
if (meta.risk() == READ_ONLY || meta.risk() == WRITE) {
return GateResult.proceed(); // 直接放行(写操作记审计)
}
// 生成审批单并暂停
ApprovalTicket ticket = ApprovalTicket.of(state.runId(), action, meta,
estimateImpact(action), summarize(state));
repo.save(ticket);
notifyApprover(ticket);
return GateResult.pause(ticket.id());
}
public record GateResult(boolean proceed, String pauseTicketId, String denyReason) {
public static GateResult proceed() { return new GateResult(true, null, null); }
public static GateResult pause(String id) { return new GateResult(false, id, null); }
public static GateResult deny(String r) { return new GateResult(false, null, r); }
}
}5.2 审批回调
java
// ch03-agent/src/main/java/com/aitech/agent/human/ApprovalController.java
@RestController
@RequestMapping("/api/approvals")
public class ApprovalController {
@PostMapping("/{ticketId}/approve")
public void approve(@PathVariable String ticketId,
@RequestBody ApprovalDecision d,
Authentication auth) {
ApprovalTicket t = repo.findById(ticketId).orElseThrow();
// 权限校验:不能审批自己的请求(除非配置允许)
if (t.requestedBy().equals(auth.getName()) && !allowSelfApprove) {
throw new AccessDeniedException("不能审批自己发起的操作");
}
audit.record(ticketId, auth.getName(), d.decision(), d.comment());
if (d.approved()) {
String obs = toolExecutor.execute(t.toolName(), t.args());
runner.resumeWithObservation(t.runId(), obs); // 继续执行
} else {
// 拒绝作为 Observation 回填,让模型有机会调整
runner.resumeWithObservation(t.runId(),
"用户拒绝了该操作。原因:" + d.comment() + "。请换一种方式或终止。");
}
}
}「不能审批自己发起的操作」是重要的内控要求,尤其在财务、权限类操作上。
5.3 超时处理
java
// ch03-agent/src/main/java/com/aitech/agent/human/ApprovalTimeoutJob.java
@Component
public class ApprovalTimeoutJob {
@Scheduled(fixedDelay = 60_000)
public void timeout() {
List<ApprovalTicket> expired = repo.findExpired(Duration.ofMinutes(30));
for (ApprovalTicket t : expired) {
repo.markTimeout(t.id());
runner.resumeWithObservation(t.runId(),
"审批超时未处理,该操作已取消。请告知用户需要人工介入。");
notify(t.requestedBy(), "审批超时:" + t.toolName());
}
}
}5.4 审计记录
java
// 每次审批都留痕
public record ApprovalAudit(String ticketId, String approver, String decision,
String comment, String toolName, String args,
Instant at) {}6. 跑起来
bash
git checkout ch03-06-hitl
mvn spring-boot:runbash
# 1. 发起一个含删除操作的任务
curl -X POST http://localhost:8080/api/agent/run \
-d '{"goal":"清理三个月前未支付的订单"}'
# 期望:返回 {"status":"NEED_APPROVAL","ticketId":"tk-1"}
# 2. 查看审批单
curl http://localhost:8080/api/approvals/tk-1
# 期望:含工具名、参数、影响预估、模型理由、前序摘要
# 3. 拒绝(并给原因)
curl -X POST http://localhost:8080/api/approvals/tk-1/reject \
-d '{"comment":"需要先备份,且范围太大"}'
# 期望:Agent 收到「用户拒绝」的 Observation,继续运行并尝试其他方案
# 4. 批准
curl -X POST http://localhost:8080/api/approvals/tk-2/approve -d '{}'
# 期望:工具执行,任务继续
# 5. 超时验证(把超时改短)
# 期望:30 分钟后标记超时,Agent 收到取消通知| 检查项 | 通过标准 |
|---|---|
| 高危拦截 | DESTRUCTIVE 工具调用前暂停 |
| 审批单完整 | 含影响预估、理由、前序摘要 |
| 拒绝可用 | 拒绝后 Agent 继续并调整方案 |
| 超时处理 | 超时后任务不挂死 |
| 审计留痕 | 每次审批有记录(谁、何时、决定) |
| 自审禁止 | 不能审批自己发起的操作 |
7. 生产避坑
- 不要用 prompt 约束代替机制:「请先询问用户再删除」这种写法完全不可靠——模型可能在某次调用中忽略它。审批必须是在执行层拦截的机制,模型无法绕过。
- 审批必须有超时处理。没有超时的审批系统会积累大量僵尸任务,而且用户会一直等着。做法:超时后明确取消并通知,让 Agent 收到取消信号继续运行或终止。
- 审批单必须包含「影响范围预估」。只给工具名和参数,审批人(通常是业务人员,不懂技术)无法判断风险,结果就是要么一律批准(等于没审批),要么一律拒绝(系统不可用)。影响预估是让审批真正有效的前提。
8. 延伸与锚点
- 思考题:审批解决了「误操作」,但如果 Agent 自己跑飞了(死循环、超时)呢?(03A 完成,进入 03B 可靠性——答案在下一课时)
- 代码锚点:
git checkout ch03-06-hitl - 下一课时:03-07 超时控制
- 对应课件:L03-06 Human-in-the-loop