项目 02 · 调用开源 LLM(Ollama + Qwen)
在你的网页里,连接本地 Ollama(Qwen 或 Llama),实现一个「聊天+检索」的 AI 助手。零 API 费用,完全本地。
技术栈
- Ollama + Qwen 2.5:7b(本地大模型)
- Vite + TypeScript
- 浏览器 Fetch API + Server-Sent Events(流式处理)
- CORS 代理
怎么算"成"?
用户可以在网页里和一个本地 AI 对话。消息流式显示,响应速度在 1–3 秒(取决于电脑性能)。网络断开时,界面优雅降级。
步骤 1 · 装 Ollama 和模型
ollama pull qwen2.5:7b ollama serve # 后台保持运行
步骤 2 · 初始化 Vite 项目
npm create vite@latest my-ai-chat -- --template vanilla-ts cd my-ai-chat npm install
步骤 3 · 配置 CORS
Ollama 默认不允许浏览器跨域请求。解决办法:
OLLAMA_ORIGINS=* ollama serve
第 1 块 · 环境变量启动 Ollama
这是最简单的方法:直接告诉 Ollama「允许来自任何域名的请求」。OLLAMA_ORIGINS=* 就是这个权限。
运行这条命令,Ollama 服务会在 http://localhost:11434 启动,并接受浏览器的跨域请求。
👉 试改:把 * 改成具体域名,比如 OLLAMA_ORIGINS=http://localhost:5173,只允许你的 Vite 开发服务器请求。这样更安全(生产级做法)。
export default {
server: {
proxy: {
'/api/generate': {
target: 'http://localhost:11434',
changeOrigin: true,
rewrite: (path) => path.replace('/api/generate', '/api/generate')
}
}
}
}第 2 块 · Vite 反向代理方案
这是另一种方法:用 Vite 本身作为「中间人」。浏览器请求你的 Vite 服务器(http://localhost:5173),Vite 在后台转发给 Ollama(http://localhost:11434)。
这样浏览器看不到跨域请求,因为所有请求来自同一个源(Vite)。
💡 关键差别:第 1 种方法改 Ollama 本身的 CORS 策略;第 2 种方法用 proxy 隐藏跨域问题。在生产环境,通常两者都用上。
👉 试改:把路径从 /api/generate 改成 /api/chat,如果你用的是不同的 Ollama endpoint。
📋 看 / 复制完整配置
# 方法 1:启动 Ollama 时指定 CORS
OLLAMA_ORIGINS=* ollama serve
# 或方法 2:在 vite.config.ts 中添加代理
export default {
server: {
proxy: {
'/api/generate': {
target: 'http://localhost:11434',
changeOrigin: true,
rewrite: (path) => path.replace('/api/generate', '/api/generate')
}
}
}
}
步骤 4 · 实现聊天 UI + 调用 LLM
// src/main.ts
const input = document.querySelector('input');
const output = document.querySelector('div.messages');第 1 块 · 获取 DOM 元素
这是老套路:用 querySelector 找 HTML 里的两个关键元素 —— 输入框和显示结果的容器。
const 表示这两个变量不会再改,一直指向这两个 DOM 节点。
👉 试改:如果你的 HTML 里输入框 id 叫 chatInput 而不是全局 input,改成 document.querySelector('#chatInput')。
input.addEventListener('keypress', async (e) => {
if (e.key !== 'Enter') return;
const message = input.value;
input.value = '';
output.textContent += `你:${message}
`;第 2 块 · 监听用户输入
addEventListener('keypress', ...) = "当用户在输入框里按键时,执行这个函数"。
if (e.key !== 'Enter') return; = "如果不是回车键,什么也不做"。这样用户按其他键不会触发。
然后拿用户的消息,立刻清空输入框(让用户能继续打下一条),再把「你:消息」显示到聊天窗口。
💡 UX 小细节:必须先清空输入框,再发请求。这样用户立刻看到自己的输入被记录了,不会重复打字。
👉 试改:加一行 input.disabled = true; 在清空之前,禁用输入框,等 AI 回答完再启用。防止用户在等待时重复提交。
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
body: JSON.stringify({
model: 'qwen2.5:7b',
prompt: message,
stream: true
})
});第 3 块 · 发 HTTP 请求给 Ollama
fetch(...) = "给 Ollama 服务(本地 11434 端口)发一个 HTTP 请求"。
method: 'POST' = "我要发数据,不是取数据"。
body: JSON.stringify({...}) = "把 JavaScript 对象转成 JSON 字符串,作为请求的内容"。里面包括:
model: 'qwen2.5:7b'= 用哪个本地模型。prompt: message= 用户的消息。stream: true= 「流式」返回 —— AI 不等全部生成完,一边想一边返回给我。
💡 流式 vs 非流式:stream: true 让用户看到「AI 在打字」的效果,体验更自然。如果 false,要等 30 秒才看到全部答案。
👉 试改:把 'qwen2.5:7b' 改成你本地下载的其他模型,比如 'llama3.2'。
const reader = response.body.getReader();
const decoder = new TextDecoder();
output.textContent += '助手:';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
const json = JSON.parse(line);
output.textContent += json.response;
}
}第 4 块 · 流式处理 AI 的回答
这是「流式处理」的核心:
response.body.getReader() = "拿到响应流的「阅读器」" —— 可以一块一块读数据,不用等全部下载。
const decoder = new TextDecoder() = "造一个工具,把字节流转成文本"。因为网络上传的是二进制字节,我们需要转成看得懂的字符。
然后进入 while (true) 循环:
const { done, value } = await reader.read()= "读一块数据。done说这块后面有没有了,value是这块的内容"。if (done) break;= "读完了就跳出循环"。decoder.decode(value)= "这块字节转成文本"。text.split('\n')= "按换行分割" —— Ollama 每行一个 JSON 对象。JSON.parse(line)+json.response= "解析每行 JSON,取出response字段(AI 生成的文字)"。output.textContent += ...= "把 AI 生成的字一个一个蹦到屏幕上"。
💡 这就是「流式聊天」的秘密:不是等 AI 想好了再显示,而是一边想一边显示。所以看起来很快。
});
第 5 块 · 关闭 event listener
关掉前面 addEventListener 的括号。
📋 看 / 复制完整代码
// src/main.ts
const input = document.querySelector('input');
const output = document.querySelector('div.messages');
input.addEventListener('keypress', async (e) => {
if (e.key !== 'Enter') return;
const message = input.value;
input.value = '';
output.textContent += `你:${message}
`;
const response = await fetch('http://localhost:11434/api/generate', {
method: 'POST',
body: JSON.stringify({
model: 'qwen2.5:7b',
prompt: message,
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
output.textContent += '助手:';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
const json = JSON.parse(line);
output.textContent += json.response;
}
}
});
步骤 5 · 测试和优化
在浏览器里输入几个问题。观察响应速度。如果太慢,考虑用更小的模型(如 qwen2.5:3b)。