Claude Code源码拆解二: 记忆管理和调取

6 阅读11分钟

Claude Code 记忆管理和调取

概述

Claude Code 采用分层记忆系统,包括:

  1. 文件记忆(File-based Memory):持久化存储在 ~/.claude/projects/<slug>/memory/
  2. 会话记忆(Session Memory):自动维护的会话摘要

核心文件

  • src/memdir/memdir.ts - 文件记忆系统
  • src/memdir/memoryTypes.ts - 记忆类型定义
  • src/services/SessionMemory/sessionMemory.ts - 会话记忆自动提取

记忆类型体系

1. 四种记忆类型

export const MEMORY_TYPES = [
  'user',      // 用户信息
  'feedback',  // 反馈指导
  'project',   // 项目信息
  'reference', // 外部系统指针
] as const;

类型详解

1.1 User(用户记忆)

范围:私人(always private)

内容

  • 用户的角色、目标、职责和知识
  • 用户的偏好和视角

保存时机

用户:我是数据科学家,正在调查现有的日志记录

助手:[保存私人用户记忆:用户是数据科学家,当前专注于可观测性/日志记录]

用户:我写 Go 已经十年了,但这是我第一次接触这个仓库的 React 部分

助手:[保存私人用户记忆:深厚的 Go 专业知识,对 React 和该项目前端不熟悉——用后端类比来解释前端概念]

使用场景

  • 根据用户的背景定制回答
  • 适应用户的知识水平
  • 提供用户视角下的价值信息
1.2 Feedback(反馈记忆)

范围:默认私人,项目级约定可设为团队

内容

  • 用户关于如何工作的指导
  • 应该避免什么和继续做什么
  • 成功和失败的反馈

保存时机

用户:不要在测试中使用数据库模拟——上个季度模拟测试通过了但生产迁移失败

助手:[保存团队反馈记忆:集成测试必须访问真实数据库,不能模拟。原因:之前的事件中模拟/生产差异掩盖了失败的迁移]

用户:停止在每个响应末尾总结你刚才做了什么,我能读懂 diff

助手:[保存私人反馈记忆:该用户想要简洁的响应,不要结尾总结]

结构

规则本身
**Why:** 用户给出的原因(通常是过去事件或强烈偏好)
**How to apply:** 此指导何时/何地生效

示例

集成测试必须访问真实数据库,不能模拟。
**Why:** 之前的事件中模拟/生产差异掩盖了失败的迁移
**How to apply:** 所有数据库相关的集成测试
1.3 Project(项目记忆)

范围:私人或团队,强烈偏向团队

内容

  • 项目中的持续工作、目标、计划、错误或事件
  • 无法从代码或 git 历史推导的信息

保存时机

用户:周四之后我们将冻结所有非关键合并——移动团队正在切发布分支

助手:[保存团队项目记忆:2026-03-05 开始合并冻结以进行移动发布。标记任何安排在该日期后的非关键 PR 工作]

用户:我们移除旧认证中间件的原因是法律标记它存储会话令牌的方式不符合新的合规要求

助手:[保存团队项目记忆:认证中间件重写由法律/合规要求驱动,不是技术债务清理——范围决策应优先考虑合规性而非可用性]

结构

事实或决策
**Why:** 动机(通常是约束、截止日期或利益相关者要求)
**How to apply:** 这应该如何塑造你的建议

示例

合并冻结从 2026-03-05 开始以进行移动发布切分支。
**Why:** 移动团队需要稳定的代码库进行发布
**How to apply:** 标记所有安排在此日期后的非关键 PR 工作
1.4 Reference(参考记忆)

范围:通常为团队

内容

  • 外部系统中的信息位置指针
  • 帮助记住在哪里查找最新信息

保存时机

用户:查看 Linear 项目 "INGEST" 如果你想了解这些工单,我们在那里跟踪所有管道错误

助手:[保存团队参考记忆:管道错误在 Linear 项目 "INGEST" 中跟踪]

用户:grafana.internal/d/api-latency 的 Grafana 面板是值班人员监控的——如果你接触请求处理,那东西会呼叫某人

助手:[保存团队参考记忆:grafana.internal/d/api-latency 是值班延迟仪表板——编辑请求路径代码时检查它]

2. 不保存的内容

明确排除

export const WHAT_NOT_TO_SAVE_SECTION = [
  '## What NOT to save in memory',
  '',
  '- 代码模式、约定、架构、文件路径或项目结构——这些可以从当前项目状态推导',
  '- Git 历史、最近更改或谁更改了什么——git log / git blame 是权威',
  '- 调试解决方案或修复方法——修复在代码中;提交消息有上下文',
  '- CLAUDE.md 文件中已记录的任何内容',
  '- 临时任务细节:进行中的工作、临时状态、当前会话上下文',
  '',
  '这些排除适用于即使用户明确要求保存。如果他们要求保存 PR 列表或活动摘要,询问什么*令人惊讶*或*非显而易见*——那部分值得保留。'
];

原因

  • 保持记忆简洁和相关
  • 避免重复可推导的信息
  • 减少记忆漂移(memory drift)

文件存储结构

1. 目录布局

~/.claude/projects/<slug>/memory/
├── MEMORY.md          # 索引文件(最大 200 行,25KB)
├── user_role.md       # 用户角色记忆
├── feedback_testing.md # 测试反馈记忆
├── project_release.md # 发布项目记忆
├── reference_linear.md # Linear 参考记忆
└── logs/              # KAIROS 模式下的日志
    └── 2026/
        └── 03/
            └── 2026-03-15.md  # 每日日志

2. MEMORY.md 索引

作用

  • 所有记忆文件的索引
  • 每行一个条目,简洁描述
  • 不超过 200 行,每行约 150 字符

格式

- [用户角色](user_role.md) — 数据科学家,专注可观测性
- [测试政策](feedback_testing.md) — 集成测试必须访问真实数据库
- [发布计划](project_release.md) — 3月5日开始合并冻结
- [错误跟踪](reference_linear.md) — 管道错误在 Linear INGEST 项目

限制

  • 200 行限制:防止索引过大
  • 25KB 限制:防止长行溢出
  • 超过限制时自动截断并显示警告
export const MAX_ENTRYPOINT_LINES = 200;
export const MAX_ENTRYPOINT_BYTES = 25_000;

截断逻辑

export function truncateEntrypointContent(raw: string): EntrypointTruncation {
  const trimmed = raw.trim();
  const contentLines = trimmed.split('\n');
  const lineCount = contentLines.length;
  const byteCount = trimmed.length;

  const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES;
  const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES;

  if (!wasLineTruncated && !wasByteTruncated) {
    return { content: trimmed, lineCount, byteCount, wasLineTruncated, wasByteTruncated };
  }

  // 先按行截断
  let truncated = wasLineTruncated
    ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n')
    : trimmed;

  // 再按字节截断(在最后一个换行符处切割)
  if (truncated.length > MAX_ENTRYPOINT_BYTES) {
    const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES);
    truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES);
  }

  const reason = wasByteTruncated && !wasLineTruncated
    ? `${formatFileSize(byteCount)} (限制: ${formatFileSize(MAX_ENTRYPOINT_BYTES)}) — 索引条目太长`
    : wasLineTruncated && !wasByteTruncated
      ? `${lineCount} 行 (限制: ${MAX_ENTRYPOINT_LINES})`
      : `${lineCount} 行和 ${formatFileSize(byteCount)}`;

  return {
    content: truncated + `\n\n> 警告:${ENTRYPOINT_NAME}${reason}。只加载了部分内容。保持索引条目在一行内约 200 字符;将详情移到主题文件中。`,
    lineCount,
    byteCount,
    wasLineTruncated,
    wasByteTruncated,
  };
}

3. 记忆文件格式

Frontmatter 格式

---
name: 记忆名称
description: 一行描述 — 用于在未来对话中决定相关性,所以要具体
type: user, feedback, project, reference
---

记忆内容 —— 对于 feedback/project 类型,结构为:规则/事实,然后是 **Why:****How to apply:**

示例

---
name: 测试数据库策略
description: 集成测试必须使用真实数据库而非模拟
type: feedback
---

集成测试必须访问真实数据库,不能模拟。
**Why:** 之前的事件中模拟/生产差异掩盖了失败的迁移
**How to apply:** 所有数据库相关的集成测试

记忆加载与提示构建

1. 加载流程

export async function loadMemoryPrompt(): Promise<string | null> {
  const autoEnabled = isAutoMemoryEnabled();
  const skipIndex = getFeatureValue_CACHED_MAY_BE_STALE('tengu_moth_copse', false);

  // KAIROS 每日日志模式
  if (feature('KAIROS') && autoEnabled && getKairosActive()) {
    return buildAssistantDailyLogPrompt(skipIndex);
  }

  // 团队和自动记忆组合
  if (feature('TEAMMEM')) {
    if (teamMemPaths!.isTeamMemoryEnabled()) {
      return teamMemPrompts!.buildCombinedMemoryPrompt(extraGuidelines, skipIndex);
    }
  }

  // 仅自动记忆
  if (autoEnabled) {
    return buildMemoryLines('auto memory', autoDir, extraGuidelines, skipIndex).join('\n');
  }

  return null;  // 记忆禁用
}

2. 构建记忆提示

export function buildMemoryLines(
  displayName: string,
  memoryDir: string,
  extraGuidelines?: string[],
  skipIndex = false,
): string[] {
  const lines: string[] = [
    `# ${displayName}`,
    '',
    `你有一个持久、基于文件的内存系统位于 \`${memoryDir}\`。`,
    '',
    "你应该随着时间的推移建立这个内存系统,以便未来的对话可以完整了解用户是谁、他们希望如何与你协作、应该避免或重复什么行为,以及你获得的工作背后的上下文。",
    '',
    ...TYPES_SECTION_INDIVIDUAL,  // 四种记忆类型说明
    ...WHAT_NOT_TO_SAVE_SECTION,   // 不保存的内容
    '',
    ...howToSave,                  // 如何保存
    '',
    ...WHEN_TO_ACCESS_SECTION,     // 何时访问
    '',
    ...TRUSTING_RECALL_SECTION,    // 信任召回
    '',
    '## 记忆和其他形式的持久性',
    // ...
  ];

  // 添加 MEMORY.md 内容(如果存在且未跳过)
  if (!skipIndex && entrypointContent.trim()) {
    const t = truncateEntrypointContent(entrypointContent);
    lines.push(`## ${ENTRYPOINT_NAME}`, '', t.content);
  }

  return lines;
}

3. KAIROS 每日日志模式

特点

  • 长时间运行的助手会话
  • 追加到日期命名的日志文件
  • 每晚自动提炼为 MEMORY.md
function buildAssistantDailyLogPrompt(skipIndex = false): string {
  const logPathPattern = join(memoryDir, 'logs', 'YYYY', 'MM', 'YYYY-MM-DD.md');

  const lines: string[] = [
    '# auto memory',
    '',
    `你有一个持久、基于文件的内存系统位于: \`${memoryDir}\``,
    '',
    "此会话是长期存在的。工作时,通过**追加**到今天的日志文件记录任何值得记住的内容:",
    '',
    `\`${logPathPattern}\``,
    '',
    "将今天的日期(来自上下文的 currentDate)替换为 `YYYY-MM-DD`。当日期在会话中变更时,开始追加到新一天的文件。",
    '',
    '每个条目写为带时间戳的项目符号。首次写入时创建文件(和父目录)。不重写或重组日志——它是追加的。单独的夜间过程将这些日志提炼为 `MEMORY.md` 和主题文件。',
    // ...
  ];

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

记忆调取(Recall)

1. 何时访问记忆

export const WHEN_TO_ACCESS_SECTION = [
  '## When to access memories',
  '- 当记忆看起来相关,或用户引用之前对话的工作时',
  '- 当用户明确要求你检查、召回或记住时,**必须**访问记忆',
  '- 如果用户说*忽略*或*不使用*记忆:继续工作,就像 MEMORY.md 为空一样',
  MEMORY_DRIFT_CAVEAT,  // 记忆漂移警告
];

// 记忆漂移警告
export const MEMORY_DRIFT_CAVEAT =
  '- 记忆记录会随时间变得过时。使用记忆作为某个时间点为真的上下文。在回答用户或仅基于记忆信息构建假设之前,通过读取文件或资源的当前状态验证记忆仍然正确和最新。如果召回的记忆与当前信息冲突,相信你现在观察到的——并更新或移除过时的记忆而不是基于它行动。';

2. 信任召回的内容

export const TRUSTING_RECALL_SECTION = [
  '## Before recommending from memory',
  '',
  '命名具体函数、文件或标志的记忆是*记忆写入时*它存在的声明。它可能已被重命名、移除或从未合并。在推荐之前:',
  '',
  '- 如果记忆命名文件路径:检查文件是否存在',
  '- 如果记忆命名函数或标志:grep 查找它',
  '- 如果用户即将根据你的建议行动(不只是询问历史),先验证',
  '',
  '"记忆说 X 存在"不等于"X 现在存在"。',
  '',
  '总结仓库状态的记忆(活动日志、架构快照)被冻结在时间中。如果用户询问*最近*或*当前*状态,优先使用 git log 或读取代码而不是回忆快照。',
];

验证步骤

  1. 文件声明:检查文件是否存在
  2. 函数/标志声明:grep 查找它
  3. 即将行动:在用户行动前验证
  4. 快照信息:对于当前状态,优先读取实时数据

3. 记忆搜索

搜索功能

export function buildSearchingPastContextSection(autoMemDir: string): string[] {
  const memSearch = hasEmbeddedSearchTools()
    ? `grep -rn "<search term>" ${autoMemDir} --include="*.md"`
    : `${GREP_TOOL_NAME} with pattern="<search term>" path="${autoMemDir}" glob="*.md"`;

  return [
    '## Searching past context',
    '',
    '查找过去上下文时:',
    '1. 搜索内存目录中的主题文件:',
    '```',
    memSearch,
    '```',
    '使用窄搜索词(错误消息、文件路径、函数名)而不是宽泛关键词。',
    '',
  ];
}

会话记忆(Session Memory)

1. 自动提取机制

特点

  • 后台运行,不中断主对话
  • 使用 forked subagent 提取关键信息
  • 定期更新会话摘要

触发条件

export function shouldExtractMemory(messages: Message[]): boolean {
  // 1. 检查初始化阈值
  const currentTokenCount = tokenCountWithEstimation(messages);
  if (!isSessionMemoryInitialized()) {
    if (!hasMetInitializationThreshold(currentTokenCount)) {
      return false;
    }
    markSessionMemoryInitialized();
  }

  // 2. 检查令牌阈值
  const hasMetTokenThreshold = hasMetUpdateThreshold(currentTokenCount);

  // 3. 检查工具调用阈值
  const toolCallsSinceLastUpdate = countToolCallsSince(messages, lastMemoryMessageUuid);
  const hasMetToolCallThreshold = toolCallsSinceLastUpdate >= getToolCallsBetweenUpdates();

  // 4. 检查最后助手回合是否有工具调用
  const hasToolCallsInLastTurn = hasToolCallsInLastAssistantTurn(messages);

  // 触发条件:(令牌阈值 && 工具阈值) || (令牌阈值 && 无工具调用)
  const shouldExtract =
    (hasMetTokenThreshold && hasMetToolCallThreshold) ||
    (hasMetTokenThreshold && !hasToolCallsInLastTurn);

  if (shouldExtract) {
    const lastMessage = messages[messages.length - 1];
    if (lastMessage?.uuid) {
      lastMemoryMessageUuid = lastMessage.uuid;
    }
    return true;
  }

  return false;
}

配置参数

export interface SessionMemoryConfig {
  minimumMessageTokensToInit: number;  // 初始化所需最小令牌数
  minimumTokensBetweenUpdate: number;  // 更新间最小令牌数
  toolCallsBetweenUpdates: number;     // 更新间工具调用数
}

export const DEFAULT_SESSION_MEMORY_CONFIG: SessionMemoryConfig = {
  minimumMessageTokensToInit: 4000,    // 约 4k 令牌后开始记录
  minimumTokensBetweenUpdate: 2000,    // 上下文增长 2k 后更新
  toolCallsBetweenUpdates: 5,          // 每 5 次工具调用更新一次
};

2. 提取流程

async function setupSessionMemoryFile(toolUseContext: ToolUseContext): Promise<{
  memoryPath: string;
  currentMemory: string;
}> {
  const sessionMemoryDir = getSessionMemoryDir();
  await fs.mkdir(sessionMemoryDir, { mode: 0o700 });

  const memoryPath = getSessionMemoryPath();

  // 如果不存在则创建(wx = O_CREAT|O_EXCL)
  try {
    await writeFile(memoryPath, '', { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
    
    // 只在新创建时加载模板
    const template = await loadSessionMemoryTemplate();
    await writeFile(memoryPath, template, { encoding: 'utf-8', mode: 0o600 });
  } catch (e: unknown) {
    const code = getErrnoCode(e);
    if (code !== 'EEXIST') {
      throw e;
    }
  }

  // 读取当前内存内容
  const result = await FileReadTool.call({ file_path: memoryPath }, toolUseContext);
  const output = result.data as FileReadToolOutput;
  const currentMemory = output.type === 'text' ? output.file.content : '';

  return { memoryPath, currentMemory };
}

3. 提取执行

const extractSessionMemory = sequential(async function (
  context: REPLHookContext,
): Promise<void> {
  const { messages, toolUseContext, querySource } = context;

  // 只在主 REPL 线程运行
  if (querySource !== 'repl_main_thread') {
    return;
  }

  // 检查功能门
  if (!isSessionMemoryGateEnabled()) {
    return;
  }

  // 初始化配置(延迟,只一次)
  initSessionMemoryConfigIfNeeded();

  // 检查是否应该提取
  if (!shouldExtractMemory(messages)) {
    return;
  }

  markExtractionStarted();

  // 创建隔离上下文进行设置
  const setupContext = createSubagentContext(toolUseContext);

  // 设置文件系统并读取当前状态
  const { memoryPath, currentMemory } = await setupSessionMemoryFile(setupContext);

  // 创建提取消息
  const userPrompt = await buildSessionMemoryUpdatePrompt(currentMemory, memoryPath);

  // 使用 runForkedAgent 运行会话内存提取
  await runForkedAgent({
    promptMessages: [createUserMessage({ content: userPrompt })],
    cacheSafeParams: createCacheSafeParams(context),
    canUseTool: createMemoryFileCanUseTool(memoryPath),
    querySource: 'session_memory',
    forkLabel: 'session_memory',
    overrides: { readFileState: setupContext.readFileState },
  });

  // 记录提取事件
  logEvent('tengu_session_memory_extraction', {
    input_tokens: usage?.input_tokens,
    output_tokens: usage?.output_tokens,
    config_min_message_tokens_to_init: config.minimumMessageTokensToInit,
    config_min_tokens_between_update: config.minimumTokensBetweenUpdate,
    config_tool_calls_between_updates: config.toolCallsBetweenUpdates,
  });

  // 记录上下文大小
  recordExtractionTokenCount(tokenCountWithEstimation(messages));

  // 更新 lastSummarizedMessageId
  updateLastSummarizedMessageIdIfSafe(messages);

  markExtractionCompleted();
});

4. 权限限制

export function createMemoryFileCanUseTool(memoryPath: string): CanUseToolFn {
  return async (tool: Tool, input: unknown) => {
    if (
      tool.name === FILE_EDIT_TOOL_NAME &&
      typeof input === 'object' &&
      input !== null &&
      'file_path' in input
    ) {
      const filePath = input.file_path;
      if (typeof filePath === 'string' && filePath === memoryPath) {
        return { behavior: 'allow' as const, updatedInput: input };
      }
    }
    return {
      behavior: 'deny' as const,
      message: `只允许 ${FILE_EDIT_TOOL_NAME}${memoryPath} 操作`,
    };
  };
}

限制

  • 只允许编辑会话内存文件
  • 防止 forked agent 修改其他文件

团队记忆(Team Memory)

1. 启用条件

if (feature('TEAMMEM')) {
  if (teamMemPaths!.isTeamMemoryEnabled()) {
    const autoDir = getAutoMemPath();
    const teamDir = teamMemPaths!.getTeamMemPath();

    // 确保目录存在
    await ensureMemoryDirExists(teamDir);

    return teamMemPrompts!.buildCombinedMemoryPrompt(extraGuidelines, skipIndex);
  }
}

2. 组合提示

私人 + 团队记忆

export function buildCombinedMemoryPrompt(
  extraGuidelines?: string[],
  skipIndex = false,
): string {
  const autoDir = getAutoMemPath();
  const teamDir = getTeamMemPath();

  const lines: string[] = [
    '# memory',
    '',
    '你有两个持久、基于文件的内存系统:',
    '',
    `- 私人:\`${autoDir}\`(仅对你)`,
    `- 团队:\`${teamDir}\`(与队友共享)`,
    '',
    DIRS_EXIST_GUIDANCE,
    '',
    // ...
    '<types>',  // 包含 <scope> 标签
    // ...
    '</types>',
    // ...
  ];

  // 加载两个 MEMORY.md 文件
  const autoEntrypoint = autoDir + ENTRYPOINT_NAME;
  const teamEntrypoint = teamDir + ENTRYPOINT_NAME;

  // ... 读取和截断逻辑 ...

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

区别

  • 私人记忆:仅当前用户可见
  • 团队记忆:项目所有贡献者共享
  • 范围标签指导何时使用哪个

记忆安全与一致性

1. 目录确保存在

export async function ensureMemoryDirExists(memoryDir: string): Promise<void> {
  const fs = getFsImplementation();
  try {
    await fs.mkdir(memoryDir);  // 递归创建,已处理 EEXIST
  } catch (e) {
    const code = e instanceof Error && 'code' in e ? e.code : undefined;
    logForDebugging(
      `ensureMemoryDirExists 对 ${memoryDir} 失败: ${code ?? String(e)}`,
      { level: 'debug' },
    );
  }
}

目的

  • 模型可以直接写入,无需检查存在性
  • 避免 ls/mkdir -p 浪费回合

2. 并发控制

会话内存

  • 使用 sequential 包装确保顺序执行
  • 防止并发提取冲突

文件记忆

  • 依赖文件系统锁
  • Forked agent 隔离执行

3. 记忆漂移防护

核心原则

记忆记录可能随时间变得过时。使用记忆作为某个时间点为真的上下文的上下文。

在仅基于记忆信息回答用户或构建假设之前,验证记忆仍然正确和最新。

如果召回的记忆与当前信息冲突,相信你现在观察到的——并更新或移除过时的记忆。

实现

  • 显式的验证提醒
  • 工具使用前的检查
  • 冲突时的当前状态优先

总结

Claude Code 的记忆系统具有以下特点:

  1. 类型化:四种明确的记忆类型,各有用途
  2. 文件化:基于文件的持久存储,易于版本控制
  3. 智能加载:自动截断和警告,保持上下文简洁
  4. 会话集成:自动提取会话摘要
  5. 团队协作:支持私人和团队记忆分离
  6. 安全验证:记忆漂移防护和一致性检查

这个系统确保:

  • 跨会话保持上下文
  • 避免重复沟通
  • 提供项目背景
  • 适应用户偏好
  • 支持团队协作