一个 starter 通吃 9 家主流大模型:Spring Boot 3.x + Spring AI 1.x 极简落地

一个 starter 通吃 9 家主流大模型:Spring Boot 3 + Spring AI 1 极简落地

导语:给项目接大模型,最费劲的往往不是调用本身,而是"每来一家就重写一遍"。这篇讲一个单模块 Java 微服务的做法:5 个 Java 文件、两个 Spring AI starter,改几行配置就能在 OpenAI、DeepSeek、通义千问、豆包、文心一言、智谱、Kimi、混元、本地 Ollama 之间切换,同时提供两个自带多轮记忆的聊天接口。文中涉及的版本号、配置项和接入地址都逐条核对过,可以直接照着写。

完整代码已开源,可直接到Gitee仓库复制使用。


一、这件事为什么值得单独抽一层

做过大模型接入的同学大概都有过这样的循环:产品说这次先接 DeepSeek,效果不错;过两周要对比一下通义,于是重写一遍 HTTP 客户端;再过一阵客户要求私有化,得把 Ollama 也接上,鉴权、流式解析、错误码映射又来一轮。

麻烦的地方不在"调通一次接口",而在四件事反复发生:

  • 每家一套协议:请求体字段、流式分片格式、错误结构都不一样,OpenAI 兼容协议只是缓解,不是终结;
  • 换模型要动业务代码:模型名、地址、密钥散落在各个 Service 里,换一次改一次;
  • 对话记忆自己管:多轮问答要把历史消息拼回 Prompt,还得自己控制长度防止超上下文,写起来琐碎;
  • 生产细节容易漏:超时、重试、熔断、中文编码,任何一项没处理,上线后都会变成故障单。

如果只接一家模型、只有一两个调用点,把调用直接写在业务代码里也没什么问题。但当模型数量到三家以上、调用点散落到多个 Service 时,重复代码和维护成本就会快速上涨:换一次模型要全项目搜一遍,加一家模型要再抄一遍流式解析。

所以更省事的做法是:把"接模型"这件事收口成一个独立小服务。业务只调它统一暴露的两个接口,谁家在下面提供能力、用哪家,全部由配置决定。将来加模型、换模型,改的是这个服务的配置文件,业务侧完全无感。

下面这套方案就叫"路线 B"------之所以叫 B,是因为前面还有一道版本兼容的坎要先迈过去。


二、绕不过的第一关:版本组合

这是整个方案里最容易翻车、也最值得先讲清楚的一点。

Spring AI 2.0.x 只支持 Spring Boot 4.0.x / 4.1.x,不能和 Spring Boot 3.x 共存。

这个坑的迷惑性在于,它的表现不是报错,而是"看起来一切正常":Maven 依赖能下载成功,IDEA 里编译通过,单元测试甚至也能跑。但应用一启动就抛 ClassNotFoundException------因为 Spring AI 2.0 是基于 Spring Framework 7 构建的,而 Spring Boot 3.x 带的是 Spring Framework 6.2。

于是就有了两条路线:

路线 组合 适用场景
路线 A JDK 21 + Spring Boot 4.1.x + Spring AI 2.0.x 全新项目,无历史包袱
路线 B JDK 17/21 + Spring Boot 3.5.x + Spring AI 1.1.x 已有项目锁在 Spring Boot 3.x,或中间件尚未适配 Boot 4

什么时候必须走路线 B?大致是这三种情况:

  • 现有工程或公司统一技术底座仍锁在 Spring Boot 3.x;
  • 团队用的注册中心、配置中心、链路追踪、内部 starter 还没适配 Spring Boot 4;
  • 生产环境 JDK 还是 17,暂时不想为升级大版本单独排期。

核心结论只有一句:版本必须成套。 只升 Spring AI、不动 Spring Boot,是最典型的启动失败原因。


三、版本三角,以及一条安全底线

确定走路线 B 之后,具体版本这样锁:

组件 版本 说明
JDK 17/21 路线 B 最低要求 17,用 21 也可以,代码不用改
Spring Boot 3.5.16 与 Spring AI 1.1.8 官方对齐的版本
Spring AI 1.1.8 必须 ≥ 1.1.6
构建工具 Maven 3.8+ 1.1.8 是正式版,不需要额外配里程碑仓库
JSON 库 Jackson 2 Spring Boot 3.5 默认,这一点和路线 A 不同

为什么 Spring AI 必须 ≥ 1.1.6

这不是"建议升级",而是安全底线。

CVE-2026-41712 (高危):Spring AI 在 1.0.x 与 1.1.0--1.1.5 中,如果调用方没有显式传入会话 ID,框架会回退到一个固定的默认值 "default"。结果是所有用户的对话历史都写进同一个会话------A 用户能看到 B 用户的聊天记录。

修复方式很干脆:官方在 1.1.6 与 1.0.7 中删掉了这层默认回退,缺失会话 ID 时直接抛 IllegalArgumentException。所以对本文这套方案来说,sessionId 是强制参数,既为安全,也为功能本身。

这个漏洞真正危险的地方在于它不报错。接口照常返回,日志里看不出任何异常,只有当你用两个不同的 sessionId 反复对话时,才会发现上下文串了。而如果业务代码本身就没传会话 ID(老项目里很常见),问题会一直潜伏,直到有人刻意构造请求去读别人的会话。所以升到 1.1.6+ 不是"顺手升级",它同时消除"默认会话"这个隐患,并强制调用方显式声明身份。

顺带说清楚:3.5.x 已经是"过渡选项"

Spring Boot 3.5.x 的开源支持在 2026-06-30 已经结束(3.5.16 是该分支最后一个开源版本)。这不影响现有应用继续构建运行,但意味着后续新披露的问题未必再有面向 3.5.x 的免费补丁。

所以路线 B 的定位是:能用、可上线,但应当作为过渡方案,升级计划要同步排好。好在这套方案的业务代码刻意写得很薄,将来切到路线 A 时,改动主要集中在 pom.xml 版本号和配置属性层级。


四、工程长什么样

整个服务是单模块,没有多余的抽象层、没有 SPI、没有 Lombok:

bash 复制代码
llm-chat-service/
├── pom.xml
└── src/main/
    ├── java/com/example/llmchat/
    │   ├── LlmChatApplication.java        # 启动类
    │   ├── config/
    │   │   └── AiConfig.java              # ChatClient + 记忆 + 编码兜底
    │   ├── controller/
    │   │   └── ChatController.java        # 两个聊天接口
    │   └── dto/
    │       ├── ChatDto.java               # 请求参数(record)
    │       └── ChatResponseDto.java       # 响应体(record)
    └── resources/
        └── application.yml                # 换模型只改这里

共 5 个 Java 文件。DTO 用 Java record 而不是 Lombok,少一个依赖,也顺带避开 Lombok 与新版编译器的兼容问题。


五、完整代码

5.1 pom.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
​
    <!-- 路线 B:Spring Boot 3.5.x(与 Spring AI 1.1.8 官方对齐) -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.16</version>
        <relativePath/>
    </parent>
​
    <groupId>com.example</groupId>
    <artifactId>llm-chat-service</artifactId>
    <version>1.0.0</version>
    <name>llm-chat-service</name>
    <description>多模型统一聊天微服务(路线 B)</description>
​
    <properties>
        <!-- JDK 17 或 21 均可 -->
        <java.version>21</java.version>
        <!-- 必须 >= 1.1.6(CVE-2026-41712 修复底线),本方案取 1.1.8 -->
        <spring-ai.version>1.1.8</spring-ai.version>
    </properties>
​
    <dependencyManagement>
        <dependencies>
            <!-- Spring AI BOM:统一管理各模块版本,子依赖无需再写版本号 -->
            <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>
        <!-- Web:Servlet 栈,同时支持同步接口与返回 Flux<String> 的流式接口 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
​
        <!-- Spring AI:OpenAI 兼容协议 Starter(改 base-url 即可切模型) -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
​
        <!-- Spring AI:对话记忆 Starter(记忆自动配置 + 内存存储实现) -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-chat-memory</artifactId>
        </dependency>
    </dependencies>
​
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

两个 starter 的坐标都由 BOM 统一管版本,不要在子依赖里再写 version。

5.2 application.yml

yaml 复制代码
server:
  port: 8080
  servlet:
    encoding:
      charset: UTF-8
      enabled: true
      force: true          # 强制请求/响应全链路走 UTF-8
​
spring:
  application:
    name: llm-chat-service
  ai:
    # 全局重试:默认 max-attempts=10、初始 2s、乘数 5(最坏要等到 3 分钟),务必修小
    retry:
      max-attempts: 3
      backoff:
        initial-interval: 1s
        multiplier: 2
        max-interval: 10s
      on-client-errors: false        # 4xx 客户端错误不重试
    openai:
      # ============ 换模型只改下面这块 ============
      base-url: https://api.deepseek.com
      api-key: ${LLM_API_KEY}        # 从环境变量读取,不要硬编码
      chat:
        # 除 OpenAI / DeepSeek 外,多数厂商需显式覆盖此项,见对照表
        completions-path: /v1/chat/completions
        options:
          model: deepseek-chat
          temperature: 0.7
      # ===========================================
​
# 对话记忆窗口:单会话保留最近多少条消息
llm:
  memory:
    max-messages: 20

5.3 启动类

typescript 复制代码
package com.example.llmchat;
​
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
​
@SpringBootApplication
public class LlmChatApplication {
​
    public static void main(String[] args) {
        SpringApplication.run(LlmChatApplication.class, args);
    }
}

5.4 AiConfig:ChatClient + 记忆 + 编码兜底

java 复制代码
package com.example.llmchat.config;
​
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
​
import java.nio.charset.StandardCharsets;
import java.util.List;
​
@Configuration
public class AiConfig implements WebMvcConfigurer {
​
    /** 单会话保留的最近消息条数,默认 20 */
    @Value("${llm.memory.max-messages:20}")
    private int maxMessages;
​
    /**
     * 对话记忆:内存存储 + 滑动窗口裁剪。
     * - ChatMemoryRepository 负责"存",MessageWindowChatMemory 负责"窗口裁剪"
     * - 自定义该 Bean 会覆盖 Spring AI 的自动配置,从而可显式控制窗口大小
     */
    @Bean
    public ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(new InMemoryChatMemoryRepository())
                .maxMessages(maxMessages)
                .build();
    }
​
    /**
     * 统一 ChatClient:挂载记忆 Advisor。
     * 调用时通过 ChatMemory.CONVERSATION_ID 传会话 ID,即可自动携带该会话的历史消息。
     */
    @Bean
    public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) {
        return builder
                .defaultSystem("你是一个友好、有用的 AI 助手,回答简洁准确。")
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }
​
    /**
     * 编码兜底:把 StringHttpMessageConverter 的默认字符集改为 UTF-8。
     * 该转换器默认使用 ISO-8859-1,是"直接返回 String 时中文乱码"的根因;
     * 这里原地修改既有实例(比 new 一个新实例插到最前面更稳妥,不会因顺序问题失效)。
     */
    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.stream()
                .filter(StringHttpMessageConverter.class::isInstance)
                .map(StringHttpMessageConverter.class::cast)
                .forEach(c -> c.setDefaultCharset(StandardCharsets.UTF_8));
    }
}

5.5 两个 DTO

arduino 复制代码
package com.example.llmchat.dto;
​
/**
 * 聊天请求参数。
 *
 * sessionId 为必填:
 *   1) 不同会话的记忆完全隔离,同一 sessionId 才共享上下文;
 *   2) 自 Spring AI 1.1.6 起,隐式默认会话 ID 已被移除(CVE-2026-41712 修复),
 *      缺失会话 ID 会直接抛 IllegalArgumentException。
 */
public record ChatDto(String sessionId, String message) {
}
arduino 复制代码
package com.example.llmchat.dto;
​
/** 简单对话接口的响应体(走 Jackson 序列化,天然 UTF-8) */
public record ChatResponseDto(String sessionId, String reply) {
}

5.6 两个接口

kotlin 复制代码
package com.example.llmchat.controller;
​
import com.example.llmchat.dto.ChatDto;
import com.example.llmchat.dto.ChatResponseDto;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
​
@RestController
@RequestMapping("/api/chat")
@CrossOrigin(origins = "*") // 允许前端跨域调用;生产环境请收紧为具体域名
public class ChatController {
​
    private final ChatClient chatClient;
​
    public ChatController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }
​
    /**
     * 接口一:简单对话(同步,一次性返回完整回复)
     * 请求体示例:{"sessionId":"u001","message":"我叫张三"}
     */
    @PostMapping("/simple")
    public ChatResponseDto simple(@RequestBody ChatDto dto) {
        String content = chatClient.prompt()
                .user(dto.message())
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, dto.sessionId()))
                .call()
                .content();
        return new ChatResponseDto(dto.sessionId(), content);
    }
​
    /**
     * 接口二:流式对话(SSE 逐字返回,前端打字机效果)
     * 显式声明 charset=UTF-8,避免 text/event-stream 走 ISO-8859-1 造成中文乱码。
     * 示例:GET /api/chat/stream?sessionId=u001&message=介绍一下Java
     */
    @GetMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")
    public Flux<String> stream(@RequestParam String sessionId,
                               @RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
                .stream()
                .content();
    }
}

到这里,代码就写完了。全部加起来 5 个 Java 文件。


六、一个 starter,通吃 9 家模型

这套方案之所以只用一个 OpenAI starter 就能覆盖各家,是因为主流大模型基本都提供了 OpenAI 兼容端点。你要做的,只是把这几个值配对。

这里先交代一个取舍:为什么不直接用各厂商的原生 SDK?

原生 SDK 的好处是能用上厂商独有能力,代价是每家一套依赖、一套异常体系、一套升级节奏------接三家就是三份维护成本。兼容协议的优势恰好在这里:一个 starter、一条调用链、一套超时重试配置,新增厂商只要补一段 YAML。付出的是丢掉部分厂商特有参数;如果确实需要某家的独有能力,再为那一家单独写一个客户端就行,不必为了个别需求把整体做复杂。

先记住一条规律

实际请求地址 = base-url + completions-path

其中:

  • spring.ai.openai.chat.completions-path 的默认值是 /v1/chat/completions,而且语义是"追加到 base URL 后的路径",不是完整 URL;
  • spring.ai.openai.base-url 默认是 https://api.openai.com。

由此推出一条非常实用的判断:如果 base-url 结尾已经带了版本段(/v1、/v2、/v3、/v4),就必须把 completions-path 覆盖成 /chat/completions,否则会拼出 /v1/v1/chat/completions 这种地址,直接 404。

各家配置对照表

厂商 base-url completions-path model 示例
OpenAI https://api.openai.com 默认 /v1/chat/completions gpt-4o-mini
DeepSeek https://api.deepseek.com 默认 /v1/chat/completions deepseek-chat / deepseek-reasoner
阿里通义(百炼) https://dashscope.aliyuncs.com/compatible-mode/v1 /chat/completions qwen-plus / qwen-max
字节豆包(火山方舟) https://ark.cn-beijing.volces.com/api/v3 /chat/completions 模型 ID 或推理接入点 ID(ep- 开头)
百度文心(千帆) https://qianfan.baidubce.com/v2 /chat/completions ernie-4.0-turbo-8k 等
智谱 GLM https://open.bigmodel.cn/api/paas/v4 /chat/completions glm-4.6 / glm-4.5
月之暗面 Kimi https://api.moonshot.cn/v1 /chat/completions kimi-k3 等
腾讯混元 https://api.hunyuan.cloud.tencent.com/v1 /chat/completions hunyuan-turbos-latest 等
本地 Ollama http://localhost:11434/v1 /chat/completions qwen2.5:7b 等

看这张表能发现规律:只有 OpenAI 和 DeepSeek 能用默认值,其余都必须显式覆盖 completions-path。

几个易错点

  • 通义千问别漏掉 /compatible-mode/v1;
  • Ollama 必须带 /v1,缺了会连不上;
  • 文心(千帆)的地址是 https://qianfan.baidubce.com/v2,不是 aip.baidubce.com/v1;
  • 豆包生产环境建议把 model 填成控制台的推理接入点 ID(ep- 开头),不要用模型名。

以后真遇到 404,最快的定位方式是把 Spring AI 的日志开到 DEBUG,看实际拼出来的完整地址:

yaml 复制代码
logging:
  level:
    org.springframework.ai: DEBUG

七、两个接口,都带记忆

接口 路径 方法 参数 返回 适用场景
简单对话 /api/chat/simple POST JSON:sessionId、message JSON:{sessionId, reply} 后台任务、无需实时输出
流式对话 /api/chat/stream GET Query:sessionId、message SSE 文本流 前端聊天窗、打字机效果

调用起来很简单:

json 复制代码
curl -X POST http://localhost:8080/api/chat/simple \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"u001","message":"你是谁?"}'
  
{
  "sessionId": "u001",
  "reply": "我是深度求索公司研发的大语言模型,英文名是DeepSeek。"
}

流式接口直接用浏览器打开就能看到逐字输出:

makefile 复制代码
http://localhost:8080/api/chat/stream?sessionId=u001&message=简单介绍一下Java
​
data:#
​
data: Java 
​
data:简介
data:
​
data:
data:## 什
​
data:么是 Java
​
data:?
data:
​
data:
data:Java 是一
​
data:种**面向
​
data:对象**
​
data:的高级
​
data:编程语言,
​
data:由 **
​
data:Sun Microsystems
​
data:**(现
​
data:已被 Oracle
​
data: 
​
data:收购)的
​
data: **James Gos
​
data:ling** 于
​
data: 
​
data:199
​
data:5 年正式
​
data:发布。
​
data:
data:
data:---
......

前端用 EventSource 对接,就是一个完整的打字机效果:

ini 复制代码
const es = new EventSource(
  `http://localhost:8080/api/chat/stream?sessionId=u001&message=${encodeURIComponent(text)}`
);
es.onmessage = (e) => { document.getElementById('out').textContent += e.data; };
es.onerror = () => es.close();

这两个接口挂的是同一个记忆 Advisor,所以无论走同步还是流式,多轮上下文都会自动延续------不需要为"同步记不住、流式记得住"这类问题做额外处理。

注意:GET + sessionId 会落入访问日志/浏览器历史。

记忆是怎么自动生效的

Spring AI 1.1.x 把记忆拆成了两层:

组件 作用
ChatMemory 对话上下文管理,按 conversationId 维度维护消息、控制窗口
ChatMemoryRepository 持久化存储,默认实现是 InMemoryChatMemoryRepository

一次请求的完整过程是这样:

  1. 调用时通过 ChatMemory.CONVERSATION_ID 传入 sessionId;
  2. MessageChatMemoryAdvisor 自动取出该会话的历史消息 → 拼进 Prompt → 把本轮问答写回;
  3. MessageWindowChatMemory 按 maxMessages 只保留最近 N 条,超出自动裁剪(系统消息不受影响);
  4. 不同 sessionId 的记忆完全隔离,互不可见。

第 3 点很关键------不用自己写任何截断逻辑,窗口裁剪交给框架。

想升级成持久化也简单:把 InMemoryChatMemoryRepository 换成 Redis 或 JDBC 实现即可,Controller 一行都不用改。

还有两个实践注意点:

  • 流式接口为什么设计成 GET:浏览器原生的 EventSource 只支持 GET 请求,所以流式接口把 sessionId 和 message 放在 query 参数里。如果你需要传更复杂的参数,就得改用 fetch 配合 ReadableStream 自己解析 SSE,或者引入第三方 SSE 客户端库。
  • 流式返回的是增量片段:Flux<String> 里每个元素都是一小段新内容,前端要做的是"追加"而不是"覆盖",否则屏幕上只会剩下最后一个字。

八、五个最容易翻车的地方

坑 1:中文乱码

这是最反直觉的一个。同样的中文,包成对象返回正常,直接返回 String 就乱码。

原因在 StringHttpMessageConverter:它只在 Content-Type 兼容 application/json 时才用 UTF-8,其余情况(text/plain、text/event-stream)一律走 ISO-8859-1。

修正方式是三处一起改:

位置 原写法 修正
简单接口 返回 String 改返回 JSON 对象,走 Jackson,天然 UTF-8
流式接口 produces = TEXT_EVENT_STREAM_VALUE produces = "text/event-stream;charset=UTF-8"
全局兜底 --- StringHttpMessageConverter 默认字符集设为 UTF-8 + yml 里 force: true

顺带消除一个担忧:Spring AI 每个 chunk 都是完整的 UTF-8 字符序列,不会出现「半个中文字」的乱码字节;但一个词完全可能被拆到相邻多个 chunk(上方示例中的「什么」就分属两条消息)------这正是前端必须「追加」而不能「覆盖」的原因。

验证的时候别只看浏览器------它和 Postman 都会自动猜编码,容易给出"看起来正常"的假象。用 curl -i 看响应头:

json 复制代码
curl -i -X POST http://localhost:8080/api/chat/simple \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"u001","message":"你好"}'

判定标准 :响应头 Content-Type 必须带 charset=UTF-8。另外 JDK 17 建议启动时加 -Dfile.encoding=UTF-8(JDK 18 起 JVM 默认就是 UTF-8)。

坑 2:全局重试默认是 10 次

spring.ai.retry.max-attempts 的默认值是 10,初始退避 2 秒、乘数 5、上限 3 分钟。

这意味着:如果 API Key 写错了,或者模型名不存在,请求不会立刻报错,而是反复重试、最长挂满 3 分钟。排查时特别容易被误判成"网络慢"。所以务必按前面的 application.yml 把它改成 3 次,并把 4xx 排除在重试之外。

坑 3:base-url 和 completions-path 必须成对看

这是 404 的第一大来源,规律在第六章已经讲透。一句话复述:base-url 结尾带了版本号,completions-path 就填 /chat/completions。

坑 4:记忆相关类名,别照抄旧教程

1.1.x 和 2.0 的 API 基本一致,但类名和写法已经变了,旧教程里的写法编译不过:

旧写法 现在应该用
InMemoryChatMessageStore InMemoryChatMemoryRepository
.chatMessageStore(...) .chatMemoryRepository(...)
new MessageWindowChatMemory(...) MessageWindowChatMemory.builder()...build()
new MessageChatMemoryAdvisor(...) MessageChatMemoryAdvisor.builder(...).build()

坑 5:spring.ai.openai.chat.enabled 已经移除

这个属性以前用来开关模型,现在已改用 spring.ai.model.chat(默认值就是 openai,一般不需要显式配置)。照抄旧教程写这个属性不会报错,只会静默失效,很难发现。

还有两条底线

  • API Key 走环境变量:配置里写 ${LLM_API_KEY},别让密钥进代码库;
  • Starter 坐标别用旧名:spring-ai-openai-spring-boot-starter 是 0.x 时代的老坐标,现在叫 spring-ai-starter-model-openai。

九、几个高频问题

Q1:对话记忆窗口满了会怎样?

maxMessages 控制的是单会话保留的消息条数。超出后,最旧的消息会被自动丢弃,只保留最近的 N 条;系统消息(defaultSystem 设置的那条)不受裁剪影响。这也意味着,如果你需要模型长期记住很早之前的关键信息,不能只依赖窗口------要么把关键信息写进系统提示,要么在外层再叠一层摘要。

Q2:服务重启后,对话历史还在吗?

默认不在。InMemoryChatMemoryRepository 是纯内存实现,进程重启就清空。需要跨重启保留,把仓库换成 Redis 或 JDBC 版即可,ChatController 一行都不用改。

另外三点值得留意:

  • 每个会话的消息数有上限(受 maxMessages 控制),不会因为对话越聊越长而占用更多内存;
  • 但会话条目本身没有淘汰机制------用户离开后,他的会话会一直留在内存里,直到重启。如果做 C 端匿名服务、会话数持续增长,建议在业务侧定期调用 chatMemory.clear(sessionId) 清理不再活跃的会话,或直接上持久化仓库。
  • 多实例不共享。多实例部署时需换持久化仓库(Redis/JDBC)或做粘性会话

Q3:@CrossOrigin(origins = "*") 能直接上生产吗?

不建议。示例里写 * 是为了本地调试方便。生产环境应当收紧为具体的前端域名,否则任何站点都能调用你的接口,既浪费额度,也有安全风险。缺失 sessionId/message 时,Spring AI 会抛 IllegalArgumentException,表现为 500。建议加 spring-boot-starter-validation,在 DTO 字段上标 @NotBlank,并用 @RestControllerAdvice 把这类异常映射为 400。

Q4:为什么不直接用 Lombok?

DTO 只有两个,用 Java 原生 record 就够了------少一个依赖,也避开 Lombok 与新版编译器的兼容问题。这套方案整体的思路就是"够用就好",能不引入的依赖就不引入。

Q5:这套方案能接本地私有化模型吗?

能。Ollama 提供 OpenAI 兼容端点,把 base-url 指向 http://localhost:11434/v1、completions-path 填 /chat/completions 即可,Java 代码一行都不用动。


十、六步验收

  1. 选一家可用模型(建议先用 DeepSeek 或通义,国内直连、接入最省事),密钥通过环境变量注入:

    ini 复制代码
    export LLM_API_KEY=你的密钥
  2. 启动服务,确认没有 ClassNotFoundException 之类的启动异常。这一关就能验证版本组合是否正确。

  3. 记忆验证(核心):

    json 复制代码
    # 第一句:告诉它名字
    curl -X POST http://localhost:8080/api/chat/simple \
      -H "Content-Type: application/json" \
      -d '{"sessionId":"u001","message":"我叫张三"}'
      
    {
      "sessionId": "u001",
      "reply": "你好,张三!😊 很高兴认识你!"
    }
    ​
    # 第二句:同一 sessionId,追问名字
    curl -X POST http://localhost:8080/api/chat/simple \
      -H "Content-Type: application/json" \
      -d '{"sessionId":"u001","message":"我叫什么名字"}'
      
    {
      "sessionId": "u001",
      "reply": "你叫**张三**!😊"
    }
    • 第二次回答"张三" → 记忆生效;
    • 换成 sessionId:"u002" 再问 → 回答"不知道" → 会话隔离生效。
  4. 流式验证:浏览器打开 http://localhost:8080/api/chat/stream?sessionId=u001&message=介绍一下Java,观察是否逐字返回。

  5. 编码验证:用 curl -i 检查两个接口的响应头是否都带 charset=UTF-8。

  6. 窗口裁剪验证(可选):把 llm.memory.max-messages 调成 2,多轮对话后应当只保留最近几条。


十一、什么时候该升级

路线 B 可以用,但既然 Spring Boot 3.5.x 的开源支持已经结束,落地时就该把升级排进计划:

阶段 动作 说明
现在 按路线 B 落地 业务代码几乎不用改,先跑起来
过渡期 关注 Boot 4.x 生态成熟度 重点看中间件和内部 starter 的适配进度
升级时 升到 Spring Boot 4.1.x + Spring AI 2.0.x + JDK 21 改动集中在 pom 版本号、配置属性层级、Jackson 3 API
升级后 复用同一套业务代码 ChatClient / ChatMemory / Advisor / Flux<String> 用法不变

因为两个接口和配置类都写得极薄,升级时预计只需要动 pom.xml 和 application.yml,ChatController 与 AiConfig 基本不用改。这也正是前面坚持"不做多余抽象"的回报。

升级前建议先做一轮接口回归:用同一组问题分别打简单接口和流式接口,对比升级前后的回复内容、响应头编码和 Token 用量,确认没有静默的行为变化。版本升级最容易出问题的往往不是编译报错,而是这种"能跑、但结果不一样"的细节。


十二、源代码

完整代码已开源:llm-chat-service(Gitee)

结语

把大模型接入收口成一个独立小服务,好处是显而易见的:业务只管调用,模型随配置切换,记忆、编码、重试这些容易漏的细节一次性解决掉。

真正需要小心的不是业务代码,而是四件事------版本匹配、厂商地址与路径的组合、默认重试次数、中文编码。这四点文中都给了可执行的写法,照着配基本不会踩。

如果你的项目正好卡在 Spring Boot 3.x 上,这套"路线 B"是目前比较稳妥的选择;等中间件生态跟上,再平滑切到 Spring Boot 4 + Spring AI 2.x 即可。

这套代码上生产前,记得按 Q2、Q3 补齐持久化记忆、鉴权与限流。

相关推荐
武子康1 小时前
MultiNet 只听到一次命令,为什么连续触发?
人工智能·后端·agent
挖掘狂人1 小时前
同一个端口能不能被两个程序同时监听?
java·后端·程序员
codigger1 小时前
同一个端口能不能被两个程序同时监听?
java·后端·程序员·编程
2501_933923252 小时前
SpringCloud:OpenFeign快速实现
java·后端·spring cloud·openfeign
拖孩2 小时前
AI 帮我写的小程序上线 44 天:36 个人进了收银台,8 个人付钱
前端·后端·微信小程序
Rain的Java大神之路2 小时前
我,13年Java老兵,建了个免费网站,公开叫板“Java已死”:没有广告,没有付费墙,151个页面全是踩坑干货
java·后端·程序员
萧瑟余晖2 小时前
Spring Integration企业集成概览详解
java·后端·spring
小小张说故事2 小时前
Python 的赋值为什么不会复制对象?拆开引用、浅拷贝与深拷贝
后端·python
SimonKing2 小时前
告别纸质合同,使用DocuSeal搭建私有电子签平台
java·后端·程序员