工具调用上线翻车?四层防护兜底
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
承接上一篇,咱们讲了怎么设计工具、怎么写好 description、怎么让模型选对工具。但工具定义写得再好,线上跑起来还是会遇到各种问题:网络超时、第三方服务挂了、用户传了非法参数、权限不足、SQL 注入攻击……
这篇文章聚焦工具调用的稳定性和安全性,从错误处理、安全防护、测试验 证、监控告警四个维度,讲清楚怎么让工具调用在生产环境稳定运行。
假设你在一家电商公司做 AI 客服系统,接入了订单查询、退货申请、年假查询等工具。某天凌晨 3 点,你被告警电话吵醒:工具调用成功率从 98% 暴跌到 60%,用户投诉激增。你打开监控一看,订单查询工具大量超时,HR 系统熔断了,还有人在尝试 SQL 注入攻击……
这种场景不是假设,是真实会发生的。工具调用不是能跑就行,而是要做到:稳定(不挂)、安全(不被攻击)、可观测(出问题能快速定位)。
下面这张图展示了生产级工具调用的完整链路,从用户请求到最终响应,每个环节都有对应的保障措施:

这张图展示了工具调用的四层防护体系:
- 工具调用层:参数校验 → 超时控制 → 工具执行,保证基本流程正确
- 容错保障层:重试 → 降级 → 熔断,保证系统不挂
- 安全防护层:权限控制 → 防注入 → 脱敏,保证不被攻击
- 可观测性层:日志 → 指标 → 追踪 → 告警,保证问题能快速定位
接下来咱们逐个展开讲。
工具调用的错误处理
工具调用会遇到各种错误:网络超时、参数错误、权限不足、第三方服务挂了……错误处理做得好,系统才稳定。
1. 超时控制
每个工具调用都要设置超时时间,避免无限等待。
推荐超时时间:
- 查询类工具:5~10 秒
- 操作类工具:10~30 秒
- 复杂计算:30~60 秒
Java 实现(使用 CompletableFuture):
public ToolResult getUserAnnualLeaveWithTimeout(String userId) {
try {
CompletableFuture<ToolResult> future = CompletableFuture.supplyAsync(() -> {
return getUserAnnualLeave(userId);
});
// 设置 10 秒超时
return future.get(10, TimeUnit.SECONDS);
} catch (TimeoutException e) {
return ToolResult.error("TIMEOUT", "查询超时,请稍后再试");
} catch (Exception e) {
return ToolResult.error("SYSTEM_ERROR", "系统错误:" + e.getMessage());
}
}
超时后返回友好的错误信息,模型可以告诉用户:"系统繁忙,请稍后再试。"
2. 重试策略
哪些错误应该重试?
- 网络错误(连接超时、连接被拒绝)
- 超时错误
- 服务暂时不可用(HTTP 503)
- 限流错误(HTTP 429)
哪些错误不应该重试?
- 参数错误(HTTP 400)
- 权限错误(HTTP 403)
- 资源不存在(HTTP 404)
- 业务逻辑错误(余额不足、库存不足)
重试次数和间隔:指数退避
- 第 1 次重试:等待 1 秒
- 第 2 次重试:等待 2 秒
- 第 3 次重试:等待 4 秒
Java 实现(手写重试逻辑):
public ToolResult callExternalApiWithRetry(String url) {
int maxRetries = 3;
int retryCount = 0;
long waitTime = 1000; // 初始等待 1 秒
while (retryCount < maxRetries) {
try {
return callExternalApi(url);
} catch (TimeoutException | IOException e) {
retryCount++;
if (retryCount >= maxRetries) {
return ToolResult.error("SYSTEM_ERROR", "调用失败,已重试 " + maxRetries + " 次");
}
try {
Thread.sleep(waitTime);
waitTime *= 2; // 指数退避
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return ToolResult.error("SYSTEM_ERROR", "重试被中断");
}
} catch (IllegalArgumentException e) {
// 参数错误,不重试
return ToolResult.error("INVALID_PARAMETER", e.getMessage());
}
}
return ToolResult.error("SYSTEM_ERROR", "未知错误");
}
也可以用 Spring Retry:
@Retryable(
value = {TimeoutException.class, IOException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public ToolResult callExternalApi(String url) {
// ...
}
3. 降级策略
工具调用失败时,不要让整个对话失败,要有降级方案。
降级方案 1:返回兜底信息
public ToolResult getUserAnnualLeave(String userId) {
try {
// 调用 HR 系统查询年假
return hrService.getAnnualLeave(userId);
} catch (Exception e) {
// 降级:返回兜底信息
return ToolResult.error(
"SYSTEM_ERROR",
"系统繁忙,无法查询年假信息。您可以访问 HR 系统(https://hr.example.com)查看详细信息。"
);
}
}
模型拿到这个错误后,会告诉用户:“抱歉,系统繁忙,无法查询年假信息。您可以访问 HR 系统(https://hr.example.com)查看详细信息。”
降级方案 2:使用缓存数据
public ToolResult getUserAnnualLeave(String userId) {
try {
// 调用 HR 系统查询年假
ToolResult result = hrService.getAnnualLeave(userId);
// 缓存结果
cache.put(userId, result, 5, TimeUnit.MINUTES);
return result;
} catch (Exception e) {
// 降级:使用缓存数据
ToolResult cached = cache.get(userId);
if (cached != null) {
return cached;
}
return ToolResult.error("SYSTEM_ERROR", "系统繁忙,请稍后再试");
}
}
降级方案 3:引导用户使用其他方式
public ToolResult submitExpense(String requestId, double amount, String reason) {
try {
return expenseService.submit(requestId, amount, reason);
} catch (Exception e) {
return ToolResult.error(
"SYSTEM_ERROR",
"提交失败,请稍后再试。您也可以通过邮件(finance@example.com)提交报销申请。"
);
}
}
4. 熔断机制
当工具持续失败时,暂时停止调用该工具,避免雪崩。
熔断条件:
- 连续失败 N 次(如 5 次)
- 失败率超过 X%(如 50%)
熔断状态:
- 关闭(Closed):正常调用
- 打开(Open):停止调用,直接返回错误
- 半开(Half-Open):尝试恢复,允许少量请求通过
使用 Resilience4j 实现熔断:
@CircuitBreaker(name = "hrService", fallbackMethod = "getUserAnnualLeaveFallback")
public ToolResult getUserAnnualLeave(String userId) {
return hrService.getAnnualLeave(userId);
}
public ToolResult getUserAnnualLeaveFallback(String userId, Exception e) {
return ToolResult.error(
"SYSTEM_ERROR",
"HR 系统暂时不可用,请稍后再试"
);
}
配置熔断参数(application.yml):
resilience4j:
circuitbreaker:
instances:
hrService:
failure-rate-threshold: 50 # 失败率超过 50% 触发熔断
wait-duration-in-open-state: 60s # 熔断打开后等待 60 秒
sliding-window-size: 10 # 滑动窗口大小 10 次调用
minimum-number-of-calls: 5 # 最少 5 次调用才计算失败率
5. 错误处理策略对比
| 策略 | 适用场景 | 实现方式 | 推荐配置 |
|---|---|---|---|
| 超时控制 | 所有工具调用 | CompletableFuture.get(timeout) | 查询类 5 |
| 重试策略 | 网络错误、超时、限流 | 指数退避(1s → 2s → 4s) | 最多重试 3 次,不重试参数错误 |
| 降级方案 | 第三方服务不可用 | 返回兜底信息/使用缓存/引导用户 | 缓存 TTL 5 分钟 |
| 熔断机制 | 持续失败场景 | Resilience4j CircuitBreaker | 失败率 >50% 触发,等待 60 秒恢复 |
工具调用的安全性
1. 权限控制
基于用户身份的权限校验,在工具执行前校验,不要依赖模型的判断。
反例:
public ToolResult getUserAnnualLeave(String userId) {
// 没有权限校验,任何人都能查任何人的年假
User user = userRepository.findById(userId);
return ToolResult.success(user.getAnnualLeave());
}
正例:
public ToolResult getUserAnnualLeave(String userId, String currentUserId) {
// 权限校验:只能查自己的年假
if (!userId.equals(currentUserId)) {
return ToolResult.error(
"PERMISSION_DENIED",
"您只能查询自己的年假信息"
);
}
User user = userRepository.findById(userId);
return ToolResult.success(user.getAnnualLeave());
}
更复杂的权限控制:
public ToolResult getUserAnnualLeave(String userId, String currentUserId, Set<String> roles) {
// 规则 1:用户可以查自己的年假
if (userId.equals(currentUserId)) {
User user = userRepository.findById(userId);
return ToolResult.success(user.getAnnualLeave());
}
// 规则 2:HR 可以查所有人的年假
if (roles.contains("HR")) {
User user = userRepository.findById(userId);
return ToolResult.success(user.getAnnualLeave());
}
// 规则 3:经理可以查下属的年假
if (roles.contains("MANAGER")) {
User user = userRepository.findById(userId);
if (user.getManagerId().equals(currentUserId)) {
return ToolResult.success(user.getAnnualLeave());
}
}
return ToolResult.error(
"PERMISSION_DENIED",
"您没有权限查询该用户的年假信息"
);
}
2. 参数校验和防注入
SQL 注入
反例:
public ToolResult searchUsers(String keyword) {
// 直接拼接 SQL,存在 SQL 注入风险
String sql = "SELECT * FROM users WHERE name LIKE '%" + keyword + "%'";
return jdbcTemplate.query(sql, new UserRowMapper());
}
攻击者可以传入 keyword = "'; DROP TABLE users; --",导致数据库被删除。
正例:
public ToolResult searchUsers(String keyword) {
// 使用参数化查询
String sql = "SELECT * FROM users WHERE name LIKE ?";
return jdbcTemplate.query(sql, new UserRowMapper(), "%" + keyword + "%");
}
路径穿越
反例:
public ToolResult readFile(String filename) {
// 没有校验文件路径,存在路径穿越风险
File file = new File("/data/files/" + filename);
return ToolResult.success(Files.readString(file.toPath()));
}
攻击者可以传入 filename = "../../etc/passwd",读取系统敏感文件。
正例:
public ToolResult readFile(String filename) {
// 校验文件名,不允许包含路径分隔符
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ToolResult.error("INVALID_PARAMETER", "文件名不合法");
}
File file = new File("/data/files/" + filename);
if (!file.exists() || !file.isFile()) {
return ToolResult.error("RESOURCE_NOT_FOUND", "文件不存在");
}
return ToolResult.success(Files.readString(file.toPath()));
}
XSS(跨站脚本攻击)