# 语音通话模块重构方案 — KB直出 + 重排 + Redis上下文 > 版本:1.0 | 日期:2026-03-24 --- ## 1. 现状分析 ### 1.1 当前 KB 查询链路 ``` ASR final → realtimeDialogRouting.resolveReply() → ToolExecutor.searchKnowledge() → rewriteKnowledgeQuery() // query改写 → searchArkKnowledge() // ★ 核心瓶颈 ┌──────────────────────────────────────────────┐ │ POST ark.cn-beijing.volces.com │ │ /api/v3/chat/completions │ │ │ │ model: Seed-2.0-lite (ep-xxx) │ │ metadata.knowledge_base: { dataset_ids, ... }│ │ max_tokens: 80 │ │ thinking: disabled │ │ │ │ → 1. 向量检索 top_k=3 chunks │ │ → 2. LLM 基于 chunks 生成回答文本(80 tokens)│ ← 要去掉 └──────────────────────────────────────────────┘ → classifyKnowledgeAnswer() // 判断 hit/no-hit → external_rag (event 502) // 注入 S2S → S2S 基于 RAG 内容合成语音 ``` ### 1.2 痛点 | 痛点 | 说明 | |------|------| | **LLM 加工多余** | KB 检索后还经过 Seed-2.0-lite 生成 80 tokens 回答,增加 ~1-2s 延迟,且回答被模型"改写",不够原汁原味 | | **无重排** | 向量检索 top_k=3 直接使用,靠 threshold=0.3 卡阈值,相关性排序不够精准 | | **上下文断裂** | S2S 仅接收单次 RAG 内容,不知道前几轮聊了什么,多轮追问体验差 | | **context 来源慢** | 上下文从 MySQL `getRecentMessages()` 获取,单次 50-200ms | | **内存缓存不可靠** | KB 缓存用 Node.js `Map`,进程重启即丢失,PM2 多实例不共享 | --- ## 2. 重构目标 ``` ASR final → resolveReply() → rewriteKnowledgeQuery() → ★ 方舟 KB 纯检索(返回原始 chunks,不经 LLM 加工) → ★ 重排模型(reranker)对 chunks 按相关性排序 → 取 top 3 reranked chunks → ★ 从 Redis 读取最近 5 轮对话 → 构建 external_rag payload:{ chunks + conversation_context } → event 502 注入 S2S → S2S 结合上下文 + KB 原始片段,自主生成回答并合成语音 ``` ### 核心改变 | 项目 | 现状 | 目标 | |------|------|------| | KB 检索 | chat/completions + knowledge_base metadata(检索+生成一体) | 纯检索 API,只返回原始 chunks | | 回答生成 | Seed-2.0-lite 生成 80 tokens | 去掉,由 S2S 直接基于 RAG 内容生成 | | 重排 | 无 | 方舟重排模型,对检索结果排序 | | 上下文存储 | MySQL + Node.js 内存 Map | Redis(最近 5 轮 = 10 条消息) | | S2S 输入 | 单条 RAG 回答文本 | 3 条原始 KB 片段 + 5 轮对话上下文 | --- ## 3. 改动全景图 ``` 涉及文件 改动类型 改动量级 说明 ───────────────────────────────────────────────────────── 新增文件 services/redisClient.js 新增 ~120行 Redis 连接管理 + 对话读写 services/kbRetriever.js 新增 ~200行 KB 纯检索 + 重排 + 结果组装 修改文件 toolExecutor.js 重构 大 searchArkKnowledge → 调用 kbRetriever nativeVoiceGateway.js 修改 中 persist* 同步写 Redis;sendExternalRag 注入上下文 realtimeDialogRouting.js 修改 小 resolveReply 适配新返回格式 package.json 修改 小 添加 ioredis 依赖 .env / .env.example 修改 小 新增 Redis + 重排模型配置 不动的文件 realtimeDialogProtocol.js 不动 event 502 协议格式不变 knowledgeKeywords.js 不动 关键词路由不变 contextKeywordTracker.js 不动 关键词追踪不变 contentSafeGuard.js 不动 内容安全不变 fastAsrCorrector.js 不动 ASR纠错不变 ``` --- ## 4. 详细设计 ### Phase 1:Redis 基础设施 #### 4.1 新增 `services/redisClient.js` **职责**:Redis 连接管理 + 对话历史读写 ``` 功能清单: ├── createRedisClient() // 连接管理,断线重连,错误日志 ├── pushMessage(sessionId, msg) // LPUSH + LTRIM 保留最近 10 条 ├── getRecentHistory(sessionId) // LRANGE 获取最近 5 轮(10 条) ├── clearSession(sessionId) // DEL 清理 └── setKbCache / getKbCache // KB 结果缓存(替代内存 Map) ``` **数据结构设计**: ``` Key: voice:history:{sessionId} Type: List Items: JSON string → { role, content, source, timestamp } MaxLen: 10 (5轮 × 2条/轮) TTL: 1800s (30分钟) Key: voice:kb_cache:{cacheKey} Type: String (JSON) TTL: 300s (hit) / 120s (no-hit) ``` **降级策略**:Redis 不可用时,自动降级到当前的内存 Map + MySQL 方案,不阻塞主链路。 #### 4.2 环境变量 ```env # Redis REDIS_URL=redis://127.0.0.1:6379 REDIS_PASSWORD= REDIS_DB=0 REDIS_KEY_PREFIX=bigwo: # 重排模型 VOLC_ARK_RERANKER_ENDPOINT_ID=ep-xxxxxxxx VOLC_ARK_RERANKER_TOP_N=3 # KB 纯检索(替代 chat/completions) VOLC_ARK_KB_RETRIEVAL_MODE=raw # 可选值: raw(纯检索,本次启用)| answer(当前模式,保留降级用) ``` #### 4.3 依赖 ```json { "ioredis": "^5.4.0" } ``` 选 `ioredis` 而非 `redis`:自动重连、Sentinel/Cluster 原生支持、性能更好。 --- ### Phase 2:KB 检索改造 #### 4.4 新增 `services/kbRetriever.js` **职责**:KB 纯检索 + 重排 + 结果组装(替代 `searchArkKnowledge` 中的检索+生成一体逻辑) ``` 功能清单: ├── retrieveChunks(query, datasetIds, topK) // 纯向量检索,返回原始 chunks ├── rerankChunks(query, chunks, topN) // 重排模型调用 ├── buildRagPayload(chunks, history) // 组装 external_rag 载荷 └── searchAndRerank(query, opts) // 主入口:检索 → 重排 → 组装 ``` **核心流程**: ``` searchAndRerank(query, { datasetIds, sessionId, session }) │ ├─ 1. retrieveChunks(query, datasetIds, topK=10) │ POST /api/v3/chat/completions │ model: 最轻量模型 │ metadata.knowledge_base: { dataset_ids, top_k: 10, threshold: 0.1 } │ max_tokens: 1 ← 关键:只要1个token,目的是触发检索 │ stream: false │ ※ 从 response 中提取 references/chunks 字段 │ ※ 如果方舟API不在response中返回原始chunks: │ → 降级方案:用 snippet 模式 prompt 让模型返回原文 │ → 或调用方舟知识库独立检索API(如有) │ ├─ 2. rerankChunks(query, chunks, topN=3) │ POST /api/v3/chat/completions(或 /api/v3/rerank) │ model: reranker-endpoint-id │ 输入:query + documents[] │ 输出:按相关性排序的 chunks + scores │ 超时:3s │ 降级:重排失败时直接使用原始检索排序 │ ├─ 3. getRecentHistory(sessionId) ← 从 Redis │ 返回最近 5 轮对话 │ └─ 4. buildRagPayload(top3Chunks, history) 组装 ragItems[]: [ { title: "对话上下文", content: "用户: xxx\n助手: xxx\n..." }, { title: "知识库片段1", content: chunk1.content }, { title: "知识库片段2", content: chunk2.content }, { title: "知识库片段3", content: chunk3.content }, ] ``` #### 4.5 关于方舟 KB 纯检索的技术方案 方舟 `chat/completions` + `knowledge_base` 的 response 结构中,`choices[0].message` 包含 LLM 生成的回答,但**原始检索 chunks 可能在 `references` 字段中返回**(取决于方舟版本)。 需要验证的关键点: ``` 方案 A(优先):方舟 response 中有 references 字段 → response.data.references = [{ content, score, doc_name, ... }, ...] → 直接提取,不用 choices[0].message.content 方案 B(备选):方舟有独立的知识库检索 API → POST /api/v3/knowledge-base/retrieve 或类似端点 → 纯向量检索 + BM25,不经 LLM 方案 C(保底):利用 snippet 模式 prompt 提取原文 → system prompt = "原样输出检索到的所有文档片段,不改写不总结" → max_tokens = 500 → 解析输出为独立 chunks ``` **建议**:先用方案 A 验证 `references` 字段是否存在;若不存在,采用方案 B 或 C。 #### 4.6 重排模型选型 | 模型 | 延迟(估) | 精度 | 建议 | |------|----------|------|------| | bge-reranker-v2-m3 | ~200ms | 高 | 首选,多语言支持好 | | bge-reranker-large | ~150ms | 较高 | 备选 | | cohere-rerank-v3 | ~300ms | 最高 | 如果方舟支持 | 需要在方舟平台创建重排模型的推理接入点。 --- ### Phase 3:S2S 上下文注入 #### 4.7 修改 `nativeVoiceGateway.js` **改动 1:`persistUserSpeech` / `persistAssistantSpeech` 同步写 Redis** ``` persistUserSpeech(session, text) ├─ 原有逻辑不变(MySQL + 字幕推送) └─ 新增:redisClient.pushMessage(sessionId, { role: 'user', content: text }) persistAssistantSpeech(session, text, opts) ├─ 原有逻辑不变 └─ 新增:redisClient.pushMessage(sessionId, { role: 'assistant', content: text }) ``` **改动 2:`sendExternalRag` 接受新格式** ``` 现状:sendExternalRag(session, [{ title, content }]) → 直接发 event 502 新增:注入上下文到 ragItems → ragItems 前置一条 { title: "对话上下文", content: "最近5轮..." } → 再跟 3 条 KB 片段 → 发 event 502 ``` #### 4.8 修改 `realtimeDialogRouting.js` — `resolveReply` ``` 现状 resolveReply: → ToolExecutor.execute('search_knowledge', { response_mode: 'answer', ... }) → extractToolResultText → 得到 LLM 生成的回答文本 → delivery: 'external_rag', ragItems: [{ content: LLM回答 }] 改为: → kbRetriever.searchAndRerank(query, { sessionId, session, datasetIds }) → 返回 { chunks: [...], rerankedChunks: [...], history: [...], ragPayload: [...] } → delivery: 'external_rag', ragItems: ragPayload(已包含上下文 + 3个原始片段) ``` #### 4.9 修改 `toolExecutor.js` ``` 现状 searchKnowledge: → searchArkKnowledge() → LLM 加工 → classifyKnowledgeAnswer() 改为: → kbRetriever.searchAndRerank() → 纯检索 + 重排 → 判断 hit/no-hit 改为基于 reranker score 阈值 → 不再调用 classifyKnowledgeAnswer()(因为没有 LLM 生成的回答文本了) ``` --- ### Phase 4:清理与适配 #### 4.10 可移除/简化的代码 | 位置 | 内容 | 原因 | |------|------|------| | `toolExecutor.js` L925-946 | system prompt 构建(baseAnswerPrompt, buildQuestionSlotInstruction等) | 不再需要 LLM 生成回答 | | `toolExecutor.js` L953-966 | chat/completions body 构建(max_tokens:80, thinking:disabled) | 改为纯检索调用 | | `toolExecutor.js` L981-996 | classifyKnowledgeAnswer() 调用 | 改为 reranker score 判断 | | `toolExecutor.js` L47-74 | 内存 kbQueryCache Map | 迁移到 Redis | | `realtimeDialogRouting.js` L275 | `normalizeTextForSpeech(replyText).replace(...)` 去除"根据知识库" | 不再有 LLM 加工文本 | #### 4.11 保留不动的模块 | 模块 | 原因 | |------|------| | KB 保护窗口(60s) | 多轮追问保护仍有价值 | | shouldForceKnowledgeRoute | 路由判断逻辑不受影响 | | prequery 预查询 | 仍然有效,改为调用 kbRetriever | | ASR 纠错 + 词表 | 与 KB 检索解耦 | | HOT_ANSWERS | 保留作为 0ms 极速响应,但需标记来源 | | 品牌保护兜底(传销问题) | 安全需求,保留 | | 助手资料系统 | 与 KB 检索解耦 | --- ## 5. 数据流对比 ### 5.1 现状(LLM 加工模式) ``` 用户: "小红产品怎么吃?" ↓ searchArkKnowledge() → Ark API: model=Seed-2.0-lite, max_tokens=80 → KB 检索到 3 chunks + LLM 生成回答: "小红Activize的服用方法是兑100-150ml温水,小口慢饮,建议在大白之后15-30分钟。" ↓ event 502: [{ title: "知识库结果", content: "小红Activize的服用方法..." }] ↓ S2S 合成语音播报(S2S 不知道前几轮聊了什么) ``` ### 5.2 重构后(直出 + 重排 + 上下文模式) ``` 用户: "小红产品怎么吃?" ↓ kbRetriever.searchAndRerank() → 纯检索: top_k=10, threshold=0.1 → 得到 10 个原始 chunks → 重排: reranker 对 10 chunks 排序 → 取 top 3 → Redis: 取最近 5 轮对话 ↓ event 502: [ { title: "对话上下文", content: "用户: 你们有什么产品?\n助手: 我们有基础三合一...\n用户: 小红是什么?\n助手: 小红是Activize Oxyplus..." }, { title: "知识库片段1", content: "Activize Oxyplus(小红):水溶性CoQ10+维生素B族+瓜拉那提取物..." }, { title: "知识库片段2", content: "基础三合一服用方法:大白空腹→小红15-30分钟后→小白睡前..." }, { title: "知识库片段3", content: "小红用法:兑100-150ml温水,小口慢饮。水温不超过40度..." }, ] ↓ S2S 基于上下文 + 3个原始片段自主生成自然口语回答,合成语音 ``` --- ## 6. 延迟预估 | 环节 | 现状 | 重构后 | 变化 | |------|------|--------|------| | query 改写 | ~5ms | ~5ms | 不变 | | KB 检索 | ~2000ms(检索+LLM生成) | ~800ms(纯检索) | ↓60% | | 重排 | 无 | ~200ms | 新增 | | Redis 读上下文 | 无(MySQL ~100ms) | ~5ms | ↓95% | | 组装 payload | ~1ms | ~2ms | 不变 | | **总计** | **~2300ms** | **~1000ms** | **↓56%** | > 注意:S2S 接收到更丰富的 RAG 内容后,语音合成可能略增,但整体用户体验(首字节到达时间)应显著改善。 --- ## 7. 风险与降级 | 风险 | 概率 | 影响 | 降级方案 | |------|------|------|----------| | 方舟 API 不返回原始 chunks(无 references 字段) | 中 | 高 | 用 snippet prompt + max_tokens=500 提取原文 | | 重排模型部署/调用失败 | 低 | 中 | 跳过重排,直接使用检索排序 | | Redis 连接失败 | 低 | 中 | 降级到内存 Map + MySQL | | S2S 处理多条 RAG 片段质量下降 | 中 | 高 | A/B 测试对比,保留旧模式开关 | | Redis 内存溢出 | 低 | 低 | TTL=30min + maxmemory-policy=allkeys-lru | ### 关键降级开关 ```env # .env 中控制 VOLC_ARK_KB_RETRIEVAL_MODE=raw # raw | answer(旧模式) ENABLE_RERANKER=true # true | false ENABLE_REDIS_CONTEXT=true # true | false ``` 每个新能力可独立关闭,回退到当前行为。 --- ## 8. 实施计划 ### 阶段一:基础设施(1天) | # | 任务 | 文件 | 预估 | |---|------|------|------| | 1.1 | 安装 ioredis 依赖 | package.json | 5min | | 1.2 | 新建 redisClient.js,实现连接管理 + pushMessage + getRecentHistory | services/redisClient.js | 2h | | 1.3 | .env 增加 Redis 配置 | .env, .env.example | 10min | | 1.4 | 服务器安装 Redis(宝塔一键装) | 运维 | 30min | | 1.5 | 验证 Redis 连接 + 读写 | 手动测试 | 30min | ### 阶段二:Redis 对话存储接入(0.5天) | # | 任务 | 文件 | 预估 | |---|------|------|------| | 2.1 | persistUserSpeech 增加 Redis 写入 | nativeVoiceGateway.js | 30min | | 2.2 | persistAssistantSpeech 增加 Redis 写入 | nativeVoiceGateway.js | 30min | | 2.3 | resolveReply 中的 getRecentMessages 改为优先读 Redis | realtimeDialogRouting.js | 1h | | 2.4 | KB 缓存迁移到 Redis(替代内存 Map) | toolExecutor.js, redisClient.js | 1h | ### 阶段三:KB 纯检索 + 重排(1.5天) | # | 任务 | 文件 | 预估 | |---|------|------|------| | 3.1 | 方舟 API 验证:确认 references 字段是否可用 | 手动测试 | 2h | | 3.2 | 新建 kbRetriever.js:retrieveChunks 实现 | services/kbRetriever.js | 3h | | 3.3 | 方舟部署重排模型,获取 endpoint_id | 方舟控制台 | 1h | | 3.4 | kbRetriever.js:rerankChunks 实现 | services/kbRetriever.js | 2h | | 3.5 | kbRetriever.js:buildRagPayload + searchAndRerank 主流程 | services/kbRetriever.js | 2h | | 3.6 | toolExecutor.js 改造:searchKnowledge 调用 kbRetriever | toolExecutor.js | 2h | ### 阶段四:S2S 注入适配(0.5天) | # | 任务 | 文件 | 预估 | |---|------|------|------| | 4.1 | resolveReply 适配新的 ragItems 格式(多片段 + 上下文) | realtimeDialogRouting.js | 1h | | 4.2 | sendExternalRag 验证多 items 发送 | nativeVoiceGateway.js | 1h | | 4.3 | hit/no-hit 判断改为 reranker score 阈值 | kbRetriever.js | 1h | ### 阶段五:测试 + 部署(1天) | # | 任务 | 说明 | 预估 | |---|------|------|------| | 5.1 | 单元测试:kbRetriever、redisClient | tests/ | 2h | | 5.2 | 集成测试:端到端语音对话验证 | 真机测试 | 2h | | 5.3 | A/B 对比:旧模式 vs 新模式(延迟、回答质量) | 测试脚本 | 2h | | 5.4 | 部署到生产 | deploy 脚本 | 1h | **总工期预估:4-5 天** --- ## 9. 验证标准 ### 功能验证(必须全部通过) | # | 场景 | 期望结果 | |---|------|----------| | V1 | 问"小红怎么吃" | S2S 基于 KB 原始片段回答,包含正确服用方法 | | V2 | 连续追问"那大白呢""小白呢" | S2S 结合上下文理解"那"指的是什么,正确回答 | | V3 | 问"你们公司正规吗" | 品牌保护兜底正常触发 | | V4 | Redis 断开时问产品问题 | 自动降级到 MySQL,不报错 | | V5 | 重排模型超时 | 跳过重排,使用原始检索结果 | | V6 | KB 检索无结果 | 走 honest_fallback 或 upstream_chat | ### 性能验证 | 指标 | 当前基线 | 目标 | |------|----------|------| | KB 查询延迟(P50) | ~2300ms | <1200ms | | KB 查询延迟(P95) | ~4500ms | <2000ms | | 上下文读取延迟 | ~100ms (MySQL) | <10ms (Redis) | | 多轮追问准确率 | ~40%(无上下文) | >80%(5轮上下文) | --- ## 10. 后续迭代方向 | 方向 | 说明 | 优先级 | |------|------|--------| | **语义缓存** | 用 embedding 相似度判断是否命中缓存,而非精确 query 匹配 | P1 | | **Redis Streams** | 对话历史用 Streams 代替 List,支持消费者组、重放 | P2 | | **Hybrid Search** | 向量检索 + BM25 关键词检索融合,提升召回率 | P1 | | **动态 top_k** | 根据 query 复杂度自动调整检索数量 | P3 | | **多轮 query 改写** | 基于 Redis 上下文做指代消解("那个"→"小红") | P1 |