Files
bigwo/test2/server/docs/conversation-long-term-memory.md
User fe25229de7 feat: conversation long-term memory + fix source ENUM bug
- New: conversationSummarizer.js (LLM summary every 3 turns, loadBestSummary, persistFinalSummary)
- db/index.js: conversation_summaries table, upsertConversationSummary, getSessionSummary
- redisClient.js: setSummary/getSummary (TTL 2h)
- nativeVoiceGateway.js: _turnCount tracking, trigger summarize, persist on close
- realtimeDialogRouting.js: inject summary context, reduce history 5->3 rounds
- Fix: messages source ENUM missing 'search_knowledge' causing chat DB writes to fail
2026-04-03 10:19:16 +08:00

18 KiB
Raw Blame History

对话长期记忆方案:会话内摘要 + 跨会话衔接

一、背景与问题

1.1 现状

当前对话记忆体系有 4 层,均为短期记忆:

层级 机制 存储 有效窗口 代码位置
L1 Redis 对话历史 最近5轮(10条) 30分钟TTL redisClient.js HISTORY_MAX_LEN=10
L2 MySQL 全量记录 无限 永久 db.addMessage()
L3 contextKeywordTracker 最近8个关键词 30分钟TTL contextKeywordTracker.js
L4 KB话题记忆 最后1个话题 60秒TTL session._lastKbTopic
L5 handoffSummary 确定性摘要(最后8条) 会话生命周期 loadHandoffSummaryForVoice()

1.2 双重核心问题

问题 A会话内遗忘第6轮起丢失

S2S 和 KB 检索实际使用的上下文只有最近5轮原文getRecentHistory(sessionId, 5)第6轮开始的信息完全丢失。

问题 B会话重启时上下文断裂

断裂场景 触发条件 当前结果
① 同 sessionId 快速重连 网络抖动/页面刷新30min内 Redis 历史还在,但 session 内存状态全丢(_lastKbTopiccontextKeywordTracker_turnCount
② 同 sessionId 超时重连 用户隔一段时间再来30min后 Redis TTL 过期,只剩 MySQL + loadHandoffSummaryForVoice确定性摘要只取最后8条质量差
③ PM2 重启/崩溃恢复 部署或进程崩溃 所有 session 内存清零Redis 还在
④ voice → chat 模式切换 用户从语音切文字 chat.jsloadHandoffMessages 做交接,但也是确定性摘要

注意:新 sessionId = 全新对话,不需要跨 session 记忆继承。

关键差距MySQL 存了全量原文却从未被用于生成高质量摘要;buildDeterministicHandoffSummary 只做模式提取("当前问题 + 上一轮关注 + 已给信息"),压缩比低、语义丢失大。

1.3 受影响场景

场景 当前体验 占比估算
用户聊了8轮后追问"刚才那个产品" AI 丢失上下文,回答偏题 ~20-30% 深度对话
长对话中多产品对比 早期提到的产品信息丢失 ~15%
网络抖动后重连继续聊 内存状态丢失KB话题追踪断裂 ~10%
隔30分钟再打开继续咨询 Redis过期只剩粗糙的确定性摘要 ~10%
voice→chat 切换后追问语音聊的内容 只有确定性摘要,细节丢失 ~5%

二、设计目标

解决两个并列的核心问题,缺一不可:

目标 描述 衡量标准
G1会话内长记忆 单次对话超过5轮后仍能准确关联早期话题 10轮后追问命中率 ≥ 80%
G2会话重启上下文衔接 同sessionId重连/PM2重启后无缝延续对话 重连后首轮上下文关联率 ≥ 90%

三、记忆分层架构(三层设计)

┌─────────────────────────────────────────────────────┐
│  L1 热记忆Redis                                  │
│  · 最近3轮原文精确上下文                          │
│  · 当前会话摘要LLM生成每3轮更新                 │
│  · TTL: 2小时                                        │
├─────────────────────────────────────────────────────┤
│  L2 温记忆MySQL conversation_summaries 表)         │
│  · 每个 session 的最终摘要(会话结束时写入)          │
│  · 永久存储,同 sessionId 重连时可恢复                │
├─────────────────────────────────────────────────────┤
│  L3 冷记忆MySQL messages 表,现有)                 │
│  · 全量原文记录                                       │
│  · 仅在 L1+L2 都缺失时作为降级数据源                  │
│  · 通过 buildDeterministicHandoffSummary 提取          │
└─────────────────────────────────────────────────────┘

加载优先级L1(Redis摘要) → L2(MySQL摘要) → L3(MySQL原文确定性提取)


四、方案详细设计

4.1 支柱一:会话内摘要(解决 G1

触发机制

用户说话 → persistUserSpeech → session._turnCount++
AI回复   → persistAssistantSpeech ──→ _turnCount % 3 === 0 ?
                                      ├─ Yes → 异步 summarize() [不阻塞]
                                      │         ↓
                                      │    LLM(旧摘要 + 最近3轮原文)
                                      │         ↓
                                      │    Redis.setSummary(sessionId)
                                      │    MySQL.upsertSummary(sessionId) // 双写
                                      └─ No → 继续

滚雪球数据流

Round 1-3: 原文正常存Redis
Round 3 完成后:
  → LLM(Round 1-3 原文) → 生成摘要 S1 → 存 Redis + MySQL
  → 后续上下文 = S1 + 最近3轮原文

Round 6 完成后:
  → LLM(S1 + Round 4-6 原文) → 生成摘要 S2 → 存 Redis + MySQL
  → 后续上下文 = S2 + 最近3轮原文

...以此类推(每次摘要都包含之前所有轮次的压缩信息)

上下文注入

// resolveReply 中
const summary = await loadBestSummary(sessionId);
const recentHistory = await redisClient.getRecentHistory(sessionId, 3);

const context = [];
if (summary) {
  context.push({ role: 'system', content: `[历史对话摘要] ${summary}` });
}
context.push(...recentHistory.map(item => ({ role: item.role, content: item.content })));

4.2 支柱二:会话重启衔接(解决 G2

新增 MySQL 表:conversation_summaries

CREATE TABLE conversation_summaries (
  id INT AUTO_INCREMENT PRIMARY KEY,
  session_id VARCHAR(128) NOT NULL,
  user_id VARCHAR(128),
  summary TEXT NOT NULL,             -- LLM 生成的摘要
  turn_count INT DEFAULT 0,          -- 该 session 的总轮次
  topics JSON,                       -- 提取的话题标签 ["活力健","基础三合一","一成系统"]
  created_at BIGINT,
  updated_at BIGINT,
  UNIQUE INDEX idx_session (session_id),
  INDEX idx_user_time (user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

断裂场景修复方案

场景 修复策略 加载顺序
① 同 sessionId 快速重连30min内 Redis 摘要仍在,直接使用 L1 Redis
② 同 sessionId 超时重连30min后 Redis 过期,从 MySQL conversation_summaries 加载该 sessionId 的摘要 L2 MySQL摘要
③ PM2 重启 Redis 还在Redis 独立进程),从 Redis 加载Redis 也丢了则走 L2 L1 → L2
④ voice→chat 模式切换 chat.jsloadHandoffMessages 改为优先从 conversation_summaries 加载 LLM 摘要 L2 MySQL摘要

核心函数:loadBestSummary(sessionId)

async function loadBestSummary(sessionId) {
  // 1. 尝试 Redis最快~1ms
  const redisSummary = await redisClient.getSummary(sessionId);
  if (redisSummary) return redisSummary;

  // 2. 尝试 MySQL 当前 session 的摘要(~5ms
  const sessionSummary = await db.getSessionSummary(sessionId);
  if (sessionSummary) {
    // 回填 Redis 加速后续读取
    await redisClient.setSummary(sessionId, sessionSummary.summary);
    return sessionSummary.summary;
  }

  // 3. 降级MySQL 原文 → 确定性摘要(现有逻辑)
  return null; // 交给现有的 loadHandoffSummaryForVoice 兜底
}

会话结束时持久化

session.client.on('close') 中触发:

// nativeVoiceGateway.js client close handler
session.client.on('close', () => {
  // ...existing cleanup...

  // 持久化最终摘要到 MySQL异步不阻塞关闭流程
  persistFinalSummary(session).catch(err => {
    console.warn('[NativeVoice] persistFinalSummary failed:', err.message);
  });
});

async function persistFinalSummary(session) {
  if (!session._turnCount || session._turnCount < 2) return; // 太短的对话不存
  
  // 优先用已有的 LLM 摘要
  let summary = await redisClient.getSummary(session.sessionId);
  
  // 如果还没生成过摘要对话不足3轮立刻生成一次
  if (!summary && session._turnCount >= 2) {
    const history = await redisClient.getRecentHistory(session.sessionId, 5);
    if (history && history.length >= 2) {
      summary = await summarizer.summarizeConversation(null, history);
    }
  }
  
  if (summary) {
    await db.upsertConversationSummary(session.sessionId, session.userId, summary, {
      turnCount: session._turnCount || 0,
      topics: extractTopicTags(summary), // 从摘要中提取话题标签
    });
  }
}

4.3 话题标签提取(增强跨会话关联)

从摘要中用正则提取产品名/系统名/话题关键词,存入 topics JSON 字段:

function extractTopicTags(summary) {
  const tags = new Set();
  // 复用现有的 knowledgeKeywords 和 knowledgeQueryResolver
  const { TRACKER_KEYWORD_GROUPS, buildKeywordRegex } = require('./knowledgeKeywords');
  for (const group of TRACKER_KEYWORD_GROUPS) {
    const regex = buildKeywordRegex(group, 'gi');
    const matches = summary.match(regex);
    if (matches) matches.forEach(m => tags.add(m));
  }
  return [...tags].slice(0, 10);
}

用途:未来可按 topics 做更精准的跨会话记忆匹配(如用户上次聊的是"活力健",这次又提"活力健",优先加载那次摘要)。


五、摘要 Prompt 设计

会话内摘要 Prompt每3轮触发

你是对话摘要助手。将以下对话历史浓缩为简短摘要,必须保留:
1. 用户询问过的所有产品名称和具体问题
2. AI给出的关键数字剂量、价格、数量等
3. 用户表达的偏好或关注点
4. 未解决的问题或用户的疑虑

规则只输出摘要正文不加前缀或标题。150字以内。用"用户"和"助手"指代双方。

跨会话注入时的格式

用户上次({时间差描述})的对话摘要:{summary}

例如:用户上次2小时前的对话摘要用户咨询了活力健和基础三合一的区别助手介绍了两者成分差异。用户对活力健的服用方法比较关心每天2次每次1条。用户还问了适不适合高血压人群。


六、关键设计决策

决策点 建议值 理由
摘要触发 persistAssistantSpeech 后异步 不阻塞对话链路,用户无感知
摘要模型 复用 Seed-2.0-lite (ep-20260320175538-lcg7g) 已部署,延迟低(~1-2s成本低
摘要 max_tokens 120 控制摘要长度,避免注入过长上下文
摘要 thinking { type: 'enabled' } 异步执行不阻塞,开启推理提升摘要质量
Redis key voice:summary:{sessionId} 与对话历史同级
Redis TTL 7200秒2小时 长于对话历史的30分钟
MySQL 双写 每次摘要同时写 Redis + MySQL Redis 快读 + MySQL 持久化
原文保留 摘要后仍保留最近3轮原文 最近对话需精确上下文
最小对话门槛 _turnCount >= 2 才持久化 太短的对话(打招呼就走)不值得存
失败降级 LLM摘要失败 → 确定性摘要 → 5轮原文 三级降级保证不影响主链路

七、涉及文件修改

文件 修改内容 复杂度
新增 services/conversationSummarizer.js LLM 摘要生成、异步触发、loadBestSummarypersistFinalSummary、话题标签提取
db/index.js 新增 conversation_summaries 建表 + upsertConversationSummary / getSessionSummary
services/redisClient.js 新增 setSummary() / getSummary()
services/nativeVoiceGateway.js session 增加 _turnCountpersistUserSpeech 计轮;persistAssistantSpeech 后触发摘要;client.closepersistFinalSummary
services/realtimeDialogRouting.js resolveReply 调用 loadBestSummary 注入上下文;getRecentHistory 轮数 5→3
services/kbRetriever.js searchAndRerank 的 conversationHistory 也注入摘要
routes/chat.js loadHandoffMessages 优先从 conversation_summaries 加载 LLM 摘要

新增文件结构:conversationSummarizer.js

exports:
  - triggerSummarizeIfNeeded(session, sessionId)    // 检查轮次 → 异步触发
  - summarizeConversation(existingSummary, messages) // 调 LLM 生成摘要
  - loadBestSummary(sessionId)                       // 三级降级加载最优摘要
  - persistFinalSummary(session)                    // 会话结束时持久化
  - extractTopicTags(text)                          // 从文本提取话题标签

constants:
  - SUMMARIZE_EVERY_N_TURNS = 3
  - SUMMARY_MAX_TOKENS = 120
  - SUMMARY_MODEL = process.env.VOLC_ARK_KB_MODEL
  - MIN_TURNS_TO_PERSIST = 2

八、成本与性能影响

成本

指标
会话内摘要频率 每3轮1次 ≈ 平均每会话1-3次
会话结束摘要 每会话最多+1次如果轮次不足3
单次摘要 token 输入400 + 输出120 ≈ 520 tokens
Seed-2.0-lite 价格 ~0.001元/千tokens估算
单会话增加成本 ~0.001-0.004元

性能

指标 影响
对话响应延迟 0ms 增加(纯异步 fire-and-forget
会话启动延迟 +5-10msloadBestSummary 读 Redis/MySQL
Redis 读取 +1次 getSummary/次对话轮(~1ms
Redis 存储 +1 key/session~300 bytes
MySQL 存储 +1 row/session~500 bytes
MySQL 查询 新 session 启动时 1次按 userId 查最近摘要,~5ms有索引
LLM 调用(后台) ~1-2s/次,不阻塞用户

九、风险评估

风险 概率 影响 缓解措施
摘要丢失关键产品名 Prompt 强调保留产品名+数字;话题标签做辅助索引
摘要引入幻觉 max_tokens=120摘要只做压缩不做推理[历史对话摘要] 前缀标识
LLM 调用失败 三级降级Redis摘要 → MySQL摘要 → 确定性摘要 → 5轮原文
MySQL 写入失败 摘要双写 Redis+MySQL任一成功即可下次触发时重试
与 contextKeywordTracker 冲突 互补摘要提供语义上下文tracker 提供精确关键词路由
用户隐私(摘要留存) - - 摘要遵循现有 messages 的同等存储策略deleteSession 时同步删除摘要

十、实施计划

P0 - 核心双支柱1.5天)

  1. db/index.js:新增 conversation_summaries 表 + CRUD 方法
  2. redisClient.js:新增 setSummary / getSummary
  3. 新建 conversationSummarizer.jsLLM 摘要 + loadBestSummary + persistFinalSummary
  4. nativeVoiceGateway.js:轮次计数 + 摘要触发 + close 时持久化
  5. realtimeDialogRouting.jsresolveReply 注入摘要上下文

P1 - 增强0.5天)

  1. kbRetriever.jsKB 检索的 conversationHistory 也注入摘要
  2. routes/chat.jsvoice→chat 切换时优先加载 LLM 摘要
  3. 话题标签提取 + extractTopicTags

P2 - 高级(未来)

  1. 摘要质量监控(定期抽检摘要 vs 原文的信息保留率)
  2. 用户画像标签聚合(高频话题、偏好产品、健康关注点)——如未来需要跨 session 记忆再启用

十一、验证方案

会话内记忆测试

1. 模拟6轮对话 → 验证第3轮后生成摘要
2. 验证摘要正确保留产品名和关键数字
3. 第7轮追问第1轮的产品 → 验证上下文关联正确
4. LLM 摘要失败时 → 验证降级到5轮原文模式

跨会话衔接测试

5. 完成6轮对话 → 断开 → 同 sessionId 重连 → 验证首轮即有摘要上下文
6. 完成6轮对话 → 等 Redis 过期 → 重连 → 验证从 MySQL 加载摘要
8. PM2 重启 → 重连 → 验证摘要恢复
9. voice→chat 切换 → 验证 chat 模式拿到语音会话摘要

降级测试

10. Redis 不可用 → 验证从 MySQL 加载 + 主链路不受影响
11. MySQL 摘要表为空 → 验证降级到确定性摘要
12. LLM 服务不可用 → 验证降级到5轮原文模式

十二、配置项

环境变量 默认值 说明
ENABLE_CONVERSATION_SUMMARY true 总开关
SUMMARY_EVERY_N_TURNS 3 每N轮触发一次摘要
SUMMARY_MAX_TOKENS 120 摘要最大 token 数
SUMMARY_REDIS_TTL_SECONDS 7200 摘要 Redis TTL
SUMMARY_MODEL VOLC_ARK_KB_MODEL 摘要使用的模型 endpoint
SUMMARY_MIN_TURNS_TO_PERSIST 2 最小轮次门槛,低于此值不持久化

十三、与现有机制的关系

现有机制 变更 共存关系
redisClient.pushMessage / getRecentHistory getRecentHistory 轮数从5降到3 摘要覆盖早期轮次原文只需最近3轮
contextKeywordTracker 不变 互补tracker 做精确关键词路由,摘要做语义上下文
session._lastKbTopic 不变 互补60s 窗口保护仍需要,摘要不能替代实时话题追踪
loadHandoffSummaryForVoice 改为降级备选 LLM 摘要不可用时才调用确定性摘要
buildDeterministicHandoffSummary 保留作为 L3 降级 三级降级的最后一道防线
withHandoffSummary loadBestSummary 替代 新函数统一管理所有来源的摘要注入