使用Ollama本地模型可以保证数据的安全性,同时能充分利用本地的算力资源~~(省点调用API的零花钱)~~。通过Ollama的API可以提供接口,为编写的程序提供本地AI模型支持。
注意事项
- 本文已假设读者能够自己解决Ollama和模型的下载,以及云服务器购买的问题。如果不会。。。网上有非常多的教程,一看就会。
- 本文提及到的工具为:宝塔面板,frp,WindSurf(AI代码编写工具,用于编写前端UI。国产平替有阿里的通义灵码(TONGYI Lingma))
- 本文使用的方法是通过frp进行内网穿透~~(所以这其实是一篇内网穿透教程)~~提供接口。如果你的本地服务器能够部署大模型并且有公网IP,可以考虑别的方法。
- 使用的frp版本是2024年底的新版本,配置文件格式为.toml,若使用旧版本配置文件格式为.ini,格式略微不同,请注意修改
- 个人电脑使用的操作系统为Windows 11
内网穿透
首先下载frp,云服务器上下载服务端(frps),你的本地服务器/个人电脑上使用客户端(frpc)(当然一般情况下个人电脑上下载的是frp整合包,没有关系)。 这里使用宝塔面板,方便操作
在宝塔面板上下载frp
下载完成后修改配置文件
frps(服务端)
我们会用到bindPort和auth.token
宝塔面板下载的frpc配置文件默认是没有webServer.addr的,需要额外添加,若考虑安全性请设置为本地电脑的IP
frpc(客户端)
serverAddr是你的云服务器IP,localPort是Ollama的运行端口,不用修改。remotePort根据个人需求修改,我懒得选,就跟本地一样。
完成配置后在云服务器安全组和宝塔面板安全组中(两个都要放行)相应端口(Ollama配置,也就是remotePort,frp,也就是serverPort)。
我们使用内网穿透的方法,需要注意跨域问题,在Ollama的配置项中需要进行修改。在系统高级设置中修改相关的用户变量 ——— OLLAMA_HOST和OLLAMA_ORIGINS
前者是Ollama监听的访问IP,默认情况只会监听本地,由云服务器发送的请求不会被处理,需要修改为0.0.0.0(即任意IP)保证外部访问能够被接收
后者用于处理跨域问题,修改成 * 保证外部能够访问Ollama的API
做好上述工作后在Windows PowerShell(不是命令提示符)里面打开本地服务器的frpc(client客户端) 输入以下命令
./frpc -c ./frpc.toml
出现了下面的提示就表明成功连接到云服务器这个“路由器”上了
现在在命令提示符中输入ollama serve
这样可以看到实时的运行日志,非常的方便。(这不是必做项,只是为了方便看到运行日志) 现在在浏览器地址输入
你的云服务器IP:11434
出现下图即可
写一个前端UI进行测试
首先看一下Ollama的基本API
分别是模型名称,用户输入内容,是否进行流式输出
这里使用WindSurf进行相关代码的编写,十分甚至九分滴好用
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chat</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/marked/marked.min.css">
</head>
<body>
<div class="container">
<div class="chat-container">
<div class="chat-messages" id="chatMessages"></div>
<div class="input-container">
<textarea id="userInput" placeholder="请输入您的问题..." rows="3"></textarea>
<button id="sendButton">发送</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="script.js"></script>
</body>
</html>
script.js
document.addEventListener('DOMContentLoaded', () => {
const chatMessages = document.getElementById('chatMessages');
const userInput = document.getElementById('userInput');
const sendButton = document.getElementById('sendButton');
// 配置marked选项
marked.setOptions({
breaks: true, // 支持GitHub风格的换行
gfm: true, // 启用GitHub风格的Markdown
});
// 处理用户输入
async function handleUserInput() {
const message = userInput.value.trim();
if (!message) return;
// 添加用户消息
appendMessage('user', message);
userInput.value = '';
sendButton.disabled = true;
// 调用Ollama API
await streamAIResponse(message);
sendButton.disabled = false;
}
// 添加消息到聊天界面
function appendMessage(sender, content) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${sender}-message`;
if (sender === 'user') {
messageDiv.innerHTML = marked.parse(content);
} else {
messageDiv.classList.add('typing');
}
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
return messageDiv;
}
// 调用Ollama API并处理流式响应
async function streamAIResponse(prompt) {
const messageDiv = appendMessage('ai', '');
let displayedContent = '';
try {
const response = await fetch('http://你的云服务器IP:11434/api/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: '你的本地模型名称',
prompt: prompt,
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解析流式响应
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.trim() === '') continue;
try {
const data = JSON.parse(line);
if (data.response) {
displayedContent += data.response;
messageDiv.innerHTML = marked.parse(displayedContent);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
} catch (e) {
console.error('Error parsing JSON:', e);
}
}
}
} catch (error) {
console.error('Error:', error);
messageDiv.innerHTML = marked.parse('抱歉,发生了错误,请稍后重试。');
}
messageDiv.classList.remove('typing');
}
// 事件监听器
sendButton.addEventListener('click', handleUserInput);
userInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleUserInput();
}
});
// 自动聚焦输入框
userInput.focus();
});
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f5f7fa;
color: #2c3e50;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.chat-container {
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
height: 80vh;
}
.chat-messages {
flex-grow: 1;
overflow-y: auto;
padding: 2rem;
}
.message {
max-width: 80%;
margin: 1rem auto;
padding: 1rem;
border-radius: 8px;
word-wrap: break-word;
}
.user-message {
background-color: #e3f2fd;
align-self: flex-end;
}
.ai-message {
background-color: #f5f5f5;
align-self: flex-start;
}
.input-container {
border-top: 1px solid #eee;
padding: 1rem;
display: flex;
gap: 1rem;
background-color: #ffffff;
}
textarea {
flex-grow: 1;
padding: 0.8rem;
border: 1px solid #ddd;
border-radius: 8px;
resize: none;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
textarea:focus {
border-color: #4a90e2;
}
button {
padding: 0.8rem 1.5rem;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
button:hover {
background-color: #357abd;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* Markdown 样式优化 */
.message p {
margin-bottom: 0.5rem;
}
.message code {
background-color: #f8f9fa;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: monospace;
}
.message pre {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 0.5rem 0;
}
.message blockquote {
border-left: 4px solid #ddd;
margin: 0.5rem 0;
padding-left: 1rem;
color: #666;
}
/* 打字机效果的光标 */
.typing::after {
content: '▋';
animation: blink 1s step-start infinite;
margin-left: 2px;
}
@keyframes blink {
50% { opacity: 0; }
}
测试