徐旺的个人博客

一个 9.8 年开发经验即将被AI取代的前端牛马,热爱开源,喜欢分享

从零搭建一个 AI 客服助手:Ollama 本地模型 + Node.js 极简 RAG

文章
纯前端 + Node.js 后端实现一个带知识库的 AI 客服。支持本地模型(Ollama)和云端 API(DeepSeek)两种模式,代码约 200 行

基于 ai-chat-demo 项目,详解如何用纯前端 + Node.js 后端实现一个带知识库的 AI 客服。支持本地模型(Ollama)和云端 API(DeepSeek)两种模式,代码约 200 行。


效果预览

用户:退货政策是怎样的?
AI  :自签收之日起 7 天内,商品完好可无理由退货,运费由买家承担。

用户:那我买完可以退吗?
AI  :可以的。自签收之日起 7 天内商品完好即可申请无理由退货。
       退货时需要联系客服申请,运费由您承担哦。

目录


技术方案概览

┌─────────────────────────────────────────────────────────┐
│                        浏览器                           │
│  ┌──────────────────────────────────────────────────┐   │
│  │  index.html                                      │   │
│  │  · 聊天界面(原生 HTML/CSS/JS,零依赖)            │   │
│  │  · SSE 流式读取(EventSource / fetch + ReadableStream)│   │
│  │  · 多轮对话历史维护(前端内存)                    │   │
│  └──────────────────────┬───────────────────────────┘   │
│                          │  POST /api/chat              │
│                          │  { message, history[] }      │
└──────────────────────────┼──────────────────────────────┘
                           │
                    ┌──────▼──────┐
                    │  Node.js     │
                    │  Express     │
                    │  server.js   │
                    └──────┬──────┘
                           │
         ┌─────────────────┼─────────────────┐
         │                                  │
  ┌──────▼──────┐                  ┌────────▼────────┐
  │   Ollama    │                  │  DeepSeek API   │
  │  本地模型    │                  │   云端模型       │
  │  (qwen2.5)  │                  │  (deepseek-chat)│
  └─────────────┘                  └─────────────────┘

关键技术点

| 技术 | 用途 | 亮点 | |------|------|------| | SSE (Server-Sent Events) | 后端 → 前端实时推送 token | 打字机效果,用户体验好 | | 流式响应 | 模型边生成边返回 | 首 token 延迟低 | | 极简 RAG | 知识库全部读入内存,每次全量注入 prompt | 知识少时比向量检索更准更快 | | 多轮对话记忆 | 前端维护历史消息,每次请求带上 | 支持追问、上下文理解 | | OpenAI 兼容接口 | 同一套代码兼容 Ollama / DeepSeek / 任何兼容服务 | 切换模型零代码改动 |


项目结构

ai-chat-demo/
├── server.js           # 后端(Ollama 本地模型版)
├── server.cloud.js     # 后端(DeepSeek 云端 API 版)
├── public/
│   └── index.html     # 前端页面(纯原生 HTML + CSS + JS)
├── knowledge.txt       # 知识库文件(一行一条)
├── docs/
│   └── blog-ai-chat-demo.md  # 本文档
└── package.json

前端实现:纯原生 HTML/JS,零依赖

完整前端代码

public/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 客服助手 Demo</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: #f5f6f8;
      height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    .chat {
      width: 100%;
      max-width: 640px;
      height: 90vh;
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }
    .header {
      padding: 16px 20px;
      background: #4f46e5;
      color: #fff;
      font-weight: 600;
    }
    .messages {
      flex: 1;
      overflow-y: auto;
      padding: 20px;
      display: flex;
      flex-direction: column;
      gap: 12px;
    }
    .msg {
      max-width: 80%;
      padding: 10px 14px;
      border-radius: 12px;
      line-height: 1.5;
      white-space: pre-wrap;
      word-break: break-word;
    }
    .msg.user {
      align-self: flex-end;
      background: #4f46e5;
      color: #fff;
    }
    .msg.ai {
      align-self: flex-start;
      background: #f0f0f3;
      color: #222;
    }
    .input-bar {
      display: flex;
      gap: 8px;
      padding: 16px;
      border-top: 1px solid #eee;
    }
    .input-bar input {
      flex: 1;
      padding: 10px 14px;
      border: 1px solid #ddd;
      border-radius: 8px;
      font-size: 14px;
      outline: none;
    }
    .input-bar input:focus { border-color: #4f46e5; }
    .input-bar button {
      padding: 10px 20px;
      background: #4f46e5;
      color: #fff;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-size: 14px;
    }
    .input-bar button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }
  </style>
</head>
<body>
  <div class="chat">
    <div class="header">示例科技 · AI 客服助手</div>
    <div class="messages" id="messages">
      <div class="msg ai">
        你好!我是示例科技的客服助手,可以问我退货政策、会员等级、配送范围等问题~
      </div>
    </div>
    <div class="input-bar">
      <input id="input" placeholder="输入你的问题…" autocomplete="off" />
      <button id="send">发送</button>
    </div>
  </div>

  <script>
    const messagesEl = document.getElementById('messages');
    const inputEl = document.getElementById('input');
    const sendBtn = document.getElementById('send');

    // 多轮对话记忆:前端维护历史,每次请求带上
    const history = [];

    /** 往聊天区域追加一条消息 */
    function addMessage(role, text) {
      const div = document.createElement('div');
      div.className = 'msg ' + role;
      div.textContent = text;
      messagesEl.appendChild(div);
      messagesEl.scrollTop = messagesEl.scrollHeight;
      return div;
    }

    /** 发送消息 */
    async function send() {
      const message = inputEl.value.trim();
      if (!message) return;

      // 禁用输入,避免重复提交
      inputEl.value = '';
      inputEl.disabled = true;
      sendBtn.disabled = true;

      addMessage('user', message);
      const aiEl = addMessage('ai', '');
      let answer = '';

      try {
        const res = await fetch('/api/chat', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          // 每次把对话历史带给后端
          body: JSON.stringify({ message, history }),
        });

        if (!res.ok) {
          const err = await res.json().catch(() => ({}));
          aiEl.textContent = err.error || '请求失败';
          return;
        }

        // SSE 流式读取
        const reader = res.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          // 累积接收到的字节,逐步解析
          buffer += decoder.decode(value, { stream: true });
          const lines = buffer.split('\n');
          // 最后一行可能不完整,先保留
          buffer = lines.pop();

          for (const line of lines) {
            if (!line.startsWith('data: ')) continue;
            const data = line.slice(6);

            if (data === '[DONE]') {
              // 一轮结束,存入历史
              history.push({ role: 'user', content: message });
              history.push({ role: 'assistant', content: answer });
              return;
            }

            try {
              const { token } = JSON.parse(data);
              answer += token;
              aiEl.textContent += token;
              messagesEl.scrollTop = messagesEl.scrollHeight;
            } catch { /* 忽略解析错误 */ }
          }
        }
      } catch (e) {
        aiEl.textContent = '网络错误:' + e.message;
      } finally {
        inputEl.disabled = false;
        sendBtn.disabled = false;
        inputEl.focus();
      }
    }

    sendBtn.addEventListener('click', send);
    inputEl.addEventListener('keydown', e => {
      if (e.key === 'Enter') send();
    });
  </script>
</body>
</html>

前端核心逻辑解析

1. 多轮对话记忆

const history = []; // 前端内存维护对话历史

// 每次发送时,把历史带上
body: JSON.stringify({ message, history }),

// 一轮结束后,追加到历史
history.push({ role: 'user', content: message });
history.push({ role: 'assistant', content: answer });

为什么不存后端? 因为这是一个最小 demo,后端无状态,刷新页面重置记忆。如需持久化,可以:

  • 存 localStorage
  • 存数据库并按用户 ID 关联

2. SSE 流式读取

后端返回的 SSE 格式:

data: {"token":"你"}
data: {"token":"好"}
data: {"token":"!"}
data: [DONE]

┌─────────────────────────────────────────┐
│  buffer 接收原始字节,逐步解析 newline    │
│  最后一行不完整 → 保留到下次循环继续     │
└─────────────────────────────────────────┘

核心代码:

let buffer = '';
while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split('\n');
  buffer = lines.pop(); // 不完整行保留

  for (const line of lines) {
    if (line.startsWith('data: ')) {
      const data = line.slice(6);
      if (data === '[DONE]') { /* 结束 */ }
      else {
        const { token } = JSON.parse(data);
        aiEl.textContent += token; // 打字机效果
      }
    }
  }
}

后端实现:Express + SSE 流式响应

server.js(Ollama 本地版)和 server.cloud.js(DeepSeek 云端版)结构完全一致,区别仅在模型配置。

核心代码结构

import express from 'express';
import OpenAI from 'openai';
import fs from 'fs';
import path from 'path';

// ── 1. 模型客户端配置 ──────────────────────────────────
const BASE_URL = process.env.BASE_URL || 'http://localhost:11434/v1';
const API_KEY = process.env.API_KEY || 'ollama';
const CHAT_MODEL = process.env.CHAT_MODEL || 'qwen2.5:7b';
const MAX_HISTORY = Number(process.env.MAX_HISTORY) || 10;

const client = new OpenAI({ baseURL: BASE_URL, apiKey: API_KEY });

// ── 2. 极简 RAG:启动时加载知识库 ──────────────────────
let knowledgeText = '';

function loadKnowledge() {
  const file = path.join(__dirname, 'knowledge.txt');
  const lines = fs
    .readFileSync(file, 'utf-8')
    .split('\n')
    .map(l => l.trim())
    .filter(l => l && !l.startsWith('#')); // 跳过空行和注释
  knowledgeText = lines.map((t, i) => `${i + 1}. ${t}`).join('\n');
  console.log(`已加载 ${lines.length} 条知识 ✓`);
}

// ── 3. HTTP 服务 ──────────────────────────────────────
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

app.post('/api/chat', async (req, res) => {
  const message = (req.body?.message || '').trim();
  if (!message || message.length > 2000) {
    return res.status(400).json({ error: '消息为空或过长' });
  }

  // 清洗历史:只保留合法消息,截断到最近 N 条
  const history = Array.isArray(req.body?.history) ? req.body.history : [];
  const safeHistory = history
    .filter(
      m =>
        m &&
        (m.role === 'user' || m.role === 'assistant') &&
        typeof m.content === 'string' &&
        m.content.trim(),
    )
    .slice(-MAX_HISTORY)
    .map(m => ({ role: m.role, content: m.content }));

  // 构建 System Prompt:注入知识库约束
  const systemPrompt =
    '你是「示例科技」的官网客服助手。请优先根据下方「参考资料」回答用户问题;' +
    '若资料中确实没有相关信息,就用常识礼貌回答或说明暂无相关信息,不要编造事实。\n\n' +
    `参考资料:\n${knowledgeText || '(无)'}`;

  // SSE 流式响应
  res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  try {
    const stream = await client.chat.completions.create({
      model: CHAT_MODEL,
      stream: true,
      messages: [
        { role: 'system', content: systemPrompt },
        ...safeHistory,
        { role: 'user', content: message },
      ],
    });

    for await (const part of stream) {
      const token = part.choices[0]?.delta?.content || '';
      if (token) {
        res.write(`data: ${JSON.stringify({ token })}\n\n`);
      }
    }
    res.write('data: [DONE]\n\n');
    res.end();
  } catch (err) {
    console.error(err);
    if (!res.headersSent) {
      res.status(500).json({ error: '模型服务异常' });
    } else {
      res.write(`data: ${JSON.stringify({ token: '\n[出错了]' })}\n\n`);
      res.end();
    }
  }
});

loadKnowledge();
app.listen(3001, () => {
  console.log(`✓ Demo 已启动:http://localhost:3001`);
});

核心:极简 RAG(知识库注入)

什么是极简 RAG

传统 RAG(检索增强生成)流程:

知识库 → 向量化 → 存入向量数据库
用户提问 → 向量化 → 相似度检索 → 取 Top-K → 拼入 Prompt → LLM

极简 RAG(本项目采用):

knowledge.txt → 全量读入内存 → 每次提问全部拼入 System Prompt → LLM

为什么这样做

| 对比 | 极简 RAG(全量注入) | 向量 RAG | |------|:-------------------:|:--------:| | 实现复杂度 | ⭐ 一行代码读文件 | ⭐⭐⭐⭐⭐ 需要部署向量库 | | 回答准确率 | ✅ 100%(全量都在 prompt 里) | ⚠️ 依赖检索质量 | | 适用规模 | < 100 条知识 | 100 条以上 | | 延迟 | 无额外延迟 | 检索增加 50-200ms | | 成本 | $0 | Embedding API 调用费用 |

结论:知识少于 ~100 条时,极简 RAG 效果更好且零成本。

知识库格式

knowledge.txt

# 这是注释,会被忽略
公司名称叫「示例科技」,成立于 2020 年,主营 AI 客服解决方案。
退货政策:自签收之日起 7 天内,商品完好可无理由退货,运费由买家承担。
换货政策:非人为损坏的质量问题,30 天内可免费换货。
会员等级:分为普通、银卡、金卡三档,累计消费满 1000 元升银卡,满 5000 元升金卡。
技术支持邮箱是 support@example.com,紧急问题可拨打 400-000-0000。

加载逻辑:

const lines = fs
  .readFileSync(file, 'utf-8')
  .split('\n')
  .map(l => l.trim())
  .filter(l => l && !l.startsWith('#')); // 过滤空行和 # 注释

knowledgeText = lines.map((t, i) => `${i + 1}. ${t}`).join('\n');
// 输出:
// 1. 公司名称叫「示例科技」,成立于 2020 年...
// 2. 退货政策:自签收之日起 7 天内...
// 3. 换货政策:非人为损坏的质量问题...

生成的 System Prompt:

你是「示例科技」的官网客服助手。请优先根据下方「参考资料」回答用户问题;
若资料中确实没有相关信息,就用常识礼貌回答...

参考资料:
1. 公司名称叫「示例科技」,成立于 2020 年,主营 AI 客服解决方案。
2. 退货政策:自签收之日起 7 天内,商品完好可无理由退货,运费由买家承担。
3. 换货政策:非人为损坏的质量问题,30 天内可免费换货。
4. 会员等级:分为普通、银卡、金卡三档...
...

什么时候该换向量 RAG

当知识库规模超过 100 条,或需要按不同产品/文档回答不同问题时,极简 RAG 会:

  • prompt 过长 → 超出模型上下文窗口
  • 成本上升 → token 费用增加
  • 准确性下降 → 知识过多干扰模型

此时应迁移到向量 RAG(推荐 Qdrant / pgvector / Milvus)。


模式一:接入 Ollama 本地模型

为什么选 Ollama

| 优点 | 说明 | |------|------| | 完全免费 | 无 API 调用费用,无限使用 | | 隐私安全 | 数据不离开本地,敏感数据首选 | | 离线可用 | 无网络也能跑 | | OpenAI 兼容 | 同一套代码,只需改 URL |

| 缺点 | 说明 | |------|------| | 需要 GPU | 7B 模型推荐 6GB+ 显存,3B 模型 4GB+ | | 需要本地部署 | 有一定技术门槛 |

安装步骤

1. 安装 Ollama

macOS/Linux:

curl -fsSL https://ollama.com/install.sh | sh

Windows:从 https://ollama.com/download 下载安装包

2. 拉取模型

# 中文能力强的模型(推荐)
ollama pull qwen2.5:7b      # 7B 参数,约 4.7GB,显存 6GB+

# 机器性能弱(内存 < 8GB)
ollama pull qwen2.5:3b      # 3B 参数,约 1.9GB,显存 4GB+

# 测试英文为主
ollama pull llama3.2:3b

3. 验证服务

curl -s http://localhost:11434/api/version
# {"version":"0.5.1"}

ollama list
# NAME              SIZE      MODIFIED
# qwen2.5:7b       4.7GB     2026-07-10 12:00:00

4. 启动服务

npm start
# 输出:
# 已加载 8 条知识 ✓
# ✓ Demo 已启动:http://localhost:3001

5. 浏览器访问

http://localhost:3001

可用模型推荐

| 模型 | 大小 | 显存要求 | 中文 | 推理能力 | 推荐场景 | |------|------|:--------:|:----:|:--------:|---------| | qwen2.5:14b | 9GB | 10GB+ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 高质量回答 | | qwen2.5:7b | 4.7GB | 6GB+ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 推荐首选 | | qwen2.5:3b | 1.9GB | 4GB+ | ⭐⭐⭐⭐ | ⭐⭐⭐ | 弱机器 | | llama3.2:3b | 2GB | 4GB+ | ⭐⭐ | ⭐⭐⭐ | 英文为主 | | phi3:3b | 2.3GB | 4GB+ | ⭐⭐⭐ | ⭐⭐⭐ | 轻量快速 |


模式二:接入 DeepSeek 云端 API

为什么选 DeepSeek

| 优点 | 说明 | |------|------| | 价格低 | ~¥1/百万 tokens,GPT-4o 的 1/50 | | 中文强 | 国产模型,中文理解优秀 | | 上下文长 | 128K 上下文窗口 | | OpenAI 兼容 | 只需改环境变量 |

注册与配置

1. 注册 DeepSeek 账号

https://platform.deepseek.com

2. 创建 API Key

控制台 → API Keys → 创建 Key(格式:sk-xxxx

3. 启动云端版本

export DEEPSEEK_API_KEY=sk-你的key
npm run start:cloud

切换其他云服务商

由于使用了 OpenAI 兼容接口,只需改三个环境变量即可切换到任意兼容服务:

# DeepSeek
BASE_URL=https://api.deepseek.com/v1
CHAT_MODEL=deepseek-chat
API_KEY=sk-your-key

# 阿里百炼(通义千问)
BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
CHAT_MODEL=qwen-plus
API_KEY=your-key

# OpenAI(官方)
BASE_URL=https://api.openai.com/v1
CHAT_MODEL=gpt-4o
API_KEY=sk-your-key

# 火山方舟(字节豆包)
BASE_URL=https://ark.cn-beijing.volces.com/api/v3
CHAT_MODEL=doubao-pro-32k
API_KEY=your-key

无需修改任何代码!


SSE 流式响应详解

什么是 SSE

SSE(Server-Sent Events)是服务器向浏览器单向推送数据的技术。相比 WebSocket 更轻量,适合"服务器 → 浏览器"的实时数据流。

┌──────────┐                  ┌──────────┐
│  浏览器   │                  │  服务器   │
└─────┬────┘                  └─────┬────┘
      │         HTTP 连接              │
      │◄───────────────────────────── │
      │  event: data: {"token":"你"}  │
      │  event: data: {"token":"好"}  │
      │  event: data: [DONE]          │
      │         连接关闭                │

SSE vs WebSocket vs 轮询

| 对比项 | SSE | WebSocket | HTTP 轮询 | |--------|-----|----------|----------| | 方向 | 单向(服务端→客户端) | 双向 | 客户端主动 | | 连接数 | HTTP/1.1 限 6 个 | 可多路复用 | 每次新建 | | 自动重连 | ✅ 原生支持 | ❌ 需手动实现 | N/A | | 复杂度 | 极简 | 复杂 | 简单 | | 适用场景 | 聊天推送、实时通知 | 游戏、协作编辑 | 简单轮询 |

后端 SSE 实现

// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

// 逐 token 推送
for await (const part of stream) {
  const token = part.choices[0]?.delta?.content || '';
  if (token) {
    // data: 是 SSE 格式前缀
    // \n\n 是事件分隔符
    res.write(`data: ${JSON.stringify({ token })}\n\n`);
  }
}
res.write('data: [DONE]\n\n'); // 通知结束
res.end();

前端 SSE 读取

const res = await fetch('/api/chat', { method: 'POST', ... });
const reader = res.body.getReader(); // 获取流读取器

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  // value 是 Uint8Array(字节),需要解码
  buffer += new TextDecoder().decode(value, { stream: true });
  // 逐步解析 buffer 中的完整行
}

关键点{ stream: true } 告诉 TextDecoder 不要等待下一个字节块,可以处理不完整的字符串。


多轮对话记忆实现

设计思路

第1轮:用户:"退货政策是什么?"
  → history: []
  → 后端收到:system + user("退货政策是什么?")

第2轮:用户:"银卡升金卡要多少?"
  → history: [user("退货政策..."), assistant("回答...")]
  → 后端收到:system + history[0] + history[1] + user("银卡...")

第3轮:用户:"那铜卡呢?"
  → history: [第1轮, 第2轮]
  → 后端收到:system + 第1轮 + 第2轮 + user("那铜卡呢?")
  → 模型知道"铜卡"指的是会员等级

历史截断策略

// 最多保留最近 MAX_HISTORY 条(每轮 2 条:问+答)
const safeHistory = history
  .filter(m => m.role === 'user' || m.role === 'assistant')
  .slice(-MAX_HISTORY)  // 超出部分丢弃,避免 prompt 过长
  .map(m => ({ role: m.role, content: m.content }));

安全清洗

前端传来的历史可能包含恶意数据,必须清洗:

const safeHistory = history
  .filter(m =>
    // 1. 非空
    m &&
    // 2. 角色只能是 user 或 assistant(拒绝 system 注入)
    (m.role === 'user' || m.role === 'assistant') &&
    // 3. 内容是字符串
    typeof m.content === 'string' &&
    // 4. 非空内容
    m.content.trim(),
  )
  .slice(-MAX_HISTORY)
  .map(m => ({ role: m.role, content: m.content }));

如何自定义知识库

基础用法

直接编辑 knowledge.txt

# 格式:一行一条,不要超过 500 字
# # 开头是注释,会被忽略

你的公司名称和简介。
产品功能介绍。
价格方案说明。
客服联系方式。
...

保存后重启服务即可生效。

知识编写技巧

✅ 推荐写法

退货政策:自签收之日起 7 天内,商品完好可无理由退货,运费由买家承担。
退货流程:联系客服 → 填写退货单 → 寄回商品 → 审核通过后 3 个工作日退款。

❌ 不推荐的写法

退货(关于退货,买了东西之后不想要了可以退,怎么退呢?运费谁出?
      七天内,商品要完好无损,运费买家出,这是我们的政策,请大家注意!)

→ 啰嗦,模型容易混淆

💡 一条只讲一件事

会员等级:分为普通、银卡、金卡三档。
普通升银卡:累计消费满 1000 元。
银卡升金卡:累计消费满 5000 元。

→ 分开写更精准,模型检索更准确

知识规模与方案选择

| 知识条数 | 方案 | 说明 | |:--------:|------|------| | < 20 条 | ✅ 极简 RAG | 直接注入,零成本零复杂度 | | 20-100 条 | ✅ 极简 RAG | 注:prompt 可能接近 4K tokens | | 100-1000 条 | ⚠️ 考虑向量 RAG | embedding + Qdrant | | 1000+ 条 | ❌ 必须向量 RAG | 需分片、检索、路由 |


完整环境变量配置

| 变量 | 本地默认值 | 云端默认值 | 说明 | |------|-----------|-----------|------| | BASE_URL | http://localhost:11434/v1 | https://api.deepseek.com/v1 | API 地址 | | CHAT_MODEL | qwen2.5:7b | deepseek-chat | 模型名称 | | API_KEY | ollama(占位) | 必填 | API Key | | DEEPSEEK_API_KEY | 继承 API_KEY | 必填 | DeepSeek 专用 | | MAX_HISTORY | 10 | 10 | 多轮记忆最大条数 |

.env 文件(可选)

创建 .env 文件(记得加入 .gitignore):

# 本地模式
BASE_URL=http://localhost:11434/v1
CHAT_MODEL=qwen2.5:7b
API_KEY=ollama
MAX_HISTORY=10

# 云端模式(取消注释即可)
# DEEPSEEK_API_KEY=sk-your-key
# BASE_URL=https://api.deepseek.com/v1
# CHAT_MODEL=deepseek-chat

安装 dotenv

npm install dotenv

server.js 顶部引入:

import 'dotenv/config';

部署到生产环境

方式一:Docker 单容器部署

# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD ["node", "server.js"]
docker build -t ai-chat-demo .
docker run -d -p 3001:3001 \
  -e BASE_URL=http://host.containers.internal:11434/v1 \
  -e CHAT_MODEL=qwen2.5:7b \
  --restart unless-stopped \
  ai-chat-demo

Ollama 需在宿主机单独运行,容器通过 host.containers.internal 访问。

方式二:Docker Compose(Ollama + 服务 + 前端)

# docker-compose.yml
version: '3.8'
services:
  ollama:
    image: ollama/ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama-data:/root/.ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

  chat-demo:
    build: .
    ports:
      - "3001:3001"
    environment:
      - BASE_URL=http://ollama:11434/v1
      - CHAT_MODEL=qwen2.5:7b
    depends_on:
      - ollama

volumes:
  ollama-data:
docker compose up -d

方式三:云服务器(DeepSeek 版)

# 1. 上传代码
scp -r ai-chat-demo user@your-server:/opt/

# 2. 安装依赖
cd /opt/ai-chat-demo
npm install --production

# 3. 设置环境变量
export DEEPSEEK_API_KEY=sk-your-key
export NODE_ENV=production

# 4. 用 PM2 守护进程
npm install -g pm2
pm2 start server.cloud.js --name ai-chat
pm2 save
pm2 startup

方式四:部署到 Vercel(仅前端,调用云端 API)

由于后端需要 SSE 流式响应,Vercel Serverless Functions 对 SSE 支持有限,推荐用传统服务器部署后端。如仅做纯前端展示,可用 Vercel 托管 public/ 目录。


常见问题

Q:Ollama 报"模型服务异常"

1. 检查 Ollama 是否在运行:
   curl http://localhost:11434/api/version

2. 检查模型是否已拉取:
   ollama list

3. 重启 Ollama 服务:
   # macOS
   brew services restart ollama
   # Linux
   sudo systemctl restart ollama

Q:DeepSeek 报"invalid API key"

1. 确认 key 正确(不要有空格或换行)
2. 确认账户有余额(https://platform.deepseek.com/balance)
3. 确认网络能访问 api.deepseek.com

Q:回答总是"不知道"

1. 检查 knowledge.txt 是否正确加载(看启动日志"已加载 N 条知识 ✓")
2. 检查知识内容是否用中文描述(模型对中文理解更好)
3. 知识太笼统 → 拆细,一条只讲一件事

Q:怎么实现对话历史持久化?

把历史存到数据库:

// 简化示例(真实场景用数据库)
app.post('/api/chat', async (req, res) => {
  const userId = req.headers['x-user-id'];
  const history = await db.getHistory(userId); // 从数据库读
  // ...
  await db.saveHistory(userId, [...history, userMsg, assistantMsg]);
});

Q:如何接入微信/企业微信?

浏览器 AI 助手
      │
      │ WebSocket / HTTP
      ▼
┌─────────────┐
│  微信消息   │  ← 需要消息推送服务(Go/Node)
│  中转服务   │
└──────┬──────┘
       │ /api/chat(复用现有接口)
       ▼
   Node 后端 → 模型 → AI 回复
       │
       ▼
┌─────────────┐
│  微信消息   │
│  推送服务   │
└─────────────┘

推荐用 WeChat Bot SDK + 现有 /api/chat 接口实现。

Q:知识库很大怎么办(> 100 条)?

极简 RAG(当前方案)
      │
      │ 知识超过 ~100 条
      ▼
向量 RAG 迁移:
  1. 接入 Embedding API(DeepSeek / OpenAI)
  2. 部署向量数据库(Qdrant / pgvector)
  3. 知识入库 → 用户提问时检索 Top-K → 拼入 prompt

总结

这个 demo 用最少的代码(~200 行)实现了一个有实际价值的 AI 客服

✅ SSE 流式打字机效果
✅ 多轮对话记忆(支持追问)
✅ 知识库 RAG 约束回答
✅ 本地模型(Ollama)和云端 API(DeepSeek)双模式
✅ 切换模型只需改环境变量,代码零改动
✅ 零前端依赖,纯原生 HTML/JS

下一步扩展方向:

  • [ ] 对话历史持久化(数据库)
  • [ ] 用户登录与认证
  • [ ] 向量 RAG(知识超过 100 条)
  • [ ] 接入微信/飞书/钉钉
  • [ ] 多模型对比(同时问多个模型)

评论