在 Java 后端领域,Spring AI 2.0 正在改变 AI Agent 的落地方式。过去要在一个 Spring Boot 项目中接入大模型,需要自己处理 HTTP 客户端、消息历史、函数调用解析和上下文管理;而 Spring AI 把这些能力统一成了可配置、可测试的组件。本文用一个智能航空项目作为主线,从零搭建一套可运行的 AI Agent 系统,包含机票查询、智能客服和行程助手三个典型场景。
这套项目适合已经熟悉 Spring Boot、但还没有完整做过 AI Agent 工程化的 Java 开发者。阅读过程中可以重点观察一件事:Agent 不是把用户消息直接丢给大模型就结束,而是由模型决定调用哪些工具、如何利用多轮记忆、如何把最终结果按业务要求返回。下面先分析智能航空项目中 Agent 的定位,再逐步完成工程搭建、工具开发、客服对话、行程助手和验证排错。所有示例代码用于说明实现思路,实际落地前需要根据 Spring AI 当前版本、模型供应商和项目包名做调整。
1. AI Agent 在智能航空项目中的定位
1.1 从关键词匹配到大模型调度
传统航空客服系统通常依赖关键词匹配或规则引擎。用户输入“北京到上海明天的航班”,系统从 Elasticsearch 或数据库里按“出发地、到达地、日期”三个字段做精确查询。这种方式在问题边界明确时很可靠,但一旦用户换一种说法,比如“明天从首都机场去虹桥”或者“帮我看看后天有没有早班机去上海”,匹配规则的维护成本就开始快速上升。
AI Agent 则把“理解意图”和“执行操作”分开。大语言模型负责理解用户自然语言,决定需要调用什么业务接口,Spring AI 负责执行这些接口调用,并把结果交回给模型继续推理。在智能航空项目中,模型并不知道实时航班价格和余票,它必须通过 FlightQueryTool 这类工具去访问业务系统。工具调用让 Agent 从“会聊天的机器人”变成“能办业务的数字员工”。
1.2 智能航空系统需要解决的三个问题
设计这个系统时,可以先回到业务本质。航空场景下面临三个核心问题:
第一,实时数据必须来自业务系统。航班时刻、舱位价格、剩余座位、退改签规则都不能由模型凭记忆生成,否则会出现严重服务事故。第二,用户对话是多轮的。用户先问“北京到上海”,再补充“要上午的”,系统必须记住前文的出发地、到达地和日期。第三,客服结果不能只停留在聊天窗口。系统需要把用户意图、关键槽位、是否需要人工接管记录下来,转入订单系统或工单系统继续处理。
这三个问题正好对应三个模块:航班查询工具负责数据接入,智能客服负责多轮对话和结构化输出,行程助手负责把查询、推荐、确认和下单一整套流程串起来。理解了业务边界,后续代码才不会写成“把大模型 API 包一层”。
1.3 为什么选择 Spring AI 2.0 来落地
市面上接入大模型的方式有很多,可以直接调用 REST API,也可以选择 LangChain4j 或 Spring AI。直接调用 API 的问题在于,Agent 需要的工具调用、会话记忆、结构化输出、向量检索都要自己实现,且每个模型供应商的协议细节不同。LangChain4j 在 Java 生态中也很活跃,但如果团队已经重度使用 Spring Boot,Spring AI 和 Spring 配置体系、Starter、自动装配的结合会更自然。
| 对比维度 | 直接调模型 API | LangChain4j | Spring AI |
|---|---|---|---|
| 工程集成成本 | 高,需自研 | 中,独立框架 | 低,Spring Boot 原生 |
| 工具调用支持 | 需要自己解析 | 支持 | 支持 |
| 对话记忆管理 | 需要自己组装 | 支持 | Advisor 机制 |
| 向量存储抽象 | 无 | 部分 | 抽象较完整 |
| 与 Spring 配置中心、监控集成 | 弱 | 较弱 | 强 |
Spring AI 2.0 的编程模型核心是 ChatClient。它把 Prompt、System Message、Tool、Advisor、Memory 组合成一条可执行的对话链路。开发者不需要关心每个模型供应商的底层 HTTP 细节,只需要写清楚“给模型什么角色、让它用什么工具、结果按什么结构返回”。
注意:Spring AI 版本迭代比较快,不同版本的 API 和配置前缀可能存在差异。本文的代码基于 2.0 常见形态,落地前务必以当前项目使用版本的官方文档和依赖源码为准。
2. 工程骨架与基础配置
2.1 前置环境清单
在开始写代码前,先确认本地环境是否满足条件。推荐使用 JDK 17 或更高版本,因为 Spring Boot 3.x 和 Spring AI 2.0 都已经转向 Jakarta EE 和现代 Java 特性。Maven 建议使用 3.8 以上,IDE 使用 IntelliJ IDEA 或 Eclipse 均可。
| 环境项 | 推荐值 | 说明 |
|---|---|---|
| JDK | 17 或 21 | 决定 Maven 编译器目标和 Lombok 兼容性 |
| Maven | 3.8+ | 依赖解析和构建 |
| Spring Boot | 3.x | 与 Spring AI 2.0 配合使用 |
| Spring AI | 2.0.x | 以当前仓库可用版本为准 |
| 模型 API | OpenAI 兼容接口 | 也可以使用本地模型,如 Ollama 适配器 |
| Elasticsearch | 8.x | 企业知识库检索使用,学习环境可暂缓 |
这里不绑定某一个云厂商。示例配置会使用 OpenAI 兼容协议,这样无论是国内还是海外模型服务,只要能兼容该协议,都可以通过调整 base-url 和 api-key 接入。如果使用本地模型,需要把 base-url 指向本机,只适合开发验证。
2.2 初始化 Spring Boot 工程并引入依赖
可以通过 Spring Initializr 创建工程,也可以手工建立 Maven 项目。核心依赖包括 Spring Boot Web、Spring AI Starter、测试依赖等。在 pom.xml 中,Spring AI 依赖需要放到独立的 dependencyManagement 中管理,因为 Spring AI 的 BOM 和 Spring Boot 的 BOM 是两套版本体系。
<properties> <java.version>17</java.version> <spring-ai.version>2.0.0</spring-ai.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>这里的spring-ai-version要替换成实际可用的版本号。如果公司内部使用私服,还要确认私服是否已经同步 Spring AI 相关依赖。否则第一次构建时会出现依赖下载失败或者版本解析不到的问题。
2.3 配置模型供应商与核心参数
创建src/main/resources/application.yml,把模型连接信息和常用参数放在配置文件中。不要把 api-key 直接写在项目里,推荐使用环境变量注入。Spring AI 的自动化配置会读取这些配置并创建对应的客户端对象。
server: port: 8080 spring: application: name: ai-flight-agent ai: openai: base-url: ${AI_BASE_URL:https://api.example.com/v1} api-key: ${AI_API_KEY:} chat: options: model: ${AI_MODEL:gpt-4o-mini} temperature: 0.2 retry: max-attempts: 3 backoff-initial-interval: 1000 backoff-multiplier: 2.0这些参数对 Agent 行为影响很大。temperature 表示随机性,客服和订票场景建议设置为 0.2 以下,避免模型每次生成不同回复。模型名称要匹配实际可用模型,不同模型对工具调用和结构化输出的支持程度不同。base-url 要确认是否带/v1路径,很多网关配置不一致会导致 404。
如果所在团队使用阿里云百炼、智谱、DeepSeek 等兼容 OpenAI 协议的服务,可以只改 base-url 和模型名。如果使用 Spring AI 的特定云 Starter,比如spring-ai-alibaba,则配置前缀会不同,但核心的 ChatClient 编程模型保持一致。
2.4 项目包结构与职责划分
为了避免把所有逻辑堆在一个类里,这里使用分层结构。controller 负责 HTTP 入口,service 负责业务和 Agent 调用,tool 负责暴露给大模型的领域方法,model 负责请求响应和结构化输出的数据载体,repository 负责会话和订单等数据持久化。
src/main/java/com/example/aiflight/ ├── AiFlightAgentApplication.java ├── config/ │ └── AgentConfig.java ├── controller/ │ ├── AgentController.java │ └── FlightController.java ├── service/ │ ├── FlightService.java │ ├── CustomerService.java │ └── ItineraryService.java ├── tool/ │ ├── FlightQueryTool.java │ ├── FlightOrderTool.java │ └── FlightSeatTool.java ├── model/ │ ├── FlightInfo.java │ ├── CustomerServiceResult.java │ └── ItineraryState.java └── repository/ └── ConversationStateRepository.java层与层之间单向依赖。controller 不直接写大模型逻辑,service 不自己解析函数调用结果,tool 不做复杂的 Prompt 拼接。这样后续接向量库、加限流、接 Redis 会话存储时,改动范围可以控制在某一个模块内。
3. 航班查询工具:让 Agent 能调用真实业务接口
3.1 先定义航班数据模型
航班查询是三个业务场景的地基。先用一个 Java record 表示航班信息,字段包括航班号、航空公司、起降城市、日期、起降时间、价格和余票。价格使用 BigDecimal 而不是 double,避免金额计算出现精度问题。
public record FlightInfo( String flightNo, String airline, String from, String to, String date, String departTime, String arriveTime, BigDecimal price, int remainingSeats ) {}城市字段建议使用三字码,例如 PEK、SHA、CAN。三字码长度固定,模型在处理用户自然语言时更容易映射,业务系统之间的接口也普遍使用这种编码。如果用户输入“北京”和“上海”,需要在 Prompt 或前置处理中把城市转换成三字码,但工具参数仍然保持三字码,避免模型给我们返回来历不明的城市名。
3.2 实现航班查询服务
FlightService 是真实的业务服务。这里不接数据库,先用内存数据模拟,重点演示工具调用的链路。生产环境可以替换成 FlightService 内部调用机票中台。为了验证方便,返回值根据出发地、到达地和日期生成两班模拟航班。
@Service public class FlightService { public List<FlightInfo> query(String from, String to, String date) { if (from == null || to == null || date == null) { return List.of(); } if (from.equals(to)) { return List.of(); } String today = LocalDate.now().toString(); if (date.compareTo(today) < 0) { return List.of(); } BigDecimal price = BigDecimal.valueOf(680 + from.hashCode() % 300); return List.of( new FlightInfo("MU5101", "东方航空", from, to, date, "08:00", "10:15", price, 12), new FlightInfo("CA1501", "中国国航", from, to, date, "13:00", "15:20", price.add(BigDecimal.valueOf(120)), 6) ); } }这段代码的关键点在于对空结果的处理。模型调用工具后,如果返回空列表,Agent 会继续向模型传递“没有航班”的工具结果。业务系统在返回空数据时,千万不要抛出异常,否则模型只会看到“工具执行失败”,很难判断是查询不到还是系统故障。
3.3 用 @Tool 将查询能力暴露给大模型
真正提供给大模型的不是 FlightService 本身,而是一个带描述的工具方法。Spring AI 通过@Tool注解识别可被模型调用的方法,通过@ToolParam的 description 帮助模型理解参数含义。
@Component public class FlightQueryTool { private final FlightService flightService; public FlightQueryTool(FlightService flightService) { this.flightService = flightService; } @Tool(name = "query_flight", description = "根据出发城市三字码、到达城市三字码和日期查询航班,返回可用航班列表") public List<FlightInfo> queryFlight( @ToolParam(description = "出发城市三字码,例如 PEK") String from, @ToolParam(description = "到达城市三字码,例如 SHA") String to, @ToolParam(description = "起飞日期,格式 yyyy-MM-dd") String date) { return flightService.query(from, to, date); } }工具描述写得好不好,直接影响模型能不能正确调用。描述中要写清楚参数格式和业务含义。比如字符串日期格式yyyy-MM-dd必须明确,否则模型可能传回带时间的字符串。在 Spring AI 2.0 中,如果版本不同,@ToolParam 可能被替换或调整名称,但设计思路一致:给模型足够明确的工具签名。
3.4 工具调用在 Spring AI 中的执行链路
工具调用不是代码里提前写好的 if-else,而是一个循环。用户消息进入 ChatClient 后,模型根据工具定义决定是否调用函数,并把函数名和参数以结构化 JSON 返回给 Spring AI。Spring AI 根据 Tool 注册关系找到对应方法,反射调用并拿到返回值,再把“工具调用结果”作为一条消息追加到对话上下文中交给模型,模型最终生成面向用户的回复。
这个循环可能执行多次。例如用户要求“查询北京到上海,再给我推荐最早一班”,模型先调用一次 query_flight,看到结果后可能再调用另一个排序工具或直接给出推荐。理解这条链路后,排查问题时就应该知道:如果模型没有调用工具,先看工具描述是否清晰;如果工具调用报错,先看参数映射;如果最终回答没有用到工具结果,再看上下文是否把工具结果正确传回。
3.5 将工具注册给 ChatClient
在 Spring AI 中,ChatClient 是统一的入口。通过 ChatClient.Builder 可以配置默认工具和默认系统提示词。把 FlightQueryTool 注册进去后,后续所有对话都会携带该工具定义。
@Configuration public class AgentConfig { @Bean public ChatClient chatClient( ChatClient.Builder builder, FlightQueryTool flightQueryTool, ChatMemory chatMemory) { return builder .defaultTools(flightQueryTool) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory) .messageWindowSize(10) .build()) .build(); } }这里把 FlightQueryTool 声明为 Spring Bean,再由构造器注入到 ChatClient。不建议在 Controller 里手动 new 工具对象,因为工具中可能还会依赖数据源、配置中心、权限服务等其他 Bean。使用 Spring 容器统一管理后,后续做单元测试或 Mock 也会方便很多。
4. 智能客服:对话记忆、结构化输出和 Prompt 约束
4.1 客服场景为什么依赖多轮上下文
机票查询场景里,用户很少在一句话里说完全部条件。常见对话是“帮我查北京到上海的航班”,然后追问“上午的有没有”“含税价多少”“改签要钱吗”。如果没有会话记忆,第二条问题就没有出发地、到达地和日期上下文。智能客服的第一层能力就是多轮会话管理。
Spring AI 提供了 ChatMemory 抽象和 MessageChatMemoryAdvisor。ChatMemory 负责存储历史消息,Advisor 在每次请求前把最近的历史消息注入 Prompt,在请求结束后把当前轮次的消息写回存储。这样 ChatClient 内部不再需要手工拼接历史消息。
4.2 使用 InMemoryChatMemory 快速跑通
开发环境可以先用 InMemoryChatMemory,它适合单机和小并发测试。生产环境建议替换成 Redis 或数据库实现,因为多实例部署时,内存实现会导致用户请求落到不同节点时读取不到上下文。
@Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); }为了区分不同用户,每次调用 Agent 时都要传入 conversationId。MessageChatMemoryAdvisor 会按照 conversationId 隔离不同会话。如果所有请求都用同一个 conversationId,所有用户的历史消息会串在一起,这是一个很容易被忽略的问题。
messageWindowSize 控制保留多少条历史消息。设置太小会丢失前文,设置太大会消耗大量 Token。在航空客服场景中,常见的做法是保留最近 10 到 20 条消息,而不是保存整个会话的全部历史。更精细的方案是把关键槽位单独存储,每次只把槽位状态注入 Prompt,后续在行程助手中会演示这种方式。
4.3 用结构化输出约束客服结果
自然语言回复适合展示给用户,但不适合直接落库。客服系统需要知道用户属于哪一类问题、意图是什么、关键参数是什么、是否需要人工接管。Spring AI 支持把模型输出映射到 Java 对象,这就是结构化输出。
先定义一个结果对象:
public record CustomerServiceResult( String category, String intent, Map<String, String> slots, String reply, boolean needHumanHandover ) {}在 ChatClient 中调用.entity(CustomerServiceResult.class),请求模型返回 JSON 并自动反序列化:
public CustomerServiceResult handleMessage(String sessionId, String userMessage) { return chatClient.prompt() .user(userMessage) .call() .entity(CustomerServiceResult.class); }结构化输出对模型能力有要求。部分模型不支持严格的 JSON 模式,或者返回结果缺少字段。建议在 System Prompt 中明确输出字段含义,并给出一个 JSON 示例。实体类尽量使用 record,字段名保持简洁清晰,不要使用中文命名,也不要在字段名中使用特殊字符。
4.4 设计业务级 System Prompt
客服 Agent 的 System Prompt 不能只写“你是智能客服助手”,要包含业务边界、工具使用规则、输出约束和人工接管条件。在航空项目中,至少要约束四点:航班信息必须调用 query_flight 获取,不得编造航班;退改签政策出现争议时转人工;回答中不能承诺赔偿金额;输出必须按 CustomerServiceResult 格式返回。
实际项目中,System Prompt 可以放到 resources 目录下的独立文件:
你是一家航空公司的智能客服助手。 你的任务包括: 1. 根据用户需求查询航班信息。 2. 查询航班信息时必须使用 query_flight 工具,不能凭记忆回答。 3. 如果用户询问退改签、行李、赔偿等复杂政策,先根据知识库回答,无法确认时置 needHumanHandover 为 true。 4. 最终输出必须包含 category、intent、slots、reply、needHumanHandover 五个字段。加载文件的方式可以是ClassPathResource,再拼接到 prompt 中。重点不是文件读取方式,而是让 System Prompt 成为可审查的配置,而不是散落在代码里的字符串。业务规则变更时,只需要修改提示词文件并发布,不需要改 Java 代码。
4.5 客服结果落库与人工接管标记
拿到 CustomerServiceResult 后,建议先做业务校验再落库。比如 intent 是否在业务允许范围内,slots 是否包含必要键值,needHumanHandover 为 true 时是否已经生成了转人工工单。
落库时可以保存 sessionId、userId、用户消息、助手回复、结构化结果 JSON 和创建时间。后续做客服质检、意图统计和 Agent 效果分析时,这些数据会非常有价值。尤其要注意,reply 是展示给用户的文本,category 和 intent 是用于路由和统计的结构化字段,两者职责不同,不能混用。
5. 行程助手:把查询、推荐和决策组合成 Agent 工作流
5.1 行程助手不是简单问答,而是有状态的流程
行程助手和智能客服的差异在于,它需要引导用户完成一个完整任务:收集出发地、目的地、日期、偏好时间、联系方式,然后查询航班、推荐方案、确认订单。这个过程中,仅靠 ChatMemory 还不够,因为模型每次都需要重新理解历史消息中的槽位。更可靠的方式是维护一个行程状态对象,在每次对话前后更新它。
5.2 行程槽位与会话状态管理
定义一个 ItineraryState 类保存关键信息。
public class ItineraryState { private String from; private String to; private String date; private String preferredTime; private String flightNo; private String contactPhone; private boolean confirmed; public boolean isComplete() { return from != null && to != null && date != null && preferredTime != null && contactPhone != null; } }服务端维护一个Map<String, ItineraryState>,key 是 sessionId。每次收到用户消息时,先从状态对象中取出已有槽位,拼入 Prompt 上下文,再把本次回复中提取到的新槽位更新回状态对象。这里的关键思想是:不要只依赖模型从历史消息中推理状态,服务端状态机要作为事实来源。
5.3 组合多个 Tool 完成一站式行程规划
行程助手需要多个工具协作。除了航班查询,还需要选座工具和下单工具。通过 @Tool 暴露以下能力:
| 工具名 | 作用 | 输入 | 输出 |
|---|---|---|---|
| query_flight | 查询可用航班 | from, to, date | 航班列表 |
| select_seat | 查看或选择座位 | flightNo, seatType | 座位结果 |
| create_order | 创建订单 | flightNo, contactPhone, seatNo | 订单号和状态 |
在行程助手的 ChatClient 中,一次性注册所有工具。模型会根据用户意图决定先调用哪个。例如用户说“帮我订明天上午从北京到上海最近的一班飞机”,模型会调用 query_flight,再根据结果询问用户是否需要选座,确认后调用 create_order。
@Bean public ChatClient itineraryChatClient( ChatClient.Builder builder, FlightQueryTool flightQueryTool, FlightSeatTool flightSeatTool, FlightOrderTool flightOrderTool) { return builder .defaultTools(flightQueryTool, flightSeatTool, flightOrderTool) .build(); }工具组合的粒度需要设计。不要把整个 FlightsService 暴露给模型,而是拆成单一职责的方法。工具名越具体,参数越少,模型越容易正确调用。一个工具方法做太多事情,模型很容易不知道何时该调用。
5.4 必须由服务端控制关键动作
这里要特别强调:Agent 可以建议用户下单,但是否真正创建订单,必须由服务端代码根据业务状态确认。不能仅凭模型说“我帮你下单了”就执行 create_order。
正确做法是:在下单工具内部再次校验 ItineraryState 是否完整,校验航班是否存在且余票足够,校验用户是否明确确认过。如果校验不通过,直接返回错误提示,而不是把异常抛给模型。类似的约束也可以应用到支付、改签、退票等敏感操作。Agent 的能力边界不是由 Prompt 单独约束,而是由服务端逻辑强制控制。
6. 运行验证:从启动到一次完整 Agent 对话
6.1 启动前检查清单
代码写完后不要急着启动,先检查几类容易出错的配置。API Key 是否已经通过环境变量注入,base-url 是否能连通,模型名称是否在当前供应商服务中可用,本机网络是否访问得到模型服务。如果模型服务连接不上,项目启动可能不报错,但第一次调用 Agent 时会超时。
可以用最简单的命令验证模型配置:
export AI_API_KEY="your-api-key" export AI_BASE_URL="https://api.example.com/v1" export AI_MODEL="gpt-4o-mini" mvn spring-boot:run看到项目成功启动并且没有配置异常后,再进入接口验证阶段。
6.2 用 curl 验证智能客服接口
在 AgentController 中暴露一个客服聊天接口,请求参数包括 sessionId 和 userMessage。
@RestController @RequestMapping("/agent/customer") public class AgentCustomerController { private final CustomerService customerService; public AgentCustomerController(CustomerService customerService) { this.customerService = customerService; } @PostMapping public CustomerServiceResult chat(@RequestBody CustomerRequest request) { return customerService.handleMessage(request.sessionId(), request.message()); } }使用 curl 发起一次完整请求:
curl -X POST http://localhost:8080/agent/customer \ -H "Content-Type: application/json" \ -d '{"sessionId":"s001","message":"帮我查明天北京到上海的航班"}'如果工具调用正常,返回结果中应当包含意图信息和航班列表。如果模型没有调用工具,回复可能会是“我不知道实时航班信息”。此时优先检查工具描述、模型是否支持 Function Calling,以及日志中是否出现了工具调用记录。
6.3 通过日志确认工具调用链路
排查 Agent 问题时,日志是最直接的线索。在 application.yml 中开启 Spring AI 相关包的 debug 日志:
logging: level: org.springframework.ai: DEBUG开启后可以看到模型请求和工具调用的关键信息。如果工具没有被调用,日志中通常不会出现工具执行相关输出。如果工具调用成功但结果没有进入最终回答,需要检查工具返回结果是否在上下文中继续传递。
建议不要在生产环境长期开启 DEBUG,否则日志量会非常大。更合理的方案是在工具方法入口和出口输出业务日志,主要内容包括 sessionId、入参、工具执行耗时和返回摘要。这样既能看到链路,又不会泄露 Prompt 的全部细节。
6.4 验证会话记忆和行程助手
智能客服的多轮记忆可以通过连续两条消息验证。第一次请求查询北京到上海,第二次请求说“把出发地换成广州”,看返回结果中的 slots 是否更新为广州。如果第二次请求丢失了第一次的上下文,说明 conversationId 没有传对,或者 ChatMemory 没有生效。
行程助手验证时,可以模拟完整流程:先查询航班,再选择上午航班,最后确认订单。观察订单号是否生成,以及 ItineraryState 中的字段是否在每一步后更新。如果发现状态没有更新,重点检查状态对象的保存和读取时机,而不是去怀疑模型是否理解对话。
7. 企业级落地的关键改造
7.1 为知识库接入航空政策文档
智能客服除了查航班,还经常需要回答行李额度、退改签规则、会员权益等政策问题。这些内容变化频繁,不适合写死在代码中。工程化做法是把政策文档切片后向量化,存入向量数据库,在用户提问时先检索相关知识,再与用户消息一起交给模型回答,也就是 RAG。
Spring AI 提供了统一的 VectorStore 抽象。学习阶段可以使用内嵌的 SimpleVectorStore,企业环境可以切换到 Elasticsearch、Milvus、Redis 等实现。向量化后,每个文档块会同时保存文本内容和 metadata,比如政策编号、生效日期、适用航线等。
7.2 向量存储如何避免重复文档
向量化过程中最常见的问题是重复添加文档。比如每天定时任务同步政策文档,如果每次都直接调用 add,向量库里会出现同一政策的多个副本,导致检索结果重复或混乱。解决思路不是靠向量库自动判断,而是在写入时使用业务唯一键控制覆盖。
先使用 metadata 过滤删除旧文档,再添加新文档:
String policyNo = "POLICY-2025-001"; Filter.Expression filter = new Filter.Expression( Filter.ExpressionType.EQ, "policyNo", policyNo ); vectorStore.delete(filter); vectorStore.add(List.of(new Document(policyNo, content, metadata)));这里的关键是 metadata 中保存的 policyNo 必须稳定。每次导入同一政策时,policyNo 保持一致,才能正确覆盖。同时要在向量库的索引 mapping 中把 policyNo 配置为可过滤字段,否则 delete(filter) 会失败或没有效果。
如果是初次搭建,建议先在小数据集上验证写入和覆盖行为。确认同一 policyNo 第二次导入后,检索结果中没有重复文档,再接入定时同步任务。
7.3 连接超时、重试与 Token 成本控制
Agent 依赖外部大模型接口,网络抖动和供应商限流是常态。生产环境必须配置超时和重试。Spring AI 的 Retry 机制可以配置最大尝试次数、退避初始间隔和退避倍数。如果调用第三方模型连续失败,重试可以改善体验,但也要设置总超时上限,否则请求会长时间占用 Web 容器线程。
spring: ai: retry: max-attempts: 3 backoff-initial-interval: 1000 backoff-multiplier: 2.0Token 成本控制要和上下文策略一起做。不要把所有历史消息都塞给模型。MessageChatMemoryAdvisor 的 messageWindowSize 要按业务需要调整。对于行程助手,更推荐把 ItineraryState 序列化为槽位摘要,只把状态和最近一条用户消息发送给模型,这样成本更低,输出也更稳定。
7.4 学习环境与生产环境还需要补充什么
| 关注点 | 学习环境 | 生产环境 |
|---|---|---|
| 会话存储 | InMemoryChatMemory | Redis 或数据库 |
| 向量库 | SimpleVectorStore | Elasticsearch 或专用向量库 |
| API Key | 环境变量本机注入 | 配置中心或密钥管理服务 |
| 日志 | DEBUG 日志打印全链路 | 结构化日志 + 链路追踪 |
| 限流 | 不需要 | 按用户/session 限流 |
| 监控 | 看控制台 | 指标采集 + 报警 |
| 权限控制 | 所有工具都可调用 | 按角色控制工具白名单 |
这里最容易忽略的是工具权限。同一个 Agent 如果同时有查询和下单工具,用户很容易构造一个“直接帮我下单”的请求。生产环境中要把下单、支付等敏感工具与普通查询工具分开,或者通过用户角色决定哪些工具可以注册到 ChatClient。
8. 常见问题与排查路径
8.1 一次排查的顺序
遇到 Agent 相关问题时,建议先按固定顺序检查,避免漫无目的地翻日志。从输入到输出依次是:用户消息是否合法、sessionId 是否传递、模型配置是否正确、工具描述和参数是否清晰、工具执行是否成功、工具结果是否带回上下文、最终结构化输出是否被业务校验拦截。
其中最关键的是第 4 和第 5 步。模型不调用工具时,优先检查工具描述和模型能力。工具执行报错时,优先检查参数类型和空值处理。最终回答不对时,再检查上下文是否保留工具结果。如果一上来就怀疑模型,往往查不出真正问题。
8.2 典型问题速查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 编译报 source release 17 需要 target 17 | Maven 未设置 compiler.release | 查看 mvn -version 和 pom.xml | 配置<maven.compiler.release>17</maven.compiler.release> |
| Lombok 报不支持当前 compiler | Lombok 版本与 JDK 版本不匹配 | 查看 Lombok 依赖版本和控制台 compiler 信息 | 升级 Lombok 到支持当前 JDK 的版本,或改用 record |
| 结构化输出实体类解析失败 | 模型返回 JSON 与字段不匹配 | 查看原始响应和日志中的 JSON | 明确 System Prompt,添加输出示例,避免字段使用下划线 |
| 向量库每次导入都多一份文档 | 没有按业务唯一键覆盖 | 查询同一 policyNo 的文档数量 | 导入前使用 metadata 过滤删除旧文档 |
| 第二次请求丢失上下文 | conversationId 传入不一致或 ChatMemory 未生效 | 检查请求参数和 Advisor 配置 | 统一 sessionId 传递规则,确认 ChatMemory Bean 已注入 |
| 工具没有被调用 | 工具描述不清晰或模型不支持 Function Calling | 开启 DEBUG 日志查看模型请求 | 加强工具描述,更换支持工具调用的模型 |
| 模型调用超时 | 网络不通或 API 配置错误 | 使用 curl 测试 base-url 连通性 | 检查网络和代理,配置超时与重试 |
| OOM 内存不足 | 堆内存过小或大量请求堆积 | 查看启动日志和监控 | 调整 JVM 参数,增加限流和异步化处理 |
8.3 三个最值得注意的工程坑
第一个坑是毫无限制地暴露业务方法。把 FlightService 的所有方法都加上 @Tool,等于让模型可以访问所有业务能力。一旦某个方法删除数据或修改订单,后果很难预测。建议每个 @Tool 方法都做权限校验和参数白名单校验。
第二个坑是在 System Prompt 中只写“你必须调用工具”,但没有给模型足够清晰的工具描述和示例。模型不是通过意志力理解业务,而是通过函数签名和描述决定调用行为。良好的工具描述应当包含参数格式、业务规则、