动机
营销站点通常只有 “Contact Sales” 表单,访客咨询靠邮件流转。当产品功能逐渐丰富(定价方案、40+ 集成、25 项 AI 技能),FAQ 篇幅暴涨,人工客服的瓶颈越来越明显。
目标很明确:一个 7x24 在线、能回答产品问题的 AI 客服,而且不能太贵。
架构选型
┌─────────────────────────────┐
│ Nginx Reverse Proxy │
│ resolver 127.0.0.11 + │
│ set_backend │
└──────────────┬──────────────┘
│
/api/chat/*
│
▼
┌───────────────────────────┐
│ FastAPI Backend │
│ (FAISS + RAG Pipeline) │
└─────────────┬─────────────┘
│
┌──────────────────────┼──────────────────────┐
│ │ │
▼ │ ▼
┌───────────────────────┐ │ ┌───────────────────────────┐
│ Ollama │ │ │ Online LLM │
│ ┌─────────────────┐ │ │ │ ┌─────────────────────┐ │
│ │ nomic │ │ │ │ │ DeepSeek │ │
│ │ qwen2 │ │ │ │ │ OpenAI etc. │ │
│ └─────────────────┘ │ │ │ └─────────────────────┘ │
└───────────────────────┘ │ └───────────────────────────┘
│
│
(Local) │ (Optional)
│
┌──────────────┴──────────────┐
│ Online/Offline │
│ * Local Mode │
│ - Hybrid Mode │
└─────────────────────────────┘
三个关键决策:
为什么是 RAG 而不是 fine-tune? 产品内容经常变更(定价调整、新功能发布),RAG 只需更新知识库文档,不需要重新训练模型。
为什么选 DeepSeek? 成本极低(约 $0.5/月),API 兼容 OpenAI 格式,中英文能力均衡,不需要海外信用卡。同时支持切换到任意 OpenAI 兼容 API。
为什么保留本地 Ollama? 嵌入模型 nomic-embed-text(274MB)负责将文本转为向量,本地处理零延迟零成本。Ollama 还可以运行本地对话模型(如 qwen2:1.5b),实现完全离线的客服系统。
组件一览
系统由两个 Docker 容器组成:
| 组件 | 部署方式 | 用途 |
|---|---|---|
| FastAPI 后端(含 FAISS 索引) | Docker 容器 | REST API + RAG 检索管道 |
| Ollama | Docker 容器 | 嵌入模型 + 可选本地对话模型 |
FAISS 向量索引集成在 FastAPI 进程内,不独立部署。对话模型可选本地(通过 Ollama)或在线(DeepSeek 等),由 .env 一个配置项切换。
RAG 管道:从提问到回答
用户提问 "Essential 方案多少钱?"
│
├── 1. 向量化
│ Ollama POST /api/embed → 768 维向量
│
├── 2. 语义检索
│ FAISS 搜索 top 30 → 取最相似片段
│
├── 3. 关键词检索
│ 分词 → 中文/英文混合匹配 → 补充语义盲区
│
├── 4. 合并去重 → 构建增强提示
│
└── 5. LLM 生成回答
├── 线上模式:DeepSeek API
└── 本地模式:Ollama qwen2:1.5b
混合搜索的必要性
纯语义搜索有一个问题:中文查询词在英文文档上的向量匹配度往往不够。比如 “价格” 这个词,在纯英文的定价文档中语义向量距离较远。
解决方式是关键词检索作为语义检索的补丁——对查询词分词后,在文档库中做逐词匹配,命中的片段直接加入结果集,不依赖向量相似度。
查询: "Essential 价格"
→ 语义搜索找到 "Subscription Model"(距离 0.50)
→ 关键词搜索找到 "Price: $249 / month"(命中 "Essential" + "价格" 中文标注)
→ 合并去重 → DeepSeek 回答 "每月 249 美元"
一个坑:去重逻辑
语义搜索返回 top 40 后,关键词搜索开始时遇到一个 bug:语义搜索把全部文档 ID 都放进了 seen 集合,关键词搜索无法添加新结果,导致中文查询永远找不到答案。
修复很简单——移除 seen 集合的互斥逻辑,改为在最终步骤统一按分数去重。
中文知识库的双语策略
为了让中文查询词能命中英文文档,知识库文件采用了中英文混写:
### Essential Plan(基础版方案)
- Price(价格): $249 / month(每月249美元)
- 1 included warehouse(包含1个仓库)
- API access(API接口): included
无论用户用中文还是英文提问,都能被关键词检索命中。生成脚本也内建了双语模板,不需要手动编写。
模型配置:一键切换本地/在线
早期版本对话模型固定走 DeepSeek。后来加入了本地模型支持,通过 .env 一个参数切换:
# .env
CHAT_MODEL=deepseek-chat # 线上模型
CHAT_MODEL=ollama/qwen2:1.5b # 本地模型
改完重启 backend 容器,前端自动生效。
实现原理
前端不直接关心模型名。页面加载时先请求后端的配置接口:
// chat.js
fetch('/api/chat/config')
.then(function(r) { return r.json(); })
.then(function(d) { MODEL = d.model; });
后端根据配置决定走哪条路:
# llm/__init__.py
def get_llm(model: str):
if model.startswith("ollama/") or model.startswith("local/"):
return OllamaLLM(), model.split("/", 1)[1] # 本地 Ollama
return OpenAILLM(), model # 在线 API
本地模型走 Ollama 容器(http://ollama:11434),不依赖任何 API key;线上模型走 OpenAI 兼容 API(DeepSeek / GPT 等),需要 LLM_BASE_URL + LLM_API_KEY。
当前可用的本地模型
qwen2:1.5b 934MB 通义千问 1.5B,中英文均衡
拉取新模型也很简单:
docker exec burtonsupport-ollama ollama pull <模型名>
Prompt 设计
系统提示词对回答质量影响很大,迭代后的最终版本:
你是平台客服助手。
根据以下文档内容回答用户问题。
规则:以客服身份直接回答,不说"根据文档内容",不用 markdown。
关键在于三点:
- 身份设定:让 AI 扮演客服而不是冒充平台本身。早期版本回答”我是 XX 平台”,这不对
- 去掉冗余前缀:模型默认会以”根据文档内容”开头,需要明确禁止
- 纯文本输出:客服对话不需要 markdown 加粗
知识库管理
data/kb/
├── auto/ ← 自动生成(从站点模板提取)
│ ├── 01-pricing.md
│ ├── 02-platform-features.md
│ ├── 03-faq.md
│ ├── 04-integrations.md
│ ├── 05-burt-skills.md
│ ├── 06-benefits.md
│ └── 07-legal.md
└── manual/ ← 人工编写(补充自动内容)
自动生成脚本读取产品站点的翻译文件和模板,输出结构化 markdown。产品内容变更后跑一遍即可同步。
限流与安全
客服 API 暴露在公网,必须有防护。在 FastAPI 中加了一层内存滑动窗口限流:
RATE_LIMIT_WINDOW = 60 # 时间窗口(秒)
RATE_LIMIT_MAX_REQUESTS = 10 # 窗口内最大请求数
限流参数通过 .env 配置。超出限制返回 HTTP 429 + Retry-After 头。
部署:路径转发而非子域名
采用路径转发避免跨域和 SSL 问题。用户请求经主站 nginx 直接转发到后端:
用户请求 → /api/chat/v1/chat/completions
↓
nginx proxy_pass → burtonsupport-backend:8000
一个坑:nginx 启动依赖
升级后遇到过一个隐蔽的问题——nginx 在启动时强制解析所有 upstream 主机名,如果 backend 容器未运行,nginx 直接 crash loop,连主站都访问不了。
修复方案:使用 Docker DNS resolver + 变量 proxy_pass,让 nginx 在运行时才解析 upstream,启动时不检查可达性。
location /api/chat/ {
resolver 127.0.0.11 valid=10s;
set $backend "http://burtonsupport-backend:8000";
rewrite ^/api/chat(/.*)$ $1 break;
proxy_pass $backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
注意 set 必须在 rewrite 之前,否则 rewrite 的 break 会跳过赋值。
前端也从 HTTPS 完整 URL 改为相对路径 /api/chat/...,同源策略自然通过。
前端小部件
聊天 UI 是纯前端组件,无框架依赖。浮动按钮 + 弹出面板,对话记录通过 localStorage 持久化。中英文自动跟随页面语言。
容错:后端不可用时自动隐藏
后端可能因配置错误、外部 API 故障等原因不可用。如果按钮照常显示,用户点击后得到 502 页面,体验很差。
解决方案是页面加载时做一次健康检测:
fetch('/api/chat/health', { method: 'GET', cache: 'no-cache' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.status === 'ok') btn.style.display = 'flex'; // 显示按钮
})
.catch(function() {
console.info('[Chat] 客服不在线'); // 隐藏按钮,仅日志提示
});
后端不可用时按钮直接隐藏,用户不会感知到客服系统的存在。后端恢复后需要刷新页面才会重新显示。
成本
系统支持两种运行模式,成本差异大:
| 模式 | 每月成本 |
|---|---|
| 本地模式(Ollama 嵌入 + Ollama 对话) | $0 |
| 混合模式(Ollama 嵌入 + DeepSeek 对话) | ~$0.5-2 |
本地模式完全离线运行,适合开发环境或无网络的私有部署。混合模式用本地嵌入、在线对话,性价比最高。
小结
这套系统的核心思路是用最少的成本解决 80% 的客服问题。不是最先进的技术栈,但足够实用:
- RAG 保证回答基于真实产品文档,不会胡编乱造
- 混合搜索解决了中英文混合查询的准确性
- 一键切换本地/在线模型,开发与生产共用一套代码
- 健康检测保证后端异常时不影响主站体验
- nginx 依赖解耦,避免上游故障引发级联崩溃
如果你也有一个内容相对稳定的 SaaS 产品站点,这套方案值得参考。整个工程代码不到 1000 行,一个周末就能跑通。