基于
ai-chat-demo项目,详解如何用纯前端 + Node.js 后端实现一个带知识库的 AI 客服。支持本地模型(Ollama)和云端 API(DeepSeek)两种模式,代码约 200 行。
效果预览
用户:退货政策是怎样的?
AI :自签收之日起 7 天内,商品完好可无理由退货,运费由买家承担。
用户:那我买完可以退吗?
AI :可以的。自签收之日起 7 天内商品完好即可申请无理由退货。
退货时需要联系客服申请,运费由您承担哦。
目录
- 技术方案概览
- 项目结构
- 前端实现:纯原生 HTML/JS,零依赖
- 后端实现:Express + SSE 流式响应
- 核心:极简 RAG(知识库注入)
- 模式一:接入 Ollama 本地模型
- 模式二:接入 DeepSeek 云端 API
- SSE 流式响应详解
- 多轮对话记忆实现
- 如何自定义知识库
- 完整环境变量配置
- 部署到生产环境
- 常见问题
技术方案概览
┌─────────────────────────────────────────────────────────┐
│ 浏览器 │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 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 条)
- [ ] 接入微信/飞书/钉钉
- [ ] 多模型对比(同时问多个模型)