15 KiB
用户级长期记忆(User Long-Term Memory)— 开发规格
状态:已设计、待开发(用户已在对话中确认全部决策)。 目标读者:负责实现本功能的下一个会话 / 开发代理。 关联:本目录
docs/DECISIONS.md(新增决策 D17 之外的实现细节在此)。
1. 背景与目标
当前 bot 每个请求都拉取该会话最近 20 条 message(MAX_HISTORY=20)注入 prompt,但:
- 没有跨会话的用户级记忆——
ai/memory/memory.ts只有空接口NoopMemory,从未落地。 - 用户换了新会话(或同一会话被 20 条窗口冲掉后)就“失忆”,同一问题被反复重新生成近似答案。
本功能为每个用户(而非会话)维护一份可跨会话、跨渠道复用的记忆,让 bot:
- 记得该用户的语言偏好、关键事实、最近问题、感兴趣的商品。
- 回答更连贯、更个性化;并为后续「重复问题探测」「转人工触发」提供数据底子。
关键设计原则
- 用户级,不是会话级——记忆锚点用
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):
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 — 数据层
MIGRATIONS追加 §3 两条 SQL。saveExchange返回类型{ conversationId, userId }(新增userId)。实现里 upsert user 后已拿到userId,RETURNING id之后一并 return。- 新增三个 Db 方法(加进
Dbinterface 及实现对象):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 替换/新增为真正实现:
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 接入
-
构造函数增加参数:
constructor( private llm: LlmProvider, private db: Db, private retriever: Retriever = new NoopRetriever(), private memory: ConversationMemory = new NoopMemory(), private summarizer?: Summarizer, ) {} -
respond(args)的入参从conversationId增加userId(与preferredLanguage平级)。 -
读记忆:
const mem = await this.memory.recallAll(args.userId),若mem.summary或mem.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 结构太多;二选一并写清)。 -
生成回复:照旧调
this.llm.chat(...)。 -
更新记忆(回复之后):
- 规则化更新
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方法):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 新增: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 行附近改为:
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 摘要”。推荐规则(实现时写清、可微调):
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 主流程:
- 读记忆 → 2. 检索 → 3. 生成主回复 → 立即 return 给上层(客服先回)。
- 摘要更新走
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 风格),至少覆盖:
db.memorySetupsert:同 user 同 key 二次写入覆盖旧值(mock 或集成,项目现有测试用 vitest + 哪种 DB 抽象照抄当前agent.test.ts/catalog.test.ts的做法)。SqlMemory.remember/recall/recallAll语义正确(用 fake Db)。MemoSummarizer.extract:fakeLlmProvider返回固定摘要 → 落库;fake 抛错 → 返回undefined且上层保留旧值。shouldSummarize各分支(lang 变 / 新 SKU / 身份词 / 超轮兜底 / 都不满足)。agent.respond注入记忆块:当recallAll有summary时,buildSystemPrompt/拼接结果包含该摘要词。last_queries截断到 5 条、单条 200 字符。
运行:cd bot && npm test && npm run typecheck,全绿再 push。
7. 部署(实现完成、测试绿之后)
- push 到 gitea(
origin,git.hoelee.com 私有仓库)。 - 触发 DSM 上
digikedai-bot容器重建(拉新镜像 /docker compose up -d --build,按现有 OPERATIONS.md 的 deploy 流程,保留 compose 里所有原注释,只改需要改的)。 - 验证:
/health200。- 新库启动后
schema_migrations出现 version=6/7(= 数组位置 6、7),user_memory表已建。 - 发几条消息,二次再问同主题(换会话题/间隔久一点),确认回复带上了“记忆”的个性化痕迹。
- 观察日志无记忆相关 error。
8. 文档同步(实现后)
docs/DECISIONS.md追加决策 D17:用户级长期记忆 = Postgresuser_memoryKV + 廉价 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 依然记得他的语言与关键事实,回答更连贯;重复提问可被识别(数据已备好);且任何记忆/摘要故障都不会让客服回复失败。