# 用户级长期记忆(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 的兴趣/最近交互时间戳 | 规则化/后续(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` — `INSERT ... ON CONFLICT (user_id, key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()`。 - `memoryGet(userId: number, key: string): Promise`。 - `memoryGetAll(userId: number): Promise>` — 全量读出拼对象。 > 记忆查询也可由 `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; recall(userId: number, key: string): Promise; recallAll(userId: number): Promise>; } 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; // 失败返回 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; ``` `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:` 本版不强做,但保留 key 命名空间与接口余地。 - ❌ 记忆 value 不存敏感原文(密码、凭证),只存结构化事实/截断问题。 --- ## 10. 一句话验收标准 > 换会话或隔一段时间再问同一个用户,bot 依然记得他的语言与关键事实,回答更连贯;重复提问可被识别(数据已备好);且任何记忆/摘要故障都不会让客服回复失败。