一、💡 一句话理解
第 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 Validation | Java 应用 | 校验空值、长度、格式和数值范围 | 可以承担基础格式校验 |
| 业务校验 | 业务 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 名称要表达业务含义
| 不推荐 | 推荐 | 原因 |
|---|---|---|
id | orderId | 明确是哪一种 ID |
value | trackingNumber | 明确值的业务含义 |
type | detailLevel | 避免与大量其他“类型”混淆 |
data | afterSaleReason | 模型能直接理解需要提取什么 |
参数名、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.javapackage 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.javapackage 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.javapackage 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.javapackage 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 的 @Valid | HTTP 请求中的 message | 防止客户端发送空问题 |
Tool 中的 validator.validate(...) | 模型生成的 orderId | 防止错误 Tool 参数进入业务逻辑 |
两者不能互相替代。
4.3.5 编写确定性测试
模型输出具有概率性,只用聊天接口测试,很难稳定覆盖所有边界。参数校验应直接测试 Java 方法。
文件位置:
src/test/java/com/afyke/ai/tool/OrderQueryToolsTest.javapackage 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 + SHIPPED4.3.6 启动前先运行测试
在项目根目录执行:
/Volumes/data/develop/apache-maven-3.9.16/bin/mvn test预期结果:
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS如果测试失败,优先检查:
pom.xml是否包含spring-boot-starter-validation;- 正则表达式在 Java 字符串中是否写成
^A\\d{4,10}$; - 测试类与
OrderQueryTools是否在同一个包下; - 是否误删了 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 校验负责保证“填错也不能执行”。