你正在 进阶版 · ⚡ 代码俱乐部 · ← 回到学院 · 进阶版主页 · 总入口

← 十二个项目

项目 02 · 调用开源 LLM(Ollama + Qwen)

在你的网页里,连接本地 Ollama(Qwen 或 Llama),实现一个「聊天+检索」的 AI 助手。零 API 费用,完全本地。

技术栈

怎么算"成"?

用户可以在网页里和一个本地 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)。

← 十二个项目 下一个 →

工程测验 为什么在浏览器端调用本地 LLM 比直接用 API 更复杂?
你想在网页里使用一个开源 LLM(Qwen、Llama)而不是国产模型。下面哪个说法最准确?
解释:关键区别是架构:API(如 DeepSeek)是 SaaS —— 直接请求远程;本地 LLM 需要「中间层」来管理 GPU/CPU 资源。Ollama 就是这个中间层。
分步引导 在浏览器里调用本地 Ollama LLM 的 5 步
  1. 装 Ollama(ollama.ai)和一个模型(ollama pull qwen2.5:7b)。验证 `curl http://localhost:11434/api/generate -d '{"model":"qwen2.5:7b","prompt":"hello"}'` 能返回结果。
    看参考

    例:如果返回 `{"response":"Hello..."}`,说明 Ollama 正在运行。记住端口 11434。

  2. 在 Vite 项目里装 `axios` 或用原生 `fetch`。写一个函数 `callLocalLLM(prompt)` 去请求 `http://localhost:11434/api/generate`。
    看参考

    例:POST 请求,body 是 `{model: 'qwen2.5:7b', prompt: 'your prompt here'}`。

  3. 处理 CORS 错误。本地开发时浏览器会拦截跨域请求。解决办法:(1)用 Vite proxy;(2)或在 Ollama 启动时开启 CORS(`OLLAMA_ORIGINS=*`)。
    看参考

    例:`vite.config.js` 里配置 `proxy: { '/api/generate': { target: 'http://localhost:11434', ...' }`。

  4. 实现「流式响应」。Ollama 的 API 支持 Server-Sent Events(SSE)。一行一行接收回复,实时显示给用户。
    看参考

    例:用 `fetch` + `ReadableStream` + `TextDecoder` 来处理流。

  5. 测试离线场景。模拟网络断开,看 UI 如何优雅降级(缓存历史对话、离线提示等)。
    看参考

    例:按 F12 → Network → 设置 offline,然后刷新页面。

动手 写一个 Ollama API 的 JavaScript 客户端
任务:实现一个 `OllamaClient` 类,方法包括:(1) `generate(prompt)` 返回完整回复;(2) `generateStream(prompt, onChunk)` 流式返回;(3) 错误处理和超时。
参考实现

工程级参考答案(带完整注释):

// 生产级 Ollama 客户端实现
interface OllamaConfig {
  baseUrl: string;
  model: string;
  timeout: number;
  retries: number;
}

class OllamaClient {
  private config: OllamaConfig;
  
  constructor(config: Partial = {}) {
    this.config = {
      baseUrl: 'http://localhost:11434',
      model: 'qwen2.5:7b',
      timeout: 30000,
      retries: 2,
      ...config,
    };
  }
  
  private async request(endpoint: string, body: any, options = {}) {
    const url = `${this.config.baseUrl}${endpoint}`;
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
    
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
        signal: controller.signal,
        ...options,
      });
      
      if (!response.ok) {
        throw new Error(`Ollama error ${response.status}: ${response.statusText}`);
      }
      
      return response;
    } finally {
      clearTimeout(timeoutId);
    }
  }
  
  async generate(prompt: string): Promise {
    const response = await this.request('/api/generate', {
      model: this.config.model,
      prompt,
      stream: false,
    });
    
    const json = await response.json();
    return json.response;
  }
  
  async generateStream(prompt: string, onChunk: (text: string) => void): Promise {
    const response = await this.request('/api/generate', {
      model: this.config.model,
      prompt,
      stream: true,
    });
    
    const reader = response.body!.getReader();
    const decoder = new TextDecoder();
    
    try {
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        
        const text = decoder.decode(value, { stream: true });
        for (const line of text.split('\n').filter(Boolean)) {
          const json = JSON.parse(line);
          onChunk(json.response);
        }
      }
    } finally {
      reader.releaseLock();
    }
  }
}
动手 为你的 Ollama 项目写一个「离线时的降级策略」
任务:你的网页使用本地 Ollama。但用户如果没装 Ollama、或者网络断了,应该怎么办?写一个 prompt 来设计这个情况的 AI 回复策略。

在下面框里写你自己的 prompt(可以用中文):

→ 打开通义千问粘贴试 已复制 ✓
看参考 prompt

参考 prompt(这是一个模板,你可以改细节):

你是一个领域专家。请基于以下规则回答问题:

1. 只基于你的专业知识和常见做法回答,不编造。
2. 如果问题超出你的领域,明确说「这不在我的专业范围内」。
3. 给出的建议应该包括「为什么」和「什么时候不应该这样做」。
4. 对于有争议的做法,列出不同观点。

现在,开始回答用户的问题。