臭名昭著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激活

一、💡 一句话理解

第 15 天核心结论

物流查询工具就是把用户口中的订单号交给 Java 业务层,由物流 Repository 返回真实的承运商、运单号、当前位置和配送状态,再让模型把结构化结果整理成自然语言;模型只负责选择工具和组织答案,不能猜物流进度。

本篇对应 0-学习路线 第 15 天,直接承接 14.实现订单查询工具。

前面已经完成:

今天不重复搭建 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 物流状态与订单状态不是一回事

订单状态回答“交易进行到哪一步”,物流状态回答“包裹运输到哪一步”。

对比订单状态物流状态
典型问题是否付款、取消、完成是否揽收、运输、派送、签收
当前项目工具queryOrderStatusqueryLogistics
主要数据PAID、SHIPPED、COMPLETEDPICKED_UP、IN_TRANSIT、OUT_FOR_DELIVERY、DELIVERED
数据来源订单系统物流系统或承运商接口

例如,订单状态为 SHIPPED 只说明商家已经发货,并不能证明包裹已经进入派送。最终物流进度必须查询物流数据源。

2.3 为什么本篇继续使用 orderId

真实物流系统经常支持按运单号查询。但当前用户通常先知道订单号,而且第 14 天已经统一了订单号格式:大写字母 A 加 4~10 位数字。

因此本篇把 Tool 参数保持为一个:

queryLogistics(String orderId, ToolContext toolContext)

这样做有三个好处:

  1. 用户可以自然地问“订单 A1001 到哪了”;
  2. 模型只需提取一个明确参数,出错概率更低;
  3. 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 方法。

层次当前类职责不负责什么
ToolLogisticsQueryTools描述工具、读取可信上下文、校验模型参数、统一结果不保存物流数据
ServiceLogisticsQueryService编排物流查询并转换对外结果不关心 Prompt 和模型
Repository 接口LogisticsRepository定义按订单号查询物流的契约不关心数据具体来自哪里
Repository 实现InMemoryLogisticsRepository为第 15 天提供可运行的本地数据不是生产物流系统
ControllerLogisticsToolChatController接收自然语言,注册物流 Tool,返回最终回答不直接查物流 Map

这条链路与订单查询保持相同形状:

3.2 orderId 与 userId 的来源不同

数据来源是否发送给模型用途
orderId用户自然语言,由模型提取是确定要查哪个订单
userIdJava 登录上下文,通过 ToolContext 传入否标识当前调用者,后续用于权限校验

Spring AI 官方文档说明,ToolContext 适合传递用户 ID、租户 ID和请求范围等非模型数据,这些数据不会发送给模型。

本篇继续使用 demo-user 作为学习占位值。它只证明可信上下文如何进入 Tool,不代表已经完成第 19 天的真实权限校验。

3.3 成功、参数错误和没有物流记录要分开

情况ToolResult.codeTool 行为模型应该怎样回答
查询成功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 工程:

项目当前值
Java21
Spring Boot4.1.1
Spring AI2.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

依次检查:

  1. LogisticsQueryTools 是否带有 @Component;
  2. Controller 是否调用了 .tools(logisticsQueryTools);
  3. @Tool 的描述是否写清“快递、包裹位置、配送进度、签收”;
  4. 当前模型和接口是否支持 Tool Calling;
  5. 用户问题是否确实包含物流意图。

不要用“请一定调用工具”掩盖注册遗漏或模型不支持的问题。

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 负责把经过校验的订单号交给物流业务层,真实数据回来后模型才能组织答案。

六、📚 官方资料