来源:OpenClaw 源码分析 版本:2026.3.26 日期:2026-03-30
一、系统概述
OpenClaw 是一个多渠道 AI 网关,支持 Telegram、Discord、WhatsApp、飞书等平台的消息统一接入。其记忆管理系统为 AI 提供跨对话、跨时间的持久记忆能力。
1.1 解决什么问题
AI 对话天然是无状态的------每次对话都是全新的开始。记忆系统让 AI 能够:
- 记住用户的偏好和习惯
- 记住之前的讨论结论和决策
- 跨越多天甚至数周持续对话
1.2 核心设计思想
Markdown 文件 ──→ 唯一真相源(可读、可编辑、可版本控制)
SQLite 索引 ──→ 加速结构(丢了可重建)
嵌入模型 ──→ 语义理解(不只是关键词匹配)
二、架构总览
2.1 三层架构
scss
┌─────────────────────────────────────────────────────────────┐
│ 工具层 │
│ memory_search / memory_get │
│ (Agent 自动调用的记忆检索工具) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Hook 层 │
│ session-memory hook │
│ (对话结束时自动提取并保存会话摘要) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 引擎层 │
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
│ │ Builtin 引擎 │ │ QMD 引擎 │ │
│ │ (SQLite + sqlite-vec)│ │ (外部 CLI) │ │
│ └─────────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 存储层 │
│ memory/*.md + SQLite (chunks / chunks_vec / chunks_fts)│
└─────────────────────────────────────────────────────────────┘
2.2 模块职责
| 模块 | 文件路径 | 职责 |
|---|---|---|
| 记忆管理器 | extensions/memory-core/src/memory/manager.ts |
协调搜索、同步、配置 |
| 混合搜索 | extensions/memory-core/src/memory/hybrid.ts |
向量分 + 关键词分融合 |
| MMR 去重 | extensions/memory-core/src/memory/mmr.ts |
去除搜索结果中的重复内容 |
| 时间衰减 | extensions/memory-core/src/memory/temporal-decay.ts |
让旧记忆的分数自然下降 |
| 向量嵌入 | extensions/memory-core/src/memory/embeddings.ts |
调用嵌入模型生成向量 |
| 文件同步 | extensions/memory-core/src/memory/manager-sync-ops.ts |
监控文件变化,执行索引 |
| 搜索执行 | extensions/memory-core/src/memory/manager-search.ts |
分别执行向量搜索和关键词搜索 |
| 工具注册 | extensions/memory-core/src/tools.ts |
给 Agent 暴露 memory_search / memory_get 工具 |
| 会话钩子 | src/hooks/bundled/session-memory/handler.ts |
对话结束时自动提取摘要 |
| 存储基础设施 | packages/memory-host-sdk/src/host/memory-schema.ts |
定义 SQLite 表结构 |
三、存储结构
3.1 文件系统布局
yaml
workspace/
├── MEMORY.md ← 用户直接编辑的长期记忆
├── memory/ ← 按日期和话题整理的记忆文件
│ ├── 2026-03-27-api-design.md
│ ├── 2026-03-28-python-tips.md
│ └── MEMORY.md
└── .openclaw/
└── memory.db ← SQLite 索引数据库(自动生成)
3.2 SQLite 数据库表结构
sql
-- 文件清单表
CREATE TABLE files (
path TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'memory', -- memory 或 sessions
hash TEXT NOT NULL, -- 内容哈希,检测变化
mtime INTEGER NOT NULL, -- 修改时间
size INTEGER NOT NULL
);
-- 文本分块表(核心)
CREATE TABLE chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL, -- 起始行号
end_line INTEGER NOT NULL, -- 结束行号
hash TEXT NOT NULL,
model TEXT NOT NULL, -- 使用的嵌入模型
text TEXT NOT NULL, -- 原文内容
embedding TEXT NOT NULL, -- 向量(JSON 字符串存储)
updated_at INTEGER NOT NULL
);
-- 向量索引表(sqlite-vec 扩展)
CREATE VIRTUAL TABLE chunks_vec USING vec0(
id TEXT PRIMARY KEY,
embedding FLOAT[1536] -- 维度由嵌入模型决定
);
-- 全文搜索索引表(FTS5)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
text, -- 要搜索的内容
id UNINDEXED,
path UNINDEXED,
source UNINDEXED,
model UNINDEXED,
start_line UNINDEXED,
end_line UNINDEXED
);
-- 嵌入缓存表(避免重复嵌入,节省 API 调用)
CREATE TABLE embedding_cache (
provider TEXT NOT NULL, -- openai / gemini / ollama
model TEXT NOT NULL, -- text-embedding-3-small
provider_key TEXT NOT NULL,
hash TEXT NOT NULL, -- 文本哈希
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
四、记忆来源
4.1 手动写入(常青记忆)
用户可直接编辑两个位置:
| 文件 | 用途 | 是否衰减 |
|---|---|---|
MEMORY.md(根目录) |
长期记忆:偏好、习惯、重要事实 | 否 |
memory/*.md |
主题记忆:按项目/话题分类 | 否 |
4.2 自动提取(会话记忆)
当用户执行 /new 或 /reset(开启新对话)时,session-memory hook 自动触发:
- 读取最近 15 条消息(可配置)
- 调用 LLM 生成描述性标题
- 保存为
memory/YYYY-MM-DD-slug.md格式
生成的记忆文件示例:
markdown
# Session: 2026-03-30 09:50:00 UTC
- **Session Key**: agent:main
- **Session ID**: abc123
- **Source**: webchat
## Conversation Summary
用户查询了历年合同金额数据。
最终查询范围:2023年、2024年、2025年(不含2026年)。
结果:
- 2023年:12,345 万元
- 2024年:15,678 万元
- 2025年:18,234 万元
五、搜索算法详解
5.1 混合搜索原理
搜索分四个步骤完成:
vbnet
Step 1:用户查询 "昨天那个合同金额"
↓
Step 2:并行执行两种搜索
├─ 向量搜索(语义)
│ embed() → [0.031, -0.008, ...] → 余弦相似度
│ → score: 0.82
│
└─ 关键词搜索(精确)
extractKeywords() → ["昨天", "合同", "金额"]
→ BM25 匹配
→ textScore: 0.65
↓
Step 3:加权融合
score = 0.7 × 0.82 + 0.3 × 0.65 = 0.769
↓
Step 4:按分数降序 + 可选的时间衰减/MMR
↓
返回 Top 10 结果
5.2 向量搜索(语义理解)
原理: 将文本和查询都转成高维向量,比较方向是否一致。
核心 SQL(sqlite-vec):
sql
SELECT c.id, c.path, c.text,
vec_distance_cosine(v.embedding, ?) AS dist -- 查询向量
FROM chunks_vec v
JOIN chunks c ON c.id = v.id
WHERE c.model = ?
ORDER BY dist ASC -- dist 越小 = 越相似
LIMIT ?
余弦相似度公式:
css
余弦相似度 = (A · B) / (|A| × |B|)
余弦距离 = 1 - 余弦相似度
最终分数 = 1 - dist
向量搜索的代码实现:
typescript
// manager-search.ts
async function searchVector(params) {
// ① 把用户查询转成向量
const queryVec = await this.provider.embed("昨天那个合同金额");
// → [0.031, -0.008, 0.112, ...] 1536维
// ② 余弦距离搜索
const rows = this.db.prepare(`
SELECT c.id, c.path, c.text,
vec_distance_cosine(v.embedding, ?) AS dist
FROM chunks_vec v
JOIN chunks c ON c.id = v.id
WHERE c.model = ?
ORDER BY dist ASC
LIMIT ?
`).all(
Buffer.from(new Float32Array(queryVec).buffer),
this.providerModel,
this.limit
);
// ③ 距离转分数:dist 越小越相似
return rows.map(row => ({
score: 1 - row.dist, // 0 = 完全相同, 1 = 完全相反
snippet: row.text.slice(0, 700),
}));
}
向量搜索的适用场景:
- 同义词理解:"合同" ≈ "协议"
- 语义相近:"缓存穿透" ≈ "缓存击穿" ≈ "布隆过滤器解决的数据问题"
- 拼写容错:有一定容错能力
5.3 关键词搜索(BM25 精确匹配)
原理: 分词 → 倒排索引 → BM25 评分。
核心 SQL(FTS5):
sql
SELECT id, path, text,
bm25(chunks_fts) AS rank -- BM25 评分
FROM chunks_fts
WHERE chunks_fts MATCH '"昨天" AND "合同" AND "金额"'
ORDER BY rank ASC -- rank 越小越相关
LIMIT ?
关键词搜索的适用场景:
- 精确术语:搜索 "HTTP 状态码 500" 必须精确匹配
- 数值/人名:搜索 "2023年"、"John"、"13888888888"
- 品牌/型号:搜索 "iPhone 16 Pro Max"
5.4 BM25 算法详解
5.4.1 问题背景
给定一个查询词和一堆文档,如何判断哪个文档更相关?
比如搜索 "合同金额":
arduino
文档A:合同金额是12,345万元 ✓ 包含"合同"和"金额"
文档B:合同条款中的金额规定... ✓ 包含"合同"和"金额"
文档C:甲乙双方签订了一份合同... ✗ 只有"合同",没有"金额"
A 和 B 都提到了,但哪个更相关?BM25 就是用来量化这个相关程度的。
5.4.2 发展历史
| 算法 | 思想 | 问题 |
|---|---|---|
| TF(词频) | 词出现越多越相关 | 文章越长词越多,长文档永远排前面 |
| TF-IDF | 常见词(如"的")降权 | 但仍未解决长度不公平的问题 |
| BM25 | TF × IDF + 长度归一化 + 词频饱和 | 目前最广泛使用的检索算法之一 |
5.4.3 完整公式
scss
BM25(D, Q) = Σ IDF(qi) × ──────────────────────────────
tf + k1 × (1 - b + b × |D|/avgdl)
公式拆分两部分理解:
makefile
BM25 = IDF × TF_score
第一部分:IDF(逆文档频率)
→ 词越常见,权重越低
→ "的"出现在几乎所有文档里,IDF → 0
→ "合同"只出现在少数文档,IDF → 高
第二部分:TF_score(词频得分)
→ 词在文档中出现越多越相关
→ 但有上限(k1 控制饱和)
→ 短文档比长文档更有优势(b 控制归一化)
IDF 公式:
ini
(N - n + 0.5)
IDF(qi) = log ───────────────
(n + 0.5)
N = 总文档数
n = 包含词 qi 的文档数
参数说明:
| 参数 | 典型值 | 含义 |
|---------|------|-------------------------|---|---------|
| k1 | 1.2 | 词频饱和参数,词出现次数再多也不会无限增加权重 |
| b | 0.75 | 文档长度归一化参数 |
| N | - | 总文档数 |
| n | - | 包含目标词的文档数 |
| ` | D | ` | - | 当前文档的词数 |
| avgdl | - | 所有文档的平均词数 |
| tf | - | 词在文档中出现的次数 |
5.4.4 IDF 的作用
IDF 过滤掉无意义的常见词:
scss
IDF("合同") 高 → 包含"合同"的文档很少(10篇/1000篇)
→ log(1000/10) = 2.19 → 词很关键
IDF("的") 低 → 几乎所有文档都有"的"(990篇/1000篇)
→ log(1000/990) = 0.01 → 词基本没用
IDF("金额") 高 → 只有少数文档有"金额"(80篇/1000篇)
→ log(1000/80) = 2.51 → 词很关键
5.4.5 TF_score 的作用:词频饱和
词出现 100 次不会比出现 10 次好太多------这就是"饱和":
ini
k1 = 1.2
tf=1: (1.2+1)×1 / (1+1.2) = 2.2/2.2 = 1.00
tf=5: (1.2+1)×5 / (5+1.2) = 11/6.2 = 1.77
tf=10: (1.2+1)×10 / (10+1.2) = 22/11.2 = 1.96
tf=100: (1.2+1)×100/ (100+1.2) = 220/101.2 = 2.17
tf=1000: (1.2+1)×1000/(1000+1.2) = 2200/1001 = 2.20
曲线:快速上升到 1.5~2.0 左右,然后趋于平坦
这就是"饱和"------再多的重复出现也没用了
5.4.6 TF_score 的作用:长度归一化
短文档不应因为篇幅短就输给长文档:
ini
b = 0.75,k1 = 1.2,avgdl = 200
文档A:|D|=7词(短),tf=2 时:
TF = 2.2×2 / (2 + 1.2×(1-0.75+0.75×7/200))
= 4.4 / 2.75 = 1.60
文档B:|D|=500词(长),tf=2 时:
TF = 2.2×2 / (2 + 1.2×(1-0.75+0.75×500/200))
= 4.4 / 4.30 = 1.02
同样出现2次,短文档得分更高(1.60 vs 1.02)
5.4.7 完整计算示例
场景:
arduino
查询:["合同", "金额"]
总文档数 N = 1000,平均长度 avgdl = 200
文档A:"合同金额是12,345万元" (短,tf合同=1, tf金额=1, |D|=7)
文档B:"关于合同、合同条款及金额规定..." (中,tf合同=2, tf金额=1, |D|=20)
文档C:"合同金额计算公式如下..." (短,tf合同=1, tf金额=1, |D|=6)
文档D:"甲乙双方签订了一份合同..." (长,tf合同=1, tf金额=0, |D|=100)
Step 1:计算 IDF
scss
包含"合同"的文档 n = 100
包含"金额"的文档 n = 80
IDF("合同") = log((1000-100+0.5)/(100+0.5)) = log(900.5/100.5) = 2.19
IDF("金额") = log((1000-80+0.5)/(80+0.5)) = log(920.5/80.5) = 2.44
Step 2:计算 TF_score(k1=1.2, b=0.75, avgdl=200)
ini
文档A:|D|=7
TF合同 = 2.2×1 / (1 + 1.2×(1-0.75+0.75×7/200)) = 2.2/1.69 = 1.30
TF金额 = 2.2×1 / (1 + 1.2×(1-0.75+0.75×7/200)) = 2.2/1.69 = 1.30
文档B:|D|=20
TF合同 = 2.2×2 / (2 + 1.2×(1-0.75+0.75×20/200)) = 4.4/2.87 = 1.53
TF金额 = 2.2×1 / (1 + 1.2×(1-0.75+0.75×20/200)) = 2.2/1.87 = 1.18
文档D:|D|=100
TF合同 = 2.2×1 / (1 + 1.2×(1-0.75+0.75×100/200)) = 2.2/2.35 = 0.94
TF金额 = 0(不包含"金额")
Step 3:计算 BM25 = IDF合同 × TF合同 + IDF金额 × TF金额
ini
文档A:2.19×1.30 + 2.44×1.30 = 2.85 + 3.17 = 6.02
文档B:2.19×1.53 + 2.44×1.18 = 3.35 + 2.88 = 6.23 ← 最高
文档C:2.19×1.30 + 2.44×1.30 = 6.02(结构同A)
文档D:2.19×0.94 + 2.44×0 = 2.06 ← 只命中一个词
排序:B(6.23)> A/C(6.02)> D(2.06)
5.4.8 OpenClaw 中的实现
typescript
// hybrid.ts --- BM25 分数转标准 0-1 分数
// FTS5 已内置 BM25 算法,这里只做分数转换
export function bm25RankToScore(rank: number): number {
if (!Number.isFinite(rank)) {
return 1 / (1 + 999); // 异常值 → 最低分
}
if (rank < 0) {
// rank 负数 = FTS5 布尔精确匹配(布尔查询命中)
const relevance = -rank; // rank=-1 → relevance=1
return relevance / (1 + relevance); // → 0.5
}
// rank=0 → score=1.0(最相关)
// rank=1 → score=0.5
// rank=9 → score=0.1
return 1 / (1 + rank);
}
FTS5 内置了 BM25 评分,OpenClaw 直接调用:
sql
-- FTS5 自动计算 BM25 值
SELECT id, path, text,
bm25(chunks_fts) AS rank -- rank 是原始 BM25 值
FROM chunks_fts
WHERE chunks_fts MATCH '"合同" AND "金额"'
ORDER BY rank ASC -- rank 越小越相关
LIMIT 10
5.4.9 一句话总结
makefile
BM25 = 词的重要程度(IDF)× 词在文档中的出现情况(TF_score)
IDF → 过滤掉"的"、"是"这类无意义的词
TF_score → 词出现多更相关,但有上限,不被长文档刷分
5.5 混合融合
typescript
// hybrid.ts
async function mergeHybridResults({ vector, keyword, vectorWeight, textWeight }) {
// ① 按 id 合并(同一条记忆可能被两种方式找到)
// chunk_2 同时命中:vectorScore=0.82, textScore=0.65
// ② 加权融合(默认 70% 向量 + 30% 关键词)
const score = 0.7 * 0.82 + 0.3 * 0.65;
// score = 0.574 + 0.195 = 0.769
// ③ 时间衰减(可选,默认关闭)
// 旧记忆分数自然下降
// ④ MMR 去重(可选,默认关闭)
// 删除与已选结果重复的内容
return decayed.sort((a, b) => b.score - a.score);
}
5.6 两种搜索的对比总结
| 维度 | 向量搜索 | 关键词搜索 |
|---|---|---|
| 引擎 | sqlite-vec 扩展 | SQLite 内置 FTS5 |
| 核心函数 | vec_distance_cosine() |
MATCH + bm25() |
| 擅长 | 语义理解、同义词、近义词 | 精确术语、数值、人名 |
| 短板 | 精确匹配不如 FTS | 不理解语义 |
| 典型应用 | "上次讨论的缓存方案" | "2023年合同金额" |
typescript
// hybrid.ts
async function mergeHybridResults({ vector, keyword, vectorWeight, textWeight }) {
// ① 按 id 合并(同一条记忆可能被两种方式找到)
// chunk_2 同时命中:vectorScore=0.82, textScore=0.65
// ② 加权融合(默认 70% 向量 + 30% 关键词)
const score = 0.7 * 0.82 + 0.3 * 0.65;
// score = 0.574 + 0.195 = 0.769
// ③ 时间衰减(可选,默认关闭)
// 旧记忆分数自然下降
// ④ MMR 去重(可选,默认关闭)
// 删除与已选结果重复的内容
return decayed.sort((a, b) => b.score - a.score);
}
5.5 两种搜索的对比总结
| 维度 | 向量搜索 | 关键词搜索 |
|---|---|---|
| 引擎 | sqlite-vec 扩展 | SQLite 内置 FTS5 |
| 核心函数 | vec_distance_cosine() |
MATCH + bm25() |
| 擅长 | 语义理解、同义词、近义词 | 精确术语、数值、人名 |
| 短板 | 精确匹配不如 FTS | 不理解语义 |
| 典型应用 | "上次讨论的缓存方案" | "2023年合同金额" |
六、MMR 去重算法
6.1 问题背景
向量搜索可能返回多条内容高度相似的记忆,浪费结果配额,降低答案多样性。
示例:
markdown
不加 MMR:
1. Python 性能优化技巧 (0.90)
2. Python 代码优化实践 (0.88) ← 和第 1 条高度重复
3. Redis 缓存穿透 (0.85)
4. Python 内存管理 (0.78)
加 MMR:
1. Python 性能优化技巧 (0.90)
2. Redis 缓存穿透 (0.85) ← 被第 3 条替换,更有多样性
3. GIL 对性能的影响 (0.80) ← 新角度
4. Python 内存管理 (0.78)
6.2 算法原理
MMR(Maximal Marginal Relevance,1998 年 Carbonell & Goldstein 提出):
diff
MMR = λ × relevance - (1 - λ) × max_similarity_to_selected
参数含义:
- λ = 0.7(默认)
- λ = 1.0:只看相关性,完全忽略多样性
- λ = 0.0:只看多样性,完全忽略相关性
6.3 代码实现
typescript
// hybrid.ts / mmr.ts
export function mmrRerank<T extends MMRItem>(items, config) {
const { lambda = 0.7 } = config;
const selected = [];
const remaining = new Set(items);
while (remaining.size > 0) {
let bestItem = null;
let bestMMRScore = -Infinity;
for (const candidate of remaining) {
// 计算与已选结果的最大 Jaccard 相似度
const maxSim = maxSimilarityToSelected(candidate, selected);
// MMR 公式
const mmrScore = lambda * candidate.score - (1 - lambda) * maxSim;
if (mmrScore > bestMMRScore) {
bestMMRScore = mmrScore;
bestItem = candidate;
}
}
selected.push(bestItem);
remaining.delete(bestItem);
}
return selected;
}
// Jaccard 相似度:集合交集/并集
function jaccardSimilarity(setA: Set<string>, setB: Set<string>): number {
const intersectionSize = [...setA].filter(x => setB.has(x)).length;
const unionSize = setA.size + setB.size - intersectionSize;
return unionSize === 0 ? 0 : intersectionSize / unionSize;
}
七、时间衰减机制
7.1 设计思想
记忆会随时间"褪色",越旧的记忆权重越低。
公式:
scss
衰减因子 = e^(-λ × age_in_days)
其中 λ = ln(2) / halfLifeDays
7.2 半衰期效果
| 记忆年龄 | 半衰期 30 天 | 半衰期 60 天 |
|---|---|---|
| 0 天 | 100% | 100% |
| 7 天 | 85% | 92% |
| 30 天 | 50% | 71% |
| 90 天 | 13% | 35% |
| 180 天 | 2% | 12% |
7.3 代码实现
typescript
// temporal-decay.ts
export function calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays }) {
// λ = ln(2) / halfLifeDays
// 半衰期 30 天 → λ ≈ 0.023
const lambda = Math.LN2 / halfLifeDays;
// 衰减 = e^(-λ × age)
return Math.exp(-lambda * Math.max(0, ageInDays));
}
// 应用衰减
function applyTemporalDecay(score, ageInDays, halfLifeDays) {
return score * calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays });
}
7.4 常青记忆
以下内容不受时间衰减影响:
MEMORY.md(根目录)memory.md(根目录)memory/目录下非日期命名的文件
typescript
// temporal-decay.ts
function isEvergreenMemoryPath(filePath) {
if (filePath === "MEMORY.md" || filePath === "memory.md") {
return true; // 常青,不衰减
}
// memory/ 下非日期命名也是常青
return filePath.startsWith("memory/") && !isDatedMemoryPath(filePath);
}
八、完整数据流
8.1 写入流(记忆如何被保存)
bash
用户输入 /new(开启新对话)
↓
session-memory hook 触发
↓
读取最近 15 条消息
↓
LLM 生成摘要 + 描述性标题
↓
写入 memory/2026-03-30-contract-history-query.md
↓
chokidar 监控检测到新文件
↓
标记 dirty = true
↓
下次 sync() 时执行:
读取文件 → 分块(按段落)→ 嵌入(调用 AI)→ 存入 SQLite
8.2 读取流(记忆如何被检索)
yaml
用户问:"昨天那个合同金额,加上2022年的"
↓
Agent 自动调用 memory_search 工具
↓
MemoryIndexManager.search()
↓
┌─────────────────┬─────────────────┐
│ 向量搜索 │ 关键词搜索 │
│ chunks_vec │ chunks_fts │
│ 余弦相似度匹配 │ BM25 精确匹配 │
│ score: 0.82 │ textScore: 0.65 │
└────────┬────────┴────────┬────────┘
↓ ↓
混合融合(70% + 30%)
↓
综合分: 0.769
↓
时间衰减(1天几乎无影响)
↓
MMR 去重
↓
返回 Top 10 结果
↓
Agent 看到记忆片段 → 补充查询 2022 年 → 完整回答
九、索引流程源码解析
9.1 文件监控
typescript
// manager-sync-ops.ts
protected ensureWatcher() {
this.watcher = chokidar.watch([
path.join(workspaceDir, "MEMORY.md"),
path.join(workspaceDir, "memory", "**", "*.md"),
], {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 500, // 等 500ms 等写入完成
pollInterval: 100,
},
});
this.watcher.on("add", () => this.dirty = true);
this.watcher.on("change", () => this.dirty = true);
this.watcher.on("unlink", () => this.dirty = true);
}
9.2 分块策略
typescript
// manager-sync-ops.ts
protected splitIntoChunks(content, maxChars = 500) {
// 按段落拆分,每块不超过 500 字符
// 保留行号信息用于引用
const paragraphs = content.split(/\n\n+/);
const chunks = [];
let current = "";
let startLine = 1;
for (const para of paragraphs) {
if (current.length + para.length > maxChars && current.length > 0) {
chunks.push({ text: current, startLine, endLine });
current = "";
}
current += para + "\n\n";
}
return chunks;
}
9.3 向量生成与存储
typescript
// manager-sync-ops.ts
async indexFile(entry, options) {
const chunks = this.splitIntoChunks(content);
for (const chunk of chunks) {
// ① 生成向量
const embedding = await this.provider.embed(chunk.text);
// → [0.023, -0.015, 0.087, ...] 1536维
// ② 存入 chunks 表(原文)
this.db.prepare(`
INSERT INTO chunks (id, path, text, embedding, ...)
VALUES (?, ?, ?, ?, ...)
`).run(chunk.id, entry.path, chunk.text, JSON.stringify(embedding));
// ③ 存入向量表(sqlite-vec 格式,必须是 Float32Array buffer)
const vecBlob = Buffer.from(new Float32Array(embedding).buffer);
this.db.prepare(`
INSERT INTO chunks_vec (id, embedding) VALUES (?, ?)
`).run(chunk.id, vecBlob);
// ④ 存入全文索引(FTS5)
this.db.prepare(`
INSERT INTO chunks_fts (id, text, path, ...) VALUES (?, ?, ?, ...)
`).run(chunk.id, chunk.text, entry.path, ...);
}
}
十、配置参数
10.1 配置文件结构
json
{
"memory": {
"provider": "openai",
"model": "text-embedding-3-small",
"sources": ["memory", "sessions"],
"query": {
"hybrid": {
"enabled": true,
"vectorWeight": 0.7,
"textWeight": 0.3,
"mmr": {
"enabled": false,
"lambda": 0.7
},
"temporalDecay": {
"enabled": false,
"halfLifeDays": 30
}
},
"minScore": 0.3,
"maxResults": 10
}
}
}
10.2 参数说明
| 参数 | 默认值 | 说明 |
|---|---|---|
provider |
"auto" | 嵌入模型:openai / gemini / ollama / local |
model |
- | 具体模型名称 |
sources |
["memory"] | 搜索哪些来源 |
vectorWeight |
0.7 | 向量搜索权重 |
textWeight |
0.3 | 关键词搜索权重 |
mmr.enabled |
false | 是否开启 MMR 去重 |
mmr.lambda |
0.7 | MMR 参数,1=重相关,0=重多样 |
temporalDecay.enabled |
false | 是否开启时间衰减 |
temporalDecay.halfLifeDays |
30 | 半衰期(天) |
minScore |
0.3 | 最低分数阈值 |
maxResults |
10 | 返回结果数量 |
十一、Agent 工具接口
11.1 memory_search
Agent 每次收到消息时自动调用此工具。
typescript
// tools.ts
export function createMemorySearchTool() {
return {
name: "memory_search",
description: "Mandatory recall step: semantically search MEMORY.md + memory/*.md...",
async execute({ cfg, agentId }, params) {
const { query, maxResults, minScore } = params;
const memory = await getMemoryManagerContext({ cfg, agentId });
const results = await memory.manager.search(query, {
maxResults,
minScore,
sessionKey: sessionKey,
});
return jsonResult({ results });
}
};
}
11.2 memory_get
根据路径精确读取记忆文件片段。
typescript
// tools.ts
export function createMemoryGetTool() {
return {
name: "memory_get",
description: "Safe snippet read from MEMORY.md or memory/*.md...",
async execute({ cfg, agentId }, params) {
const { path, from, lines } = params;
const result = await memory.manager.readFile({
relPath: path,
from,
lines,
});
return jsonResult(result);
}
};
}
十二、总结
12.1 核心设计原则
| 原则 | 实现 |
|---|---|
| Markdown 是唯一真相源 | 文件可读可编辑,索引可重建 |
| 向量 + 关键词互补 | hybrid.ts 混合融合 |
| 按需启用高级功能 | MMR 和时间衰减默认关闭 |
| 节省 API 调用 | embedding_cache 缓存已嵌入的内容 |
12.2 记忆系统三问
markdown
1. 记什么? → memory/*.md 文件(手动写入 + 自动提取)
2. 怎么记? → LLM 摘要 + SQLite 向量索引
3. 怎么找? → 混合搜索(向量+关键词)+ MMR + 时间衰减
附录:源码文件索引
| 功能 | 源码文件 |
|---|---|
| 表结构定义 | packages/memory-host-sdk/src/host/memory-schema.ts |
| 核心管理器 | extensions/memory-core/src/memory/manager.ts |
| 向量搜索 | extensions/memory-core/src/memory/manager-search.ts |
| 混合融合 | extensions/memory-core/src/memory/hybrid.ts |
| MMR 去重 | extensions/memory-core/src/memory/mmr.ts |
| 时间衰减 | extensions/memory-core/src/memory/temporal-decay.ts |
| 嵌入生成 | extensions/memory-core/src/memory/embeddings.ts |
| 文件同步 | extensions/memory-core/src/memory/manager-sync-ops.ts |
| Agent 工具 | extensions/memory-core/src/tools.ts |
| 会话钩子 | src/hooks/bundled/session-memory/handler.ts |
| 工具注册入口 | extensions/memory-core/index.ts |