AI Agent 记忆系统设计与实现深度解析

前言

本文基于 OpenClaw 开源项目的 src/memory 模块,完整拆解了一个生产级 AI Agent 记忆系统的设计与实现。

OpenClaw 是一个本地优先的个人 AI 助手,支持 WhatsApp、Telegram、Slack 等多种消息通道。它的记忆模块让 Agent 能够跨会话记住用户的偏好、讨论过的方案、做出的决策------而不是每次对话都从零开始。

该模块的核心思路是:Markdown 文件给人读写,向量索引给机器检索。人类用编辑器维护记忆文件,系统自动构建向量 + 全文索引,Agent 通过混合搜索(向量语义 + BM25 关键词)在记忆中找到相关内容。整套系统从文件监听、分块、嵌入、存储、搜索、结果优化到多层降级容错,形成了完整的闭环。

阅读引导

章节 关注点 适合谁
1. 为什么需要记忆系统 问题定义 所有人
2. 架构总览 全局视角,理解各模块关系 所有人
3. 记忆的生成 写入来源、分块策略、嵌入 Provider、数据库设计 想自己实现的工程师
4. 记忆的同步 文件监听、增量/全量同步、安全重建 关心数据一致性的工程师
5. 记忆的检索 混合搜索、向量/关键词双路径、结果优化 关心搜索质量的工程师
6. 降级与容错 Provider Fallback、后端主备、FTS-only 降级 关心可用性的工程师
7. 成本控制 嵌入缓存、Batch API、本地模型 关心 API 费用的工程师
8. 核心设计决策 5 个关键 Why 的权衡分析 做架构选型的技术负责人
9. 参考价值总结 10 个可复用模块 + 完整文件清单 想借鉴落地的工程师

如果时间有限,建议至少阅读第 1、2、8 章,可以在 15 分钟内掌握核心设计思路。


目录

  • [1. 为什么需要记忆系统](#1. 为什么需要记忆系统 "#1-%E4%B8%BA%E4%BB%80%E4%B9%88%E9%9C%80%E8%A6%81%E8%AE%B0%E5%BF%86%E7%B3%BB%E7%BB%9F")
  • [2. 架构总览](#2. 架构总览 "#2-%E6%9E%B6%E6%9E%84%E6%80%BB%E8%A7%88")
  • [3. 记忆的生成(写入)](#3. 记忆的生成(写入) "#3-%E8%AE%B0%E5%BF%86%E7%9A%84%E7%94%9F%E6%88%90%E5%86%99%E5%85%A5")
    • [3.1 记忆文件的来源](#3.1 记忆文件的来源 "#31-%E8%AE%B0%E5%BF%86%E6%96%87%E4%BB%B6%E7%9A%84%E6%9D%A5%E6%BA%90")
    • [3.2 索引构建流程](#3.2 索引构建流程 "#32-%E7%B4%A2%E5%BC%95%E6%9E%84%E5%BB%BA%E6%B5%81%E7%A8%8B")
    • [3.3 文本分块策略](#3.3 文本分块策略 "#33-%E6%96%87%E6%9C%AC%E5%88%86%E5%9D%97%E7%AD%96%E7%95%A5")
    • [3.4 向量嵌入与多 Provider 支持](#3.4 向量嵌入与多 Provider 支持 "#34-%E5%90%91%E9%87%8F%E5%B5%8C%E5%85%A5%E4%B8%8E%E5%A4%9A-provider-%E6%94%AF%E6%8C%81")
    • [3.5 数据库 Schema 设计](#3.5 数据库 Schema 设计 "#35-%E6%95%B0%E6%8D%AE%E5%BA%93-schema-%E8%AE%BE%E8%AE%A1")
  • [4. 记忆的同步(更新)](#4. 记忆的同步(更新) "#4-%E8%AE%B0%E5%BF%86%E7%9A%84%E5%90%8C%E6%AD%A5%E6%9B%B4%E6%96%B0")
    • [4.1 同步触发机制](#4.1 同步触发机制 "#41-%E5%90%8C%E6%AD%A5%E8%A7%A6%E5%8F%91%E6%9C%BA%E5%88%B6")
    • [4.2 增量同步与全量重建](#4.2 增量同步与全量重建 "#42-%E5%A2%9E%E9%87%8F%E5%90%8C%E6%AD%A5%E4%B8%8E%E5%85%A8%E9%87%8F%E9%87%8D%E5%BB%BA")
    • [4.3 安全重建索引](#4.3 安全重建索引 "#43-%E5%AE%89%E5%85%A8%E9%87%8D%E5%BB%BA%E7%B4%A2%E5%BC%95")
  • [5. 记忆的检索(读取)](#5. 记忆的检索(读取) "#5-%E8%AE%B0%E5%BF%86%E7%9A%84%E6%A3%80%E7%B4%A2%E8%AF%BB%E5%8F%96")
    • [5.1 两种 Agent Tool](#5.1 两种 Agent Tool "#51-%E4%B8%A4%E7%A7%8D-agent-tool")
    • [5.2 混合搜索引擎](#5.2 混合搜索引擎 "#52-%E6%B7%B7%E5%90%88%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E")
    • [5.3 向量搜索路径](#5.3 向量搜索路径 "#53-%E5%90%91%E9%87%8F%E6%90%9C%E7%B4%A2%E8%B7%AF%E5%BE%84")
    • [5.4 关键词搜索路径](#5.4 关键词搜索路径 "#54-%E5%85%B3%E9%94%AE%E8%AF%8D%E6%90%9C%E7%B4%A2%E8%B7%AF%E5%BE%84")
    • [5.5 结果融合与优化](#5.5 结果融合与优化 "#55-%E7%BB%93%E6%9E%9C%E8%9E%8D%E5%90%88%E4%B8%8E%E4%BC%98%E5%8C%96")
  • [6. 降级与容错](#6. 降级与容错 "#6-%E9%99%8D%E7%BA%A7%E4%B8%8E%E5%AE%B9%E9%94%99")
    • [6.1 Provider 三层 Fallback](#6.1 Provider 三层 Fallback "#61-provider-%E4%B8%89%E5%B1%82-fallback")
    • [6.2 后端主备切换](#6.2 后端主备切换 "#62-%E5%90%8E%E7%AB%AF%E4%B8%BB%E5%A4%87%E5%88%87%E6%8D%A2")
    • [6.3 FTS-only 降级模式](#6.3 FTS-only 降级模式 "#63-fts-only-%E9%99%8D%E7%BA%A7%E6%A8%A1%E5%BC%8F")
  • [7. 成本控制](#7. 成本控制 "#7-%E6%88%90%E6%9C%AC%E6%8E%A7%E5%88%B6")
  • [8. 核心设计决策与权衡](#8. 核心设计决策与权衡 "#8-%E6%A0%B8%E5%BF%83%E8%AE%BE%E8%AE%A1%E5%86%B3%E7%AD%96%E4%B8%8E%E6%9D%83%E8%A1%A1")
  • [9. 参考价值总结](#9. 参考价值总结 "#9-%E5%8F%82%E8%80%83%E4%BB%B7%E5%80%BC%E6%80%BB%E7%BB%93")

1. 为什么需要记忆系统

AI Agent 的核心痛点是无状态------每次对话开始,Agent 对用户的历史一无所知。

记忆系统解决的问题:

问题 没有记忆时 有记忆时
"我们之前讨论的方案是什么?" Agent 无法回答 从记忆中检索到具体方案
"用我习惯的代码风格" Agent 不知道偏好 MEMORY.md 读取偏好配置
"上周的 bug 修了吗?" 需要用户重新描述 自动关联历史会话内容

仅存储不够,还需要高效检索。用户用的词往往和文档里的词不同("高并发" vs "令牌桶限流"),这就需要语义级别的检索能力。


2. 架构总览

整个记忆系统由两层组成------文件层 (给人用)和索引层(给机器用)。

flowchart TD subgraph WRITE["✏️ 写入方"] direction LR H["人类编辑器
手动编辑 .md"] K["session-memory Hook
/new 时自动生成 .md"] S["会话 Transcript
.jsonl 自动记录"] end subgraph FILES["📁 Markdown 文件 --- Source of Truth"] F["MEMORY.md · memory.md · memory/*.md · session *.jsonl"] end subgraph SYNC["⚙️ MemoryIndexManager.sync()"] PIPELINE["文件监听 → hash 对比 → chunkMarkdown → embedBatch → 写入 SQLite"] end PROVIDER["🔌 Embedding Provider
local · OpenAI · Gemini
Voyage · Mistral"] subgraph DB["🗄️ SQLite 数据库 --- 搜索索引"] direction LR C["chunks
文本 + 向量"] V["chunks_vec
sqlite-vec"] FTS["chunks_fts
FTS5"] EC["embedding_cache
嵌入缓存"] end subgraph READ["🔍 读取方 --- Agent Tool"] direction LR MS["memory_search
语义召回 → 查向量 + FTS"] MG["memory_get
精确获取 → 读 Markdown"] end H --> F K --> F S --> F F -- "chokidar 监听 / search 触发 / 定时" --> PIPELINE PIPELINE <-- "embedQuery / embedBatch" --> PROVIDER PIPELINE --> DB DB -- "向量 + BM25 混合搜索" --> MS F -. "fs.readFile 直接读文件" .-> MG style WRITE fill:#F9F9F9,stroke:#E9EBED,stroke-dasharray:5 style FILES fill:#B3E1DB,stroke:#113366,color:#113366 style SYNC fill:#E9EBED,stroke:#113366,color:#113366 style PROVIDER fill:#EE4D2D,stroke:none,color:#FFFFFF style DB fill:#B3CEF3,stroke:#113366,color:#113366 style READ fill:#F9F9F9,stroke:#E9EBED,stroke-dasharray:5 style MS fill:#EE4D2D,stroke:none,color:#FFFFFF style MG fill:#113366,stroke:none,color:#FFFFFF style H fill:#113366,stroke:none,color:#FFFFFF style K fill:#113366,stroke:none,color:#FFFFFF style S fill:#113366,stroke:none,color:#FFFFFF

核心设计理念:Markdown 文件是 source of truth(丢了索引可以重建,丢了文件不行)。向量索引是搜索加速结构,职责类似数据库的 B+ 树索引。

两个 Agent Tool 的分工体现了这一点:

  • memory_search 查的是 SQLite 向量索引------"找到"相关内容
  • memory_get 读的是 Markdown 原始文件------"读全"完整上下文

3. 记忆的生成(写入)

3.1 记忆文件的来源

记忆有两种写入来源:

来源一:人类手动编辑

用户用任何编辑器直接维护 Markdown 文件:

bash 复制代码
workspace/
├── MEMORY.md              # 核心知识(技术栈、偏好、规范)
├── memory.md              # 同上,备选文件名
└── memory/
    ├── conventions.md     # 项目规范
    └── architecture.md    # 架构决策

这些文件是常青内容(evergreen),不随时间衰减。

来源二:session-memory Hook 自动生成

当用户执行 /new/reset 结束对话时,系统自动:

  1. 读取上一轮会话的最近 N 条消息(默认 15 条)
  2. 调用 LLM 生成描述性文件名 slug(如 "api-design")
  3. 写入 memory/YYYY-MM-DD-slug.md
typescript 复制代码
// session-memory hook 核心逻辑
const filename = `${dateStr}-${slug}.md`;  // 如 2026-02-25-api-design.md
const memoryFilePath = path.join(memoryDir, filename);
await fs.writeFile(memoryFilePath, entry, "utf-8");

生成的文件格式:

markdown 复制代码
# Session: 2026-02-25 14:30:00 UTC

- **Session Key**: agent:main:main
- **Session ID**: abc123def456
- **Source**: telegram

## Conversation Summary

User: 我们需要设计一个 API 限流方案...
Assistant: 推荐使用令牌桶算法...

这些带日期的文件会随时间衰减------30 天后搜索权重减半。

来源三:会话 Transcript(JSONL)

除了 Markdown 记忆文件,系统还支持索引原始会话记录(.jsonl 文件)。会话文件经过特殊处理:

typescript 复制代码
// session-files.ts: 将 JSONL 转为可索引的纯文本
export async function buildSessionEntry(absPath: string): Promise<SessionFileEntry | null> {
  const lines = raw.split("\n");
  const collected: string[] = [];
  const lineMap: number[] = [];  // 记录每行对应的原始 JSONL 行号

  for (const line of lines) {
    const record = JSON.parse(line);
    if (record.type === "message") {
      const text = extractSessionText(message.content);
      const safe = redactSensitiveText(text, { mode: "tools" });  // 脱敏处理
      collected.push(`${label}: ${safe}`);
      lineMap.push(jsonlIdx + 1);  // 保留行号映射,支持精确引用
    }
  }
  return { path, content: collected.join("\n"), lineMap, hash, ... };
}

关键设计:

  • 敏感信息脱敏:入索引前自动 redact
  • 行号映射:chunk 的 startLine/endLine 映射回原始 JSONL 行号,而非展平后的文本行号

3.2 索引构建流程

当文件变化时,indexFile() 执行以下流程:

scss 复制代码
读取文件内容
  → chunkMarkdown() 分块
  → enforceEmbeddingMaxInputTokens() 确保不超限
  → embedChunksInBatches() 或 embedChunksWithBatch() 获取向量
  → 写入 chunks 表(文本 + 向量 JSON)
  → 写入 chunks_vec 表(sqlite-vec 二进制向量)
  → 写入 chunks_fts 表(FTS5 全文索引)
  → 更新 files 表(文件元信息)

核心代码:

typescript 复制代码
// manager-embedding-ops.ts: indexFile
protected async indexFile(entry, options) {
  const content = options.content ?? await fs.readFile(entry.absPath, "utf-8");

  // Step 1: 分块
  const chunks = enforceEmbeddingMaxInputTokens(
    this.provider,
    chunkMarkdown(content, this.settings.chunking).filter(c => c.text.trim().length > 0),
    EMBEDDING_BATCH_MAX_TOKENS,
  );

  // Step 2: 获取嵌入向量(支持缓存 + 批量 API)
  const embeddings = this.batch.enabled
    ? await this.embedChunksWithBatch(chunks, entry, options.source)
    : await this.embedChunksInBatches(chunks);

  // Step 3: 写入三张表
  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i];
    const embedding = embeddings[i];
    const id = hashText(`${source}:${path}:${startLine}:${endLine}:${hash}:${model}`);

    // 写 chunks 主表
    db.prepare(`INSERT INTO chunks ...`).run(id, path, source, ...);

    // 写 sqlite-vec 向量表
    if (vectorReady && embedding.length > 0) {
      db.prepare(`INSERT INTO chunks_vec (id, embedding) VALUES (?, ?)`)
        .run(id, Buffer.from(new Float32Array(embedding).buffer));
    }

    // 写 FTS5 全文索引表
    if (fts.available) {
      db.prepare(`INSERT INTO chunks_fts (text, id, path, ...) VALUES (?, ?, ?, ...)`)
        .run(chunk.text, id, path, ...);
    }
  }
}

3.3 文本分块策略

分块是检索质量的关键。这个实现采用行级滑动窗口 + 重叠策略:

typescript 复制代码
// internal.ts: chunkMarkdown
export function chunkMarkdown(content: string, chunking: { tokens: number; overlap: number }) {
  const maxChars = Math.max(32, chunking.tokens * 4);   // token → 字符估算(1 token ≈ 4 chars)
  const overlapChars = Math.max(0, chunking.overlap * 4);

  // 按行扫描,累积字符数达到 maxChars 时 flush 一个 chunk
  for (const line of lines) {
    if (currentChars + lineSize > maxChars && current.length > 0) {
      flush();         // 产出一个 chunk
      carryOverlap();  // 保留尾部 overlapChars 作为下一个 chunk 的开头
    }
    current.push({ line, lineNo });
    currentChars += lineSize;
  }
}

设计特点:

特性 说明
token 估算 tokens × 4 转字符数,粗略但高效,避免引入 tokenizer 依赖
滑动窗口重叠 chunk 之间有 overlap,防止语义在边界处被截断
超长行切割 单行超过 maxChars 时自动分段
行号追踪 每个 chunk 记录 startLine/endLine,支持精确引用回原文
模型限制裁剪 enforceEmbeddingMaxInputTokens() 确保每个 chunk 不超过模型的 token 上限

3.4 向量嵌入与多 Provider 支持

嵌入层采用工厂模式,支持 5 种 Provider:

typescript 复制代码
// embeddings.ts: createEmbeddingProvider
export async function createEmbeddingProvider(options): Promise<EmbeddingProviderResult> {
  if (requestedProvider === "auto") {
    // 自动模式:本地 → openai → gemini → voyage → mistral
    if (canAutoSelectLocal(options)) {
      return createProvider("local");   // node-llama-cpp 本地模型
    }
    for (const provider of ["openai", "gemini", "voyage", "mistral"]) {
      try { return await createProvider(provider); } catch { continue; }
    }
    // 全部失败 → 返回 null provider,进入 FTS-only 模式
    return { provider: null, providerUnavailableReason: reason };
  }
  // 指定模式:primary → fallback → FTS-only
}
Provider 模型 特点
local embeddinggemma-300m (GGUF) 零成本,无网络依赖,通过 node-llama-cpp 运行
openai text-embedding-3-small 最稳定,8192 token 上限
gemini text-embedding-004 Google 生态
voyage voyage-3 专注嵌入质量
mistral mistral-embed 多语言支持好

统一的 EmbeddingProvider 接口:

typescript 复制代码
export type EmbeddingProvider = {
  id: string;
  model: string;
  maxInputTokens?: number;
  embedQuery: (text: string) => Promise<number[]>;     // 单条查询
  embedBatch: (texts: string[]) => Promise<number[][]>; // 批量索引
};

所有远程 Provider 都通过统一的 createRemoteEmbeddingProvider() 构建,共享同一套 HTTP 请求、重试、超时逻辑:

typescript 复制代码
// embeddings-remote-provider.ts
export function createRemoteEmbeddingProvider(params) {
  const url = `${client.baseUrl}/embeddings`;
  const embed = async (input: string[]) => {
    return await fetchRemoteEmbeddingVectors({ url, headers, body: { model, input } });
  };
  return { id, model, embedQuery: (text) => embed([text])[0], embedBatch: embed };
}

3.5 数据库 Schema 设计

存储层使用 Node.js 内置的 node:sqlite + sqlite-vec 扩展 + FTS5:

sql 复制代码
-- 元信息:记录当前索引的 model/provider/配置,用于判断是否需要全量重建
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);

-- 文件记录:通过 hash 判断文件是否变化
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,       -- hash(source:path:startLine:endLine:hash:model)
  path TEXT NOT NULL,
  source TEXT NOT NULL DEFAULT 'memory',
  start_line INTEGER NOT NULL,
  end_line INTEGER NOT NULL,
  hash TEXT NOT NULL,         -- 内容 hash,用于缓存命中
  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 全文索引:BM25 关键词搜索
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,
  model TEXT NOT NULL,
  provider_key TEXT NOT NULL,  -- 标识 API endpoint + headers
  hash TEXT NOT NULL,          -- 文本内容 hash
  embedding TEXT NOT NULL,
  dims INTEGER,
  updated_at INTEGER NOT NULL,
  PRIMARY KEY (provider, model, provider_key, hash)
);

设计要点:

  • chunks.model 字段:切换嵌入模型时,旧向量和新向量不可比较,通过 model 字段过滤
  • embedding_cache 四元组主键:同一文本在不同 provider/model 下产出不同向量,需要分别缓存
  • source 字段:区分 memory 文件和 session 文件,支持按来源过滤

4. 记忆的同步(更新)

4.1 同步触发机制

系统有 5 种触发同步的方式,覆盖不同场景:

触发方式 触发时机 适用场景
watch chokidar 监听到文件增/删/改 用户编辑 MEMORY.md 后自动更新
search 搜索时发现 dirty 标记 搜索前保证索引是最新的
session-start 新会话开始时 预热索引
session-delta 会话文件增长超过阈值 长对话中增量索引新内容
interval 定时器(可配置分钟数) 兜底,防止遗漏

文件监听的实现:

typescript 复制代码
// manager-sync-ops.ts
protected ensureWatcher() {
  this.watcher = chokidar.watch([
    path.join(workspaceDir, "MEMORY.md"),
    path.join(workspaceDir, "memory.md"),
    path.join(workspaceDir, "memory", "**", "*.md"),
    // + 配置的额外路径
  ], {
    ignoreInitial: true,
    ignored: (p) => shouldIgnoreMemoryWatchPath(p),  // 忽略 .git, node_modules 等
    awaitWriteFinish: { stabilityThreshold: debounceMs, pollInterval: 100 },
  });

  const markDirty = () => { this.dirty = true; this.scheduleWatchSync(); };
  this.watcher.on("add", markDirty);
  this.watcher.on("change", markDirty);
  this.watcher.on("unlink", markDirty);
}

会话增量同步使用字节/消息数双阈值

typescript 复制代码
// 当会话文件增长超过 deltaBytes 或 deltaMessages 时触发
const bytesHit = delta.pendingBytes >= thresholds.deltaBytes;
const messagesHit = delta.pendingMessages >= thresholds.deltaMessages;
if (bytesHit || messagesHit) {
  this.sessionsDirty = true;
  void this.sync({ reason: "session-delta" });
}

4.2 增量同步与全量重建

增量同步(常规路径):只处理变化的文件

typescript 复制代码
// 对比文件 hash,未变化则跳过
const record = db.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
  .get(entry.path, "memory");
if (!needsFullReindex && record?.hash === entry.hash) {
  return; // 跳过
}
await this.indexFile(entry, { source: "memory" });

全量重建触发条件:

typescript 复制代码
const needsFullReindex =
  params?.force ||                                    // 用户手动强制
  !meta ||                                            // 首次运行
  meta.model !== this.provider.model ||               // 切换了嵌入模型
  meta.provider !== this.provider.id ||               // 切换了 provider
  meta.providerKey !== this.providerKey ||            // API endpoint 变了
  this.metaSourcesDiffer(meta, configuredSources) ||  // sources 配置变了
  meta.chunkTokens !== this.settings.chunking.tokens || // 分块参数变了
  meta.chunkOverlap !== this.settings.chunking.overlap;

4.3 安全重建索引

全量重建使用临时数据库 + 原子交换,确保在线查询不受影响:

typescript 复制代码
// manager-sync-ops.ts: runSafeReindex
async runSafeReindex(params) {
  const tempDbPath = `${dbPath}.tmp-${randomUUID()}`;
  const tempDb = this.openDatabaseAtPath(tempDbPath);

  // 1. 切换到临时 DB
  this.db = tempDb;
  this.ensureSchema();

  // 2. 从旧 DB 复制嵌入缓存(避免重复 API 调用)
  this.seedEmbeddingCache(originalDb);

  // 3. 在临时 DB 中完成全量索引
  await this.syncMemoryFiles({ needsFullReindex: true });
  await this.syncSessionFiles({ needsFullReindex: true });

  // 4. 关闭两个 DB,原子交换文件
  this.db.close();
  originalDb.close();
  await this.swapIndexFiles(dbPath, tempDbPath);  // rename 原子操作

  // 5. 重新打开正式 DB
  this.db = this.openDatabaseAtPath(dbPath);
}

失败时自动回滚:

typescript 复制代码
catch (err) {
  this.db.close();
  await this.removeIndexFiles(tempDbPath);  // 清理临时文件
  restoreOriginalState();                   // 恢复旧 DB
  throw err;
}

5. 记忆的检索(读取)

5.1 两种 Agent Tool

系统为 AI Agent 提供两个互补的工具:

typescript 复制代码
// Agent Tool 定义
{
  name: "memory_search",
  description: "Mandatory recall step: semantically search MEMORY.md + memory/*.md ...",
  execute: async (params) => {
    const { manager } = await getMemorySearchManager({ cfg, agentId });
    const results = await manager.search(query, { maxResults, minScore, sessionKey });
    // 返回: [{ path, startLine, endLine, score, snippet, source, citation }]
  }
}

何时使用:Agent 收到用户问题的第一步,语义级别的模糊搜索,返回最相关的 chunk 片段。

memory_get --- 精确获取(读文件)

typescript 复制代码
// Agent Tool 定义
{
  name: "memory_get",
  description: "Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; "
    + "use after memory_search to pull only the needed lines ...",
  execute: async (params) => {
    const { manager } = await getMemorySearchManager({ cfg, agentId });
    const result = await manager.readFile({ relPath, from, lines });
    // 返回: { text: "文件原始内容", path: "memory/xxx.md" }
  }
}

何时使用memory_search 找到线索后,需要更多上下文时,按路径+行号读取 Markdown 原文。

典型使用链路

css 复制代码
用户: "之前讨论的 API 限流方案是什么?"

Step 1: Agent 调用 memory_search("API 限流方案")
  → 返回: [{ path: "memory/2026-01-10.md", startLine: 15, endLine: 28,
             score: 0.87, snippet: "对接口设置令牌桶限流..." }]

Step 2: Agent 需要更多上下文,调用 memory_get
  → readFile({ relPath: "memory/2026-01-10.md", from: 10, lines: 30 })
  → 返回完整的 Markdown 原文

Step 3: Agent 基于完整上下文回答用户

5.2 混合搜索引擎

搜索的核心是向量 + 关键词的混合检索

typescript 复制代码
// manager.ts: search()
async search(query, opts?) {
  // 无 Provider → 纯 FTS 关键词搜索
  if (!this.provider) {
    const keywords = extractKeywords(cleaned);  // 多语言停用词过滤 + 关键词提取
    return searchKeyword(keywords, candidates);
  }

  // 有 Provider → 混合搜索
  const keywordResults = await this.searchKeyword(cleaned, candidates);
  const queryVec = await this.embedQueryWithTimeout(cleaned);  // 1 次 Embedding API 调用
  const vectorResults = await this.searchVector(queryVec, candidates);

  // 加权融合 + 时间衰减 + MMR 重排序
  return this.mergeHybridResults({
    vector: vectorResults,
    keyword: keywordResults,
    vectorWeight: hybrid.vectorWeight,
    textWeight: hybrid.textWeight,
    mmr: hybrid.mmr,
    temporalDecay: hybrid.temporalDecay,
  });
}

5.3 向量搜索路径

向量搜索有两种实现,自动根据环境选择:

typescript 复制代码
// manager-search.ts: searchVector
export async function searchVector(params) {
  // 路径 1: sqlite-vec 可用 → 原生向量索引(推荐)
  if (await params.ensureVectorReady(queryVec.length)) {
    return db.prepare(
      `SELECT c.*, 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(vectorToBlob(queryVec), providerModel, limit);
    // score = 1 - dist(距离越小,相似度越高)
  }

  // 路径 2: sqlite-vec 不可用 → 全量暴力余弦计算(降级)
  const candidates = listChunks({ db, providerModel });
  return candidates
    .map(chunk => ({ chunk, score: cosineSimilarity(queryVec, chunk.embedding) }))
    .toSorted((a, b) => b.score - a.score)
    .slice(0, limit);
}

向量转为二进制 blob 以供 sqlite-vec 使用:

typescript 复制代码
const vectorToBlob = (embedding: number[]): Buffer =>
  Buffer.from(new Float32Array(embedding).buffer);

5.4 关键词搜索路径

基于 SQLite FTS5 的 BM25 全文搜索:

typescript 复制代码
// manager-search.ts: searchKeyword
export async function searchKeyword(params) {
  const ftsQuery = buildFtsQuery(query);  // "限流 方案" → '"限流" AND "方案"'

  return db.prepare(
    `SELECT id, path, text, bm25(chunks_fts) AS rank
     FROM chunks_fts
     WHERE chunks_fts MATCH ?
     ORDER BY rank ASC LIMIT ?`
  ).all(ftsQuery, limit);
  // score = 1 / (1 + rank)  --- BM25 rank 归一化到 [0, 1]
}

FTS-only 模式下,查询会经过多语言关键词提取,支持中/英/日/韩/西/葡/阿 7 种语言的停用词过滤:

typescript 复制代码
// query-expansion.ts
export function extractKeywords(query: string): string[] {
  const tokens = tokenize(query);  // 支持 CJK 字符级分词
  return tokens.filter(t =>
    !STOP_WORDS_EN.has(t) && !STOP_WORDS_ZH.has(t) && !STOP_WORDS_JA.has(t) &&
    !STOP_WORDS_KO.has(t) && !STOP_WORDS_ES.has(t) && !STOP_WORDS_PT.has(t) &&
    !STOP_WORDS_AR.has(t) && isValidKeyword(t)
  );
}
// "之前讨论的那个方案" → ["讨论", "方案"](过滤掉 "之前", "的", "那个")

5.5 结果融合与优化

搜索结果经过三层处理管线:

第一层:加权融合

typescript 复制代码
// hybrid.ts
const score = vectorWeight * vectorScore + textWeight * textScore;

向量搜索和关键词搜索的结果按 ID 合并,同时出现在两个结果集的 chunk 会获得双重加分。

第二层:时间衰减

typescript 复制代码
// temporal-decay.ts
// 指数衰减:score = score × e^(-λ × age)
// halfLifeDays=30 时:30 天前 → 权重 0.5,60 天前 → 0.25
export function calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays }) {
  const lambda = Math.LN2 / halfLifeDays;
  return Math.exp(-lambda * Math.max(0, ageInDays));
}

时间来源优先级:

  1. 文件名日期 memory/2026-01-15.md → 直接解析
  2. 常青文件 MEMORY.mdmemory/conventions.md不衰减
  3. fallback 到文件 mtime

第三层:MMR 多样性重排序

typescript 复制代码
// mmr.ts
// MMR = λ × relevance - (1-λ) × max_similarity_to_selected
export function computeMMRScore(relevance, maxSimilarity, lambda) {
  return lambda * relevance - (1 - lambda) * maxSimilarity;
}

使用 Jaccard 相似度(token 集合的交/并)衡量结果间的相似程度,迭代选择既相关又多样的结果。避免 top-K 被高度相似的内容霸占。


6. 降级与容错

6.1 Provider 三层 Fallback

sql 复制代码
auto 模式:local → openai → gemini → voyage → mistral → FTS-only
指定模式:primary → fallback → FTS-only

运行时如果 primary provider 出现嵌入错误,还能自动切换:

typescript 复制代码
// manager-sync-ops.ts
catch (err) {
  const reason = err.message;
  // 嵌入相关错误 → 尝试激活 fallback provider
  if (this.shouldFallbackOnError(reason)) {
    const activated = await this.activateFallbackProvider(reason);
    if (activated) {
      await this.runSafeReindex({ reason: "fallback", force: true });
      return;
    }
  }
  throw err;
}

6.2 后端主备切换

FallbackMemoryManager 实现了透明的主备切换:

typescript 复制代码
// search-manager.ts
class FallbackMemoryManager implements MemorySearchManager {
  async search(query, opts?) {
    if (!this.primaryFailed) {
      try {
        return await this.deps.primary.search(query, opts);  // 先走 QMD
      } catch (err) {
        this.primaryFailed = true;
        await this.deps.primary.close?.();
        this.evictCacheEntry();  // 从缓存移除,下次可重试
      }
    }
    const fallback = await this.ensureFallback();  // 懒创建 builtin 后端
    return await fallback.search(query, opts);
  }
}

6.3 FTS-only 降级模式

当所有 Embedding Provider 都不可用时(没有 API key、没有本地模型),系统不会直接报错,而是退化为纯关键词搜索:

typescript 复制代码
// manager.ts: search() 中的 FTS-only 分支
if (!this.provider) {
  // 没有向量能力,只用 FTS
  const keywords = extractKeywords(cleaned);
  const resultSets = await Promise.all(
    searchTerms.map(term => this.searchKeyword(term, candidates))
  );
  // 合并去重,按分数排序
  return merged;
}

这意味着:零配置也能使用记忆搜索,只是精度从"语义级"降到"关键词级"。


7. 成本控制

优化措施 实现方式 节省
嵌入缓存 embedding_cache 表,按 provider+model+hash 去重 相同内容不重复调 API
增量同步 文件 hash 对比,只处理变化的文件 未修改文件零成本
Batch API OpenAI/Gemini/Voyage 批量嵌入接口 比逐条调用便宜约 50%
本地嵌入 node-llama-cpp + embeddinggemma-300m 完全零成本
FTS-only 降级 无 API key 时纯 FTS 零成本可用
缓存跨重建迁移 全量重建时从旧 DB seed 缓存到新 DB 重建不重复调 API
缓存 LRU 淘汰 超过 maxEntries 时删除最旧的条目 控制缓存大小

嵌入缓存的写入和重建迁移:

typescript 复制代码
// 写入缓存
private upsertEmbeddingCache(entries: Array<{ hash: string; embedding: number[] }>) {
  for (const entry of entries) {
    stmt.run(provider.id, provider.model, providerKey, entry.hash,
             JSON.stringify(entry.embedding), entry.embedding.length, Date.now());
  }
}

// 重建时迁移缓存
private seedEmbeddingCache(sourceDb: DatabaseSync) {
  const rows = sourceDb.prepare(
    `SELECT * FROM embedding_cache`
  ).all();
  for (const row of rows) {
    insert.run(row.provider, row.model, row.provider_key, row.hash,
               row.embedding, row.dims, row.updated_at);
  }
}

API 调用还有重试 + 指数退避机制:

typescript 复制代码
// manager-embedding-ops.ts
protected async embedBatchWithRetry(texts: string[]) {
  let attempt = 0;
  let delayMs = 500;  // 起始 500ms
  while (true) {
    try {
      return await this.withTimeout(this.provider.embedBatch(texts), timeoutMs, ...);
    } catch (err) {
      if (!isRetryableError(err) || attempt >= 3) throw err;
      // 429/rate-limit/5xx → 指数退避重试
      await sleep(Math.min(8000, delayMs * (1 + Math.random() * 0.2)));
      delayMs *= 2;
      attempt += 1;
    }
  }
}

8. 核心设计决策与权衡

决策 1: Markdown 文件 + 向量索引(而非纯向量数据库)

选择:记忆以 Markdown 文件为 source of truth,向量索引是可重建的搜索加速结构。

Why

  • 人类可以直接用编辑器读写 Markdown,向量数据库做不到
  • Markdown 可以 git diff、code review、版本回溯
  • 索引丢失不丢数据,从文件重建即可
  • 文件复制即迁移,不依赖特定数据库

权衡:需要额外的同步机制保持文件和索引一致。

决策 2: SQLite 而非专用向量数据库

选择:SQLite + sqlite-vec + FTS5,单文件数据库。

Why

  • 零部署成本,嵌入在应用进程中
  • 单文件便于备份和迁移
  • sqlite-vec 在万级 chunk 规模下性能足够
  • 与 FTS5 共享同一个 SQLite 实例,减少复杂度

权衡:不适合多用户并发写入,不适合百万级向量。

决策 3: 混合搜索(而非纯向量搜索)

选择:向量搜索 + BM25 关键词搜索,加权融合。

Why

  • 向量擅长语义匹配("高并发" ≈ "QPS 限制")
  • 关键词擅长精确匹配(函数名、配置项、ID)
  • 两者互补,单独使用都有盲区

权衡:融合权重需要调优,线性加权不一定是最优融合方式。

决策 4: Jaccard MMR 而非向量 MMR

选择:MMR 重排序使用 Jaccard 相似度(token 集合交/并),而非向量余弦。

Why

  • 不需要额外的嵌入计算
  • 基于已有 snippet 文本即可完成
  • 性能更轻量

权衡:语义区分度不如向量余弦。"自然语言处理" 和 "NLP" 的 Jaccard = 0,但语义相同。

决策 5: 默认关闭 MMR 和时间衰减

选择mmr.enabled = falsetemporalDecay.enabled = false

Why

  • 大多数场景下基础的混合搜索已经够好
  • MMR 和时间衰减引入了额外的复杂度和性能开销
  • opt-in 让用户按需启用

9. 参考价值总结

如果你要在自己的项目中实现类似的记忆系统,以下是可以直接借鉴的设计:

模块 可复用的设计 适用场景
统一接口 MemorySearchManager 接口抽象 任何需要多后端的搜索系统
混合搜索 向量 + BM25 加权融合 RAG 系统、知识库检索
分块策略 行级滑动窗口 + overlap + 行号追踪 任何需要文档分块的场景
嵌入缓存 按 provider+model+hash 缓存嵌入 减少 Embedding API 成本
增量同步 文件 hash 对比 + 文件监听 实时性要求不高的索引系统
安全重建 临时 DB → 原子交换 → 失败回滚 任何在线索引重建场景
三层降级 auto provider → fallback → FTS-only 需要高可用的 AI 应用
主备切换 FallbackManager 代理模式 多后端容错
时间衰减 指数衰减 + 常青文件豁免 需要时效性的知识检索
多语言 FTS 7 语言停用词 + CJK 字符级分词 面向国际化的搜索系统

文件清单

bash 复制代码
src/memory/
├── index.ts                    # 模块公共导出
├── types.ts                    # 核心类型定义
├── manager.ts                  # MemoryIndexManager(主入口)
├── manager-sync-ops.ts         # 同步操作基类(文件监听、增量/全量同步)
├── manager-embedding-ops.ts    # 嵌入操作(分块、批量嵌入、缓存)
├── manager-search.ts           # 搜索实现(向量搜索、关键词搜索)
├── search-manager.ts           # 后端选择 + FallbackManager
├── backend-config.ts           # 后端配置解析
├── memory-schema.ts            # SQLite Schema 定义
├── internal.ts                 # 文件扫描、分块、hash、余弦相似度
├── session-files.ts            # 会话 JSONL 解析与索引
├── hybrid.ts                   # 混合搜索融合
├── temporal-decay.ts           # 时间衰减
├── mmr.ts                      # MMR 多样性重排序
├── query-expansion.ts          # 多语言关键词提取(FTS-only 模式)
├── embeddings.ts               # Embedding Provider 工厂
├── embeddings-openai.ts        # OpenAI Provider
├── embeddings-gemini.ts        # Gemini Provider
├── embeddings-voyage.ts        # Voyage Provider
├── embeddings-mistral.ts       # Mistral Provider
├── embeddings-remote-provider.ts # 通用远程 Provider
├── embedding-chunk-limits.ts   # 分块大小限制
├── embedding-input-limits.ts   # UTF-8 字节限制
├── embedding-model-limits.ts   # 模型 token 限制
├── sqlite.ts                   # Node SQLite 加载
├── sqlite-vec.ts               # sqlite-vec 扩展加载
├── node-llama.ts               # node-llama-cpp 本地嵌入
├── batch-runner.ts             # 批量 API 执行器
├── batch-openai.ts             # OpenAI Batch API
├── batch-gemini.ts             # Gemini Batch API
├── batch-voyage.ts             # Voyage Batch API
└── fs-utils.ts                 # 文件工具函数

总结一句话:这是一个为本地优先的 AI Agent 设计的记忆系统------Markdown 文件给人读写,向量索引给机器检索,混合搜索兼顾语义和精确匹配,三层降级保证在任何环境下都能用。整体设计在工程质量、成本控制、可用性之间取得了很好的平衡。

相关推荐
志栋智能1 小时前
自动化运维还有这样一种模式。
运维·人工智能·安全·机器人·自动化
IvanCodes1 小时前
机器学习算法分类与数据处理
人工智能·机器学习
香芋Yu2 小时前
【从零构建AI Code终端系统】03 -- Agent 循环:一个 while 就是全部
人工智能·agent·claude·code·agent loop
sali-tec2 小时前
C# 基于OpenCv的视觉工作流-章26-图像拼接
图像处理·人工智能·opencv·算法·计算机视觉
2501_926978332 小时前
思想波与引力共振理论:统一物理主义意识框架的革命性探索--AGI理论系统基础12
人工智能·经验分享·架构·langchain·agi
朴实赋能2 小时前
当AI成为“家人”:心伴机器人如何重塑老年居家康养新模式
人工智能·陪伴机器人·情感计算·认知衰退干预·亲人音色复刻·虚拟家人·多智能体协同15分钟养老圈
模型时代3 小时前
Arista暗示正在开发AI网络管理遥测工具
开发语言·人工智能·php
紧固视界3 小时前
2026 紧固件质检三大难题揭秘|上海紧固件专业展
大数据·人工智能·紧固件·上海紧固件展·紧固件展
十铭忘3 小时前
动作识别12——yolo26s-pose+PoseC3D第1篇之标注工具升级2.0
人工智能·python·深度学习