289 lines
15 KiB
Markdown
289 lines
15 KiB
Markdown
# 用户级长期记忆(User Long-Term Memory)— 开发规格
|
||
|
||
> 状态:**已设计、待开发**(用户已在对话中确认全部决策)。
|
||
> 目标读者:负责实现本功能的下一个会话 / 开发代理。
|
||
> 关联:本目录 `docs/DECISIONS.md`(新增决策 D17 之外的实现细节在此)。
|
||
|
||
---
|
||
|
||
## 1. 背景与目标
|
||
|
||
当前 bot 每个请求都拉取该会话最近 20 条 `message`(`MAX_HISTORY=20`)注入 prompt,但:
|
||
|
||
- **没有跨会话的用户级记忆**——`ai/memory/memory.ts` 只有空接口 `NoopMemory`,从未落地。
|
||
- 用户换了新会话(或同一会话被 20 条窗口冲掉后)就“失忆”,同一问题被反复重新生成近似答案。
|
||
|
||
本功能为每个**用户**(而非会话)维护一份可跨会话、跨渠道复用的记忆,让 bot:
|
||
1. 记得该用户的语言偏好、关键事实、最近问题、感兴趣的商品。
|
||
2. 回答更连贯、更个性化;并为后续「重复问题探测」「转人工触发」提供数据底子。
|
||
|
||
### 关键设计原则
|
||
|
||
- **用户级,不是会话级**——记忆锚点用 `bot_user.id`(bigint),不是 `conversation.id`。
|
||
- **跨渠道通用**——`bot_user` 表已有 `UNIQUE(channel, external_user_id)`,Telegram / Shopee / Lazada 各自独立分区、互不串号。记忆 value 存「纯事实」而非「渠道话术」,接 Shopee/Lazada adapter 时零改造即可复用。
|
||
- **记忆失败绝不打断客服回复**——所有记忆读/写/摘要调用都包 try/catch,失败则降级(保留旧值 / 跳过),主回复路径不受影响。
|
||
- **不引 pgvector、不引第二个 LLM 依赖**——复用现有 Postgres + 现有 LiteLLM 网关。
|
||
|
||
---
|
||
|
||
## 2. 已确认的决策(来自用户)
|
||
|
||
| # | 决策点 | 结论 |
|
||
|---|---|---|
|
||
| 1 | 记忆方案 | 复用现有 Postgres,新增 `user_memory` 表(否 mem0 HTTP / 否 pgvector) |
|
||
| 2 | 摘要写回策略 | **加一个廉价 LLM 调用提取「用户关键事实摘要」**(否纯规则化、否只读不写) |
|
||
| 3 | 摘要触发频率 | **仅当“新事实出现”才抽取**(`lang` 变化 / 出现新 SKU / 用户自报身份等),否每轮、否每 N 条 |
|
||
| 4 | 摘要模型 | **config 可配**:新增 `SUMMARY_MODEL` env,默认复用主模型 `mem0-openai` |
|
||
| 5 | 摘要是否阻塞回复 | **异步后台**:主回复先发,摘要稍后落库(否同步阻塞) |
|
||
| 6 | 最近问题队列长度 | **5 条**(`last_queries`) |
|
||
| 7 | userId 来源 | 依赖 `saveExchange` 返回值新增 `userId` |
|
||
| 8 | 是否 push + 部署 | 是:实现后 push gitea,触发 DSM 重建部署 |
|
||
|
||
> 触发频率第 3 点会引出一个实现细节:摘要「异步后台」+「仅新事实触发」意味着需要一个轻量检测函数判断“本轮是否值得更新记忆”(见 §5.3)。
|
||
|
||
---
|
||
|
||
## 3. 数据结构(migration 004)
|
||
|
||
在 `src/db/db.ts` 的 `MIGRATIONS` 数组**末尾追加**(版本号 = 数组位置+1,与注释标号无关;截至实现时数组有 5 个元素,故新表实际为 **006/007**):
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS user_memory (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
user_id BIGINT NOT NULL REFERENCES bot_user(id) ON DELETE CASCADE,
|
||
key TEXT NOT NULL,
|
||
value TEXT NOT NULL,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
UNIQUE (user_id, key)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_user_memory_user ON user_memory (user_id);
|
||
```
|
||
|
||
- 自迁移机制已存在:`applyMigrations` 按 `schema_migrations.version` 幂等跳过,老库新库都能自动补表,**无需手跑 psql**。
|
||
- key 采用命名空间约定(见 §4)。
|
||
|
||
### key 命名空间约定
|
||
|
||
| key | 含义 | 写入者 |
|
||
|---|---|---|
|
||
| `lang` | 用户语言偏好(`zh` / `en` / `ms` …) | 规则化:`preferredLanguage` 变化即更新 |
|
||
| `summary` | LLM 提取的关键事实摘要(≤300 字,一段文字) | `MemoSummarizer` |
|
||
| `last_queries` | 最近 5 条用户问题,JSON 字符串数组 | 规则化:每条用户消息后 push(截断) |
|
||
| `interest:<sku>` | 对某 SKU 的兴趣/最近交互时间戳 | 规则化/后续(Phase 2 可选,本规格**不强制实现**,留接口即可) |
|
||
|
||
---
|
||
|
||
## 4. 分层与文件改动清单
|
||
|
||
### 4.1 `src/db/db.ts` — 数据层
|
||
|
||
1. `MIGRATIONS` 追加 §3 两条 SQL。
|
||
2. `saveExchange` 返回类型 `{ conversationId, userId }`(新增 `userId`)。实现里 upsert user 后已拿到 `userId`,`RETURNING id` 之后一并 return。
|
||
3. 新增三个 Db 方法(加进 `Db` interface 及实现对象):
|
||
- `memorySet(userId: number, key: string, value: string): Promise<void>` — `INSERT ... ON CONFLICT (user_id, key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()`。
|
||
- `memoryGet(userId: number, key: string): Promise<string | undefined>`。
|
||
- `memoryGetAll(userId: number): Promise<Record<string, string>>` — 全量读出拼对象。
|
||
|
||
> 记忆查询也可由 `SqlMemory` 直接持 `Db` 或 `pool` 完成。**推荐**:`SqlMemory` 持 `Db` 实例,调用上述三个方法——保持 SQL 全在 `db.ts` 单点维护,`memory.ts` 只演纯逻辑。若你更倾向把 SQL 放 memory 类,须在实现说明里注明偏离。
|
||
|
||
### 4.2 `src/ai/memory/memory.ts` — 记忆层
|
||
|
||
把 `NoopMemory` 替换/新增为真正实现:
|
||
|
||
```ts
|
||
export interface ConversationMemory {
|
||
remember(userId: number, key: string, value: string): Promise<void>;
|
||
recall(userId: number, key: string): Promise<string | undefined>;
|
||
recallAll(userId: number): Promise<Record<string, string>>;
|
||
}
|
||
|
||
export class SqlMemory implements ConversationMemory {
|
||
constructor(private db: Db) {}
|
||
remember(userId, key, value) { return this.db.memorySet(userId, key, value); }
|
||
recall(userId, key) { return this.db.memoryGet(userId, key); }
|
||
recallAll(userId) { return this.db.memoryGetAll(userId); }
|
||
}
|
||
```
|
||
|
||
- 保留 `NoopMemory`(测试/禁用场景用),但生产走 `SqlMemory`。
|
||
- **接口签名改动**:原占位接口用 `conversationId`,本实现改 `userId`——这是刻意的语义升级,记得同步所有引用。
|
||
|
||
### 4.3 `src/ai/memory/summarizer.ts` — 新增,摘要层
|
||
|
||
核心:一个 `MemoSummarizer` 类,用廉价 LLM 把「旧摘要 + 最近问题 + 本轮对话」压缩为 ≤300 字的新摘要。
|
||
|
||
```ts
|
||
export interface Summarizer {
|
||
extract(args: {
|
||
priorSummary?: string; // 旧 summary
|
||
priorQueries: string[]; // 旧 last_queries(最多 5)
|
||
currentTurn: string; // 本轮用户消息(可能含 lang/sku 线索)
|
||
}): Promise<string | undefined>; // 失败返回 undefined(调用侧保旧值)
|
||
}
|
||
```
|
||
|
||
实现要点:
|
||
- 用 `LlmProvider` 的**小模型通道**(§4.5 的 `chatSmall`)发一次请求。
|
||
- prompt 要求输出**纯摘要文字**(不要 JSON 包裹,降低解析成本;或 JSON `{summary}` 二选一,**选定并写死**,建议纯文字)。
|
||
- 约束:≤300 字、只保留「关键事实 / 偏好 / 已购买或咨询过的商品 / 语言」,丢弃寒暄与重复。
|
||
- **失败兜底**:try/catch,任何异常返回 `undefined`,调用侧保留旧 summary。
|
||
- **去重语义**:摘要里已有的旧事实,遇到新信息要合并而非简单叠加(在 prompt 里明确指示)。
|
||
|
||
### 4.4 `src/ai/agent/agent.ts` — Agent 接入
|
||
|
||
1. 构造函数增加参数:
|
||
```ts
|
||
constructor(
|
||
private llm: LlmProvider,
|
||
private db: Db,
|
||
private retriever: Retriever = new NoopRetriever(),
|
||
private memory: ConversationMemory = new NoopMemory(),
|
||
private summarizer?: Summarizer,
|
||
) {}
|
||
```
|
||
2. `respond(args)` 的入参从 `conversationId` 增加 `userId`(与 `preferredLanguage` 平级)。
|
||
3. **读记忆**:`const mem = await this.memory.recallAll(args.userId)`,若 `mem.summary` 或 `mem.lang` 存在,拼一块注入 system prompt,例如:
|
||
|
||
```ts
|
||
const memoryBlock = mem.summary
|
||
? `\n\nUser memory (known facts about this customer — use to personalise, never fabricate beyond it):\n${mem.summary}`
|
||
: "";
|
||
```
|
||
注入位置:`buildSystemPrompt` 之后,product facts 之前(记忆是“关于这个用户的先验”)。**需要给 `buildSystemPrompt` 增加一个可选 `userMemory?` 参数**,或直接在 agent 里拼接字符串(推荐后者,避免动 prompt 结构太多;二选一并写清)。
|
||
4. **生成回复**:照旧调 `this.llm.chat(...)`。
|
||
5. **更新记忆(回复之后)**:
|
||
- 规则化更新 `lang`:若 `args.preferredLanguage` 与 `mem.lang` 不同 → `memory.remember(userId, 'lang', ...)`。
|
||
- 规则化更新 `last_queries`:push 本轮 `userText`,截断保留最近 5 条,`JSON.stringify` 存回。
|
||
- **摘要触发**:调用 §5.3 的 `shouldSummarize(...)` 判断,若为真且 `summarizer` 存在 → 异步抽取 → `memory.remember(userId, 'summary', newSummary)`。
|
||
|
||
### 4.5 `src/ai/providers/llm.ts` + `src/config/config.ts` — 模型通道
|
||
|
||
- `LlmProvider` 接口新增可选方法(或独立 `chatSmall` 方法):
|
||
```ts
|
||
chatSmall(args: { system: string; messages: {role:"user"|"assistant";content:string}[] }): Promise<string>;
|
||
```
|
||
`OpenAiCompatibleProvider` 里 `chatSmall` 用 `cfg.summaryModel`(默认回退 `cfg.llmModel`)作为 `model`,其余与 `chat` 相同(timeout 可短一点,如 30s,因摘要是后台任务)。
|
||
- `config.ts` 的 schema 新增:
|
||
```ts
|
||
summaryModel: z.string().default(""),
|
||
summaryEnabled: z.coerce.boolean().default(true),
|
||
```
|
||
`loadConfig` 里读 `env.SUMMARY_MODEL`、`env.SUMMARY_ENABLED`;`summaryModel` 为空则摘要模型 = 主模型。
|
||
- `.env.example` 补充注释 + 两行可选变量(`SUMMARY_MODEL` / `SUMMARY_ENABLED`)。`../SECRETS.md`(仓库根)bot 段补说明:可选指定摘要用小模型。
|
||
|
||
### 4.6 `src/core/message-service.ts` — 接线
|
||
|
||
- `saveExchange` 现在返回 `{ conversationId, userId }`,`handle` 里把 `userId` 传给 `agent.respond({ ..., userId })`。
|
||
|
||
### 4.7 `src/app/server.ts` — 组装(唯一实例化点)
|
||
|
||
第 33 行附近改为:
|
||
|
||
```ts
|
||
const llm = createProvider(cfg);
|
||
const memory = cfg.summaryEnabled ? new SqlMemory(db) : new NoopMemory();
|
||
const summarizer = cfg.summaryEnabled ? new MemoSummarizer(llm, logger) : undefined;
|
||
const agent = new Agent(llm, db, new CatalogRetriever(), memory, summarizer);
|
||
```
|
||
|
||
(`SqlMemory`、`MemoSummarizer` 需 import。`logger` 可在 summarizer 构造时传入用于告警。)
|
||
|
||
---
|
||
|
||
## 5. 关键实现细节
|
||
|
||
### 5.1 用户语言判定(供 `lang` 记忆)
|
||
|
||
优先级:本轮 `preferredLanguage`(= Telegram `language_code`)> 已有 `mem.lang` > 由 `userText` 启发式判断(中文/马来/英文关键词或脚本范围检测)。实现里至少做前两级,第三级作为可选增强。
|
||
|
||
### 5.2 最近问题队列(供摘要输入 + 未来重复探测)
|
||
|
||
- key `last_queries`,value = `JSON.stringify(string[])`,最多 5 条,FIFO。
|
||
- 每条用户消息后更新(**不含** /start /help 命令文本;仅自由文本)。
|
||
- 只存用户原文截断到 200 字符/条,防 prompt 膨胀。
|
||
|
||
### 5.3 摘要触发判定 `shouldSummarize`
|
||
|
||
决定“本轮是否值得花一次 LLM 摘要”。推荐规则(实现时写清、可微调):
|
||
|
||
```ts
|
||
shouldSummarize(mem, userText, args): boolean {
|
||
// 1. 语言偏好变化 → 值得
|
||
if (langChanged) return true;
|
||
// 2. 出现新 SKU 令牌(/\[A-Z0-9]{2,}\d{2,}/i 匹配且此前 last_queries/摘要未出现)→ 值得
|
||
if (newSkuFound) return true;
|
||
// 3. 用户自报身份/需求关键词(我是/我叫/需要/想买/订单/退款)→ 值得
|
||
if (identityKeywordFound) return true;
|
||
// 4. 距上次摘要已超 N 轮(如 ≥10 轮)也没总结 → 值得(兜底,防久拖不记)
|
||
if (turnsSinceLastSummary >= 10) return true;
|
||
return false;
|
||
}
|
||
```
|
||
|
||
> `turnsSinceLastSummary` 可用一个附加 key(如 `meta:last_summarized_at` 或轮次计数)记录,或用 `last_queries` 长度近似。实现时选一种并写清。
|
||
|
||
### 5.4 异步摘要的时序与一致性
|
||
|
||
`agent.respond` 主流程:
|
||
1. 读记忆 → 2. 检索 → 3. 生成主回复 → **立即 return 给上层**(客服先回)。
|
||
4. 摘要更新走 `fire-and-forget`:`void this.updateMemoryAsync(...)`,内部 try/catch + `logger.warn`。
|
||
|
||
注意:fire-and-forget 在 webhook 返回后进程若立刻退出,异步句柄可能被丢弃。当前 Hono 服务是常驻进程,不退出,故安全。**但**在测试环境需 `await` 或 mock 掉 `summarizer`,避免测试挂起(见 §6)。
|
||
|
||
### 5.5 成本控制
|
||
|
||
- 摘要仅在 §5.3 触发时发生,不是每轮。
|
||
- 摘要用 `chatSmall`(默认同主模型,配 `SUMMARY_MODEL` 可换便宜小模型)。
|
||
- 摘要 prompt 极短(旧摘要 ≤300 + 5 条问题 + 本轮一句),token 量小。
|
||
|
||
---
|
||
|
||
## 6. 测试要求
|
||
|
||
在 `tests/` 新增 `memory.test.ts`、`summarizer.test.ts`(或并入现有 test 风格),至少覆盖:
|
||
|
||
1. `db.memorySet` upsert:同 user 同 key 二次写入覆盖旧值(mock 或集成,项目现有测试用 vitest + 哪种 DB 抽象照抄当前 `agent.test.ts`/`catalog.test.ts` 的做法)。
|
||
2. `SqlMemory.remember/recall/recallAll` 语义正确(用 fake Db)。
|
||
3. `MemoSummarizer.extract`:fake `LlmProvider` 返回固定摘要 → 落库;fake 抛错 → 返回 `undefined` 且上层保留旧值。
|
||
4. `shouldSummarize` 各分支(lang 变 / 新 SKU / 身份词 / 超轮兜底 / 都不满足)。
|
||
5. `agent.respond` 注入记忆块:当 `recallAll` 有 `summary` 时,`buildSystemPrompt`/拼接结果包含该摘要词。
|
||
6. `last_queries` 截断到 5 条、单条 200 字符。
|
||
|
||
运行:`cd bot && npm test && npm run typecheck`,全绿再 push。
|
||
|
||
---
|
||
|
||
## 7. 部署(实现完成、测试绿之后)
|
||
|
||
1. push 到 gitea(`origin`,git.hoelee.com 私有仓库)。
|
||
2. 触发 DSM 上 `digikedai-bot` 容器重建(拉新镜像 / `docker compose up -d --build`,按现有 OPERATIONS.md 的 deploy 流程,**保留 compose 里所有原注释,只改需要改的**)。
|
||
3. 验证:
|
||
- `/health` 200。
|
||
- 新库启动后 `schema_migrations` 出现 version=6/7(= 数组位置 6、7),`user_memory` 表已建。
|
||
- 发几条消息,二次再问同主题(换会话题/间隔久一点),确认回复带上了“记忆”的个性化痕迹。
|
||
- 观察日志无记忆相关 error。
|
||
|
||
---
|
||
|
||
## 8. 文档同步(实现后)
|
||
|
||
- `docs/DECISIONS.md` 追加决策 **D17**:用户级长期记忆 = Postgres `user_memory` KV + 廉价 LLM 异步摘要;触发=新事实;摘要模型 config 可配 `SUMMARY_MODEL`;记忆失败降级不阻塞回复。
|
||
- `docs/ARCHITECTURE.md`:数据流图补 memory 分支(recall 注入 + summarizer 异步写回)。
|
||
- `docs/OPERATIONS.md`:env 新增 `SUMMARY_MODEL` / `SUMMARY_ENABLED` 说明。
|
||
- `../SECRETS.md`(仓库根):bot 段补可选 `SUMMARY_MODEL`。
|
||
|
||
---
|
||
|
||
## 9. 边界(本规格明确不做什么)
|
||
|
||
- ❌ 不接 mem0 HTTP API、不引 pgvector、不做语义向量检索(Phase 3 再说)。
|
||
- ❌ 不做多轮「总结压缩」的联动 agent 编排。
|
||
- ❌ `interest:<sku>` 本版不强做,但保留 key 命名空间与接口余地。
|
||
- ❌ 记忆 value 不存敏感原文(密码、凭证),只存结构化事实/截断问题。
|
||
|
||
---
|
||
|
||
## 10. 一句话验收标准
|
||
|
||
> 换会话或隔一段时间再问同一个用户,bot 依然记得他的语言与关键事实,回答更连贯;重复提问可被识别(数据已备好);且任何记忆/摘要故障都不会让客服回复失败。
|