Spring Boot 3.2 + Ollama:在本地构建你的私有化AI对话服务

最近和几个做企业服务的朋友聊天,他们都在为一个问题头疼:想给自家产品加上智能对话能力,但一提到要把用户数据送到第三方AI平台,法务和客户就直摇头。数据隐私、API调用成本、还有那说不清道不明的网络延迟,都成了拦路虎。

其实,这个问题早就有解了。把大模型“请”到本地服务器上,用你最熟悉的Java和Spring Boot技术栈,自己搭一套AI服务,数据不出内网,成本可控,响应速度还快。听起来很复杂?我刚开始也这么觉得,但上手后发现,借助Spring AI和Ollama,整个过程比想象中简单太多。

这篇文章,我就带你走一遍完整的流程。从零开始,用Spring Boot 3.2和Ollama,在本地搭建一个功能完备的私有AI对话API。我们不止实现基础的问答,还会搞定流式输出、对话记忆这些提升体验的关键功能。目标是让你看完就能动手,快速为你的项目注入AI能力。

1. 环境准备与核心工具选型

在敲下第一行代码之前,我们需要把“舞台”搭好。这个阶段的核心是选择并配置好那些能让我们事半功倍的工具。

为什么是Spring Boot 3.2 + Ollama? 这不是随意组合。Spring Boot 3.2是当前Java生态中成熟且广泛使用的版本,它对现代Java特性(如虚拟线程)的支持更好。而Ollama,则是目前在本地运行和管理开源大模型最顺手的工具。它把模型下载、加载、服务化这些繁琐步骤打包成简单的命令,让你能像使用Docker一样管理模型。Spring AI作为中间的“粘合剂”,提供了一套标准化的API,让你用同样的Java代码去调用不同的模型后端,无论是本地的Ollama还是云端的OpenAI。

第一步:安装基础运行时 确保你的机器上已经安装了JDK 17或更高版本。我推荐使用Amazon Corretto 17或Eclipse Temurin 17,它们都是免费的OpenJDK发行版,企业级支持也做得不错。用下面的命令检查一下:

java -version

输出应该类似这样:

openjdk version "17.0.10" 2024-01-16 LTS
OpenJDK Runtime Environment Corretto-17.0.10.7.1 (build 17.0.10+7-LTS)
OpenJDK 64-Bit Server VM Corretto-17.0.10.7.1 (build 17.0.10+7-LTS, mixed mode, sharing)

第二步:安装并启动Ollama Ollama的安装极其简单。访问其官网,根据你的操作系统下载安装包。以macOS或Linux为例,安装后只需在终端执行一条命令就能启动服务:

# 启动Ollama服务,它会默认监听11434端口
ollama serve

服务启动后,另开一个终端窗口,拉取一个适合本地运行的轻量级模型。对于中文场景,qwen2.5:7bdeepseek-r1:7b都是不错的选择,它们在7B参数量级上提供了不错的推理能力和中文支持。

# 拉取Qwen2.5 7B模型(约4.5GB)
ollama pull qwen2.5:7b

# 拉取完成后,可以立即运行一个简单对话测试
ollama run qwen2.5:7b "你好,请介绍一下你自己。"

如果模型能正常回复,说明Ollama环境已经就绪。至此,我们的“模型服务器”已经准备妥当。

工具选型对比

为了让你更清楚我们为什么选择这套组合,我简单对比一下其他常见方案:

方案 优势 劣势 适用场景
Spring AI + Ollama 数据完全私有、零API成本、延迟极低、Java原生集成 需要本地GPU/CPU资源、模型能力受硬件限制 企业内部工具、对数据隐私要求高的C端产品、离线环境
Spring AI + OpenAI API 模型能力最强、无需管理基础设施、开箱即用 数据需出境、持续产生API费用、网络依赖性强 快速原型验证、对模型效果要求极高的场景、无隐私顾虑的应用
Python FastAPI + 本地模型 Python生态AI库丰富、灵活度高 需要维护Python技术栈、与企业现有Java架构整合成本高 AI研究、算法团队主导的项目、纯Python技术栈团队

可以看到,对于大多数Java技术栈的企业来说,Spring AI + Ollama在数据安全、成本控制和架构统一性上取得了最佳平衡

2. 项目初始化与Spring AI集成

环境就绪,现在让我们创建Spring Boot项目,并把Spring AI引入进来。我会带你避开几个常见的“坑”。

使用Spring Initializr快速创建项目 最省心的方式是访问 start.spring.io。在页面上进行如下选择:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.2.x (建议选择最新的3.2.x版本,如3.2.11)
  • Group & Artifact: 按你的项目命名习惯填写
  • Packaging: Jar
  • Java: 17

Dependencies一栏,先添加 Spring Web。至于Spring AI的依赖,我们稍后在pom.xml里手动添加会更清晰,因为需要引入其特定的BOM(物料清单)来管理版本。

点击“GENERATE”下载项目压缩包,解压后用你喜欢的IDE(IntelliJ IDEA或VS Code)打开。

配置Maven依赖与仓库 打开项目根目录的pom.xml文件,这是整个项目的依赖蓝图。我们需要做三件事:1) 添加Spring AI的BOM;2) 引入Ollama starter依赖;3) 配置Spring的快照仓库(因为Spring AI某些版本可能还在里程碑阶段)。

找到<project>标签下的<properties>部分,我们可以先定义Spring AI的版本,方便管理:

<properties>
    <java.version>17</java.version>
    <spring-ai.version>1.0.0-M6</spring-ai.version> <!-- 以当时最新稳定版为准 -->
</properties>

接下来,在<dependencies>标签之前,添加<dependencyManagement>部分,导入Spring AI的BOM。这是关键一步,它能确保所有Spring AI相关组件的版本一致,避免冲突。

<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>部分,添加我们实际需要的依赖:

<dependencies>
    <!-- Spring Boot Web Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Spring AI for Ollama 核心依赖 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
    </dependency>
    
    <!-- 测试依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

最后,由于Spring AI的一些版本可能存放在Spring的里程碑仓库里,我们需要在<repositories>部分(如果没有就新建)添加以下配置:

<repositories>
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>

注意:Spring AI版本迭代较快,1.0.0-M6是撰写本文时的一个稳定里程碑版。建议你动手时,去Spring AI的GitHub Releases页面查看最新版本,替换掉上面的版本号。通用版本(GA)发布后,将无需配置里程碑仓库。

编写核心配置文件 依赖搞定后,我们来配置应用如何连接Ollama。在src/main/resources目录下,创建或编辑application.yml文件(用YAML格式更清晰)。这里我们做最基础的配置:

server:
  port: 8080

spring:
  application:
    name: local-ai-service
  ai:
    ollama:
      # Ollama服务地址,默认就在本地11434端口
      base-url: http://localhost:11434
      chat:
        options:
          # 必须指定你通过`ollama pull`下载的模型名称
          model: qwen2.5:7b
          # 温度参数,控制输出的随机性。0.0最确定,1.0更多样。
          temperature: 0.7

这个配置告诉Spring AI:去找本地11434端口上的Ollama服务,并使用qwen2.5:7b这个模型进行对话。temperature设为0.7是一个比较折中的值,让回答既有一定创造性,又不至于太天马行空。

至此,项目骨架和基础连接配置已经完成。你可以尝试运行主类(通常是*Application.java),如果控制台没有报错,并且能看到Spring AI相关的自动配置日志,那么恭喜你,集成工作成功了。

3. 实现基础与流式对话API

现在进入最有趣的部分——让我们的服务“开口说话”。我们将实现两种最常用的接口:同步响应和流式响应。你会发现,有了Spring AI的抽象,这比调用一个普通的Web Service还要简单。

同步对话接口:快速获取完整回答 同步接口适用于那些不需要即时反馈、回答较短(比如翻译、摘要)的场景。它的特点是客户端发送请求后,会一直等待,直到服务器端模型生成完整的回答,一次性返回。

创建一个新的Controller类,例如AiChatController

package com.yourcompany.localai.controller;

import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/ai")
public class AiChatController {

    private final ChatModel chatModel;

    // 通过构造器注入ChatModel,Spring AI会自动配置Ollama的实现
    public AiChatController(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    /**
     * 同步对话接口
     * GET /api/ai/chat?message=你的问题
     */
    @GetMapping("/chat")
    public Map<String, String> chat(@RequestParam String message) {
        // 1. 构建消息列表。可以加入SystemMessage来设定AI的角色。
        List<Message> messages = List.of(
                new SystemMessage("你是一个乐于助人且专业的AI助手,请用中文回答用户的问题。"),
                new UserMessage(message)
        );
        
        // 2. 将消息列表封装成Prompt对象
        Prompt prompt = new Prompt(messages);
        
        // 3. 调用模型,获取完整的响应
        String response = chatModel.call(prompt).getResult().getOutput().getContent();
        
        // 4. 以JSON格式返回
        return Map.of("response", response);
    }
}

这个接口非常直观。你可以在浏览器或Postman里访问 http://localhost:8080/api/ai/chat?message=Spring Boot是什么?,很快就会收到一个完整的JSON响应。

流式对话接口:实现“打字机”效果 对于真正的聊天场景,用户希望看到文字逐个蹦出来的效果,而不是盯着空白页等待。这就需要流式接口。它基于Server-Sent Events (SSE) 技术,服务器会持续发送多个数据块,直到回答完成。

在同一个Controller里,我们添加流式接口:

    import org.springframework.http.MediaType;
    import reactor.core.publisher.Flux;
    import org.springframework.ai.chat.model.ChatResponse;

    /**
     * 流式对话接口
     * GET /api/ai/chat/stream?message=你的问题
     * 注意:produces属性指定了SSE的媒体类型
     */
    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatStream(@RequestParam String message) {
        List<Message> messages = List.of(
                new SystemMessage("你是一个乐于助人且专业的AI助手,请用中文回答用户的问题。"),
                new UserMessage(message)
        );
        Prompt prompt = new Prompt(messages);
        
        // 关键区别:使用stream()方法,返回一个Flux流
        return chatModel.stream(prompt)
                .map(chatResponse -> {
                    // 从每个响应块中提取文本内容
                    if (chatResponse.getResult() != null && chatResponse.getResult().getOutput() != null) {
                        return chatResponse.getResult().getOutput().getContent();
                    }
                    return "";
                });
    }

这个接口的响应是持续的。你可以用curl命令来测试:

curl -N http://localhost:8080/api/ai/chat/stream?message=讲一个关于编程的笑话

你会看到文字一段一段地输出到终端。在前端,你可以使用EventSource API来连接这个端点,轻松实现聊天界面的打字机效果。

两种方式的对比与选择

为了帮你更好地决策,我整理了一个对比表格:

特性 同步接口 (/chat) 流式接口 (/chat/stream)
响应方式 一次性返回完整结果 分多次返回文本块
用户体验 需等待,有延迟感 实时感强,体验更佳
网络开销 单次响应,数据包大 多次小数据包,总大小可能略增
后端压力 请求处理时间长,连接占用久 连接占用久,但可提前释放部分资源
适用场景 工具类调用(总结、翻译)、后台任务 实时对话、长文本生成、需要即时反馈的交互
前端实现 普通的HTTP请求 使用 EventSourcefetch 读取流

我的经验是,对于面向用户的聊天产品,流式接口是必选项。它能极大提升感知速度和交互体验。同步接口则可以用于内部工具或不需要即时UI反馈的批量处理。

4. 进阶功能:为AI注入记忆与上下文

基础的问答机器人每次都是“金鱼记忆”,回答完就忘。要实现多轮有逻辑的对话,我们必须给AI加上“记忆”功能。这里,我们设计一个将会话历史存储到数据库的方案,这是生产环境中最可靠的做法。

第一步:关闭Spring AI默认的内存记忆 Spring AI为了方便,提供了一个基于内存的会话记忆实现。但我们要用数据库,为了避免冲突,先在application.yml中禁用它:

spring:
  ai:
    chat:
      memory:
        enabled: false # 关闭默认的内存记忆

第二步:设计数据库表结构 我们需要两张核心表。一张记录每次对话的会话(chat_session),另一张记录会话内的每一条消息(chat_message)。

-- 会话表:每个独立的聊天窗口对应一条记录
CREATE TABLE `chat_session` (
  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  `session_id` VARCHAR(100) NOT NULL UNIQUE COMMENT '前端生成的唯一会话ID',
  `title` VARCHAR(255) COMMENT '会话标题,通常用首条问题生成',
  `user_id` VARCHAR(64) COMMENT '关联的用户ID,用于多用户隔离',
  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `status` TINYINT DEFAULT 1 COMMENT '1: 活跃, 0: 已删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 消息表:记录会话中的所有问答对
CREATE TABLE `chat_message` (
  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  `session_id` VARCHAR(100) NOT NULL COMMENT '关联的会话ID',
  `role` ENUM('user', 'assistant', 'system') NOT NULL COMMENT '消息角色',
  `content` TEXT NOT NULL COMMENT '消息内容',
  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX `idx_session_id` (`session_id`),
  FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`session_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

第三步:实现数据持久层与服务层 使用Spring Data JPA来简化数据库操作。首先在pom.xml中添加相关依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

然后创建实体类ChatMessageChatSession(此处省略JPA注解细节),以及对应的Repository接口。接着,我们创建一个服务类ChatMemoryService,它的核心方法是根据sessionId获取历史对话,并组装成Spring AI能识别的Message列表:

@Service
public class ChatMemoryService {
    
    @Autowired
    private ChatMessageRepository messageRepository;
    
    /**
     * 根据会话ID加载历史消息,并转换为Spring AI的Message列表
     */
    public List<Message> loadConversationHistory(String sessionId) {
        List<ChatMessage> dbMessages = messageRepository.findBySessionIdOrderByCreatedAtAsc(sessionId);
        
        return dbMessages.stream().map(dbMsg -> {
            switch (dbMsg.getRole()) {
                case "user":
                    return new UserMessage(dbMsg.getContent());
                case "assistant":
                    return new AssistantMessage(dbMsg.getContent());
                case "system":
                    return new SystemMessage(dbMsg.getContent());
                default:
                    throw new IllegalArgumentException("Unknown role: " + dbMsg.getRole());
            }
        }).collect(Collectors.toList());
    }
    
    /**
     * 保存一轮对话(用户问题 + AI回答)
     */
    @Transactional
    public void saveMessagePair(String sessionId, String userId, String userQuestion, String aiResponse) {
        // 保存用户消息
        ChatMessage userMsg = new ChatMessage();
        userMsg.setSessionId(sessionId);
        userMsg.setRole("user");
        userMsg.setContent(userQuestion);
        messageRepository.save(userMsg);
        
        // 保存AI助手消息
        ChatMessage aiMsg = new ChatMessage();
        aiMsg.setSessionId(sessionId);
        aiMsg.setRole("assistant");
        aiMsg.setContent(aiResponse);
        messageRepository.save(aiMsg);
        
        // 如果是新会话,创建会话记录
        ChatSession session = sessionRepository.findBySessionId(sessionId);
        if (session == null) {
            session = new ChatSession();
            session.setSessionId(sessionId);
            session.setUserId(userId);
            // 用用户的第一条问题前20字作为会话标题
            session.setTitle(userQuestion.length() > 20 ? userQuestion.substring(0, 20) + "..." : userQuestion);
            sessionRepository.save(session);
        }
    }
}

第四步:创建带记忆的流式对话接口 最后,我们改造之前的流式接口,让它支持上下文记忆。这里有个技术细节:我们需要在流式输出完成后,再将完整的AI回复保存到数据库。

    @GetMapping(value = "/chat/stream/with-memory", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatStreamWithMemory(@RequestParam String sessionId,
                                             @RequestParam String message,
                                             @RequestParam(required = false) String userId) {
        // 1. 加载该会话的历史消息
        List<Message> historyMessages = chatMemoryService.loadConversationHistory(sessionId);
        
        // 2. 构建本次对话的消息列表:历史 + 新问题 + 系统指令
        List<Message> currentMessages = new ArrayList<>(historyMessages);
        currentMessages.add(new UserMessage(message));
        currentMessages.add(new SystemMessage("请用中文友好且专业地回答问题。"));
        
        Prompt prompt = new Prompt(currentMessages);
        
        // 3. 用于收集流式输出中AI的完整回复
        StringBuilder aiCompleteResponse = new StringBuilder();
        
        // 4. 发起流式调用,并在流结束时保存历史
        return chatModel.stream(prompt)
                .map(chatResponse -> {
                    String chunk = chatResponse.getResult().getOutput().getContent();
                    if (chunk != null) {
                        aiCompleteResponse.append(chunk); // 收集片段
                        return chunk; // 实时发送给前端
                    }
                    return "";
                })
                .doOnComplete(() -> {
                    // 5. 流式输出全部完成后,异步保存本轮对话到数据库
                    if (aiCompleteResponse.length() > 0) {
                        // 使用异步任务,避免阻塞主响应线程
                        CompletableFuture.runAsync(() -> {
                            chatMemoryService.saveMessagePair(sessionId, 
                                                               userId != null ? userId : "anonymous", 
                                                               message, 
                                                               aiCompleteResponse.toString());
                        });
                    }
                });
    }

这个接口的调用方式变成了:/api/ai/chat/stream/with-memory?sessionId=abc123&message=新问题&userId=user001。前端需要在开始一个新对话时生成一个唯一的sessionId(比如UUID),并在后续同一对话中持续传递它。这样,AI就能基于之前的所有对话历史来回答新问题,实现了真正的上下文连贯性。

我曾在项目中采用这种方案,将用户与AI的完整对话记录留存下来,不仅提升了用户体验,还为后续分析用户意图、优化提示词提供了宝贵的数据基础。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐