一、💡 一句话理解
第 15 天核心结论
物流查询工具就是把用户口中的订单号交给 Java 业务层,由物流 Repository 返回真实的承运商、运单号、当前位置和配送状态,再让模型把结构化结果整理成自然语言;模型只负责选择工具和组织答案,不能猜物流进度。
本篇对应 0-学习路线 第 15 天,直接承接 14.实现订单查询工具。
前面已经完成:
- 11.Function Calling、Tool Calling 原理:理解模型只提出调用申请,Java 应用才是真正执行者;
- 12.用 Java 方法定义 Tool:会使用
@Tool、@ToolParam和.tools(...); - 13.工具参数设计和校验:会校验模型生成的参数,并用
ToolContext传递可信上下文; - 14.实现订单查询工具:已经把 Tool、Service 和 Repository 分层。
今天不重复搭建 Tool Calling 基础,而是在当前项目中增加一条独立的物流查询链路:
用户询问订单 A1001 的物流
→ 模型生成 queryLogistics(orderId = "A1001")
→ LogisticsQueryTools 校验参数和调用上下文
→ LogisticsQueryService 执行业务查询
→ LogisticsRepository 读取物流数据
→ ToolResult 返回结构化结果
→ 模型生成最终中文回答本篇严格停在第 15 天
本篇新建独立的
/api/logistics-tool-chat接口,并且只向模型注册queryLogistics。暂不把订单工具和物流工具同时开放给同一次请求;“多个工具自动选择”是第 17 天的内容。
二、🧭 理论:物流查询工具是什么
2.1 它查询的是业务系统中的实时事实
大模型知道“快递通常会经历揽收、运输、派送和签收”,但它不知道某个真实订单现在到了哪里。
用户问:
订单 A1001 到哪了?模型最多只能从问题里提取订单号,并申请调用:
{
"name": "queryLogistics",
"arguments": {
"orderId": "A1001"
}
}真正的运单号、承运商、当前位置和预计送达时间必须由 Java 应用查询。Spring AI 官方文档也明确说明:模型负责请求工具并提供参数,应用负责执行工具;模型不会直接访问工具背后的数据库或 API。
2.2 物流状态与订单状态不是一回事
订单状态回答“交易进行到哪一步”,物流状态回答“包裹运输到哪一步”。
| 对比 | 订单状态 | 物流状态 |
|---|---|---|
| 典型问题 | 是否付款、取消、完成 | 是否揽收、运输、派送、签收 |
| 当前项目工具 | queryOrderStatus | queryLogistics |
| 主要数据 | PAID、SHIPPED、COMPLETED | PICKED_UP、IN_TRANSIT、OUT_FOR_DELIVERY、DELIVERED |
| 数据来源 | 订单系统 | 物流系统或承运商接口 |
例如,订单状态为 SHIPPED 只说明商家已经发货,并不能证明包裹已经进入派送。最终物流进度必须查询物流数据源。
2.3 为什么本篇继续使用 orderId
真实物流系统经常支持按运单号查询。但当前用户通常先知道订单号,而且第 14 天已经统一了订单号格式:大写字母 A 加 4~10 位数字。
因此本篇把 Tool 参数保持为一个:
queryLogistics(String orderId, ToolContext toolContext)这样做有三个好处:
- 用户可以自然地问“订单 A1001 到哪了”;
- 模型只需提取一个明确参数,出错概率更低;
- Java 业务层负责完成“订单号 → 物流记录”的关系查询。
如果以后确实需要按运单号查询,应把它设计成另一个明确工具或明确参数方案,不要把订单号和运单号都塞进一个含糊的 id 字段。
2.4 物流结果为什么要结构化
不推荐让 Tool 返回一段随意拼接的字符串:
顺丰SF10010001现在在上海,明天到。推荐返回字段稳定的结果:
{
"orderId": "A1001",
"trackingNumber": "SF10010001",
"carrier": "顺丰速运",
"status": "IN_TRANSIT",
"statusDescription": "运输中",
"currentLocation": "上海转运中心",
"updatedAt": "2026-09-28T08:40:00",
"estimatedDeliveryAt": "2026-09-29T18:00:00"
}结构化结果便于模型理解,也方便测试、日志记录以及以后接入真实物流 API。Spring AI 默认使用 Jackson 把 Tool 返回对象序列化为模型能够接收的字符串。
三、⚙️ 理论:完整物流查询链路怎样工作
3.1 各层分别负责什么
沿用第 14 天的分层方式,不把所有逻辑堆进 @Tool 方法。
| 层次 | 当前类 | 职责 | 不负责什么 |
|---|---|---|---|
| Tool | LogisticsQueryTools | 描述工具、读取可信上下文、校验模型参数、统一结果 | 不保存物流数据 |
| Service | LogisticsQueryService | 编排物流查询并转换对外结果 | 不关心 Prompt 和模型 |
| Repository 接口 | LogisticsRepository | 定义按订单号查询物流的契约 | 不关心数据具体来自哪里 |
| Repository 实现 | InMemoryLogisticsRepository | 为第 15 天提供可运行的本地数据 | 不是生产物流系统 |
| Controller | LogisticsToolChatController | 接收自然语言,注册物流 Tool,返回最终回答 | 不直接查物流 Map |
这条链路与订单查询保持相同形状:
3.2 orderId 与 userId 的来源不同
| 数据 | 来源 | 是否发送给模型 | 用途 |
|---|---|---|---|
orderId | 用户自然语言,由模型提取 | 是 | 确定要查哪个订单 |
userId | Java 登录上下文,通过 ToolContext 传入 | 否 | 标识当前调用者,后续用于权限校验 |
Spring AI 官方文档说明,ToolContext 适合传递用户 ID、租户 ID和请求范围等非模型数据,这些数据不会发送给模型。
本篇继续使用 demo-user 作为学习占位值。它只证明可信上下文如何进入 Tool,不代表已经完成第 19 天的真实权限校验。
3.3 成功、参数错误和没有物流记录要分开
| 情况 | ToolResult.code | Tool 行为 | 模型应该怎样回答 |
|---|---|---|---|
| 查询成功 | OK | 返回物流详情 | 根据真实字段说明进度 |
| 订单号为空或格式错误 | INVALID_ARGUMENT | 不调用 Service | 告知正确格式并请用户补充 |
| 缺少可信调用上下文 | CONTEXT_MISSING | 不调用 Service | 说明当前无法确认调用身份 |
| 没有物流记录 | LOGISTICS_NOT_FOUND | 返回受控失败 | 说明暂未查到物流,不编造位置 |
“没有物流记录”可能表示订单尚未发货、数据暂未同步或订单不存在。本篇的数据源无法进一步区分,所以只返回“暂未查到物流记录”。不能擅自推断成“订单不存在”。
3.4 为什么 Tool 方法不返回 Optional
LogisticsQueryService 可以使用 Optional<LogisticsQueryResult> 表达“可能查不到”。但 @Tool 方法最终返回 ToolResult<LogisticsQueryResult>。
原因是 Spring AI 官方文档列出的 Method Tool 限制中,Optional 不适合作为工具参数或返回值;同时模型也更需要明确的成功标志、错误码和错误说明。
3.5 本篇的数据边界
为了严格遵守学习路线,本篇有意保留这些边界:
- 使用内存 Map,不提前接 PostgreSQL;
- 不调用第三方快递公司的真实 API;
- 只实现查询,不实现修改物流状态;
- 不实现超时和重试,第 18 天再集中处理工具异常;
- 不实现真实权限判断,第 19 天再加入权限和人工确认;
- 不同时注册订单与物流工具,第 17 天再学习自动选择。
四、🚀 实践:实现完整物流查询工具
4.1 前置准备
4.1.1 当前项目环境
本篇直接使用当前项目,不新建 Spring Boot 工程:
| 项目 | 当前值 |
|---|---|
| Java | 21 |
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 |
| 构建工具 | Maven 3.9.16 |
| Web 技术 | 当前项目同时包含 WebMVC 和 WebFlux;本篇 Controller 使用 WebMVC 注解 |
| 模型接入 | OpenAI 兼容接口,由 application.properties 配置 |
Maven 路径:
/Volumes/data/develop/apache-maven-3.9.16本机 Java 21 路径:
/Volumes/data/develop/JDK/jdk21/Contents/Home当前终端默认 java 仍可能指向 Java 8。执行 Maven 前先在当前终端切换到 Java 21:
export JAVA_HOME="/Volumes/data/develop/JDK/jdk21/Contents/Home"
export PATH="$JAVA_HOME/bin:$PATH"
java -version预期 java -version 显示 21。若仍显示 1.8,后续会因为 record 和文本块语法无法识别而编译失败。
4.1.2 沿用已有依赖
当前 pom.xml 已经包含本篇需要的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>因此本篇不修改 pom.xml。
4.1.3 沿用安全的模型配置
当前项目使用环境变量读取 API Key:
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运行前在本机设置真实密钥,不要把它写进代码、笔记或 Git:
export DEEPSEEK_API_KEY="替换为你自己的密钥"模型名称和能力会变化。若普通聊天成功但 Tool 不执行,应先确认当前账号下的模型与 OpenAI 兼容接口是否支持 Tool Calling。
4.1.4 本篇新增文件
src/main/java/com/afyke/ai/
├── logistics/
│ ├── domain/
│ │ ├── Logistics.java
│ │ └── LogisticsStatus.java
│ ├── repository/
│ │ ├── LogisticsRepository.java
│ │ └── InMemoryLogisticsRepository.java
│ └── service/
│ ├── LogisticsQueryResult.java
│ └── LogisticsQueryService.java
├── tool/
│ ├── LogisticsQueryTools.java
│ └── QueryLogisticsCommand.java
└── controller/toolController/
└── LogisticsToolChatController.java
src/test/java/com/afyke/ai/
├── logistics/service/
│ └── LogisticsQueryServiceTests.java
└── tool/
└── LogisticsQueryToolsTests.java已有的 ToolResult.java 直接复用,不重复创建。
4.2 可以拿来干什么
4.2.1 用自然语言查询真实物流字段
用途:用户不必记住固定查询接口,只要说“订单 A1001 到哪了”,模型就能提取订单号并调用 Java Tool。
前置对象:LogisticsQueryTools 由 Spring 注入,模型配置来自当前 application.properties。
String answer = chatClient.prompt()
// user:放入用户原始问题,由模型判断是否需要查询物流。
.user("订单 A1001 到哪了?")
// tools:只给本次请求开放物流工具。
.tools(logisticsQueryTools)
// toolContext:userId 由应用提供,不允许模型伪造。
.toolContext(Map.of("userId", "demo-user"))
// call:发起同步调用,并驱动完整工具调用循环。
.call()
// content:取得模型结合物流结果生成的最终文字。
.content();输入是自然语言,处理过程是“模型提取 orderId → Java 查询”,输出是最终中文回答。适用于客服聊天、订单详情助手和售后入口。
4.2.2 让普通接口与 Agent 复用物流业务层
用途:LogisticsQueryService 不依赖大模型,因此普通 REST 接口、后台任务或其他 Agent 都能复用。
前置对象:由 Spring 注入 LogisticsQueryService。
Optional<LogisticsQueryResult> result =
logisticsQueryService.queryByOrderId("A1001");输入是订单号,输出是可能存在、也可能为空的物流结果。适合确定性业务代码;这类调用不消耗模型 Token。
4.2.3 将来替换真实物流数据源
用途:当前 Service 只依赖 LogisticsRepository 接口,以后可以改成数据库、RPC 或承运商 API 实现。
public interface LogisticsRepository {
Optional<Logistics> findByOrderId(String orderId);
}只要新实现继续遵守这个契约,Tool 和 Controller 不需要感知数据来自哪里。
4.3 完整实践:代码、启动和验证
4.3.1 定义物流状态
文件位置:
src/main/java/com/afyke/ai/logistics/domain/LogisticsStatus.java
package com.afyke.ai.logistics.domain;
/**
* 当前学习项目支持的物流状态。
* 英文枚举值保持稳定,中文说明用于回答用户。
*/
public enum LogisticsStatus {
PICKED_UP("已揽收"),
IN_TRANSIT("运输中"),
OUT_FOR_DELIVERY("派送中"),
DELIVERED("已签收"),
EXCEPTION("物流异常");
private final String description;
LogisticsStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}status 适合程序判断,description 适合模型和用户阅读。
4.3.2 定义内部物流对象
文件位置:
src/main/java/com/afyke/ai/logistics/domain/Logistics.java
package com.afyke.ai.logistics.domain;
import java.time.LocalDateTime;
/**
* 应用内部物流对象。
* 当前只保留查询页面和模型回答真正需要的字段。
*/
public record Logistics(
// 业务订单编号:把物流记录与订单关联起来,例如 A1001。
String orderId,
// 物流运单号:由承运商生成,用于识别具体包裹,例如 SF10010001。
String trackingNumber,
// 承运商名称:说明包裹由哪家物流公司负责运输,例如顺丰速运。
String carrier,
// 当前物流状态:使用 LogisticsStatus 枚举限制可选值,例如运输中或已签收。
LogisticsStatus status,
// 当前所在位置:表示最近一次物流轨迹发生的地点,例如上海转运中心。
String currentLocation,
// 状态更新时间:表示这条物流进度最后一次更新的时间。
LocalDateTime updatedAt,
// 预计送达时间:物流系统根据当前运输进度预测的送达时间。
LocalDateTime estimatedDeliveryAt
) {
}生产项目中,这个对象可能由数据库实体或第三方物流响应转换而来。
4.3.3 定义物流 Repository 契约
文件位置:
src/main/java/com/afyke/ai/logistics/repository/LogisticsRepository.java
package com.afyke.ai.logistics.repository;
import com.afyke.ai.logistics.domain.Logistics;
import java.util.Optional;
/**
* 物流数据读取契约。
* Service 只依赖接口,不依赖内存 Map 或第三方快递 API。
*/
public interface LogisticsRepository {
/**
* findByOrderId:按照订单编号查询物流。
* 找到时返回 Optional.of(logistics),找不到时返回 Optional.empty()。
*/
Optional<Logistics> findByOrderId(String orderId);
}4.3.4 提供可运行的内存物流数据
文件位置:
src/main/java/com/afyke/ai/logistics/repository/InMemoryLogisticsRepository.java
package com.afyke.ai.logistics.repository;
import com.afyke.ai.logistics.domain.Logistics;
import com.afyke.ai.logistics.domain.LogisticsStatus;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
/**
* 第 15 天使用的本地物流数据源。
* 它让调用链无需数据库和第三方物流账号即可运行。
*/
@Repository
public class InMemoryLogisticsRepository implements LogisticsRepository {
private final Map<String, Logistics> logisticsByOrderId = Map.of(
"A1001", new Logistics(
"A1001",
"SF10010001",
"顺丰速运",
LogisticsStatus.IN_TRANSIT,
"上海转运中心",
LocalDateTime.of(2026, 9, 28, 8, 40),
LocalDateTime.of(2026, 9, 29, 18, 0)
),
"A1003", new Logistics(
"A1003",
"YT10030001",
"圆通速递",
LogisticsStatus.DELIVERED,
"杭州市西湖区",
LocalDateTime.of(2026, 9, 27, 16, 20),
LocalDateTime.of(2026, 9, 27, 18, 0)
)
);
@Override
public Optional<Logistics> findByOrderId(String orderId) {
// ofNullable:Map 中没有记录时返回 Optional.empty(),不返回 null。
return Optional.ofNullable(logisticsByOrderId.get(orderId));
}
}这里故意不给 A1002 配置物流,用于验证“没有物流记录”的分支。
4.3.5 定义对外物流查询结果
文件位置:
src/main/java/com/afyke/ai/logistics/service/LogisticsQueryResult.java
package com.afyke.ai.logistics.service;
import java.time.LocalDateTime;
/**
* 物流查询的只读结果。
* 不把第三方物流响应中的内部字段和敏感字段直接交给模型。
*/
public record LogisticsQueryResult(
String orderId,
String trackingNumber,
String carrier,
String status,
String statusDescription,
String currentLocation,
LocalDateTime updatedAt,
LocalDateTime estimatedDeliveryAt
) {
}4.3.6 实现物流查询 Service
文件位置:
src/main/java/com/afyke/ai/logistics/service/LogisticsQueryService.java
package com.afyke.ai.logistics.service;
import com.afyke.ai.logistics.domain.Logistics;
import com.afyke.ai.logistics.repository.LogisticsRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* 物流查询业务层。
* 它不依赖 ChatClient、Prompt 或 @Tool,可以被其他入口复用。
*/
@Service
public class LogisticsQueryService {
private final LogisticsRepository logisticsRepository;
public LogisticsQueryService(LogisticsRepository logisticsRepository) {
// Spring 注入当前唯一的 InMemoryLogisticsRepository 实现。
this.logisticsRepository = logisticsRepository;
}
public Optional<LogisticsQueryResult> queryByOrderId(String orderId) {
return logisticsRepository.findByOrderId(orderId)
// map:只有查到 Logistics 时才转换成对外结果。
// Repository 返回 empty 时,结果继续保持 Optional.empty()。
.map(this::toQueryResult);
}
private LogisticsQueryResult toQueryResult(Logistics logistics) {
return new LogisticsQueryResult(
logistics.orderId(),
logistics.trackingNumber(),
logistics.carrier(),
logistics.status().name(),
logistics.status().getDescription(),
logistics.currentLocation(),
logistics.updatedAt(),
logistics.estimatedDeliveryAt()
);
}
}4.3.7 定义物流查询参数校验对象
文件位置:
src/main/java/com/afyke/ai/tool/QueryLogisticsCommand.java
package com.afyke.ai.tool;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* Java 后端真正执行校验的物流查询命令。
* @ToolParam 负责引导模型,这里的 Bean Validation 才负责拦截错误输入。
*/
public record QueryLogisticsCommand(
@NotBlank(message = "订单编号不能为空")
@Pattern(
regexp = "^A\\d{4,10}$",
message = "订单编号格式错误,应为字母 A 加 4~10 位数字,例如 A1001"
)
String orderId
) {
}虽然规则与 QueryOrderCommand 相同,本篇先保留独立命令名,让每个 Tool 的入口清楚。项目继续扩大后,可以把共同的订单号值对象下沉到领域层。
4.3.8 实现 queryLogistics Tool
文件位置:
src/main/java/com/afyke/ai/tool/LogisticsQueryTools.java
package com.afyke.ai.tool;
import com.afyke.ai.logistics.service.LogisticsQueryResult;
import com.afyke.ai.logistics.service.LogisticsQueryService;
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;
@Component
public class LogisticsQueryTools {
private final Validator validator;
private final LogisticsQueryService logisticsQueryService;
public LogisticsQueryTools(
Validator validator,
LogisticsQueryService logisticsQueryService) {
// Validator:校验模型生成的 orderId。
this.validator = validator;
// Service:执行真正的物流查询。
this.logisticsQueryService = logisticsQueryService;
}
@Tool(
name = "queryLogistics",
description = "根据订单编号查询物流信息;用户询问快递、包裹位置、配送进度、是否派送或签收时使用;只读,不修改物流"
)
public ToolResult<LogisticsQueryResult> queryLogistics(
@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()) {
return ToolResult.failure(
"CONTEXT_MISSING",
"缺少调用用户上下文",
List.of("Java 应用必须通过 ToolContext 提供 userId")
);
}
// Tool 入口只处理上下文和边界,再把查询交给内部方法与 Service。
return queryLogisticsInternal(orderId);
}
/**
* 内部方法不依赖 ToolContext,便于确定性测试参数和业务结果。
*/
ToolResult<LogisticsQueryResult> queryLogisticsInternal(String orderId) {
QueryLogisticsCommand command = new QueryLogisticsCommand(orderId);
// validate:执行 @NotBlank 和 @Pattern 约束。
var violations = validator.validate(command);
if (!violations.isEmpty()) {
List<String> errors = violations.stream()
// getMessage:只保留受控错误信息,不把异常堆栈交给模型。
.map(ConstraintViolation::getMessage)
// sorted:让返回顺序稳定,方便测试断言。
.sorted()
.toList();
// 参数错误时立即结束,不调用物流 Service。
return ToolResult.failure(
"INVALID_ARGUMENT",
"工具参数校验失败",
errors
);
}
return logisticsQueryService.queryByOrderId(command.orderId())
// map:查到物流时包装成统一成功结果。
.map(logistics -> ToolResult.success("物流查询成功", logistics))
// orElseGet:没有记录时返回受控失败,不让模型猜测物流位置。
.orElseGet(() -> ToolResult.failure(
"LOGISTICS_NOT_FOUND",
"暂未查到物流记录",
List.of("订单 " + command.orderId() + " 暂无可用物流信息")
));
}
}这里复用项目已有的 ToolResult<T>。成功与失败都有稳定结构,模型无需从异常文本中猜测发生了什么。
4.3.9 新建独立物流聊天接口
文件位置:
src/main/java/com/afyke/ai/controller/toolController/LogisticsToolChatController.java
package com.afyke.ai.controller.toolController;
import com.afyke.ai.tool.LogisticsQueryTools;
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/logistics-tool-chat")
public class LogisticsToolChatController {
private final ChatClient chatClient;
private final LogisticsQueryTools logisticsQueryTools;
public LogisticsToolChatController(
ChatClient.Builder chatClientBuilder,
LogisticsQueryTools logisticsQueryTools) {
// build:使用 Spring AI 自动配置的 ChatModel 创建 ChatClient。
this.chatClient = chatClientBuilder.build();
this.logisticsQueryTools = logisticsQueryTools;
}
@PostMapping
public ChatResponse chat(@Valid @RequestBody ChatRequest request) {
String answer = chatClient.prompt()
// system:物流实时事实必须以 Tool 结果为准,禁止模型猜测。
.system("""
你是物流查询助手。
用户询问快递、包裹位置、配送进度或签收状态时,必须调用 queryLogistics 工具查询,不能根据常识猜测。
如果用户没有提供订单编号,请要求用户补充,不能编造编号。
如果工具返回参数错误,请用中文说明正确格式。
如果工具返回暂未查到物流,请如实说明,不要编造承运商、位置或送达时间。
""")
// user:本次用户的自然语言问题。
.user(request.message())
// tools:第 15 天只开放物流工具,不提前实现多工具自动选择。
.tools(logisticsQueryTools)
// toolContext:学习阶段使用占位用户;生产环境应读取认证登录态。
.toolContext(Map.of("userId", "demo-user"))
// call:发起同步模型调用,并由 Spring AI 驱动 Tool Calling 循环。
.call()
// content:取得模型结合 Tool Result 生成的最终回答。
.content();
return new ChatResponse(answer);
}
public record ChatRequest(
// 这里只校验 HTTP 请求体;Tool 内还会再次校验模型生成的 orderId。
@NotBlank(message = "message 不能为空")
String message
) {
}
public record ChatResponse(String answer) {
}
}本篇保留已有 /api/tool-chat 订单查询接口不动。两个独立入口分别只注册一个工具,等第 17 天再合并。
4.3.10 测试物流 Service
文件位置:
src/test/java/com/afyke/ai/logistics/service/LogisticsQueryServiceTests.java
package com.afyke.ai.logistics.service;
import com.afyke.ai.logistics.repository.InMemoryLogisticsRepository;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LogisticsQueryServiceTests {
private final LogisticsQueryService service =
new LogisticsQueryService(new InMemoryLogisticsRepository());
@Test
void shouldReturnLogisticsWhenOrderHasRecord() {
var result = service.queryByOrderId("A1001");
assertTrue(result.isPresent());
assertEquals("SF10010001", result.orElseThrow().trackingNumber());
assertEquals("IN_TRANSIT", result.orElseThrow().status());
assertEquals("上海转运中心", result.orElseThrow().currentLocation());
}
@Test
void shouldReturnEmptyWhenLogisticsDoesNotExist() {
var result = service.queryByOrderId("A1002");
assertTrue(result.isEmpty());
}
}这个测试不启动 Spring 上下文、不调用模型,也不需要 API Key。
4.3.11 测试 Tool 参数与业务边界
文件位置:
src/test/java/com/afyke/ai/tool/LogisticsQueryToolsTests.java
package com.afyke.ai.tool;
import com.afyke.ai.logistics.repository.InMemoryLogisticsRepository;
import com.afyke.ai.logistics.service.LogisticsQueryService;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class LogisticsQueryToolsTests {
private static Validator validator;
@BeforeAll
static void createValidator() {
// buildDefaultValidatorFactory:创建 Jakarta Validation 的默认实现。
validator = Validation.buildDefaultValidatorFactory().getValidator();
}
private LogisticsQueryTools createTools() {
var repository = new InMemoryLogisticsRepository();
var service = new LogisticsQueryService(repository);
return new LogisticsQueryTools(validator, service);
}
@Test
void shouldReturnSuccessForExistingLogistics() {
var result = createTools().queryLogisticsInternal("A1001");
assertTrue(result.success());
assertEquals("OK", result.code());
assertEquals("SF10010001", result.data().trackingNumber());
}
@Test
void shouldRejectInvalidOrderId() {
var result = createTools().queryLogisticsInternal("1001");
assertFalse(result.success());
assertEquals("INVALID_ARGUMENT", result.code());
assertTrue(result.errors().stream()
.anyMatch(message -> message.contains("订单编号格式错误")));
}
@Test
void shouldReturnControlledFailureWhenNoLogisticsExists() {
var result = createTools().queryLogisticsInternal("A1002");
assertFalse(result.success());
assertEquals("LOGISTICS_NOT_FOUND", result.code());
}
}这里直接测试内部方法,避免单元测试依赖真实模型响应。ToolContext 是否缺失的分支可以在后续安全测试中单独补充。
4.3.12 编译并运行测试
项目没有 Maven Wrapper,因此使用学习路线指定的 Maven:
/Volumes/data/develop/apache-maven-3.9.16/bin/mvn clean compile预期结果:
BUILD SUCCESS再运行本篇测试:
/Volumes/data/develop/apache-maven-3.9.16/bin/mvn \
-Dtest=LogisticsQueryServiceTests,LogisticsQueryToolsTests test预期结果:5 个测试通过。若测试数量不同,以实际新增测试为准,但不应出现失败或错误。
4.3.13 启动应用
先确认 Redis 已按当前项目配置启动,并设置模型 API Key,然后运行:
/Volumes/data/develop/apache-maven-3.9.16/bin/mvn spring-boot:run如果只想验证 Service 和 Tool 单元测试,不需要启动 Redis,也不需要调用模型。
4.3.14 验证物流查询成功
请求:
curl -X POST 'http://localhost:8080/api/logistics-tool-chat' \
-H 'Content-Type: application/json' \
-d '{"message":"订单 A1001 到哪了?"}'模型措辞可能不同,但回答应只基于内存数据,例如:
{
"answer": "订单 A1001 由顺丰速运承运,运单号为 SF10010001,目前正在运输中,最新位置是上海转运中心,预计 2026 年 9 月 29 日 18:00 前送达。"
}验收重点不是句子完全一致,而是承运商、运单号、状态、位置和时间不能与 Tool 结果冲突。
4.3.15 验证已签收物流
curl -X POST 'http://localhost:8080/api/logistics-tool-chat' \
-H 'Content-Type: application/json' \
-d '{"message":"帮我查一下 A1003 的快递是否签收"}'预期回答应明确 A1003 已签收,承运商为圆通速递,最新位置为杭州市西湖区。
4.3.16 验证没有物流记录
curl -X POST 'http://localhost:8080/api/logistics-tool-chat' \
-H 'Content-Type: application/json' \
-d '{"message":"查询订单 A1002 的物流"}'预期:回答“暂未查到物流记录”或同义内容,不能编造快递公司、运单号和包裹位置。
4.3.17 验证缺少或错误订单号
缺少订单号:
curl -X POST 'http://localhost:8080/api/logistics-tool-chat' \
-H 'Content-Type: application/json' \
-d '{"message":"我的快递到哪了?"}'预期:模型要求用户补充订单号,而不是编造一个编号。
格式错误:
curl -X POST 'http://localhost:8080/api/logistics-tool-chat' \
-H 'Content-Type: application/json' \
-d '{"message":"查询订单 1001 的物流"}'预期:说明订单编号应为大写字母 A 加 4~10 位数字,例如 A1001。
4.3.18 本日验收清单
-
queryLogistics能被模型调用; - Tool 参数只包含用户能够提供的
orderId; -
userId通过ToolContext传入,不由模型生成; - 物流查询经过 Tool、Service 和 Repository 分层;
- 查询成功时返回结构化物流字段;
- 参数错误时不进入 Service;
- 没有物流记录时返回受控失败,不编造结果;
- Service 和 Tool 边界测试通过;
- 第 15 天接口只注册物流工具,没有提前实现多工具自动选择。
4.4 关键报错排查
4.4.1 模型没有调用 queryLogistics
依次检查:
LogisticsQueryTools是否带有@Component;- Controller 是否调用了
.tools(logisticsQueryTools); @Tool的描述是否写清“快递、包裹位置、配送进度、签收”;- 当前模型和接口是否支持 Tool Calling;
- 用户问题是否确实包含物流意图。
不要用“请一定调用工具”掩盖注册遗漏或模型不支持的问题。
4.4.2 Tool 被调用,但总是返回参数错误
检查模型生成的 orderId 是否为 A1001 这种格式。@ToolParam 只是模型说明书,Java 正则才是最终校验。
如果业务中的真实订单号规则不同,应同时修改:
@ToolParam.description;QueryLogisticsCommand的正则;- 测试数据和请求示例。
三者必须保持一致。
4.4.3 返回 CONTEXT_MISSING
说明调用处没有提供:
.toolContext(Map.of("userId", "demo-user"))生产环境不能永远写死 demo-user,应从认证后的用户上下文读取。真实权限校验安排在第 19 天。
4.4.4 A1002 为什么不是“订单不存在”
当前 LogisticsRepository 只能说明“有没有物流记录”,不能证明订单是否存在。因此它只能返回 LOGISTICS_NOT_FOUND。
若要区分“订单不存在”和“尚未发货”,需要联合订单服务判断。这会涉及多个业务能力的组合,本篇不提前展开。
4.4.5 时间字段显示格式不统一
当前结果使用 LocalDateTime,Jackson 会序列化为 ISO 风格时间,模型可能再改写成中文。
测试时应优先校验原始结构化字段,不要依赖模型的具体标点和日期措辞。若生产系统跨时区,应改用带时区的时间类型并统一时区策略。
4.4.6 Tool 内抛出异常后模型直接展示异常信息
本篇对“参数错误”和“没有记录”都返回 ToolResult.failure(...),不会故意抛异常。
Spring AI 2.0.1 默认会把部分运行时异常消息交回模型。不要在异常消息中包含数据库地址、SQL、密钥、用户隐私或堆栈。工具异常、超时与重试会在第 18 天集中处理。
4.4.7 应用启动失败,但单元测试可以通过
当前项目还配置了 Redis 聊天记忆。完整启动失败时,检查 Redis 是否运行、端口是否为 6379。
本篇的 LogisticsQueryServiceTests 和 LogisticsQueryToolsTests 不启动 Spring 上下文,因此可以先验证物流业务代码,再处理外部依赖。
4.4.8 编译出现 record 或文本块语法错误
先运行:
java -version
/Volumes/data/develop/apache-maven-3.9.16/bin/mvn -version两处都应显示 Java 21。当前机器同时安装了 Java 8,如果 Maven 误用 Java 8,即使 pom.xml 写了 <java.version>21</java.version>,也无法用旧 JDK 编译 Java 21 语法。回到 4.1.1,正确设置 JAVA_HOME 后再运行构建。
五、📌 总结
5.1 快速回顾
queryLogistics查询的是业务系统中的物流事实,模型不能猜位置和时间;- 物流状态与订单状态不同,应该使用独立领域对象和 Repository;
- 用户问题提供
orderId,可信的userId由 Java 应用通过ToolContext提供; - Tool 负责边界,Service 负责编排,Repository 隔离数据来源;
- Tool 返回稳定的
ToolResult<LogisticsQueryResult>,不直接返回Optional或异常堆栈; - 第 15 天使用独立物流接口,只注册一个物流工具;第 17 天再合并多个工具自动选择。
一句话复述
模型负责听懂“用户想查物流”,Java Tool 负责把经过校验的订单号交给物流业务层,真实数据回来后模型才能组织答案。