Files

15 KiB
Raw Permalink Blame History

用户级长期记忆(User Long-Term Memory)— 开发规格

状态:已设计、待开发(用户已在对话中确认全部决策)。 目标读者:负责实现本功能的下一个会话 / 开发代理。 关联:本目录 docs/DECISIONS.md(新增决策 D17 之外的实现细节在此)。


1. 背景与目标

当前 bot 每个请求都拉取该会话最近 20 条 messageMAX_HISTORY=20)注入 prompt,但:

  • 没有跨会话的用户级记忆——ai/memory/memory.ts 只有空接口 NoopMemory,从未落地。
  • 用户换了新会话(或同一会话被 20 条窗口冲掉后)就“失忆”,同一问题被反复重新生成近似答案。

本功能为每个用户(而非会话)维护一份可跨会话、跨渠道复用的记忆,让 bot:

  1. 记得该用户的语言偏好、关键事实、最近问题、感兴趣的商品。
  2. 回答更连贯、更个性化;并为后续「重复问题探测」「转人工触发」提供数据底子。

关键设计原则

  • 用户级,不是会话级——记忆锚点用 bot_user.idbigint),不是 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.tsMIGRATIONS 数组末尾追加(版本号 = 数组位置+1,与注释标号无关;截至实现时数组有 5 个元素,故新表实际为 006/007):

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);
  • 自迁移机制已存在:applyMigrationsschema_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 后已拿到 userIdRETURNING 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 直接持 Dbpool 完成。推荐SqlMemoryDb 实例,调用上述三个方法——保持 SQL 全在 db.ts 单点维护,memory.ts 只演纯逻辑。若你更倾向把 SQL 放 memory 类,须在实现说明里注明偏离。

4.2 src/ai/memory/memory.ts — 记忆层

NoopMemory 替换/新增为真正实现:

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 字的新摘要。

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. 构造函数增加参数:

    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.summarymem.lang 存在,拼一块注入 system prompt,例如:

    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.preferredLanguagemem.lang 不同 → memory.remember(userId, 'lang', ...)
    • 规则化更新 last_queriespush 本轮 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 方法):
    chatSmall(args: { system: string; messages: {role:"user"|"assistant";content:string}[] }): Promise<string>;
    
    OpenAiCompatibleProviderchatSmallcfg.summaryModel(默认回退 cfg.llmModel)作为 model,其余与 chat 相同(timeout 可短一点,如 30s,因摘要是后台任务)。
  • config.ts 的 schema 新增:
    summaryModel: z.string().default(""),
    summaryEnabled: z.coerce.boolean().default(true),
    
    loadConfig 里读 env.SUMMARY_MODELenv.SUMMARY_ENABLEDsummaryModel 为空则摘要模型 = 主模型。
  • .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 行附近改为:

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);

SqlMemoryMemoSummarizer 需 import。logger 可在 summarizer 构造时传入用于告警。)


5. 关键实现细节

5.1 用户语言判定(供 lang 记忆)

优先级:本轮 preferredLanguage= Telegram language_code> 已有 mem.lang > 由 userText 启发式判断(中文/马来/英文关键词或脚本范围检测)。实现里至少做前两级,第三级作为可选增强。

5.2 最近问题队列(供摘要输入 + 未来重复探测)

  • key last_queriesvalue = JSON.stringify(string[]),最多 5 条,FIFO。
  • 每条用户消息后更新(不含 /start /help 命令文本;仅自由文本)。
  • 只存用户原文截断到 200 字符/条,防 prompt 膨胀。

5.3 摘要触发判定 shouldSummarize

决定“本轮是否值得花一次 LLM 摘要”。推荐规则(实现时写清、可微调):

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 给上层(客服先回)。
  2. 摘要更新走 fire-and-forgetvoid 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.tssummarizer.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.extractfake LlmProvider 返回固定摘要 → 落库;fake 抛错 → 返回 undefined 且上层保留旧值。
  4. shouldSummarize 各分支(lang 变 / 新 SKU / 身份词 / 超轮兜底 / 都不满足)。
  5. agent.respond 注入记忆块:当 recallAllsummary 时,buildSystemPrompt/拼接结果包含该摘要词。
  6. last_queries 截断到 5 条、单条 200 字符。

运行:cd bot && npm test && npm run typecheck,全绿再 push。


7. 部署(实现完成、测试绿之后)

  1. push 到 giteaorigingit.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.mdenv 新增 SUMMARY_MODEL / SUMMARY_ENABLED 说明。
  • ../SECRETS.md(仓库根):bot 段补可选 SUMMARY_MODEL

9. 边界(本规格明确不做什么)

  • 不接 mem0 HTTP API、不引 pgvector、不做语义向量检索(Phase 3 再说)。
  • 不做多轮「总结压缩」的联动 agent 编排。
  • interest:<sku> 本版不强做,但保留 key 命名空间与接口余地。
  • 记忆 value 不存敏感原文(密码、凭证),只存结构化事实/截断问题。

10. 一句话验收标准

换会话或隔一段时间再问同一个用户,bot 依然记得他的语言与关键事实,回答更连贯;重复提问可被识别(数据已备好);且任何记忆/摘要故障都不会让客服回复失败。