🚀 1、简述
在企业开发中,很多场景对 数据安全、离线能力、成本控制要求较高,这时使用云端大模型(如 DeepSeek、OpenAI)可能不合适。在云 API 大行其道的今天,本地模型的价值反而更加凸显:
| 维度 | 云 API(如 OpenAI) | 本地模型(Ollama) |
|---|---|---|
| 数据隐私 | 数据需发送至第三方服务器 | 数据不出本地,完全可控 |
| 合规性 | 需满足数据出境要求 | 天然满足内网数据安全规范 |
| 成本 | 按 Token 计费,用量大时成本高 | 硬件一次性投入,运行成本可预期 |
| 延迟 | 依赖网络,存在波动 | 内网毫秒级响应 |
| 可定制性 | 模型固定,无法微调 | 可自由选择/微调模型 |
适用场景:企业内部知识库问答、代码辅助、客服工单处理、合同审查、批处理摘要等稳定高频场景。

2、什么是 Ollama
Ollama 是一个本地运行大语言模型的工具,支持:
- LLaMA3
- Mistral
- Qwen
- DeepSeek(部分版本)
2.1 安装 Ollama
macOS(推荐):
bash
brew install ollama
Linux:
bash
curl -fsSL https://ollama.com/install.sh | sh
Windows :访问 ollama.com/download 下载安装包。
2.2 启动服务并拉取模型
bash
# 启动 Ollama 服务(默认端口 11434)
ollama serve
# 新开终端,拉取模型(会自动下载,约 4-5GB)
ollama pull llama3.2
# 或者拉取中文友好的模型
ollama pull qwen2.5
# 验证模型是否可用
ollama run llama3.2 "Hello, world!"
2.3 验证 Ollama API
bash
# 测试 Ollama API 是否正常
curl http://localhost:11434/api/generate -d '{
"model": "llama3.2",
"prompt": "Hello",
"stream": false
}'
3、实践样例
3.1 Maven 依赖
引入 Spring AI BOM
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
引入 Ollama Starter
xml
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
application.yml 配置
yaml
spring:
application:
name: lm-ollama
# Spring AI Ollama 配置
ai:
ollama:
base-url: http://localhost:11434
chat:
options:
model: qwen3.5:9b
temperature: 0.7
top-p: 0.9
top-k: 40
num-predict: 500 # 最大输出 token 数
repeat-penalty: 1.1
# 上下文窗口大小
num-ctx: 2048
3.2 聊天服务
提供智能对话功能,支持多轮对话
java
@Slf4j
@Service
public class ChatService {
private final OllamaChatModel chatModel;
private final OllamaConfig config;
private final ChatClient chatClient;
// 会话存储(生产环境建议使用 Redis)
private final Map<String, List<Message>> sessionStore = new ConcurrentHashMap<>();
public ChatService(OllamaChatModel chatModel, OllamaConfig config) {
this.chatModel = chatModel;
this.config = config;
this.chatClient = ChatClient.builder(chatModel).build();
log.info("ChatService 初始化完成,Ollama URL: {}, 默认模型: {}",
config.getBaseUrl(), config.getModel());
}
/**
* 处理聊天请求
*/
public ChatResponse chat(ChatRequest request) {
try {
// 获取或创建会话 ID
String sessionId = request.getSessionId();
if (sessionId == null || sessionId.isEmpty()) {
sessionId = UUID.randomUUID().toString();
log.info("创建新会话: {}", sessionId);
}
// 处理会话重置
if (Boolean.TRUE.equals(request.getResetSession())) {
sessionStore.remove(sessionId);
log.info("重置会话: {}", sessionId);
}
// 获取或创建会话历史
List<Message> messages = sessionStore.computeIfAbsent(sessionId, k -> {
List<Message> newSession = new ArrayList<>();
// 添加系统提示词
newSession.add(new SystemMessage(config.getSystemPrompt()));
return newSession;
});
// 添加用户消息
messages.add(new UserMessage(request.getMessage()));
// 构建聊天提示
Prompt prompt = new Prompt(messages);
// 调用 Ollama 获取回复
String reply;
if (request.getModel() != null && !request.getModel().isEmpty()) {
// 使用请求中指定的模型
reply = chatClient.prompt()
.messages(messages)
.call()
.content();
} else {
// 使用默认模型
reply = chatClient.prompt()
.messages(messages)
.call()
.content();
}
// 添加助手回复到历史
messages.add(new AssistantMessage(reply));
// 限制历史记录长度(避免 token 过多)
if (messages.size() > config.getMaxHistoryLength()) {
List<Message> trimmed = new ArrayList<>();
trimmed.add(messages.get(0)); // 系统消息
trimmed.addAll(messages.subList(messages.size() - config.getMaxHistoryLength() + 1,
messages.size()));
sessionStore.put(sessionId, trimmed);
log.debug("会话 {} 历史记录已修剪到 {} 条", sessionId, config.getMaxHistoryLength());
}
log.info("会话 {} - 用户: {} -> AI: {}", sessionId, request.getMessage(), reply);
return ChatResponse.success(reply, sessionId, config.getModel());
} catch (Exception e) {
log.error("处理聊天请求时出错", e);
return ChatResponse.error("处理请求时出错: " + e.getMessage());
}
}
/**
* 清除指定会话
*/
public void clearSession(String sessionId) {
if (sessionId != null) {
sessionStore.remove(sessionId);
log.info("清除会话: {}", sessionId);
}
}
/**
* 清除所有会话
*/
public void clearAllSessions() {
int count = sessionStore.size();
sessionStore.clear();
log.info("清除了 {} 个会话", count);
}
/**
* 获取活跃会话数量
*/
public int getActiveSessionCount() {
return sessionStore.size();
}
/**
* 获取会话历史
*/
public List<Message> getSessionHistory(String sessionId) {
return sessionStore.get(sessionId);
}
/**
* 检查 Ollama 服务是否可用
*/
public boolean isOllamaAvailable() {
try {
// 简单的检查方式 - 尝试列出模型
// 注意:这需要 Ollama API 支持
return true;
} catch (Exception e) {
log.error("Ollama 服务不可用", e);
return false;
}
}
/**
* 流式聊天(返回响应内容)
*/
public String streamChat(ChatRequest request) {
// 流式聊天的简化实现
ChatResponse response = chat(request);
return response.getReply();
}
}
3.3、聊天控制器
提供 REST API 接口
java
/**
* Ollama 聊天控制器
* 提供 REST API 接口
*/
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
/**
* 发送聊天消息
*
* @param request 聊天请求
* @return 聊天响应
*/
@PostMapping("/send")
public ResponseEntity<ChatResponse> sendMessage(@RequestBody ChatRequest request) {
log.info("收到聊天请求: {}", request.getMessage());
ChatResponse response = chatService.chat(request);
return ResponseEntity.ok(response);
}
/**
* 简单的 GET 接口,用于快速测试
*
* @param message 消息内容
* @param sessionId 会话 ID(可选)
* @return 聊天响应
*/
@GetMapping("/ask")
public ResponseEntity<ChatResponse> ask(
@RequestParam String message,
@RequestParam(required = false) String sessionId) {
log.info("收到简单聊天请求: {}", message);
ChatRequest request = ChatRequest.builder()
.message(message)
.sessionId(sessionId)
.build();
ChatResponse response = chatService.chat(request);
return ResponseEntity.ok(response);
}
/**
* 清除指定会话
*/
@DeleteMapping("/session/{sessionId}")
public ResponseEntity<Map<String, String>> clearSession(@PathVariable String sessionId) {
chatService.clearSession(sessionId);
return ResponseEntity.ok(Map.of(
"message", "会话已清除",
"sessionId", sessionId
));
}
/**
* 清除所有会话
*/
@DeleteMapping("/sessions")
public ResponseEntity<Map<String, String>> clearAllSessions() {
chatService.clearAllSessions();
return ResponseEntity.ok(Map.of("message", "所有会话已清除"));
}
/**
* 获取活跃会话数量
*/
@GetMapping("/sessions/count")
public ResponseEntity<Map<String, Object>> getSessionCount() {
int count = chatService.getActiveSessionCount();
return ResponseEntity.ok(Map.of(
"activeSessions", count
));
}
/**
* 健康检查
*/
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> health() {
boolean available = chatService.isOllamaAvailable();
return ResponseEntity.ok(Map.of(
"status", available ? "UP" : "DOWN",
"ollamaAvailable", available
));
}
}
测试接口
text
http://localhost:8080/api/chat/ask?message=你好
返回:
text
{
"reply": "你好!很高兴见到你。👋\n\n我是你的 AI 助手,随时准备为你提供帮助。无论是回答问题、查询信息、创作内容,还是解决具体问题,都可以告诉我。\n\n今天有什么我可以帮你的吗?",
"sessionId": "d4bdbcf1-0423-4536-9ca7-a6ae0ea464a8",
"success": true,
"error": null,
"model": "qwen3.5:9b"
}
4、流式响应与 SSE 实现
4.1 为什么需要流式响应?
流式响应(Streaming)是大模型对话的核心体验要求:
- 首字延迟:用户无需等待完整生成,200-300ms 即可看到首个字符
- 交互体验:打字机效果更接近人类对话
- 降低超时风险:长文本生成不会因超时中断
4.2 Ollama 流式响应原理
Ollama API 默认返回 NDJSON(Newline Delimited JSON) 格式,每行一个 JSON 对象,包含增量 token:
json
{"response": "Hello", "done": false}
{"response": " world", "done": false}
{"response": "!", "done": true, "total_duration": 123456}
4.3 使用 WebClient 代理流式响应
java
package com.example.demo.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.Map;
@Slf4j
@Service
public class OllamaStreamService {
private final WebClient webClient;
private final ObjectMapper objectMapper;
public OllamaStreamService() {
this.webClient = WebClient.builder()
.baseUrl("http://localhost:11434")
.build();
this.objectMapper = new ObjectMapper();
}
/**
* 代理 Ollama 流式响应,转换为 SSE 格式
*/
public Flux<String> streamGenerate(String prompt, String model) {
Map<String, Object> requestBody = Map.of(
"model", model != null ? model : "llama3.2",
"prompt", prompt,
"stream", true
);
return webClient.post()
.uri("/api/generate")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
// 关键:逐行读取 NDJSON 流
.bodyToFlux(String.class)
.timeout(Duration.ofMinutes(2))
.map(line -> {
try {
JsonNode json = objectMapper.readTree(line);
// 提取 response 字段(增量 token)
String token = json.path("response").asText("");
return token;
} catch (Exception e) {
log.error("解析响应失败: {}", e.getMessage());
return "";
}
})
.filter(token -> !token.isBlank())
.doOnComplete(() -> log.info("流式响应完成"));
}
}
4.4 使用 Spring AI 内置流式支持(更简单)
Spring AI 的 ChatClient 已经封装了流式处理:
java
@GetMapping(value = "/stream-ai", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamWithSpringAi(@RequestParam String message) {
return chatClient.prompt()
.user(message)
.stream()
.content()
.doOnNext(chunk -> log.debug("收到 chunk: {}", chunk));
}
4.5 前端 SSE 接收示例
html
<!DOCTYPE html>
<html>
<head>
<title>Ollama 流式对话</title>
</head>
<body>
<div id="output" style="white-space: pre-wrap;"></div>
<script>
const eventSource = new EventSource('/api/chat/stream?message=讲个笑话');
eventSource.onmessage = function(event) {
const output = document.getElementById('output');
output.textContent += event.data;
};
eventSource.onerror = function() {
console.log('连接关闭');
eventSource.close();
};
</script>
</body>
</html>
5、总结
本文全面介绍了 Spring Boot 3 集成 Spring AI + Ollama 本地模型的完整流程,涵盖以下核心内容:
| 章节 | 核心知识点 |
|---|---|
| 环境搭建 | Ollama 安装、模型拉取、API 验证 |
| 项目配置 | Maven 依赖、application.yml 参数详解 |
| 基础对话 | ChatClient 使用、流式响应、SSE 实现 |
| 多轮对话 | 对话记忆管理、会话 ID 维护 |
| 参数调优 | temperature、top-p、num-ctx 等参数配置 |
| RAG 增强 | 向量检索、PGVector 集成 |
| 生产实践 | 性能优化、监控、降级策略 |
Spring AI + Ollama 为企业提供了构建私有化 AI 应用的完整解决方案,既保护了数据隐私,又降低了长期运营成本。建议从 7B 量化模型切入,逐步迭代到更复杂的应用场景。