臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

一、💡 一句话理解

第 13 天核心结论

工具参数设计负责让模型“尽量传对”,后端参数校验负责保证“传错也不能乱执行”。JSON Schema 和 @ToolParam 是说明书,不是安全边界;Java 应用必须再次做格式校验、业务校验和上下文校验。

本篇对应 0-学习路线 第 13 天,前置知识是 11.Function Calling、Tool Calling 原理 和 12.用 Java 方法定义 Tool。

今天只聚焦一件事:模型已经会调用 Java Tool 以后,怎样设计清楚的参数,并在 Java 后端拦住缺失、格式错误和业务上不存在的输入。

用户自然语言
  → Java 应用把用户问题和工具参数 Schema 发给模型
    (Schema 是给模型看的“参数填写规则”,说明参数名、类型、是否必填、格式和示例;它不是本次调用的实际参数)
  → 模型返回 Tool Call
    (Tool Call 是一份“工具调用申请”,包含工具名 name 和 JSON 参数 arguments;此时工具还没有执行)
  → Java 应用收到模型响应
  → Spring AI 解析 arguments,并转换成 Java 方法需要的参数值
  → Spring AI 使用转换后的参数调用 @Tool 方法
  → @Tool 方法做格式与业务校验
  → 校验通过才调用业务能力
  → 返回稳定、可理解的 Tool Result

这里必须区分四个概念:

  • 工具参数 Schema 是 Java 应用提供给模型的参数说明书,告诉模型允许填写哪些字段、字段类型以及填写规则;
  • Tool Call 是模型返回的结构化调用申请,核心内容是“希望调用哪个工具”以及“准备了哪些 JSON 参数”,它不等于 Java 方法已经执行;
  • arguments 是模型通过 Tool Calling 协议返回的 JSON 数据,例如 {"orderId":"A1001"};
  • Java 参数是 Spring AI 解析 JSON 成功后,真正调用 queryOrderStatus("A1001") 时传入 JVM 方法的值。

模型不能跨网络直接创建 Java 方法参数,也不能直接执行 Java 方法。

二、🧭 理论:工具参数到底是什么

2.1 参数是模型生成的不可信输入

在普通 Controller 中,请求参数来自浏览器或调用方;在 Tool Calling 中,工具参数通常由模型根据用户问题生成。

例如用户说:

帮我查一下订单 A1001

模型可能生成:

{
  "orderId": "A1001"
}

但模型也可能生成下面这些内容:

{
  "orderId": ""
}
{
  "orderId": "刚才那个订单"
}
{
  "orderId": "A1001'; DROP TABLE orders; --"
}

因此,模型生成的 arguments 与普通 HTTP 请求参数一样,都属于外部不可信输入。不能因为参数来自大模型,就跳过校验。

参数描述不能代替校验

在 @ToolParam 中写“订单号必须以 A 开头”只能引导模型,不能保证模型一定遵守。真正的限制必须在 Java 代码中执行。

2.2 参数 Schema 和参数校验不是一回事

Spring AI 会根据 Java 方法参数和注解生成工具的 inputSchema。它的主要作用是帮助模型理解应该怎样调用工具。

机制给谁使用主要作用能否作为最终安全边界
@ToolParam、JSON Schema大模型告诉模型参数名、类型、含义、格式和是否必填不能
JSON 到 Java 的类型绑定Spring AI/Jackson把模型生成的 JSON 转成 Java 参数只能拦截部分类型错误
Jakarta Bean ValidationJava 应用校验空值、长度、格式和数值范围可以承担基础格式校验
业务校验业务 Service 或 Tool 入口校验数据是否存在、状态是否允许、关系是否正确必须有
权限和登录上下文安全组件、业务 Service判断当前用户是否允许访问或执行必须有,不能让模型代填

可以把它们理解为两道门:

第一道门:Schema
作用:让模型尽量生成正确参数
 
第二道门:Java 校验
作用:即使模型生成错误参数,也不能进入真实业务逻辑

2.3 一个好参数要回答哪些问题

一个参数不能只写名称,还要让模型知道下面这些信息:

问题orderId 的答案
它表示什么企业订单的唯一编号
是否必填必填
数据类型字符串
格式是什么大写字母 A 加 4~10 位数字
正确示例A1001
错误示例1001、订单一号、空字符串
能否从登录态获得不能,必须从用户问题中提取或向用户追问

对应代码:

@ToolParam(
        description = "订单编号,格式为大写字母 A 加 4~10 位数字,例如 A1001",
        required = true
)
String orderId

这里的 description 是给模型看的;后端还要用 @Pattern 或等价代码真正校验格式。

三、⚙️ 理论:怎样设计和校验参数

3.1 先判断参数应该由谁提供

不是所有信息都应该让模型生成。

参数来源典型数据正确做法
用户自然语言订单号、查询关键词、时间范围作为 Tool 参数交给模型提取
已认证登录态userId、tenantId、角色、权限通过后端上下文或 ToolContext 传入
服务端配置超时时间、最大条数、安全开关在配置或 Java 代码中读取
数据库查询结果订单状态、金额、物流单号由业务 Service 查询,不让模型填写

错误设计:

@Tool(description = "查询订单")
public String queryOrderStatus(String orderId, String userId, boolean isAdmin) {
    // userId 和 isAdmin 如果也由模型填写,就可以被模型猜测或伪造。
}

正确思路:

@Tool(description = "查询订单")
public String queryOrderStatus(String orderId, ToolContext toolContext) {
    // orderId 来自用户问题。
    // userId、tenantId 等可信上下文由 Java 应用通过 ToolContext 放入,
    // 不经过模型,也不会出现在模型生成的参数中。
}

Spring AI 官方把 ToolContext 定义为传递非模型数据的机制。它适合携带用户 ID、租户 ID、请求 ID 等上下文,但权限判断仍应由后端完成。

3.2 参数越少越好,但不能含糊

每增加一个参数,模型就多一次填错、漏填或混淆的机会。只保留完成当前工具真正需要的输入。

// 不推荐:把当前查询用不到的字段也交给模型。
queryOrderStatus(String orderId, String userId, String token,
                 String databaseName, String tableName, String language)
 
// 推荐:模型只负责提供业务问题中真正出现的订单号。
queryOrderStatus(String orderId, ToolContext toolContext)

“参数少”不等于“用一个含糊字符串包办所有事情”。下面的设计也不推荐:

queryOrderStatus(String input)

input 可能是订单号、用户问题,也可能是一段 JSON。模型不知道应该放什么,Java 代码也难以稳定解析。

3.3 名称要表达业务含义

不推荐推荐原因
idorderId明确是哪一种 ID
valuetrackingNumber明确值的业务含义
typedetailLevel避免与大量其他“类型”混淆
dataafterSaleReason模型能直接理解需要提取什么

参数名、Java 变量名、Schema 字段名和业务文档应保持一致。同一个字段不要一会叫 orderNo,一会叫 orderId。

3.4 描述要包含格式、范围和示例

只写“订单编号”仍然不够。更有效的描述应包含模型无法从类型中看出的规则:

@ToolParam(
        description = "订单编号,格式为大写字母 A 加 4~10 位数字,例如 A1001",
        required = true
)
String orderId

对于枚举值,优先使用 Java enum 表达有限选项:

public enum DetailLevel {
    SUMMARY,
    FULL
}

模型看到的 Schema 可以包含有限选项,比自由填写字符串更稳定。业务代码仍要考虑类型绑定失败,并返回受控错误。

3.5 必填和可选必须符合真实业务

Spring AI 2.0.1 默认把方法参数视为必填。可以通过 @ToolParam(required = false) 或 @Nullable 声明可选参数。

@ToolParam(
        description = "是否包含历史状态;未提供时默认为 false",
        required = false
)
Boolean includeHistory

不要把用户无法提供的信息标成必填。否则模型为了满足 Schema,可能会猜一个值。

可选参数必须由后端设置明确默认值:

boolean normalizedIncludeHistory = Boolean.TRUE.equals(includeHistory);

不要对可选参数直接拆箱:

// includeHistory 为 null 时会产生 NullPointerException。
boolean value = includeHistory;

3.6 四层校验要按顺序执行

工具进入真实业务逻辑前,建议按下面顺序处理:

1. 类型绑定
   JSON 能否转换成 Java 参数
        ↓
2. 基础格式校验
   是否为空、长度、格式、范围是否正确
        ↓
3. 业务校验
   订单是否存在、状态是否允许、时间范围是否合理
        ↓
4. 权限与风险校验
   当前用户是否能访问;写操作是否需要人工确认

本篇完整实践实现前 3 层,并用 ToolContext 演示可信用户上下文从哪里进入。第 19 天再系统学习权限校验和人工确认。

3.6.1 第一层:类型绑定

如果 Java 参数是 Integer,模型却传入 "很多",Spring AI/Jackson 在调用方法前就可能绑定失败。

因此,能用明确类型时不要全部写成 String:

// 推荐:数量本身就是整数。
Integer quantity
 
// 不推荐:业务代码还要自行解析任意字符串。
String quantity

类型正确只代表“能转换”,不代表业务上合法。例如 quantity = -100 可以转换成整数,但仍必须做范围校验。

3.6.2 第二层:基础格式校验

Jakarta Bean Validation 适合表达稳定的字段规则:

Jakarta Bean Validation 是一套 Java 参数校验标准。

大白话理解:

它允许你把“这个字段不能为空、长度不能超过多少、必须符合什么格式”等规则,直接写在 Java 字段上,然后交给校验器统一检查。

public record QueryOrderCommand(
        @NotBlank(message = "订单编号不能为空")
        @Pattern(
                regexp = "^A\\d{4,10}$",
                message = "订单编号格式错误,应为字母 A 加 4~10 位数字,例如 A1001"
        )
        String orderId
) {
}

常用约束:

注解用途示例
@NotNull不能是 null必填对象、枚举
@NotBlank字符串不能为 null、空串或全空格订单号、关键词
@Size限制字符串、集合长度原因说明最多 500 字
@Pattern限制字符串格式订单号、业务编码
@Min、@Max限制整数范围查询条数、页码
@DecimalMin、@DecimalMax限制小数范围金额、比例

@Valid 不会自动神奇地校验所有 Tool 参数

Tool 不是普通的 Controller 请求体。为了让流程清楚、结果可控,本篇在 Tool 方法中显式调用 validator.validate(command)。不要只加约束注解,却从未触发 Validator。

3.6.3 第三层:业务校验

基础格式正确后,还要检查业务事实:

A1001  → 格式正确,数据库中也存在,可以查询
A9999  → 格式正确,但数据库中不存在,返回 ORDER_NOT_FOUND

“订单不存在”不能使用正则表达式判断,必须查询真实业务数据。

3.6.4 第四层:权限与风险校验

订单存在也不代表当前用户有权查看。生产系统还必须检查订单归属、租户和用户权限。

格式正确
  ≠ 数据存在
  ≠ 当前用户有权访问
  ≠ 当前操作可以直接执行

查询一般是只读操作;退款、删除、创建售后工单等写操作还要增加幂等、审计和人工确认。不要只靠 Prompt 写一句“请谨慎操作”。

3.7 校验失败要返回稳定结果

不要把数据库异常、Java 堆栈或内部类名直接返回给模型。推荐给工具定义稳定的结果结构:

{
  "success": false,
  "code": "INVALID_ARGUMENT",
  "message": "工具参数校验失败",
  "data": null,
  "errors": [
    "订单编号格式错误,应为字母 A 加 4~10 位数字,例如 A1001"
  ]
}
字段作用
success是否执行成功
code稳定错误码,方便程序和日志识别
message给模型理解的简短结果
data成功时的业务数据
errors参数错误明细;不包含堆栈和敏感信息

模型拿到失败结果后,可以向用户说明正确格式或要求补充信息。真正的异常处理、超时和重试会在第 18 天继续学习。

四、🚀 实践:完成可校验的订单状态 Tool

4.1 前置准备

4.1.1 当前项目环境

沿用学习路线中的固定环境:

  • Java 21;
  • Spring Boot 4.1.1;
  • Spring AI 2.0.1;
  • Maven 3.9.16;
  • 一个支持 Tool Calling 的 OpenAI 兼容模型。

本篇暂时使用内存中的两条模拟订单,目标是看清参数设计和校验流程。第 14 天再把模拟数据替换为真实订单查询 Service。

4.1.2 检查 Maven 依赖

学习路线的基础 pom.xml 已包含下面两个依赖,无须重复添加:

<!-- Spring AI:提供 ChatClient、@Tool、@ToolParam 和 ToolContext。 -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
 
<!-- Bean Validation:提供 @NotBlank、@Pattern 和 Validator。 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

如果当前项目没有 Validation Starter,jakarta.validation 注解可能无法正常工作。添加依赖后,在 IDEA 的 Maven 面板重新加载项目。

4.1.3 模型配置

继续沿用已有的 src/main/resources/application.properties:

# API Key 从环境变量读取,不要写死在配置文件或提交到 Git。
spring.ai.openai.api-key=${DEEPSEEK_API_KEY}
spring.ai.openai.base-url=https://api.deepseek.com
spring.ai.openai.chat.model=deepseek-v4-flash

模型名称和能力可能变化。运行前应以当前供应商文档为准,确认所选模型支持 Tool Calling。

4.2 可以拿来干什么

4.2.1 减少模型猜参数

用途:通过准确的参数名、格式说明和必填状态,让模型更容易从自然语言中提取正确值。

前置:已经在 Java 方法上定义 @Tool,并通过 .tools(...) 注册工具。

@Tool(description = "根据订单编号查询当前订单状态;只读,不修改订单")
public String queryOrderStatus(
        @ToolParam(
                description = "订单编号,格式为大写字母 A 加 4~10 位数字,例如 A1001",
                required = true
        )
        String orderId) {
    return "查询结果";
}

输入“查询订单 A1001”时,模型应生成 orderId=A1001。适用于订单、物流、库存等字段含义明确的查询工具。

4.2.2 在执行业务前拦截错误格式

用途:即使模型传了空值或错误格式,也不进入订单查询 Service。

前置:项目已引入 spring-boot-starter-validation,并注入 jakarta.validation.Validator。

QueryOrderCommand command = new QueryOrderCommand(orderId);
 
// validate:读取 record 上的 Bean Validation 注解并执行校验。
var violations = validator.validate(command);
 
if (!violations.isEmpty()) {
    // 参数不合法时立即返回,不继续调用真实业务系统。
    return ToolResult.failure(
            "INVALID_ARGUMENT",
            "工具参数校验失败",
            violations.stream()
                    .map(ConstraintViolation::getMessage)
                    .sorted()
                    .toList()
    );
}

输入空订单号时,输出包含 INVALID_ARGUMENT 和可理解的错误信息。适合所有调用数据库、RPC 或外部系统之前的输入保护。

4.2.3 防止模型伪造用户身份

用途:把用户 ID、租户 ID 等可信信息从 Java 后端直接传给工具,而不是让模型生成。

前置:Controller 已从可信登录态取得用户信息。下面使用 demo-user 只是本地学习占位值,生产环境必须替换为认证系统中的真实身份。

return chatClient.prompt()
        .user(request.message())
        .tools(orderQueryTools)
        // toolContext:上下文直接从 Java 应用传给 Tool,不发送给模型填写。
        .toolContext(Map.of("userId", "demo-user"))
        .call()
        .content();

Tool 方法可以读取 ToolContext,但模型的工具参数中不会出现 userId。适合登录用户、租户、请求链路 ID 等非模型数据。

4.3 完整实践:代码、启动和验证

4.3.1 定义可复用的工具结果

文件位置:

src/main/java/com/afyke/ai/tool/ToolResult.java
package com.afyke.ai.tool;
 
import java.util.List;
 
/**
 * Tool 返回给模型的统一结果。
 *
 * @param success 是否成功
 * @param code    稳定结果码,成功时为 OK
 * @param message 给模型阅读的简短说明
 * @param data    成功时的业务数据
 * @param errors  参数错误明细;成功时为空列表
 */
public record ToolResult<T>(
        boolean success,
        String code,
        String message,
        T data,
        List<String> errors
) {
 
    /**
     * success:构造成功结果,避免每个 Tool 重复填写固定字段。
     */
    public static <T> ToolResult<T> success(String message, T data) {
        return new ToolResult<>(true, "OK", message, data, List.of());
    }
 
    /**
     * failure:构造失败结果,只返回受控错误,不暴露异常堆栈。
     */
    public static <T> ToolResult<T> failure(
            String code,
            String message,
            List<String> errors) {
        return new ToolResult<>(false, code, message, null, List.copyOf(errors));
    }
}

输入是工具执行结果或错误信息;输出是结构稳定、可由 Jackson 序列化的 record。

4.3.2 定义参数校验对象

文件位置:

src/main/java/com/afyke/ai/tool/QueryOrderCommand.java
package com.afyke.ai.tool;
 
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
 
/**
 * Java 后端真正执行校验的命令对象。
 * 这里的注解负责约束输入,不只是给模型看的说明。
 */
public record QueryOrderCommand(
        // NotBlank:同时拦截 null、空字符串和全空格字符串。
        @NotBlank(message = "订单编号不能为空")
 
        // Pattern:只有满足正则表达式的订单号才能通过格式校验。
        @Pattern(
                regexp = "^A\\d{4,10}$",
                message = "订单编号格式错误,应为字母 A 加 4~10 位数字,例如 A1001"
        )
        String orderId
) {
}

输入是模型生成的订单号,输出是等待 Validator 检查的 Java 对象。注意:只创建对象不会自动校验,下一步必须显式调用 validator.validate(...)。

4.3.3 定义订单 Tool

文件位置:

src/main/java/com/afyke/ai/tool/OrderQueryTools.java
package com.afyke.ai.tool;
 
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
 
import java.util.List;
import java.util.Map;
 
@Component
public class OrderQueryTools {
 
    private final Validator validator;
 
    /**
     * 第 13 天只用内存数据验证参数流程。
     * 第 14 天将这里替换为真正的 OrderService 或下游 RPC。
     */
    private final Map<String, OrderStatusData> mockOrders = Map.of(
            "A1001", new OrderStatusData("A1001", "SHIPPED", "订单已发货"),
            "A1002", new OrderStatusData("A1002", "PAID", "订单已付款,等待发货")
    );
 
    public OrderQueryTools(Validator validator) {
        // Validator 由 Spring Boot Validation 自动配置并注入。
        this.validator = validator;
    }
 
    @Tool(
            name = "queryOrderStatus",
            description = "根据订单编号查询当前订单状态;用户询问订单是否付款、发货或完成时使用;只读,不修改订单"
    )
    public ToolResult<OrderStatusData> queryOrderStatus(
            @ToolParam(
                    description = "订单编号,格式为大写字母 A 加 4~10 位数字,例如 A1001",
                    required = true
            )
            String orderId,
            ToolContext toolContext) {
 
        // ToolContext 中的数据由 Java 应用提供,而不是由模型生成。
        Object userIdValue = toolContext.getContext().get("userId");
        String userId = userIdValue == null ? "" : userIdValue.toString();
 
        if (userId.isBlank()) {
            // 缺少可信调用上下文时拒绝继续,不让模型自己补 userId。
            return ToolResult.failure(
                    "CONTEXT_MISSING",
                    "缺少调用用户上下文",
                    List.of("Java 应用必须通过 ToolContext 提供 userId")
            );
        }
 
        // 把模型参数放进受 Bean Validation 约束的命令对象。
        return queryOrderStatusInternal(orderId, userId);
    }
 
    /**
     * 内部方法把校验流程与 Spring AI 调用入口分开,便于做确定性的单元测试。
     */
    ToolResult<OrderStatusData> queryOrderStatusInternal(
            String orderId,
            String userId) {
 
        QueryOrderCommand command = new QueryOrderCommand(orderId);
 
        // validate:执行 @NotBlank、@Pattern 等 Bean Validation 约束。
        var violations = validator.validate(command);
 
        if (!violations.isEmpty()) {
            List<String> errors = violations.stream()
                    // getMessage:只提取受控的校验提示,不返回内部堆栈。
                    .map(ConstraintViolation::getMessage)
                    // sorted:保证错误顺序稳定,方便测试和日志比较。
                    .sorted()
                    // toList:收集为不可变列表。
                    .toList();
 
            // 参数校验失败后立即返回,不查询真实业务数据。
            return ToolResult.failure(
                    "INVALID_ARGUMENT",
                    "工具参数校验失败",
                    errors
            );
        }
 
        OrderStatusData order = mockOrders.get(command.orderId());
 
        if (order == null) {
            // 格式正确但数据不存在,属于业务校验失败。
            return ToolResult.failure(
                    "ORDER_NOT_FOUND",
                    "未找到订单",
                    List.of("订单 " + command.orderId() + " 不存在")
            );
        }
 
        // userId 当前只证明可信上下文已经进入 Tool。
        // 第 19 天应在这里或业务 Service 中继续校验用户是否有权查看该订单。
        return ToolResult.success(
                "用户 " + userId + " 的订单状态查询成功",
                order
        );
    }
 
    /**
     * 返回给模型的订单数据只保留必要字段,不直接返回数据库实体。
     */
    public record OrderStatusData(
            String orderId,
            String status,
            String description
    ) {
    }
}

当前流程中,参数来源和处理关系如下:

orderId
  ← 模型根据用户问题生成
  ← @ToolParam 引导格式
  ← Validator 真正校验格式
 
userId
  ← Java 应用通过 ToolContext 传入
  ← 模型不可填写

4.3.4 定义聊天接口

文件位置:

src/main/java/com/afyke/ai/controller/ToolChatController.java
package com.afyke.ai.controller;
 
import com.afyke.ai.tool.OrderQueryTools;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.Map;
 
@RestController
@RequestMapping("/api/tool-chat")
public class ToolChatController {
 
    private final ChatClient chatClient;
    private final OrderQueryTools orderQueryTools;
 
    public ToolChatController(
            ChatClient.Builder chatClientBuilder,
            OrderQueryTools orderQueryTools) {
        // build:根据 Spring AI 自动配置的模型创建 ChatClient。
        this.chatClient = chatClientBuilder.build();
        this.orderQueryTools = orderQueryTools;
    }
 
    @PostMapping
    public ChatResponse chat(@Valid @RequestBody ChatRequest request) {
        String answer = chatClient.prompt()
                // system:要求实时订单信息必须通过 Tool 查询,不能猜测。
                .system("""
                        你是订单查询助手。
                        实时订单状态必须调用 queryOrderStatus 工具查询,不能根据常识猜测。
                        如果工具返回参数错误,请用中文告诉用户正确格式并要求补充。
                        如果订单不存在,请明确说明未找到,不要编造状态。
                        """)
                // user:本次用户的自然语言问题。
                .user(request.message())
                // tools:只给本次请求开放订单查询工具。
                .tools(orderQueryTools)
                // toolContext:把可信用户上下文直接传给 Tool,不让模型生成。
                // 这里只是学习用占位值;生产环境应从认证登录态读取。
                .toolContext(Map.of("userId", "demo-user"))
                // call:发起同步模型请求,并驱动 Tool Calling 循环。
                .call()
                // content:取得模型结合 Tool Result 生成的最终文字回答。
                .content();
 
        return new ChatResponse(answer);
    }
 
    public record ChatRequest(
            // 这里校验的是 HTTP 请求体,不等同于 Tool 参数校验。
            @NotBlank(message = "message 不能为空")
            String message
    ) {
    }
 
    public record ChatResponse(String answer) {
    }
}

这里有两套不同的校验:

校验位置校验对象作用
Controller 的 @ValidHTTP 请求中的 message防止客户端发送空问题
Tool 中的 validator.validate(...)模型生成的 orderId防止错误 Tool 参数进入业务逻辑

两者不能互相替代。

4.3.5 编写确定性测试

模型输出具有概率性,只用聊天接口测试,很难稳定覆盖所有边界。参数校验应直接测试 Java 方法。

文件位置:

src/test/java/com/afyke/ai/tool/OrderQueryToolsTest.java
package com.afyke.ai.tool;
 
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
 
import static org.assertj.core.api.Assertions.assertThat;
 
class OrderQueryToolsTest {
 
    private static ValidatorFactory validatorFactory;
    private static OrderQueryTools orderQueryTools;
 
    @BeforeAll
    static void setUp() {
        // buildDefaultValidatorFactory:启动默认 Bean Validation 实现。
        validatorFactory = Validation.buildDefaultValidatorFactory();
        Validator validator = validatorFactory.getValidator();
        orderQueryTools = new OrderQueryTools(validator);
    }
 
    @AfterAll
    static void tearDown() {
        // close:测试结束后释放 ValidatorFactory 资源。
        validatorFactory.close();
    }
 
    @Test
    void shouldRejectInvalidOrderId() {
        // 直接测试 Tool 内部流程,不依赖模型是否稳定地产生错误参数。
        ToolResult<OrderQueryTools.OrderStatusData> result =
                orderQueryTools.queryOrderStatusInternal("订单一号", "demo-user");
 
        assertThat(result.success()).isFalse();
        assertThat(result.code()).isEqualTo("INVALID_ARGUMENT");
        assertThat(result.errors())
                .contains("订单编号格式错误,应为字母 A 加 4~10 位数字,例如 A1001");
    }
 
    @Test
    void shouldReturnNotFoundWhenFormatIsValidButOrderDoesNotExist() {
        ToolResult<OrderQueryTools.OrderStatusData> result =
                orderQueryTools.queryOrderStatusInternal("A9999", "demo-user");
 
        assertThat(result.success()).isFalse();
        assertThat(result.code()).isEqualTo("ORDER_NOT_FOUND");
    }
 
    @Test
    void shouldReturnOrderStatusWhenInputIsValid() {
        ToolResult<OrderQueryTools.OrderStatusData> result =
                orderQueryTools.queryOrderStatusInternal("A1001", "demo-user");
 
        assertThat(result.success()).isTrue();
        assertThat(result.code()).isEqualTo("OK");
        assertThat(result.data().status()).isEqualTo("SHIPPED");
    }
}

这三个测试分别覆盖:

格式错误
  → INVALID_ARGUMENT
 
格式正确但不存在
  → ORDER_NOT_FOUND
 
格式正确且存在
  → OK + SHIPPED

4.3.6 启动前先运行测试

在项目根目录执行:

/Volumes/data/develop/apache-maven-3.9.16/bin/mvn test

预期结果:

Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

如果测试失败,优先检查:

  1. pom.xml 是否包含 spring-boot-starter-validation;
  2. 正则表达式在 Java 字符串中是否写成 ^A\\d{4,10}$;
  3. 测试类与 OrderQueryTools 是否在同一个包下;
  4. 是否误删了 Validation Provider 依赖。

4.3.7 启动项目

先配置 API Key:

export DEEPSEEK_API_KEY="替换成你自己的密钥"

再启动:

/Volumes/data/develop/apache-maven-3.9.16/bin/mvn spring-boot:run

不要把真实密钥写进 Markdown、源码、命令历史截图或 Git。

4.3.8 验证正确参数

请求:

curl -X POST 'http://localhost:8080/api/tool-chat' \
  -H 'Content-Type: application/json' \
  -d '{"message":"查询订单 A1001 的当前状态"}'

工具内部结果近似为:

{
  "success": true,
  "code": "OK",
  "message": "用户 demo-user 的订单状态查询成功",
  "data": {
    "orderId": "A1001",
    "status": "SHIPPED",
    "description": "订单已发货"
  },
  "errors": []
}

最终 HTTP 响应由模型组织,措辞可能不同,例如:

{
  "answer": "订单 A1001 已发货。"
}

4.3.9 验证错误格式

请求:

curl -X POST 'http://localhost:8080/api/tool-chat' \
  -H 'Content-Type: application/json' \
  -d '{"message":"帮我查询订单一号"}'

如果模型仍然尝试调用工具,Tool 应返回 INVALID_ARGUMENT。最终回答应说明订单号格式,并要求用户提供类似 A1001 的编号。

结果可能出现两种合理路径

模型可能直接发现信息不足并向用户追问,也可能先调用 Tool,再根据校验失败结果追问。关键不是一定走哪条路径,而是错误参数绝不能进入真实订单查询逻辑。

4.3.10 验证业务不存在

请求:

curl -X POST 'http://localhost:8080/api/tool-chat' \
  -H 'Content-Type: application/json' \
  -d '{"message":"查询订单 A9999"}'

A9999 符合格式,但模拟数据中不存在。Tool 应返回:

{
  "success": false,
  "code": "ORDER_NOT_FOUND",
  "message": "未找到订单",
  "data": null,
  "errors": [
    "订单 A9999 不存在"
  ]
}

这证明“格式校验通过”与“业务数据存在”是两件事。

4.4 常见错误与排查

4.4.1 只写 @ToolParam,没有后端校验

现象:正常问题可以运行,但模型偶尔传入空值或错误格式后,业务 Service 仍被调用。

原因:@ToolParam 主要用于生成 Schema 和引导模型,不会替代业务代码中的校验。

修复:使用 Validator 或等价的显式校验,并确保失败后立即返回。

4.4.2 加了 @Pattern,错误参数仍然通过

现象:命令对象上已经有约束注解,但没有产生任何校验错误。

原因:只声明约束,没有调用 Validator;或者项目没有 Validation Provider。

修复:确认依赖存在,并执行:

validator.validate(command);

4.4.3 把 userId 也定义成模型参数

现象:模型可能猜测、遗漏或伪造用户 ID。

原因:混淆了“用户问题中的业务参数”和“后端可信身份上下文”。

修复:从认证登录态取得身份,通过 ToolContext 或业务上下文传入,并在后端鉴权。

4.4.4 把必填参数错误地标成可选

现象:Tool 经常收到 null,业务代码出现空指针或无意义查询。

修复:真正不可缺少的参数保持必填;用户没有提供时,让模型追问,不要给危险默认值。

4.4.5 把可选参数错误地标成必填

现象:用户没有提供相关信息,模型却自行编造一个值。

修复:使用 @ToolParam(required = false),并由后端提供明确、安全的默认值。

4.4.6 直接把异常堆栈返回模型

现象:回答中出现包名、SQL、服务器地址或内部实现细节。

修复:转换成稳定错误码和受控提示;日志中记录详细异常,Tool Result 只返回必要信息。第 18 天再系统处理工具异常、超时和重试。

五、📌 总结

  • Tool 参数由模型生成,必须按不可信输入处理。
  • @ToolParam 和 JSON Schema 的作用是引导模型,不是替代 Java 校验。
  • 参数只保留完成任务真正需要的字段,并使用清楚的业务名称、格式、范围和示例。
  • 用户 ID、租户 ID 和权限不能让模型填写,应从可信登录态通过后端上下文传入。
  • 校验顺序是:类型绑定 → 基础格式校验 → 业务校验 → 权限与风险校验。
  • 校验失败返回稳定错误码和可理解提示,不暴露异常堆栈和敏感信息。
  • 参数校验必须写确定性单元测试,不能只依赖模型对话测试。

一句话复述

Schema 负责告诉模型“应该怎么填”,Java 校验负责保证“填错也不能执行”。

六、🔗 官方资料