本文面向 Java 后端和 AI 应用开发者,讨论 AI Agent 从“能够调用工具”走向“能够可靠完成业务流程”时,必须补齐的状态管理、幂等、重试、人工确认和恢复机制。示例以 Java 21、Spring Boot 3.x 和常见工具调用链路为背景,代码用于说明架构方法,具体 API 需要按实际依赖版本调整。
摘要
很多 Agent Demo 都有相似的交互:模型先理解用户问题,再调用查询工具,拿到结果后继续调用另一个工具,最后生成回答。演示时流程很顺,但一旦接入真实业务,问题很快出现:工具调用超时后应该重试吗?重试会不会重复扣款?模型已经生成了“准备提交”的结果,但用户还没有确认,系统能不能继续执行?服务重启后,任务从哪里恢复?同一个请求重复发送时,是否会产生两张工单?
这些问题说明,工具调用循环不等于业务工作流。模型擅长理解自然语言、选择候选动作和解释结果,但不应该独自承担状态持久化、事务边界、幂等控制和高风险操作。可靠的 Agent 系统,需要让模型负责不确定的部分,让确定性工程逻辑负责状态、权限和执行。
本文用“售后退款 Agent”作为案例,设计一条从查询订单、计算退款、等待确认到创建退款任务的工作流,并给出 Java 状态机、工具契约、幂等处理、重试策略和上线检查清单。
目录
- 一、工具调用循环为什么不能直接当工作流
- 二、把 Agent 拆成模型决策层和确定性执行层
- 三、实战案例:售后退款 Agent 的状态机设计
- 四、幂等、重试和人工确认必须写进代码
- 五、服务重启后,任务如何继续执行
- 六、什么场景适合 Agent,什么场景应该使用传统流程
- 结论:Agent 的可靠性来自工作流,而不是模型的自信
一、工具调用循环为什么不能直接当工作流
1. 模型的下一步不是系统状态
模型可以返回一个工具调用请求,例如查询订单、计算退款或创建售后单。这个请求只是一个建议,不代表业务动作已经成功完成。
至少有四个事实需要由系统自己确认:
如果系统把模型返回的工具调用直接当成状态变更,就会把“不确定的生成结果”和“确定的业务事实”混在一起。一次网络超时,模型可能认为工具没有执行,系统也可能认为工具执行失败,下一次重试就有机会造成重复操作。
2. 三种系统的区别
| 聊天问答 | 解释和生成文本 | 通常不适合 | 事实错误、引用不准 |
| Agent 循环 | 选择工具并组合结果 | 只能在受控范围内 | 工具选错、循环失控 |
| 业务工作流 | 管理状态和执行边界 | 可以,但必须有策略 | 状态丢失、重复执行、无法恢复 |
Agent 可以成为工作流中的智能节点,但不能替代工作流本身。尤其是退款、付款、发货、审批、权限变更等动作,应该由确定性服务根据状态和策略执行。
3. 一个典型的重复退款事故
用户说:“把这笔订单退掉。”
系统流程如下:
如果退款接口没有幂等键,用户可能得到两次退款,或者系统进入人工对账。模型不是故意做错,而是它没有办法从网络超时中推断业务事实。
二、把 Agent 拆成模型决策层和确定性执行层
1. 模型适合做什么
模型可以承担以下任务:
- 从用户语言中识别意图;
- 提取订单号、原因、时间等参数;
- 在候选工具中选择可能的下一步;
- 根据检索结果生成解释;
- 在多个方案中提出建议;
- 发现信息不足并向用户提问。
这些任务本身存在语义不确定性,模型的优势正好在这里。
2. 系统必须自己决定什么
下面的内容不能只依赖模型输出:
- 当前工作流处于哪个状态;
- 用户是否有权限执行动作;
- 请求是否已经执行过;
- 工具是否允许重试;
- 是否必须等待用户确认;
- 业务数据是否已经提交;
- 失败后应该暂停、补偿还是转人工。
可以用一句话概括:
模型提出候选动作,工作流决定动作是否允许,领域服务决定动作如何执行。
3. 一条更稳的链路
#mermaid-svg-JjQUhz0CVPumUJMR{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JjQUhz0CVPumUJMR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JjQUhz0CVPumUJMR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JjQUhz0CVPumUJMR .error-icon{fill:#552222;}#mermaid-svg-JjQUhz0CVPumUJMR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JjQUhz0CVPumUJMR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JjQUhz0CVPumUJMR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JjQUhz0CVPumUJMR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JjQUhz0CVPumUJMR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JjQUhz0CVPumUJMR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JjQUhz0CVPumUJMR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JjQUhz0CVPumUJMR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JjQUhz0CVPumUJMR .marker.cross{stroke:#333333;}#mermaid-svg-JjQUhz0CVPumUJMR svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JjQUhz0CVPumUJMR p{margin:0;}#mermaid-svg-JjQUhz0CVPumUJMR .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-JjQUhz0CVPumUJMR .cluster-label text{fill:#333;}#mermaid-svg-JjQUhz0CVPumUJMR .cluster-label span{color:#333;}#mermaid-svg-JjQUhz0CVPumUJMR .cluster-label span p{background-color:transparent;}#mermaid-svg-JjQUhz0CVPumUJMR .label text,#mermaid-svg-JjQUhz0CVPumUJMR span{fill:#333;color:#333;}#mermaid-svg-JjQUhz0CVPumUJMR .node rect,#mermaid-svg-JjQUhz0CVPumUJMR .node circle,#mermaid-svg-JjQUhz0CVPumUJMR .node ellipse,#mermaid-svg-JjQUhz0CVPumUJMR .node polygon,#mermaid-svg-JjQUhz0CVPumUJMR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JjQUhz0CVPumUJMR .rough-node .label text,#mermaid-svg-JjQUhz0CVPumUJMR .node .label text,#mermaid-svg-JjQUhz0CVPumUJMR .image-shape .label,#mermaid-svg-JjQUhz0CVPumUJMR .icon-shape .label{text-anchor:middle;}#mermaid-svg-JjQUhz0CVPumUJMR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JjQUhz0CVPumUJMR .rough-node .label,#mermaid-svg-JjQUhz0CVPumUJMR .node .label,#mermaid-svg-JjQUhz0CVPumUJMR .image-shape .label,#mermaid-svg-JjQUhz0CVPumUJMR .icon-shape .label{text-align:center;}#mermaid-svg-JjQUhz0CVPumUJMR .node.clickable{cursor:pointer;}#mermaid-svg-JjQUhz0CVPumUJMR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JjQUhz0CVPumUJMR .arrowheadPath{fill:#333333;}#mermaid-svg-JjQUhz0CVPumUJMR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JjQUhz0CVPumUJMR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JjQUhz0CVPumUJMR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JjQUhz0CVPumUJMR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JjQUhz0CVPumUJMR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JjQUhz0CVPumUJMR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JjQUhz0CVPumUJMR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JjQUhz0CVPumUJMR .cluster text{fill:#333;}#mermaid-svg-JjQUhz0CVPumUJMR .cluster span{color:#333;}#mermaid-svg-JjQUhz0CVPumUJMR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JjQUhz0CVPumUJMR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JjQUhz0CVPumUJMR rect.text{fill:none;stroke-width:0;}#mermaid-svg-JjQUhz0CVPumUJMR .icon-shape,#mermaid-svg-JjQUhz0CVPumUJMR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JjQUhz0CVPumUJMR .icon-shape p,#mermaid-svg-JjQUhz0CVPumUJMR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JjQUhz0CVPumUJMR .icon-shape .label rect,#mermaid-svg-JjQUhz0CVPumUJMR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JjQUhz0CVPumUJMR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JjQUhz0CVPumUJMR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JjQUhz0CVPumUJMR :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
查询类动作
写入类动作
不通过
用户请求
意图识别
提取结构化参数
加载工作流状态
权限与规则校验
模型提出候选动作
确定性策略审批
执行只读工具
生成待确认计划
用户确认
带幂等键执行
记录事件与结果
更新状态
生成下一步或结束
拒绝、澄清或转人工
注意这里有两个不同的“动作”:模型提出的候选动作,以及服务端批准后的实际动作。两者之间的策略层,就是保护业务系统的边界。
三、实战案例:售后退款 Agent 的状态机设计
1. 业务目标和边界
售后助手需要帮助用户处理退款咨询。第一版只开放以下能力:
- 查询当前用户自己的订单;
- 判断订单是否满足“未发货可取消”的规则;
- 计算预估退款金额;
- 展示退款计划;
- 用户明确确认后创建退款任务。
以下能力暂不开放:
- 模型直接执行退款;
- 修改收款账户;
- 绕过订单状态强制退款;
- 对一批订单进行无确认批量操作。
把第一版限制在低风险和可追踪范围内,可以减少工作流状态和异常分支,也方便建立评测集。
2. 状态定义
public enum RefundState {
RECEIVED,
ORDER_LOADED,
ELIGIBLE,
PLAN_READY,
WAITING_CONFIRMATION,
EXECUTING,
SUCCEEDED,
REJECTED,
FAILED,
NEEDS_HUMAN
}
状态不是页面上的标签,而是业务事实。每次状态变化都应该有来源、时间和操作者,不能只保存在一次请求的内存变量中。
3. 约束状态转换
public final class RefundStateMachine {
private static final Map<RefundState, Set<RefundState>> ALLOWED = Map.of(
RefundState.RECEIVED,
Set.of(RefundState.ORDER_LOADED, RefundState.REJECTED),
RefundState.ORDER_LOADED,
Set.of(RefundState.ELIGIBLE, RefundState.REJECTED),
RefundState.ELIGIBLE,
Set.of(RefundState.PLAN_READY, RefundState.NEEDS_HUMAN),
RefundState.PLAN_READY,
Set.of(RefundState.WAITING_CONFIRMATION),
RefundState.WAITING_CONFIRMATION,
Set.of(RefundState.EXECUTING, RefundState.REJECTED),
RefundState.EXECUTING,
Set.of(RefundState.SUCCEEDED, RefundState.FAILED),
RefundState.FAILED,
Set.of(RefundState.EXECUTING, RefundState.NEEDS_HUMAN)
);
public static void assertAllowed(RefundState from, RefundState to) {
if (!ALLOWED.getOrDefault(from, Set.of()).contains(to)) {
throw new IllegalStateException(
"Illegal transition: " + from + " -> " + to
);
}
}
}
状态机的价值在于拒绝非法路径。例如一个已经成功的退款任务,不能因为模型又生成了一次“执行退款”就回到执行中。
4. 状态变化要落成事件
public record RefundEvent(
UUID eventId,
UUID workflowId,
RefundState from,
RefundState to,
String reason,
String actor,
Instant occurredAt
) {}
事件可以写入关系数据库或消息系统。具体选型取决于吞吐量、顺序要求和恢复方式,但至少要做到:
- 事件有唯一 ID;
- 同一个工作流可以按顺序读取;
- 状态变化前后可审计;
- 可以根据事件重建当前状态;
- 用户确认和人工介入有明确操作者。
四、幂等、重试和人工确认必须写进代码
1. 工具契约需要区分查询和写入
一个工具的描述不能只写“执行退款”。更完整的工具契约应该包含:
| name | 工具唯一名称 |
| purpose | 允许解决什么问题 |
| input schema | 参数类型、格式和范围 |
| side effect | 是否修改业务数据 |
| idempotency | 是否支持幂等 |
| permission | 哪类角色可以调用 |
| confirmation | 是否必须用户确认 |
| timeout | 超时后如何处理 |
工具越多,描述越重要;但描述越清楚,也不能代替服务端权限和状态校验。
2. 用幂等键保护写操作
public RefundResult executeRefund(
UUID workflowId,
String idempotencyKey,
RefundCommand command
) {
Optional<RefundResult> existing =
refundRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return existing.get();
}
RefundResult result = paymentGateway.refund(command);
refundRepository.saveResult(
workflowId,
idempotencyKey,
result
);
return result;
}
真实实现必须使用数据库唯一约束或供应商提供的幂等能力,不能只靠先查询再插入。因为“查询”和“插入”之间仍然可能发生并发竞争。
3. 重试策略不能一刀切
| 查询订单 | 可有限重试 | 通常没有副作用 |
| 读取库存 | 可重试,但要标注时间 | 数据可能在重试间变化 |
| 创建工单 | 只有有幂等键时重试 | 避免重复工单 |
| 扣款或退款 | 先查询最终状态 | 网络超时不代表业务失败 |
| 修改权限 | 默认不自动重试 | 需要人工核查 |
| 删除数据 | 不由模型直接执行 | 错误不可逆 |
尤其要区分“请求失败”和“业务失败”。请求失败可能是网络、超时或连接中断;业务失败可能是余额不足、订单状态不允许或权限不足。两者的恢复路径完全不同。
4. 用户确认必须绑定计划版本
如果用户在等待确认期间修改了订单或退款金额,原来的确认不能无限期有效。确认应该绑定一个不可变的计划快照:
public record RefundPlan(
UUID planId,
UUID orderId,
BigDecimal amount,
String currency,
String ruleVersion,
String planHash,
Instant expiresAt
) {}
用户确认时,服务端重新检查 planHash、订单状态、金额和过期时间。只要其中一项变化,就重新生成计划,而不是沿用旧确认。
五、服务重启后,任务如何继续执行
1. 不要把工作流状态放在内存
如果 Agent 运行到一半服务重启,以下信息不能丢:
- 工作流 ID;
- 当前状态;
- 用户身份摘要;
- 已经执行过的工具;
- 每个工具的请求 ID 和幂等键;
- 当前计划版本;
- 下次可重试时间;
- 等待用户确认还是等待系统恢复。
把这些信息放在单次 HTTP 请求的内存里,服务重启或请求超时就无法恢复。
2. 采用“命令 + 事件 + 状态快照”
一个简单的持久化模型可以包含三张表:
| workflow | workflow_id、state、version | 保存当前状态 |
| workflow_event | event_id、from、to、reason | 保存状态变化 |
| tool_execution | tool_call_id、idempotency_key、status | 保存工具执行事实 |
更新状态时使用乐观锁版本号,避免两个并发请求同时推进同一个工作流。
public void transition(
UUID workflowId,
long expectedVersion,
RefundState from,
RefundState to
) {
RefundStateMachine.assertAllowed(from, to);
int updated = jdbc.update(
"""
update refund_workflow
set state = ?, version = version + 1
where workflow_id = ?
and version = ?
and state = ?
""",
to.name(),
workflowId,
expectedVersion,
from.name()
);
if (updated != 1) {
throw new ConcurrentModificationException(
"Workflow was changed by another request"
);
}
}
这里的 SQL 使用了 Java 文本块,仅用于展示结构。生产代码需要结合事务、数据库方言和异常处理完善。
3. 失败后要有明确的恢复动作
恢复动作不应该只写成“稍后再试”,而应该根据失败类型决定:
- 可重试错误:进入延迟队列,带退避时间;
- 状态未知:先查询外部系统最终状态;
- 业务拒绝:终止并把原因展示给用户;
- 权限错误:停止执行并记录安全事件;
- 多次失败:转人工并附带完整 Trace;
- 数据不一致:进入对账或补偿流程。
4. 工具调用的观测要能回答五个问题
一次线上故障发生时,日志至少要回答:
Spring AI 官方工具调用文档目前把工具定义、模型请求、应用执行、结果回传和继续生成拆成明确环节,同时支持框架控制和用户控制的执行方式。对于高风险工作流,建议保留足够的手动控制点,而不是把整个循环交给默认实现。
参考:Spring AI Tool Calling 官方文档。
六、什么场景适合 Agent,什么场景应该使用传统流程
1. 适合 Agent 的部分
Agent 更适合处理:
- 用户说法不统一的意图识别;
- 多轮澄清和信息补全;
- 从多个候选工具中选择查询路径;
- 对资料进行总结、解释和改写;
- 为人工客服整理上下文;
- 给出多个方案并说明差异。
这些任务需要理解自然语言,且输出可以被后续规则校验。
2. 不适合让 Agent 主导的部分
以下场景更适合由传统工作流、规则引擎或人工审批主导:
- 付款、扣款、退款;
- 权限和账号变更;
- 删除数据;
- 合规审批;
- 发送不可撤回的外部通知;
- 大规模批量操作。
并不是完全不能使用 AI,而是让 AI 做解释、提取和建议,把真正的执行放在确定性流程中。
3. 什么时候可以放宽人工确认
只有在以下条件同时满足时,才可以考虑降低确认频率:
任何一项缺失,都不适合仅凭“模型表现很好”取消确认。
结论:Agent 的可靠性来自工作流,而不是模型的自信
Agent 的价值不在于让模型连续调用多少个工具,而在于让用户用更自然的方式完成一项真实工作。要实现这一点,系统必须把智能和确定性分开:
如果一个 Agent 只能在服务不重启、网络不超时、用户不重复点击、模型不选错工具的理想环境里工作,它只是一个演示程序。真正可以上线的 Agent,应该允许失败,并且在失败后能够知道发生了什么、停止扩大损失、恢复到正确状态。
所以,AI Agent 的下一道门槛不是让模型“更像一个人”,而是让整个系统更像一个可靠的业务流程。
网硕互联帮助中心





评论前必须登录!
注册