Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 08 节:长短期记忆系统 (Memory & RAG)

“LLM 的 context window 是工作记忆——容量有限,关机即失;向量数据库是长期记忆——无限扩展,随时召回。Agent 需要两者的结合。”

本节改动全景

相比第 07 节,本节的改动集中在记忆层,Agent 主循环与浏览器工具完全不变

改动点第 07 节第 08 节
记忆范围LLM context window(会话内)+ 向量存储(跨会话持久化)
遗忘机制超窗口后 context.ts 截断+ 主动提取 + 相似度召回
新增工具memory_save / memory_search / kb_index / kb_search
新增模块memory.ts(MemoryStore 接口 + 两种后端实现)
knowledgebase.ts(文档切片 + 批量索引)
Agent 主循环不变不变(记忆注入发生在 assembleContext 之前)
DB 层sessions + traces+ memories 表(id / session_id / source / content / embedding)

这一节的核心设计思想:记忆层是透明的玻璃——Agent 主循环不感知“这是记忆调用还是知识库查询“,照常 {"action":"memory_save"} 发起;记忆层封装了所有 Embedding 和向量检索的细节。

本节涵盖两种独立但技术同源的能力

Agent 长期记忆                       企业知识库 RAG
────────────────────                 ────────────────────
运行时动态写入                        离线批量索引
个人偏好 / 对话摘要                   外部文档(PDF/Wiki/代码)
百~千条记忆片段                       万~百万条文档 chunk
Agent 自身调用写入                    管理员 / kb_index 工具写入

           ↓                                   ↓
        共用同一套技术管道
  Embed → 向量存储 → 余弦/ANN 检索 → 注入 system prompt

为什么需要长期记忆

前 7 节的 Agent 状态全部活在 context window 里。会话一关,记忆归零。

第一次会话:
  用户:我的代码风格是 TypeScript,2 空格缩进,不用分号
  Agent:好的,我记住了。

(关闭会话,第二天重新打开)

第二次会话:
  用户:帮我写一个 HTTP 请求模块
  Agent:(写出 Python 代码,4 空格缩进)   ← 完全忘记用户偏好

对比有记忆层的 Agent:

第二次会话(有记忆层):
  [系统] 自动召回相关记忆:用户使用 TypeScript,2 空格缩进,不用分号
  Agent:(写出正确风格的 TypeScript 代码)  ✓

除了个人偏好,还有两类场景是纯 context window 无法解决的:

场景 A — 知识超出 context window
  公司内部有 500 份 API 文档、数百页运维手册。
  塞进 context 不仅超出限制,每次调用还要付出巨额 token 成本。
  → 知识库 RAG:只把与当前问题相关的 3-5 个片段注入,其余不动。

场景 B — 信息跨越多个会话积累
  Agent 用了三个月,参与了 50 个项目的代码评审。
  这些经验判断力无法从单次会话中获得。
  → Agent 长期记忆:每次会话结束后提炼要点,下次自动带入。

短期记忆 vs 长期记忆对比

维度短期记忆(context window)长期记忆(向量存储)
范围单次会话跨会话持久化
容量受限(~200K token)理论无限
写入自动(每轮对话)主动提取(Agent 调 memory_save)
读取全量(付出完整 token 代价)按需召回(相似度 top-K)
遗忘截断(context.ts truncate)不遗忘(除非主动删除)
速度即时毫秒级(本地)/ 网络延迟(Milvus)

1. 核心概念:Embedding 与语义搜索

什么是 Embedding

文本无法直接做“距离计算“——“苹果手机“和“iPhone“字面上完全不同,但语义相近。Embedding 把文本映射到高维向量空间,让语义相近的文本在空间中距离也近:

"苹果手机" → [0.82, 0.13, -0.45, 0.67, ...]  ← 1536 维向量
"iPhone"   → [0.80, 0.15, -0.43, 0.65, ...]  ← 非常接近
"香蕉"     → [0.12, 0.71,  0.23, -0.31, ...] ← 距离很远

余弦相似度

衡量两个向量方向的一致程度,与向量长度无关:

// 1.0 = 完全相同,0 = 无关,-1 = 完全相反
function cosineSimilarity(a: number[], b: number[]): number {
  const dot   = a.reduce((sum, ai, i) => sum + ai * b[i]!, 0);
  const normA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0));
  const normB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0));
  return normA && normB ? dot / (normA * normB) : 0;
}

为什么用余弦而非欧氏距离?文本 Embedding 的长度受文本长度影响,方向才反映语义。一篇长文章和同内容的摘要,方向相近但长度差异大——余弦相似度正确,欧氏距离错误。

Embedding API 调用

// memory.ts — 复用项目已有的 OpenAI 配置(providers/ 层已有 baseURL/apiKey)
async function embed(text: string): Promise<number[]> {
  const res = await openai.embeddings.create({
    model: 'text-embedding-3-small',   // 1536 维,$0.02/1M tokens
    input: text.slice(0, 8192),        // API 有输入长度限制
  });
  return res.data[0]!.embedding;
}

常用 Embedding 模型选型:

模型模态支持上下文窗口默认维度部署方式核心优势场景
text-embedding-3-small纯文本8K tokens1536(支持裁剪)闭源 API低成本、高并发的常规 RAG 任务
text-embedding-3-large纯文本8K tokens3072(支持裁剪)闭源 API传统文本检索、对稳定性要求极高的商业项目
Qwen3-Embedding-8B纯文本32K tokens4096(支持自定义)开源可自托管多语言 / 跨语言检索、长文档 RAG
Colnomic-embed-multimodal-7B多模态(文 / 图 / PDF / 图表)高上下文(基于 ColBERT 多向量)多向量表示(Late Interaction)开源可自托管复杂 PDF、扫描件、财报图表的精细化文档检索
Qwen3-VL-Embedding-8B多模态(文 / 图 / 视频 / 混合)32K tokens4096(支持自定义)开源可自托管视频片段检索、跨模态多任务聚类、复杂图文 RAG

模型通过 .env 中的 OPENAI_EMBEDDING_MODEL 切换,OpenAI 兼容接口(如本地部署的 Qwen3-Embedding)同样适用,无需改代码:

# .env — 切换 embedding 模型
OPENAI_API_BASE_URL=http://localhost:11434/v1   # 本地 Ollama / vLLM
OPENAI_EMBEDDING_MODEL=qwen3-embedding-8b

闭源 API 成本估算:10,000 条记忆 × 平均 100 token/条 = 100 万 token → text-embedding-3-small $0.02,可忽略不计。


2. MemoryStore 接口:统一后端,可插拔切换

关键设计:Agent 记忆和知识库 RAG 使用同一个 MemoryStore 接口,通过 source 字段区分来源,后端实现(SQLite / Milvus)对上层透明。

// memory.ts
export interface MemoryEntry {
  id: string;
  sessionId: string;       // 所属会话(全局记忆如用户偏好可设为 'global')
  source: 'agent' | 'kb'; // Agent 记忆 vs 知识库文档
  docId?: string;          // 知识库条目的原始文档标识
  content: string;         // 原始文本
  embedding: number[];     // 向量(SQLite 中 JSON 序列化存储)
  tags: string[];          // 可选标签,用于过滤
  createdAt: number;
}

export interface MemoryStore {
  save(entry: Omit<MemoryEntry, 'id' | 'createdAt'>): Promise<string>;
  search(
    queryEmbedding: number[],
    topK: number,
    filter?: { source?: 'agent' | 'kb'; sessionId?: string; docId?: string },
  ): Promise<MemoryEntry[]>;
  delete(id: string): Promise<void>;
  close(): Promise<void>;
}

2.1 SQLite 实现(零依赖)

向量 JSON 序列化存 TEXT,检索时全量加载到 JS 内存做余弦排序。简单直接,无需额外服务:

// memory.ts — SQLiteMemoryStore
export class SQLiteMemoryStore implements MemoryStore {
  private db: DatabaseSync;

  constructor(dbPath: string) {
    this.db = new DatabaseSync(dbPath);
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS memories (
        id          TEXT PRIMARY KEY,
        session_id  TEXT NOT NULL,
        source      TEXT NOT NULL DEFAULT 'agent',
        doc_id      TEXT,
        content     TEXT NOT NULL,
        embedding   TEXT NOT NULL,   -- JSON 序列化的 number[]
        tags        TEXT DEFAULT '[]',
        created_at  INTEGER NOT NULL
      );
      CREATE INDEX IF NOT EXISTS idx_mem_session ON memories(session_id);
      CREATE INDEX IF NOT EXISTS idx_mem_source  ON memories(source);
    `);
  }

  async save(entry: Omit<MemoryEntry, 'id' | 'createdAt'>): Promise<string> {
    const id = crypto.randomUUID();
    this.db.prepare(
      `INSERT INTO memories (id, session_id, source, doc_id, content, embedding, tags, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    ).run(
      id, entry.sessionId, entry.source, entry.docId ?? null,
      entry.content, JSON.stringify(entry.embedding),
      JSON.stringify(entry.tags ?? []), Date.now(),
    );
    return id;
  }

  async search(
    queryEmbedding: number[],
    topK: number,
    filter?: { source?: 'agent' | 'kb'; sessionId?: string; docId?: string },
  ): Promise<MemoryEntry[]> {
    // 构建 WHERE 子句
    const conditions: string[] = [];
    const args: (string | null)[] = [];
    if (filter?.source)    { conditions.push('source = ?');     args.push(filter.source); }
    if (filter?.sessionId) { conditions.push('session_id = ?'); args.push(filter.sessionId); }
    if (filter?.docId)     { conditions.push('doc_id = ?');     args.push(filter.docId); }
    const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';

    const rows = this.db.prepare(`SELECT * FROM memories ${where}`).all(...args) as any[];

    // 全量余弦排序(< 50K 条时 < 50ms)
    return rows
      .map(r => ({
        id: r.id, sessionId: r.session_id, source: r.source as 'agent' | 'kb',
        docId: r.doc_id ?? undefined, content: r.content,
        embedding: JSON.parse(r.embedding) as number[],
        tags: JSON.parse(r.tags) as string[], createdAt: r.created_at,
        _score: cosineSimilarity(queryEmbedding, JSON.parse(r.embedding) as number[]),
      }))
      .sort((a, b) => b._score - a._score)
      .slice(0, topK);
  }

  async delete(id: string): Promise<void> {
    this.db.prepare('DELETE FROM memories WHERE id = ?').run(id);
  }

  async close(): Promise<void> {
    this.db.close();
  }
}

性能边界

  • 10,000 条 × 1536 维 ≈ 60 MB 内存,全量余弦排序 < 50ms
  • 50,000 条以上建议切换 Milvus

2.2 Milvus 实现(ANN 索引)

适合生产环境、多用户、知识库百万级文档:

// memory.ts — MilvusMemoryStore
import { MilvusClient, DataType } from '@zilliz/milvus2-sdk-node';

export class MilvusMemoryStore implements MemoryStore {
  private client: MilvusClient;
  private readonly collection = 'xclaw_memories';

  constructor(address: string) {
    this.client = new MilvusClient({ address });
  }

  async init(): Promise<void> {
    await this.client.createCollection({
      collection_name: this.collection,
      fields: [
        { name: 'id',          data_type: DataType.VarChar,     is_primary_key: true, max_length: 36 },
        { name: 'session_id',  data_type: DataType.VarChar,     max_length: 64 },
        { name: 'source',      data_type: DataType.VarChar,     max_length: 8 },
        { name: 'doc_id',      data_type: DataType.VarChar,     max_length: 128 },
        { name: 'content',     data_type: DataType.VarChar,     max_length: 4096 },
        { name: 'tags',        data_type: DataType.VarChar,     max_length: 512 },
        { name: 'created_at',  data_type: DataType.Int64 },
        { name: 'embedding',   data_type: DataType.FloatVector, dim: 1536 },
      ],
    });
    // HNSW 索引:M=16 控制图的连通性,efConstruction=200 控制构建精度
    await this.client.createIndex({
      collection_name: this.collection,
      field_name: 'embedding',
      index_type: 'HNSW',
      metric_type: 'COSINE',
      params: { M: 16, efConstruction: 200 },
    });
    await this.client.loadCollection({ collection_name: this.collection });
  }

  async search(
    queryEmbedding: number[],
    topK: number,
    filter?: { source?: string; sessionId?: string; docId?: string },
  ): Promise<MemoryEntry[]> {
    const exprs: string[] = [];
    if (filter?.source)    exprs.push(`source == "${filter.source}"`);
    if (filter?.sessionId) exprs.push(`session_id == "${filter.sessionId}"`);
    if (filter?.docId)     exprs.push(`doc_id == "${filter.docId}"`);

    const results = await this.client.search({
      collection_name: this.collection,
      vectors: [queryEmbedding],
      output_fields: ['id', 'session_id', 'source', 'doc_id', 'content', 'tags', 'created_at'],
      limit: topK,
      expr: exprs.length ? exprs.join(' && ') : undefined,
    });
    return results.results.map(r => ({
      id: r.id, sessionId: r.session_id, source: r.source as 'agent' | 'kb',
      docId: r.doc_id || undefined, content: r.content,
      embedding: [], tags: JSON.parse(r.tags ?? '[]'), createdAt: Number(r.created_at),
    }));
  }

  // save / delete 实现类似,略
  async save(_entry: any): Promise<string> { /* ... */ return ''; }
  async delete(_id: string): Promise<void> { /* ... */ }
  async close(): Promise<void> { await this.client.closeConnection(); }
}

HNSW 参数说明

参数作用推荐值
M每个节点的最大连边数,越大精度越高、内存越多16(通用)
efConstruction构建时的搜索深度,越大精度越高、构建越慢200(通用)
搜索时 ef查询时的候选集大小,越大精度越高、越慢64-128

SQLite vs Milvus 选型对比

维度SQLite + 余弦Milvus HNSW
外部依赖无(node:sqlite 内置)Docker / Milvus 独立服务
算法复杂度O(n) 全量扫描O(log n) ANN 近似
精确度100% 精确ANN 近似(召回率 >95%)
规模上限~50K 条(< 50ms)百万级无压力
运维成本需维护独立进程
适用场景开发 / 个人 / 小团队生产 / 多用户 / 大规模知识库

2.3 工厂函数(Config 驱动切换)

// memory.ts
export function createMemoryStore(cfg: Config): MemoryStore {
  if (cfg.memory.backend === 'milvus') {
    return new MilvusMemoryStore(cfg.memory.milvus.address);
  }
  return new SQLiteMemoryStore(cfg.state.dbPath); // 默认:零依赖本地
}
# xclaw.yaml — 切换后端只改这一行
memory:
  backend: sqlite      # 或 milvus
  milvus:
    address: localhost:19530
  topK: 5              # 每次召回条数

3. Agent 长期记忆

3.1 记忆注入流程

每次用户发消息,在构建 LLM 上下文之前,先用消息语义召回相关记忆注入 system prompt:

用户发送消息
    │
    ▼
embed(msg.content)              ← 将用户消息向量化
    │
    ▼
memoryStore.search(topK=5,      ← 语义召回(source: 'agent')
  source: 'agent')
    │
    ▼
buildSystemPrompt(recalled)     ← 注入 system prompt 末尾
    │
    ▼
LLM 调用(带记忆上下文)

注入位置选择 system prompt 末尾,而非 user message——避免污染对话历史,也避免 LLM 把记忆当用户说的话处理:

// agent.ts — handle() 方法,在构建 messages 之前
const recalled = await recallAgentMemories(msg.content, memoryStore);
const systemPrompt = buildSystemPrompt(recalled);

// ...
if (!this.sessions.has(msg.sessionId)) {
  this.sessions.set(msg.sessionId, [{ role: 'system', content: systemPrompt }]);
}
// agent.ts
async function recallAgentMemories(query: string, store: MemoryStore): Promise<string> {
  const embedding = await embed(query);
  const results = await store.search(embedding, 5, { source: 'agent' });
  if (results.length === 0) return '';
  return results.map(r => `- ${r.content}`).join('\n');
}

export function buildSystemPrompt(memoryContext = ''): string {
  const base = `You are xclaw...(原有 system prompt 内容)`;
  if (!memoryContext) return base;
  return `${base}

## 相关历史记忆
${memoryContext}
(以上为与本次对话相关的历史记忆,请在回答中自然参考,无需引用编号)`;
}

3.2 记忆工具

// tools.ts — memory_save
registerTool(
  {
    name: 'memory_save',
    description: '将重要事实、用户偏好、项目背景保存到长期记忆,以便在未来会话中自动召回。适合保存:用户的技术栈偏好、代码风格要求、项目背景信息、重要决策结论。',
    parameters: {
      type: 'object',
      properties: {
        content: { type: 'string', description: '要记住的内容,1-3 句话,简洁完整,包含足够上下文' },
        tags:    { type: 'string', description: 'JSON 数组格式的标签,如 ["preference","typescript"]' },
      },
      required: ['content'],
    },
  },
  async (sessionId, params) => {
    const embedding = await embed(params['content']!);
    const id = await memoryStore.save({
      sessionId,
      source: 'agent',
      content: params['content']!,
      embedding,
      tags: JSON.parse(params['tags'] ?? '[]'),
    });
    return `memory saved: ${id}`;
  },
);

// tools.ts — memory_search
registerTool(
  {
    name: 'memory_search',
    description: '语义搜索长期记忆,返回与查询最相关的历史记录。用于主动查询历史信息,日常召回由系统自动处理。',
    parameters: {
      type: 'object',
      properties: {
        query: { type: 'string', description: '查询文本' },
        top_k: { type: 'string', description: '返回条数(默认 5)' },
      },
      required: ['query'],
    },
  },
  async (_sessionId, params) => {
    const embedding = await embed(params['query']!);
    const results = await memoryStore.search(
      embedding,
      parseInt(params['top_k'] ?? '5', 10),
      { source: 'agent' },
    );
    if (results.length === 0) return 'no relevant memories found';
    return results.map((r, i) => `[${i + 1}] ${r.content}`).join('\n');
  },
);

system prompt 中需要告知 Agent 何时主动保存记忆:

## 记忆指南
当用户提到以下信息时,主动调用 memory_save 保存:
- 技术栈偏好(语言、框架、工具)
- 代码风格要求(缩进、命名、格式)
- 项目背景和目标
- 重要决策和结论
每条记忆应包含足够上下文,使其在未来会话中独立可读。

3.3 三种记忆写入策略

策略触发时机适用场景
主动保存(Agent 调 memory_saveLLM 判断值得保存时用户偏好、项目决策、不变事实
自动提取(Session 结束后)status 变为 Success长会话结束后的要点摘要
显式命令(用户说“记住这个“)用户明确指令用户主导的记忆管理

3.4 自动记忆提取 Pipeline

不依赖 Agent 自觉调用 memory_save,会话结束后用 LLM 从对话历史中蒸馏重要事实:

// memory.ts — extractAndSaveMemories
export async function extractAndSaveMemories(
  messages: Message[],
  sessionId: string,
  provider: Provider,
  store: MemoryStore,
): Promise<void> {
  const history = messages
    .filter(m => m.role !== 'system')
    .map(m => `${m.role}: ${typeof m.content === 'string' ? m.content : '[image]'}`)
    .join('\n');

  if (history.length < 200) return; // 对话太短,无需提取

  const extractPrompt = `从以下对话中提取值得长期记住的事实(用户偏好、项目设定、重要决策)。
每条一行,以 "- " 开头,最多 10 条,简洁完整(每条含足够上下文),无关紧要的内容不要提取:

${history}`;

  const raw = await provider.chat([{ role: 'user', content: extractPrompt }]);
  const lines = raw.split('\n').filter(l => l.trim().startsWith('- '));

  for (const line of lines) {
    const content = line.replace(/^-\s*/, '').trim();
    if (content.length < 15) continue; // 过短的条目丢弃
    const embedding = await embed(content);
    await store.save({ sessionId, source: 'agent', content, embedding, tags: ['auto-extracted'] });
  }
}

agent.ts 中,Session 成功完成后异步触发(不阻塞回复):

// agent.ts — handle() 方法末尾
if (this.db) this.db.setStatus(msg.sessionId, 'Success');
this.sessions.set(msg.sessionId, messages);

// 异步提取,不 await,不阻塞当前会话
extractAndSaveMemories(messages, msg.sessionId, currentProvider, this.memoryStore)
  .catch(e => warn(`[memory] extraction failed: ${e.message}`));

return reply;

3.5 与第 06 节 traces 表的关系

第 06 节引入的 traces 表和本节的 memories 表都持久化了“对话发生过什么“,但服务完全不同的目的:

维度s06 tracess08 memories
存什么每一步的完整输入/输出(verbatim)提炼后的语义事实(distilled)
为什么存崩溃恢复、回滚、Fork跨会话语义召回
访问方式按时间顺序重放(loadMessages按语义相似度检索(向量 top-K)
生命周期会话范围内(跨 restart 可恢复)跨会话长期持久
体量全量(每个 tool_call、llm_call 都记录)精选(每次会话最多十几条)

两者之间存在一条数据流——traces 是原料,memories 是精华

s06 traces 表(原始日志)
    │
    │  Session status → 'Success'
    │  db.loadMessages(sessionId) 重建完整对话历史
    │         │
    │         ▼
    │  extractAndSaveMemories(messages, ...)
    │  用 LLM 从对话历史中蒸馏重要事实
    │
    ▼
s08 memories 表(语义知识)
    │
    ▼
下次会话:embed(query) → search() → 注入 system prompt

extractAndSaveMemories() 接收的 messages 参数正是 db.loadMessages() 从 traces 表重建出来的对话历史——第 06 节的持久化基础设施直接喂养了第 08 节的记忆提取 Pipeline。

类比:traces 是服务器 access.log(全量、逐行、用于回放调试),memories 是工程师的个人笔记(提炼后的关键点、随时语义查找)。


4. 企业知识库 RAG

4.1 与 Agent 记忆的关键差异

两者技术栈完全相同,区别在于“谁写入、写什么、何时写“:

维度Agent 长期记忆企业知识库 RAG
数据来源Agent 运行过程中产生外部文档(PDF / Markdown / Wiki)
写入时机实时(对话中 / Session 结束)离线批量索引
数据规模百~千条短片段万~百万条文档 chunk
写入者Agent 自身管理员 / kb_index 工具
更新频率频繁(随对话增长)低频(文档变更时重新索引)
source 字段'agent''kb'

4.2 文档切片(Chunking)

原始文档不能整体 embed——一篇 50 页的 PDF embed 成一个向量,语义太宽泛,“在第 23 页提到的 rate limit“这类具体问题无法命中。

必须切成合适大小的 chunk,每个 chunk 语义聚焦,可以独立回答一个具体问题:

原始 PDF(50 页,约 25000 字)
    │
    ▼
文本提取(fs.readFile / pdf-parse)
    │
    ▼
chunkText():固定大小滑动窗口切片
    │
    ├── chunk 1:第 1-512 token(含 64 token 尾部)
    ├── chunk 2:第 448-960 token(前 64 token 与 chunk 1 重叠)
    ├── chunk 3:第 896-1408 token
    └── ...
    │
    ▼
每个 chunk → embed() → memoryStore.save(source: 'kb')

重叠(overlap)的作用

chunk 1 末尾:"...用户可以通过 API 调用来触发工作流。工作流"
chunk 2 开头:"工作流支持并行步骤和条件分支,每个步骤..."

没有重叠 → chunk 2 开头的"工作流"语境丢失,LLM 不知道指什么
有重叠  → chunk 2 保留上下文,独立可读,召回时 LLM 理解语境
// knowledgebase.ts — 文档切片
export function chunkText(
  text: string,
  maxTokens = 512,
  overlapTokens = 64,
): string[] {
  const charsPerToken = 4; // 粗估:1 token ≈ 4 个英文字符 / 1.5 个中文字符
  const maxChars     = maxTokens    * charsPerToken;
  const overlapChars = overlapTokens * charsPerToken;

  const chunks: string[] = [];
  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + maxChars, text.length);
    const chunk = text.slice(start, end).trim();
    if (chunk.length > 0) chunks.push(chunk);
    if (end === text.length) break;
    start = end - overlapChars; // 滑动窗口:保留尾部 overlap
  }
  return chunks;
}

切片策略对比

策略实现优点缺点
固定大小 + 重叠chunkText()(本节实现)简单,适合密集文本可能在句子中间切断
段落切片\n\n 分割保持段落完整段落长度不均,长段超限
递归字符切片优先 \n\n,退化到 \n,再退化到 . 兼顾结构 + 长度实现稍复杂
语义切片用 LLM 判断边界最精准成本高,速度慢

4.3 批量索引文档

// knowledgebase.ts — 索引单个文档
export async function indexDocument(
  filePath: string,
  docId: string,
  store: MemoryStore,
): Promise<{ docId: string; chunks: number }> {
  const raw = await fs.readFile(filePath, 'utf-8');
  const chunks = chunkText(raw);

  for (let i = 0; i < chunks.length; i++) {
    const embedding = await embed(chunks[i]!);
    await store.save({
      sessionId: 'global',   // 知识库记录不属于特定会话
      source: 'kb',
      docId,
      content: chunks[i]!,
      embedding,
      tags: [docId, `chunk-${i}`],
    });
  }

  return { docId, chunks: chunks.length };
}

4.4 知识库工具

// tools.ts — kb_index
registerTool(
  {
    name: 'kb_index',
    description: '将文件批量索引到知识库,建立语义检索能力。支持 .txt / .md 等文本格式,索引后可用 kb_search 检索。',
    parameters: {
      type: 'object',
      properties: {
        path:   { type: 'string', description: '文件路径(workspace 内)' },
        doc_id: { type: 'string', description: '文档唯一标识符,用于后续按文档过滤检索' },
      },
      required: ['path', 'doc_id'],
    },
  },
  async (_sessionId, params) => {
    const abs = canonicalize(params['path']!, workDir);
    const result = await indexDocument(abs, params['doc_id']!, memoryStore);
    return `indexed ${result.chunks} chunks from ${params['path']} (doc_id: ${result.docId})`;
  },
);

// tools.ts — kb_search
registerTool(
  {
    name: 'kb_search',
    description: '在知识库中语义搜索,返回最相关的文档片段。适合从已索引文档中查找具体信息。',
    parameters: {
      type: 'object',
      properties: {
        query:  { type: 'string', description: '查询问题或关键词' },
        top_k:  { type: 'string', description: '返回条数(默认 5)' },
        doc_id: { type: 'string', description: '限定在特定文档内检索(可选)' },
      },
      required: ['query'],
    },
  },
  async (_sessionId, params) => {
    const embedding = await embed(params['query']!);
    const results = await memoryStore.search(
      embedding,
      parseInt(params['top_k'] ?? '5', 10),
      { source: 'kb', docId: params['doc_id'] },
    );
    if (results.length === 0) return 'no relevant documents found';
    return results
      .map((r, i) => `[${i + 1}] (doc: ${r.docId})\n${r.content}`)
      .join('\n\n');
  },
);

4.5 双路召回:Agent 记忆 + 知识库并行

用户发送消息:"用 TypeScript 帮我写一个遵循 API 文档限制的请求模块"
    │
    ├── 并行召回 ①:Agent 记忆(source: 'agent')
    │     → 用户使用 TypeScript,2 空格缩进,不用分号
    │
    └── 并行召回 ②:知识库(source: 'kb')
          → [api-ref] 每分钟最多 60 次请求,超出返回 429...
          → [api-ref] 建议使用指数退避重试策略...
    │
    ▼
合并注入 system prompt:

  ## 相关历史记忆
  - 用户使用 TypeScript,2 空格缩进,不用分号

  ## 相关文档
  [1] (doc: api-ref) 每分钟最多 60 次请求...
  [2] (doc: api-ref) 建议使用指数退避重试策略...

    │
    ▼
LLM 生成:包含 rate limit 处理逻辑的 TypeScript 代码,2 空格缩进,无分号 ✓
// agent.ts — 双路并行召回
async function buildContextWithMemory(
  query: string,
  memoryStore: MemoryStore,
): Promise<string> {
  const embedding = await embed(query);

  // 并行召回两个来源
  const [agentMemories, kbChunks] = await Promise.all([
    memoryStore.search(embedding, 5, { source: 'agent' }),
    memoryStore.search(embedding, 5, { source: 'kb'    }),
  ]);

  const parts: string[] = [];

  if (agentMemories.length > 0) {
    parts.push('## 相关历史记忆\n' + agentMemories.map(r => `- ${r.content}`).join('\n'));
  }
  if (kbChunks.length > 0) {
    parts.push(
      '## 相关文档\n' +
      kbChunks.map((r, i) => `[${i + 1}] (doc: ${r.docId})\n${r.content}`).join('\n\n'),
    );
  }

  return parts.join('\n\n');
}

5. 改动全景

第 07 节                              第 08 节

agent.ts                              agent.ts
  handle()                    →         handle()
    buildSystemPrompt()                   + buildContextWithMemory()
                                              并行召回 Agent 记忆 + KB
                                          buildSystemPrompt(memoryContext)
    agent loop(不变)                      agent loop(不变)
                                          + extractAndSaveMemories()
                                              (异步,Success 后,不阻塞回复)

tools.ts                              tools.ts
  registerBrowserTools()      →         registerBrowserTools()(不变)
                                        + registerMemoryTools(memoryStore)
                                              memory_save  ← 主动保存偏好/事实
                                              memory_search ← 主动查询记忆
                                        + registerKBTools(memoryStore)
                                              kb_index     ← 批量索引文档
                                              kb_search    ← 知识库语义检索

memory.ts(新建)                       MemoryEntry 接口(source: 'agent'|'kb')
                                        MemoryStore 接口
                                        SQLiteMemoryStore  ← 零依赖,余弦全量排序
                                        MilvusMemoryStore  ← ANN,百万级文档
                                        createMemoryStore(cfg) ← 工厂函数
                                        embed(text)        ← OpenAI embeddings
                                        cosineSimilarity() ← 向量相似度
                                        extractAndSaveMemories() ← 自动提取 Pipeline

knowledgebase.ts(新建)                chunkText(text, maxTokens, overlapTokens)
                                          固定大小滑动窗口切片
                                        indexDocument(filePath, docId, store)
                                          文本提取 → 切片 → 批量 embed → 存储

config.ts                             config.ts
  无 memory 字段              →         + memory:
                                            backend: 'sqlite' | 'milvus'
                                            milvus: { address: string }
                                            topK: 5

db.ts / SQLite                        db.ts / SQLite(backend=sqlite 时扩展)
  sessions + traces           →         + memories 表
                                            id / session_id / source / doc_id
                                            content / embedding / tags / created_at

index.ts                              index.ts
  browserPool.init()          →         + memoryStore = createMemoryStore(cfg)
  registerBrowserTools()                + registerMemoryTools(memoryStore)
                                        + registerKBTools(memoryStore)
  SIGINT: browserPool.closeAll()        + memoryStore.close()

增加能力:
  跨会话记忆  → memory_save + 自动提取 → 下次会话自动召回用户偏好
  语义记忆搜索 → memory_search(主动查询历史记录)
  企业文档索引 → kb_index(离线批量切片 + embed)
  知识库检索  → kb_search(语义搜索文档片段)
  双路召回    → 并行 Agent 记忆 + KB,合并注入 system prompt
  后端可插拔  → SQLite(零依赖)/ Milvus(生产级)按 config 切换

知识点总结

知识点说明
短期 vs 长期记忆context window = 工作记忆(容量限、关窗即失);向量存储 = 长期记忆(持久、按需召回)
Agent 记忆 vs 知识库 RAGAgent 记忆:运行时动态写入,个人偏好/历史;知识库:离线批量索引,外部文档;共用 MemoryStore 接口
Embedding文本 → 高维向量;语义相近的文本向量方向相近;text-embedding-3-small 1536 维
余弦相似度衡量向量方向一致程度;1 = 完全相同,0 = 无关;不受向量长度影响,优于欧氏距离
RAG 注入位置召回结果注入 system prompt 末尾,而非 user message;避免污染对话历史
双路并行召回Promise.all([search(agent), search(kb)]) 并行召回后合并注入;互不干扰
MemoryStore 接口SQLite(零依赖,O(n) 全量余弦,<50K 条)/ Milvus(O(log n) ANN,百万级);工厂函数按 config 切换
source 字段'agent' = Agent 运行时写入;'kb' = 知识库离线索引;search filter 按来源隔离召回
文档切片(Chunking)固定大小滑动窗口:512 token + 64 token overlap;overlap 保证跨 chunk 语义连续性
主动保存Agent 判断值得保存时调 memory_save;system prompt 中给出保存时机指南
自动提取 PipelineSession Success 后异步触发;用 LLM 从对话历史蒸馏要点;不阻塞当前回复
topK 召回默认 top-5;Agent 记忆片段短可用 5-10;KB chunk 较长建议 3-5;过多引入噪音
HNSW 索引Hierarchical Navigable Small World;M 控制图连通性,efConstruction 控制构建精度;ANN 召回率 >95%
Embedding 成本text-embedding-3-small $0.02/1M tokens;10K 条 × 100 token ≈ $0.002,可忽略

试一试

cd sections/08-memory-rag/nodejs
cp .env.example .env
# 确认 .env 中 OPENAI_API_KEY 正确(用于 embedding + chat)
npm install
npm start

Terminal 2(CLI 客户端)

node --env-file=.env src/cli.ts

验证 Agent 长期记忆

You: 我用 TypeScript 开发,代码风格是 2 空格缩进、不用分号

xclaw uses [memory_save]: {"content":"用户使用 TypeScript 开发,代码风格:2 空格缩进,不使用分号","tags":"[\"preference\",\"code-style\"]"}
→ memory saved: a1b2c3d4-...

(Ctrl+C 停止服务,重新启动,新建会话)

You: 帮我写一个简单的 fetch 封装

(系统在 system prompt 中自动注入:
  ## 相关历史记忆
  - 用户使用 TypeScript 开发,代码风格:2 空格缩进,不使用分号)

xclaw: 这是符合你风格的 TypeScript fetch 封装:
  async function request<T>(url: string, options?: RequestInit): Promise<T> {
    const res = await fetch(url, options)    ← 无分号 ✓
    if (!res.ok) throw new Error(`HTTP ${res.status}`)  ← 2 空格缩进 ✓
    return res.json() as Promise<T>
  }

验证知识库 RAG

You: 先把 API 文档索引一下

xclaw uses [kb_index]: {"path":"docs/api-reference.md","doc_id":"api-ref"}
→ indexed 47 chunks from docs/api-reference.md (doc_id: api-ref)

You: API 的 rate limit 策略是什么?

xclaw uses [kb_search]: {"query":"rate limit 频率限制策略","top_k":"3","doc_id":"api-ref"}
→ [1] (doc: api-ref) 每分钟最多 60 次请求,超出限制返回 HTTP 429...
→ [2] (doc: api-ref) 建议客户端实现指数退避重试:首次等待 1s,依次翻倍...

xclaw: 根据 API 文档,rate limit 为每分钟 60 次请求。
       超出时服务器返回 429,建议实现指数退避重试策略。

验证双路召回

You: 帮我写一个符合我风格的 API 客户端,要处理好限流

(系统并行召回:
  Agent 记忆 → "用户 TypeScript,2 空格,无分号"
  知识库     → "rate limit 60次/min" + "指数退避重试")

xclaw: 这是包含限流处理的 TypeScript API 客户端:

  async function apiRequest<T>(url: string, retries = 3): Promise<T> {
    for (let i = 0; i < retries; i++) {
      const res = await fetch(url)         ← 无分号 ✓,2 空格缩进 ✓
      if (res.status === 429) {
        await new Promise(r => setTimeout(r, 1000 * 2 ** i))  ← 指数退避 ✓
        continue
      }
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json() as Promise<T>
    }
    throw new Error('max retries exceeded')
  }

验证自动记忆提取

(经过一次较长会话,Agent 完成了若干任务)
(会话结束,status → Success,异步触发提取)

[memory] extractAndSaveMemories: session cli-abc → 3 memories extracted
  - 用户正在开发名为 xclaw 的 AI Agent 框架
  - 项目使用 Node.js + TypeScript,数据库为 SQLite
  - API 文档已索引到知识库,doc_id: api-ref

(下次会话,这些事实自动作为上下文注入)