Files

289 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 用户级长期记忆(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 依然记得他的语言与关键事实,回答更连贯;重复提问可被识别(数据已备好);且任何记忆/摘要故障都不会让客服回复失败。