Spring AI 2.1 工具调用确认怎么做?从人工审批到幂等恢复的 Agent 安全实战
技术范围:Java、Spring AI 2.1 预发布 API、Spring Boot、Tool Calling、Agent 安全
AI Agent 不再只是回答问题:它可以调用删除文件、创建订单、发送邮件、修改数据库等工具。真正危险的不是模型偶尔答错,而是模型在用户尚未批准时执行了有副作用的操作。
Spring AI 2.1 系列引入了工具调用确认(Tool Call Confirmation)机制:模型准备执行某些工具时,可以先返回待确认调用,让应用展示参数、获取人工决策,再继续执行。本文围绕审批流程、状态保存、权限校验、重复执行防护展开,而不是重复罗列 2.1 的全部新功能。
一、为什么“在提示词里要求先确认”不够?
假设系统有一个发送邮件的工具:
@Tool(description = "Send an email")
public String sendEmail(String recipient, String body) {
return mailService.send(recipient, body);
}
如果只在系统提示词中写“发送前必须询问用户”,模型可能遵守,也可能在复杂上下文或恶意输入影响下直接提出调用。提示词不是可靠的授权边界。
正确的工程划分应是:
- 模型:提出要调用哪个工具,以及参数是什么;
- 框架:识别需要审批的调用并暂停;
- 应用:验证当前用户身份、展示完整操作信息、记录审批决定;
- 服务端:再次校验权限、执行幂等控制并调用真实业务服务。
二、Spring AI 2.1 的确认机制是什么?
根据 Spring AI 2.1 参考文档,确认检查发生在 ToolCallingManager 中。若一次模型回复包含需要确认的工具调用,管理器会暂停该轮工具执行,并将等待决策的调用交回应用。
一个容易忽略的细节是:如果同一条模型回复中有多个工具调用,只要其中一个需要确认,这一条回复里的工具调用都不会先执行。应用需要对该回复中的所有调用给出决策,再继续。
框架侧的典型注解:
class BookingTools {
@Tool(description = "Book a flight", requiresConfirmation = true)
String bookFlight(String flightNumber) {
return bookingService.book(flightNumber);
}
@Tool(description = "Get flight status")
String flightStatus(String flightNumber) {
return bookingService.status(flightNumber);
}
}
这里的 bookingService 是示意性的业务依赖,片段展示注解用法,不是可直接编译的完整工程。
对于已有第三方工具或 MCP 工具,也可以通过确认策略按工具名拦截,而不是要求修改每个工具的源码。
三、一次完整的人工确认流程
用户提出请求
|
模型生成工具调用
|
ToolCallingManager 判断是否需要确认
|
+-- 无需确认 --> 正常执行
|
+-- 需要确认 --> 本轮调用暂停
|
后端保存待确认状态
|
前端展示操作详情
|
+-------+-------+
| |
拒绝 同意
| |
返回拒绝信息 权限与幂等校验
| |
+-------+-------+
|
工具结果交回模型
|
生成最终回复
官方文档提供了 ToolCallDecisions:
ToolCallDecisions decisions = ToolCallDecisions.of(
ToolCallDecision.allow("call_1"),
ToolCallDecision.deny("call_2", "用户取消了该操作")
);
调用 ToolCallingManager.executeToolCalls(prompt, reply, decisions) 时,需要给该回复的每个工具调用提供对应决定。决策必须对应实际调用键;不存在的键或缺失的决策会被拒绝。
这比“前端弹出确认框后再把一句‘我同意’发给模型”更可靠,因为审批决定是应用传给执行器的结构化信息,而不是自然语言猜测。
四、最容易踩的坑:把待确认调用交给浏览器保存
一个错误设计是:
后端把完整待执行工具调用 JSON 返回前端
用户点击确认
前端原样提交 JSON 给后端
后端直接执行 JSON 中的工具和参数
这样用户可能修改工具名、目标文件路径、金额等参数。甚至有人能构造本来不应出现的工具调用。
Spring AI 官方安全说明明确强调:待执行的模型回复必须从终端用户无法修改的服务端存储中读取,不应信任客户端回传的工具调用内容。
推荐做法:
- 服务端保存模型回复及工具调用原始信息;
- 返回给浏览器的仅是
approvalId和经过过滤的展示信息; - 浏览器提交
approvalId、批准/拒绝决定; - 服务端根据当前用户身份读取原始回复;
- 核验审批人权限、过期时间、操作参数与业务状态;
- 再执行已批准的工具。
如果存在多次工具调用,应分别展示和记录每个调用的决策。
五、审批记录表如何设计?
一个最小化的关系型数据库设计:
CREATE TABLE agent_approval (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
conversation_id VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
request_hash CHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
decided_at DATETIME NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_request_hash (tenant_id, request_hash)
);
request_hash 的作用是识别同一租户内相同的待审批请求,但不能直接把它当作“全局业务幂等键”:同样的邮件内容可能是用户有意再次发送。生产系统应以具体模型回复 ID + 工具调用键 + 业务操作 ID建立明确的唯一性约束。
待确认模型回复、参数和决策详情可放在受控服务端存储中,按敏感数据级别进行加密和访问控制;不要把完整提示词、个人信息、令牌无差别记录到普通日志。
六、如何避免用户重复点击导致工具执行两次?
官方文档明确指出:确认机制不自动阻止重放。同一组决策如果被执行两次,工具可能执行两次。
最小防线是数据库状态原子转换:
UPDATE agent_approval
SET status = 'EXECUTING', decided_at = CURRENT_TIMESTAMP
WHERE id = ?
AND user_id = ?
AND status = 'PENDING'
AND expires_at > CURRENT_TIMESTAMP;
只有影响行数为 1 的请求获得本次执行权;其他重复请求返回“已处理或已过期”。
但请注意,这还不够。进程可能在“状态改为 EXECUTING”之后、外部工具调用之前宕机;也可能在工具成功后、状态写回之前宕机。
对于转账、退款等操作,还应使用业务幂等键和下游服务提供的幂等接口;对于发送通知,可采用 Outbox + 唯一业务消息键;对于无法幂等的外部动作,需要专门的补偿、查询与人工处理流程。
数据库状态锁 + 业务幂等 + 恢复机制 才是完整方案。
七、拒绝审批后怎么继续对话?
拒绝不是把模型调用简单丢掉。应把拒绝作为工具结果返回给模型,让它能解释为何没有执行,并询问是否需要其他操作。
官方提供了 ToolCallDecision.deny(key, message)。其中的 message 会作为工具结果进入模型上下文,因此应视为不可信输入,避免直接拼入高权限系统提示词。
如果用户一直不确认,还需要处理超时或放弃的情况。官方文档指出,某些模型提供商不接受“工具调用已经发出、却没有对应工具结果”的下一轮对话历史。对过期审批应生成拒绝结果或完成其他一致性收尾,不能把半截工具调用留在会话中。
八、工具执行前还需要做哪些校验?
1. 审批人身份
当前登录人是否为有权批准该操作的用户?仅凭前端传来的 userId 不够,必须从认证上下文获取。
2. 业务资源权限
例如删除文件,必须检查文件属于当前租户、当前用户具有删除权限。不能因为模型给出了文件 ID 就允许执行。
3. 参数一致性
前端展示的金额、收件人、文件名必须与服务端存储的待执行参数一致。如果用户想修改参数,应取消原审批并发起新请求,而不是悄悄替换已审批内容。
4. 工具白名单和最小权限
Agent 只应看到完成当前任务需要的工具。只读查询和高风险写入工具分开授权。
5. 审计与成本
记录谁在什么时候批准了什么动作、实际执行结果和错误码;避免在审计日志中泄露密码、访问令牌或敏感个人数据。
九、工具调用确认能覆盖所有 Agent 风险吗?
不能。它只负责“调用之前暂停并获取决策”的一部分能力。
| 风险 | 工具确认能否直接解决 | 仍需应用处理 |
|---|---|---|
| 未经批准的工具执行 | 可以建立拦截点 | 工具策略与权限配置 |
| 审批人伪造 | 不能 | 身份认证与授权 |
| 同一调用重复执行 | 不能 | 幂等键、原子状态更新 |
| 客户端篡改调用参数 | 不能 | 服务端保存原始调用 |
| 审批长时间无人处理 | 不能自动完成业务恢复 | 超时与拒绝收尾 |
| Prompt Injection | 不能完全解决 | 输入隔离、权限边界与最小授权 |
特别要注意工具循环和记忆的关系。Spring AI 参考文档说明,如果在暂停之前已经发生过其他工具调用,必须确保恢复时能够获得必要的历史消息;不同 Memory Advisor 的放置位置会影响记录范围。采用 Session Memory 或自定义历史存储时,应专门验证多轮工具调用恢复。
十、建议如何测试?
至少覆盖六种场景:
- 需要审批的工具在用户决定之前绝不执行。
- 同一回复包含只读工具与高风险工具时,暂停行为符合预期。
- 未授权用户提交审批 ID,被服务端拒绝。
- 用户重复点击批准,只产生一次业务效果。
- 审批期间服务器重启,仍能恢复或安全拒绝。
- 审批超时后,新对话不会因缺失工具结果而异常。
这些是测试设计,本文没有实际执行完整的 Spring AI 集成测试。具体 API 请以所使用的 2.1 版本文档和 Release Notes 为准;候选版 API 在 GA 前仍可能调整。
十一、常见问题
需要用户确认的工具是否必须加 @Tool 注解?
不必须。除了 requiresConfirmation,也可以使用工具元数据或 ToolCallConfirmationPolicy 按工具名、参数决定是否确认。
用户点击同意后,框架是否自动处理重复执行?
不会。官方明确提示确认机制不提供重放保护,需要应用层实现幂等和状态恢复。
能否只让转账超过 1000 元的调用进入审批?
可以使用基于工具参数的确认策略,但阈值应由可信服务端配置,不能由模型自由决定。
前端能否直接保存完整 Tool Call JSON?
不应将客户端保存的 JSON 当作可信执行来源。服务端必须保存不可被用户篡改的原始待执行回复。
Spring AI 2.1 是否已经正式 GA?
本文讨论的是 2.1 系列参考文档中的能力,不把候选发布版写成 GA。实际部署前请核对最新发布状态、兼容的 Spring Boot 版本和升级说明。
十二、从 ChatClient 到审批界面:核心 API 调用示例
下面以 Spring AI 2.1.0-RC1 为背景,演示如何把模型提出的工具调用变成人工审批。
代码依据官方文档整理,是便于理解的核心片段,并非在本次环境中已经编译通过的独立工程。
1. 定义需要批准的业务工具
class BookingTools {
private final BookingService bookingService;
BookingTools(BookingService bookingService) {
this.bookingService = bookingService;
}
@Tool(
description = "Book a flight",
requiresConfirmation = true
)
String bookFlight(String flightNo) {
return bookingService.book(flightNo);
}
@Tool(description = "Query flight status")
String flightStatus(String flightNo) {
return bookingService.status(flightNo);
}
}
这两个方法的风险不同:查询可以自动进行,真实预订必须要求确认。
如果是第三方 MCP 工具,无法在源码中添加注解,还可以按名称指定:
spring.ai.tools.confirmation.tools=deleteFile,transferMoney
配置必须使用模型真正看到的工具名称。带有前缀的工具也要匹配前缀,避免规则未生效。
2. 读取暂停状态,而不是只获取文本
ChatResponse response = chatClient.prompt()
.user("帮我预订明天的航班")
.call()
.chatResponse();
List<String> awaiting =
ToolCallDecisions.awaitingDecision(response);
if (!awaiting.isEmpty()) {
// 服务端保存本次 Prompt 和 ChatResponse
// 返回 approvalId 与脱敏展示信息
// 等待用户作出是否允许的决定
}
如果使用 .content(),你可能只得到一段空字符串,误以为模型没有回复。
需要判断暂停状态时,应读取完整响应;流式模式同样要检查最后一块响应携带的元数据。
3. 同一回复中多个工具如何处理?
AssistantMessage message =
response.getResult().getOutput();
List<String> keys =
ToolCallDecisions.toolCallKeys(message);
for (int i = 0; i < keys.size(); i++) {
var call = message.getToolCalls().get(i);
String key = keys.get(i);
// 只将展示信息与 key 绑定到审批页面
}
框架在工具调用缺少 ID 或出现重复 ID 时,可使用 #0、#1 等位置键。
必须保持原始回复与工具调用顺序,不可从浏览器重建或排序后再恢复执行。
十三、审批系统不要只保存一个“是否同意”
一个可靠的审批系统还需要区分操作的授权状态和实际执行状态。
PENDING --拒绝--> REJECTED
|
+--过期--> EXPIRED
|
+--批准--> APPROVED
|
v
EXECUTING
|
+------+------+
| |
SUCCEEDED UNKNOWN
|
人工核查
UNKNOWN 表示外部请求已经发出,但结果无法确定。比如付款接口超时,不能把它当作失败并立刻重试。
1. 关联工具调用键与租户
CREATE TABLE agent_approval_request (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
conversation_id VARCHAR(100) NOT NULL,
tool_call_key VARCHAR(100) NOT NULL,
tool_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
expires_at DATETIME(3) NOT NULL,
decided_at DATETIME(3),
created_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_call (
tenant_id, conversation_id, tool_call_key
),
KEY idx_approval (tenant_id, status, expires_at)
) ENGINE=InnoDB;
真实的 Prompt、暂停回复及操作参数应保存在客户端不可修改的服务端存储中;要按敏感程度控制访问与保留时间。
2. 用户批准时做原子条件更新
UPDATE agent_approval_request
SET status = 'APPROVED',
version = version + 1,
decided_at = CURRENT_TIMESTAMP(3)
WHERE id = ?
AND tenant_id = ?
AND user_id = ?
AND status = 'PENDING'
AND expires_at > CURRENT_TIMESTAMP(3);
只有影响行数为 1 才表示这次审批成功,重复点击不会再次从 PENDING 转成 APPROVED。
这里的 tenant_id 和 user_id 必须从认证上下文获得,而不能直接信任浏览器传来的数字。
前端只提交:
{
"approvalId": 10086,
"decision": "ALLOW"
}
不要允许客户端传回任意工具名、收件人、金额、文件路径,然后直接据此执行。
十四、审批后的恢复:原始回复与决策必须成对
Spring AI 的一条回复如果包含多个工具调用,只要其中一个需要确认,这一批调用都会先暂停。
恢复执行时,需要为本次回复中的每个调用提供决定。
ToolCallDecisions decisions = ToolCallDecisions.of(
ToolCallDecision.allow("call_1"),
ToolCallDecision.deny(
"call_2", "用户拒绝当前操作")
);
call_1 和 call_2 是演示用 ID。实际必须使用上面从原始回复取得的真实键。
如果应用自行持有会话历史,官方展示的恢复方式是:
ToolCallingChatOptions options =
ToolCallingChatOptions.builder()
.toolCallbacks(
ToolCallbacks.from(
new BookingTools(bookingService)))
.build();
Prompt prompt = new Prompt(conversation, options);
ToolExecutionResult result =
toolCallingManager.executeToolCalls(
prompt, response, decisions);
String answer = chatClient.prompt()
.messages(result.conversationHistory())
.call()
.content();
这里的 conversation 和 response 均须来自受信任的服务端存储,不能接受浏览器回传的“完整模型回复”。
如果应用使用 Memory Advisor,恢复方法和历史保存边界会不同;特别是暂停之前已发生过工具调用的场景,需要按照官方文档调整 Advisor 位置或自行持久化完整调用轨迹。
拒绝动作也必须产生对应工具结果,否则模型可能在下一轮对话时遇到历史消息不完整的问题。
十五、如何处理重复点击、超时和崩溃恢复?
数据库对审批状态的原子更新仅是第一层防线,无法保证下游业务动作恰好一次。
| 时间点 | 可能的问题 | 建议方案 |
|---|---|---|
| 暂停回复保存前宕机 | 审批来源不可信 | 禁止恢复 |
| 批准后执行前宕机 | 状态停留在 APPROVED | 持久执行队列 |
| 工具成功后回写失败 | 恢复可能重复执行 | 下游业务幂等键 |
| 用户过期后点击 | 本不应批准的请求被执行 | 条件更新检查 |
| 服务端恢复旧 Prompt | 执行权限与参数发生变化 | 重新校验权限 |
对于资金、订单、文件删除类工具,应确保业务侧有明确的请求 ID 或操作 ID。
对无法幂等的第三方 API,失败时需要先查询实际结果,必要时人工处理,不要对所有网络超时立即重发。
对于需要长时间审批的场景,还要设置明确的过期时间,并在用户开启下一轮对话前将未完成的工具调用安全收尾。
十六、上线前至少做十项安全测试
- 高风险工具在批准之前没有执行。
- 同一回复包含只读工具和写入工具时,整批正确暂停。
- 其他租户拿到审批 ID 也无法批准。
- 用户连续点击两次,只发生一次审批状态转换。
- 浏览器篡改工具名与执行参数,服务端忽略篡改内容。
- 用户拒绝后,模型收到拒绝消息。
- 服务重启后,系统能恢复原始回复或安全终止。
- 下游超时但可能成功,不会自动重复敏感操作。
- 缺失 ID 或重复 ID 的工具调用仍可正确匹配决策。
- 过期审批不会污染下一次正常对话。
一个最小化的审批更新测试思路
int changed = jdbcTemplate.update("""
UPDATE agent_approval_request
SET status = 'APPROVED'
WHERE id = ?
AND status = 'PENDING'
AND expires_at > CURRENT_TIMESTAMP(3)
""", approvalId);
assertEquals(1, changed);
这个断言只验证一次状态转换。真实并发测试还应启动两个独立事务,确保同一个审批只允许一方成功。
审批界面的验收也很重要
- 用清晰语言展示操作对象、金额和影响范围,不只展示 JSON。
- 高风险操作可以要求二次认证,而不是仅凭“确定”按钮。
- 过期审批明确提示重新发起,不允许静默恢复。
- 审计记录关联租户、会话、工具、审批人和操作结果。
- 日志不要存储访问令牌、完整个人信息或模型隐藏推理内容。
框架提供“先暂停”的能力,但安全的审批服务必须自己负责权限、幂等、恢复与追责。
总结
Agent 工具调用确认的价值,不是多了一个“你确定吗”的弹窗,而是让模型提出操作与业务系统真正执行操作之间出现一个可审计、可授权、可恢复的边界。
Spring AI 2.1 提供了暂停和恢复工具调用的基础机制;真正能否安全上线,还取决于你如何设计服务端审批记录、权限校验、业务幂等和异常恢复。

浙公网安备 33010602011771号