工具调用架构设计指南

eve2333 发布于 1 天前 5 次阅读


这篇文章就来聊聊工具调用的最佳实践:怎么设计好的工具定义、怎么写清晰的工具描述、怎么处理错误和异常、怎么保证安全性和可观测性。这些原则和技巧适用于 Function Call 和 MCP 两种方式,也适用于任何需要让 AI 调用外部工具的场景。

工具定义的设计原则

1. 单一职责原则

一个工具只做一件事,不要把多个功能塞进一个工具。

  1. 1.功能太杂:年假、病假、考勤、工资是完全不同的业务领域,查询逻辑、权限控制、数据来源都不一样
  2. 2.模型容易选错:用户问“我还剩几天年假”,模型要先判断该用这个工具,再判断 infoType 该传什么值,多了一层判断就多了一次出错的机会
  3. 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. 1.模型容易传错参数:参数越多,模型越容易搞混(threshold 该传 0.7 还是 70?sortBy 该传 relevance 还是 Relevance?)
  2. 2.用户体验差:用户只是想问个问题,模型却要花时间判断这么多参数,响应变慢
  3. 3.大部分参数用不上:实际场景中,90% 的查询用默认值就够了,暴露这么多参数反而增加复杂度

正例只保留 query 一个参数,其他参数在工具实现中用合理的默认值:

  • topK = 5(大部分场景够用)
  • threshold = 0.7(经验值)
  • enableRerank = true(提高准确率)
  • sortBy = relevance(按相关性排序)
  • includeMetadata = true(方便引用)

如果真的需要灵活控制这些参数(比如高级用户场景),可以提供另一个工具 searchKnowledgeBaseAdvanced,但大部分用户用不到。

设计原则:尽量减少必填参数,降低模型出错概率。参数越少,模型越容易用对。

3. 幂等性原则

查询类工具天然幂等,操作类工具要设计成幂等。

查询类工具(getUserAnnualLeavesearchKnowledgeBase)天然幂等,调用多少次都只是查数据,不会产生副作用。但操作类工具(submitExpensecreateOrdersendEmail)就要小心了,模型可能重复调用工具:

  • 网络超时重试
  • 用户重复提问(帮我提交报销 → 等了 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. 1.工具调用层:参数校验 → 超时控制 → 工具执行,保证基本流程正确
  2. 2.容错保障层:重试 → 降级 → 熔断,保证系统不挂
  3. 3.安全防护层:权限控制 → 防注入 → 脱敏,保证不被攻击
  4. 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 > 2sP2(重要)短信 + 企业微信
工具调用量异常5 分钟内 QPS 突增/突降 50%P2(重要)企业微信
某工具持续失败某工具 5 分钟内失败率 > 50%P1(严重)电话 + 短信 + 企业微信
熔断器打开某工具熔断器状态变为 OPENP1(严重)电话 + 短信 + 企业微信
错误码异常某错误码 5 分钟内占比 > 20%P2(重要)企业微信

可观测性最佳实践:三个支柱:日志 + 指标 + 链路追踪;每次工具调用生成一个 TraceID,贯穿日志、指标、链路追踪:

  • 日志中记录 TraceID,方便从日志跳转到链路追踪
  • 指标中记录 TraceID(Exemplar),方便从监控大盘跳转到链路追踪
  • 链路追踪中记录 TraceID,方便从链路追踪跳转到日志

测试策略:如何验证工具调用的质量

工具调用不是写完就能上线的,要经过完整的测试验证。测试分三个层次:单元测试(工具本身)、集成测试(模型+工具)、压力测试(高并发场景)。

1. 单元测试:验证工具逻辑

单元测试关注工具本身的逻辑是否正确,不依赖模型。

测试维度:

  • 正常场景:参数合法,返回正确结果
  • 边界场景:参数为空、为 null、超出范围
  • 异常场景:数据库连接失败、第三方服务超时、权限不足
  • 安全场景:SQL 注入、路径穿越、XSS 攻击

示例:getUserAnnualLeave 工具的单元测试

// 正常场景:查询自己的年假
// 异常场景:查询别人的年假
// 边界场景:userId 为空
// 异常场景:HR 系统超时
// 安全场景:SQL 注入攻击
// 应该正常返回空结果,而不是抛异常或执行恶意 

覆盖率要求:

  • 核心工具(订单查询、支付、退货):行覆盖率 ≥ 90%,分支覆盖率 ≥ 80%
  • 普通工具(知识库搜索、FAQ):行覆盖率 ≥ 80%,分支覆盖率 ≥ 70%

2. 集成测试:验证模型+工具协作

集成测试关注模型是否能正确选择工具、传递参数、处理返回值。

测试维度:

  • 工具选择准确性:给定用户问题,模型是否选对了工具
  • 参数传递准确性:模型传的参数是否符合工具定义
  • 多轮对话:工具返回结果后,模型是否能生成正确答案
  • 异常处理:工具返回错误时,模型是否能给出合理的兜底回复
用户问题
调用模型(第一轮)
验证模型选择了正确的工具
验证参数传递正确
执行工具
调用模型(第二轮)
验证模型生成了正确的答案
模拟工具调用失败
验证模型给出了兜底回复

测试用例设计:

每个工具至少准备 5 类测试用例:

  1. 1.正常场景:用户问题清晰,工具返回正确结果
  2. 2.模糊场景:用户问题不清晰,模型需要澄清或猜测
  3. 3.多工具场景:用户问题可能匹配多个工具,验证模型选择逻辑
  4. 4.异常场景:工具返回错误,验证模型的兜底回复
  5. 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. 1.数据库连接池不足:增加连接池大小(HikariCP maxPoolSize)
  2. 2.线程池不足:增加线程池大小(ThreadPoolExecutor corePoolSize)
  3. 3.第三方服务慢:加缓存、加超时控制、加熔断
  4. 4.模型调用慢:批量调用、异步调用、加缓存

4. A/B 测试:验证优化效果

工具定义优化后,要通过 A/B 测试验证效果是否真的提升了。

测试方案:

  • A 组(对照组):使用旧版工具定义
  • B 组(实验组):使用新版工具定义
  • 流量分配:50% vs 50%
  • 测试时长:至少 7 天
阶段关键原则具体措施
设计阶段单一职责、参数最小化、幂等性、返回值结构化一个工具只做一件事;只暴露必要参数;操作类工具传入 requestId;返回 JSON 格式
描述阶段三要素齐全、关键词优化、突出差异、避免歧义功能说明+适用场景+参数说明;加入用户可能的各种说法;用"优先使用"、"仅当"引导
实现阶段参数校验、超时控制、重试策略、降级方案、熔断机制类型/格式/范围/权限校验;5~30 秒超时;指数退避重试;兜底信息/缓存数据;Resilience4j
安全阶段权限控制、防注入、敏感信息脱敏、审计日志基于用户身份校验;参数化查询;手机号/身份证号脱敏;记录谁/何时/调用了什么
运维阶段日志记录、指标监控、链路追踪、告警机制结构化日志(JSON);调用量/成功率/耗时/错误分布;traceId;成功率<95% 告警