[CELN] 01 本地部署大模型 + 简单 HTML 交互界面
1. 简介活动目标 ¶
本次 Workshop 主要目标是:
- 通过 Ollama 运行
deepseek-r1
1.5B 模型,体验 reasoning 模型的推理过程。 - 使用 HTML 搭建一个简易的交互式界面。
- 在有时间的情况下,尝试实现简单的 记忆功能(将对话存入 JSON)。
2. Ollama 的安装 ¶
Ollama 是一个轻量级的 LLM 运行框架,支持本地模型推理。
📄下载地址:https://ollama.com/download
安装完成后,使用以下命令检查是否安装成功:
ollama --version
如果输出如下版本号,则安装成功:
ollama version is 0.5.10
3. Ollama 下载并运行模型 ¶
使用以下命令下载 deepseek-r1
1.5B 模型(大小约1.1GB
),并启动:
ollama pull deepseek-r1:1.5B # 拉取(下载)模型
ollama run deepseek-r1 # 运行模型(进入交互式命令行)
运行成功后,可在命令行中输入问题,模型将返回答案。
一些常用的快捷键:
- 按下
Ctrl + D
可退出交互式命令行 - 按下
Ctrl + C
可打断当前对话,并重新输入问题 - 按下
Ctrl + L
可清空屏幕(仍然可以通过滑轮查看历史记录)
以下是一个简单的命令行中的演示效果:
其中<think></think>
标签中的内容是模型的推理过程,而在其之后的才是模型真正的回答。
4. HTML 简介 ¶
我们不需要成为专业的前端开发者,只需要了解 HTML 的基本概念,就可以搭建一个简单的交互界面。(毕竟我们能求助AI辅助开发!)
4.1 HTML 的核心:标签 ¶
HTML 是前端开发的基础语言,其主要思路是通过一个个标签对页面进行描述。
示例代码:
<h1>这是一级标题</h1>
<p>这是一个段落</p>
<div>这是一个区块</div>
只要是成对的<xx>
和</xx>
标签,就可以将内容包裹在其中,无论其名字是什么。
只是不同的标签有不同的功能,以及默认样式。
4.2 HTML 的两大帮手:CSS 和 JavaScript ¶
- CSS:用于控制页面的样式,如字体、颜色、布局等。
- JavaScript:用于控制页面的行为,如点击事件、动画效果等。
它们既可以用单独的文件储存,并在 HTML 中引用,也可以直接写在 HTML 文件中。这里我们使用后者。
5. 简单的 HTML 界面实现 ¶
我们创建一个最简单的 HTML 页面,包含:
- 一个 输入框 供用户输入问题。
- 一个 按钮 发送请求。
- 一个 显示框 展示 AI 回复。
我们完全可以求助刚才安装好的 deepseek-r1 模型,让 AI 帮我们写一个简单的 HTML 页面。
我是这么问它的:
请帮我写一个用于制作与本地大模型交互的 HTML 页面。
具体要求如下:
1. 只需要包含一个大标题,一个输入框(用于给用户输入内容)、一个提交按钮(用于提交输入的信息)和一个显示框(用于显示大模型的返回)即可。
2. 不需要有任何实际功能(不需要JavaScript代码),只需要基本的结构即可。
3. 可以添加简单的样式,但是不应过于复杂
请注意,由于大模型输出的是 Markdown 格式,所以代码会包含在 “```html” 和 “```” 之间。
当输出结果不理想时,你完全可以再次询问,也可以去问更强大的AI(毕竟我们使用的仅仅是1.5B的蒸馏模型,相比于原版671B的完整模型还是有很大差距的)。
以下是一个简单的 HTML 页面示例:(由ChatGPT-4o
生成)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>本地大模型交互</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background-color: #f4f4f4;
}
h1 {
margin-bottom: 20px;
}
.container {
width: 50%;
max-width: 600px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
width: 100%;
padding: 10px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
.output {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
min-height: 50px;
background: #fff;
}
</style>
</head>
<body>
<h1>本地大模型交互</h1>
<div class="container">
<input type="text" placeholder="请输入内容...">
<button>提交</button>
<div class="output"></div>
</div>
</body>
</html>
6. 后端交互功能实现 ¶
6.1 Ollama API ¶
在实际应用中,前后端的交互主要是通过 API 实现的。
首先,Ollama 提供了一个 HTTP API,可以通过 POST 请求发送问题,并返回回答。
在确认 Ollama 运行时,我们可以尝试以下一系列 POST 请求:
-
获取所有模型列表:
curl http://localhost:11434/api/tags
-
请求模型信息:
curl http://localhost:11434/api/show -d '{ "model": "deepseek-r1:1.5B" }'
-
生成回答:
curl http://localhost:11434/api/chat -d '{ "model": "deepseek-r1:1.5B", "messages": [ { "role": "user", "content": "why is the sky blue?" } ] }'
注意:由于是Ollama可以通过流式回答,所以其在回答时会出现非常多行的回复,每一行都是完整回答中的一个词。
📄更多用法见官方文档:https://github.com/ollama/ollama/blob/main/docs/api.md
6.2 JavaScript 实现 ¶
⚠️ 注意:由于 CORS 限制,浏览器不允许直接从本地主机(
localhost
)请求数据。
我的解决方案是临时地修改(添加)环境变量。
Windows:
添加环境变量OLLAMA_ORIGINS
,值为*
。
Linux&Mac:
添加环境变量export OLLAMA_ORIGINS=*
。
之后重启Ollama服务即可应用(如果不行,可以尝试重启电脑)。请注意,这样做会使得所有的网站都可以访问你的Ollama服务,所以请在使用完毕后及时删除这个环境变量。
以下 JavaScript 可以为 HTML 提供与 Ollama API 的交互功能。
你需要:
- 将其放入
<body>
标签中。 - 在 HTML 中添加一个
id
为model-input
的<input>
元素。 - 在 HTML 中添加一个
id
为model-submit
的<button>
元素。 - 在 HTML 中添加一个
id
为model-output
的<div>
元素。 - (可选)在 HTML 中添加一个
id
为save-chat
的<button>
元素,用于保存对话为 JSON 文件。
<script>
document.addEventListener("DOMContentLoaded", function () {
const input = document.getElementById("model-input");
const button = document.getElementById("model-submit");
const output = document.getElementById("model-output");
const saveButton = document.getElementById("save-chat"); // 保存对话按钮
let messages = []; // 存储所有对话
button.addEventListener("click", async function () {
const userMessage = input.value.trim();
if (!userMessage) return;
// 记录用户输入并添加到对话框
messages.push({ role: "user", content: userMessage });
output.innerHTML += `<div class="user-msg"><b>你:</b> ${userMessage}</div>`;
// 清空输入框
input.value = "";
// 在对话框中添加占位符,模型回复将在此处更新
const messageContainer = document.createElement("div");
messageContainer.className = "model-msg";
messageContainer.innerHTML = `<b>模型:</b> <span class="model-response"></span><br><br>`;
output.appendChild(messageContainer);
const responseSpan = messageContainer.querySelector(".model-response");
try {
const response = await fetch("http://localhost:11434/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "deepseek-r1:1.5B",
messages: [{ role: "user", content: userMessage }]
})
});
if (!response.body) {
throw new Error("No response body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let fullResponse = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split("\n");
buffer = lines.pop(); // 处理未完成的 JSON 片段
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line.trim());
if (parsed.message && parsed.message.content) {
fullResponse += parsed.message.content;
responseSpan.innerHTML = fullResponse;
}
} catch (err) {
console.error("解析 JSON 失败:", line.trim());
}
}
}
}
// 处理剩余的 JSON 片段
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer.trim());
if (parsed.message && parsed.message.content) {
fullResponse += parsed.message.content;
responseSpan.innerHTML = fullResponse;
}
} catch (err) {
console.error("解析 JSON 失败:", buffer.trim());
}
}
// 保存模型的完整回复
messages.push({ role: "assistant", content: fullResponse });
} catch (error) {
console.error("请求失败:", error);
responseSpan.innerHTML = "<b>请求失败:</b> 请检查 Ollama 是否运行。";
}
});
// 保存对话到 JSON
saveButton.addEventListener("click", function () {
const jsonBlob = new Blob([JSON.stringify(messages, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(jsonBlob);
a.download = "chat_history.json"; // 让用户下载 JSON 文件
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
});
</script>
😉 如果你成功地运行了这段代码,不妨拍张照片或者录制一个视频,分享给别人看看吧!
6.3 成果展示 ¶
7. 记忆功能实现 ¶
目前的代码已经实现了简单的记忆功能,但是其实现方式并不能让AI拥有真正的记忆。我们的解决方案是简单粗暴地在提问的时将目前的所有对话都传给AI。
遗憾的是,放眼现有的AI技术,虽然有如Letta一样的项目致力于实现AI的记忆功能,但是目前还没有一个能够真正实现完美记忆的AI。
8. Open WebUI: 更好的交互体验 ¶
如果没搞懂以上的响应式网页代码,你也可以尝试使用 Open WebUI。推荐的原因是它的安装可以通过我们熟悉的pip
来完成,并且其有一个非常友好的界面。
- Open WebUI 项目地址:Open WebUI is an extensible, feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.
8.1 简略的安装步骤 ¶
-
创建并进入用于存放 Open WebUI 的虚拟环境:
你完全可以将其安装在已有的虚拟环境中,不必单独为其创建一个虚拟环境。
conda create -n local-llm python # local-llm 是环境名,可以自定义 conda activate local-llm
-
安装 Open WebUI:
pip install open-webui
-
运行 Open WebUI:
open-webui serve
默认情况下,Open WebUI 会在
http://localhost:8080
上运行。 -
打开浏览器,访问
http://localhost:8080
,即可看到 Open WebUI 的界面。⚠️注意:Open WebUI 会在第一次使用时要求创建用户,而这个用户信息是完全本地保存的,可以随意设置,但请务必记住(建议使用浏览器的密码管理器)。
9. 总结 ¶
通过本次 Workshop,我们实现了:
✅ 使用 Ollama 本地运行 DeepSeek 模型。
✅ 通过 HTML + JavaScript 构建交互界面。
✅ 在有时间的情况下,扩展支持简单的对话记忆。
希望本次 Workshop 能帮助大家理解 本地大模型推理 和 前端交互 的基础概念,期待在下次 CELN Workshop 见到大家!🚀
😉 希望大家能分享活动的照片或视频!无论是成功的代码运行截图,还是大家一起学习的场景,都可以分享给大家看看!
10. 附录:完整示例代码 ¶
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>本地大模型交互</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
background-color: #f4f4f4;
}
h1 {
margin-bottom: 20px;
}
.container {
width: 50%;
max-width: 600px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
input {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
width: 100%;
padding: 10px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
.output {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
min-height: 50px;
background: #fff;
}
</style>
</head>
<body>
<h1>本地大模型交互</h1>
<div class="container">
<input id="model-input" type="text" placeholder="请输入内容...">
<button id="model-submit">提交</button>
<div id="model-output" class="output"></div>
<button id="save-chat">保存对话</button>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const input = document.getElementById("model-input");
const button = document.getElementById("model-submit");
const output = document.getElementById("model-output");
const saveButton = document.getElementById("save-chat"); // 保存对话按钮
let messages = []; // 存储所有对话记录
// 监听 Enter 键提交
input.addEventListener("keydown", function (event) {
if (event.key === "Enter") {
event.preventDefault(); // 阻止默认换行行为
button.click(); // 触发点击事件
}
});
button.addEventListener("click", async function () {
const userMessage = input.value.trim();
if (!userMessage) return;
// 记录用户输入并添加到对话框
messages.push({ role: "user", content: userMessage });
output.innerHTML += `<div class="user-msg"><b>你:</b> ${userMessage}</div>`;
// 清空输入框
input.value = "";
// 在对话框中添加占位符,模型回复将在此处更新
const messageContainer = document.createElement("div");
messageContainer.className = "model-msg";
messageContainer.innerHTML = `<b>模型:</b> <span class="model-response"></span><br><br>`;
output.appendChild(messageContainer);
const responseSpan = messageContainer.querySelector(".model-response");
try {
const response = await fetch("http://localhost:11434/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "deepseek-r1:1.5B",
messages: messages // 传递完整对话历史
})
});
if (!response.body) {
throw new Error("No response body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let fullResponse = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split("\n");
buffer = lines.pop(); // 处理未完成的 JSON 片段
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line.trim());
if (parsed.message && parsed.message.content) {
fullResponse += parsed.message.content;
responseSpan.innerHTML = fullResponse;
}
} catch (err) {
console.error("解析 JSON 失败:", line.trim());
}
}
}
}
// 处理剩余的 JSON 片段
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer.trim());
if (parsed.message && parsed.message.content) {
fullResponse += parsed.message.content;
responseSpan.innerHTML = fullResponse;
}
} catch (err) {
console.error("解析 JSON 失败:", buffer.trim());
}
}
// 保存模型的完整回复
messages.push({ role: "assistant", content: fullResponse });
} catch (error) {
console.error("请求失败:", error);
responseSpan.innerHTML = "<b>请求失败:</b> 请检查 Ollama 是否运行。";
}
});
// 保存对话到 JSON
saveButton.addEventListener("click", function () {
const jsonBlob = new Blob([JSON.stringify(messages, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(jsonBlob);
a.download = "chat_history.json"; // 让用户下载 JSON 文件
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
});
</script>
</body>
</html>