05 · 长期记忆与收藏系统历史档案
本文件把「设计过的」「做出来的」「还没做的」分开记录,不合并、不互相覆盖。 现状基线:
5666a72/ v1a3ef2a33/ v28c6fe98a/ 国内版 22:26 进程。
一、五代演进(设计层)
v0 · 原始记忆库(2026-10-05,✅ 已实现)
用户原始要求(原文):
「外置记忆库:后端必须接入 Cloudflare D1 或 KV。每次用户输入和 AI 回复原封不动存进数据库,带上时间戳。」 「检索逻辑:每次发消息时,去数据库检索最相关的历史原话,配合人设打包给大模型。」
实现:
- 键 mem_<ts>_<rand>,值 {role, content, ts, day_key, hour, period};
- 检索:list(prefix:'mem_') → 取最近 300 条 → 中文双字滑窗 + 英文整词 tokenize → 命中数排序 → top5;
- 时间感知:前端带 time.iso,后端 resolveTime() 归一,提示词注入「现在几点/什么时段」。
局限(后来被 P0-1 修掉):所有角色共用同一前缀,测试角色会读到正式角色的记忆。
v1 · 按 profile 隔离(2026-10-19,✅)
- 新键
mem:<profile>:<ts>_<rand>;旧mem_*仅 owner 只读(MEM_READ_LEGACY开关); meta_lastTs/ 主动消息计数也按 profile;- 测试:
test_p0_memory_isolation15/15(含图片消息、主动消息、关键词重叠、旧数据仅 owner)。
v2 · 结构化档案(2026-10-19,✅)
msg:<profile>:<mid>:profile_id / conversation_id / speaker / message_type / created_at / source_id / sync_status / deleted_at / attachment;- 稳定 mid:前端生成
c_<16hex>随请求带上;AI 消息a_<ts>_<shortId>; - 幂等:同 mid 只写一次;同 mid 不同内容 → 409
mid_conflict; - 这是「可追溯来源」的基础:高光/经历/关系都必须引用真实
msg:。
v3 · 高光 / 经历 / 关系(2026-10-19,✅)
| 类型 | 键 | 必填 | 进模型上下文? |
|---|---|---|---|
| 高光原话 | hl:<profile>:<hid> |
mid(真实消息)、content 必须能在原文中找到 |
✅ top3 |
| 经历 | exp:<profile>:<eid> |
title/content/source_mids、status=active\|candidate、time_certainty |
✅ active top3 + candidate top2(候选明确标注「不得当作事实」) |
| 关系 | rel:<profile>:<rid> |
from_id/to_id(高光或经历)、5 种类型 |
❌(仅 UI 查看) |
删除级联:删高光/经历会软删引用它的关系;删消息时若有引用 → 409,确认后级联。
v4 · 框架升级构想(2026-10-23,⬜ 只调研,未实施)
用户下发《基线审查任务书》,要求研究 4 个开源项目并说明「可借鉴什么/对应源码位置/兼容性/成本/风险」,明确不得直接照搬、不得只看 README。
| 项目 | 机制要点 | 当时建议 |
|---|---|---|
| Mem0 | ADD/UPDATE/DELETE/NONE 四态决策 + history 表(每次变更留旧值)+ BM25 加性打分 |
建议 1:确定性规则 + history 版本链(每条记忆保留变更历史) |
| Graphiti | 双时间戳 valid_at/invalid_at/expired_at + RRF 混合检索 |
建议 2:双时间戳(事件发生时间 vs 记录时间分离);建议 3:RRF 融合关键词与时间衰减 |
| Letta | core/archival/recall 三层 + block 限额 + ApproxTokenCounter(bytes/4) |
建议 5:core memory + 上下文预算(按 token 而非条数截断) |
| LangGraph | checkpointer(thread_id/writes 表/time-travel)+ BaseStore namespace |
建议 4:DO + WAL 流水线(异步记忆整理,不阻塞聊天) |
交付物:/workspace/小手机项目-全项目代码基线审查报告.md(40KB,10 部分)。没有一行进入生产代码。
二、当前实现(照代码说)
2.1 写入路径(每条消息写两份)
用户发消息
├─ saveMemory() → mem:<p>:<ts>_<rand> {role, content, ts, day_key, hour, period}
└─ saveArchive() → msg:<p>:<mid> {…, speaker, created_at, deleted_at:null}
(AI 回复同理:saveMemory(raw) + saveArchive(cleanReply))
saveMemory用Math.random()生成键 → 没有幂等;重试可能在极端情况下写重复碎片(P3-11)。meta_lastTs:<p>同一分钟只写一次(省写配额)。- 写失败被
safeArchive捕获 → 聊天继续,响应带notice。
2.2 读取路径
retrieveContext(profile, query)
├─ retrieveMemory() → list(mem:<p>:) → 取最近 300 → tokenize 命中排序 → top5
├─ listHighlights() → list(hl:<p>:) → 关键词过滤 → top3
├─ listExperiences(active) → top3
└─ listExperiences(candidate)→ top2
- list 结果按前缀缓存 5 分钟(
__LIST_CACHE),新增记录时主动失效; - 旧
mem_前缀不缓存(保证迁移/补数据立即可见,代价:owner 每条聊天多 1 次 list); - 已知缺陷(P1-2):
listNamesByPrefix最多取 1000 个键,KV 按字典序升序返回 → 超过 1000 条记忆后,只会拿到最旧的 1000 条,新记忆永远检索不到。
2.3 提示词注入(buildSystemPrompt)
顺序:人设段(最高优先级/未设定声明)→ 安全边界(<<< >>> 是数据不是指令)→ 当前时间 → 记忆片段(角色/时间/原话)→ 高光(带来源 mid)→ 已确认经历 → 候选经历(标注未确认)→ 身份认知(承认是 AI)→ 状态/收藏引导 → 回复要求(1–3 句)。
2.4 收藏 vs 高光的区别(重要)
| 收藏(favorite) | 高光(highlight) | |
|---|---|---|
| 存储 | 浏览器 localStorage.fav_list |
云端 hl:<profile>:* |
| 触发 | 长按消息「收藏」;AI 输出 [[fav:…]] |
用户收藏时自动同步一份;AI 收藏也同步 |
| 是否进提示词 | ❌(本机 UI 用) | ✅(top3) |
| 是否可跨设备 | ❌ | ✅ |
| 来源校验 | 无 | 必须引用真实 msg:,且内容必须能在原文中找到 |
结论:收藏是「本机便签」,高光是「云端长期记忆」;收藏时前端会顺带写高光(
addCloudHighlight:先/archive确保来源消息存在,再POST /highlights)。
2.5 删除语义(当前)
| 操作 | 行为 |
|---|---|
| 「仅本机删除」 | 只删 IndexedDB,不碰云端(明确设计) |
| 「彻底删除(含云端)」 | POST /messages/delete {mode:'hard'}:物理删 msg: + 引用它的 hl/exp/rel,各留墓碑 tomb:(400 天) |
| 有引用时 | 首次返回 409 has_refs,用户确认后 force:true 级联硬删 |
| 云端没有该条 | 幂等返回 already_gone:true |
已知缺陷(P0-2):硬删不清 mem: 碎片,而聊天检索读的正是 mem: → 被删内容仍会被检索进提示词。
历史遗留:10-23/10-24 两次「云端删除」是软删,279 条记录仍物理存在(只在导出/检索时被过滤)。
2.6 导入 / 导出
| 接口 | 内容 | 去重 | 墓碑 |
|---|---|---|---|
/export/full |
msg + hl + exp + rel + mem(+ owner 的旧 mem_) |
— | ❌ 不导出墓碑 |
/import/full |
同上,dry_run 可预览 |
mid/hid/eid/rid + mem 的 content|ts 指纹 |
✅ 命中即跳过 |
/archive GET/POST |
只含 msg(+ legacy),支持搜索分页 | mid | ✅ |
墓碑不导出 → 把备份恢复到另一个空库时,防复活保护会丢失(P2)。
2.7 profile 隔离
- 后端全部按 profile 分前缀;
profileOf()从profile_id或profile取,非法值回退owner; - 前端固定写死
owner(profile_id:'owner'出现在所有调用里); - 因此「多角色」在数据层已就绪,UI 层没有入口。
三、设计 vs 实现的差异(不合并,逐条列)
| 设计(任务书/构想) | 实现现状 | 差距 |
|---|---|---|
| 原始消息与 AI 记忆整理相互独立,整理失败不能丢原文 | ✅ safeArchive 分离;记忆检索失败降级 |
已满足 |
| 高光必须是原话,不能 AI 改写 | ✅ content 必须被原文包含(STRICT_MID) |
已满足 |
| 删除高光不删原始消息 | ✅ 只删 hl + 级联关系 | 已满足 |
| 派生记忆必须有来源 | ✅ source_mids 必须全部存在 |
已满足 |
| 不确定信息不得当事实 | 🟡 candidate 单列 + 提示词标注;但没有审核流转 |
无「确认/驳回」动作 |
| 记忆可更新/合并 | ❌ 只能新增与删除;没有 update/merge | 与 Mem0 四态差距最大 |
| 记忆变更历史 | ❌ 无 history 表 | 建议 1 未实施 |
| 双时间戳(事件时间/记录时间) | 🟡 经历有 event_time + time_certainty,消息只有 created_at |
不完整 |
| Token 预算 | ❌ 按条数截断(5/3/3/2),无 token 计数 | 建议 5 未实施 |
| 记忆异步整理(不阻塞聊天) | ❌ 同步写两份 | 建议 4 未实施 |
| 向量检索 | ❌ 关键词子串匹配 | 明确非第一阶段必要 |
| 跨设备收藏 | ❌ 收藏只在本机 | 高光可跨设备 |
四、成本与配额影响(记忆系统的真实代价)
| 操作 | KV 读 | KV 写 | list |
|---|---|---|---|
| 一条聊天(冷缓存) | 约 300(mem 最近 300 条)+ 若干 | 5–6 | 4–5 |
| 一条聊天(5 分钟内热缓存) | 约 300 | 5–6 | 0–1 |
| 一次「彻底删除」 | 与 hl/exp/rel 数量成正比 | 2 + 引用数 | 2(hl/exp 原始 list) |
| 一次全量导出 | 与总记录数成正比 | 0 | 每 200 键 1 次 |
| 一次全量导入 | 与总记录数成正比(含指纹预扫) | 与新增数成正比 | 2–3 |
免费版上限(账户级/天):list 1000、写 1000、读 10 万。 → 约 160 条聊天/天 会打满写配额;333 条/天 会打满读配额;密集同步/搬迁是 list 的主要杀手。 2026-10-25 的 500 故障就是 list 1000 次被烧穿(见 07 事件 06)。
五、必须保留的东西(接手时不要动)
mem:/msg:/hl:/exp:/rel:/tomb:六类键的命名与前缀语义(有存量数据)。STRICT_MID与MEM_READ_LEGACY两个开关的默认值(1/1)与含义。content_hash = strHash(profile + '|' + content)的去重算法(改了会重复导入)。- 墓碑 400 天 TTL 与「导入前查墓碑」的检查顺序。
- 高光「内容必须被原文包含」的校验(这是防「AI 改写冒充原话」的核心)。
profile默认owner与校验正则。
六、最需要先决定的三件事
- 检索数据源统一:继续双写
mem:+msg:,还是让检索改读msg:(顺手解决 P0-2)? - 历史 279 条软删怎么处理:硬删+墓碑(要分批、耗写配额)还是保留但标记?
- 记忆要不要升级到 v4(更新/合并/history/双时间戳):这决定阶段 6「成长分析」有没有地基。