Spring AI与Ollama深度整合:从零构建Java大模型应用
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 多模态应用场景
多模态功能在实际项目中很有用,我举几个例子:
- 商品图片分析:电商平台可以自动分析商品图片,生成商品描述
- 文档OCR+理解:上传扫描件,AI既能识别文字又能理解内容
- 医疗影像辅助:分析X光片、CT影像(需要专业模型)
- 教育应用:学生上传作业图片,AI批改并给出建议
- 内容审核:自动识别图片中的违规内容
需要注意的是,多模态模型通常比纯文本模型大,需要更多内存。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没问题,但生产环境建议:
- 专用服务器:AI模型比较吃资源,最好用单独的服务器
- GPU加速:如果有NVIDIA显卡,Ollama可以自动用CUDA加速
- 容器化部署:用Docker部署Ollama和Spring Boot应用
- 负载均衡:如果流量大,可以部署多个Ollama实例
- 监控告警:监控内存、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:
这套方案我在实际项目中跑过,稳定性不错。关键是数据完全私有,不用担心隐私问题,成本也可控。如果你刚开始接触,建议先从本地开发开始,熟悉了再考虑生产部署。
更多推荐



所有评论(0)