当三个外部依赖都可能随时挂掉时,如何保证用户永远有响应?
问题:完美主义害死人
做RAG系统时,我们很容易陷入一种思维定势:向量检索要准、LLM要强、整个链路要丝滑。但现实是------任何一个外部服务挂了,用户就得不到响应。
在微信小程序这种C端场景,可用性比准确性重要得多。用户不在乎你用的是Qdrant还是Pinecone,他只知道:点进来,白屏,关掉,再也不会用。
我们系统依赖三个外部服务:
- SiliconFlow Embedding API:将用户问题转成向量
- Qdrant向量数据库:存储和检索菜谱向量
- DeepSeek LLM:基于检索结果生成回复
任何一个挂了,如果按传统思路直接报错,用户就流失了。
本文分享我们如何在云函数环境中,用四级降级策略保证系统永远有响应------宁可推荐不够精准,也不能白屏无响应。
容错架构:四层兜底,层层递进
整体架构如下:
用户消息
│
▼
┌─────────────────────┐
│ 第1级: Qdrant向量搜索 │ ──超时/失败──┐
└─────────┬───────────┘ │
│ ▼
有高分结果? ┌─────────────────────┐
│ │ │ 第2级: TF-IDF关键词检索 │ ──超时/失败──┐
│ └────── 成功 ──────→ │ (纯JS,无外部依赖) │ │
│ └─────────┬───────────┘ │
│ │ ▼
│ 有高分结果? ┌──────────────┐
│ │ │ │ 第3级: 无RAG │
│ │ └── 成功 ──→ 用TF-IDF结果 │ LLM直接回答 │
│ └──── 失败 ──→ └──────┬───────┘
│ │
└────────────────────── 统一送入DeepSeek ──────────────────────┘
│
DeepSeek也挂了?
│
▼
┌──────────────────────┐
│ 第4级: fallbackResponse │
│ 本地关键词+数据库查询 │
└──────────────────────┘
核心设计原则:每一级都不依赖上一级,每一级都有自己的超时控制,失败后静默降级。
第1级→第2级:向量检索失败时的TF-IDF降级
向量检索失败可能的原因:
- Embedding API 超时(5秒无响应)
- Qdrant 查询超时(3秒无响应)
- 网络错误(ETIMEDOUT、ECONNREFUSED)
- Qdrant 服务完全不可用
降级逻辑的核心代码:
javascript
// chat/index.js - retrieveContext()
async function retrieveContext(message, openid) {
let systemRagRecipes = []
let contextText = ''
// ═══ 尝试向量搜索 ═══
try {
const vectorResults = await withTimeout(
qdrant.vectorSearch(message, 3),
TIMEOUT_CONFIG.embedding + TIMEOUT_CONFIG.qdrant, // 8秒总超时
'向量检索超时'
)
const relevant = vectorResults.filter(r => r.similarity > 0.3)
if (relevant.length > 0) {
systemRagRecipes = relevant.map(r => r.recipe)
contextText = formatContext(relevant)
}
} catch (e) {
// Qdrant挂了或超时,静默降级 --- 用户完全无感知
console.warn('[RAG-Vector] 向量检索失败,降级为 TF-IDF:', e.message)
}
// ═══ 仅在向量搜索无结果时走TF-IDF ═══
if (systemRagRecipes.length === 0) {
const { data: recipes } = await db.collection('recipes')
.where({ isPrivate: _.neq(true) })
.limit(50).get()
const results = search(message, recipes, 3) // TF-IDF检索
const relevant = results.filter(r => r.similarity > 0.05) // 阈值更宽松
if (relevant.length > 0) {
systemRagRecipes = relevant.map(r => r.recipe)
contextText = formatContext(relevant)
}
}
return { contextText, ragRecipes: systemRagRecipes }
}
关键设计细节:
1. 静默降级:catch块中只打日志不抛异常,用户永远看不到"系统繁忙"之类的错误提示。
2. 阈值差异:TF-IDF用0.05,向量用0.3。因为两者的分数分布不同,TF-IDF的相似度天然更低,阈值需要调低。
3. 只查系统菜谱 :降级时过滤isPrivate,避免查到其他用户的私人菜谱造成隐私问题。
TF-IDF的纯JS实现:零依赖才能真兜底
为什么TF-IDF能成为可靠的第二级?因为它是纯JavaScript实现,不依赖任何外部服务、不依赖C++扩展、不依赖网络。
云函数环境中,很多npm包需要编译(如jieba分词),部署困难。我们实现了一个轻量级的中文TF-IDF:
中文分词:二元组+停用词
javascript
// tfidf.js
const STOP_WORDS = new Set(['的', '了', '是', '在', '和', '也', '都', '不', '就', '有'])
function tokenize(text) {
const chineseChunks = text.match(/[\u4e00-\u9fa5]+/g) || []
const tokens = []
for (const chunk of chineseChunks) {
// 双字组合(匹配菜名、食材名)
for (let i = 0; i < chunk.length - 1; i++) {
const bigram = chunk[i] + chunk[i + 1]
if (!STOP_WORDS.has(bigram)) tokens.push(bigram)
}
}
return tokens
}
为什么用bigram而不是jieba?因为云函数装不了C++扩展,而bigram对短文本(菜名、食材名)效果足够好。像"宫保鸡丁"会被切分为["宫保","保鸡","鸡丁"],足够匹配用户查询。
增强归一化TF
javascript
function computeTFIDF(tokens, idf) {
const tf = {}
for (const t of tokens) tf[t] = (tf[t] || 0) + 1
const maxTf = Math.max(...Object.values(tf))
const tfidf = {}
for (const [term, freq] of Object.entries(tf)) {
// 增强归一化:避免长文档的单字频率被过度归一化
const normalizedTf = 0.5 + 0.5 * (freq / maxTf)
tfidf[term] = normalizedTf * (idf[term] || 1)
}
return tfidf
}
0.5 + 0.5 * (tf/maxTf)比简单的tf/maxTf更柔和,避免长文档中重复词失去权重。
IDF平滑
javascript
// 预处理:计算IDF
function precomputeIdf(recipes) {
const N = recipes.length
const df = {}
for (const recipe of recipes) {
const tokens = tokenize(recipe.name + ' ' + (recipe.ingredients || []).join(' '))
const unique = new Set(tokens)
for (const term of unique) {
df[term] = (df[term] || 0) + 1
}
}
const idf = {}
for (const [term, freq] of Object.entries(df)) {
// +1平滑防止除零,再+1偏移保证IDF不为0
idf[term] = Math.log((N + 1) / (freq + 1)) + 1
}
return idf
}
超时控制:每个环节都有底线
没有超时控制的降级是假降级------一个请求卡住3分钟,用户早走了。
javascript
const TIMEOUT_CONFIG = {
embedding: 5000, // Embedding API: 5秒
qdrant: 3000, // Qdrant查询: 3秒
ragQuery: 3000, // TF-IDF查询: 3秒
deepseekApi: 30000, // DeepSeek: 30秒
cloudFunction: 34000 // 云函数总超时: 34秒(留1秒缓冲)
}
// 用Promise.race实现
function withTimeout(promise, ms, errorMsg) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(errorMsg)), ms)
)
])
}
关键原则:
- 每个外部调用独立超时:Embedding超时不影响后续降级
- 总超时兜底:云函数34秒超时,保证不会无限等待
- 超时即降级:超时被当作失败,进入下一级
第3级:LLM降级
向量检索和TF-IDF都失败了怎么办?走纯LLM生成(无RAG上下文):
javascript
// chat/index.js - callDeepSeekAPI()
try {
const response = await got.post(deepseekUrl, {
timeout: { request: TIMEOUT_CONFIG.deepseekApi }
})
// 解析响应...
} catch (err) {
console.warn('[DeepSeek] API调用失败,走本地兜底:', err.message)
return await fallbackResponse(userMessage)
}
此时依赖就只剩下DeepSeek API了。但如果DeepSeek也挂了呢?
第4级:本地关键词兜底
最后一道防线:不依赖任何外部API,只依赖云数据库。
javascript
async function fallbackResponse(userMessage) {
const msg = userMessage.toLowerCase()
// 1. 问候语直接回复(不需要查库)
const greetings = ['你好', 'hello', 'hi', '嗨']
if (greetings.some(g => msg.includes(g))) {
return { reply: '你好!我是灶台导航助手,可以帮你推荐菜谱~', action: 'ask' }
}
// 2. 关键词→分类映射
const categoryMap = {
'牛': 'beef', '猪': 'pork', '鸡': 'chicken',
'鱼': 'fish', '素': 'vegetarian', '甜': 'dessert'
}
let matchedCategory = null
for (const [keyword, category] of Object.entries(categoryMap)) {
if (msg.includes(keyword)) { matchedCategory = category; break }
}
// 3. 从数据库按分类或关键词查菜谱
const db = cloud.database()
const _ = db.command
let query = db.collection('recipes')
if (matchedCategory) {
query = query.where({ category: matchedCategory })
} else {
// 模糊匹配菜名
const keywordArr = msg.split(/[\s,,、]+/).filter(k => k.length > 0)
const orConditions = keywordArr.map(k => ({ name: db.RegExp({ regexp: k, options: 'i' }) }))
query = query.where(_.or(orConditions))
}
const { data: recipes } = await query
.limit(3)
.orderBy('views', 'desc')
.get()
if (recipes.length > 0) {
const reply = `为您推荐以下${matchedCategory ? matchedCategory : '相关'}菜谱:\n` +
recipes.map(r => `${r.name}(${r.difficulty || '中等'})`).join('\n')
return { reply, recommendations: recipes, action: 'recommend' }
}
// 连数据库都查不到------兜底的兜底
return {
reply: '暂时没找到相关菜谱,可以说说你想吃什么食材吗?',
action: 'ask'
}
}
这个兜底层有三大特点:
- 零外部依赖:只调用云数据库,即便Embedding/Qdrant/DeepSeek全挂也能工作
- 规则驱动:关键词映射不依赖AI,100%确定
- 渐进式降级:问候语直接回复 → 分类匹配 → 模糊匹配 → 通用提示
降级触发条件速查表
| 降级级别 | 触发条件 | 用户感知 |
|---|---|---|
| 向量→TF-IDF | ① Embedding API 超时(5s) ② Qdrant 查询超时(3s) ③ 网络错误 ④ 所有结果相似度 < 0.3 | 无感知,推荐精度略降 |
| TF-IDF→纯生成 | ① 数据库查询超时(3s) ② 所有结果相似度 < 0.05 ③ 数据库为空 | 无感知,回答无菜谱引用 |
| 纯生成→兜底 | DeepSeek API 超时(30s) 或报错 | 回复变简短,可能答非所问 |
| 兜底内部降级 | 关键词/分类匹配失败 | 返回通用提示,继续对话 |
为什么不合并多路结果?
有人会问:为什么不做向量检索和TF-IDF的并行召回+融合排序?
当前设计选择串行降级 而非并行融合,原因如下:
- 数据规模小:菜谱数量在百级,单路召回已经足够覆盖
- 分数不可比 :向量相似度(01)和TF-IDF分数(0几十)尺度不同,简单线性融合会引入噪声
- 延迟更低:向量检索成功时不需要等待TF-IDF执行,串行反而更快
- 代码简单:逻辑清晰,维护成本低
如果后续扩展到万级数据,可以考虑升级为多路并行召回+Cross-Encoder重排序。
完整的请求生命周期
以一次完整对话为例,追踪数据路径:
1. 用户输入:"家里有鸡肉和土豆,想做点下饭的"
2. 小程序调用云函数
wx.cloud.callFunction({ name: 'chat', data: { message: "..." } })
3. 云函数入口 exports.main()
├─ qdrant.setApiKey("sk-xxx")
└─ processChat(event, openid)
4. retrieveContext()
├─ 尝试向量搜索
│ ├─ getEmbedding() → SiliconFlow API
│ └─ searchQdrant() → 返回宫保鸡丁(0.72)、土豆烧鸡(0.68)、辣子鸡丁(0.51)
├─ 过滤相似度 > 0.3 → 3条通过
└─ 返回 RAG 上下文
5. callDeepSeekAPI()
├─ System Prompt = 基础词 + RAG上下文
└─ DeepSeek返回 → {"reply":"推荐宫保鸡丁...","action":"recommend"}
6. resolveRecipeIds()
└─ "宫保鸡丁" → 匹配 ragRecipes 中的真实 _id
7. 保存会话到数据库
8. 返回小程序
如果第4步向量检索失败,会自动走TF-IDF降级,后续流程完全一样,用户无感知。
降级效果实测
场景1:正常情况(所有服务正常)
- 用户:"想吃点下饭的"
- Qdrant返回宫保鸡丁(0.72)、鱼香肉丝(0.68)
- 阈值0.3通过,直接使用向量结果
- 总耗时:~2秒
场景2:Qdrant临时宕机
- Embedding API超时(5s)→抛异常
- 自动降级TF-IDF,按"下饭"关键词匹配
- 找到标签含"下饭"的菜谱
- 用户无感知,耗时增加约1秒(降级开销)
场景3:用户说"你好"
- Qdrant返回低分结果(最高0.15)
- TF-IDF也无有意义匹配
- 进入纯生成模式,DeepSeek直接回复
- 用户得到正常问候,无报错
场景4:DeepSeek也挂了
- 进入fallbackResponse,关键词匹配
- 回复"您好!请说食材名称..."
- 虽然不够智能,但用户不会看到白屏
关于Qdrant的安全提醒
目前Qdrant直接暴露在公网且无认证,存在风险:
风险:
- 任何人都可以查询/修改/删除你的向量数据
- 数据泄露或被恶意清空
两种加固方案:
方案一:API Key认证(推荐)
yaml
# docker-compose.yml
services:
qdrant:
image: qdrant/qdrant
environment:
- QDRANT__SERVICE__API_KEY=your-secure-key-here
方案二:防火墙白名单(更安全)
bash
# 云厂商控制台设置入站规则
# 只允许云函数所在网段访问 Qdrant 端口(6333,6334)
后续扩展方向
当前架构满足小规模场景,后续可以迭代的方向:
-
查询改写:先调LLM将模糊提问("晚上吃啥")改写为精确query("家常菜 简单 快手 30分钟"),提高召回率
-
多路召回+重排序:向量检索、TF-IDF、元数据过滤三路并行,用Cross-Encoder重排序
-
增量同步:菜谱新增/修改后自动触发Qdrant更新,而非全量重新同步
-
缓存层:对高频查询的Embedding结果做缓存,减少SiliconFlow API调用
-
混合检索:利用Qdrant的filtering能力,先按分类/难度过滤,再做向量匹配
-
监控告警:增加各级降级的metrics上报,及时发现问题
小结
容错不是锦上添花,而是RAG系统上线的必备条件。核心思路:
- 分级降级:向量搜索→关键词搜索→LLM直答→本地兜底,每一级都不依赖上一级
- 独立超时:每个外部调用有自己的超时,不互相阻塞,超时即降级
- 静默失败:用户永远看不到技术错误,只是推荐精度逐步降低
- 零依赖兜底:最后一道防线只依赖云数据库,保证最低可用性
在C端场景,用户要的不是完美的推荐,而是一个永远能用的产品。
项目地址 :Gitee/ZaoTaiNavigation
团队名称 :倒灶了队
更新时间:2026年5月