Ollama本地模型公网接口调用

221 阅读5分钟

使用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 image.png 下载完成后修改配置文件

frps(服务端) image.png

我们会用到bindPort和auth.token

宝塔面板下载的frpc配置文件默认是没有webServer.addr的,需要额外添加,若考虑安全性请设置为本地电脑的IP

frpc(客户端)

image.png

serverAddr是你的云服务器IP,localPort是Ollama的运行端口,不用修改。remotePort根据个人需求修改,我懒得选,就跟本地一样。

完成配置后在云服务器安全组和宝塔面板安全组中(两个都要放行)相应端口(Ollama配置,也就是remotePort,frp,也就是serverPort)。

我们使用内网穿透的方法,需要注意跨域问题,在Ollama的配置项中需要进行修改。在系统高级设置中修改相关的用户变量 ——— OLLAMA_HOST和OLLAMA_ORIGINS

image.png

前者是Ollama监听的访问IP,默认情况只会监听本地,由云服务器发送的请求不会被处理,需要修改为0.0.0.0(即任意IP)保证外部访问能够被接收

后者用于处理跨域问题,修改成 * 保证外部能够访问Ollama的API

做好上述工作后在Windows PowerShell(不是命令提示符)里面打开本地服务器的frpc(client客户端) 输入以下命令

./frpc -c ./frpc.toml

出现了下面的提示就表明成功连接到云服务器这个“路由器”上了

image.png

现在在命令提示符中输入ollama serve

image.png

这样可以看到实时的运行日志,非常的方便。(这不是必做项,只是为了方便看到运行日志) 现在在浏览器地址输入

你的云服务器IP:11434

出现下图即可

image.png

写一个前端UI进行测试

首先看一下Ollama的基本API

image.png

分别是模型名称,用户输入内容,是否进行流式输出

这里使用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; }
}

测试

image.png

image.png