跳到主要内容

记忆系统:SQLite 混合检索 + 时间衰减 + MMR

30 秒导读: Dexter 是"金融版 Claude Code"。会话会结束、上下文窗口会被压缩,但用户的 理财目标、风险偏好、持仓规则不该跟着蒸发。记忆系统就是它的"长期记忆":把耐久知识写成 人可读的 Markdown 文件(真源),在旁边用 SQLite 建一份向量 + 关键词索引(可重建的派生物), 检索时用一套"混合打分 → 时间衰减 → MMR 去重"的三级重排,每次只捞回最相关、又不互相重复 的少量片段喂给模型。本章讲透这一整条链路。

scope(本章边界):跨会话的持久知识。 只讲 src/memory/ 这个子系统本身:它怎么存、怎么 建索引、怎么检索、怎么自动沉淀。不讲上下文压缩循环在哪里被触发(那是 03 上下文工程 的事),也不讲系统提示整体是怎么装配的(那是 01 核心主循环 的事)——本章 只在"记忆被谁调用"的接缝处轻点一下,不重复它们。


1. 这是什么(零基础也能懂)

一句话定义: 记忆系统是 Dexter 的"磁盘版大脑"——把值得长期记住的事实写进 Markdown 文件, 再让 agent 用语义搜索把它们随时捞回来。

它解决谁的什么问题。 大模型的上下文窗口是"内存":容量有限、一关机(会话结束或被压缩) 就清空。可理财是长期的事——用户上周说"我风险偏好保守、单只股票不超过仓位 5%",这周问"能买 点英伟达吗",agent 必须还记得那条规则。记忆系统就是那块关机不丢的磁盘

一个熟悉的类比:

概念类比在 Dexter 里
上下文窗口内存(RAM),快但易失当前会话的对话历史
记忆文件磁盘,慢但持久.dexter/memory/*.md
语义搜索磁盘的"按内容找文件"memory_search 工具

它能做什么:

  • 持久记住用户的耐久事实与偏好(理财目标、风险容忍度、持仓规则、过往决策)。
  • 语义 + 关键词混合检索:既能按"意思相近"找,也能按"关键词精确"找。
  • 偏爱新知识:同样相关时,最近记的排在陈旧的前面(时间衰减)。
  • 去冗余:一次返回的几条结果彼此不重复(MMR)。
  • 自动沉淀:在上下文快被压缩前,自动把这轮里的耐久事实摘进当日记忆,免得随压缩丢失。
  • 回忆过往对话:连从没显式保存过的历史对话,也被索引进来、可被搜到。

用起来什么样。 对模型而言,记忆就是三个工具。最典型的一次交互:

用户:英伟达最近能买吗?
→ agent 先调用 memory_search("英伟达 风险偏好 仓位规则")
→ 捞回:「## 用户偏好\n- 风险保守;单只个股不超过总仓位 5%」
→ agent 据此回答:结合你"单股≤5%"的规则,当前……(个性化,而非泛泛而谈)

用户:记住我把 401k 的目标退休年龄改成 60 岁了。
→ agent 调用 memory_update(content="退休目标年龄:60")
→ 追加进 MEMORY.md,下次会话仍在。

一句话直觉: 把记忆想成一个带语义搜索的笔记本——Markdown 是笔记本本身(人能直接翻、 能手改),SQLite 索引是贴在旁边的"智能标签",让 agent 不用读完整本就能翻到相关那几页。


2. 顶层全景(它大概怎么转)

记忆系统有两条互不阻塞的数据流:写入流(知识落进 Markdown)和检索流(把知识捞回来); 中间夹着一条后台索引流(把 Markdown 同步进 SQLite)。

怎么读下面这张图: 左边是"真源"(人可读文件),右边是"派生索引"(可重建);上半是写入、 下半是检索;中间的 MemoryIndexer 是把左边同步到右边的后台工。

写入侧(真源,人可读) 后台同步 检索侧(派生,可重建)
┌──────────────────────────┐ ┌───────────────────────────┐
│ .dexter/memory/ │ 文件变化 │ SQLite index.sqlite │
│ MEMORY.md (常青) │ ── watch+防抖 ──▶ │ chunks (向量+关键词) │
│ 2026-07-14.md (当日) │ │ chunks_fts (FTS5 全文) │
│ 2026-07-13.md ... │ ◀── chunker 分块 │ embedding_cache (缓存) │
└──────────────────────────┘ + 嵌入 upsert └───────────────────────────┘
▲ │
│ append/edit/delete │ 向量候选 + 关键词候选
│ (memory_update) ▼
│ ┌───────────────────────────┐
┌────┴───────────┐ loadSessionContext │ hybridSearch │
│ 三个记忆工具 │◀── 装进系统提示 (→01) │ ①合并 ②过滤 ③衰减 ④MMR ⑤topK│
│ search/get/ │ └───────────────────────────┘
│ update │──── memory_search ──────────────────────▶ 返回 top-K 片段
└────────────────┘

部件一句话职责:

部件干什么文件
MemoryManager单例门面;初始化、聚合、对外暴露 search/get/updatesrc/memory/index.ts
MemoryStoreMarkdown 文件存储:读写/编辑/删除、会话上下文装配、目录逃逸防护src/memory/store.ts
MemoryIndexer后台工:watch 文件、防抖、脏标记同步、分块 + 嵌入 upsertsrc/memory/indexer.ts
chunkMemoryText把 Markdown 按段落切成带行号的块src/memory/chunker.ts
MemoryDatabaseSQLite 封装:向量表 + FTS5 关键词表 + 嵌入缓存 + 指纹src/memory/database.ts
createEmbeddingClient按 provider(openai/gemini/ollama/auto)造嵌入客户端src/memory/embeddings.ts
hybridSearch检索核心:五阶段混合打分与重排src/memory/search.ts
applyTemporalDecay按记忆年龄做指数衰减,常青文件豁免src/memory/temporal-decay.ts
applyMMRToHybridResults相关性与多样性平衡,去重src/memory/mmr.ts
runMemoryFlush压缩前把耐久事实摘进当日记忆src/memory/flush.ts
三个工具暴露给模型的 memory_search/memory_get/memory_updatesrc/tools/memory/*

主线走一遍(高层,不进代码):

  1. 写: 用户说"记住 X" → memory_updateMemoryStore 把 X 追加进 MEMORY.md → 顺手给索引器打脏标记
  2. 同步: MemoryIndexer 要么被文件 watch(防抖后)唤醒、要么在下次搜索前发现"脏"了, 把改动的文件重新分块 → 嵌入 → upsert 进 SQLite
  3. 读: 用户下次提问 → memory_searchhybridSearch 从 SQLite 取向量候选 + 关键词候选 → 合并打分 → 时间衰减 → MMR 去重 → 返回 top-K → 拼进上下文供模型回答。

一句话把两侧的关系钉牢:Markdown 是真源,SQLite 只是可随时重建的加速索引——删掉 index.sqlite,下次启动会从 Markdown 重新建好,一个字都不丢。


3. 存储与门面:一切从 Markdown 开始

3.1 门面:MemoryManager 单例

对外,整个子系统只有一个入口——MemoryManager。它是懒加载单例:第一次 get() 时 构造 + 初始化,之后复用同一实例。见 src/memory/index.ts:58-65MemoryManager.get

初始化(src/memory/index.ts:74-117initialize)按固定顺序把三大件串起来:

  1. 确保 .dexter/memory/ 目录存在(store.ensureDirectoryExists)。
  2. 造嵌入客户端(createEmbeddingClient)。
  3. 打开 SQLite(MemoryDatabase.create),用 try/catch 兜底——建库失败只记 initError、 降级为"记忆不可用",绝不让 agent 崩(src/memory/index.ts:88-95)。
  4. 对比 provider 指纹:若当前嵌入模型和库里记录的不一致,清空所有旧向量再重记指纹 (src/memory/index.ts:97-101)——见 §4.2
  5. MemoryIndexer、开始 watch、跑一次 sync

配置的默认值(resolveConfig)。 配置来自"内置默认 + 用户 settings 覆盖"的浅合并, 但对嵌套的 temporalDecay / mmr 做了逐层深合并,免得用户只想改半衰期却把整个衰减配置 覆盖没了(src/memory/index.ts:45-53)。关键默认值(DEFAULT_CONFIG,index.ts:18-33):

配置默认含义
maxResults6每次检索最终返回几条
minScore0.1合并分低于此丢弃
vectorWeight / textWeight0.7 / 0.3向量 vs 关键词的权重
chunkTokens / chunkOverlapTokens400 / 80分块目标大小 / 重叠
maxSessionContextTokens2000开局塞进提示的记忆预算
watchDebounceMs1500文件变化防抖
temporalDecay.halfLifeDays30衰减半衰期
mmr.lambda0.7相关性 vs 多样性 的天平

文件别名。 门面把两个人性化别名翻译成真实文件名(resolveFileAlias,index.ts:221-225):

  • long_termMEMORY.md(耐久事实、偏好)
  • daily → 今天的 YYYY-MM-DD.md

所以工具层可以只说 file="daily",不必自己算日期。凡是改动记忆的方法(append/edit/delete) 成功后都会 indexer?.markDirty(),把"该重新索引了"这件事记下(见 index.ts:184-209)。

3.2 存储:两类 Markdown 文件

MemoryStore 管的就是 .dexter/memory/ 下的一堆 .md,分两类:

  • MEMORY.md——长期、常青:耐久事实和偏好,不随时间衰减(见 §5.1)。
  • YYYY-MM-DD.md——每日日志:当天发生的、带时间语义的记录,会随年龄衰减。

isManagedMemoryFile(store.ts:88-90)用一条正则 ^\d{4}-\d{2}-\d{2}\.md$ 加上 MEMORY.md 来界定"哪些文件归我管"——这条判断后面 watch 时很关键(见 §4.1)。

读写都是纯文本操作。 append 会智能补换行(store.ts:57-61);edit/delete 走最朴素的 字符串 includes + replace,找不到就返回 false(store.ts:63-81):

// src/memory/store.ts:63 editInMemoryFile —— 示意其精神(真源码稍长)
async editInMemoryFile(path, oldText, newText) {
const content = await this.readMemoryFile(path);
if (!content.includes(oldText)) return false; // 匹配不到就明说失败
await this.writeMemoryFile(path, content.replace(oldText, newText));
return true;
}

这份"朴素"是刻意的:记忆文件是人可读、可手改的真源,编辑语义就该和"人拿记事本查找替换" 一样直白;delete 还顺手把连续空行折叠回双换行(store.ts:78),保持文件整洁。

3.3 目录逃逸防护:resolveMemoryPath

工具允许模型传任意 path(比如 memory_get(path="..."))。要是模型被诱导传 ../../etc/passwd 呢?resolveMemoryPath(store.ts:148-157)就是那道闸:把路径归一化后, 算它相对记忆目录的相对路径,只要逃出目录就抛错

// src/memory/store.ts:148 resolveMemoryPath —— 目录逃逸防护(示意)
const rel = relative(memoryDir, candidate);
if (rel.startsWith('..') || rel.includes('/../') || rel === '..') {
throw new Error(`Path is outside memory directory: ${path}`);
}

重点看:所有 readMemoryFile / writeMemoryFile 都先过这一关,所以记忆读写被牢牢关在 .dexter/memory/,越不出去。

3.4 开局装配:loadSessionContext 按 token 预算取记忆

会话一开始,agent 需要"先知道点关于用户的事"再开口。loadSessionContext(store.ts:118-146) 干这个:它按固定优先级挑候选文件——MEMORY.md → 今天 → 昨天——依次往里塞,一旦加上 某段会超出 token 预算(默认 2000)就跳过该段,保证塞进系统提示的记忆有上界。

// src/memory/store.ts:121 候选顺序:长期 → 今天 → 昨天
const candidates = [LONG_TERM_FILE, formatDailyFileName(),
formatDailyFileName(new Date(Date.now() - 86_400_000))];
// 逐个累加,超预算即 continue(不是 break),让后面更短的段还有机会挤进来

这段被 01 主循环 在开局装配系统提示时调用(src/agent/agent.ts:106)—— 装配的整体逻辑归 01 讲,本章只提供这块"料"。注意优先级:长期偏好永远优先于当日流水, 这样即便预算紧张,agent 也先记住"你是谁",再记住"今天聊了啥"。


4. 索引管线:把 Markdown 同步进 SQLite

真源是 Markdown,但语义搜索搜不动纯文本文件——得先切块、算嵌入、灌进能算相似度的库。 这条"文件 → 块 → 向量 → SQLite"的流水线,由 MemoryIndexer 驱动。

4.1 watch + 防抖:别把索引自己的写入当成变化

索引器启动时(indexer.ts:38-68startWatching)挂两个 fs.watch:一个盯记忆目录、 一个盯聊天历史目录。文件一变,不立刻同步,而是防抖:清掉上一个定时器、重设一个 watchDebounceMs(1500ms)后触发的(scheduleDebouncedSync,indexer.ts:70-78)——用户 连改几下只触发一次同步。

这里有个精妙的自反循环防护index.sqlite 和它的 -wal/-shm 边车文件,就住在被 watch 的同一个记忆目录里。每次同步都会写这个库——要是 watch 也对这些写入起反应,就会 watch → sync → 写库 → watch → … 无限自激。所以 watch 回调先过一道 isManagedMemoryFile 过滤,只对 MEMORY.md 和日志文件的变化反应,忽略 SQLite 边车(indexer.ts:50-55):

// src/memory/indexer.ts:50 只对"被管理的 md 文件"反应,否则会自激死循环
this.watcher = watch(this.store.getMemoryDir(), { recursive: false }, (_event, filename) => {
if (filename && !this.store.isManagedMemoryFile(filename.toString())) return; // 忽略 index.sqlite 等
this.scheduleDebouncedSync();
});

4.2 脏标记:watch 之外的第二重保险

watch 不是唯一触发同步的路径。MemoryIndexer 维护一个 dirty 布尔(初值 true)。凡是通过 工具改了记忆的地方,门面都会 markDirty()。于是搜索前有一道检查:若脏了,先同步再搜 (src/memory/index.ts:143-145):

// src/memory/index.ts:143 搜索前若脏则先同步,保证搜到的是最新的
if (this.indexer.isDirty()) {
await this.indexer.sync();
}

这是"watch 可能有延迟/丢事件"的兜底——即便文件事件没来,只要有人写过记忆,下次搜索也一定 先把索引补齐。sync 自己还有两层保护:非强制且不脏就直接返回空统计;并发调用复用同一个 在飞的 Promise(indexer.ts:91-107),不会叠着跑。

4.3 分块:按段落切,带行号

chunkMemoryText(chunker.ts:62-139)把一份 Markdown 切成若干块。思路很朴素但有讲究:

  • 先按空行切成段落,每段带 startLine/endLine 行号(splitIntoParagraphs,chunker.ts:22-56)—— 行号一路带到检索结果里,memory_get 才能精确定位。
  • 贪心攒段成块:一段段往当前块里加,直到字符数超过预算就收口。预算由 token 数换算, 用 tokens * 3.5 的近似(tokenToCharBudget,chunker.ts:11-14)。
  • 块间重叠:下一块从上一块末尾回退约 overlapTokens 的字符开始(chunker.ts:117-135), 避免把一个完整语义正好切在块边界、两边都搜不全。
  • 强制前进:当单个段落比重叠预算还大时,强制至少前进一段,防止死循环(chunker.ts:131-134)。

每块算一个 sha256 内容哈希(chunker.ts:58-60)——这是后面缓存和幂等 upsert 的钥匙。

4.4 嵌入与 upsert:哈希去重、按需调 API

embedAndUpsertChunks(indexer.ts:207-253)是同步的干活主体,核心是能不调嵌入 API 就不调:

  1. 先按内容哈希查 embedding_cache,只对没缓存过的块批量调嵌入 API(indexer.ts:211-215)。
  2. 新算出的向量写回缓存。
  3. 每块 upsertChunk:按 content_hash 判存在则更新、否则插入(database.ts:218-275), 同时维护 FTS5 全文表。

因为块以内容哈希为身份,没改动的段落哈希不变、命中缓存、零 API 调用;只有真正改了的段落 才会重新嵌入。这让"改一行 MEMORY.md"的增量同步极其便宜。

4.5 SQLite:一库三表

MemoryDatabase(database.ts)封装一切 SQL。运行时优先用 bun:sqlite,回退到 better-sqlite3(database.ts:135-163)——同一套接口两种驱动。库里三张表(schema 见 database.ts:38-72):

作用关键列
chunks主表:每块的文本、行号、嵌入向量(BLOB)、来源、更新时间content_hash(唯一)、embeddingsourceupdated_at
chunks_ftsFTS5 虚拟表:关键词全文检索contentchunk_id
embedding_cache按内容哈希缓存向量,跨文件复用content_hash(主键)

向量以 Float32Array 序列化成 BLOB 存(toBlob/fromBlob,database.ts:74-82)。

provider 指纹:换了模型就作废旧向量。 不同嵌入模型产出的向量不在同一个空间,混着算 余弦相似度是胡来。所以库里在 meta 表存一条 provider_fingerprint(形如 openai:text-embedding-3-small)。 初始化时一比,不一致就 clearEmbeddings() 清空所有向量、重记指纹(index.ts:97-101 调用, database.ts:182-185 实现)。清空后下次同步会用新模型重新嵌入——安全但代价明确。


5. 检索算法:hybridSearch 五阶段(本章核心)

检索是记忆系统的心脏。hybridSearch(search.ts:31-145)把"向量搜"和"关键词搜"两路候选 揉在一起,再过三级重排。先给全景,再逐阶段拆。

怎么读这张流水线: 从上到下是数据流动方向,每一格是一个阶段,右侧是它解决的问题。

query

├──▶ 向量候选 searchVector (余弦相似, top 4×K) ┐
│ ├─ 两路各取 4×maxResults 个候选
└──▶ 关键词候选 searchKeyword (FTS5 bm25, top 4×K) ┘

①合并打分 finalScore = 向量分·w_v + 关键词分·w_t ← 按"哪路有结果"归一化权重

②minScore 过滤 丢掉 finalScore < 0.1 的 ← 砍掉噪声

③时间衰减 score ×= 半衰期指数衰减 ← 越新越值钱;MEMORY.md 豁免
│ (重排)
④MMR 去重 λ·相关 −(1−λ)·与已选最大 Jaccard 相似 ← 相关又不重复

⑤top-K slice(0, maxResults) ← 最终只留 6 条

返回结果

两路候选各取 maxResults * 4 个(search.ts:47),故意多取——因为后面的衰减和 MMR 会重排, 候选池太小就没得选。

5.1 阶段①:按可用路径归一化权重合并

默认向量:关键词 = 0.7:0.3。但有个务实的边界处理:若某一路没结果(比如没配嵌入 API, 向量路为空),就把全部权重给能跑的那一路,免得可用那路的分被对半砍、白白被 minScore 滤掉 (search.ts:55-62):

// src/memory/search.ts:57 哪路没候选,就把权重全给另一路
const weights =
hasVector && hasKeyword ? normalizeWeights(vectorWeight, textWeight) // 两路都在:0.7/0.3
: hasVector ? { vector: 1, text: 0 } // 只有向量
: { vector: 0, text: 1 }; // 只有关键词

合并用一个 Map<chunkId, {vectorScore, keywordScore}>:同一块被两路都命中就各记一份分, 最后 finalScore = 向量分·w_v + 关键词分·w_t(search.ts:64-92)。结果还带一个 source 字段标注它是 vector/keyword/both(search.ts:112-117)。

两路的原始分怎么来:

  • 向量分:查询嵌入和每块嵌入的余弦相似度,库里全表算再排序取 top(cosineSimilarity
    • searchVector,database.ts:97-114299-317)。这是暴力线性扫描——记忆量不大,够用。
  • 关键词分:FTS5 的 bm25 排名,再压成 1/(1+max(0,rank)) 落到 (0,1](searchKeyword, database.ts:319-333)。FTS 查询把 token 用 AND 连接、逐个加引号,追求精确——因为 召回的广度交给向量路了(buildFtsQuery,database.ts:86-95)。

5.2 阶段②:minScore 过滤

合并后按 finalScore >= minScore(默认 0.1)砍一刀、降序排(search.ts:86-92)。这是第一道 噪声闸门:分太低的块根本不进入后续昂贵的重排。

5.3 阶段③:时间衰减——越新越值钱,常青文件豁免

它要解决的小问题: 两条记忆同样相关,但一条是三个月前的旧决策、一条是昨天的新决策—— 理财场景下,新的通常更该信。可"用户永久偏好"这种事又不该因为记得早就掉价。

思路: 对带日期语义的记忆做指数衰减,对常青记忆豁免

衰减用经典半衰期公式(temporal-decay.ts):multiplier = exp(-λ · ageInDays),其中 λ = ln2 / halfLifeDays(toDecayLambda,temporal-decay.ts:21-26)。半衰期默认 30 天—— 意味着一条 30 天前的日志,相关性权重打对折

// src/memory/temporal-decay.ts:28 年龄越大,乘子越小(半衰期指数衰减)
const lambda = Math.LN2 / halfLifeDays; // 30 天 → 每天衰一点
return Math.exp(-lambda * clampedAge); // age=0→1;age=30→0.5;age=60→0.25

哪些豁免、哪些衰减?extractTimestampMs(temporal-decay.ts:81-102)按路径判定年龄来源:

记忆种类年龄怎么算是否衰减
MEMORY.md(及非日期命名的话题文件)无时间戳 → 返回 null豁免,永远满分
YYYY-MM-DD.md 日志从文件名解析日期按日历年龄衰减
sessions/ 会话块用 DB 里的 updatedAt(索引时间)按索引年龄衰减

isEvergreenFile(temporal-decay.ts:66-72)的判据很妙:凡不是"日期命名"的记忆文件,一律 当常青——所以你随手建个 goals.md 也自动不衰减。衰减后重新排序(search.ts:128-134), 让新鲜记忆浮上来。

5.4 阶段④:MMR——相关又不重复

它要解决的小问题: 前几名很可能是"同一件事的近义复述"。比如用户在三天里三次提到"我想 退休时有 200 万",三条块都高分挤在前面——返回它们等于浪费了 3 个宝贵名额说同一件事

思路: 用 MMR(Maximal Marginal Relevance,最大边际相关性)——每选一条,都惩罚它与 已选结果的重复度,逼着结果去覆盖不同的点。

打分公式(computeMMRScore,mmr.ts:82-84):

MMR(候选) = λ · 相关性 − (1−λ) · 该候选与"已选集"里的最大相似度
└── λ=0.7:七分看相关,三分看新意 ──┘

算法是贪心迭代(mmrRerank,mmr.ts:94-155):

  1. 先把所有候选的相关性分归一化到 [0,1](和相似度同量纲才好加减)。
  2. 每轮从剩余候选里,选 MMR 分最高的那条放进已选;平分时选原始相关性更高的。
  3. 重复到选完。

相似度用 Jaccard(jaccardSimilarity,mmr.ts:37-57):把两块文本各自 tokenize 成词集合, 算 交集/并集。为效率,所有候选的词集合预先算好缓存(mmr.ts:107-111)。

// src/memory/mmr.ts:37 Jaccard = 交集 / 并集,衡量两块文本有多"像"
return unionSize === 0 ? 0 : intersectionSize / unionSize; // ∈ [0,1],1=一模一样

两个边界处理值得记:λ=1 时退化成纯按相关性排(mmr.ts:103-105);候选 ≤1 条直接原样返回。 喂给 MMR 的是衰减后的 top maxResults*2 条(search.ts:139)——又是"多给候选,让它有得挑"。

5.5 阶段⑤:top-K

MMR 重排后 slice(0, maxResults)(search.ts:144),最终只留 6 条。至此一条 query 走完 全部五阶段:两路召回 → 加权合并 → 阈值过滤 → 时间衰减 → MMR 去重 → 截断。

把五阶段的"取多少"连起来看,是一条不断收窄的漏斗:

阶段候选规模收窄理由
召回每路 4×K = 24多召回,给重排留余地
合并 + 过滤≤ 48 去重后再砍 minScore去噪
衰减重排取前 2×K = 12 喂 MMR平衡质量与 MMR 成本
MMR + top-KK = 6最终给模型的少量精华

6. 写回与自动沉淀:记忆怎么"长出来"

记忆不只靠用户显式说"记住"。Dexter 还有一条自动沉淀通道,和一组给模型主动用的工具。

6.1 压缩前的自动 flush

问题: 上下文快满了要被压缩(03 上下文工程 负责触发压缩), 压缩会丢掉细节。这一轮里聊出来的耐久事实(比如"我刚把风险偏好调激进了")要是没落盘,就随 压缩蒸发了。

runMemoryFlush(flush.ts:39-69) 就是那道"压缩前的抢救":在压缩发生前,用一段 MEMORY_FLUSH_PROMPT(flush.ts:7-25)让模型把本轮里值得长期记住的耐久事实和用户偏好 摘成 markdown bullet,追加进当日记忆文件。

prompt 里对金融场景做了强定向:优先抓理财目标、风险偏好、持仓决策、交易理由、账户信息; 明确排除临时工具输出、行情、股价(flush.ts:12-23)。

NO_MEMORY_TO_FLUSH 令牌: 若这轮没什么值得存,模型被要求原样回一个哨兵字符串 NO_MEMORY_TO_FLUSH;代码见到它(或空回复)就跳过写入(flush.ts:562-64)。这是个 干净的"什么都不做"信号,免得往记忆里灌空话。

// src/memory/flush.ts:62 哨兵令牌:模型说"没啥可存"就不写
if (!response || response === MEMORY_FLUSH_TOKEN) {
return { flushed: true, written: false };
}
await manager.appendDailyMemory(`## Pre-compaction memory flush\n${response}`);

触发点在哪归 03 讲。 flush 由主循环在压缩前调用(src/agent/agent.ts:577-591),配一个 alreadyFlushed 状态防止一轮里重复 flush(shouldRunMemoryFlush,flush.ts:27-37)。本章 只讲 flush 做了什么;它何时被触发属于压缩循环,见 03

6.2 三个工具:模型主动读写记忆的手

记忆对模型的暴露面,就是 src/tools/memory/ 下三个 LangChain 工具:

工具干什么关键设计
memory_search语义 + 关键词混合检索不可用时优雅降级返回 disabled,不报错崩(memory-search.ts:33-39)
memory_get按路径读记忆文件的精确行段供 search 之后"取原文验证"用
memory_updateappend/edit/delete默认极简:只传 content 即 append 进 MEMORY.md(memory-update.ts:23-33)

memory_update 的默认值设计很体贴模型:action 默认 appendfile 默认 long_term, 所以"记住 X"这个最常见场景只需一个 content 参数(memory-update.ts:42-49)。edit/delete 失败时返回的错误信息还会提示模型先用 memory_get 核对原文(memory-update.ts:92-93)—— 因为底层匹配是精确字符串。

工具存在还不够,得让模型知道何时用。系统提示里写死了一条硬策略 (src/agent/prompts.ts:105-108):

在给任何个性化理财建议——买卖决策、组合建议、选股、仓位——之前,必须先调 memory_search,召回用户的目标、风险偏好、仓位上限和过往决策。

memory_search 的工具描述本身也把这条重复了一遍("ALWAYS before giving personalized financial advice",memory-search.ts:11)。这就是记忆系统真正的价值闭环:存得住 + 搜得回 + 模型被强制在关键时刻去搜——三者齐了,agent 才真的"记得你是谁"。


7. 巧妙之处(可借鉴的技术)

  • 真源/派生分离,索引可弃可重建。 Markdown 是唯一真源,SQLite 只是加速层——删库不丢数据、 下次从 md 重建(index.ts:82-113)。这让记忆人可读、可 git、可手改,又不牺牲检索。
  • watch 的自反循环防护。 SQLite 边车文件和被 watch 的目录同处一地,用 isManagedMemoryFile 白名单过滤,避免"写库→触发 watch→再同步"的死循环(indexer.ts:50-55)。这是"监听自己会 写的目录"时的通用坑,值得记。
  • provider 指纹作废机制。 换嵌入模型即向量空间失效,用一条 meta 指纹自动检测并清空 (index.ts:97-101)——简单一招堵住"混用不同模型向量算相似度"的隐形错误。
  • 可用路径归一化权重。 缺嵌入 API 时不让向量路的空结果拖累关键词分,而是把权重全给能跑 的那路(search.ts:57-62)——优雅降级而非硬失败。
  • 内容哈希驱动的嵌入缓存。 块以 sha256 为身份,没改的段落命中缓存零 API 调用 (indexer.ts:211-215)——增量同步几乎免费。
  • 三级重排各司其职。 混合召回管"广度",时间衰减管"新鲜度",MMR 管"多样性"——把三个 正交目标拆成三个独立、可各自开关的阶段(search.ts:126-141),干净且好调。

8. 边界与局限(诚实说)

  • 向量搜索是全表线性扫描。 searchVector 把所有带嵌入的块拉出来逐个算余弦 (database.ts:299-317),没有 ANN 近似索引。记忆规模适中时没问题,量大了会变慢 (inferred:代码里未见分页或近似索引)。
  • edit/delete 是精确字符串匹配。 找不到子串就整体失败(store.ts:63-81),没有模糊匹配; 这也是工具反复提示"先 memory_get 核对原文"的原因。
  • 衰减依赖文件名日期。 日期语义来自 YYYY-MM-DD.md 命名(temporal-decay.ts:40-64)。 手动建的非日期文件一律当常青、永不衰减——是特性也是约束。
  • 嵌入有超时。 单次嵌入调用 15s 超时(embeddings.ts:1063);外部 API 慢或挂了, 该轮嵌入会失败,但初始化对建库失败有 try/catch 降级(index.ts:88-95),记忆整体不至于崩。
  • 无并发写锁语义暴露。 存储层是"读-改-写"整文件(store.ts:57-81),多写并发的一致性 依赖调用侧串行(inferred)。

9. 横向对比(同组其它章怎么接)

记忆系统不孤立运行,它嵌在 Dexter 这台"金融版 Claude Code"里:

  • 被主循环调用: 开局 loadSessionContext 装进系统提示 → 见 01 核心主循环
  • 作为工具被路由: memory_search/get/update 走元工具路由和权限闸门 → 见 02 工具与权限
  • 与压缩协作: runMemoryFlush 在压缩前抢救耐久事实,触发点在压缩循环里 → 见 03 上下文工程。这是全书上下文工程精华与记忆的接缝。
  • 交付侧无关: 子 agent、Skill、无人值守交付分别见 0506

一句话定位:上下文窗口是易失内存,记忆系统是持久磁盘;压缩(03)负责"腾内存",记忆负责 "别把该长期记的东西一起腾掉"。


10. 代码地图(导航索引)

主题文件路径符号名
单例门面 / 初始化 / 配置默认src/memory/index.tsMemoryManagerresolveConfigDEFAULT_CONFIG
文件别名(long_term/daily)src/memory/index.tsresolveFileAliasgetTodayFileName
Markdown 存储 / 编辑 / 删除src/memory/store.tsMemoryStoreeditInMemoryFiledeleteFromMemoryFile
会话上下文按 token 预算装配src/memory/store.tsloadSessionContext
目录逃逸防护src/memory/store.tsresolveMemoryPath
被管理文件判定src/memory/store.tsisManagedMemoryFile
watch + 防抖 + 脏同步src/memory/indexer.tsMemoryIndexerscheduleDebouncedSyncperformSync
哈希去重嵌入 upsertsrc/memory/indexer.tsembedAndUpsertChunks
按段落分块 + 重叠src/memory/chunker.tschunkMemoryTextsplitIntoParagraphs
SQLite 三表 / 驱动回退src/memory/database.tsMemoryDatabaseopenSqliteCREATE_SCHEMA_SQL
provider 指纹作废src/memory/database.tsgetProviderFingerprintclearEmbeddings
向量 / 关键词检索src/memory/database.tssearchVectorsearchKeywordcosineSimilarity
多 provider 嵌入客户端src/memory/embeddings.tscreateEmbeddingClientresolveProviderembedSingleQuery
混合检索五阶段(核心)src/memory/search.tshybridSearchnormalizeWeights
时间衰减(半衰期 / 常青豁免)src/memory/temporal-decay.tsapplyTemporalDecaytoDecayLambdaisEvergreenFile
MMR 去重(Jaccard)src/memory/mmr.tsapplyMMRToHybridResultsmmrRerankjaccardSimilarity
压缩前自动 flushsrc/memory/flush.tsrunMemoryFlushMEMORY_FLUSH_TOKENshouldRunMemoryFlush
三个记忆工具src/tools/memory/*.tsmemorySearchToolmemoryGetToolmemoryUpdateTool
"先 search 再建议"策略src/agent/prompts.ts系统提示 ## Memory 段(:105-108)