8. 记忆系统

本章目标

实现跨会话记忆:让 Agent 在多次对话间保持对用户和项目的认知,不依赖对话历史。

graph TB
    Save[保存记忆<br/>write_file → .md] --> Index[MEMORY.md 索引]
    Index --> Inject[注入 system prompt]
    Query[用户提问] --> Prefetch[异步预取<br/>startMemoryPrefetch]
    Prefetch --> SideQuery[sideQuery<br/>语义选择相关记忆]
    SideQuery --> Recall[注入为 user message]

    style SideQuery fill:#7c5cfc,color:#fff
    style Inject fill:#e8e0ff

Claude Code 怎么做的

Claude Code 记忆系统的核心约束只有一条:只记忆不可从当前项目状态推导的信息。代码模式、架构、文件路径、git 历史、正在进行的调试——这些读代码和 git log 就能获得,记忆中的版本只会制造漂移。连用户明确要求保存的信息也不例外——如果用户说”记住这个 PR 列表”,Agent 应该追问:列表中有什么是不可推导的?某个截止日期?某个意外发现?

记忆分四种类型:

类型记什么触发时机
user用户身份、偏好、知识背景了解到用户角色/偏好时
feedback对 Agent 行为的纠正和肯定用户纠正或肯定某个行为时
project项目进展、决策、截止日期了解到项目动态时
reference外部系统的定位信息了解到外部系统位置时

封闭分类法而非自由标签——防止标签膨胀导致召回时的模糊匹配。

feedback 类型有个细节:不只记录纠正,也记录用户的肯定。原因很实际:只记录”错误”会让模型避免重蹈覆辙,但也可能无意间放弃用户已经验证过的好做法。这两种类型还要求正文包含 WhyHow to apply——因为知道”为什么”才能判断边界情况,盲目执行规则往往适得其反。

project 类型有个具体要求:相对日期必须转为绝对日期。“周四之后合并冻结”→“2026-03-05 后合并冻结”。记忆可能在几周后被读取,“周四”到时已毫无意义。

MEMORY.md 是索引不是容器。 它每次会话都完整加载到 system prompt,所以必须紧凑——每条一行链接,实际内容按需读取。设有 200 行/25KB 双重截断,超出时追加提示”keep index entries to one line under ~200 chars”。错误消息包含修复指引,这是贯穿整个系统的设计习惯。

召回机制sideQuery 调模型做语义匹配,而非关键词搜索。用户问”部署流程”时,语义匹配能找到标题为”CI/CD 注意事项”的记忆,关键词匹配则不行。召回在模型开始生成响应的同时异步执行(pendingMemoryPrefetch),对用户而言延迟近乎为零。每次最多返回 5 条,上下文成本可控。

每条记忆还附带 freshness warning——超过 1 天的记忆会标注过期天数,提醒模型记忆是时间切片而非实时状态。“下周截止”的记忆在两周后读到时,模型需要知道它可能已经过时。


我们的实现

存储结构

~/.mini-claude/projects/{sha256-hash}/memory/
├── MEMORY.md                          # 索引文件
├── user_prefers_concise_output.md
├── feedback_no_summary_at_end.md
├── project_auth_migration_q2.md
└── reference_ci_dashboard_url.md

路径中的哈希是 process.cwd() 的 sha256 前 16 位——同一项目目录始终映射到同一记忆空间。

记忆文件格式

---
name: 不要在回复末尾总结
description: 用户明确要求省略总结段落
type: feedback
---
用户说"不要在响应末尾总结",因为他们能自己看 diff 和代码变更。
 
**Why:** 用户觉得总结浪费时间,更喜欢直接给出结果。
**How to apply:** 完成任务后直接结束,不要加 "总结" 或 "以上是..." 段落。

Frontmatter 解析(共享模块)

记忆和技能都要解析 YAML frontmatter,抽出 frontmatter.ts

TypeScript

// frontmatter.ts
 
export function parseFrontmatter(content: string): FrontmatterResult {
  const lines = content.split("\n");
  if (lines[0]?.trim() !== "---") return { meta: {}, body: content };
 
  let endIdx = -1;
  for (let i = 1; i < lines.length; i++) {
    if (lines[i].trim() === "---") { endIdx = i; break; }
  }
  if (endIdx === -1) return { meta: {}, body: content };
 
  const meta: Record<string, string> = {};
  for (let i = 1; i < endIdx; i++) {
    const colonIdx = lines[i].indexOf(":");
    if (colonIdx === -1) continue;
    const key = lines[i].slice(0, colonIdx).trim();
    const value = lines[i].slice(colonIdx + 1).trim();
    if (key) meta[key] = value;
  }
 
  const body = lines.slice(endIdx + 1).join("\n").trim();
  return { meta, body };
}

Python

# frontmatter.py
 
@dataclass
class FrontmatterResult:
    meta: dict[str, str] = field(default_factory=dict)
    body: str = ""
 
 
def parse_frontmatter(content: str) -> FrontmatterResult:
    lines = content.split("\n")
    if not lines or lines[0].strip() != "---":
        return FrontmatterResult(body=content)
 
    end_idx = -1
    for i in range(1, len(lines)):
        if lines[i].strip() == "---":
            end_idx = i
            break
    if end_idx == -1:
        return FrontmatterResult(body=content)
 
    meta: dict[str, str] = {}
    for i in range(1, end_idx):
        colon_idx = lines[i].find(":")
        if colon_idx == -1:
            continue
        key = lines[i][:colon_idx].strip()
        value = lines[i][colon_idx + 1:].strip()
        if key:
            meta[key] = value
 
    body = "\n".join(lines[end_idx + 1:]).strip()
    return FrontmatterResult(meta=meta, body=body)

没有用 js-yaml 之类的库——我们的 frontmatter 只是简单的 key: value,20 行手写解析器够用且零依赖。

保存与索引

TypeScript

// memory.ts — saveMemory
 
export function saveMemory(entry: Omit<MemoryEntry, "filename">): string {
  const dir = getMemoryDir();
  const filename = `${entry.type}_${slugify(entry.name)}.md`;
  const content = formatFrontmatter(
    { name: entry.name, description: entry.description, type: entry.type },
    entry.content
  );
  writeFileSync(join(dir, filename), content);
  updateMemoryIndex();
  return filename;
}
 
function updateMemoryIndex(): void {
  const memories = listMemories();
  const lines = ["# Memory Index", ""];
  for (const m of memories) {
    lines.push(`- **[${m.name}](${m.filename})** (${m.type}) — ${m.description}`);
  }
  writeFileSync(getIndexPath(), lines.join("\n"));
}

Python

# memory.py — save_memory
 
def save_memory(name: str, description: str, type: str, content: str) -> str:
    d = get_memory_dir()
    filename = f"{type}_{_slugify(name)}.md"
    text = format_frontmatter(
        {"name": name, "description": description, "type": type}, content
    )
    (d / filename).write_text(text)
    _update_memory_index()
    return filename
 
def _update_memory_index() -> None:
    memories = list_memories()
    lines = ["# Memory Index", ""]
    for m in memories:
        lines.append(f"- **[{m.name}]({m.filename})** ({m.type}) — {m.description}")
    _get_index_path().write_text("\n".join(lines))

文件名格式 {type}_{slugified_name}.md 让文件系统排序时自动按类型分组,人眼扫描也一目了然。每次写入后立即重建索引,保持 MEMORY.md 与文件系统同步。

索引截断

TypeScript

// memory.ts — loadMemoryIndex
 
const MAX_INDEX_LINES = 200;
const MAX_INDEX_BYTES = 25000;
 
export function loadMemoryIndex(): string {
  // ...
  const lines = content.split("\n");
  if (lines.length > MAX_INDEX_LINES) {
    content = lines.slice(0, MAX_INDEX_LINES).join("\n") +
      "\n\n[... truncated, too many memory entries ...]";
  }
  if (Buffer.byteLength(content) > MAX_INDEX_BYTES) {
    content = content.slice(0, MAX_INDEX_BYTES) +
      "\n\n[... truncated, index too large ...]";
  }
  return content;
}

Python

# memory.py — load_memory_index
 
MAX_INDEX_LINES = 200
MAX_INDEX_BYTES = 25000
 
def load_memory_index() -> str:
    index_path = _get_index_path()
    if not index_path.exists():
        return ""
    content = index_path.read_text()
    lines = content.split("\n")
    if len(lines) > MAX_INDEX_LINES:
        content = "\n".join(lines[:MAX_INDEX_LINES]) + "\n\n[... truncated, too many memory entries ...]"
    if len(content.encode()) > MAX_INDEX_BYTES:
        content = content[:MAX_INDEX_BYTES] + "\n\n[... truncated, index too large ...]"
    return content

两层截断各有用途:行截断(200 行)是正常防护,按完整条目截断;字节截断(25KB)是异常防御,捕捉行数不多但单行极长的情况——Claude Code 团队在生产中见过 197KB 塞在 200 行内的案例。

System Prompt 注入

buildMemoryPromptSection() 生成注入到 system prompt 的文本,告诉模型记忆系统的存在和用法:

TypeScript

// memory.ts — buildMemoryPromptSection(简化展示)
 
export function buildMemoryPromptSection(): string {
  const index = loadMemoryIndex();
  const memoryDir = getMemoryDir();
 
  return `# Memory System
 
You have a persistent, file-based memory system at \`${memoryDir}\`.
 
## Memory Types
- **user**: User's role, preferences, knowledge level
- **feedback**: Corrections and guidance from the user
- **project**: Ongoing work, goals, deadlines, decisions
- **reference**: Pointers to external resources
 
## How to Save Memories
Use the write_file tool to create a memory file with YAML frontmatter:
...
Save to: \`${memoryDir}/\`
Filename format: \`{type}_{slugified_name}.md\`
 
## What NOT to Save
- Code patterns or architecture (read the code instead)
- Git history (use git log)
- Anything already in CLAUDE.md
- Ephemeral task details
 
${index ? `## Current Memory Index\n${index}` : "(No memories saved yet.)"}`;
}

Python

# memory.py — build_memory_prompt_section(简化展示)
 
def build_memory_prompt_section() -> str:
    index = load_memory_index()
    memory_dir = str(get_memory_dir())
 
    return f"""# Memory System
 
You have a persistent, file-based memory system at `{memory_dir}`.
 
## Memory Types
- **user**: User's role, preferences, knowledge level
- **feedback**: Corrections and guidance from the user
- **project**: Ongoing work, goals, deadlines, decisions
- **reference**: Pointers to external resources
 
## How to Save Memories
Use the write_file tool to create a memory file with YAML frontmatter:
...
Save to: `{memory_dir}/`
Filename format: `{{type}}_{{slugified_name}}.md`
 
## What NOT to Save
- Code patterns or architecture (read the code instead)
- Git history (use git log)
- Anything already in CLAUDE.md
- Ephemeral task details
 
{"## Current Memory Index" + chr(10) + index if index else "(No memories saved yet.)"}"""

这段 prompt 做了三件事:教模型分类(四种类型)、教模型操作(用 write_file、存到哪里、什么格式)、教模型克制(“What NOT to Save”)。“让模型使用记忆”不只是给它一个工具,还要在 prompt 中描述完整的类型体系和边界,模型才能做出好的决策。

最后在 prompt.ts 中通过占位符注入:

TypeScript

systemPrompt = systemPrompt.replace("{{memory}}", buildMemoryPromptSection());

Python

result = result.replace("{{memory}}", build_memory_prompt_section())

CLI 交互

用户在 REPL 中输入 /memory 可以列出所有记忆:

TypeScript

if (input === "/memory") {
  const memories = listMemories();
  if (memories.length === 0) {
    printInfo("No memories saved yet.");
  } else {
    printInfo(`${memories.length} memories:`);
    for (const m of memories) {
      console.log(`    [${m.type}] ${m.name} — ${m.description}`);
    }
  }
}

Python

if inp == "/memory":
    memories = list_memories()
    if not memories:
        print_info("No memories saved yet.")
    else:
        print_info(f"{len(memories)} memories:")
        for m in memories:
            print(f"    [{m.type}] {m.name}{m.description}")
    continue

语义召回(sideQuery)

早期版本用关键词匹配做记忆召回——把查询拆成词,统计每条记忆的命中数排序。这很简单但能力有限:用户问”部署流程”时,标题为”CI/CD 注意事项”的记忆完全匹配不上,因为没有共同关键词。

新版本用 sideQuery 做语义召回:把所有记忆的文件名和描述发给模型,让模型判断哪些与当前查询相关。

// memory.ts — selectRelevantMemories
 
const SELECT_MEMORIES_PROMPT = `You are selecting memories that will be useful to an AI coding assistant as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions.
 
Return a JSON object with a "selected_memories" array of filenames for the memories that will clearly be useful (up to 5). Only include memories that you are certain will be helpful based on their name and description.
- If you are unsure if a memory will be useful, do not include it.
- If no memories would clearly be useful, return an empty array.`;
 
export async function selectRelevantMemories(
  query: string,
  sideQuery: SideQueryFn,
  alreadySurfaced: Set<string>,
  signal?: AbortSignal,
): Promise<RelevantMemory[]> {
  const headers = scanMemoryHeaders();
  if (headers.length === 0) return [];
 
  // 过滤已经在本会话中展示过的记忆
  const candidates = headers.filter((h) => !alreadySurfaced.has(h.filePath));
  if (candidates.length === 0) return [];
 
  const manifest = formatMemoryManifest(candidates);
 
  try {
    const text = await sideQuery(
      SELECT_MEMORIES_PROMPT,
      `Query: ${query}\n\nAvailable memories:\n${manifest}`,
      signal,
    );
 
    // 从响应中提取 JSON(模型可能用 markdown 代码块包裹)
    const jsonMatch = text.match(/\{[\s\S]*\}/);
    if (!jsonMatch) return [];
 
    const parsed = JSON.parse(jsonMatch[0]);
    const selectedFilenames: string[] = parsed.selected_memories || [];
 
    // 文件名映射回 header,读取完整内容
    const filenameSet = new Set(selectedFilenames);
    const selected = candidates.filter((h) => filenameSet.has(h.filename));
 
    return selected.slice(0, 5).map((h) => {
      let content = readFileSync(h.filePath, "utf-8");
      // 单文件截断(4KB)
      if (Buffer.byteLength(content) > MAX_MEMORY_BYTES_PER_FILE) {
        content = content.slice(0, MAX_MEMORY_BYTES_PER_FILE) +
          "\n\n[... truncated, memory file too large ...]";
      }
      const freshness = memoryFreshnessWarning(h.mtimeMs);
      const headerText = freshness
        ? `${freshness}\n\nMemory: ${h.filePath}:`
        : `Memory (saved ${memoryAge(h.mtimeMs)}): ${h.filePath}:`;
 
      return { path: h.filePath, content, mtimeMs: h.mtimeMs, header: headerText };
    });
  } catch (err: any) {
    // 静默失败——记忆召回永远不应阻塞主循环
    if (signal?.aborted) return [];
    console.error(`[memory] semantic recall failed: ${err.message}`);
    return [];
  }
}

几个关键设计点:

sideQuery 用的是同一个模型,不是单独的小模型。 Claude Code 用 Sonnet 做 sideQuery,我们简化为直接复用用户配置的模型。sideQuery 只发送记忆清单(文件名 + 描述),不发送完整内容,所以输入 token 很少。

模型做语义选择,比关键词匹配强得多。 “部署流程”能匹配到”CI/CD 注意事项”,“数据库性能”能匹配到”PostgreSQL 索引优化经验”——因为模型理解语义关联,不只是字面重叠。

alreadySurfaced Set 防止重复召回。 同一会话中已经展示过的记忆不会再次出现,避免用户每次提问都看到相同的记忆。这个 Set 在整个会话生命周期内持续增长。

单文件 4KB 截断 + 会话总预算 60KB。 防止单条巨大记忆或累积过多召回挤占上下文。预算是字节级控制,不是 token 级——字节计算更快,且对多语言文本更公平。

对比旧版关键词匹配(已替换): 旧实现把查询拆词后逐条匹配,零 API 调用但准确度低。新版每次召回消耗 1 次 API 调用,但语义理解能力质的飞跃。对于教程项目记忆量少的场景,这个 API 成本完全可以接受。

异步预取(startMemoryPrefetch)

语义召回需要一次 API 调用,如果同步执行会增加用户等待时间。解决方案:在用户提交输入的瞬间就启动召回,与第一次模型 API 调用并行执行。

// memory.ts — startMemoryPrefetch
 
export function startMemoryPrefetch(
  query: string,
  sideQuery: SideQueryFn,
  alreadySurfaced: Set<string>,
  sessionMemoryBytes: number,
  signal?: AbortSignal,
): MemoryPrefetch | null {
  // 门控 1: 单词查询跳过(太短,无法语义匹配)
  if (!/\s/.test(query.trim())) return null;
 
  // 门控 2: 会话预算已满
  if (sessionMemoryBytes >= MAX_SESSION_MEMORY_BYTES) return null;
 
  // 门控 3: 没有记忆文件
  const dir = getMemoryDir();
  const hasMemories = readdirSync(dir).some(
    (f) => f.endsWith(".md") && f !== "MEMORY.md"
  );
  if (!hasMemories) return null;
 
  const handle: MemoryPrefetch = {
    promise: selectRelevantMemories(query, sideQuery, alreadySurfaced, signal),
    settled: false,
    consumed: false,
  };
  handle.promise.then(() => { handle.settled = true; }).catch(() => { handle.settled = true; });
  return handle;
}

agent.ts 中的使用:

// agent.ts — 预取启动与消费
 
// 用户消息进入后立即启动预取
this.anthropicMessages.push({ role: "user", content: userMessage });
let memoryPrefetch: MemoryPrefetch | null = null;
if (!this.isSubAgent) {
  const sq = this.buildSideQuery();
  if (sq) {
    memoryPrefetch = startMemoryPrefetch(
      userMessage, sq,
      this.alreadySurfacedMemories, this.sessionMemoryBytes,
      this.abortController?.signal,
    );
  }
}
 
// while 循环中,每次 API 调用前做非阻塞轮询
if (memoryPrefetch && memoryPrefetch.settled && !memoryPrefetch.consumed) {
  memoryPrefetch.consumed = true;
  const memories = await memoryPrefetch.promise;
  if (memories.length > 0) {
    const injectionText = formatMemoriesForInjection(memories);
    this.anthropicMessages.push({ role: "user", content: injectionText });
    // 跟踪已展示的记忆和会话预算
    for (const m of memories) {
      this.alreadySurfacedMemories.add(m.path);
      this.sessionMemoryBytes += Buffer.byteLength(m.content);
    }
  }
}

这个设计的关键在于非阻塞轮询

  1. 预取在用户输入时启动——与第一次模型 API 调用并行,用户感知不到额外延迟
  2. 每次循环迭代都检查——如果预取还没完成,不等待,直接跳过;下一次迭代再检查
  3. settled 标志用 .then() 设置——不用 await,只在确认完成后才读取结果
  4. 消费后标记 consumed = true——确保同一次预取只注入一次

三个门控条件避免浪费 API 调用:

  • 多词查询:单个词(如 “hi”)太短,语义匹配无意义
  • 会话预算:累积超过 60KB 后停止召回,防止上下文过载
  • 记忆存在性:没有记忆文件时跳过,省一次 API 调用

formatMemoriesForInjection 把每条记忆包裹在 <system-reminder> 标签中注入为 user message:

export function formatMemoriesForInjection(memories: RelevantMemory[]): string {
  return memories
    .map((m) => `<system-reminder>\n${m.header}\n\n${m.content}\n</system-reminder>`)
    .join("\n\n");
}

Freshness Warning

记忆是时间切片,不是实时状态。一条”项目下周截止”的记忆在两周后读到时已经过时,模型如果不知道这一点就会给出错误建议。

// memory.ts — memoryFreshnessWarning
 
export function memoryFreshnessWarning(mtimeMs: number): string {
  const days = Math.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000));
  if (days <= 1) return "";
  return `This memory is ${days} days old. Memories are point-in-time observations, not live state — claims about code behavior may be outdated. Verify against current code before asserting as fact.`;
}

规则很简单:1 天以内不提示(信息基本新鲜),超过 1 天就附带警告。警告文本明确告诉模型两件事:“这是过去某个时刻的观察”和”需要对照当前代码验证”。这比简单标注”X 天前”更有效——它给出了行动指引,而非只是信息。


关键设计决策

为什么记忆用文件系统而非数据库? 三个好处:用户可以直接用编辑器读写记忆文件;模型用已有的 write_file/read_file 工具就能操作,不需要专门的记忆 API;如有需要可以纳入 git 版本控制。记忆系统”寄生”在工具系统上,减少了需要暴露的接口数量。

为什么用语义召回而非关键词匹配? 关键词匹配只能找到字面重叠的记忆,语义召回能理解”部署流程”和”CI/CD 注意事项”的关联。代价是每次召回消耗 1 次 API 调用,但 sideQuery 只发送记忆清单(文件名 + 描述),输入 token 极少,成本很低。对于记忆量有限的场景,这个 trade-off 完全值得。

为什么异步预取而非同步召回? 同步召回意味着用户每次提问都要多等一个 API 往返。预取与第一次模型调用并行,如果预取先完成,记忆在第一轮响应中就可见;如果没完成,第二轮也能赶上。最差情况下记忆晚到一轮,但用户永远不需要等。

为什么需要会话级预算? 无限召回会让上下文充满记忆,挤掉真正的对话内容。60KB 预算大约相当于 20-30 条中等长度的记忆,足够覆盖一次会话的上下文需求。alreadySurfaced 集合配合预算上限,让越到会话后期记忆召回越精准——已经展示过的不重复,预算内只留真正需要的。

对比总览

维度Claude Codemini-claude
召回方式Sonnet sideQuery 语义匹配sideQuery 语义匹配(同模型)
异步预取pendingMemoryPrefetchstartMemoryPrefetch
会话预算60KB60KB
Freshness过期警告过期警告
API 调用每次召回 1 次每次召回 1 次

下一章:可复用的 Prompt 模块——技能系统。