Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ pnpm-debug.log*
*.tsbuildinfo
next-env.d.ts
.gstack/

e2e/thread-chat/shots/**
384 changes: 384 additions & 0 deletions docs/memory/01-survey.md

Large diffs are not rendered by default.

130 changes: 130 additions & 0 deletions docs/memory/02-deep-dives/01-self-built-postgres.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 自建 Postgres 路线:本项目的候选蓝本

## 为什么它是首选

当前项目已经具备这条路线最贵的基础设施:

- [`lib/db/schema.ts`](../../../lib/db/schema.ts) 已使用 Drizzle/Postgres,并有 `vector(1536)` 与 HNSW 索引;
- [`lib/ai/embeddings.ts`](https://github.com/hifizz/thread-chatbot/blob/main/lib/ai/embeddings.ts) 已封装 OpenAI-compatible embedding;
- [`lib/chat/retrieve.ts`](https://github.com/hifizz/thread-chatbot/blob/main/lib/chat/retrieve.ts) 已实现 cosine 检索;
- [`app/api/chat/route.ts`](../../../app/api/chat/route.ts) 已有用户身份、服务端 system prompt、`prepareStep`、`onFinish` 和 `after()`;
- 分支聊天树已经整体持久化,但它保存的是会话状态,不是跨树共享的用户事实。

因此“自建”不是从零实现向量数据库,而是给现有数据层增加正确的记忆语义。

## 现有 RAG 可以复用什么

附件 RAG 的链路是:

```text
PDF pages
-> chunkPages(size=1000, overlap=150)
-> embedMany
-> attachment_chunks + HNSW
-> embedQuery
-> cosine top-k
-> 带页码片段注入消息
```

可复用:embedding provider、批量写入、HNSW 建索引方式、相似度查询和失败降级。

不可直接复用:

- `attachment_chunks` 以 `attachment_id` 分区,记忆必须以 `user_id` 为第一隔离边界;
- 文档块是 append/replace 内容,事实需要来源、置信度、有效时间、撤销和用户编辑;
- `gt(similarity, 0)` 对文档兜底可以接受,对用户记忆召回过宽;
- `EMBEDDING_DIMENSIONS=1536` 是数据库契约,更换模型不能只改环境变量;
- 向量近邻不能可靠完成“用户现在住哪里”这类当前值查询。

## 目标数据模型

建议阶段 3 先验证四层,不一次性做完整产品表:

```text
memory_events
id, user_id, source_tree_id, source_message_id
observed_at, payload, extraction_version

memory_facts
id, user_id, subject, predicate, value_json
valid_from, valid_to, status, confidence
source_event_id, supersedes_id, created_at, updated_at

memory_profiles
user_id, profile_json, version, refreshed_at

memory_embeddings (可选)
fact_id, embedding, searchable_text
```

关键不变量:

1. 所有查询必须显式带 `user_id`;
2. 事实来源不可丢,用户能追溯到树和消息;
3. 当前值由 `status/valid_to/supersedes_id` 和 SQL 决定,不让 LLM 临场猜;
4. 删除默认先做可审计的失效,隐私删除再做物理清理;
5. embedding 是事实的索引,不是事实本体。

## 写入路径

```text
assistant 流完成
-> 记录待处理 event(幂等键 = source_message_id + extractor_version)
-> 后台/缓冲任务读取若干新 turn
-> LLM 输出受 schema 约束的候选事实
-> 确定性校验(作用域、敏感字段、枚举、时间)
-> 按 (user, subject, predicate) 查当前值
-> ADD / supersede / ignore
-> 异步生成 embedding 与刷新 profile
```

`onFinish` 当前承担计费,不能把昂贵抽取直接塞进去。`after()` 只保证请求后的工作有机会完成,不是持久队列;阶段 3 可以用它做最小实验,生产实现要有持久状态、重试和幂等。

写入不应默认记住 assistant 的所有回答。第一版只抽取用户明确陈述和明确“请记住”的内容;assistant 生成的计划另设 `kind`,避免把模型幻觉升级成用户事实。

## 读取与注入路径

每次请求建议只做一次读:

1. SQL 获取紧凑画像和与问题中明确实体匹配的当前事实;
2. 若问题需要长尾回忆,再对 active facts/events 做向量召回;
3. 按固定 token/字符预算打包;
4. 拼入 [`app/api/chat/route.ts`](../../../app/api/chat/route.ts) 的服务端 `system`。

`prepareStep` 适合“模型在本轮调用记忆工具后,需要让下一步看到最新记忆”的场景。若记忆只在请求开始前读取,直接构造 `system` 更简单,也避免每个 tool step 重复查库。AI SDK v7 源码显示 `prepareStep` 返回的 `instructions/messages` 会传递到后续步骤,实施时必须避免重复追加同一 memory block。

建议注入格式:

```xml
<user_memory generated_at="...">
<instruction>仅在与当前问题相关时使用;不得把记忆当作用户本轮明确陈述。</instruction>
<profile>...</profile>
<facts>...</facts>
<episodes>...</episodes>
</user_memory>
```

## 用户控制面

最小可用 UI 必须同时交付:

- 查看记忆、来源与最近更新时间;
- 编辑当前值并标记为用户确认;
- 删除单条/清空全部;
- 关闭自动记忆或使用临时聊天;
- 在回答旁解释“本次使用了哪些记忆”。

没有控制面就不应默认上线自动写入,因为错误记忆和投毒会跨会话持续生效。

## 阶段 3 的最小切片

先只做 `preferred_language`、`dietary_restriction`、`current_project` 三类 predicate:

- 结构固定,容易验证冲突;
- 能覆盖偏好、敏感约束和短期状态;
- 不需要一开始就做通用 ontology;
- 可直接比较“全上下文 / 纯向量 / 结构化事实”三条路线。

## 判定

**进入实验。** 技术栈匹配、数据主权最好,也最符合阶段 1 对确定性新鲜度消解的结论。主要风险不在数据库,而在抽取质量、后台可靠性、隐私控制和 schema 演进。
80 changes: 80 additions & 0 deletions docs/memory/02-deep-dives/02-mem0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Mem0:从四操作管线到 V3 ADD-only

> 源码快照:`mem0ai/mem0@d6d89c987bddf580870db14c69db974edfc5263c`(2026-07-23)。重点文件:[`mem0/memory/main.py`](https://github.com/mem0ai/mem0/blob/d6d89c987bddf580870db14c69db974edfc5263c/mem0/memory/main.py)、[`mem0/configs/prompts.py`](https://github.com/mem0ai/mem0/blob/d6d89c987bddf580870db14c69db974edfc5263c/mem0/configs/prompts.py)。

## 设计动机

Mem0 把“从对话里挑出值得长期保存的信息”包装成一个通用 memory API,并用 `user_id`、`agent_id`、`run_id` 做作用域。调用者可选择:

- `infer=True`:由 LLM 抽取记忆;
- `infer=False`:把非 system 消息原文直接存为记忆;
- `memory_type="procedural_memory"`:生成代理执行历史的过程性摘要。

## 必须修正的旧认识

阶段 1 根据论文和旧源码把 Mem0 描述为:

```text
事实抽取 -> 检索相似旧记忆 -> LLM 决定 ADD/UPDATE/DELETE/NONE
```

当前 OSS 主干仍保留 `DEFAULT_UPDATE_MEMORY_PROMPT`,但 `Memory._add_to_vector_store()` 的主路径已经标成 `V3 PHASED BATCH PIPELINE`,实际采用 **ADD-only + memory linking**。不能仅看到旧 prompt 就断言运行时仍走四操作管线。

## 当前源码调用链

`Memory.add()` 的通用记忆路径最终进入 `_add_to_vector_store()`:

1. 从 history DB 取同一 session 最近 10 条消息;
2. `parse_messages()` 把新消息串成抽取输入;
3. 对输入生成 query embedding,在当前 `user_id/agent_id/run_id` 范围内检索 top 10;
4. 把真实 UUID 映射为短整数 ID,降低模型伪造 ID 的概率;
5. 使用 `ADDITIVE_EXTRACTION_PROMPT` 做一次 JSON 抽取;
6. prompt 明确规定唯一操作是 ADD,旧记忆仅用于去重和 `linked_memory_ids`;
7. 批量 embedding 新记忆;失败时退回逐条 embedding;
8. 用文本 MD5 对既有结果与本批次做精确去重;
9. 批量写向量条目、history,并抽取实体建立“实体 -> memory ids”关联;
10. 保存原消息,供后续抽取读取最近上下文。

V3 prompt 还要求:

- 同时从 user 与 assistant 消息抽取,但正确标注来源;
- 用 observation date 把相对时间转为绝对时间;
- 记忆保持自包含、保留专名和精确数字;
- 发生偏好变化或矛盾时,新增一条并链接旧记忆,而不是覆盖旧值。

## 数据与检索

向量条目的 payload 至少包含 `data`、`hash`、`created_at`、`updated_at`、`text_lemmatized` 和作用域字段。代码同时维护 SQLite history,并引入 BM25 词形化、多信号排序与 entity boost;具体 vector store、LLM、embedder、reranker 都经 factory 配置。

这带来良好的适配性,但也意味着语义取决于多个可替换组件,不能把单一 benchmark 数字当作固定产品能力。

## 优点

- API 面小,作用域清晰,provider/vector store 可替换;
- V3 单次抽取与批量 embedding 比逐事实多轮判断更省调用;
- 旧记忆 ID 映射、hash 去重、history、实体关联都有明确工程防线;
- ADD-only 保留演化轨迹,避免 LLM 直接破坏历史。

## 风险

- ADD-only 把“冲突消解”推迟到读取或后处理;当前值仍不能只靠相似检索得到;
- prompt 采用“有疑问就抽取”,更偏召回率,可能制造大量低价值记忆;
- assistant 推荐也会成为记忆,若不分 source/kind,模型输出可能被再次当作用户事实;
- OSS 的 temporal `timestamp` 参数在代码中明确报“platform-only”,不能假设开源版已提供完整时间语义;
- hash 只消除字面重复,语义重复仍依赖 LLM 与检索。

## 对本项目的可复用点

- 作用域字段必须在所有写/读路径中保持不变;
- 给模型短 ID,执行时再映射真实主键;
- 抽取失败与“没有事实”必须是两种状态,便于重试;
- 先批量 embedding,失败再逐条降级;
- 保留原始 event/history,派生事实可以重算。

## 不直接接入的原因

本项目核心需求是“当前用户事实可解释、可编辑、可确定性更新”。Mem0 V3 更像高召回的记忆事件层,而不是强 schema 的记录系统。直接接入仍需额外实现当前值、权限、来源和 UI,收益不足以抵消一层外部抽象。

## 判定

**借鉴,不接入。** 阶段 3 可把 Mem0 V3 的“单次 ADD-only 抽取”作为候选 B,与“schema 候选 + 确定性 supersede”候选 A 对比。
82 changes: 82 additions & 0 deletions docs/memory/02-deep-dives/03-anthropic-memory-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Anthropic memory tool:文件协议与 JIT 读取

> 文档快照:2026-07-24。官方入口:[Memory tool](https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool)。工具 `memory_20250818` 已 GA,但部分 SDK helper 仍位于 beta namespace。

## 设计动机

Anthropic 没有替开发者托管记忆。模型只发出文件操作请求,应用在自己控制的存储上执行,再以 `tool_result` 返回。`/memories` 是逻辑前缀,可以映射到本地目录、对象存储或数据库。

核心思路不是“每轮把所有记忆塞进 prompt”,而是:

```text
任务开始 -> view /memories
-> view 相关文件/行
-> 使用信息
-> create/edit/rename/delete 持久化新进展
```

这是 just-in-time context retrieval,尤其适合长程任务和跨 session 恢复。

## 工具协议

请求只需声明:

```json
{ "type": "memory_20250818", "name": "memory" }
```

应用必须实现六个命令:

| command | 关键参数 | 语义 |
| --- | --- | --- |
| `view` | `path`, `view_range?` | 列目录或按 1-based 行号读文件 |
| `create` | `path`, `file_text` | 创建文件;官方 handler 对已存在路径报错 |
| `str_replace` | `path`, `old_str`, `new_str?` | 仅在旧文本唯一出现时替换 |
| `insert` | `path`, `insert_line`, `insert_text` | 在指定行后插入,0 表示文件头 |
| `delete` | `path` | 删除文件/目录,但不能删除根目录 |
| `rename` | `old_path`, `new_path` | 移动或改名,不覆盖已有目标 |

Python/TypeScript SDK 提供本地文件 helper;生产实现仍应替换为每用户隔离的持久存储。

## 为什么 `str_replace` 值得借鉴

它要求 `old_str` 唯一匹配,否则拒绝操作。这相当于一种面向文本的乐观并发控制:模型必须先读到精确旧值,才能修改。相比“整文件覆盖”,它减少误删与基于过期视图覆盖新内容的风险。

对结构化事实表,可映射成:

```text
update memory_facts
set value = :new, version = version + 1
where id = :id and version = :expected_version
```

## 安全边界

官方文档明确把安全责任交给应用:

- 所有路径必须位于 `/memories`;
- canonicalize 后再次验证根目录;
- 拒绝 `../`、`..\\` 和 URL-encoded traversal;
- 限制文件大小、单次 `view` 返回量与文件寿命;
- 不把敏感数据交给模型自行决定保存;
- 错误用 `tool_result.is_error=true` 返回,不能静默成功。

多租户实现还必须把逻辑路径绑定到已鉴权 `userId`,绝不能让模型输入决定真实租户目录。

## 与当前项目的映射

项目已有 AI SDK tool loop 和 `prepareStep`,可以用普通 `tool()` 模拟相同协议;但原生 Anthropic schema 只适用于 Claude provider。当前产品支持多模型,因此若采用,应定义 provider-neutral 的 `viewMemory/updateMemory` 工具,而不是把 `memory_20250818` 写死到主链路。

此外,Thread Chat 的主要对象是“关于用户的事实”,不是“agent 自己维护的项目文件”。对用户画像而言,文件缺少 predicate、来源和有效时间,不应成为主存储。

## 可复用点

- 存储完全由应用控制;
- 先读后写、精确替换、显式错误;
- 按需读取而非全量常驻;
- memory 与 compaction 分工:前者跨 session,后者压缩当前会话;
- 目录/文件可直接成为用户可查看的审计界面。

## 判定

**借鉴工具协议,不照搬文件模型。** 若阶段 4 增加“模型主动管理记忆”,应把可写动作限制为候选提议或带版本条件的 CRUD,并继续以 Postgres 事实表作为记录系统。
Loading