前言
这里需要做到的可以根据上下文内容进行聊天,下面是做到的简单效果图

一、Ollama与DeepSeek-R1简介
1. Ollama是什么?
Ollama是一款开源的大模型本地化部署工具,支持一键安装、管理和运行多种大型语言模型
。其核心优势在于:
- 提供类似Docker的模型管理体验(创建、加载、运行)
- 支持Windows/macOS/Linux多平台
- 仅需命令行即可完成所有操作
- 默认API接口(11434端口)便于集成其他应用
2. DeepSeek-R1:7B模型特点
作为国产明星开源模型,DeepSeek-R1:7B在代码生成与中文理解方面表现优异:
- 70亿参数规模平衡性能与资源消耗
- 支持128K超长上下文处理
- 特别优化中文语义理解能力
- 适用于技术问答、文档总结等场景
二、Ollama安装指南(Mac & Windows)
🍎 Mac用户安装步骤
方法1:Homebrew安装(推荐)
bash
# 安装Homebrew(已安装可跳过)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 通过Homebrew安装Ollama
brew install ollama
# 启动
ollama serve &
# 验证安装
ollama --version
方法2:手动安装
-
访问官网下载页面:ollama.com/download/ma...
-
下载
Ollama-darwin.zip
并解压 -
将Ollama拖入
应用程序
文件夹 -
首次运行时在终端授权:
bashxattr -d com.apple.quarantine /Applications/Ollama.app
⊞ Windows用户安装步骤
-
访问官网下载安装包:ollama.com/download
-
双击运行
OllamaSetup.exe
-
按默认设置完成安装(无需手动配置环境变量)
-
验证安装:
powershellollama --version
三、DeepSeek-R1:7B模型部署
1. 一键安装模型
bash
# 终端执行(Mac/Win命令相同)
ollama run deepseek-r1:7b
-
首次运行自动下载(约4.7GB)
-
终端显示下载进度:
markdownpulling manifest ███████████████ 100% pulling 96c415656d37... ███████████████ 4.7GB ... writing manifest success
2. 验证模型安装
bash
ollama list
text
NAME SIZE
deepseek-r1:7b 4.7GB
四、模型测试与交互方式
1. API接口测试
bash
curl http://localhost:11434/api/generate -d '{
"model": "deepseek-r1:7b",
"prompt": "你好,你是谁",
"stream": false
}'
# 接口返回结构
{
"model": "deepseek-r1:7b",
"created_at": "2025-07-10T02:47:51.018357Z",
"response": "<think>\n我是DeepSeek-R1,一个由深度求索公司开发的智能助手,我会尽我所能为您提供帮助。\n</think>\n\n我是DeepSeek-R1,一个由深度求索公司开发的智能助手,我会尽我所能为您提供帮助。",
"done": true,
"done_reason": "stop",
"context": [
151644,
108386,
......
],
"total_duration": 3981124167,
"load_duration": 2727482917,
"prompt_eval_count": 7,
"prompt_eval_duration": 185178833,
"eval_count": 53,
"eval_duration": 1064137792
}
五、spring boot 中使用模型聊天
1、添加流式支持的依赖
目前的 AI 聊天都是逐字显示的,就需要用到这个流式依赖
xml
<!-- WebFlux (SSE流式支持) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
2、核心服务方法
java
package com.spring.ldj.ollama;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.StringUtils;
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.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class OllamaChatService {
private final WebClient webClient = WebClient.builder().build();
private final ObjectMapper objectMapper = new ObjectMapper();
private Map<String, Boolean> isThinking = new HashMap<>();
// 是否回显思考流程
private static final boolean SHOW_THINKING = false;
// 适合单次回答
// private static final String OLLAMA_URL = "http://localhost:11434/api/generate";
// 适合聊天模式,可根据上下文生成内容
private static final String OLLAMA_URL = "http://localhost:11434/api/chat";
public Flux<String> chat(String sessionId, String userInput) {
// 1. 添加上下文
ChatContextManager.addMessage(sessionId, "user", userInput);
List<Map<String, String>> fullContext = ChatContextManager.getContext(sessionId);
for (Map<String, String> context : fullContext) {
System.out.println(JSONUtil.parse(context));
}
// 2. 构造Ollama请求体
Map<String, Object> request = new HashMap<>();
request.put("model", "deepseek-r1:7b");
request.put("messages", fullContext);
request.put("stream", true); // 关闭流式
request.put("options", Map.of(
"temperature", 0.7, // 控制随机性
"num_ctx", 4096 // 上下文窗口大小
));
StringBuilder sb = new StringBuilder(); // 临时存储流式上下文
// 是否显示思考内容
isThinking.put(sessionId,true);
return webClient.post()
.uri(OLLAMA_URL)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(request)
.retrieve()
.bodyToFlux(String.class) // 接收文本流
.map(context -> parseResponseChunk(context,sessionId))
.doOnNext(content -> {
if (StringUtils.isNotBlank(content)){
sb.append(content);
}
if (content.equals("[DONE]")) {
// 结束时保存完整上下文
ChatContextManager.addMessage(sessionId, "assistant", sb.toString());
}
}); // 解析每个数据块
}
private String parseResponseChunk(String json, String sessionId) {
try {
System.out.println("parseResponseChunk: " + json);
JsonNode node = objectMapper.readTree(json);
JsonNode messageNode = node.has("message") ? objectMapper.readTree(node.get("message").toString()) : objectMapper.nullNode();
String content = messageNode.has("content") ? messageNode.get("content").asText() : "";
if (!SHOW_THINKING){
if (content.equals("</think>")){
isThinking.remove(sessionId);
return "";
}
if (isThinking.containsKey(sessionId)){
return "";
}
}
boolean isDone = node.has("done") && node.get("done").asBoolean();
if (isDone) {
content = "[DONE]";
}
return content;
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON parsing error", e);
}
}
}
3、接口
java
@RestController
@RequestMapping("/api/ai")
public class AiController {
@Autowired
private OllamaChatService ollamaChatService;
@GetMapping("/getSessionId")
public CommonResult<String> getSessionId() {
String response = DateUtil.format(new Date(), "yyyyMMddHHmmss");
return CommonResult.ok(response);
}
// SSE流式接口
@GetMapping(value = "/ask")
public SseEmitter ask1(@RequestParam String content, @RequestParam String sessionId) {
SseEmitter emitter = new SseEmitter(30_000L);
ollamaChatService.chat(sessionId,content)
.subscribe(
chunk -> {
try {
emitter.send(SseEmitter.event().data(chunk));
} catch (IOException e) {
throw new RuntimeException(e);
}
}, // 发送数据块
emitter::completeWithError, // 错误处理
emitter::complete // 流结束
);
return emitter;// 超时30秒
}
}
4、前端
vue
<template>
<div class="chat-container">
<div class="chat-history" ref="history">
<!-- 消息容器添加对齐类 -->
<div
v-for="msg in messages"
:key="msg.id"
:class="['message', msg.type]"
>
<div class="message-bubble">
{{ msg.content }}
</div>
</div>
<div v-if="isLoading" class="loading">回答中...</div>
</div>
<div class="input-area">
<textarea
v-model="inputContent"
@keyup.enter="fetchStreamResponse"
placeholder="输入您的问题..."
></textarea>
<button
@click="fetchStreamResponse"
:disabled="isLoading"
>
{{ isLoading ? '发送中...' : '发送' }}
</button>
</div>
</div>
</template>
<script>
export default {
data() {
return {
inputContent: '',
aiResponse: "",
messages: [],
isLoading: false,
chatHistory: [],
sessionId: ''
}
},
methods: {
async fetchStreamResponse() {
// 添加用户消息
this.messages.push({
id: Date.now(),
type: "user",
content: this.inputContent
});
// 初始化AI消息
const aiMsgId = Date.now() + 1;
this.messages.push({
id: aiMsgId,
type: "ai",
content: "" // 初始为空
});
// 创建SSE连接
console.log('创建SSE连接',this.inputContent);
const eventSource = new EventSource(`/ldj001/api/ai/ask1?sessionId=${this.sessionId}&content=${encodeURIComponent(this.inputContent)}`,
{ withCredentials: true } // 处理跨域
);
this.inputContent = '';
this.isLoading = true;
this.aiResponse = ""; // 重置响应内容
eventSource.onmessage = (event) => {
console.log(event);
const chunk = event.data;
// 结束
if (chunk === "[DONE]") {
eventSource.close(); // 主动关闭连接
this.isLoading = false;
return; // 终止后续处理
}
this.aiResponse += chunk;
// 更新AI消息内容
const aiMsgIndex = this.messages.findIndex(msg => msg.id === aiMsgId);
if (aiMsgIndex !== -1) {
this.messages[aiMsgIndex].content = this.aiResponse;
}
this.scrollToBottom(); // 自动滚动到底部
};
eventSource.onerror = (error) => {
console.error("Stream error:", error);
eventSource.close();
};
},
scrollToBottom() {
this.$nextTick(() => { // 确保DOM更新后执行
const container = this.$refs.history;
container.scrollTop = container.scrollHeight;
});
}
},
async created() {
try {
this.isLoading = true
const response = await fetch('/ldj001/api/ai/getSessionId', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`)
}
const rawText = await response.text() // 先获取原始文本
console.log('Raw response:', rawText) // 检查实际内容
const data = rawText ? JSON.parse(rawText) : null
this.sessionId = data.content
} catch (err) {
console.error('获取sessionId失败:', err)
this.error = err.message || '获取sessionId失败'
} finally {
this.isLoading = false
}
}
}
</script>
<style scoped>
/* 基础布局 */
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.chat-history {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
}
/* 消息通用样式 */
.message {
margin-bottom: 15px;
max-width: 80%; /* 控制最大宽度 */
align-self: flex-start; /* 默认左对齐 */
}
.message-bubble {
padding: 12px 16px;
border-radius: 18px;
display: inline-block;
word-wrap: break-word;
white-space: pre-line; /* 保留换行符 */
}
/* 用户消息右对齐 */
.message.user {
align-self: flex-end; /* 右对齐 */
}
.message.user .message-bubble {
background-color: #dcf8c6; /* 用户气泡颜色 */
border-bottom-right-radius: 4px; /* 气泡尖角效果 */
}
/* AI消息左对齐 */
.message.ai .message-bubble {
background-color: #f0f0f0; /* AI气泡颜色 */
border-bottom-left-radius: 4px; /* 气泡尖角效果 */
}
/* 输入区域样式 */
.input-area {
padding: 15px;
border-top: 1px solid #eee;
display: flex;
gap: 10px;
}
textarea {
flex: 1;
padding: 12px;
border: 1px solid #ddd;
border-radius: 20px;
resize: none;
height: 50px;
}
button {
padding: 0 20px;
border: none;
border-radius: 20px;
background: #0084ff;
color: white;
cursor: pointer;
}
</style>