首页/文章/AI / Agent
AI / Agent

Agent 工作流状态机设计:工具调用的审批与恢复

用提案、人工审批、事务发件箱和幂等执行,把模型提出的高风险操作变成可追踪的业务流程。

当 Agent 只回答问题时,模型调用失败通常意味着重新发起请求;当它能创建部署、退款或发送通知时,重试就可能产生真实副作用。把一个写操作直接暴露成 @Tool 方法,虽然代码短,却把“模型想做什么”和“系统允许做什么”混成了一步。

本文用部署申请说明一种更稳妥的边界:模型只能创建待审批提案;身份、权限、人工确认和实际执行由普通应用服务负责。

先把状态和责任分开

一次操作至少要能回答四个问题:是谁提出的、具体批准了什么、谁批准的、执行到哪一步。建议把对话记录与业务动作分开保存,后者用明确状态推进:

stateDiagram-v2
    [*] --> PROPOSED
    PROPOSED --> APPROVED: authorized human
    PROPOSED --> REJECTED: human rejects
    PROPOSED --> EXPIRED: deadline passed
    APPROVED --> QUEUED: outbox committed
    QUEUED --> RUNNING: worker claims
    RUNNING --> SUCCEEDED
    RUNNING --> RETRY_WAIT: transient failure
    RETRY_WAIT --> QUEUED
    RUNNING --> FAILED: permanent failure

APPROVED 不是成功。它只表示特定审批人批准了提案;真正的执行结果还需要由 worker 回写。提案中保存规范化参数和参数摘要,审批页面展示的内容必须与最终执行内容一致。

架构路径

flowchart LR
    U[用户] --> C[Chat API]
    C --> M[模型选择工具]
    M --> T[只创建待审批提案]
    T --> DB[(Proposal DB)]
    U --> A[审批 API]
    A -->|校验身份与状态| DB
    A --> O[(Outbox)]
    O --> W[执行 Worker]
    W --> P[领域服务 / 部署平台]
    W --> DB

Proposal 和 Outbox 放在同一个数据库事务中。这样审批状态和待执行事件一起提交,避免“审批显示成功,但队列消息没有写进去”的双写窗口。

模型只创建提案

Spring AI 的工具调用可以把 Java 方法暴露给模型。这里的工具只负责提出方案,不直接部署;用户身份从应用安全上下文读取,不能相信模型生成的 userId。

@Component
class DeploymentProposalTool {
    private final ProposalService proposals;
    private final CurrentActor currentActor;

    @Tool(description = "为指定服务创建部署提案;不会执行部署")
    ProposalView propose(DeployRequest request) {
        Actor actor = currentActor.require();
        return proposals.createPending(actor, request);
    }
}

record DeployRequest(String service, String version, String environment) {}

createPending 仍需验证服务名、版本格式、目标环境和提出权限。工具描述只是给模型看的说明,不构成授权。低风险查询工具可以直接执行;有副作用的能力应按风险分层。

审批与入队必须原子提交

审批接口需要验证审批人对该资源有权限,并以条件更新防止重复点击或并发审批。示意代码省略了 repository 和异常映射:

@Transactional
public void approve(UUID proposalId, Actor approver) {
    Proposal proposal = proposals.lockPending(proposalId);
    policy.requireCanApprove(approver, proposal);
    policy.requireNotExpired(proposal);

    proposals.markApproved(proposalId, approver.id(), proposal.payloadHash());
    outbox.append("deployment.approved", proposalId.toString());
}

状态更新应包含 WHERE status = 'PROPOSED',并检查受影响行数恰好为 1。Outbox 与状态表必须参与同一个数据库事务。不能在事务里先调用远程部署 API:网络超时会让数据库回滚与远端执行结果不一致,也会长时间占用数据库连接和锁。

Worker 执行时再做一次校验

审批到执行之间可能发生权限撤销、提案过期或目标资源变化。Worker 领取任务后应重新检查提案状态、参数摘要、执行权限和幂等键,再调用领域服务。推荐将稳定的 proposalId 传作幂等键:同一提案被重复投递时,领域服务返回已有结果而不重复创建部署。

执行端还要区分错误:超时、限流等短暂故障可以有限退避重试;参数错误或资源不存在通常是永久失败。每次状态变化记录事件 ID、操作者、时间、重试次数和脱敏后的结果摘要。不要把完整提示词、令牌或个人数据默认写入 Trace。

常见踩坑

  1. 把审批按钮做成“继续对话”。 审批的是一份明确、不可变的操作快照,不是模型下一轮可能改写的意图。
  2. 审批后不再验证。 执行前重验策略与目标状态,避免过期提案或权限变更后仍可运行。
  3. 超时后直接重放写操作。 先用业务幂等键去重;模型请求 ID 不一定等于业务操作 ID。
  4. 在数据库事务中等待模型或远程 API。 外部调用应在事务外进行,事务只围绕本地状态和 Outbox。
  5. 把模型的工具参数当作可信身份。 身份、租户和审批角色必须由服务端上下文确定。

落地顺序

先选择一个可撤销、低影响的操作,完成提案表、审批状态机、Outbox 和幂等执行;再补拒绝、过期、取消、重试和审计。上线前用重复审批、重复消息、权限撤销、worker 重启和远程超时构造回归用例。

框架 API 会随 Spring AI 版本变化。本文的 @Tool 片段表达边界设计,实际项目应按锁定的依赖版本核对方法签名。

参考资料

文章反馈

如果这篇文章对你有帮助,欢迎发邮件交流。

通过邮箱联系 ↗继续阅读 →