426 lines
18 KiB
Markdown
426 lines
18 KiB
Markdown
|
|
# 对话长期记忆方案:会话内摘要 + 跨会话衔接
|
|||
|
|
|
|||
|
|
## 一、背景与问题
|
|||
|
|
|
|||
|
|
### 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 内存状态全丢(`_lastKbTopic`、`contextKeywordTracker`、`_turnCount`) |
|
|||
|
|
| ② 同 sessionId 超时重连 | 用户隔一段时间再来(30min后) | Redis TTL 过期,只剩 MySQL + `loadHandoffSummaryForVoice`(确定性摘要,只取最后8条,质量差) |
|
|||
|
|
| ③ PM2 重启/崩溃恢复 | 部署或进程崩溃 | 所有 session 内存清零,Redis 还在 |
|
|||
|
|
| ④ voice → chat 模式切换 | 用户从语音切文字 | `chat.js` 有 `loadHandoffMessages` 做交接,但也是确定性摘要 |
|
|||
|
|
|
|||
|
|
> **注意**:新 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轮原文
|
|||
|
|
|
|||
|
|
...以此类推(每次摘要都包含之前所有轮次的压缩信息)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 上下文注入
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// 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`
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
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.js` 的 `loadHandoffMessages` 改为优先从 `conversation_summaries` 加载 LLM 摘要 | L2 MySQL摘要 |
|
|||
|
|
|
|||
|
|
#### 核心函数:`loadBestSummary(sessionId)`
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
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')` 中触发:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// 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 字段:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
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 摘要生成、异步触发、`loadBestSummary`、`persistFinalSummary`、话题标签提取 | 中 |
|
|||
|
|
| `db/index.js` | 新增 `conversation_summaries` 建表 + `upsertConversationSummary` / `getSessionSummary` | 中 |
|
|||
|
|
| `services/redisClient.js` | 新增 `setSummary()` / `getSummary()` | 低 |
|
|||
|
|
| `services/nativeVoiceGateway.js` | session 增加 `_turnCount`;`persistUserSpeech` 计轮;`persistAssistantSpeech` 后触发摘要;`client.close` 时 `persistFinalSummary` | 低 |
|
|||
|
|
| `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-10ms(`loadBestSummary` 读 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.js`:LLM 摘要 + `loadBestSummary` + `persistFinalSummary`
|
|||
|
|
4. `nativeVoiceGateway.js`:轮次计数 + 摘要触发 + close 时持久化
|
|||
|
|
5. `realtimeDialogRouting.js`:`resolveReply` 注入摘要上下文
|
|||
|
|
|
|||
|
|
### P1 - 增强(0.5天)
|
|||
|
|
|
|||
|
|
6. `kbRetriever.js`:KB 检索的 conversationHistory 也注入摘要
|
|||
|
|
7. `routes/chat.js`:voice→chat 切换时优先加载 LLM 摘要
|
|||
|
|
8. 话题标签提取 + `extractTopicTags`
|
|||
|
|
|
|||
|
|
### P2 - 高级(未来)
|
|||
|
|
|
|||
|
|
9. 摘要质量监控(定期抽检摘要 vs 原文的信息保留率)
|
|||
|
|
10. 用户画像标签聚合(高频话题、偏好产品、健康关注点)——如未来需要跨 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` 替代 | 新函数统一管理所有来源的摘要注入 |
|