1. 为什么选择Spring AI + Ollama?Java开发者的本地AI新选择

如果你是一名Java开发者,最近肯定被各种AI大模型的消息刷屏了。但你可能也发现了,很多AI应用要么需要调用昂贵的云端API,要么就是Python生态的工具,Java开发者想玩转AI总觉得有点隔靴搔痒。我刚开始接触大模型时也有这种感觉,直到我发现了Spring AI和Ollama这对黄金组合。

Spring AI是Spring官方推出的AI集成框架,它让Java开发者能够用熟悉的Spring方式接入各种大模型。而Ollama则是一个让你能在本地轻松运行开源大模型的工具,完全免费,数据隐私有保障。把这两者结合起来,你就能在自己的电脑上搭建一个完全私有的AI服务,不需要联网,不需要API密钥,想怎么用就怎么用。

我实测下来,这套方案特别适合以下几种场景:一是企业内部需要私有化部署的AI应用,数据安全要求高;二是个人开发者想低成本学习大模型开发;三是需要快速原型验证的创业团队。用Spring Boot写个接口,本地跑个模型,前后端联调一气呵成,那种顺畅感真的让人上瘾。

更重要的是,这套方案的学习成本很低。如果你已经熟悉Spring Boot开发,基本上半天就能上手。我见过不少团队用这个组合快速搭建了内部的智能客服原型、代码助手工具,甚至是文档分析系统。下面我就带你从零开始,一步步构建一个完整的Java大模型应用。

2. 环境准备:5分钟搞定基础配置

在开始编码之前,我们需要先把环境搭建好。这个过程比你想的要简单,我保证10分钟内就能搞定。

2.1 安装Ollama和下载模型

首先去Ollama官网下载对应你操作系统的安装包。Windows用户直接运行exe安装程序,Mac用户用Homebrew安装也很方便。安装完成后,打开终端输入ollama --version,能看到版本号就说明安装成功了。

接下来下载模型。Ollama支持很多开源模型,我推荐从DeepSeek开始,它对中文支持好,而且模型大小适中。在终端执行:

# 查看可用的模型列表
ollama list

# 拉取DeepSeek模型(7B版本对大多数电脑都友好)
ollama pull deepseek-r1:7b

# 运行模型测试一下
ollama run deepseek-r1:7b

运行模型后,你会进入一个交互式命令行界面,可以直接和模型对话。输入"你好",看看它会不会用中文回复你。如果一切正常,按Ctrl+D退出。

这里有个小技巧:如果你电脑内存不够大(比如只有8GB),可以试试1.5B的小模型,响应速度会快很多。但如果你有16GB以上内存,7B模型的效果会明显更好。我自己的MacBook Pro M1(16GB)跑7B模型完全没问题,响应速度在可接受范围内。

2.2 创建Spring Boot项目

打开你喜欢的IDE,我用的是IntelliJ IDEA。通过Spring Initializr创建新项目,选择以下依赖:

  • Spring Web(提供REST API支持)
  • Spring AI Ollama(核心的AI集成)
  • Lombok(简化代码,可选但推荐)
  • Spring Boot DevTools(开发时热加载)

如果你习惯用命令行,也可以用curl快速创建:

curl https://start.spring.io/starter.zip \
  -d dependencies=web,ai-ollama,lombok,devtools \
  -d javaVersion=17 \
  -d type=maven-project \
  -d groupId=com.example \
  -d artifactId=ai-demo \
  -o ai-demo.zip

解压后用IDE打开项目,我们先来配置最重要的pom.xml文件。

3. 依赖配置:pom.xml和application.yml详解

3.1 pom.xml的关键配置

打开pom.xml,确保你有以下依赖。我建议使用Spring AI 1.0.0以上版本,API更稳定:

<?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 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.11</version>
        <relativePath/>
    </parent>
    
    <groupId>com.example</groupId>
    <artifactId>spring-ai-ollama-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.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>
        <!-- Web支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- Spring AI Ollama核心依赖 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-ollama</artifactId>
        </dependency>
        
        <!-- 开发工具 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        
        <!-- 测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        
        <!-- Lombok简化代码 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

这里有个细节需要注意:Spring AI的版本管理通过BOM(Bill of Materials)来统一管理,这样能确保所有Spring AI相关组件的版本一致性。如果你发现某些类找不到,大概率是版本不匹配的问题。

3.2 application.yml的完整配置

接下来是application.yml的配置,这是连接Ollama服务的关键:

server:
  port: 8080
  servlet:
    context-path: /api

spring:
  application:
    name: spring-ai-demo
  
  # Spring AI Ollama配置
  ai:
    ollama:
      # Ollama服务地址,默认运行在11434端口
      base-url: http://localhost:11434
      
      # 聊天模型配置
      chat:
        options:
          # 指定使用的模型名称,必须和Ollama中安装的模型一致
          model: deepseek-r1:7b
          
          # 温度参数,控制输出的随机性(0-1)
          # 值越低输出越确定,值越高越有创造性
          temperature: 0.7
          
          # top-p参数,控制输出的多样性
          top-p: 0.9
          
          # 最大生成token数,-1表示无限制
          num-predict: 512
          
          # 重复惩罚,防止模型重复输出相同内容
          repeat-penalty: 1.1
          
          # 上下文窗口大小
          num-ctx: 4096

# 日志配置,方便调试
logging:
  level:
    org.springframework.ai: DEBUG
    com.example: INFO

我强烈建议你在开发阶段把Spring AI的日志级别设为DEBUG,这样能看到详细的请求和响应信息,方便排查问题。实际部署时可以调回INFO或WARN。

这里有个我踩过的坑:base-url一定要写对,Ollama默认运行在11434端口。如果你改了端口或者部署在远程服务器,这里要相应调整。另外model名称必须完全匹配,包括大小写。你可以通过ollama list命令查看本地已安装的模型名称。

4. 基础API调用:从Hello World到实际应用

环境配置好了,现在我们来写第一个AI接口。我会带你实现两种最常用的调用方式:同步调用和流式调用。

4.1 同步调用:最简单的聊天接口

先创建一个简单的Controller,实现最基本的问答功能:

package com.example.ai.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/chat")
@RequiredArgsConstructor
public class ChatController {
    
    private final OllamaChatModel chatModel;
    
    /**
     * 基础同步聊天接口
     * 适合短文本、不需要实时反馈的场景
     */
    @GetMapping("/simple")
    public String simpleChat(@RequestParam String message) {
        // 最简单的调用方式
        return chatModel.call(message);
    }
    
    /**
     * 带系统提示词的聊天接口
     * 可以给AI设定角色和任务
     */
    @PostMapping("/with-prompt")
    public String chatWithPrompt(@RequestBody ChatRequest request) {
        // 构建包含系统提示的消息
        String systemPrompt = "你是一个专业的Java技术专家,用简洁明了的方式回答问题。";
        String userMessage = request.getMessage();
        
        // 使用Prompt对象,可以更精细地控制消息
        Prompt prompt = new Prompt(
            List.of(
                new SystemMessage(systemPrompt),
                new UserMessage(userMessage)
            )
        );
        
        ChatResponse response = chatModel.call(prompt);
        return response.getResult().getOutput().getContent();
    }
    
    /**
     * 带参数的聊天接口
     * 可以动态调整生成参数
     */
    @PostMapping("/with-options")
    public String chatWithOptions(@RequestBody ChatRequest request) {
        // 创建自定义选项
        OllamaOptions options = OllamaOptions.builder()
            .model("deepseek-r1:7b")
            .temperature(request.getTemperature() != null ? request.getTemperature() : 0.7)
            .topP(request.getTopP() != null ? request.getTopP() : 0.9)
            .numPredict(request.getMaxTokens() != null ? request.getMaxTokens() : 512)
            .build();
        
        Prompt prompt = new Prompt(
            new UserMessage(request.getMessage()),
            options
        );
        
        return chatModel.call(prompt).getResult().getOutput().getContent();
    }
}

// 请求对象
@Data
class ChatRequest {
    private String message;
    private Float temperature;
    private Float topP;
    private Integer maxTokens;
}

测试一下,启动应用后访问http://localhost:8080/api/chat/simple?message=你好,应该能看到AI的回复。如果遇到连接错误,先检查Ollama服务是否在运行,可以用curl http://localhost:11434/api/tags测试Ollama API是否可用。

4.2 流式调用:实现打字机效果

同步调用的问题是用户要等AI完全生成完才能看到结果,体验不好。现在的主流AI应用都是流式输出,像打字机一样一个字一个字显示。用Spring AI实现这个功能非常简单:

@RestController
@RequestMapping("/chat")
@RequiredArgsConstructor
public class StreamChatController {
    
    private final OllamaChatModel chatModel;
    
    /**
     * 流式聊天接口
     * 使用Server-Sent Events (SSE) 技术
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestParam String message) {
        Prompt prompt = new Prompt(new UserMessage(message));
        
        return chatModel.stream(prompt)
            .map(response -> {
                // 提取每次返回的内容
                if (response.getResult() != null && 
                    response.getResult().getOutput() != null) {
                    return response.getResult().getOutput().getContent();
                }
                return "";
            })
            .filter(content -> !content.isEmpty());
    }
    
    /**
     * 更完整的流式接口,包含元数据
     */
    @GetMapping(value = "/stream-full", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<ChatStreamResponse>> streamChatFull(@RequestParam String message) {
        Prompt prompt = new Prompt(new UserMessage(message));
        
        AtomicInteger chunkCount = new AtomicInteger(0);
        
        return chatModel.stream(prompt)
            .map(response -> {
                ChatStreamResponse streamResponse = new ChatStreamResponse();
                streamResponse.setChunkId(chunkCount.incrementAndGet());
                
                if (response.getResult() != null && 
                    response.getResult().getOutput() != null) {
                    streamResponse.setContent(response.getResult().getOutput().getContent());
                    streamResponse.setFinished(false);
                } else {
                    streamResponse.setFinished(true);
                }
                
                // 添加元数据
                Map<String, Object> metadata = new HashMap<>();
                metadata.put("timestamp", System.currentTimeMillis());
                metadata.put("model", "deepseek-r1:7b");
                streamResponse.setMetadata(metadata);
                
                return ServerSentEvent.builder(streamResponse)
                    .event("message")
                    .id(String.valueOf(streamResponse.getChunkId()))
                    .build();
            });
    }
}

// 流式响应对象
@Data
class ChatStreamResponse {
    private Integer chunkId;
    private String content;
    private Boolean finished;
    private Map<String, Object> metadata;
}

前端调用这个接口时,可以使用EventSource API:

// 前端JavaScript示例
function startStreamChat(message) {
    const eventSource = new EventSource(`/api/chat/stream?message=${encodeURIComponent(message)}`);
    const outputDiv = document.getElementById('output');
    
    eventSource.onmessage = function(event) {
        outputDiv.innerHTML += event.data;
    };
    
    eventSource.onerror = function() {
        eventSource.close();
        outputDiv.innerHTML += '<br/>[对话结束]';
    };
}

流式接口有几个好处:一是用户体验好,看到文字逐渐出现;二是可以实时处理长文本,避免超时;三是可以中途停止生成,节省计算资源。我在实际项目中发现,用户对流式输出的接受度明显更高。

5. 高级功能:对话记忆与多轮对话

基础功能跑通后,我们来看看如何实现更实用的功能。单次问答的AI没什么意思,真正的价值在于多轮对话,让AI记住之前的聊天内容。

5.1 使用Spring AI内置的记忆功能

Spring AI提供了简单的内存记忆功能,开箱即用:

@Service
public class MemoryChatService {
    
    private final OllamaChatModel chatModel;
    private final Map<String, List<Message>> conversationMemory = new ConcurrentHashMap<>();
    
    public MemoryChatService(OllamaChatModel chatModel) {
        this.chatModel = chatModel;
    }
    
    /**
     * 带记忆的聊天
     * @param sessionId 会话ID,用于区分不同用户或对话
     * @param message 用户消息
     * @return AI回复
     */
    public String chatWithMemory(String sessionId, String message) {
        // 获取或创建会话历史
        List<Message> history = conversationMemory.getOrDefault(sessionId, new ArrayList<>());
        
        // 添加用户新消息
        history.add(new UserMessage(message));
        
        // 构建包含历史记录的Prompt
        Prompt prompt = new Prompt(history);
        
        // 调用模型
        ChatResponse response = chatModel.call(prompt);
        String aiResponse = response.getResult().getOutput().getContent();
        
        // 添加AI回复到历史
        history.add(new AssistantMessage(aiResponse));
        
        // 保存更新后的历史(限制历史长度,避免内存溢出)
        if (history.size() > 20) { // 保留最近10轮对话
            history = history.subList(history.size() - 20, history.size());
        }
        conversationMemory.put(sessionId, history);
        
        return aiResponse;
    }
    
    /**
     * 清空指定会话的记忆
     */
    public void clearMemory(String sessionId) {
        conversationMemory.remove(sessionId);
    }
    
    /**
     * 获取会话历史
     */
    public List<ChatHistory> getHistory(String sessionId) {
        List<Message> messages = conversationMemory.get(sessionId);
        if (messages == null) {
            return Collections.emptyList();
        }
        
        return messages.stream()
            .map(msg -> {
                ChatHistory history = new ChatHistory();
                history.setRole(msg.getMessageType().name());
                history.setContent(msg.getContent());
                history.setTimestamp(new Date());
                return history;
            })
            .collect(Collectors.toList());
    }
}

内存记忆的优点是简单快速,但有个致命缺点:服务重启后记忆就丢失了。对于生产环境,我们需要持久化存储。

5.2 数据库持久化记忆实现

下面我实现一个基于MySQL的完整记忆系统,包含会话管理和消息存储:

@Entity
@Table(name = "chat_session")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatSession {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "session_id", unique = true, nullable = false)
    private String sessionId;
    
    @Column(name = "user_id")
    private String userId;
    
    @Column(name = "title")
    private String title;
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
    
    @Column(name = "is_active")
    private Boolean isActive = true;
    
    @OneToMany(mappedBy = "session", cascade = CascadeType.ALL)
    private List<ChatMessage> messages = new ArrayList<>();
}

@Entity
@Table(name = "chat_message")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "session_id", referencedColumnName = "session_id")
    private ChatSession session;
    
    @Column(name = "role", nullable = false)
    private String role; // "user" 或 "assistant"
    
    @Column(name = "content", columnDefinition = "TEXT")
    private String content;
    
    @Column(name = "tokens")
    private Integer tokens;
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    @Column(name = "metadata", columnDefinition = "JSON")
    private String metadata; // 存储额外信息,如模型参数等
}

@Repository
public interface ChatSessionRepository extends JpaRepository<ChatSession, Long> {
    Optional<ChatSession> findBySessionId(String sessionId);
    List<ChatSession> findByUserIdAndIsActiveTrue(String userId);
}

@Repository
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
    List<ChatMessage> findBySession_SessionIdOrderByCreatedAtAsc(String sessionId);
    
    @Query("SELECT cm FROM ChatMessage cm WHERE cm.session.sessionId = :sessionId " +
           "ORDER BY cm.createdAt DESC LIMIT :limit")
    List<ChatMessage> findRecentMessages(@Param("sessionId") String sessionId, 
                                         @Param("limit") int limit);
}

@Service
@Transactional
@RequiredArgsConstructor
public class PersistentChatService {
    
    private final OllamaChatModel chatModel;
    private final ChatSessionRepository sessionRepository;
    private final ChatMessageRepository messageRepository;
    
    /**
     * 带持久化记忆的聊天
     */
    public ChatResponse chatWithPersistentMemory(String sessionId, String userId, String message) {
        // 获取或创建会话
        ChatSession session = sessionRepository.findBySessionId(sessionId)
            .orElseGet(() -> createNewSession(sessionId, userId, message));
        
        // 加载最近的历史消息(限制条数避免token超限)
        List<ChatMessage> recentMessages = messageRepository
            .findRecentMessages(sessionId, 10);
        
        // 转换为Spring AI的Message对象
        List<Message> messageHistory = recentMessages.stream()
            .map(msg -> {
                if ("user".equals(msg.getRole())) {
                    return new UserMessage(msg.getContent());
                } else {
                    return new AssistantMessage(msg.getContent());
                }
            })
            .collect(Collectors.toList());
        
        // 添加当前用户消息
        messageHistory.add(new UserMessage(message));
        
        // 保存用户消息到数据库
        ChatMessage userMessage = new ChatMessage();
        userMessage.setSession(session);
        userMessage.setRole("user");
        userMessage.setContent(message);
        userMessage.setCreatedAt(LocalDateTime.now());
        messageRepository.save(userMessage);
        
        // 调用AI
        Prompt prompt = new Prompt(messageHistory);
        ChatResponse response = chatModel.call(prompt);
        String aiResponse = response.getResult().getOutput().getContent();
        
        // 保存AI回复到数据库
        ChatMessage aiMessage = new ChatMessage();
        aiMessage.setSession(session);
        aiMessage.setRole("assistant");
        aiMessage.setContent(aiResponse);
        aiMessage.setCreatedAt(LocalDateTime.now());
        messageRepository.save(aiMessage);
        
        // 更新会话时间
        session.setUpdatedAt(LocalDateTime.now());
        sessionRepository.save(session);
        
        return response;
    }
    
    /**
     * 流式版本,适合前端实时显示
     */
    public Flux<ChatResponse> streamWithPersistentMemory(String sessionId, String userId, String message) {
        // 类似的逻辑,但返回Flux
        ChatSession session = sessionRepository.findBySessionId(sessionId)
            .orElseGet(() -> createNewSession(sessionId, userId, message));
        
        List<ChatMessage> recentMessages = messageRepository
            .findRecentMessages(sessionId, 10);
        
        List<Message> messageHistory = recentMessages.stream()
            .map(this::convertToMessage)
            .collect(Collectors.toList());
        
        messageHistory.add(new UserMessage(message));
        
        // 保存用户消息
        saveMessage(session, "user", message);
        
        // 流式调用
        Prompt prompt = new Prompt(messageHistory);
        Flux<ChatResponse> responseFlux = chatModel.stream(prompt);
        
        // 收集AI回复并保存
        StringBuilder aiResponseBuilder = new StringBuilder();
        
        return responseFlux
            .doOnNext(response -> {
                if (response.getResult() != null && 
                    response.getResult().getOutput() != null) {
                    String content = response.getResult().getOutput().getContent();
                    if (content != null) {
                        aiResponseBuilder.append(content);
                    }
                }
            })
            .doOnComplete(() -> {
                // 流式完成后保存完整的AI回复
                String aiResponse = aiResponseBuilder.toString();
                if (!aiResponse.isEmpty()) {
                    saveMessage(session, "assistant", aiResponse);
                    session.setUpdatedAt(LocalDateTime.now());
                    sessionRepository.save(session);
                }
            });
    }
    
    private ChatSession createNewSession(String sessionId, String userId, String firstMessage) {
        ChatSession session = new ChatSession();
        session.setSessionId(sessionId);
        session.setUserId(userId);
        session.setTitle(generateTitle(firstMessage));
        session.setCreatedAt(LocalDateTime.now());
        session.setUpdatedAt(LocalDateTime.now());
        session.setIsActive(true);
        return sessionRepository.save(session);
    }
    
    private String generateTitle(String message) {
        // 简单地从第一条消息生成标题
        if (message.length() > 30) {
            return message.substring(0, 30) + "...";
        }
        return message;
    }
    
    private Message convertToMessage(ChatMessage chatMessage) {
        if ("user".equals(chatMessage.getRole())) {
            return new UserMessage(chatMessage.getContent());
        } else {
            return new AssistantMessage(chatMessage.getContent());
        }
    }
    
    private void saveMessage(ChatSession session, String role, String content) {
        ChatMessage message = new ChatMessage();
        message.setSession(session);
        message.setRole(role);
        message.setContent(content);
        message.setCreatedAt(LocalDateTime.now());
        messageRepository.save(message);
    }
}

这个实现有几个关键点:一是使用JPA做持久化,二是限制历史消息条数避免token超限,三是自动生成会话标题。我还加了流式版本的实现,可以边生成边保存。

实际使用中,你可能还需要考虑token计数、消息清理策略、会话过期等。比如可以添加一个定时任务,定期清理过期的会话:

@Component
@RequiredArgsConstructor
public class SessionCleanupTask {
    
    private final ChatSessionRepository sessionRepository;
    
    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
    @Transactional
    public void cleanupInactiveSessions() {
        LocalDateTime cutoff = LocalDateTime.now().minusDays(30); // 清理30天未活动的会话
        List<ChatSession> inactiveSessions = sessionRepository
            .findByUpdatedAtBeforeAndIsActiveTrue(cutoff);
        
        inactiveSessions.forEach(session -> {
            session.setIsActive(false);
            sessionRepository.save(session);
        });
        
        log.info("清理了 {} 个不活跃会话", inactiveSessions.size());
    }
}

6. 多模态支持:图片+文本的AI应用

现在的AI不仅能处理文字,还能看懂图片。Ollama支持多模态模型(比如LLaVA),Spring AI也提供了相应的API。下面我带你实现一个能分析图片的接口。

6.1 配置多模态模型

首先确保你安装了多模态模型:

# 拉取LLaVA多模态模型
ollama pull llava

# 或者使用其他多模态模型
ollama pull bakllava

然后在application.yml中配置多模态模型:

spring:
  ai:
    ollama:
      base-url: http://localhost:11434
      chat:
        options:
          model: llava  # 使用多模态模型

6.2 实现图片分析接口

@RestController
@RequestMapping("/multimodal")
@RequiredArgsConstructor
public class MultimodalController {
    
    private final OllamaChatModel chatModel;
    
    /**
     * 分析图片接口
     * 支持base64编码的图片
     */
    @PostMapping(value = "/analyze-image", consumes = MediaType.APPLICATION_JSON_VALUE)
    public String analyzeImage(@RequestBody ImageAnalysisRequest request) {
        try {
            // 解码base64图片
            byte[] imageBytes = Base64.getDecoder().decode(request.getImageBase64());
            ByteArrayResource imageResource = new ByteArrayResource(imageBytes);
            
            // 构建多模态消息
            UserMessage userMessage = UserMessage.builder()
                .text(request.getPrompt())
                .media(new Media(MediaType.IMAGE_PNG, imageResource))
                .build();
            
            // 可以添加系统提示词优化效果
            SystemMessage systemMessage = new SystemMessage(
                "你是一个专业的图像分析助手。请详细描述图片内容,包括物体、场景、颜色、文字等所有细节。"
            );
            
            Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
            ChatResponse response = chatModel.call(prompt);
            
            return response.getResult().getOutput().getContent();
            
        } catch (IllegalArgumentException e) {
            throw new RuntimeException("图片base64格式错误", e);
        }
    }
    
    /**
     * 流式图片分析接口
     */
    @PostMapping(value = "/analyze-image-stream", 
                consumes = MediaType.APPLICATION_JSON_VALUE,
                produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> analyzeImageStream(@RequestBody ImageAnalysisRequest request) {
        byte[] imageBytes = Base64.getDecoder().decode(request.getImageBase64());
        ByteArrayResource imageResource = new ByteArrayResource(imageBytes);
        
        UserMessage userMessage = UserMessage.builder()
            .text(request.getPrompt())
            .media(new Media(MediaType.IMAGE_PNG, imageResource))
            .build();
        
        SystemMessage systemMessage = new SystemMessage(
            "请逐步分析这张图片,先描述整体场景,再分析细节。"
        );
        
        Prompt prompt = new Prompt(List.of(systemMessage, userMessage));
        
        return chatModel.stream(prompt)
            .map(response -> {
                if (response.getResult() != null && 
                    response.getResult().getOutput() != null) {
                    return response.getResult().getOutput().getContent();
                }
                return "";
            })
            .filter(content -> !content.isEmpty());
    }
    
    /**
     * 处理上传的图片文件
     */
    @PostMapping(value = "/upload-and-analyze", 
                consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String uploadAndAnalyze(
            @RequestParam("file") MultipartFile file,
            @RequestParam("prompt") String prompt) {
        
        try {
            // 将上传的文件转为base64
            String base64Image = Base64.getEncoder()
                .encodeToString(file.getBytes());
            
            ImageAnalysisRequest request = new ImageAnalysisRequest();
            request.setImageBase64(base64Image);
            request.setPrompt(prompt);
            
            return analyzeImage(request);
            
        } catch (IOException e) {
            throw new RuntimeException("文件处理失败", e);
        }
    }
}

@Data
class ImageAnalysisRequest {
    private String imageBase64;  // base64编码的图片
    private String prompt;       // 分析指令
}

前端调用时,需要将图片转为base64:

<!-- HTML前端示例 -->
<input type="file" id="imageInput" accept="image/*">
<textarea id="promptInput" placeholder="你想了解图片的什么?"></textarea>
<button onclick="analyzeImage()">分析图片</button>
<div id="result"></div>

<script>
async function analyzeImage() {
    const fileInput = document.getElementById('imageInput');
    const promptInput = document.getElementById('promptInput');
    const resultDiv = document.getElementById('result');
    
    if (!fileInput.files[0]) {
        alert('请选择图片');
        return;
    }
    
    const file = fileInput.files[0];
    const prompt = promptInput.value || '请描述这张图片';
    
    // 将图片转为base64
    const reader = new FileReader();
    reader.onload = async function(e) {
        const base64 = e.target.result.split(',')[1]; // 去掉data:image/png;base64,前缀
        
        const request = {
            imageBase64: base64,
            prompt: prompt
        };
        
        try {
            const response = await fetch('/api/multimodal/analyze-image', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(request)
            });
            
            const result = await response.text();
            resultDiv.innerHTML = `<p><strong>分析结果:</strong><br/>${result}</p>`;
            
        } catch (error) {
            resultDiv.innerHTML = `<p style="color: red;">分析失败:${error.message}</p>`;
        }
    };
    
    reader.readAsDataURL(file);
}
</script>

6.3 多模态应用场景

多模态功能在实际项目中很有用,我举几个例子:

  1. 商品图片分析:电商平台可以自动分析商品图片,生成商品描述
  2. 文档OCR+理解:上传扫描件,AI既能识别文字又能理解内容
  3. 医疗影像辅助:分析X光片、CT影像(需要专业模型)
  4. 教育应用:学生上传作业图片,AI批改并给出建议
  5. 内容审核:自动识别图片中的违规内容

需要注意的是,多模态模型通常比纯文本模型大,需要更多内存。LLaVA-7B模型大概需要8-10GB内存,如果你的电脑配置不够,可以考虑用更小的模型或者使用云服务器。

7. 性能优化与生产部署

当你的AI应用从demo走向生产时,性能优化就变得很重要了。下面分享一些我实战中总结的优化经验。

7.1 连接池和超时配置

默认情况下,Spring AI使用简单的HTTP客户端。在生产环境中,我们需要配置连接池和合理的超时时间:

spring:
  ai:
    ollama:
      base-url: http://localhost:11434
      client:
        # 连接超时
        connect-timeout: 10s
        # 读取超时(根据模型响应时间调整)
        read-timeout: 300s
        # 连接池配置
        max-connections: 50
        max-connections-per-route: 20
        # 重试配置
        retry:
          max-attempts: 3
          backoff:
            initial-interval: 1s
            max-interval: 10s
            multiplier: 2

对于长时间运行的生成任务,read-timeout要设置得足够长。我建议根据你的应用场景调整:如果是实时对话,可以设短一些(30-60秒);如果是文档生成,可能需要几分钟。

7.2 模型参数调优

不同的任务需要不同的模型参数。下面是我总结的一些经验值:

@Service
public class ModelOptimizerService {
    
    private final OllamaChatModel chatModel;
    
    /**
     * 创意写作模式
     */
    public String creativeWriting(String prompt) {
        OllamaOptions options = OllamaOptions.builder()
            .temperature(0.9)      // 高温度,更有创意
            .topP(0.95)            // 高top-p,更多样化
            .repeatPenalty(1.2)    // 适当惩罚重复
            .numPredict(1000)      // 生成长文本
            .build();
        
        return chatModel.call(new Prompt(prompt, options));
    }
    
    /**
     * 代码生成模式
     */
    public String codeGeneration(String requirement) {
        OllamaOptions options = OllamaOptions.builder()
            .temperature(0.2)      // 低温度,更确定
            .topP(0.8)             // 中等多样性
            .repeatPenalty(1.1)    // 轻微惩罚重复
            .numPredict(500)       // 中等长度
            .build();
        
        String systemPrompt = "你是一个经验丰富的程序员,请生成高质量、可运行的代码。";
        List<Message> messages = List.of(
            new SystemMessage(systemPrompt),
            new UserMessage(requirement)
        );
        
        return chatModel.call(new Prompt(messages, options));
    }
    
    /**
     * 摘要生成模式
     */
    public String textSummarization(String text) {
        OllamaOptions options = OllamaOptions.builder()
            .temperature(0.3)      // 低温度,保持原意
            .topP(0.7)             // 较低多样性
            .numPredict(200)       // 摘要要简洁
            .build();
        
        String prompt = "请用中文总结以下内容,保留关键信息:\n\n" + text;
        return chatModel.call(new Prompt(prompt, options));
    }
    
    /**
     * 对话模式
     */
    public String conversationalChat(String message, List<Message> history) {
        OllamaOptions options = OllamaOptions.builder()
            .temperature(0.7)      // 适中温度,自然对话
            .topP(0.9)             // 适当多样性
            .repeatPenalty(1.15)   // 防止重复
            .numPredict(300)       // 对话不宜过长
            .build();
        
        // 添加历史上下文
        List<Message> allMessages = new ArrayList<>(history);
        allMessages.add(new UserMessage(message));
        
        return chatModel.call(new Prompt(allMessages, options));
    }
}

7.3 缓存策略

对于重复的查询,可以使用缓存提高响应速度:

@Service
@CacheConfig(cacheNames = "aiResponses")
public class CachedAIService {
    
    private final OllamaChatModel chatModel;
    
    /**
     * 带缓存的AI调用
     * 适合答案相对固定的问题
     */
    @Cacheable(key = "#prompt + '|' + #optionsHash", unless = "#result == null")
    public String getCachedResponse(String prompt, String optionsHash) {
        // 实际调用AI
        return chatModel.call(prompt);
    }
    
    /**
     * 生成选项的哈希值,用于缓存键
     */
    public String generateOptionsHash(OllamaOptions options) {
        try {
            ObjectMapper mapper = new ObjectMapper();
            String json = mapper.writeValueAsString(options);
            return DigestUtils.md5DigestAsHex(json.getBytes());
        } catch (JsonProcessingException e) {
            return "default";
        }
    }
    
    /**
     * 带时间戳的缓存,适合时效性内容
     */
    @Cacheable(key = "#prompt + '|' + #date", unless = "#result == null")
    public String getDailyResponse(String prompt, @CacheParam LocalDate date) {
        return chatModel.call(prompt);
    }
}

// 缓存配置
@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(1, TimeUnit.HOURS)  // 1小时过期
            .maximumSize(1000)                     // 最大缓存1000条
            .recordStats());                       // 记录统计信息
        return cacheManager;
    }
}

7.4 监控和日志

生产环境需要完善的监控:

@Aspect
@Component
@Slf4j
public class AIMonitoringAspect {
    
    @Around("execution(* org.springframework.ai.chat.model.ChatModel.call(..))")
    public Object monitorCall(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        String model = "unknown";
        
        try {
            // 获取模型信息
            if (joinPoint.getTarget() instanceof OllamaChatModel) {
                model = "ollama";
            }
            
            Object result = joinPoint.proceed();
            long duration = System.currentTimeMillis() - startTime;
            
            // 记录成功日志
            log.info("AI调用成功 - 模型: {}, 耗时: {}ms", model, duration);
            
            // 发送监控指标
            Metrics.counter("ai.call.success", "model", model).increment();
            Metrics.timer("ai.call.duration", "model", model).record(duration, TimeUnit.MILLISECONDS);
            
            return result;
            
        } catch (Exception e) {
            long duration = System.currentTimeMillis() - startTime;
            
            // 记录错误日志
            log.error("AI调用失败 - 模型: {}, 耗时: {}ms, 错误: {}", 
                     model, duration, e.getMessage(), e);
            
            // 发送错误指标
            Metrics.counter("ai.call.error", "model", model).increment();
            
            throw e;
        }
    }
    
    /**
     * 监控token使用情况
     */
    @AfterReturning(
        pointcut = "execution(* org.springframework.ai.chat.model.ChatModel.call(..))",
        returning = "response"
    )
    public void monitorTokens(ChatResponse response) {
        if (response != null && response.getMetadata() != null) {
            Map<String, Object> metadata = response.getMetadata();
            Object promptTokens = metadata.get("promptTokens");
            Object completionTokens = metadata.get("completionTokens");
            Object totalTokens = metadata.get("totalTokens");
            
            if (promptTokens != null) {
                Metrics.summary("ai.tokens.prompt").record(((Number) promptTokens).doubleValue());
            }
            if (completionTokens != null) {
                Metrics.summary("ai.tokens.completion").record(((Number) completionTokens).doubleValue());
            }
            if (totalTokens != null) {
                Metrics.summary("ai.tokens.total").record(((Number) totalTokens).doubleValue());
            }
        }
    }
}

// 简单的指标收集
@Component
class Metrics {
    private static final Map<String, io.micrometer.core.instrument.Counter> counters = new ConcurrentHashMap<>();
    private static final Map<String, io.micrometer.core.instrument.Timer> timers = new ConcurrentHashMap<>();
    private static final Map<String, io.micrometer.core.instrument.DistributionSummary> summaries = new ConcurrentHashMap<>();
    
    public static Counter counter(String name, String... tags) {
        String key = name + Arrays.toString(tags);
        return counters.computeIfAbsent(key, k -> 
            io.micrometer.core.instrument.Metrics.counter(name, tags));
    }
    
    public static Timer timer(String name, String... tags) {
        String key = name + Arrays.toString(tags);
        return timers.computeIfAbsent(key, k -> 
            io.micrometer.core.instrument.Metrics.timer(name, tags));
    }
    
    public static DistributionSummary summary(String name) {
        return summaries.computeIfAbsent(name, 
            io.micrometer.core.instrument.Metrics::summary);
    }
}

7.5 部署建议

最后说说部署。本地开发用Ollama没问题,但生产环境建议:

  1. 专用服务器:AI模型比较吃资源,最好用单独的服务器
  2. GPU加速:如果有NVIDIA显卡,Ollama可以自动用CUDA加速
  3. 容器化部署:用Docker部署Ollama和Spring Boot应用
  4. 负载均衡:如果流量大,可以部署多个Ollama实例
  5. 监控告警:监控内存、GPU使用率、响应时间等

Docker部署示例:

# Dockerfile for Spring Boot app
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

# docker-compose.yml
version: '3.8'
services:
  ollama:
    image: ollama/ollama:latest
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
  
  ai-app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_AI_OLLAMA_BASE_URL=http://ollama:11434
    depends_on:
      - ollama

volumes:
  ollama_data:

这套方案我在实际项目中跑过,稳定性不错。关键是数据完全私有,不用担心隐私问题,成本也可控。如果你刚开始接触,建议先从本地开发开始,熟悉了再考虑生产部署。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐