
本文同步发布于 CSDN,聚焦 AI Agent 工具调用从"演示能跑"到"生产敢上"的可靠性工程。
一、为什么工具调用是 Agent 从 Demo 走向生产最脆弱的一环
最近 CSDN 热榜上有一篇文章引发广泛讨论:《AI Agent 工具调用如何更可靠?Spring AI + MCP 实战:参数校验、权限校验与人工确认》。它戳中了一个所有把 Agent 推向生产环境的团队都绕不开的问题:
模型负责理解意图、提出下一步动作,但系统必须掌握"谁能做、做什么、何时真正执行"的决定权。
一个真实场景:运维团队给 Agent 接了一个"重启服务"的 MCP 工具,模型在一次多轮对话中误解了用户意图,把 restart api-gateway 理解成 restart database,工具参数直接透传给 MCP Server 执行------生产数据库重启,P0 事故。这不是段子,是过去一年里无数团队真实踩过的坑。
问题出在哪?不在模型不够聪明,而在于调用边界没有设计清楚 。Spring AI 的工具调用链路本身只负责"执行",它默认信任模型产出的一切:参数不校验、身份不验证、执行不确认。MCP 协议把工具能力标准化了,但MCP 协议本身不包含权限控制机制------它把安全责任交给了 Host(Spring AI 官方文档明确说明了这一设计取舍)。
这篇文章我们围绕一个完整的企业运维 Agent 项目 OpsPilot ,把工具调用可靠性拆成三道防线逐一落地:参数校验、权限校验、人工确认(HITL)。全部基于 Spring AI 2.0 + MCP Client/Server 真实 API,不是 Hello World。
二、先看清楚 Spring AI 工具调用的执行链路
要加固边界,先得看清边界在哪。Spring AI 的工具调用核心链路是这样的:
text
ChatModel.chat(prompt)
└─ DefaultToolCallingManager.executeToolCalls(prompt, chatResponse)
├─ 1. ToolExecutionEligibilityChecker 判断是否需要执行工具
│ (isInternalToolExecutionEnabled && chatResponse.hasToolCalls())
├─ 2. 遍历 AssistantMessage.getToolCalls()
├─ 3. findToolCallback(toolName) 解析出对应 ToolCallback
│ (MethodToolCallback 反射 / FunctionToolCallback / SyncMcpToolCallback)
├─ 4. 解析 JSON 参数 → callback.call(arguments, toolContext) → 执行
└─ 5. buildConversationHistoryAfterToolExecution()
把 AssistantMessage(工具调用请求) 和 ToolResponseMessage(结果)
追加进历史,交回模型继续推理
几个关键事实:
DefaultToolCallingManager.executeToolCall()从模型产出的 JSON 字符串解析参数后,直接反射调用 Java 方法或转发给 MCP Server 。中间没有任何校验、没有任何权限判断。也就是说,模型 hallucinate 出{"path": "../../etc/passwd"},这个参数会原样到达你的工具方法。- MCP 工具通过
SyncMcpToolCallbackProvider桥接 :Spring AI 的 MCP Client Boot Starter 启动时连接 MCP Server,把tools/list拿到的工具元数据(name/description/inputSchema)包装成ToolCallback列表,注入到ToolCallingChatOptions中。tools/call请求的参数是模型生成的,MCP Server 端拿到的就是这份未经验证的 JSON。 ToolContext是唯一可信的旁路数据通道 :它由调用方代码构造,模型接触不到。这意味着"真实用户身份"应该放进ToolContext,而不是让模型在参数里自报"我是管理员"。
边界看清了,三道防线的位置也就清楚了:参数校验卡在解析后执行前,权限校验卡在身份可信通道上,人工确认卡在执行前的最后一道闸门。
三、实战:OpsPilot 运维 Agent 的三道防线
3.1 项目骨架
技术栈:Spring Boot 4 + Spring AI 2.0 + MCP Client Boot Starter(对接内部 MCP Server)+ Spring Security。核心依赖:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置文件:
yaml
spring:
ai:
mcp:
client:
enabled: true
type: SYNC
toolcallback:
enabled: true # 自动把 MCP 工具注册为 ToolCallback
sse:
connections:
ops-server:
url: https://mcp-ops.internal:8443
启动后,MCP Server 上的 restartService、queryServiceStatus、scaleDeployment、execDiagnostics 等工具自动出现在 Spring 容器的 ToolCallbackProvider 里。
3.2 防线一:参数校验------Schema 是给模型看的,校验是给系统做的
MCP 的 inputSchema 帮助模型理解参数怎么填,但Schema 约束不等于业务校验。模型可以填出格式完全合法但语义危险的参数:路径穿越、超范围的 replica 数、不在白名单的服务名。
第一层:服务名白名单 + 规模上限,在工具入口处强制收敛参数空间:
java
@Service
public class OpsToolGuard {
// 只允许操作注册在案的巡检目标,收敛模型可触达的爆炸半径
private static final Set<String> ALLOWED_SERVICES = Set.of(
"api-gateway", "order-service", "inventory-service");
private static final int MAX_REPLICAS = 20;
/** 返回空 Optional 表示校验通过;否则返回给模型的拒绝原因 */
public Optional<String> validateRestart(RestartRequest req) {
if (!ALLOWED_SERVICES.contains(req.serviceName())) {
return Optional.of(
"服务 %s 不在运维白名单内,允许的操作目标: %s"
.formatted(req.serviceName(), ALLOWED_SERVICES));
}
if (req.confirmToken() == null || req.confirmToken().isBlank()) {
return Optional.of("缺少 confirmToken,请先通过预览接口获取确认令牌");
}
return Optional.empty();
}
public Optional<String> validateScale(String service, int replicas) {
if (!ALLOWED_SERVICES.contains(service)) {
return Optional.of("服务 %s 不在运维白名单内".formatted(service));
}
if (replicas < 0 || replicas > MAX_REPLICAS) {
return Optional.of("replicas 超出允许范围 [0, %d],当前值: %d"
.formatted(MAX_REPLICAS, replicas));
}
return Optional.empty();
}
}
第二层:路径与文件类参数做规范化穿越检查 。文件类工具是参数注入的重灾区,模型可能生成 ../../etc/passwd 这类路径:
java
public final class PathGuard {
private final Path baseDir;
public PathGuard(Path baseDir) throws IOException {
this.baseDir = baseDir.toRealPath();
}
/** 把模型给的任意路径约束在 baseDir 内,越界即拒绝 */
public Path resolve(String userPath) throws IOException {
Path resolved = baseDir.resolve(userPath).normalize().toRealPath();
if (!resolved.startsWith(baseDir)) {
throw new SecurityException(
"路径越界: " + userPath + " 解析后不在允许目录内");
}
return resolved;
}
}
两个工程细节值得强调:
- 拒绝要以"工具结果"的形式返回给模型,而不是抛异常终止 。Spring AI 的
DefaultToolCallingManager对工具异常的处理是中断整轮循环,模型失去"修正后重试"的机会。校验失败时返回一句描述性文本,模型会理解并换一种问法或提示用户,交互体验完全不同。 - 工具方法返回结构化对象而不是拼好的 String。Spring AI 会自动把 Java 对象序列化成结构化内容,模型能基于字段推理,编造内容的概率大幅降低。
3.3 防线二:权限校验------身份来自认证上下文,绝不接受模型自报
这是最容易出事、也最容易被忽视的边界。反面做法长这样:
java
// ❌ 绝对禁止:把"操作人"作为工具参数交给模型填
@Tool(description = "以指定用户身份重启服务")
public String restartService(String operatorId, String serviceName) { ... }
模型填什么都行,这等于没有权限系统。正确做法是:用户身份从认证上下文取(SecurityContextHolder),通过 ToolContext 旁路传入工具方法:
java
@RestController
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient.Builder builder,
ToolCallbackProvider mcpTools) {
this.chatClient = builder
.defaultToolCallbacks(mcpTools)
.build();
}
@PostMapping("/api/chat")
public Flux<String> chat(@RequestBody ChatRequest req,
Authentication auth) {
// 身份从 Spring Security 上下文获取,模型无法伪造
ToolContext ctx = new ToolContext(Map.of(
"userId", auth.getName(),
"roles", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList()));
return chatClient.prompt()
.user(req.message())
.toolContext(ctx) // ToolContext 对模型不可见,是可信通道
.stream()
.content();
}
}
工具方法侧,从 ToolContext 取身份做 RBAC 判断:
java
@Tool(name = "scaleDeployment",
description = "扩缩容指定服务的 Deployment 副本数")
public String scaleDeployment(
@ToolParam(description = "服务名") String service,
@ToolParam(description = "目标副本数") int replicas,
ToolContext ctx) {
String userId = (String) ctx.getContext().get("userId");
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) ctx.getContext().get("roles");
// 写操作要求 SRE 角色;工具描述和 Prompt 都不能替代代码里的权限判断
if (!roles.contains("ROLE_SRE")) {
return "拒绝: 用户 %s 无 SRE 角色,不允许执行扩缩容操作".formatted(userId);
}
Optional<String> invalid = guard.validateScale(service, replicas);
if (invalid.isPresent()) {
return invalid.get(); // 校验拒绝也以工具结果返回,让模型可自我修正
}
return opsService.scale(service, replicas, userId);
}
如果你自己就是 MCP Server 的提供方(spring-ai-starter-mcp-server-webmvc),还可以直接用方法级安全注解------Spring AI 的 MCP Server 安全模块支持在 @McpTool 方法上使用 @PreAuthorize,并在工具方法内通过 SecurityContextHolder 直接访问当前认证信息:
java
@McpTool(name = "restartService",
description = "重启指定服务(高危操作)")
@PreAuthorize("hasRole('SRE')")
public String restartService(
@ToolParam(description = "服务名") String service) {
var auth = SecurityContextHolder.getContext().getAuthentication();
return opsService.restart(service, auth.getName());
}
再补一层场景化工具白名单 :不要盲目信任 MCP Server 暴露的所有工具。按会话场景动态构造 ToolCallbackProvider,普通客服会话根本不该看到 restartService:
java
public ToolCallbackProvider toolsForScenario(String scenario) {
// 按标签过滤客户端注册的 MCP 工具,读写分离,危险工具默认不下发
List<McpSyncClient> clients = clientRegistry.getByTag(scenario);
return SyncMcpToolCallbackProvider.builder()
.mcpClients(clients)
.build();
}
只读查询工具自动执行,读写分离,危险操作默认不下发或走审批------工具下发的最小化原则,本身就是最便宜的权限防线。
3.4 防线三:HITL 人工确认------预览、审批、幂等执行
Spring AI 官方仓库的 Tool Approval Strategy 设计提案(discussion #4878) 提出了 ToolMetadata.requiresApproval() + ToolApprovalStrategy 的方案:DefaultToolCallingManager 在执行前咨询审批策略,拒绝则把结构化拒绝原因回传给模型而不执行工具。这个方向还在演进,生产上现在更常见的做法是装饰 ToolCallingManager,在工具循环里插一道审批闸门:
java
public class HitlToolCallingManager implements ToolCallingManager {
private final ToolCallingManager delegate; // DefaultToolCallingManager
private final ApprovalRegistry approvals;
private final Set<String> gatedTools; // 需人审的工具清单
public HitlToolCallingManager(ToolCallingManager delegate,
ApprovalRegistry approvals,
Set<String> gatedTools) {
this.delegate = delegate;
this.approvals = approvals;
this.gatedTools = gatedTools;
}
@Override
public ToolExecutionResult executeToolCalls(Prompt prompt,
ChatResponse chatResponse) {
List<AssistantMessage.ToolCall> calls =
chatResponse.getResult().getOutput().getToolCalls();
Map<String, String> results = new LinkedHashMap<>();
for (AssistantMessage.ToolCall call : calls) {
if (gatedTools.contains(call.name())) {
// 高危工具:注册审批单,阻塞等待人工决策
String requestId = approvals.register(call);
ApprovalDecision decision =
approvals.await(requestId, Duration.ofMinutes(2));
// fail-safe:超时/拒绝一律不放行
if (decision != ApprovalDecision.APPROVE) {
results.put(call.id(),
"工具执行被%s: 已记录审批单 %s,请勿重试此工具"
.formatted(decision == ApprovalDecision.TIMEOUT
? "超时" : "人工拒绝", requestId));
continue;
}
}
// 低危工具 / 已批准:交给真正的执行器
ToolExecutionResult r = delegate.executeToolCalls(prompt,
chatResponseWithSingleCall(chatResponse, call));
r.conversationHistory().stream()
.filter(ToolResponseMessage.class::isInstance)
.map(ToolResponseMessage.class::cast)
.flatMap(m -> m.getResponses().stream())
.forEach(resp -> results.putIfAbsent(resp.id(),
resp.textData()));
}
return rebuildResult(prompt, calls, results);
}
}
审批登记器用 CompletableFuture 阻塞等待前端回调,fail-safe 是底线:任何非 APPROVE 路径都不放行:
java
@Component
public class ApprovalRegistry {
private final Map<String, PendingApproval> pending = new ConcurrentHashMap<>();
public record PendingApproval(AssistantMessage.ToolCall call,
CompletableFuture<ApprovalDecision> future,
Instant deadline) {}
public String register(AssistantMessage.ToolCall call) {
String requestId = UUID.randomUUID().toString();
pending.put(requestId, new PendingApproval(call,
new CompletableFuture<>(), Instant.now().plus(Duration.ofMinutes(2))));
// 通过 WebSocket/SSE 把"要调什么工具、什么参数"推给前端渲染审批按钮
eventPublisher.publish(new ApprovalRequestEvent(requestId,
call.name(), call.arguments()));
return requestId;
}
public ApprovalDecision await(String requestId, Duration timeout) {
try {
return pending.get(requestId).future().get(timeout.toMillis(),
TimeUnit.MILLISECONDS);
} catch (Exception e) {
return ApprovalDecision.TIMEOUT; // 超时不执行
} finally {
pending.remove(requestId);
}
}
/** 前端回调入口 */
public void complete(String requestId, ApprovalDecision decision) {
PendingApproval p = pending.get(requestId);
if (p != null) p.future().complete(decision);
}
}
最后是执行端的幂等与状态机 。审批通过不等于执行成功------审批窗口内环境可能已变化。真正执行写操作时用条件更新 + 幂等键 + 状态机防止重复操作:
java
@Transactional
public String restart(String service, String userId, String idempotencyKey) {
// 1. 幂等键去重:同一审批单重复回调直接返回原结果
Optional<OpsRecord> dup = opsRepo.findByIdempotencyKey(idempotencyKey);
if (dup.isPresent()) return "操作已执行过(幂等拦截),审批单: " + idempotencyKey;
// 2. 状态机条件更新:只允许从 IDLE/FAILED 进入 RESTARTING
int updated = opsRepo.casTransition(service,
Set.of(ServiceState.IDLE, ServiceState.FAILED),
ServiceState.RESTARTING, userId, Instant.now());
if (updated == 0) {
return "服务当前状态不允许重启(可能已有其他操作在进行)";
}
// 3. Outbox 模式保证业务操作与审计记录最终一致
opsRepo.save(new OpsRecord(service, userId, idempotencyKey, Instant.now()));
outbox.publish(new ServiceRestartRequested(service, userId, idempotencyKey));
return "重启指令已下发";
}
四、生产踩坑清单
这一套三防线落地过程中,真实踩过的坑比代码多得多:
- 把权限判断写在 Prompt 里。在 system prompt 里写"只有管理员才能调用重启工具"是自欺欺人------Prompt 注入一句话就能绕过。工具描述、参数 Schema 和 Prompt 都不能替代代码中的权限判断,权限必须在 Java 代码里做。
ToolContext忘传导致 NPE 而不是拒绝 。非 Web 入口(如定时任务、消息队列消费者)调 ChatClient 时容易漏掉toolContext(),工具方法里取身份拿到 null。生产做法:工具入口统一断言身份存在,缺失直接返回拒绝文本,同时打告警日志。- 审批等待阻塞虚拟线程,把平台线程拖死 。
future.get()阻塞在传统线程池上,几个并发审批就把池占满,整个 Agent 假死。解法:审批等待必须跑在虚拟线程上(spring.threads.virtual.enabled=true),或者干脆改造为完全异步------工具循环挂起、审批回调后恢复。 - 审批窗口内环境漂移。用户花 90 秒审批"扩容到 10 副本",期间另一个 SRE 已手动扩过。没有条件更新就会重复执行。所有写操作必须带状态机条件更新 + 幂等键,幂等键用审批单 requestId 贯穿。
- 流式模式下审批事件与 token 流交错。SSE 输出流里混入审批请求事件,前端解析炸掉。事件总线要分通道:token 流和审批事件用不同 event type,前端按类型分发渲染。
- 只做方法级校验,漏了参数级注入 。服务名白名单挡住了越权目标,但模型生成的 JSON 里塞进
{"serviceName":"api-gateway\"; rm -rf /"}这类畸形值,透传给下游 CLI 类工具就是命令注入。所有最终落到 shell/SQL/路径的参数必须再过一遍各自的注入防线,不能因为过了白名单就放行。 - 审批超时语义没对齐。超时后既不放行也不明确告知模型,模型反复重试同一工具制造审批单风暴。超时返回文本要明确"该操作等待审批已超时,请勿重试,请向用户说明情况"。
- MCP HTTP Server 裸奔上线 。Spring AI 的 MCP Server 默认不带业务认证,
/sse和/mcp端点公开可达时,任何能连上的人都能触发工具。上线前必须:token 认证(自定义 Header 透传)→@PreAuthorize方法级校验 → 网络隔离(只在内网/服务网格内可达)。三件套缺一不可。 - 审计日志只记成功不记拒绝。排查"模型乱调用"时,拒绝记录才是关键证据。参数校验拒绝数、权限拒绝数、审批超时数、重复确认拦截数,都应该进 Micrometer 指标和审计表------不只是合规要求,更是你调优工具白名单和风险分级的数据来源。
五、总结:模型提出动作,系统掌握执行权
回顾这三道防线的分工:
- 参数校验(防线一):白名单收敛爆炸半径、路径规范化防穿越、拒绝以工具结果形式回传让模型可自我修正;
- 权限校验 (防线二):身份从认证上下文取、
ToolContext旁路传参、RBAC 方法级校验、场景化工具白名单实现读写分离与最小下发; - 人工确认(防线三):高危工具预览审批、fail-safe 超时不放行、状态机 + 条件更新 + 幂等键防重复执行。
这套设计背后的价值观只有一句话:**模型可以负责理解意图和提出下一步,但"谁能做、做什么、何时真正执行"的决定权,必须牢牢握在系统手里。**工具描述和 Prompt 永远是"建议",代码校验才是"法律"。
对 Java 工程师来说,这恰恰是最熟悉的领域------参数校验是 @Validated 的日常,权限是 Spring Security 的日常,幂等和状态机是分布式系统的日常。Agent 时代的工具调用可靠性工程,本质上就是把后端工程师十年积累的边界防御经验,平移到"模型是新的不可信调用方"这个新前提上。你不需要转 Python,你过去写的每一道防线,正是 AI Agent 落地最稀缺的能力。
参考资料:
-
Spring AI 2.0.x 官方文档:Tool Calling / MCP Client / MCP Server 安全模块
-
spring-projects/spring-ai discussion #4878: Tool Approval Strategy for Spring AI Tool Calling
-
Spring AI Community Playground Tutorial 11: Approve a Tool in Chat(HITL 风险分级与超时 fail-safe)
-
CSDN 热榜《AI Agent 工具调用如何更可靠?Spring AI + MCP 实战》