这篇文章就来聊聊工具调用的最佳实践:怎么设计好的工具定义、怎么写清晰的工具描述、怎么处理错误和异常、怎么保证安全性和可观测性。这些原则和技巧适用于 Function Call 和 MCP 两种方式,也适用于任何需要让 AI 调用外部工具的场景。
工具定义的设计原则
1. 单一职责原则
一个工具只做一件事,不要把多个功能塞进一个工具。
- 1.功能太杂:年假、病假、考勤、工资是完全不同的业务领域,查询逻辑、权限控制、数据来源都不一样
- 2.模型容易选错:用户问“我还剩几天年假”,模型要先判断该用这个工具,再判断
infoType该传什么值,多了一层判断就多了一次出错的机会 - 3.维护成本高:后续要加新的信息类型(比如加班时长),就得改这个工具的定义和实现,改一个地方可能影响其他功能
拆分后的好处:
- 模型更容易选对工具:用户问年假,模型直接匹配到
getUserAnnualLeave,不需要再判断参数 - 工具描述更清晰:每个工具的 description 可以加入更多关键词(年假、假期余额、剩余天数),提高匹配准确率
- 维护更简单:要改年假查询逻辑,只改
getUserAnnualLeave,不影响其他工具 - 权限控制更精细:可以单独控制每个工具的权限(比如工资信息只有 HR 能查)
2. 参数最小化原则
只暴露必要的参数,不要把所有可能的参数都加上。
反例:
{"name":"searchKnowledgeBase","description":"在知识库中搜索相关文档","parameters":{"type":"object","properties":{"query":{"type":"string","description":"搜索关键词"},"topK":{"type":"integer","description":"返回结果数量,默认 5"},"threshold":{"type":"number","description":"相似度阈值,0-1 之间,默认 0.7"},"enableRerank":{"type":"boolean","description":"是否启用重排序,默认 true"},"filter":{"type":"object","description":"过滤条件,如 {\"category\": \"产品文档\"}"},"sortBy":{"type":"string","description":"排序方式:relevance(相关性)、time(时间)"},"includeMetadata":{"type":"boolean","description":"是否返回元数据,默认 false"}},"required":["query"]}}
这个工具定义有 7 个参数,只有 query 是必填的。问题在哪?
- 1.模型容易传错参数:参数越多,模型越容易搞混(
threshold该传 0.7 还是 70?sortBy该传 relevance 还是 Relevance?) - 2.用户体验差:用户只是想问个问题,模型却要花时间判断这么多参数,响应变慢
- 3.大部分参数用不上:实际场景中,90% 的查询用默认值就够了,暴露这么多参数反而增加复杂度
正例只保留 query 一个参数,其他参数在工具实现中用合理的默认值:
topK = 5(大部分场景够用)threshold = 0.7(经验值)enableRerank = true(提高准确率)sortBy = relevance(按相关性排序)includeMetadata = true(方便引用)
如果真的需要灵活控制这些参数(比如高级用户场景),可以提供另一个工具 searchKnowledgeBaseAdvanced,但大部分用户用不到。
设计原则:尽量减少必填参数,降低模型出错概率。参数越少,模型越容易用对。
3. 幂等性原则
查询类工具天然幂等,操作类工具要设计成幂等。
查询类工具(getUserAnnualLeave、searchKnowledgeBase)天然幂等,调用多少次都只是查数据,不会产生副作用。但操作类工具(submitExpense、createOrder、sendEmail)就要小心了,模型可能重复调用工具:
- 网络超时重试
- 用户重复提问(帮我提交报销 → 等了 10 秒没反应 → 再问一次帮我提交报销)
- 模型自己判断失败重试
工具实现中,用 requestId 做幂等控制:
publicToolResultsubmitExpense(String requestId,double amount,String reason){// 先查询是否已经提交过Expense existing = expenseRepository.findByRequestId(requestId);if(existing !=null){returnToolResult.success(existing);// 返回已有记录,不重复创建}// 创建新的报销记录Expense expense =newExpense();
expense.setRequestId(requestId);
expense.setAmount(amount);
expense.setReason(reason);
expenseRepository.save(expense);returnToolResult.success(expense);}
设计原则:操作类工具必须支持幂等,避免重复执行产生副作用。
4. 返回值结构化原则
返回 JSON 格式,不要返回纯文本或 HTML。包含足够的信息让模型生成好的答案,但不要冗余。
反例 1:返回纯文本
模型拿到这个文本后,只能原样返回给用户,无法做进一步处理(比如用户追问“我总共有多少天年假”,模型答不上来)。
反例 2:返回太多冗余信息
用户只是问年假,返回这么多无关信息(姓名、部门、职位、邮箱、电话)浪费 token,也增加模型处理负担。
正例:
{"success":true,"data":{"totalDays":10,"usedDays":3,"remainingDays":7,"expiryDate":"2026-12-31"}}
只返回年假相关的信息,模型可以灵活组织答案:
- 用户问:我还剩几天年假 → 您还剩 7 天年假
- 用户问:我总共有多少天年假 → 您总共有 10 天年假,已用 3 天,还剩 7 天
- 用户问:年假什么时候过期 → 您的年假将在 2026 年 12 月 31 日过期
错误响应也要结构化:
{"success":false,"errorCode":"PERMISSION_DENIED","errorMessage":"您没有权限查询该用户的年假信息","details":{"requestedUserId":"67890","currentUserId":"12345"}}
模型拿到这个错误后,可以生成友好的提示:“抱歉,您只能查询自己的年假信息,无法查询其他用户的数据。”
设计原则:返回结构化的 JSON,包含足够但不冗余的信息,错误响应要包含错误码和错误信息。
工具描述的编写技巧
工具的 description 字段是模型判断该不该用这个工具的关键依据。description 写得好,模型选对工具的概率就高;写得烂,模型就会选错工具或者根本不用。
1. 描述的三要素
一个好的工具描述应该包含三个要素:
(1)功能说明:这个工具做什么
用一句话说清楚工具的核心功能,不要模糊不清。
(2)适用场景:什么时候用这个工具
加入用户可能使用的关键词,帮助模型匹配。
(3)参数说明:每个参数的含义和示例
在参数的 description 中说清楚参数的含义、格式、示例值。
2. 关键词优化
模型根据 description 匹配用户问题,关键词越准确,匹配越精准。技巧:把用户可能问的各种说法都加到 description 里,提高匹配准确率。把小规模的llm看成一个有点智能的处理助手,但是不太智能要处理好对于这种逻辑性强 的不要思考而要匹配
3. 多工具场景的描述策略
如果有多个相似的工具,description 要突出差异,避免模型选错。
举个例子,系统有两个工具:
[{"name":"getUserAnnualLeave","description":"查询用户的年假余额"},{"name":"getUserSickLeave","description":"查询用户的病假余额"}]
用户问:我还有几天假?
模型可能不知道该用哪个工具(年假还是病假?)。
优化后:
[
{
"name": "getUserAnnualLeave",
"description": "查询用户的年假余额(带薪休假),包括剩余天数、已用天数、总天数。适用于用户询问年假、带薪假期、休假余额等问题。注意:年假和病假是不同的假期类型。"
},
{
"name": "getUserSickLeave",
"description": "查询用户的病假余额(因病请假),包括剩余天数、已用天数、总天数。适用于用户询问病假、因病请假等问题。注意:病假和年假是不同的假期类型。"
}
]
加入了带薪休假、因病请假等区分性描述,模型更容易判断。
如果用户问的是“我还有几天假”(没说年假还是病假),模型可能会反问用户:您是想查询年假还是病假?
技巧:用优先使用、仅当等引导词控制工具选择优先级。
4. 避免歧义的描述
不要用模糊词和否定句,用祈使句和主动语态
工具参数的设计模式
1. 必填 vs 可选参数
必填参数:模型必须提供,否则工具无法执行。
可选参数:有合理的默认值,模型可以不提供。
设计原则:尽量减少必填参数,降低模型出错概率。
2. 参数的默认值设计
默认值要符合大多数场景的需求,不要用极端值。
topK默认 5(不是 1 也不是 100)timeout默认 30 秒(不是 1 秒也不是 300 秒)enableCache默认 true(大部分场景需要缓存)
3. 参数校验
工具执行前要校验参数,不要相信模型传的参数一定是对的。
(1)类型校验
(2)格式校验
(3)业务校验
校验失败时返回清晰的错误信息,帮助模型理解问题。
使用语义化的错误码,不要用 HTTP 状态码(403、500)。
常见错误码分类:
| 错误码 | 含义 | 示例 |
|---|---|---|
INVALID_PARAMETER | 参数错误 | 参数格式不对、必填参数缺失 |
PERMISSION_DENIED | 权限错误 | 无权访问该资源 |
RESOURCE_NOT_FOUND | 资源不存在 | 用户不存在、订单不存在 |
BUSINESS_ERROR | 业务错误 | 余额不足、库存不足 |
SYSTEM_ERROR | 系统错误 | 数据库连接失败、第三方服务超时 |
RATE_LIMIT_EXCEEDED | 限流 | 调用频率超过限制 |
或者参考阿里巴巴开发手册规范那种,设计前置错误码+数字形式标识,都是可以的。
- 信息密度适中:不冗余、不缺失
- 错误码语义化:INVALID_PARAMETER、PERMISSION_DENIED、SYSTEM_ERROR
- 错误信息友好:告诉模型怎么修正,而不是只说“错了”

这张图展示了工具调用的四层防护体系:
- 1.工具调用层:参数校验 → 超时控制 → 工具执行,保证基本流程正确
- 2.容错保障层:重试 → 降级 → 熔断,保证系统不挂
- 3.安全防护层:权限控制 → 防注入 → 脱敏,保证不被攻击
- 4.可观测性层:日志 → 指标 → 追踪 → 告警,保证问题能快速定位
工具调用的错误处理
工具调用会遇到各种错误:网络超时、参数错误、权限不足、第三方服务挂了……错误处理做得好,系统才稳定。
1. 超时控制
每个工具调用都要设置超时时间,避免无限等待。
推荐超时时间:
- 查询类工具:5~10 秒
- 操作类工具:10~30 秒
- 复杂计算:30~60 秒
2. 重试策略
哪些错误应该重试?
- 网络错误(连接超时、连接被拒绝)
- 超时错误
- 服务暂时不可用(HTTP 503)
- 限流错误(HTTP 429)
哪些错误不应该重试?
- 参数错误(HTTP 400)
- 权限错误(HTTP 403)
- 资源不存在(HTTP 404)
- 业务逻辑错误(余额不足、库存不足)
重试次数和间隔:指数退避
- 第 1 次重试:等待 1 秒
- 第 2 次重试:等待 2 秒
- 第 3 次重试:等待 4 秒
3. 降级策略
工具调用失败时,不要让整个对话失败,要有降级方案。
降级方案 1:返回兜底信息
模型拿到这个错误后,会告诉用户:“抱歉,系统繁忙,无法查询年假信息。您可以访问 HR 系统(https://hr.example.com)查看详细信息。”
降级方案 2:使用缓存数据
降级方案 3:引导用户使用其他方式,提交失败,请稍后再试。您也可以通过邮件(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 系统暂时不可用,请稍后再试"
);
}
工具调用的安全性
1. 权限控制
基于用户身份的权限校验,在工具执行前校验,不要依赖模型的判断。
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 注入使用参数化查询String sql
路径穿越
攻击者可以传入 filename = "../../etc/passwd",读取系统敏感文件。
正例: 校验文件名,不允许包含路径分隔符
XSS(跨站脚本攻击)
如果工具返回值会在网页上显示,要对特殊字符进行转义。 对用户输入的内容进行 HTML 转义
3. 敏感信息脱敏
工具返回值中的敏感信息要脱敏,避免泄露。日志中也不要记录敏感信息
4. 审计日志
记录工具调用的完整信息,用于安全审计和问题排查。
审计日志应该包含:
- 谁(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,创建一个 Grafana Dashboard,
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(重要) | 企业微信 |
可观测性最佳实践:三个支柱:日志 + 指标 + 链路追踪;每次工具调用生成一个 TraceID,贯穿日志、指标、链路追踪:
- 日志中记录 TraceID,方便从日志跳转到链路追踪
- 指标中记录 TraceID(Exemplar),方便从监控大盘跳转到链路追踪
- 链路追踪中记录 TraceID,方便从链路追踪跳转到日志
测试策略:如何验证工具调用的质量
工具调用不是写完就能上线的,要经过完整的测试验证。测试分三个层次:单元测试(工具本身)、集成测试(模型+工具)、压力测试(高并发场景)。
1. 单元测试:验证工具逻辑
单元测试关注工具本身的逻辑是否正确,不依赖模型。
测试维度:
- 正常场景:参数合法,返回正确结果
- 边界场景:参数为空、为 null、超出范围
- 异常场景:数据库连接失败、第三方服务超时、权限不足
- 安全场景:SQL 注入、路径穿越、XSS 攻击
示例:getUserAnnualLeave 工具的单元测试
// 正常场景:查询自己的年假
// 异常场景:查询别人的年假
// 边界场景:userId 为空
// 异常场景:HR 系统超时
// 安全场景:SQL 注入攻击
// 应该正常返回空结果,而不是抛异常或执行恶意
覆盖率要求:
- 核心工具(订单查询、支付、退货):行覆盖率 ≥ 90%,分支覆盖率 ≥ 80%
- 普通工具(知识库搜索、FAQ):行覆盖率 ≥ 80%,分支覆盖率 ≥ 70%
2. 集成测试:验证模型+工具协作
集成测试关注模型是否能正确选择工具、传递参数、处理返回值。
测试维度:
- 工具选择准确性:给定用户问题,模型是否选对了工具
- 参数传递准确性:模型传的参数是否符合工具定义
- 多轮对话:工具返回结果后,模型是否能生成正确答案
- 异常处理:工具返回错误时,模型是否能给出合理的兜底回复
用户问题
调用模型(第一轮)
验证模型选择了正确的工具
验证参数传递正确
执行工具
调用模型(第二轮)
验证模型生成了正确的答案
模拟工具调用失败
验证模型给出了兜底回复
测试用例设计:
每个工具至少准备 5 类测试用例:
- 1.正常场景:用户问题清晰,工具返回正确结果
- 2.模糊场景:用户问题不清晰,模型需要澄清或猜测
- 3.多工具场景:用户问题可能匹配多个工具,验证模型选择逻辑
- 4.异常场景:工具返回错误,验证模型的兜底回复
- 5.边界场景:参数为空、超出范围,验证参数校验逻辑
3. 压力测试:验证高并发表现
压力测试关注工具在高并发场景下的表现:吞吐量、响应时间、错误率。
测试场景:
- 正常负载:10 QPS(每秒 10 次请求)
- 高负载:50 QPS
- 峰值负载:100 QPS
测试工具:JMeter / Gatling
setUp(
scn.inject(
rampUsersPerSec(10) to 50 during (1 minute), // 1 分钟内从 10 QPS 增加到 50 QPS
constantUsersPerSec(50) during (5 minutes), // 保持 50 QPS 持续 5 分钟
rampUsersPerSec(50) to 100 during (2 minutes) // 2 分钟内从 50 QPS 增加到 100 QPS
)
).protocols(httpProtocol)
性能指标要求:
| 指标 | 正常负载(10 QPS) | 高负载(50 QPS) | 峰值负载(100 QPS) |
|---|---|---|---|
| P50 响应时间 | < 200ms | < 500ms | < 1s |
| P95 响应时间 | < 500ms | < 1s | < 2s |
| P99 响应时间 | < 1s | < 2s | < 3s |
| 成功率 | ≥ 99.9% | ≥ 99% | ≥ 95% |
| CPU 使用率 | < 50% | < 70% | < 85% |
| 内存使用率 | < 60% | < 75% | < 85% |
常见性能瓶颈:
- 1.数据库连接池不足:增加连接池大小(HikariCP maxPoolSize)
- 2.线程池不足:增加线程池大小(ThreadPoolExecutor corePoolSize)
- 3.第三方服务慢:加缓存、加超时控制、加熔断
- 4.模型调用慢:批量调用、异步调用、加缓存
4. A/B 测试:验证优化效果
工具定义优化后,要通过 A/B 测试验证效果是否真的提升了。
测试方案:
- A 组(对照组):使用旧版工具定义
- B 组(实验组):使用新版工具定义
- 流量分配:50% vs 50%
- 测试时长:至少 7 天
| 阶段 | 关键原则 | 具体措施 |
|---|---|---|
| 设计阶段 | 单一职责、参数最小化、幂等性、返回值结构化 | 一个工具只做一件事;只暴露必要参数;操作类工具传入 requestId;返回 JSON 格式 |
| 描述阶段 | 三要素齐全、关键词优化、突出差异、避免歧义 | 功能说明+适用场景+参数说明;加入用户可能的各种说法;用"优先使用"、"仅当"引导 |
| 实现阶段 | 参数校验、超时控制、重试策略、降级方案、熔断机制 | 类型/格式/范围/权限校验;5~30 秒超时;指数退避重试;兜底信息/缓存数据;Resilience4j |
| 安全阶段 | 权限控制、防注入、敏感信息脱敏、审计日志 | 基于用户身份校验;参数化查询;手机号/身份证号脱敏;记录谁/何时/调用了什么 |
| 运维阶段 | 日志记录、指标监控、链路追踪、告警机制 | 结构化日志(JSON);调用量/成功率/耗时/错误分布;traceId;成功率<95% 告警 |

Comments NOTHING