工具调用上线翻车?四层防护兜底
作者:程序员马丁
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(跨站脚本攻击)
如果工具返回值会在网页上显示,要对特殊字符进行转义。
public ToolResult getUserInfo(String userId) {
User user = userRepository.findById(userId);
// 对用户输入的内容进行 HTML 转义
String safeName = StringEscapeUtils.escapeHtml4(user.getName());
return ToolResult.success(Map.of("name", safeName));
}
3. 敏感信息脱敏
工具返回值中的敏感信息要脱敏,避免泄露。
public ToolResult getUserInfo(String userId) {
User user = userRepository.findById(userId);
// 手机号脱敏:138****1234
String maskedPhone = user.getPhone().replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
// 身份证号脱敏:110***********1234
String maskedIdCard = user.getIdCard().replaceAll("(\\d{3})\\d{11}(\\d{4})", "$1***********$2");
return ToolResult.success(Map.of(
"name", user.getName(),
"phone", maskedPhone,
"idCard", maskedIdCard
));
}
日志中也不要记录敏感信息:
public ToolResult submitExpense(String requestId, double amount, String reason) {
// ❌ 错误:日志中记录了敏感信息
log.info("提交报销:requestId={}, amount={}, reason={}", requestId, amount, reason);
// ✅ 正确:日志中不记录敏感信息
log.info("提交报销:requestId={}", requestId);
// ...
}
4. 审计日志
记录工具调用的完整信息,用于安全审计和问题排查。
public ToolResult getUserAnnualLeave(String userId, String currentUserId) {
long startTime = System.currentTimeMillis();
try {
// 权限校验
if (!userId.equals(currentUserId)) {
auditLog.warn("权限拒绝:用户 {} 尝试查询用户 {} 的年假", currentUserId, userId);
return ToolResult.error("PERMISSION_DENIED", "您只能查询自己的年假信息");
}
// 执行查询
User user = userRepository.findById(userId);
ToolResult result = ToolResult.success(user.getAnnualLeave());
// 记录审计日志
long duration = System.currentTimeMillis() - startTime;
auditLog.info("工具调用成功:function=getUserAnnualLeave, userId={}, duration={}ms", userId, duration);
return result;
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
auditLog.error("工具调用失败:function=getUserAnnualLeave, userId={}, duration={}ms, error={}", userId, duration, e.getMessage());
return ToolResult.error("SYSTEM_ERROR", "系统错误");
}
}
审计日志应该包含:
- 谁(currentUserId)
- 什么时候(timestamp)
- 调用了什么工具(functionName)
- 传了什么参数(arguments,敏感信息要脱敏)
- 返回了什么结果(result,敏感信息要脱敏)
- 耗时(duration)
- 是否成功(success)
这里可以查看咱们牛券里的 mzt-biz-log 操作日志文章,详情查看:引入日志组件优雅记录操作日志
监控与告警:如何快速发现和定位问题
工具调用上线后,要有完善的监控和告警机制,出问题能第一时间发现和定位。
1. 指标监控:四个黄金指标
监控工具调用的四个核心指标:调用量、成功率、耗时、错误分布。
指标 1:调用量(QPS)
- 定义:每秒工具调用次数
- 监控维度:总调用量、按工具名分组、按用户分组
- 告警阈值:QPS 突增 50%(可能是攻击)或突降 50%(可能是服务挂了)
指标 2:成功率
- 定义:成功调用次数 / 总调用次数
- 监控维度:总成功率、按工具名分组、按错误码分组
- 告警阈值:成功率 < 95%
指标 3:耗时(P50 / P95 / P99)
- 定义:工具调用的响应时间
- 监控维度:P50(中位数)、P95(95% 的请求)、P99(99% 的请求)
- 告警阈值:P95 > 1s 或 P99 > 2s
指标 4:错误分布
- 定义:各类错误的占比
- 监控维度:按错误码分组(TIMEOUT、PERMISSION_DENIED、INVALID_PARAMETER、SYSTEM_ERROR)
- 告警阈值:某类错误占比 > 10%
Java 实现:使用 Micrometer + Prometheus
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
@Component
public class ToolCallMetrics {
private final MeterRegistry meterRegistry;
public ToolCallMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
public void recordToolCall(String toolName, boolean success, long duration, String errorCode) {
// 记录调用量
Counter.builder("tool.call.total")
.tag("tool", toolName)
.tag("success", String.valueOf(success))
.register(meterRegistry)
.increment();
// 记录耗时
Timer.builder("tool.call.duration")
.tag("tool", toolName)
.register(meterRegistry)
.record(duration, TimeUnit.MILLISECONDS);
// 记录错误
if (!success) {
Counter.builder("tool.call.error")
.tag("tool", toolName)
.tag("errorCode", errorCode)
.register(meterRegistry)
.increment();
}
}
}
Prometheus 查询语句:
# 总调用量(QPS)
rate(tool_call_total[1m])
# 成功率
sum(rate(tool_call_total{success="true"}[5m])) / sum(rate(tool_call_total[5m]))
# P95 耗时
histogram_quantile(0.95, rate(tool_call_duration_bucket[5m]))
# 错误分布
sum by (errorCode) (rate(tool_call_error[5m]))
Grafana 监控大盘:
创建一个 Grafana Dashboard,包含以下面 板:
- 总览面板:总调用量、总成功率、平均耗时
- 工具面板:按工具名分组的调用量、成功率、耗时
- 错误面板:错误分布饼图、错误趋势折线图
- 用户面板:Top 10 调用用户、异常用户(调用失败率高)
2. 链路追踪:定位慢调用和异常
链路追踪能看到一次工具调用的完整链路:从用户请求 → 模型调用 → 工具执行 → 第三方服务 → 返回结果。
使用 OpenTelemetry 实现链路追踪:
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;
@Component
public class ToolService {
private final Tracer tracer;
public ToolService(Tracer tracer) {
this.tracer = tracer;
}
public ToolResult getUserAnnualLeave(String userId, String currentUserId) {
// 创建 Span
Span span = tracer.spanBuilder("getUserAnnualLeave").startSpan();
try (Scope scope = span.makeCurrent()) {
// 添加属性
span.setAttribute("userId", userId);
span.setAttribute("currentUserId", currentUserId);
// 权限校验
if (!userId.equals(currentUserId)) {
span.setAttribute("error", "PERMISSION_DENIED");
return ToolResult.error("PERMISSION_DENIED", "您只能查询自己的年假信息");
}
// 调用 HR 系统
Span hrSpan = tracer.spanBuilder("hrService.getAnnualLeave").startSpan();
try (Scope hrScope = hrSpan.makeCurrent()) {
User user = hrService.getAnnualLeave(userId);
return ToolResult.success(user.getAnnualLeave());
} finally {
hrSpan.end();
}
} catch (Exception e) {
span.recordException(e);
span.setAttribute("error", "SYSTEM_ERROR");
return ToolResult.error("SYSTEM_ERROR", "系统错误");
} finally {
span.end();
}
}
}
链路追踪效果:
TraceID: abc123def456
Span 1: POST /api/chat (500ms)
├─ Span 2: chatService.chat (480ms)
│ ├─ Span 3: modelService.call (200ms) ← 模型调用
│ ├─ Span 4: getUserAnnualLeave (250ms) ← 工具执行
│ │ └─ Span 5: hrService.getAnnualLeave (240ms) ← 第三方服务
│ └─ Span 6: modelService.call (20ms) ← 第二轮模型调用
└─ Span 7: response.write (10ms)
通过链路追踪,可以快速定位慢调用:
- 如果 Span 4(工具执行)耗时长,说明工具本身有问题
- 如果 Span 5(第三方服务)耗时长,说明第三方服务慢,需要加缓存或熔断
3. 告警机制:第一时间发现问题
告警要做到:及时(问题发生后 1 分钟内通知)、准确(不误报)、可操作(告警信息包含定位线索)。
告警规则:
| 告警项 | 触发条件 | 级别 | 通知方式 |
|---|---|---|---|
| 工具调用成功率低 | 5 分钟内成功率 < 95% | P1(严重) | 电话 + 短信 + 企业微信 |
| 工具调用耗时高 | 5 分钟内 P95 > 2s | P2(重要) | 短信 + 企业微信 |
| 工具调用量异常 | 5 分钟内 QPS 突增/突降 50% | P2(重要) | 企业微信 |
| 某工具持续失败 | 某工具 5 分钟内失败率 > 50% | P1(严重) | 电话 + 短信 + 企业微信 |
| 熔断器打开 | 某工具熔断器状态变为 OPEN | P1(严重) | 电话 + 短信 + 企业微信 |
| 错误码异常 | 某错误码 5 分钟内占比 > 20% | P2(重要) | 企业微信 |
Prometheus AlertManager 告警规则:
groups:
- name: tool_call_alerts
interval: 1m
rules:
# 成功率低
- alert: ToolCallSuccessRateLow
expr: |
sum(rate(tool_call_total{success="true"}[5m])) / sum(rate(tool_call_total[5m])) < 0.95
for: 1m
labels:
severity: critical
annotations:
summary: "工具调用成功率低于 95%"
description: "当前成功率:{{ $value | humanizePercentage }}"
# 耗时高
- alert: ToolCallDurationHigh
expr: |
histogram_quantile(0.95, rate(tool_call_duration_bucket[5m])) > 2000
for: 1m
labels:
severity: warning
annotations:
summary: "工具调用 P95 耗时超过 2 秒"
description: "当前 P95 耗时:{{ $value }}ms"
# 某工具持续失败
- alert: ToolCallFailureRateHigh
expr: |
sum by (tool) (rate(tool_call_total{success="false"}[5m])) /
sum by (tool) (rate(tool_call_total[5m])) > 0.5
for: 1m
labels:
severity: critical
annotations:
summary: "工具 {{ $labels.tool }} 失败率超过 50%"
description: "当前失败率:{{ $value | humanizePercentage }}"
告警通知模板:
【P1 告警】工具调用成功率低
时间:2026-03-05 15:30:45
环境:生产环境
告警项:工具调用成功率低于 95%
当前值:92.3%
持续时长:3 分钟
影响范围:
- 影响工具:getUserAnnualLeave、getOrderStatus
- 影响用户:约 500 人
- 错误分布:TIMEOUT 60%、SYSTEM_ERROR 30%、其他 10%
快速定位:
- Grafana 大盘:https://grafana.example.com/d/tool-call
- 日志查询:https://kibana.example.com/app/discover?q=errorCode:TIMEOUT
- 链路追踪:https://jaeger.example.com/search?service=tool-service
处理建议:
1. 检查 HR 系统是否正常(TIMEOUT 占比高)
2. 检查数据库连接池是否耗尽
3. 检查是否有大量异常请求(攻击)
值班人员:张三(13800138000)
告警降噪:
- 合并相同告警:5 分钟内相同告警只发一次
- 告警升级:P2 告警持续 10 分钟未处理,自动升级为 P1
- 告警恢复通知:问题解决后发送恢复通知
4. 可观测性最佳实践
三个支柱:日志 + 指标 + 链路追踪
| 维度 | 日志(Logs) | 指标(Metrics) | 链路追踪(Traces) |
|---|---|---|---|
| 用途 | 排查问题细节 | 监控系统健康度 | 定位性能瓶颈 |
| 粒度 | 单次请求 | 聚合统计 | 单次请求 |
| 存储成本 | 高 | 低 | 中 |
| 查询速度 | 慢 | 快 | 中 |
| 典型工具 | ELK、Loki | Prometheus、Grafana | SkyWalking |
关联三者:TraceID
每次工具调用生成一个 TraceID,贯穿日志、指标、链路追踪:
- 日志中记录 TraceID,方便从日志跳转到链路追踪
- 指标中记录 TraceID(Exemplar),方便从监控大盘跳转到链路追踪
- 链路追踪中记录 TraceID,方便从链路追踪跳转到日志
排查问题的典型流程:
- 发现问题:Grafana 监控大盘显示成功率下降
- 定位范围:查看错误分布,发现 TIMEOUT 占比高
- 找到慢调用:从 Grafana 点击 Exemplar,跳转到 SkyWalking 链路追踪
- 查看详细日志:从 SkyWalking 复制 TraceID,到 Kibana 查询日志
- 分析根因:日志显示 HR 系统连接超时,联系 HR 系统负责人
测试策略:如何验证工具调用的质量
工具调用不是写完就能上线的,要经过完整的测试验证。测试分三个层次:单元测试(工具本身)、集成测试(模型+工具)、压力测试(高并发场景)。
1. 单元测试:验证工具逻辑
单元测试关注工具本身的逻辑是否正确,不依赖模型。
测试维度:
- 正常场景:参数合法,返回正确结果
- 边界场景:参数为空、为 null、超出范围
- 异常场景:数据库连接失败、第三方服务超时、权限不足
- 安全场景:SQL 注入、路径穿越、XSS 攻击
示例:getUserAnnualLeave 工具的单元测试
@Test
public void testGetUserAnnualLeave_Success() {
// 正常场景:查询自己的年假
ToolResult result = toolService.getUserAnnualLeave("user123", "user123");
assertTrue(result.isSuccess());
assertEquals(10, result.getData().get("remainingDays"));
}
@Test
public void testGetUserAnnualLeave_PermissionDenied() {
// 异常场景:查询别人的年假
ToolResult result = toolService.getUserAnnualLeave("user456", "user123");
assertFalse(result.isSuccess());
assertEquals("PERMISSION_DENIED", result.getErrorCode());
}
@Test
public void testGetUserAnnualLeave_InvalidUserId() {
// 边界场景:userId 为空
ToolResult result = toolService.getUserAnnualLeave("", "user123");
assertFalse(result.isSuccess());
assertEquals("INVALID_PARAMETER", result.getErrorCode());
}
@Test
public void testGetUserAnnualLeave_Timeout() {
// 异常场景:HR 系统超时
when(hrService.getAnnualLeave(anyString())).thenThrow(new TimeoutException());
ToolResult result = toolService.getUserAnnualLeave("user123", "user123");
assertFalse(result.isSuccess());
assertEquals("TIMEOUT", result.getErrorCode());
}
@Test
public void testSearchUsers_SqlInjection() {
// 安全场景:SQL 注入攻击
String maliciousKeyword = "'; DROP TABLE users; --";
ToolResult result = toolService.searchUsers(maliciousKeyword);
// 应该正常返回空结果,而不是抛异常或执行恶意 SQL
assertTrue(result.isSuccess());
assertEquals(0, result.getData().get("total"));
}
覆盖率要求:
- 核心工具(订单查询、支付、退货):行覆盖率 ≥ 90%,分支覆盖率 ≥ 80%
- 普通工具(知识库搜索、FAQ):行覆盖率 ≥ 80%,分支覆盖率 ≥ 70%
2. 集成测试:验证模型+工具协作
集成测试关注模型是否能正确选择工具、传递参数、处理返回值。
测试维度:
- 工具选择准确性:给定用户问题,模型是否选对了工具
- 参数传递准确性:模型传的参数是否符合工具定义
- 多轮对话:工具返回结果后,模型是否能生成正确答案
- 异常处理:工具返回错误时,模型是否能给出合理的兜底回复
示例:集成测试框架
@Test
public void testIntegration_GetAnnualLeave() {
// 用户问题
String userQuestion = "我还剩几天年假?";
// 调用模型(第一轮)
ChatResponse response1 = chatService.chat(userQuestion, "user123");
// 验证模型选择了正确的工具
assertTrue(response1.hasToolCalls());
assertEquals("getUserAnnualLeave", response1.getToolCalls().get(0).getName());
// 验证参数传递正确
Map<String, Object> arguments = response1.getToolCalls().get(0).getArguments();
assertEquals("user123", arguments.get("userId"));
// 执行工具
ToolResult toolResult = toolService.getUserAnnualLeave("user123", "user123");
// 调用模型(第二轮)
ChatResponse response2 = chatService.chat(userQuestion, "user123", toolResult);
// 验证模型生成了正确的答案
assertTrue(response2.getContent().contains("10 天"));
}
@Test
public void testIntegration_ToolError() {
// 模拟工具调用失败
when(hrService.getAnnualLeave(anyString())).thenThrow(new RuntimeException("HR 系统不可用"));
String userQuestion = "我还剩 几天年假?";
ChatResponse response = chatService.chat(userQuestion, "user123");
// 验证模型给出了兜底回复
assertTrue(response.getContent().contains("系统繁忙") ||
response.getContent().contains("稍后再试"));
}
测试用例设计:
每个工具至少准备 5 类测试用例:
- 正常场景:用户问题清晰,工具返回正确结果
- 模糊场景:用户问题不清晰,模型需要澄清或猜测
- 多工具场景:用户问题可能匹配多个工具,验证模型选择逻辑
- 异常场景:工具返回错误,验证模型的兜底回复
- 边界场景:参数为空、超出范围,验证参数校验逻辑